Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions sdk/shreds/go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions sdk/shreds/go/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions sdk/shreds/go/discriminator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)

Expand Down
12 changes: 12 additions & 0 deletions sdk/shreds/go/discriminator_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package shreds

import (
"fmt"
"testing"
)

Expand All @@ -16,6 +17,7 @@ func TestDiscriminatorsAreUnique(t *testing.T) {
"WithdrawSeatRequest": DiscriminatorWithdrawSeatRequest,
"MetroHistory": DiscriminatorMetroHistory,
"DeviceHistory": DiscriminatorDeviceHistory,
"FeedDistribution": DiscriminatorFeedDistribution,
}

seen := make(map[[8]byte]string)
Expand Down Expand Up @@ -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)
}
}
27 changes: 27 additions & 0 deletions sdk/shreds/go/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions sdk/shreds/go/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package shreds

import (
"bytes"
"encoding/base64"
"encoding/binary"
"testing"
"unsafe"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
}