From 540df106c3acb2d441f6b0b956148e417a28dd4b Mon Sep 17 00:00:00 2001 From: Ben Marx Date: Thu, 20 Aug 2026 14:21:24 -0700 Subject: [PATCH] sdk/shreds: add the feed subscription program's FeedDistribution account The feed subscription program is a second program alongside shred subscription, and this SDK carried nothing for it, so every consumer decoded the account at fixed byte offsets itself. Lake has one such copy today. FeedDistribution is a bytemuck Pod onchain, read here field by field, which agrees with the Pod bytes only because the field order leaves no interior padding. Two tests pin that: the 120-byte size in TestStructSizes, and every field of a real mainnet account, whose three bump seeds around a zero proportion make a one-byte shift detectable rather than merely plausible. Client is built around one program ID, so it gains no fetch method. DeserializeFeedDistribution is exported instead, for a caller that brings its own getProgramAccounts call. sdk-test never ran ./sdk/shreds/go/..., so this package's layout pins have never run in CI. Added, and the package passes. --- CHANGELOG.md | 2 + Makefile | 1 + sdk/shreds/go/client.go | 11 +++++ sdk/shreds/go/config.go | 6 +++ sdk/shreds/go/discriminator.go | 7 +++ sdk/shreds/go/discriminator_test.go | 12 +++++ sdk/shreds/go/state.go | 27 ++++++++++ sdk/shreds/go/state_test.go | 77 +++++++++++++++++++++++++++++ 8 files changed, 143 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d582729da..220a5e1d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable changes to this project will be documented in this file. - RFCs - RFC-27: IP Ownership Verification Service for user connection +- SDK + - `sdk/shreds/go` carries the feed subscription program's `FeedDistribution` account: how much USDC one feed collected for one calendar month. The program is a second program alongside shred subscription and had nothing in this SDK, so each consumer decoded the account at fixed byte offsets itself, lake included. The account is a bytemuck Pod read here field by field, which agrees with the Pod bytes only because the field order leaves no interior padding; `TestStructSizes` pins the 120-byte total and a new test pins every field against a real mainnet account. `Client` is built around one program ID and so gains no fetch method, and `DeserializeFeedDistribution` is exported for a caller that makes its own `getProgramAccounts` call. `make sdk-test` never ran `./sdk/shreds/go/...`, so this package's layout pins have never run in CI; it runs them now. (#4216) ## [v0.36.0](https://github.com/malbeclabs/doublezero/compare/client/v0.35.0...client/v0.36.0) - 2026-08-14 diff --git a/Makefile b/Makefile index fb2dc6ae8f..9aefcf4493 100644 --- a/Makefile +++ b/Makefile @@ -141,6 +141,7 @@ rust-program-accounts-compat: sdk-test: go test ./sdk/borsh-incremental/go/... go test ./sdk/geolocation/go/... + go test ./sdk/shreds/go/... go test ./sdk/revdist/go/... $(MAKE) python-test-borsh-incremental $(MAKE) python-test-revdist diff --git a/sdk/shreds/go/client.go b/sdk/shreds/go/client.go index 1ac3834c1b..c81cca5477 100644 --- a/sdk/shreds/go/client.go +++ b/sdk/shreds/go/client.go @@ -34,6 +34,17 @@ func deserializeAccount[T any](data []byte, disc [8]byte) (*T, error) { return &item, nil } +// DeserializeFeedDistribution decodes a FeedDistribution account from raw +// account data, discriminator included. +// +// This is exported where the shred subscription accounts are not, because +// FeedDistribution belongs to a second program: Client is built around one +// program ID, so a caller reading the feed subscription program makes its own +// getProgramAccounts call and decodes what comes back. +func DeserializeFeedDistribution(data []byte) (*FeedDistribution, error) { + return deserializeAccount[FeedDistribution](data, DiscriminatorFeedDistribution) +} + // RPCClient is the minimal RPC interface needed by the client. type RPCClient interface { GetAccountInfo(ctx context.Context, account solana.PublicKey) (*rpc.GetAccountInfoResult, error) diff --git a/sdk/shreds/go/config.go b/sdk/shreds/go/config.go index 7cd5530bc4..0d83a4eb20 100644 --- a/sdk/shreds/go/config.go +++ b/sdk/shreds/go/config.go @@ -5,6 +5,12 @@ import "github.com/gagliardetto/solana-go" // ProgramID is the shred subscription program ID. var ProgramID = solana.MustPublicKeyFromBase58("dzshrr3yL57SB13sJPYHYo3TV8Bo1i1FxkyrZr3bKNE") +// FeedProgramID is the feed subscription program ID. That is a separate program +// from the shred subscription one above, and it is deployed on Solana +// mainnet-beta only: on a cluster without it, getProgramAccounts returns an +// empty list rather than an error. +var FeedProgramID = solana.MustPublicKeyFromBase58("J9gupbyffs4XAoKn5NrJ4hrbdqW5ZfvMDaaas3FtH8yC") + // SolanaRPCURLs are the Solana RPC URLs per environment. var SolanaRPCURLs = map[string]string{ "mainnet-beta": "https://api.mainnet-beta.solana.com", diff --git a/sdk/shreds/go/discriminator.go b/sdk/shreds/go/discriminator.go index ed8117797a..46b724d656 100644 --- a/sdk/shreds/go/discriminator.go +++ b/sdk/shreds/go/discriminator.go @@ -20,6 +20,13 @@ var ( DiscriminatorMetroHistory = sha256First8("dz::account::metro_history") DiscriminatorDeviceHistory = sha256First8("dz::account::device_history") + // FeedDistribution belongs to the feed subscription program (FeedProgramID), + // not to the shred subscription program above. The ::v2 suffix is part of the + // seed the program hashes: v2 replaced the per-month vault with a per-feed + // vault, which orphaned every v1 account. A v1 account must therefore fail + // validation rather than decode into a wrong collected amount. + DiscriminatorFeedDistribution = sha256First8("dz::account::feed_distribution::v2") + ErrInvalidDiscriminator = errors.New("invalid account discriminator") ) diff --git a/sdk/shreds/go/discriminator_test.go b/sdk/shreds/go/discriminator_test.go index 71727b8430..ff0d859f9f 100644 --- a/sdk/shreds/go/discriminator_test.go +++ b/sdk/shreds/go/discriminator_test.go @@ -1,6 +1,7 @@ package shreds import ( + "fmt" "testing" ) @@ -16,6 +17,7 @@ func TestDiscriminatorsAreUnique(t *testing.T) { "WithdrawSeatRequest": DiscriminatorWithdrawSeatRequest, "MetroHistory": DiscriminatorMetroHistory, "DeviceHistory": DiscriminatorDeviceHistory, + "FeedDistribution": DiscriminatorFeedDistribution, } seen := make(map[[8]byte]string) @@ -43,3 +45,13 @@ func TestValidateDiscriminator(t *testing.T) { t.Fatal("expected error for short data") } } + +// The seed string is hashed at runtime, so pin the result against the value the +// deployed program uses. A ::v3 bump upstream then fails here rather than +// quietly matching nothing on chain. +func TestDiscriminatorFeedDistributionMatchesOnchainSeed(t *testing.T) { + const want = "38677e51559a48dc" + if got := fmt.Sprintf("%x", DiscriminatorFeedDistribution); got != want { + t.Errorf("DiscriminatorFeedDistribution = %s, want %s", got, want) + } +} diff --git a/sdk/shreds/go/state.go b/sdk/shreds/go/state.go index 8e432d7883..22d2419973 100644 --- a/sdk/shreds/go/state.go +++ b/sdk/shreds/go/state.go @@ -381,6 +381,33 @@ func (d *DeviceHistory) HasSettledSeats() bool { return d.Flags&(1<<2) != 0 } +// --- Feed subscription program --- + +// FeedDistribution is how much USDC one feed collected for one calendar month. +// It belongs to the feed subscription program (FeedProgramID), which owns one +// such account per feed per month plus its own ProgramConfig. +// +// CollectedUSDCAmount is USDC base units and only ever increases. Read a month +// as an allocation, not as cash that arrived in it: one subscription payment is +// credited across the calendar months the subscription spans, in day fractions, +// so an account can exist for a month that has not started. Sum every month for +// cash collected to date. +// +// The feed's vault balance is a different number, and it drains to zero once the +// month settles. +type FeedDistribution struct { + FeedKey solana.PublicKey + Year uint16 + Month uint8 + BumpSeed uint8 + PublisherRewardsProportionBps uint16 + PaymentAuthorityBumpSeed uint8 + VaultUSDCATABumpSeed uint8 + CollectedUSDCAmount uint64 + Flags uint64 // Flags + Gap [2][32]byte // StorageGap<2> +} + // --- Keyed wrappers for batch fetches --- // KeyedClientSeat pairs a client seat with its onchain address. diff --git a/sdk/shreds/go/state_test.go b/sdk/shreds/go/state_test.go index 1a6f48a9f4..7d0c99bfd0 100644 --- a/sdk/shreds/go/state_test.go +++ b/sdk/shreds/go/state_test.go @@ -2,6 +2,7 @@ package shreds import ( "bytes" + "encoding/base64" "encoding/binary" "testing" "unsafe" @@ -31,6 +32,7 @@ func TestStructSizes(t *testing.T) { {"DeviceHistory", unsafe.Sizeof(DeviceHistory{}), 2776}, {"ValidatorClientRewardsProportion", unsafe.Sizeof(ValidatorClientRewardsProportion{}), 4}, {"ValidatorClientRewardsConfig", unsafe.Sizeof(ValidatorClientRewardsConfig{}), 136}, + {"FeedDistribution", unsafe.Sizeof(FeedDistribution{}), 120}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -424,3 +426,78 @@ func TestExecutionPhaseString(t *testing.T) { } } } + +// mainnetFeedDistribution is the full 128 bytes of feed distribution account +// crW8HCYDpQVyCxYG7m3hXeC42rAnjoLroGGfgGLLXM2 on Solana mainnet-beta, read on +// 2026-08-19: an 8-byte discriminator followed by the 120-byte struct. +// +// Real bytes, not synthesised ones. A fixture this package encoded itself would +// agree with any field-order mistake it also made. +const mainnetFeedDistribution = "OGd+UVWaSNwwUMWSdfRNaNAxqeB5i3T95mrqWd7yZShRKdBh7irZu+oHCP8AAP3/JyAEfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + +// TestFeedDistributionDeserialization is the layout guard for the one account +// this package carries from the feed subscription program. +// +// FeedDistribution is a bytemuck Pod onchain (#[repr(C, align(8))]). This +// package reads it field by field, which agrees with the Pod bytes only because +// the field order leaves no interior padding. TestStructSizes pins that total; +// this pins every field against known values, so a reordered or resized field +// fails here instead of reporting a plausible wrong amount. +// +// The three bump seeds around a zero proportion (255, 0, 253, 255) are what make +// a one-byte shift detectable rather than merely plausible. +func TestFeedDistributionDeserialization(t *testing.T) { + data, err := base64.StdEncoding.DecodeString(mainnetFeedDistribution) + if err != nil { + t.Fatalf("decoding fixture: %v", err) + } + if want := discriminatorSize + int(unsafe.Sizeof(FeedDistribution{})); len(data) != want { + t.Fatalf("fixture is %d bytes, want %d", len(data), want) + } + + dist, err := DeserializeFeedDistribution(data) + if err != nil { + t.Fatalf("DeserializeFeedDistribution: %v", err) + } + + const wantFeedKey = "4Fc1Fyd1x8BoWYPWN8vFhbP6fpgayybQuLUSPRwfE7Wi" + if got := dist.FeedKey.String(); got != wantFeedKey { + t.Errorf("FeedKey = %s, want %s", got, wantFeedKey) + } + if dist.Year != 2026 { + t.Errorf("Year = %d, want 2026", dist.Year) + } + if dist.Month != 8 { + t.Errorf("Month = %d, want 8", dist.Month) + } + if dist.BumpSeed != 255 { + t.Errorf("BumpSeed = %d, want 255", dist.BumpSeed) + } + if dist.PublisherRewardsProportionBps != 0 { + t.Errorf("PublisherRewardsProportionBps = %d, want 0", dist.PublisherRewardsProportionBps) + } + if dist.PaymentAuthorityBumpSeed != 253 { + t.Errorf("PaymentAuthorityBumpSeed = %d, want 253", dist.PaymentAuthorityBumpSeed) + } + if dist.VaultUSDCATABumpSeed != 255 { + t.Errorf("VaultUSDCATABumpSeed = %d, want 255", dist.VaultUSDCATABumpSeed) + } + if dist.CollectedUSDCAmount != 2080645159 { + t.Errorf("CollectedUSDCAmount = %d, want 2080645159", dist.CollectedUSDCAmount) + } +} + +// A v1 account carries a different discriminator and is orphaned: v2 replaced +// the per-month vault with a per-feed vault. Reading a v1 account as v2 would +// report a wrong collected amount, so the mismatch has to be an error. +func TestFeedDistributionRejectsWrongDiscriminator(t *testing.T) { + data, err := base64.StdEncoding.DecodeString(mainnetFeedDistribution) + if err != nil { + t.Fatalf("decoding fixture: %v", err) + } + data[0] ^= 0xff + + if _, err := DeserializeFeedDistribution(data); err == nil { + t.Fatal("expected an error for a mismatched discriminator, got nil") + } +}