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") + } +}