From a7a898950cad0bb07126af930bb182584311d42e Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Fri, 27 Mar 2026 11:26:26 -0400 Subject: [PATCH 1/4] sdk: add Go SDK for shreds subscription program Add read-only Go SDK for deserializing shred subscription program accounts including epoch state, seat assignments, pricing, settlement, and validator client rewards. Includes PDA derivation, RPC helpers, compatibility tests against Rust-generated fixtures, and a fetch example. --- sdk/Makefile | 11 +- sdk/README.md | 24 +- sdk/shreds/go/client.go | 268 +++++++++++++++++++++ sdk/shreds/go/compat_test.go | 239 ++++++++++++++++++ sdk/shreds/go/config.go | 14 ++ sdk/shreds/go/discriminator.go | 43 ++++ sdk/shreds/go/discriminator_test.go | 45 ++++ sdk/shreds/go/examples/fetch/main.go | 216 +++++++++++++++++ sdk/shreds/go/pda.go | 68 ++++++ sdk/shreds/go/pda_test.go | 152 ++++++++++++ sdk/shreds/go/rpc.go | 75 ++++++ sdk/shreds/go/state.go | 310 ++++++++++++++++++++++++ sdk/shreds/go/state_test.go | 347 +++++++++++++++++++++++++++ 13 files changed, 1805 insertions(+), 7 deletions(-) create mode 100644 sdk/shreds/go/client.go create mode 100644 sdk/shreds/go/compat_test.go create mode 100644 sdk/shreds/go/config.go create mode 100644 sdk/shreds/go/discriminator.go create mode 100644 sdk/shreds/go/discriminator_test.go create mode 100644 sdk/shreds/go/examples/fetch/main.go create mode 100644 sdk/shreds/go/pda.go create mode 100644 sdk/shreds/go/pda_test.go create mode 100644 sdk/shreds/go/rpc.go create mode 100644 sdk/shreds/go/state.go create mode 100644 sdk/shreds/go/state_test.go diff --git a/sdk/Makefile b/sdk/Makefile index ac05e3277f..3ea796e503 100644 --- a/sdk/Makefile +++ b/sdk/Makefile @@ -13,6 +13,7 @@ test: go test ./revdist/go/... go test ./serviceability/go/... go test ./telemetry/go/... + go test ./shreds/go/... $(MAKE) test-python $(MAKE) test-typescript @@ -46,6 +47,7 @@ compat-test: SERVICEABILITY_COMPAT_TEST=1 go test -run TestCompat -v ./serviceability/go/... $(MAKE) compat-test-python-serviceability $(MAKE) compat-test-typescript-serviceability + SHREDS_COMPAT_TEST=1 go test -run TestCompat -v ./shreds/go/... .PHONY: compat-test-python-revdist compat-test-python-revdist: @@ -65,6 +67,13 @@ compat-test-typescript-serviceability: bun install SERVICEABILITY_COMPAT_TEST=1 bun test --cwd serviceability/typescript --grep compat +# ----------------------------------------------------------------------------- +# Shreds examples +# ----------------------------------------------------------------------------- +.PHONY: example-shreds-go +example-shreds-go: + go run ./shreds/go/examples/fetch --env $(sdk_env) --epoch $(sdk_epoch) + # ----------------------------------------------------------------------------- # Serviceability examples # ----------------------------------------------------------------------------- @@ -114,7 +123,7 @@ example-revdist-typescript: # Run all examples for a specific language # ----------------------------------------------------------------------------- .PHONY: examples-go -examples-go: example-serviceability-go example-telemetry-go example-revdist-go +examples-go: example-serviceability-go example-telemetry-go example-revdist-go example-shreds-go .PHONY: examples-python examples-python: example-serviceability-python example-telemetry-python example-revdist-python diff --git a/sdk/README.md b/sdk/README.md index e30dff33b4..9070e22891 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -5,7 +5,8 @@ Read-only SDKs for deserializing DoubleZero onchain program accounts in Go, Pyth - **serviceability** -- Serviceability program (contributors, access passes, devices, etc.) - **telemetry** -- Telemetry program (metrics, reporting) - **revdist** -- Revenue distribution program (epochs, claim tickets, etc.) -- **borsh-incremental** -- Shared Borsh deserialization library used by all three SDKs, implemented in each language +- **shreds** -- Shred subscription program (seats, pricing, settlement, rewards) +- **borsh-incremental** -- Shared Borsh deserialization library used by the SDKs, implemented in each language ## Running Examples @@ -38,6 +39,7 @@ Available targets: - `example-serviceability-go`, `example-serviceability-python`, `example-serviceability-typescript` - `example-telemetry-go`, `example-telemetry-python`, `example-telemetry-typescript` - `example-revdist-go`, `example-revdist-python`, `example-revdist-typescript` +- `example-shreds-go` ### Direct Commands @@ -83,6 +85,13 @@ cd sdk/revdist/python && python examples/fetch.py --env mainnet-beta cd sdk/revdist/typescript && bun run examples/fetch.ts --env mainnet-beta ``` +### Shred Subscription (seats, pricing, settlement, rewards) + +```bash +# Go +go run ./sdk/shreds/go/examples/fetch --env mainnet-beta --epoch 42 +``` + ## Running Tests ``` @@ -97,6 +106,7 @@ Per-SDK test commands: | serviceability | `go test ./sdk/serviceability/go/...` | `cd sdk/serviceability/python && uv run pytest` | `cd sdk/serviceability/typescript && bun test` | | telemetry | `go test ./sdk/telemetry/go/...` | `cd sdk/telemetry/python && uv run pytest` | `cd sdk/telemetry/typescript && bun test` | | revdist | `go test ./sdk/revdist/go/...` | `cd sdk/revdist/python && uv run pytest` | `cd sdk/revdist/typescript && bun test` | +| shreds | `go test ./sdk/shreds/go/...` | -- | -- | ## Regenerating Fixtures @@ -162,11 +172,13 @@ sdk/ │ ├── python/examples/ │ ├── typescript/examples/ │ └── testdata/fixtures/ -└── revdist/ # Revenue distribution program SDK - ├── go/examples/ - ├── python/examples/ - ├── typescript/examples/ - └── testdata/fixtures/ +├── revdist/ # Revenue distribution program SDK +│ ├── go/examples/ +│ ├── python/examples/ +│ ├── typescript/examples/ +│ └── testdata/fixtures/ +└── shreds/ # Shred subscription program SDK + └── go/examples/ ``` Each SDK follows the same layout with `go/`, `python/`, `typescript/` subdirectories containing example CLIs, and a shared `testdata/fixtures/` directory containing the Rust-generated test data. diff --git a/sdk/shreds/go/client.go b/sdk/shreds/go/client.go new file mode 100644 index 0000000000..1ac3834c1b --- /dev/null +++ b/sdk/shreds/go/client.go @@ -0,0 +1,268 @@ +package shreds + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "unsafe" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" +) + +var ErrAccountNotFound = errors.New("account not found") + +// deserializeAccount validates the discriminator and deserializes the account +// data into the given struct type. Tolerates trailing bytes for forward +// compatibility. +func deserializeAccount[T any](data []byte, disc [8]byte) (*T, error) { + if err := validateDiscriminator(data, disc); err != nil { + return nil, err + } + body := data[discriminatorSize:] + var zero T + need := int(unsafe.Sizeof(zero)) + if len(body) < need { + return nil, fmt.Errorf("account data too short: have %d bytes, need at least %d", len(body), need) + } + var item T + if err := binary.Read(bytes.NewReader(body[:need]), binary.LittleEndian, &item); err != nil { + return nil, fmt.Errorf("deserializing account: %w", err) + } + return &item, nil +} + +// RPCClient is the minimal RPC interface needed by the client. +type RPCClient interface { + GetAccountInfo(ctx context.Context, account solana.PublicKey) (*rpc.GetAccountInfoResult, error) + GetProgramAccountsWithOpts(ctx context.Context, publicKey solana.PublicKey, opts *rpc.GetProgramAccountsOpts) (rpc.GetProgramAccountsResult, error) +} + +// Client provides read-only access to shred subscription program accounts. +type Client struct { + rpc RPCClient + programID solana.PublicKey +} + +// New creates a new shred subscription client. +func New(rpc RPCClient, programID solana.PublicKey) *Client { + return &Client{rpc: rpc, programID: programID} +} + +// NewForEnv creates a client configured for the given environment. +func NewForEnv(env string) *Client { + return New(NewRPCClient(SolanaRPCURLs[env]), ProgramID) +} + +// NewMainnetBeta creates a client configured for mainnet-beta. +func NewMainnetBeta() *Client { return NewForEnv("mainnet-beta") } + +// NewTestnet creates a client configured for testnet. +func NewTestnet() *Client { return NewForEnv("testnet") } + +// NewDevnet creates a client configured for devnet. +func NewDevnet() *Client { return NewForEnv("devnet") } + +// NewLocalnet creates a client configured for localnet. +func NewLocalnet() *Client { return NewForEnv("localnet") } + +// ProgramID returns the configured program ID. +func (c *Client) ProgramID() solana.PublicKey { return c.programID } + +// --- Singleton fetches --- + +func (c *Client) FetchProgramConfig(ctx context.Context) (*ProgramConfig, error) { + addr, _, err := DeriveProgramConfigPDA(c.programID) + if err != nil { + return nil, fmt.Errorf("deriving program config PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[ProgramConfig](data, DiscriminatorProgramConfig) +} + +func (c *Client) FetchExecutionController(ctx context.Context) (*ExecutionController, error) { + addr, _, err := DeriveExecutionControllerPDA(c.programID) + if err != nil { + return nil, fmt.Errorf("deriving execution controller PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[ExecutionController](data, DiscriminatorExecutionController) +} + +// --- Keyed fetches --- + +func (c *Client) FetchClientSeat(ctx context.Context, deviceKey solana.PublicKey, clientIPBits uint32) (*ClientSeat, error) { + addr, _, err := DeriveClientSeatPDA(c.programID, deviceKey, clientIPBits) + if err != nil { + return nil, fmt.Errorf("deriving client seat PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[ClientSeat](data, DiscriminatorClientSeat) +} + +func (c *Client) FetchPaymentEscrow(ctx context.Context, clientSeatKey, withdrawAuthorityKey solana.PublicKey) (*PaymentEscrow, error) { + addr, _, err := DerivePaymentEscrowPDA(c.programID, clientSeatKey, withdrawAuthorityKey) + if err != nil { + return nil, fmt.Errorf("deriving payment escrow PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[PaymentEscrow](data, DiscriminatorPaymentEscrow) +} + +func (c *Client) FetchShredDistribution(ctx context.Context, subscriptionEpoch uint64) (*ShredDistribution, error) { + addr, _, err := DeriveShredDistributionPDA(c.programID, subscriptionEpoch) + if err != nil { + return nil, fmt.Errorf("deriving shred distribution PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[ShredDistribution](data, DiscriminatorShredDistribution) +} + +func (c *Client) FetchValidatorClientRewards(ctx context.Context, clientID uint16) (*ValidatorClientRewards, error) { + addr, _, err := DeriveValidatorClientRewardsPDA(c.programID, clientID) + if err != nil { + return nil, fmt.Errorf("deriving validator client rewards PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[ValidatorClientRewards](data, DiscriminatorValidatorClientRewards) +} + +func (c *Client) FetchMetroHistory(ctx context.Context, exchangeKey solana.PublicKey) (*MetroHistory, error) { + addr, _, err := DeriveMetroHistoryPDA(c.programID, exchangeKey) + if err != nil { + return nil, fmt.Errorf("deriving metro history PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[MetroHistory](data, DiscriminatorMetroHistory) +} + +func (c *Client) FetchDeviceHistory(ctx context.Context, deviceKey solana.PublicKey) (*DeviceHistory, error) { + addr, _, err := DeriveDeviceHistoryPDA(c.programID, deviceKey) + if err != nil { + return nil, fmt.Errorf("deriving device history PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[DeviceHistory](data, DiscriminatorDeviceHistory) +} + +func (c *Client) FetchInstantSeatAllocationRequest(ctx context.Context, deviceKey solana.PublicKey, clientIPBits uint32) (*InstantSeatAllocationRequest, error) { + addr, _, err := DeriveInstantSeatAllocationRequestPDA(c.programID, deviceKey, clientIPBits) + if err != nil { + return nil, fmt.Errorf("deriving instant seat allocation request PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[InstantSeatAllocationRequest](data, DiscriminatorInstantSeatAllocationRequest) +} + +func (c *Client) FetchWithdrawSeatRequest(ctx context.Context, clientSeatKey solana.PublicKey) (*WithdrawSeatRequest, error) { + addr, _, err := DeriveWithdrawSeatRequestPDA(c.programID, clientSeatKey) + if err != nil { + return nil, fmt.Errorf("deriving withdraw seat request PDA: %w", err) + } + data, err := c.fetchAccountData(ctx, addr) + if err != nil { + return nil, err + } + return deserializeAccount[WithdrawSeatRequest](data, DiscriminatorWithdrawSeatRequest) +} + +// --- Batch fetches --- + +func (c *Client) FetchAllClientSeats(ctx context.Context) ([]KeyedClientSeat, error) { + return fetchAllKeyed[ClientSeat, KeyedClientSeat](ctx, c, DiscriminatorClientSeat, func(pk solana.PublicKey, v ClientSeat) KeyedClientSeat { + return KeyedClientSeat{Pubkey: pk, ClientSeat: v} + }) +} + +func (c *Client) FetchAllPaymentEscrows(ctx context.Context) ([]KeyedPaymentEscrow, error) { + return fetchAllKeyed[PaymentEscrow, KeyedPaymentEscrow](ctx, c, DiscriminatorPaymentEscrow, func(pk solana.PublicKey, v PaymentEscrow) KeyedPaymentEscrow { + return KeyedPaymentEscrow{Pubkey: pk, PaymentEscrow: v} + }) +} + +func (c *Client) FetchAllMetroHistories(ctx context.Context) ([]KeyedMetroHistory, error) { + return fetchAllKeyed[MetroHistory, KeyedMetroHistory](ctx, c, DiscriminatorMetroHistory, func(pk solana.PublicKey, v MetroHistory) KeyedMetroHistory { + return KeyedMetroHistory{Pubkey: pk, MetroHistory: v} + }) +} + +func (c *Client) FetchAllDeviceHistories(ctx context.Context) ([]KeyedDeviceHistory, error) { + return fetchAllKeyed[DeviceHistory, KeyedDeviceHistory](ctx, c, DiscriminatorDeviceHistory, func(pk solana.PublicKey, v DeviceHistory) KeyedDeviceHistory { + return KeyedDeviceHistory{Pubkey: pk, DeviceHistory: v} + }) +} + +func (c *Client) FetchAllValidatorClientRewards(ctx context.Context) ([]KeyedValidatorClientRewards, error) { + return fetchAllKeyed[ValidatorClientRewards, KeyedValidatorClientRewards](ctx, c, DiscriminatorValidatorClientRewards, func(pk solana.PublicKey, v ValidatorClientRewards) KeyedValidatorClientRewards { + return KeyedValidatorClientRewards{Pubkey: pk, ValidatorClientRewards: v} + }) +} + +// --- Internal helpers --- + +func (c *Client) fetchAccountData(ctx context.Context, addr solana.PublicKey) ([]byte, error) { + result, err := c.rpc.GetAccountInfo(ctx, addr) + if err != nil { + return nil, fmt.Errorf("fetching account %s: %w", addr, err) + } + if result == nil || result.Value == nil { + return nil, ErrAccountNotFound + } + return result.Value.Data.GetBinary(), nil +} + +func fetchAllKeyed[T any, K any](ctx context.Context, c *Client, disc [8]byte, wrap func(solana.PublicKey, T) K) ([]K, error) { + opts := &rpc.GetProgramAccountsOpts{ + Filters: []rpc.RPCFilter{ + { + Memcmp: &rpc.RPCFilterMemcmp{ + Offset: 0, + Bytes: disc[:], + }, + }, + }, + } + accounts, err := c.rpc.GetProgramAccountsWithOpts(ctx, c.programID, opts) + if err != nil { + return nil, fmt.Errorf("fetching program accounts: %w", err) + } + results := make([]K, 0, len(accounts)) + for _, acct := range accounts { + data := acct.Account.Data.GetBinary() + item, err := deserializeAccount[T](data, disc) + if err != nil { + return nil, fmt.Errorf("deserializing account %s: %w", acct.Pubkey, err) + } + results = append(results, wrap(acct.Pubkey, *item)) + } + return results, nil +} diff --git a/sdk/shreds/go/compat_test.go b/sdk/shreds/go/compat_test.go new file mode 100644 index 0000000000..5be760bef1 --- /dev/null +++ b/sdk/shreds/go/compat_test.go @@ -0,0 +1,239 @@ +package shreds + +import ( + "context" + "encoding/binary" + "os" + "testing" + + "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" +) + +// These tests fetch live mainnet data and verify that our struct deserialization +// matches raw byte reads at known offsets. Run with: +// +// SHREDS_COMPAT_TEST=1 go test -run TestCompat -v ./sdk/shreds/go/ + +func skipUnlessCompat(t *testing.T) { + t.Helper() + if os.Getenv("SHREDS_COMPAT_TEST") == "" { + t.Skip("set SHREDS_COMPAT_TEST=1 to run compatibility tests against mainnet") + } +} + +func compatRPCClient(t *testing.T) *solanarpc.Client { + t.Helper() + url := SolanaRPCURLs["mainnet-beta"] + if envURL := os.Getenv("SHREDS_RPC_URL"); envURL != "" { + url = envURL + } + return NewRPCClient(url) +} + +func compatClient(t *testing.T) *Client { + t.Helper() + return New(compatRPCClient(t), ProgramID) +} + +func fetchRawAccount(t *testing.T, rpcClient *solanarpc.Client, addr solana.PublicKey) []byte { + t.Helper() + result, err := rpcClient.GetAccountInfo(context.Background(), addr) + if err != nil { + t.Fatalf("fetching %s: %v", addr, err) + } + if result == nil || result.Value == nil { + t.Fatalf("account %s not found", addr) + } + return result.Value.Data.GetBinary() +} + +func TestCompatProgramConfig(t *testing.T) { + skipUnlessCompat(t) + client := compatClient(t) + ctx := context.Background() + + config, err := client.FetchProgramConfig(ctx) + if err != nil { + t.Fatalf("FetchProgramConfig: %v", err) + } + + addr, _, _ := DeriveProgramConfigPDA(ProgramID) + raw := fetchRawAccount(t, compatRPCClient(t), addr) + + if err := validateDiscriminator(raw, DiscriminatorProgramConfig); err != nil { + t.Fatalf("discriminator: %v", err) + } + + assertU64(t, raw, 8, config.Flags, "Flags") + assertPubkey(t, raw, 16, config.AdminKey, "AdminKey") + assertU32(t, raw, 48, config.ClosedForRequestsGracePeriodSlots, "GracePeriodSlots") + assertU16(t, raw, 52, config.USDC2ZMaxSlippageBps, "MaxSlippageBps") + assertPubkey(t, raw, 56, config.ShredOracleKey, "ShredOracleKey") + assertPubkey(t, raw, 88, config.USDC2ZOracleKey, "USDC2ZOracleKey") + + t.Logf("admin=%s, oracle=%s", config.AdminKey, config.ShredOracleKey) +} + +func TestCompatExecutionController(t *testing.T) { + skipUnlessCompat(t) + client := compatClient(t) + ctx := context.Background() + + ec, err := client.FetchExecutionController(ctx) + if err != nil { + t.Fatalf("FetchExecutionController: %v", err) + } + + addr, _, _ := DeriveExecutionControllerPDA(ProgramID) + raw := fetchRawAccount(t, compatRPCClient(t), addr) + + if err := validateDiscriminator(raw, DiscriminatorExecutionController); err != nil { + t.Fatalf("discriminator: %v", err) + } + + assertU8(t, raw, 8, ec.Phase, "Phase") + assertU16(t, raw, 12, ec.TotalMetros, "TotalMetros") + assertU16(t, raw, 14, ec.TotalEnabledDevices, "TotalEnabledDevices") + assertU32(t, raw, 16, ec.TotalClientSeats, "TotalClientSeats") + assertU64(t, raw, 32, ec.CurrentSubscriptionEpoch, "CurrentSubscriptionEpoch") + + if ec.CurrentSubscriptionEpoch == 0 { + t.Error("CurrentSubscriptionEpoch is 0, expected > 0 on mainnet") + } + + t.Logf("epoch=%d, phase=%s, metros=%d, devices=%d, seats=%d", + ec.CurrentSubscriptionEpoch, ec.GetPhase(), ec.TotalMetros, + ec.TotalEnabledDevices, ec.TotalClientSeats) +} + +func TestCompatMetroHistories(t *testing.T) { + skipUnlessCompat(t) + client := compatClient(t) + ctx := context.Background() + + metros, err := client.FetchAllMetroHistories(ctx) + if err != nil { + t.Fatalf("FetchAllMetroHistories: %v", err) + } + if len(metros) == 0 { + t.Fatal("no metro histories found on mainnet") + } + + for _, m := range metros { + if m.Prices.TotalCount == 0 { + continue + } + idx := m.Prices.CurrentIndex + entry := m.Prices.Entries[idx] + t.Logf("metro %s: %d devices, current price $%d (epoch %d)", + m.Pubkey, m.TotalInitializedDevices, + entry.Price.USDCPriceDollars, entry.Epoch) + } + + t.Logf("validated %d metro histories", len(metros)) +} + +func TestCompatDeviceHistories(t *testing.T) { + skipUnlessCompat(t) + client := compatClient(t) + ctx := context.Background() + + devices, err := client.FetchAllDeviceHistories(ctx) + if err != nil { + t.Fatalf("FetchAllDeviceHistories: %v", err) + } + if len(devices) == 0 { + t.Fatal("no device histories found on mainnet") + } + + enabled := 0 + for _, d := range devices { + if d.IsEnabled() { + enabled++ + } + } + + t.Logf("validated %d device histories (%d enabled)", len(devices), enabled) +} + +func TestCompatClientSeats(t *testing.T) { + skipUnlessCompat(t) + client := compatClient(t) + ctx := context.Background() + + seats, err := client.FetchAllClientSeats(ctx) + if err != nil { + t.Fatalf("FetchAllClientSeats: %v", err) + } + + funded := 0 + for _, s := range seats { + if s.FundedEpoch > 0 { + funded++ + } + } + + t.Logf("validated %d client seats (%d funded)", len(seats), funded) +} + +func TestCompatPaymentEscrows(t *testing.T) { + skipUnlessCompat(t) + client := compatClient(t) + ctx := context.Background() + + escrows, err := client.FetchAllPaymentEscrows(ctx) + if err != nil { + t.Fatalf("FetchAllPaymentEscrows: %v", err) + } + + totalUSDC := uint64(0) + for _, e := range escrows { + totalUSDC += e.USDCBalance + } + + t.Logf("validated %d payment escrows, total USDC balance: %d", len(escrows), totalUSDC) +} + +// Helpers to compare deserialized values against raw byte reads. + +func assertU8(t *testing.T, raw []byte, offset int, got uint8, name string) { + t.Helper() + want := raw[offset] + if got != want { + t.Errorf("%s: deserialized=%d, raw[%d]=%d", name, got, offset, want) + } +} + +func assertU16(t *testing.T, raw []byte, offset int, got uint16, name string) { + t.Helper() + want := binary.LittleEndian.Uint16(raw[offset:]) + if got != want { + t.Errorf("%s: deserialized=%d, raw[%d]=%d", name, got, offset, want) + } +} + +func assertU32(t *testing.T, raw []byte, offset int, got uint32, name string) { + t.Helper() + want := binary.LittleEndian.Uint32(raw[offset:]) + if got != want { + t.Errorf("%s: deserialized=%d, raw[%d]=%d", name, got, offset, want) + } +} + +func assertU64(t *testing.T, raw []byte, offset int, got uint64, name string) { + t.Helper() + want := binary.LittleEndian.Uint64(raw[offset:]) + if got != want { + t.Errorf("%s: deserialized=%d, raw[%d]=%d", name, got, offset, want) + } +} + +func assertPubkey(t *testing.T, raw []byte, offset int, got solana.PublicKey, name string) { + t.Helper() + var want solana.PublicKey + copy(want[:], raw[offset:offset+32]) + if got != want { + t.Errorf("%s: deserialized=%s, raw[%d]=%s", name, got, offset, want) + } +} diff --git a/sdk/shreds/go/config.go b/sdk/shreds/go/config.go new file mode 100644 index 0000000000..7cd5530bc4 --- /dev/null +++ b/sdk/shreds/go/config.go @@ -0,0 +1,14 @@ +package shreds + +import "github.com/gagliardetto/solana-go" + +// ProgramID is the shred subscription program ID. +var ProgramID = solana.MustPublicKeyFromBase58("dzshrr3yL57SB13sJPYHYo3TV8Bo1i1FxkyrZr3bKNE") + +// SolanaRPCURLs are the Solana RPC URLs per environment. +var SolanaRPCURLs = map[string]string{ + "mainnet-beta": "https://api.mainnet-beta.solana.com", + "testnet": "https://api.testnet.solana.com", + "devnet": "https://api.devnet.solana.com", + "localnet": "http://localhost:8899", +} diff --git a/sdk/shreds/go/discriminator.go b/sdk/shreds/go/discriminator.go new file mode 100644 index 0000000000..0d8c63f0e1 --- /dev/null +++ b/sdk/shreds/go/discriminator.go @@ -0,0 +1,43 @@ +package shreds + +import ( + "crypto/sha256" + "errors" + "fmt" +) + +const discriminatorSize = 8 + +var ( + DiscriminatorProgramConfig = sha256First8("dz::account::program_config") + DiscriminatorExecutionController = sha256First8("dz::account::execution_controller") + DiscriminatorClientSeat = sha256First8("dz::account::client_seat") + DiscriminatorPaymentEscrow = sha256First8("dz::account::payment_escrow") + DiscriminatorShredDistribution = sha256First8("dz::account::shred_distribution") + DiscriminatorValidatorClientRewards = sha256First8("dz::account::validator_client_rewards") + DiscriminatorInstantSeatAllocationRequest = sha256First8("dz::account::instant_seat_allocation_request") + DiscriminatorWithdrawSeatRequest = sha256First8("dz::account::withdraw_seat_request") + DiscriminatorMetroHistory = sha256First8("dz::account::metro_history") + DiscriminatorDeviceHistory = sha256First8("dz::account::device_history") + + ErrInvalidDiscriminator = errors.New("invalid account discriminator") +) + +func sha256First8(s string) [8]byte { + h := sha256.Sum256([]byte(s)) + var disc [8]byte + copy(disc[:], h[:8]) + return disc +} + +func validateDiscriminator(data []byte, expected [8]byte) error { + if len(data) < discriminatorSize { + return fmt.Errorf("%w: data too short", ErrInvalidDiscriminator) + } + var got [8]byte + copy(got[:], data[:8]) + if got != expected { + return fmt.Errorf("%w: got %x, want %x", ErrInvalidDiscriminator, got, expected) + } + return nil +} diff --git a/sdk/shreds/go/discriminator_test.go b/sdk/shreds/go/discriminator_test.go new file mode 100644 index 0000000000..d4a480f65e --- /dev/null +++ b/sdk/shreds/go/discriminator_test.go @@ -0,0 +1,45 @@ +package shreds + +import ( + "testing" +) + +func TestDiscriminatorsAreUnique(t *testing.T) { + discs := map[string][8]byte{ + "ProgramConfig": DiscriminatorProgramConfig, + "ExecutionController": DiscriminatorExecutionController, + "ClientSeat": DiscriminatorClientSeat, + "PaymentEscrow": DiscriminatorPaymentEscrow, + "ShredDistribution": DiscriminatorShredDistribution, + "ValidatorClientRewards": DiscriminatorValidatorClientRewards, + "InstantSeatAllocationRequest": DiscriminatorInstantSeatAllocationRequest, + "WithdrawSeatRequest": DiscriminatorWithdrawSeatRequest, + "MetroHistory": DiscriminatorMetroHistory, + "DeviceHistory": DiscriminatorDeviceHistory, + } + + seen := make(map[[8]byte]string) + for name, disc := range discs { + if prev, ok := seen[disc]; ok { + t.Errorf("discriminator collision: %s and %s both produce %x", prev, name, disc) + } + seen[disc] = name + } +} + +func TestValidateDiscriminator(t *testing.T) { + data := make([]byte, 16) + copy(data[:8], DiscriminatorProgramConfig[:]) + + if err := validateDiscriminator(data, DiscriminatorProgramConfig); err != nil { + t.Fatalf("expected valid discriminator: %v", err) + } + + if err := validateDiscriminator(data, DiscriminatorClientSeat); err == nil { + t.Fatal("expected error for wrong discriminator") + } + + if err := validateDiscriminator(data[:4], DiscriminatorProgramConfig); err == nil { + t.Fatal("expected error for short data") + } +} diff --git a/sdk/shreds/go/examples/fetch/main.go b/sdk/shreds/go/examples/fetch/main.go new file mode 100644 index 0000000000..19dbaa77a4 --- /dev/null +++ b/sdk/shreds/go/examples/fetch/main.go @@ -0,0 +1,216 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net" + "os" + "time" + + shreds "github.com/malbeclabs/doublezero/sdk/shreds/go" +) + +func main() { + env := flag.String("env", "mainnet-beta", "Environment: mainnet-beta, testnet, devnet, localnet") + epoch := flag.Uint64("epoch", 0, "Specific epoch to fetch distribution for (0 = latest settled)") + flag.Parse() + + validEnvs := map[string]bool{"mainnet-beta": true, "testnet": true, "devnet": true, "localnet": true} + if !validEnvs[*env] { + fmt.Fprintf(os.Stderr, "Invalid environment: %s\n", *env) + os.Exit(1) + } + + fmt.Printf("Fetching shred subscription data from %s...\n\n", *env) + + client := shreds.NewForEnv(*env) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Program Config + config, err := client.FetchProgramConfig(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching config: %v\n", err) + os.Exit(1) + } + fmt.Printf("=== Program Config ===\n") + fmt.Printf("Admin: %s\n", config.AdminKey) + fmt.Printf("Shred Oracle: %s\n", config.ShredOracleKey) + fmt.Printf("USDC-2Z Oracle: %s\n", config.USDC2ZOracleKey) + fmt.Printf("Max Slippage: %d bps\n", config.USDC2ZMaxSlippageBps) + fmt.Printf("Grace Period Slots: %d\n", config.ClosedForRequestsGracePeriodSlots) + fmt.Println() + + // Execution Controller + ec, err := client.FetchExecutionController(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching execution controller: %v\n", err) + os.Exit(1) + } + fmt.Printf("=== Execution Controller ===\n") + fmt.Printf("Current Epoch: %d\n", ec.CurrentSubscriptionEpoch) + fmt.Printf("Phase: %s\n", ec.GetPhase()) + fmt.Printf("Total Metros: %d\n", ec.TotalMetros) + fmt.Printf("Enabled Devices: %d\n", ec.TotalEnabledDevices) + fmt.Printf("Total Client Seats: %d\n", ec.TotalClientSeats) + fmt.Printf("Prices Updated: %d / %d\n", ec.UpdatedDevicePricesCount, ec.TotalEnabledDevices) + fmt.Printf("Devices Settled: %d / %d\n", ec.SettledDevicesCount, ec.TotalEnabledDevices) + fmt.Printf("Seats Settled: %d\n", ec.SettledClientSeatsCount) + fmt.Println() + + // Metro Histories + metros, err := client.FetchAllMetroHistories(ctx) + if err != nil { + fmt.Printf("=== Metro Histories ===\n") + fmt.Printf(" Error: %v\n\n", err) + } else { + fmt.Printf("=== Metro Histories (%d) ===\n", len(metros)) + for _, m := range metros { + priceStr := "no price" + if m.Prices.TotalCount > 0 { + entry := m.Prices.Entries[m.Prices.CurrentIndex] + priceStr = fmt.Sprintf("$%d (epoch %d)", entry.Price.USDCPriceDollars, entry.Epoch) + } + fmt.Printf(" %s: %d devices, %s%s\n", + m.Pubkey, m.TotalInitializedDevices, priceStr, + boolTag(m.IsCurrentPriceFinalized(), " [finalized]")) + } + fmt.Println() + } + + // Device Histories + devices, err := client.FetchAllDeviceHistories(ctx) + if err != nil { + fmt.Printf("=== Device Histories ===\n") + fmt.Printf(" Error: %v\n\n", err) + } else { + enabled := 0 + for _, d := range devices { + if d.IsEnabled() { + enabled++ + } + } + fmt.Printf("=== Device Histories (%d total, %d enabled) ===\n", len(devices), enabled) + for i, d := range devices { + if i >= 10 { + fmt.Printf(" ... and %d more\n", len(devices)-10) + break + } + subStr := "no subscriptions" + if d.Subscriptions.TotalCount > 0 { + entry := d.Subscriptions.Entries[d.Subscriptions.CurrentIndex] + sub := entry.Subscription + subStr = fmt.Sprintf("seats %d/%d, premium %+d (epoch %d)", + sub.GrantedSeatCount, sub.TotalAvailableSeats, + sub.USDCMetroPremiumDollars, entry.Epoch) + } + fmt.Printf(" %s: %s%s\n", + d.Pubkey, subStr, + boolTag(d.IsEnabled(), " [enabled]")) + } + fmt.Println() + } + + // Client Seats + seats, err := client.FetchAllClientSeats(ctx) + if err != nil { + fmt.Printf("=== Client Seats ===\n") + fmt.Printf(" Error: %v\n\n", err) + } else { + fmt.Printf("=== Client Seats (%d) ===\n", len(seats)) + for i, s := range seats { + if i >= 10 { + fmt.Printf(" ... and %d more\n", len(seats)-10) + break + } + ip := ipFromBits(s.ClientIPBits) + fmt.Printf(" %s: device=%s ip=%s tenure=%d funded_epoch=%d active_epoch=%d escrows=%d\n", + s.Pubkey, s.DeviceKey.String()[:12]+"...", ip, + s.TenureEpochs, s.FundedEpoch, s.ActiveEpoch, s.EscrowCount) + } + fmt.Println() + } + + // Payment Escrows + escrows, err := client.FetchAllPaymentEscrows(ctx) + if err != nil { + fmt.Printf("=== Payment Escrows ===\n") + fmt.Printf(" Error: %v\n\n", err) + } else { + totalUSDC := uint64(0) + for _, e := range escrows { + totalUSDC += e.USDCBalance + } + fmt.Printf("=== Payment Escrows (%d) ===\n", len(escrows)) + fmt.Printf(" Total USDC balance: %d (%.2f USDC)\n", totalUSDC, float64(totalUSDC)/1_000_000) + for i, e := range escrows { + if i >= 10 { + fmt.Printf(" ... and %d more\n", len(escrows)-10) + break + } + fmt.Printf(" %s: seat=%s balance=%.2f USDC\n", + e.Pubkey, e.ClientSeatKey.String()[:12]+"...", + float64(e.USDCBalance)/1_000_000) + } + fmt.Println() + } + + // Shred Distribution for a specific epoch + targetEpoch := *epoch + if targetEpoch == 0 && ec.CurrentSubscriptionEpoch > 1 { + targetEpoch = ec.CurrentSubscriptionEpoch - 1 + } + if targetEpoch > 0 { + dist, err := client.FetchShredDistribution(ctx, targetEpoch) + if err != nil { + fmt.Printf("=== Shred Distribution (epoch %d) ===\n", targetEpoch) + fmt.Printf(" Not found or error: %v\n", err) + } else { + fmt.Printf("=== Shred Distribution (epoch %d) ===\n", dist.SubscriptionEpoch) + fmt.Printf(" Associated DZ Epoch: %d\n", dist.AssociatedDZEpoch) + fmt.Printf(" Devices: %d\n", dist.DeviceCount) + fmt.Printf(" Client Seats: %d\n", dist.ClientSeatCount) + fmt.Printf(" Collected USDC: %d (%.2f USDC)\n", + dist.CollectedUSDCPayments, float64(dist.CollectedUSDCPayments)/1_000_000) + fmt.Printf(" 2Z from USDC: %d\n", dist.Collected2ZConvertedFromUSDC) + fmt.Printf(" Publishing Validators: %d\n", dist.TotalPublishingValidators) + fmt.Printf(" Validator 2Z Dist: %d\n", dist.DistributedValidator2ZAmount) + fmt.Printf(" Contributor 2Z Dist: %d\n", dist.DistributedContributor2ZAmount) + fmt.Printf(" Burned 2Z: %d\n", dist.Burned2ZAmount) + } + fmt.Println() + } + + // Validator Client Rewards + vcrs, err := client.FetchAllValidatorClientRewards(ctx) + if err != nil { + fmt.Printf("=== Validator Client Rewards ===\n") + fmt.Printf(" Error: %v\n\n", err) + } else { + fmt.Printf("=== Validator Client Rewards (%d) ===\n", len(vcrs)) + for _, v := range vcrs { + fmt.Printf(" ID=%d manager=%s desc=%q\n", + v.ClientID, v.ManagerKey.String()[:12]+"...", v.ShortDescription()) + } + fmt.Println() + } + + fmt.Println("Done.") +} + +func ipFromBits(bits uint32) string { + ip := make(net.IP, 4) + ip[0] = byte(bits) + ip[1] = byte(bits >> 8) + ip[2] = byte(bits >> 16) + ip[3] = byte(bits >> 24) + return ip.String() +} + +func boolTag(cond bool, tag string) string { + if cond { + return tag + } + return "" +} diff --git a/sdk/shreds/go/pda.go b/sdk/shreds/go/pda.go new file mode 100644 index 0000000000..36f7d604fb --- /dev/null +++ b/sdk/shreds/go/pda.go @@ -0,0 +1,68 @@ +package shreds + +import ( + "encoding/binary" + + "github.com/gagliardetto/solana-go" +) + +var ( + seedProgramConfig = []byte("program_config") + seedExecutionController = []byte("execution_controller") + seedClientSeat = []byte("client_seat") + seedPaymentEscrow = []byte("payment_escrow") + seedShredDistribution = []byte("shred_distribution") + seedValidatorClientRewards = []byte("validator_client_rewards") + seedInstantSeatAllocationRequest = []byte("instant_seat_allocation_request") + seedWithdrawSeatRequest = []byte("withdraw_seat_request") + seedMetroHistory = []byte("metro_history") + seedDeviceHistory = []byte("device_history") +) + +func DeriveProgramConfigPDA(programID solana.PublicKey) (solana.PublicKey, uint8, error) { + return solana.FindProgramAddress([][]byte{seedProgramConfig}, programID) +} + +func DeriveExecutionControllerPDA(programID solana.PublicKey) (solana.PublicKey, uint8, error) { + return solana.FindProgramAddress([][]byte{seedExecutionController}, programID) +} + +func DeriveClientSeatPDA(programID solana.PublicKey, deviceKey solana.PublicKey, clientIPBits uint32) (solana.PublicKey, uint8, error) { + ipBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(ipBytes, clientIPBits) + return solana.FindProgramAddress([][]byte{seedClientSeat, deviceKey[:], ipBytes}, programID) +} + +func DerivePaymentEscrowPDA(programID solana.PublicKey, clientSeatKey solana.PublicKey, withdrawAuthorityKey solana.PublicKey) (solana.PublicKey, uint8, error) { + return solana.FindProgramAddress([][]byte{seedPaymentEscrow, clientSeatKey[:], withdrawAuthorityKey[:]}, programID) +} + +func DeriveShredDistributionPDA(programID solana.PublicKey, subscriptionEpoch uint64) (solana.PublicKey, uint8, error) { + epochBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(epochBytes, subscriptionEpoch) + return solana.FindProgramAddress([][]byte{seedShredDistribution, epochBytes}, programID) +} + +func DeriveValidatorClientRewardsPDA(programID solana.PublicKey, clientID uint16) (solana.PublicKey, uint8, error) { + idBytes := make([]byte, 2) + binary.LittleEndian.PutUint16(idBytes, clientID) + return solana.FindProgramAddress([][]byte{seedValidatorClientRewards, idBytes}, programID) +} + +func DeriveInstantSeatAllocationRequestPDA(programID solana.PublicKey, deviceKey solana.PublicKey, clientIPBits uint32) (solana.PublicKey, uint8, error) { + ipBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(ipBytes, clientIPBits) + return solana.FindProgramAddress([][]byte{seedInstantSeatAllocationRequest, deviceKey[:], ipBytes}, programID) +} + +func DeriveWithdrawSeatRequestPDA(programID solana.PublicKey, clientSeatKey solana.PublicKey) (solana.PublicKey, uint8, error) { + return solana.FindProgramAddress([][]byte{seedWithdrawSeatRequest, clientSeatKey[:]}, programID) +} + +func DeriveMetroHistoryPDA(programID solana.PublicKey, exchangeKey solana.PublicKey) (solana.PublicKey, uint8, error) { + return solana.FindProgramAddress([][]byte{seedMetroHistory, exchangeKey[:]}, programID) +} + +func DeriveDeviceHistoryPDA(programID solana.PublicKey, deviceKey solana.PublicKey) (solana.PublicKey, uint8, error) { + return solana.FindProgramAddress([][]byte{seedDeviceHistory, deviceKey[:]}, programID) +} diff --git a/sdk/shreds/go/pda_test.go b/sdk/shreds/go/pda_test.go new file mode 100644 index 0000000000..d9ee2665fc --- /dev/null +++ b/sdk/shreds/go/pda_test.go @@ -0,0 +1,152 @@ +package shreds + +import ( + "testing" + + "github.com/gagliardetto/solana-go" +) + +var testProgramID = solana.MustPublicKeyFromBase58("dzshrr3yL57SB13sJPYHYo3TV8Bo1i1FxkyrZr3bKNE") + +func TestDeriveProgramConfigPDA(t *testing.T) { + addr, _, err := DeriveProgramConfigPDA(testProgramID) + if err != nil { + t.Fatalf("DeriveProgramConfigPDA: %v", err) + } + if addr.IsZero() { + t.Error("derived zero address") + } + // Deterministic. + addr2, _, _ := DeriveProgramConfigPDA(testProgramID) + if addr != addr2 { + t.Error("PDA derivation not deterministic") + } +} + +func TestDeriveExecutionControllerPDA(t *testing.T) { + addr, _, err := DeriveExecutionControllerPDA(testProgramID) + if err != nil { + t.Fatalf("DeriveExecutionControllerPDA: %v", err) + } + if addr.IsZero() { + t.Error("derived zero address") + } +} + +func TestDeriveClientSeatPDA(t *testing.T) { + device := solana.NewWallet().PublicKey() + addr1, _, err := DeriveClientSeatPDA(testProgramID, device, 0x0A000001) + if err != nil { + t.Fatalf("DeriveClientSeatPDA: %v", err) + } + if addr1.IsZero() { + t.Error("derived zero address") + } + + // Different IP produces different PDA. + addr2, _, _ := DeriveClientSeatPDA(testProgramID, device, 0x0A000002) + if addr1 == addr2 { + t.Error("different IPs produced same PDA") + } + + // Different device produces different PDA. + device2 := solana.NewWallet().PublicKey() + addr3, _, _ := DeriveClientSeatPDA(testProgramID, device2, 0x0A000001) + if addr1 == addr3 { + t.Error("different devices produced same PDA") + } +} + +func TestDerivePaymentEscrowPDA(t *testing.T) { + seat := solana.NewWallet().PublicKey() + auth1 := solana.NewWallet().PublicKey() + auth2 := solana.NewWallet().PublicKey() + + addr1, _, err := DerivePaymentEscrowPDA(testProgramID, seat, auth1) + if err != nil { + t.Fatalf("DerivePaymentEscrowPDA: %v", err) + } + if addr1.IsZero() { + t.Error("derived zero address") + } + + // Different authority produces different PDA. + addr2, _, _ := DerivePaymentEscrowPDA(testProgramID, seat, auth2) + if addr1 == addr2 { + t.Error("different authorities produced same PDA") + } +} + +func TestDeriveShredDistributionPDA(t *testing.T) { + addr1, _, err := DeriveShredDistributionPDA(testProgramID, 1) + if err != nil { + t.Fatalf("DeriveShredDistributionPDA: %v", err) + } + addr2, _, _ := DeriveShredDistributionPDA(testProgramID, 2) + if addr1 == addr2 { + t.Error("different epochs produced same PDA") + } +} + +func TestDeriveValidatorClientRewardsPDA(t *testing.T) { + addr1, _, err := DeriveValidatorClientRewardsPDA(testProgramID, 1) + if err != nil { + t.Fatalf("DeriveValidatorClientRewardsPDA: %v", err) + } + addr2, _, _ := DeriveValidatorClientRewardsPDA(testProgramID, 2) + if addr1 == addr2 { + t.Error("different client IDs produced same PDA") + } +} + +func TestDeriveInstantSeatAllocationRequestPDA(t *testing.T) { + device := solana.NewWallet().PublicKey() + addr, _, err := DeriveInstantSeatAllocationRequestPDA(testProgramID, device, 0x0A000001) + if err != nil { + t.Fatalf("DeriveInstantSeatAllocationRequestPDA: %v", err) + } + if addr.IsZero() { + t.Error("derived zero address") + } +} + +func TestDeriveWithdrawSeatRequestPDA(t *testing.T) { + seat := solana.NewWallet().PublicKey() + addr, _, err := DeriveWithdrawSeatRequestPDA(testProgramID, seat) + if err != nil { + t.Fatalf("DeriveWithdrawSeatRequestPDA: %v", err) + } + if addr.IsZero() { + t.Error("derived zero address") + } +} + +func TestDeriveMetroHistoryPDA(t *testing.T) { + exchange := solana.NewWallet().PublicKey() + addr, _, err := DeriveMetroHistoryPDA(testProgramID, exchange) + if err != nil { + t.Fatalf("DeriveMetroHistoryPDA: %v", err) + } + if addr.IsZero() { + t.Error("derived zero address") + } +} + +func TestDeriveDeviceHistoryPDA(t *testing.T) { + device := solana.NewWallet().PublicKey() + addr, _, err := DeriveDeviceHistoryPDA(testProgramID, device) + if err != nil { + t.Fatalf("DeriveDeviceHistoryPDA: %v", err) + } + if addr.IsZero() { + t.Error("derived zero address") + } +} + +func TestSingletonPDAsAreDistinct(t *testing.T) { + config, _, _ := DeriveProgramConfigPDA(testProgramID) + exec, _, _ := DeriveExecutionControllerPDA(testProgramID) + if config == exec { + t.Error("ProgramConfig and ExecutionController PDAs collide") + } +} diff --git a/sdk/shreds/go/rpc.go b/sdk/shreds/go/rpc.go new file mode 100644 index 0000000000..7212c5dc13 --- /dev/null +++ b/sdk/shreds/go/rpc.go @@ -0,0 +1,75 @@ +package shreds + +import ( + "bytes" + "io" + "net/http" + "time" + + "github.com/gagliardetto/solana-go/rpc" + "github.com/gagliardetto/solana-go/rpc/jsonrpc" +) + +const defaultMaxRetries = 5 + +// retryHTTPClient wraps an http.Client and retries on transient errors: +// network failures (EOF, connection reset), HTTP 429, and HTTP 5xx. +type retryHTTPClient struct { + inner *http.Client + maxRetries int +} + +func (c *retryHTTPClient) Do(req *http.Request) (*http.Response, error) { + var bodyBytes []byte + if req.Body != nil { + var err error + bodyBytes, err = io.ReadAll(req.Body) + req.Body.Close() + if err != nil { + return nil, err + } + } + + for attempt := 0; ; attempt++ { + if bodyBytes != nil { + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + resp, err := c.inner.Do(req) + + if err != nil { + if attempt >= c.maxRetries { + return nil, err + } + backoff := time.Duration(attempt+1) * 2 * time.Second + time.Sleep(backoff) + continue + } + + if (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500) && attempt < c.maxRetries { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + backoff := time.Duration(attempt+1) * 2 * time.Second + time.Sleep(backoff) + continue + } + + return resp, nil + } +} + +func (c *retryHTTPClient) CloseIdleConnections() { + c.inner.CloseIdleConnections() +} + +// NewRPCClient creates a Solana RPC client with automatic retry on transient errors. +func NewRPCClient(url string) *rpc.Client { + httpClient := &retryHTTPClient{ + inner: http.DefaultClient, + maxRetries: defaultMaxRetries, + } + rpcClient := jsonrpc.NewClientWithOpts(url, &jsonrpc.RPCClientOpts{ + HTTPClient: httpClient, + }) + return rpc.NewWithCustomRPCClient(rpcClient) +} diff --git a/sdk/shreds/go/state.go b/sdk/shreds/go/state.go new file mode 100644 index 0000000000..546707d1bd --- /dev/null +++ b/sdk/shreds/go/state.go @@ -0,0 +1,310 @@ +package shreds + +import "github.com/gagliardetto/solana-go" + +// MaxHistoryCount is the number of epoch entries in each ring buffer. +const MaxHistoryCount = 32 + +// MaxValidatorClientRewardProportions is the capacity of the proportions array. +const MaxValidatorClientRewardProportions = 32 + +// ExecutionPhase represents the current phase of the epoch state machine. +type ExecutionPhase uint8 + +const ( + ExecutionPhaseClosedForRequests ExecutionPhase = 0 + ExecutionPhaseUpdatingPrices ExecutionPhase = 1 + ExecutionPhaseOpenForRequests ExecutionPhase = 2 +) + +func (p ExecutionPhase) String() string { + switch p { + case ExecutionPhaseClosedForRequests: + return "closed for requests" + case ExecutionPhaseUpdatingPrices: + return "updating prices" + case ExecutionPhaseOpenForRequests: + return "open for requests" + default: + return "unknown" + } +} + +// ValidatorClientRewardsProportion pairs a validator client ID with its +// reward share (basis points, max 10 000). +type ValidatorClientRewardsProportion struct { + ID uint16 // Validator client ID + RewardProportion uint16 // UnitShare16 +} + +// ValidatorClientRewardProportionsArray is the fixed-size array of reward +// proportions stored inside ProgramConfig and ShredDistribution. +type ValidatorClientRewardProportionsArray [MaxValidatorClientRewardProportions]ValidatorClientRewardsProportion + +// ValidatorClientRewardsConfig configures how rewards are split across +// validator clients. +type ValidatorClientRewardsConfig struct { + DefaultProportion uint16 // UnitShare16 + Padding0 [6]byte // alignment + Proportions ValidatorClientRewardProportionsArray +} + +// ProgramConfig is the global program configuration account. +type ProgramConfig struct { + Flags uint64 // Flags + AdminKey solana.PublicKey // 32 bytes + ClosedForRequestsGracePeriodSlots uint32 + USDC2ZMaxSlippageBps uint16 // UnitShare16 + USDC2ZConversionGracePeriodEpochs uint8 + Padding0 [1]byte + ShredOracleKey solana.PublicKey + USDC2ZOracleKey solana.PublicKey + ValidatorClientRewardsConfig ValidatorClientRewardsConfig +} + +// ExecutionController tracks the epoch state machine and settlement progress. +type ExecutionController struct { + Phase uint8 // ExecutionPhase + BumpSeed uint8 + Padding0 [2]byte + TotalMetros uint16 + TotalEnabledDevices uint16 + TotalClientSeats uint32 + OracleInstantRequestCount uint16 + ValidatorClientIDsCount uint8 + Padding1 [1]byte + Flags uint64 // Flags + CurrentSubscriptionEpoch uint64 + UpdatedDevicePricesCount uint16 + SettledDevicesCount uint16 + SettledClientSeatsCount uint16 + Padding2 [2]byte + LastSettledSlot uint64 + LastUpdatingPricesSlot uint64 + LastOpenForRequestsSlot uint64 + LastClosedForRequestsSlot uint64 + EpochRoundCommitment [32]byte + EpochRoundReveal [32]byte + NextSeatFundingIndex uint64 +} + +// GetPhase returns the execution phase as a typed enum. +func (e *ExecutionController) GetPhase() ExecutionPhase { + return ExecutionPhase(e.Phase) +} + +// ClientSeat represents one client's subscription seat on a device. +type ClientSeat struct { + DeviceKey solana.PublicKey + ClientIPBits uint32 + Padding0 [2]byte + TenureEpochs uint16 + Flags uint64 + FundedEpoch uint64 + ActiveEpoch uint64 + NewFundingIndex uint64 + NewSettlementSortKey [32]byte + FundingAuthorityKey solana.PublicKey + EscrowCount uint32 + OverrideUSDCPriceDollars uint16 + Padding1 [26]byte + Gap [2][32]byte // StorageGap<2> +} + +// HasPriceOverride returns true if a flat price override is active. +func (s *ClientSeat) HasPriceOverride() bool { + return s.Flags&1 != 0 +} + +// PaymentEscrow holds USDC balance funding a client seat. +type PaymentEscrow struct { + ClientSeatKey solana.PublicKey + WithdrawAuthorityKey solana.PublicKey + USDCBalance uint64 + Gap [2][32]byte // StorageGap<2> +} + +// ShredDistribution tracks payment collection and reward distribution for a +// single subscription epoch. +type ShredDistribution struct { + SubscriptionEpoch uint64 + Flags uint64 + AssociatedDZEpoch uint64 + BumpSeed uint8 + ATAUSDBumpSeed uint8 + ATA2ZBumpSeed uint8 + Padding0 [1]byte + DeviceCount uint16 + ClientSeatCount uint16 + Padding1 [2]byte + ValidatorRewardsProportion uint16 // UnitShare16 + TotalPublishingValidators uint32 + ValidatorRewardsMerkleRoot [32]byte + CollectedUSDCPayments uint64 + Collected2ZConvertedFromUSDC uint64 + DistributedValidatorRewardsCount uint32 + DistributedContributorRewardsCount uint32 + DistributedValidator2ZAmount uint64 + DistributedContributor2ZAmount uint64 + Burned2ZAmount uint64 + ProcessedValidatorRewardsStartIndex uint32 + ProcessedValidatorRewardsEndIndex uint32 + ProcessedContributorRewardsStartIndex uint32 + ProcessedContributorRewardsEndIndex uint32 + ValidatorClientRewardProportions ValidatorClientRewardProportionsArray + Gap [4][32]byte // StorageGap<4> +} + +// ValidatorClientRewards is a registered validator client eligible for reward +// distribution. +type ValidatorClientRewards struct { + ClientID uint16 + Padding0 [6]byte + ManagerKey solana.PublicKey + ShortDescriptionBytes [64]byte + Gap [2][32]byte // StorageGap<2> +} + +// ShortDescription returns the UTF-8 description with trailing nulls trimmed. +func (v *ValidatorClientRewards) ShortDescription() string { + end := len(v.ShortDescriptionBytes) + for end > 0 && v.ShortDescriptionBytes[end-1] == 0 { + end-- + } + return string(v.ShortDescriptionBytes[:end]) +} + +// InstantSeatAllocationRequest is an ephemeral account requesting immediate +// seat allocation. +type InstantSeatAllocationRequest struct { + DeviceKey solana.PublicKey + ClientIPBits uint32 + Padding0 [4]byte + RequiredUSDCAmount uint64 +} + +// WithdrawSeatRequest is an ephemeral account requesting seat withdrawal. +type WithdrawSeatRequest struct { + DeviceKey solana.PublicKey + ClientIPBits uint32 +} + +// --- History types --- + +// MetroPrice is the per-epoch metro price. +type MetroPrice struct { + USDCPriceDollars uint16 + Padding0 [6]byte + Gap [2][32]byte // StorageGap<2> +} + +// MetroPriceEntry is an epoch-stamped metro price. +type MetroPriceEntry struct { + Epoch uint64 + Price MetroPrice +} + +// MetroPriceRingBuffer stores the last 32 epochs of metro prices. +type MetroPriceRingBuffer struct { + CurrentIndex uint8 + TotalCount uint8 + Padding0 [6]byte + Entries [MaxHistoryCount]MetroPriceEntry +} + +// MetroHistory tracks pricing history for a metro area. +type MetroHistory struct { + ExchangeKey solana.PublicKey + Flags uint64 + TotalInitializedDevices uint16 + Padding0 [6]byte + Gap [4][32]byte // StorageGap<4> + Prices MetroPriceRingBuffer +} + +// IsCurrentPriceFinalized returns true if the current epoch price is finalized. +func (m *MetroHistory) IsCurrentPriceFinalized() bool { + return m.Flags&(1<<1) != 0 +} + +// DeviceSubscription is the per-epoch device subscription data. +type DeviceSubscription struct { + USDCMetroPremiumDollars int16 + RequestedSeatCount uint16 + TotalAvailableSeats uint16 + GrantedSeatCount uint16 + Gap [2][32]byte // StorageGap<2> +} + +// DeviceSubscriptionEntry is an epoch-stamped device subscription. +type DeviceSubscriptionEntry struct { + Epoch uint64 + Subscription DeviceSubscription +} + +// DeviceSubscriptionRingBuffer stores the last 32 epochs of device +// subscriptions. +type DeviceSubscriptionRingBuffer struct { + CurrentIndex uint8 + TotalCount uint8 + Padding0 [6]byte + Entries [MaxHistoryCount]DeviceSubscriptionEntry +} + +// DeviceHistory tracks subscription history for a device. +type DeviceHistory struct { + DeviceKey solana.PublicKey + Flags uint64 + BumpSeed uint8 + USDCTokenPDABumpSeed uint8 + Padding0 [6]byte + MetroExchangeKey solana.PublicKey + ActiveGrantedSeats uint16 + ActiveTotalAvailableSeats uint16 + ActiveSeatsPadding [28]byte + Gap [3][32]byte // StorageGap<3> + Subscriptions DeviceSubscriptionRingBuffer +} + +// IsEnabled returns true if this device is enabled for subscriptions. +func (d *DeviceHistory) IsEnabled() bool { + return d.Flags&(1<<1) != 0 +} + +// HasSettledSeats returns true if this device has settled seats in the current epoch. +func (d *DeviceHistory) HasSettledSeats() bool { + return d.Flags&(1<<2) != 0 +} + +// --- Keyed wrappers for batch fetches --- + +// KeyedClientSeat pairs a client seat with its onchain address. +type KeyedClientSeat struct { + Pubkey solana.PublicKey + ClientSeat +} + +// KeyedPaymentEscrow pairs a payment escrow with its onchain address. +type KeyedPaymentEscrow struct { + Pubkey solana.PublicKey + PaymentEscrow +} + +// KeyedMetroHistory pairs a metro history with its onchain address. +type KeyedMetroHistory struct { + Pubkey solana.PublicKey + MetroHistory +} + +// KeyedDeviceHistory pairs a device history with its onchain address. +type KeyedDeviceHistory struct { + Pubkey solana.PublicKey + DeviceHistory +} + +// KeyedValidatorClientRewards pairs a validator client rewards account with its +// onchain address. +type KeyedValidatorClientRewards struct { + Pubkey solana.PublicKey + ValidatorClientRewards +} diff --git a/sdk/shreds/go/state_test.go b/sdk/shreds/go/state_test.go new file mode 100644 index 0000000000..7d56eb3003 --- /dev/null +++ b/sdk/shreds/go/state_test.go @@ -0,0 +1,347 @@ +package shreds + +import ( + "bytes" + "encoding/binary" + "testing" + "unsafe" +) + +func TestStructSizes(t *testing.T) { + tests := []struct { + name string + size uintptr + expected uintptr + }{ + {"ProgramConfig", unsafe.Sizeof(ProgramConfig{}), 248}, + {"ExecutionController", unsafe.Sizeof(ExecutionController{}), 144}, + {"ClientSeat", unsafe.Sizeof(ClientSeat{}), 232}, + {"PaymentEscrow", unsafe.Sizeof(PaymentEscrow{}), 136}, + {"ShredDistribution", unsafe.Sizeof(ShredDistribution{}), 392}, + {"ValidatorClientRewards", unsafe.Sizeof(ValidatorClientRewards{}), 168}, + {"InstantSeatAllocationRequest", unsafe.Sizeof(InstantSeatAllocationRequest{}), 48}, + {"WithdrawSeatRequest", unsafe.Sizeof(WithdrawSeatRequest{}), 36}, + {"MetroPrice", unsafe.Sizeof(MetroPrice{}), 72}, + {"MetroPriceEntry", unsafe.Sizeof(MetroPriceEntry{}), 80}, + {"MetroPriceRingBuffer", unsafe.Sizeof(MetroPriceRingBuffer{}), 2568}, + {"MetroHistory", unsafe.Sizeof(MetroHistory{}), 2744}, + {"DeviceSubscription", unsafe.Sizeof(DeviceSubscription{}), 72}, + {"DeviceSubscriptionEntry", unsafe.Sizeof(DeviceSubscriptionEntry{}), 80}, + {"DeviceSubscriptionRingBuffer", unsafe.Sizeof(DeviceSubscriptionRingBuffer{}), 2568}, + {"DeviceHistory", unsafe.Sizeof(DeviceHistory{}), 2776}, + {"ValidatorClientRewardsProportion", unsafe.Sizeof(ValidatorClientRewardsProportion{}), 4}, + {"ValidatorClientRewardsConfig", unsafe.Sizeof(ValidatorClientRewardsConfig{}), 136}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.size != tt.expected { + t.Errorf("sizeof(%s) = %d, want %d", tt.name, tt.size, tt.expected) + } + }) + } +} + +func TestExecutionControllerDeserialization(t *testing.T) { + data := make([]byte, unsafe.Sizeof(ExecutionController{})) + data[0] = 2 // Phase = OpenForRequests + data[1] = 42 // BumpSeed + binary.LittleEndian.PutUint16(data[4:], 5) // TotalMetros + binary.LittleEndian.PutUint16(data[6:], 10) // TotalEnabledDevices + binary.LittleEndian.PutUint32(data[8:], 100) // TotalClientSeats + binary.LittleEndian.PutUint64(data[24:], 42) // CurrentSubscriptionEpoch + binary.LittleEndian.PutUint64(data[136:], 999) // NextSeatFundingIndex + + var ec ExecutionController + if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &ec); err != nil { + t.Fatalf("deserializing: %v", err) + } + if ec.GetPhase() != ExecutionPhaseOpenForRequests { + t.Errorf("Phase = %d, want %d", ec.Phase, ExecutionPhaseOpenForRequests) + } + if ec.BumpSeed != 42 { + t.Errorf("BumpSeed = %d, want 42", ec.BumpSeed) + } + if ec.TotalMetros != 5 { + t.Errorf("TotalMetros = %d, want 5", ec.TotalMetros) + } + if ec.TotalEnabledDevices != 10 { + t.Errorf("TotalEnabledDevices = %d, want 10", ec.TotalEnabledDevices) + } + if ec.TotalClientSeats != 100 { + t.Errorf("TotalClientSeats = %d, want 100", ec.TotalClientSeats) + } + if ec.CurrentSubscriptionEpoch != 42 { + t.Errorf("CurrentSubscriptionEpoch = %d, want 42", ec.CurrentSubscriptionEpoch) + } + if ec.NextSeatFundingIndex != 999 { + t.Errorf("NextSeatFundingIndex = %d, want 999", ec.NextSeatFundingIndex) + } +} + +func TestClientSeatDeserialization(t *testing.T) { + data := make([]byte, unsafe.Sizeof(ClientSeat{})) + // Set device_key to known pattern. + for i := range 32 { + data[i] = byte(i + 1) + } + binary.LittleEndian.PutUint32(data[32:], 0x0A000001) // ClientIPBits = 10.0.0.1 + binary.LittleEndian.PutUint16(data[38:], 7) // TenureEpochs + binary.LittleEndian.PutUint64(data[40:], 1) // Flags = HAS_PRICE_OVERRIDE + binary.LittleEndian.PutUint32(data[136:], 3) // EscrowCount + binary.LittleEndian.PutUint16(data[140:], 500) // OverrideUSDCPriceDollars + + var seat ClientSeat + if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &seat); err != nil { + t.Fatalf("deserializing: %v", err) + } + if seat.DeviceKey[0] != 1 || seat.DeviceKey[31] != 32 { + t.Error("DeviceKey not deserialized correctly") + } + if seat.ClientIPBits != 0x0A000001 { + t.Errorf("ClientIPBits = %x, want 0A000001", seat.ClientIPBits) + } + if seat.TenureEpochs != 7 { + t.Errorf("TenureEpochs = %d, want 7", seat.TenureEpochs) + } + if !seat.HasPriceOverride() { + t.Error("expected HasPriceOverride = true") + } + if seat.EscrowCount != 3 { + t.Errorf("EscrowCount = %d, want 3", seat.EscrowCount) + } + if seat.OverrideUSDCPriceDollars != 500 { + t.Errorf("OverrideUSDCPriceDollars = %d, want 500", seat.OverrideUSDCPriceDollars) + } +} + +func TestPaymentEscrowDeserialization(t *testing.T) { + data := make([]byte, unsafe.Sizeof(PaymentEscrow{})) + for i := range 32 { + data[i] = byte(i + 1) // ClientSeatKey + } + for i := range 32 { + data[32+i] = byte(i + 33) // WithdrawAuthorityKey + } + binary.LittleEndian.PutUint64(data[64:], 1_000_000) // USDCBalance = 1 USDC + + var escrow PaymentEscrow + if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &escrow); err != nil { + t.Fatalf("deserializing: %v", err) + } + if escrow.ClientSeatKey[0] != 1 { + t.Error("ClientSeatKey not deserialized correctly") + } + if escrow.WithdrawAuthorityKey[0] != 33 { + t.Error("WithdrawAuthorityKey not deserialized correctly") + } + if escrow.USDCBalance != 1_000_000 { + t.Errorf("USDCBalance = %d, want 1000000", escrow.USDCBalance) + } +} + +func TestShredDistributionDeserialization(t *testing.T) { + data := make([]byte, unsafe.Sizeof(ShredDistribution{})) + binary.LittleEndian.PutUint64(data[0:], 42) // SubscriptionEpoch + binary.LittleEndian.PutUint64(data[16:], 100) // AssociatedDZEpoch + binary.LittleEndian.PutUint16(data[28:], 5) // DeviceCount + binary.LittleEndian.PutUint16(data[30:], 20) // ClientSeatCount + binary.LittleEndian.PutUint64(data[72:], 5_000_000) // CollectedUSDCPayments + + var dist ShredDistribution + if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &dist); err != nil { + t.Fatalf("deserializing: %v", err) + } + if dist.SubscriptionEpoch != 42 { + t.Errorf("SubscriptionEpoch = %d, want 42", dist.SubscriptionEpoch) + } + if dist.AssociatedDZEpoch != 100 { + t.Errorf("AssociatedDZEpoch = %d, want 100", dist.AssociatedDZEpoch) + } + if dist.DeviceCount != 5 { + t.Errorf("DeviceCount = %d, want 5", dist.DeviceCount) + } + if dist.ClientSeatCount != 20 { + t.Errorf("ClientSeatCount = %d, want 20", dist.ClientSeatCount) + } + if dist.CollectedUSDCPayments != 5_000_000 { + t.Errorf("CollectedUSDCPayments = %d, want 5000000", dist.CollectedUSDCPayments) + } +} + +func TestValidatorClientRewardsDeserialization(t *testing.T) { + data := make([]byte, unsafe.Sizeof(ValidatorClientRewards{})) + binary.LittleEndian.PutUint16(data[0:], 42) // ClientID + // Manager key at offset 8 + for i := range 32 { + data[8+i] = byte(i + 1) + } + // Short description at offset 40 + desc := "Test Client" + copy(data[40:], desc) + + var vcr ValidatorClientRewards + if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &vcr); err != nil { + t.Fatalf("deserializing: %v", err) + } + if vcr.ClientID != 42 { + t.Errorf("ClientID = %d, want 42", vcr.ClientID) + } + if vcr.ManagerKey[0] != 1 { + t.Error("ManagerKey not deserialized correctly") + } + if vcr.ShortDescription() != desc { + t.Errorf("ShortDescription() = %q, want %q", vcr.ShortDescription(), desc) + } +} + +func TestDeserializeAccountWithDiscriminator(t *testing.T) { + // Build a full account blob: discriminator + PaymentEscrow body. + body := make([]byte, unsafe.Sizeof(PaymentEscrow{})) + binary.LittleEndian.PutUint64(body[64:], 42) // USDCBalance + + data := make([]byte, discriminatorSize+len(body)) + copy(data[:8], DiscriminatorPaymentEscrow[:]) + copy(data[8:], body) + + escrow, err := deserializeAccount[PaymentEscrow](data, DiscriminatorPaymentEscrow) + if err != nil { + t.Fatalf("deserializeAccount: %v", err) + } + if escrow.USDCBalance != 42 { + t.Errorf("USDCBalance = %d, want 42", escrow.USDCBalance) + } + + // Wrong discriminator should fail. + _, err = deserializeAccount[PaymentEscrow](data, DiscriminatorClientSeat) + if err == nil { + t.Fatal("expected error for wrong discriminator") + } +} + +func TestMetroHistoryDeserialization(t *testing.T) { + data := make([]byte, unsafe.Sizeof(MetroHistory{})) + // Set exchange key + for i := range 32 { + data[i] = byte(i + 1) + } + // Flags with IS_CURRENT_PRICE_FINALIZED (bit 1) + binary.LittleEndian.PutUint64(data[32:], 1<<1) + // TotalInitializedDevices + binary.LittleEndian.PutUint16(data[40:], 3) + + // RingBuffer starts at offset 176 (32 + 8 + 2 + 6 + 128) + ringOffset := 176 + data[ringOffset] = 1 // CurrentIndex + data[ringOffset+1] = 2 // TotalCount + + // First entry at ringOffset + 8 + entryOffset := ringOffset + 8 + binary.LittleEndian.PutUint64(data[entryOffset:], 100) // Epoch + binary.LittleEndian.PutUint16(data[entryOffset+8:], 5000) // USDCPriceDollars + + var mh MetroHistory + if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &mh); err != nil { + t.Fatalf("deserializing: %v", err) + } + if mh.ExchangeKey[0] != 1 { + t.Error("ExchangeKey not deserialized correctly") + } + if !mh.IsCurrentPriceFinalized() { + t.Error("expected IsCurrentPriceFinalized = true") + } + if mh.TotalInitializedDevices != 3 { + t.Errorf("TotalInitializedDevices = %d, want 3", mh.TotalInitializedDevices) + } + if mh.Prices.CurrentIndex != 1 { + t.Errorf("Prices.CurrentIndex = %d, want 1", mh.Prices.CurrentIndex) + } + if mh.Prices.TotalCount != 2 { + t.Errorf("Prices.TotalCount = %d, want 2", mh.Prices.TotalCount) + } + if mh.Prices.Entries[0].Epoch != 100 { + t.Errorf("first entry epoch = %d, want 100", mh.Prices.Entries[0].Epoch) + } + if mh.Prices.Entries[0].Price.USDCPriceDollars != 5000 { + t.Errorf("first entry price = %d, want 5000", mh.Prices.Entries[0].Price.USDCPriceDollars) + } +} + +func TestDeviceHistoryDeserialization(t *testing.T) { + data := make([]byte, unsafe.Sizeof(DeviceHistory{})) + // Device key + for i := range 32 { + data[i] = byte(i + 1) + } + // Flags with IS_ENABLED (bit 1) and HAS_SETTLED_SEATS (bit 2) + binary.LittleEndian.PutUint64(data[32:], (1<<1)|(1<<2)) + data[40] = 255 // BumpSeed + data[41] = 254 // USDCTokenPDABumpSeed + // ActiveGrantedSeats at offset 80 + binary.LittleEndian.PutUint16(data[80:], 7) + // ActiveTotalAvailableSeats at offset 82 + binary.LittleEndian.PutUint16(data[82:], 10) + + // RingBuffer starts at offset 208 (32 + 8 + 1 + 1 + 6 + 32 + 2 + 2 + 28 + 96) + ringOffset := 208 + data[ringOffset] = 0 // CurrentIndex + data[ringOffset+1] = 1 // TotalCount + + // First entry at ringOffset + 8 + entryOffset := ringOffset + 8 + binary.LittleEndian.PutUint64(data[entryOffset:], 50) // Epoch + // DeviceSubscription starts at entryOffset + 8 + subOffset := entryOffset + 8 + neg100 := int16(-100) + binary.LittleEndian.PutUint16(data[subOffset:], uint16(neg100)) // USDCMetroPremiumDollars = -100 + binary.LittleEndian.PutUint16(data[subOffset+2:], 20) // RequestedSeatCount + binary.LittleEndian.PutUint16(data[subOffset+4:], 15) // TotalAvailableSeats + binary.LittleEndian.PutUint16(data[subOffset+6:], 10) // GrantedSeatCount + + var dh DeviceHistory + if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &dh); err != nil { + t.Fatalf("deserializing: %v", err) + } + if !dh.IsEnabled() { + t.Error("expected IsEnabled = true") + } + if !dh.HasSettledSeats() { + t.Error("expected HasSettledSeats = true") + } + if dh.ActiveGrantedSeats != 7 { + t.Errorf("ActiveGrantedSeats = %d, want 7", dh.ActiveGrantedSeats) + } + if dh.ActiveTotalAvailableSeats != 10 { + t.Errorf("ActiveTotalAvailableSeats = %d, want 10", dh.ActiveTotalAvailableSeats) + } + + sub := dh.Subscriptions.Entries[0].Subscription + if sub.USDCMetroPremiumDollars != -100 { + t.Errorf("USDCMetroPremiumDollars = %d, want -100", sub.USDCMetroPremiumDollars) + } + if sub.RequestedSeatCount != 20 { + t.Errorf("RequestedSeatCount = %d, want 20", sub.RequestedSeatCount) + } + if sub.TotalAvailableSeats != 15 { + t.Errorf("TotalAvailableSeats = %d, want 15", sub.TotalAvailableSeats) + } + if sub.GrantedSeatCount != 10 { + t.Errorf("GrantedSeatCount = %d, want 10", sub.GrantedSeatCount) + } +} + +func TestExecutionPhaseString(t *testing.T) { + tests := []struct { + phase ExecutionPhase + want string + }{ + {ExecutionPhaseClosedForRequests, "closed for requests"}, + {ExecutionPhaseUpdatingPrices, "updating prices"}, + {ExecutionPhaseOpenForRequests, "open for requests"}, + {ExecutionPhase(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.phase.String(); got != tt.want { + t.Errorf("ExecutionPhase(%d).String() = %q, want %q", tt.phase, got, tt.want) + } + } +} From 40e770eb7e7712b744faa5a743cd89cfd2b2f420 Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Fri, 27 Mar 2026 11:29:45 -0400 Subject: [PATCH 2/4] sdk: add changelog entry for shreds Go SDK --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d318d92a6..948e33ae24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ All notable changes to this project will be documented in this file. - Funder - Top up contributor owner keys alongside device metrics publishers, multicast group owners, and the internet latency collector +- SDK + - Add Go SDK for shred subscription program with read-only account deserialization (epoch state, seat assignments, pricing, settlement, validator client rewards), PDA derivation helpers, RPC fetchers, compatibility tests, and a fetch example CLI - Smartcontract - Fix multicast publisher/subscriber device counter divergence: `multicast_publishers_count` never decremented and `multicast_subscribers_count` over-decremented on user disconnect because the decrement logic checked `!publishers.is_empty()`, which is always false at delete time. Add a durable `tunnel_flags` field to the `User` struct with a `CreatedAsPublisher` bit, set at activation, and use it in the delete and closeaccount instructions. - Allow foundation allowlist members and the sentinel to create multicast users with a custom `owner` via a new `owner` field on `CreateSubscribeUser`, enabling user creation on behalf of another identity's access pass From aaf055a361e30825f18b1c15510750a55258a8bb Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Fri, 27 Mar 2026 12:37:01 -0400 Subject: [PATCH 3/4] sdk: fix gofmt formatting in shreds Go SDK --- sdk/shreds/go/discriminator.go | 18 ++-- sdk/shreds/go/pda.go | 18 ++-- sdk/shreds/go/state.go | 170 ++++++++++++++++----------------- 3 files changed, 103 insertions(+), 103 deletions(-) diff --git a/sdk/shreds/go/discriminator.go b/sdk/shreds/go/discriminator.go index 0d8c63f0e1..ed8117797a 100644 --- a/sdk/shreds/go/discriminator.go +++ b/sdk/shreds/go/discriminator.go @@ -9,16 +9,16 @@ import ( const discriminatorSize = 8 var ( - DiscriminatorProgramConfig = sha256First8("dz::account::program_config") - DiscriminatorExecutionController = sha256First8("dz::account::execution_controller") - DiscriminatorClientSeat = sha256First8("dz::account::client_seat") - DiscriminatorPaymentEscrow = sha256First8("dz::account::payment_escrow") - DiscriminatorShredDistribution = sha256First8("dz::account::shred_distribution") - DiscriminatorValidatorClientRewards = sha256First8("dz::account::validator_client_rewards") + DiscriminatorProgramConfig = sha256First8("dz::account::program_config") + DiscriminatorExecutionController = sha256First8("dz::account::execution_controller") + DiscriminatorClientSeat = sha256First8("dz::account::client_seat") + DiscriminatorPaymentEscrow = sha256First8("dz::account::payment_escrow") + DiscriminatorShredDistribution = sha256First8("dz::account::shred_distribution") + DiscriminatorValidatorClientRewards = sha256First8("dz::account::validator_client_rewards") DiscriminatorInstantSeatAllocationRequest = sha256First8("dz::account::instant_seat_allocation_request") - DiscriminatorWithdrawSeatRequest = sha256First8("dz::account::withdraw_seat_request") - DiscriminatorMetroHistory = sha256First8("dz::account::metro_history") - DiscriminatorDeviceHistory = sha256First8("dz::account::device_history") + DiscriminatorWithdrawSeatRequest = sha256First8("dz::account::withdraw_seat_request") + DiscriminatorMetroHistory = sha256First8("dz::account::metro_history") + DiscriminatorDeviceHistory = sha256First8("dz::account::device_history") ErrInvalidDiscriminator = errors.New("invalid account discriminator") ) diff --git a/sdk/shreds/go/pda.go b/sdk/shreds/go/pda.go index 36f7d604fb..5921a624ba 100644 --- a/sdk/shreds/go/pda.go +++ b/sdk/shreds/go/pda.go @@ -7,16 +7,16 @@ import ( ) var ( - seedProgramConfig = []byte("program_config") - seedExecutionController = []byte("execution_controller") - seedClientSeat = []byte("client_seat") - seedPaymentEscrow = []byte("payment_escrow") - seedShredDistribution = []byte("shred_distribution") - seedValidatorClientRewards = []byte("validator_client_rewards") + seedProgramConfig = []byte("program_config") + seedExecutionController = []byte("execution_controller") + seedClientSeat = []byte("client_seat") + seedPaymentEscrow = []byte("payment_escrow") + seedShredDistribution = []byte("shred_distribution") + seedValidatorClientRewards = []byte("validator_client_rewards") seedInstantSeatAllocationRequest = []byte("instant_seat_allocation_request") - seedWithdrawSeatRequest = []byte("withdraw_seat_request") - seedMetroHistory = []byte("metro_history") - seedDeviceHistory = []byte("device_history") + seedWithdrawSeatRequest = []byte("withdraw_seat_request") + seedMetroHistory = []byte("metro_history") + seedDeviceHistory = []byte("device_history") ) func DeriveProgramConfigPDA(programID solana.PublicKey) (solana.PublicKey, uint8, error) { diff --git a/sdk/shreds/go/state.go b/sdk/shreds/go/state.go index 546707d1bd..8e4ae8ad45 100644 --- a/sdk/shreds/go/state.go +++ b/sdk/shreds/go/state.go @@ -13,8 +13,8 @@ type ExecutionPhase uint8 const ( ExecutionPhaseClosedForRequests ExecutionPhase = 0 - ExecutionPhaseUpdatingPrices ExecutionPhase = 1 - ExecutionPhaseOpenForRequests ExecutionPhase = 2 + ExecutionPhaseUpdatingPrices ExecutionPhase = 1 + ExecutionPhaseOpenForRequests ExecutionPhase = 2 ) func (p ExecutionPhase) String() string { @@ -44,48 +44,48 @@ type ValidatorClientRewardProportionsArray [MaxValidatorClientRewardProportions] // ValidatorClientRewardsConfig configures how rewards are split across // validator clients. type ValidatorClientRewardsConfig struct { - DefaultProportion uint16 // UnitShare16 - Padding0 [6]byte // alignment + DefaultProportion uint16 // UnitShare16 + Padding0 [6]byte // alignment Proportions ValidatorClientRewardProportionsArray } // ProgramConfig is the global program configuration account. type ProgramConfig struct { - Flags uint64 // Flags - AdminKey solana.PublicKey // 32 bytes - ClosedForRequestsGracePeriodSlots uint32 - USDC2ZMaxSlippageBps uint16 // UnitShare16 - USDC2ZConversionGracePeriodEpochs uint8 - Padding0 [1]byte - ShredOracleKey solana.PublicKey - USDC2ZOracleKey solana.PublicKey - ValidatorClientRewardsConfig ValidatorClientRewardsConfig + Flags uint64 // Flags + AdminKey solana.PublicKey // 32 bytes + ClosedForRequestsGracePeriodSlots uint32 + USDC2ZMaxSlippageBps uint16 // UnitShare16 + USDC2ZConversionGracePeriodEpochs uint8 + Padding0 [1]byte + ShredOracleKey solana.PublicKey + USDC2ZOracleKey solana.PublicKey + ValidatorClientRewardsConfig ValidatorClientRewardsConfig } // ExecutionController tracks the epoch state machine and settlement progress. type ExecutionController struct { - Phase uint8 // ExecutionPhase - BumpSeed uint8 - Padding0 [2]byte - TotalMetros uint16 - TotalEnabledDevices uint16 - TotalClientSeats uint32 + Phase uint8 // ExecutionPhase + BumpSeed uint8 + Padding0 [2]byte + TotalMetros uint16 + TotalEnabledDevices uint16 + TotalClientSeats uint32 OracleInstantRequestCount uint16 - ValidatorClientIDsCount uint8 - Padding1 [1]byte - Flags uint64 // Flags - CurrentSubscriptionEpoch uint64 - UpdatedDevicePricesCount uint16 - SettledDevicesCount uint16 - SettledClientSeatsCount uint16 - Padding2 [2]byte - LastSettledSlot uint64 - LastUpdatingPricesSlot uint64 - LastOpenForRequestsSlot uint64 + ValidatorClientIDsCount uint8 + Padding1 [1]byte + Flags uint64 // Flags + CurrentSubscriptionEpoch uint64 + UpdatedDevicePricesCount uint16 + SettledDevicesCount uint16 + SettledClientSeatsCount uint16 + Padding2 [2]byte + LastSettledSlot uint64 + LastUpdatingPricesSlot uint64 + LastOpenForRequestsSlot uint64 LastClosedForRequestsSlot uint64 - EpochRoundCommitment [32]byte - EpochRoundReveal [32]byte - NextSeatFundingIndex uint64 + EpochRoundCommitment [32]byte + EpochRoundReveal [32]byte + NextSeatFundingIndex uint64 } // GetPhase returns the execution phase as a typed enum. @@ -95,20 +95,20 @@ func (e *ExecutionController) GetPhase() ExecutionPhase { // ClientSeat represents one client's subscription seat on a device. type ClientSeat struct { - DeviceKey solana.PublicKey - ClientIPBits uint32 - Padding0 [2]byte - TenureEpochs uint16 - Flags uint64 - FundedEpoch uint64 - ActiveEpoch uint64 - NewFundingIndex uint64 - NewSettlementSortKey [32]byte - FundingAuthorityKey solana.PublicKey - EscrowCount uint32 + DeviceKey solana.PublicKey + ClientIPBits uint32 + Padding0 [2]byte + TenureEpochs uint16 + Flags uint64 + FundedEpoch uint64 + ActiveEpoch uint64 + NewFundingIndex uint64 + NewSettlementSortKey [32]byte + FundingAuthorityKey solana.PublicKey + EscrowCount uint32 OverrideUSDCPriceDollars uint16 - Padding1 [26]byte - Gap [2][32]byte // StorageGap<2> + Padding1 [26]byte + Gap [2][32]byte // StorageGap<2> } // HasPriceOverride returns true if a flat price override is active. @@ -127,42 +127,42 @@ type PaymentEscrow struct { // ShredDistribution tracks payment collection and reward distribution for a // single subscription epoch. type ShredDistribution struct { - SubscriptionEpoch uint64 - Flags uint64 - AssociatedDZEpoch uint64 - BumpSeed uint8 - ATAUSDBumpSeed uint8 - ATA2ZBumpSeed uint8 - Padding0 [1]byte - DeviceCount uint16 - ClientSeatCount uint16 - Padding1 [2]byte - ValidatorRewardsProportion uint16 // UnitShare16 - TotalPublishingValidators uint32 - ValidatorRewardsMerkleRoot [32]byte - CollectedUSDCPayments uint64 - Collected2ZConvertedFromUSDC uint64 - DistributedValidatorRewardsCount uint32 - DistributedContributorRewardsCount uint32 - DistributedValidator2ZAmount uint64 - DistributedContributor2ZAmount uint64 - Burned2ZAmount uint64 - ProcessedValidatorRewardsStartIndex uint32 - ProcessedValidatorRewardsEndIndex uint32 + SubscriptionEpoch uint64 + Flags uint64 + AssociatedDZEpoch uint64 + BumpSeed uint8 + ATAUSDBumpSeed uint8 + ATA2ZBumpSeed uint8 + Padding0 [1]byte + DeviceCount uint16 + ClientSeatCount uint16 + Padding1 [2]byte + ValidatorRewardsProportion uint16 // UnitShare16 + TotalPublishingValidators uint32 + ValidatorRewardsMerkleRoot [32]byte + CollectedUSDCPayments uint64 + Collected2ZConvertedFromUSDC uint64 + DistributedValidatorRewardsCount uint32 + DistributedContributorRewardsCount uint32 + DistributedValidator2ZAmount uint64 + DistributedContributor2ZAmount uint64 + Burned2ZAmount uint64 + ProcessedValidatorRewardsStartIndex uint32 + ProcessedValidatorRewardsEndIndex uint32 ProcessedContributorRewardsStartIndex uint32 - ProcessedContributorRewardsEndIndex uint32 - ValidatorClientRewardProportions ValidatorClientRewardProportionsArray - Gap [4][32]byte // StorageGap<4> + ProcessedContributorRewardsEndIndex uint32 + ValidatorClientRewardProportions ValidatorClientRewardProportionsArray + Gap [4][32]byte // StorageGap<4> } // ValidatorClientRewards is a registered validator client eligible for reward // distribution. type ValidatorClientRewards struct { - ClientID uint16 - Padding0 [6]byte - ManagerKey solana.PublicKey + ClientID uint16 + Padding0 [6]byte + ManagerKey solana.PublicKey ShortDescriptionBytes [64]byte - Gap [2][32]byte // StorageGap<2> + Gap [2][32]byte // StorageGap<2> } // ShortDescription returns the UTF-8 description with trailing nulls trimmed. @@ -253,17 +253,17 @@ type DeviceSubscriptionRingBuffer struct { // DeviceHistory tracks subscription history for a device. type DeviceHistory struct { - DeviceKey solana.PublicKey - Flags uint64 - BumpSeed uint8 - USDCTokenPDABumpSeed uint8 - Padding0 [6]byte - MetroExchangeKey solana.PublicKey - ActiveGrantedSeats uint16 + DeviceKey solana.PublicKey + Flags uint64 + BumpSeed uint8 + USDCTokenPDABumpSeed uint8 + Padding0 [6]byte + MetroExchangeKey solana.PublicKey + ActiveGrantedSeats uint16 ActiveTotalAvailableSeats uint16 - ActiveSeatsPadding [28]byte - Gap [3][32]byte // StorageGap<3> - Subscriptions DeviceSubscriptionRingBuffer + ActiveSeatsPadding [28]byte + Gap [3][32]byte // StorageGap<3> + Subscriptions DeviceSubscriptionRingBuffer } // IsEnabled returns true if this device is enabled for subscriptions. From 21846c512963fcec878ec868baf08300f03ee02e Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Fri, 27 Mar 2026 12:53:22 -0400 Subject: [PATCH 4/4] sdk: fix gofmt formatting in shreds test files --- CHANGELOG.md | 4 ++-- sdk/shreds/go/discriminator_test.go | 18 ++++++++--------- sdk/shreds/go/state_test.go | 30 ++++++++++++++--------------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 948e33ae24..3fe431beba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable changes to this project will be documented in this file. - CLI - Reset SIGPIPE to SIG_DFL at the start of main() in all 3 CLI binaries (doublezero, doublezero-geolocation, doublezero-admin) so the process exits silently like standard CLI tools +- SDK + - Add Go SDK for shred subscription program with read-only account deserialization (epoch state, seat assignments, pricing, settlement, validator client rewards), PDA derivation helpers, RPC fetchers, compatibility tests, and a fetch example CLI ## [v0.15.0](https://github.com/malbeclabs/doublezero/compare/client/v0.14.0...client/v0.15.0) - 2026-03-27 @@ -23,8 +25,6 @@ All notable changes to this project will be documented in this file. - Funder - Top up contributor owner keys alongside device metrics publishers, multicast group owners, and the internet latency collector -- SDK - - Add Go SDK for shred subscription program with read-only account deserialization (epoch state, seat assignments, pricing, settlement, validator client rewards), PDA derivation helpers, RPC fetchers, compatibility tests, and a fetch example CLI - Smartcontract - Fix multicast publisher/subscriber device counter divergence: `multicast_publishers_count` never decremented and `multicast_subscribers_count` over-decremented on user disconnect because the decrement logic checked `!publishers.is_empty()`, which is always false at delete time. Add a durable `tunnel_flags` field to the `User` struct with a `CreatedAsPublisher` bit, set at activation, and use it in the delete and closeaccount instructions. - Allow foundation allowlist members and the sentinel to create multicast users with a custom `owner` via a new `owner` field on `CreateSubscribeUser`, enabling user creation on behalf of another identity's access pass diff --git a/sdk/shreds/go/discriminator_test.go b/sdk/shreds/go/discriminator_test.go index d4a480f65e..71727b8430 100644 --- a/sdk/shreds/go/discriminator_test.go +++ b/sdk/shreds/go/discriminator_test.go @@ -6,16 +6,16 @@ import ( func TestDiscriminatorsAreUnique(t *testing.T) { discs := map[string][8]byte{ - "ProgramConfig": DiscriminatorProgramConfig, - "ExecutionController": DiscriminatorExecutionController, - "ClientSeat": DiscriminatorClientSeat, - "PaymentEscrow": DiscriminatorPaymentEscrow, - "ShredDistribution": DiscriminatorShredDistribution, - "ValidatorClientRewards": DiscriminatorValidatorClientRewards, + "ProgramConfig": DiscriminatorProgramConfig, + "ExecutionController": DiscriminatorExecutionController, + "ClientSeat": DiscriminatorClientSeat, + "PaymentEscrow": DiscriminatorPaymentEscrow, + "ShredDistribution": DiscriminatorShredDistribution, + "ValidatorClientRewards": DiscriminatorValidatorClientRewards, "InstantSeatAllocationRequest": DiscriminatorInstantSeatAllocationRequest, - "WithdrawSeatRequest": DiscriminatorWithdrawSeatRequest, - "MetroHistory": DiscriminatorMetroHistory, - "DeviceHistory": DiscriminatorDeviceHistory, + "WithdrawSeatRequest": DiscriminatorWithdrawSeatRequest, + "MetroHistory": DiscriminatorMetroHistory, + "DeviceHistory": DiscriminatorDeviceHistory, } seen := make(map[[8]byte]string) diff --git a/sdk/shreds/go/state_test.go b/sdk/shreds/go/state_test.go index 7d56eb3003..86586b4bdd 100644 --- a/sdk/shreds/go/state_test.go +++ b/sdk/shreds/go/state_test.go @@ -43,10 +43,10 @@ func TestStructSizes(t *testing.T) { func TestExecutionControllerDeserialization(t *testing.T) { data := make([]byte, unsafe.Sizeof(ExecutionController{})) - data[0] = 2 // Phase = OpenForRequests - data[1] = 42 // BumpSeed - binary.LittleEndian.PutUint16(data[4:], 5) // TotalMetros - binary.LittleEndian.PutUint16(data[6:], 10) // TotalEnabledDevices + data[0] = 2 // Phase = OpenForRequests + data[1] = 42 // BumpSeed + binary.LittleEndian.PutUint16(data[4:], 5) // TotalMetros + binary.LittleEndian.PutUint16(data[6:], 10) // TotalEnabledDevices binary.LittleEndian.PutUint32(data[8:], 100) // TotalClientSeats binary.LittleEndian.PutUint64(data[24:], 42) // CurrentSubscriptionEpoch binary.LittleEndian.PutUint64(data[136:], 999) // NextSeatFundingIndex @@ -85,10 +85,10 @@ func TestClientSeatDeserialization(t *testing.T) { data[i] = byte(i + 1) } binary.LittleEndian.PutUint32(data[32:], 0x0A000001) // ClientIPBits = 10.0.0.1 - binary.LittleEndian.PutUint16(data[38:], 7) // TenureEpochs - binary.LittleEndian.PutUint64(data[40:], 1) // Flags = HAS_PRICE_OVERRIDE - binary.LittleEndian.PutUint32(data[136:], 3) // EscrowCount - binary.LittleEndian.PutUint16(data[140:], 500) // OverrideUSDCPriceDollars + binary.LittleEndian.PutUint16(data[38:], 7) // TenureEpochs + binary.LittleEndian.PutUint64(data[40:], 1) // Flags = HAS_PRICE_OVERRIDE + binary.LittleEndian.PutUint32(data[136:], 3) // EscrowCount + binary.LittleEndian.PutUint16(data[140:], 500) // OverrideUSDCPriceDollars var seat ClientSeat if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &seat); err != nil { @@ -141,7 +141,7 @@ func TestPaymentEscrowDeserialization(t *testing.T) { func TestShredDistributionDeserialization(t *testing.T) { data := make([]byte, unsafe.Sizeof(ShredDistribution{})) - binary.LittleEndian.PutUint64(data[0:], 42) // SubscriptionEpoch + binary.LittleEndian.PutUint64(data[0:], 42) // SubscriptionEpoch binary.LittleEndian.PutUint64(data[16:], 100) // AssociatedDZEpoch binary.LittleEndian.PutUint16(data[28:], 5) // DeviceCount binary.LittleEndian.PutUint16(data[30:], 20) // ClientSeatCount @@ -236,8 +236,8 @@ func TestMetroHistoryDeserialization(t *testing.T) { // First entry at ringOffset + 8 entryOffset := ringOffset + 8 - binary.LittleEndian.PutUint64(data[entryOffset:], 100) // Epoch - binary.LittleEndian.PutUint16(data[entryOffset+8:], 5000) // USDCPriceDollars + binary.LittleEndian.PutUint64(data[entryOffset:], 100) // Epoch + binary.LittleEndian.PutUint16(data[entryOffset+8:], 5000) // USDCPriceDollars var mh MetroHistory if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &mh); err != nil { @@ -288,14 +288,14 @@ func TestDeviceHistoryDeserialization(t *testing.T) { // First entry at ringOffset + 8 entryOffset := ringOffset + 8 - binary.LittleEndian.PutUint64(data[entryOffset:], 50) // Epoch + binary.LittleEndian.PutUint64(data[entryOffset:], 50) // Epoch // DeviceSubscription starts at entryOffset + 8 subOffset := entryOffset + 8 neg100 := int16(-100) binary.LittleEndian.PutUint16(data[subOffset:], uint16(neg100)) // USDCMetroPremiumDollars = -100 - binary.LittleEndian.PutUint16(data[subOffset+2:], 20) // RequestedSeatCount - binary.LittleEndian.PutUint16(data[subOffset+4:], 15) // TotalAvailableSeats - binary.LittleEndian.PutUint16(data[subOffset+6:], 10) // GrantedSeatCount + binary.LittleEndian.PutUint16(data[subOffset+2:], 20) // RequestedSeatCount + binary.LittleEndian.PutUint16(data[subOffset+4:], 15) // TotalAvailableSeats + binary.LittleEndian.PutUint16(data[subOffset+6:], 10) // GrantedSeatCount var dh DeviceHistory if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &dh); err != nil {