From 4ccdf4953147ac5572519cbdcd16d34b7b4283b9 Mon Sep 17 00:00:00 2001 From: Nik Weidenbacher Date: Tue, 30 Jun 2026 17:27:20 +0000 Subject: [PATCH] sdk: Feed and EdgeSeat read support for Go, Python, and TypeScript Add Feed account and EdgeSeat FeedSeat deserialization to the Go, Python, and TypeScript serviceability SDKs, and regenerate the feed and access_pass_edge_seat binary fixtures the SDK tests read. --- .../python/serviceability/state.py | 74 ++++++++++++++++- .../serviceability/tests/test_fixtures.py | 49 ++++++++++- .../fixtures/access_pass_edge_seat.bin | Bin 103 -> 143 bytes .../fixtures/access_pass_edge_seat.json | 20 +++++ sdk/serviceability/testdata/fixtures/feed.bin | Bin 0 -> 230 bytes .../testdata/fixtures/feed.json | 76 ++++++++++++++++++ .../fixtures/generate-fixtures/Cargo.lock | 4 +- .../fixtures/generate-fixtures/src/main.rs | 66 ++++++++++++++- .../typescript/serviceability/state.ts | 73 ++++++++++++++++- .../serviceability/tests/fixtures.test.ts | 42 +++++++++- smartcontract/sdk/go/serviceability/client.go | 7 ++ .../sdk/go/serviceability/client_test.go | 9 +++ .../sdk/go/serviceability/deserialize.go | 38 ++++++++- .../sdk/go/serviceability/fixture_test.go | 29 ++++++- smartcontract/sdk/go/serviceability/state.go | 36 ++++++++- 15 files changed, 510 insertions(+), 13 deletions(-) create mode 100644 sdk/serviceability/testdata/fixtures/feed.bin create mode 100644 sdk/serviceability/testdata/fixtures/feed.json diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index fc14898756..28543cd4c1 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -43,6 +43,7 @@ class AccountTypeEnum(IntEnum): TENANT = 13 PERMISSION = 15 TOPOLOGY = 16 + FEED = 18 # --------------------------------------------------------------------------- @@ -1023,6 +1024,15 @@ def from_bytes(cls, data: bytes) -> Tenant: return t +@dataclass +class FeedSeat: + """One purchased SKU seat on an EdgeSeat access pass.""" + + feed_key: Pubkey = Pubkey.default() + max_users: int = 0 + current_users: int = 0 + + @dataclass class AccessPass: account_type: int = 0 @@ -1032,6 +1042,7 @@ class AccessPass: associated_pubkey: Pubkey | None = None # for SolanaValidator, SolanaRPC others_type_name: str = "" # for Others variant others_key: str = "" # for Others variant + feed_seats: list[FeedSeat] = field(default_factory=list) # for EdgeSeat variant client_ip: bytes = b"\x00" * 4 user_payer: Pubkey = Pubkey.default() last_access_epoch: int = 0 @@ -1065,7 +1076,19 @@ def from_bytes(cls, data: bytes) -> AccessPass: elif tag == 3: ap.others_type_name = r.read_string() ap.others_key = r.read_string() - # Prepaid (0) and EdgeSeat (4) carry no associated data. + # EdgeSeat carries a Vec: u32 count, then each FeedSeat is + # feed_key (32) + max_users (u16) + current_users (u16). + elif tag == 4: + count = r.read_u32() + ap.feed_seats = [ + FeedSeat( + feed_key=_read_pubkey(r), + max_users=r.read_u16(), + current_users=r.read_u16(), + ) + for _ in range(count) + ] + # Prepaid (0) carries no associated data. ap.client_ip = r.read_ipv4() ap.user_payer = _read_pubkey(r) ap.last_access_epoch = r.read_u64() @@ -1182,3 +1205,52 @@ def from_bytes(cls, data: bytes) -> TopologyInfo: t.flex_algo_number = r.read_u8() t.constraint = TopologyConstraint(r.read_u8()) return t + + +# --------------------------------------------------------------------------- +# Feed +# --------------------------------------------------------------------------- + + +@dataclass +class FeedMetro: + """Maps an exchange (metro) pubkey to the multicast groups joinable from it.""" + + exchange: Pubkey = Pubkey.default() + groups: list[Pubkey] = field(default_factory=list) + + +@dataclass +class Feed: + """Serviceability catalog entry: the metro(exchange) -> group-set map for one SKU. + + A Feed with an empty metros list imposes no metro restriction. + """ + + account_type: int = 0 + owner: Pubkey = Pubkey.default() + bump_seed: int = 0 + code: str = "" + name: str = "" + reference_count: int = 0 + metros: list[FeedMetro] = field(default_factory=list) + pub_key: Pubkey = Pubkey.default() # set from account address after deserialization + + @classmethod + def from_bytes(cls, data: bytes) -> Feed: + r = DefensiveReader(data) + f = cls() + f.account_type = r.read_u8() + f.owner = _read_pubkey(r) + f.bump_seed = r.read_u8() + f.code = r.read_string() + f.name = r.read_string() + f.reference_count = r.read_u32() + # metros is a Vec<(Pubkey, Vec)>: u32 count, then each entry is an + # exchange pubkey followed by a Vec of joinable groups. + count = r.read_u32() + f.metros = [ + FeedMetro(exchange=_read_pubkey(r), groups=_read_pubkey_vec(r)) + for _ in range(count) + ] + return f diff --git a/sdk/serviceability/python/serviceability/tests/test_fixtures.py b/sdk/serviceability/python/serviceability/tests/test_fixtures.py index 3f06e6cc6b..db1a027efa 100644 --- a/sdk/serviceability/python/serviceability/tests/test_fixtures.py +++ b/sdk/serviceability/python/serviceability/tests/test_fixtures.py @@ -12,6 +12,7 @@ Contributor, Device, Exchange, + Feed, GlobalConfig, GlobalState, Link, @@ -501,6 +502,16 @@ def test_deserialize(self): "Owner": ap.owner, "BumpSeed": ap.bump_seed, "AccessPassType": ap.access_pass_type_tag, + "EdgeSeatFeedSeatsLen": len(ap.feed_seats), + "EdgeSeatFeedSeat0FeedKey": ap.feed_seats[0].feed_key + if ap.feed_seats + else None, + "EdgeSeatFeedSeat0MaxUsers": ap.feed_seats[0].max_users + if ap.feed_seats + else None, + "EdgeSeatFeedSeat0CurrentUsers": ap.feed_seats[0].current_users + if ap.feed_seats + else None, "UserPayer": ap.user_payer, "ConnectionCount": ap.connection_count, "Status": ap.status, @@ -511,15 +522,51 @@ def test_deserialize(self): "MaxMulticastUsers": ap.max_multicast_users, }, ) - # EdgeSeat is tag 4 and carries no payload; the seat is the user_payer. + # EdgeSeat is tag 4 and now carries a Vec payload. assert ap.access_pass_type_tag == 4 assert ap.associated_pubkey is None + assert len(ap.feed_seats) == 1 + assert ap.feed_seats[0].max_users == 7 + assert ap.feed_seats[0].current_users == 3 assert ap.unicast_user_count == 2 assert ap.max_unicast_users == 4 assert ap.multicast_user_count == 1 assert ap.max_multicast_users == 3 +class TestFixtureFeed: + def test_deserialize(self): + data, meta = _load_fixture("feed") + feed = Feed.from_bytes(data) + _assert_fields( + meta["fields"], + { + "AccountType": feed.account_type, + "Owner": feed.owner, + "BumpSeed": feed.bump_seed, + "Code": feed.code, + "Name": feed.name, + "ReferenceCount": feed.reference_count, + "MetrosLen": len(feed.metros), + "Metro0Exchange": feed.metros[0].exchange, + "Metro0GroupsLen": len(feed.metros[0].groups), + "Metro0Group0": feed.metros[0].groups[0], + "Metro0Group1": feed.metros[0].groups[1], + "Metro1Exchange": feed.metros[1].exchange, + "Metro1GroupsLen": len(feed.metros[1].groups), + "Metro1Group0": feed.metros[1].groups[0], + }, + ) + assert feed.account_type == 18 + assert feed.bump_seed == 239 + assert feed.code == "shreds" + assert feed.name == "Shreds" + assert feed.reference_count == 4 + assert len(feed.metros) == 2 + assert len(feed.metros[0].groups) == 2 + assert len(feed.metros[1].groups) == 1 + + class TestFixtureAccessPassLegacyCapDefaults: def test_deserialize(self): # A pre-migration account lacks the 8 trailing cap bytes; counts default to 0 and caps to 1, diff --git a/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.bin b/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.bin index c920275d2c7ab8d0e69c8e08b75ddd1e9659a949..22e49a8116d23fb2a4eb5c80ba076f28b6e95265 100644 GIT binary patch delta 38 ocmYfAXPltS%E-XLuxX;9*hB|*ZUzQ+24*0ifng&=WTLAQ0Fnv^fdBvi delta 17 XcmeBYOrN04$^Zl#CkDz*%u@saCWZuo diff --git a/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.json b/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.json index c4ed2fc208..1a560ed8b5 100644 --- a/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.json +++ b/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.json @@ -22,6 +22,26 @@ "value": "4", "typ": "u8" }, + { + "name": "EdgeSeatFeedSeatsLen", + "value": "1", + "typ": "u32" + }, + { + "name": "EdgeSeatFeedSeat0FeedKey", + "value": "Cyqa5AA25q85i8QRYpQUMwfh4jaCg78NwPu4biE9JCgP", + "typ": "pubkey" + }, + { + "name": "EdgeSeatFeedSeat0MaxUsers", + "value": "7", + "typ": "u16" + }, + { + "name": "EdgeSeatFeedSeat0CurrentUsers", + "value": "3", + "typ": "u16" + }, { "name": "ClientIp", "value": "0.0.0.0", diff --git a/sdk/serviceability/testdata/fixtures/feed.bin b/sdk/serviceability/testdata/fixtures/feed.bin new file mode 100644 index 0000000000000000000000000000000000000000..e4033e78c5945e2f39981b3233a799b12df21383 GIT binary patch literal 230 zcmWf7z)>). Two metros, the first with two +/// groups and the second with one, so the nested-vec decoding is exercised. +fn generate_feed(dir: &Path) { + let owner = pubkey_from_byte(0xE0); + let metro0 = pubkey_from_byte(0xE1); + let metro0_group0 = pubkey_from_byte(0xE2); + let metro0_group1 = pubkey_from_byte(0xE3); + let metro1 = pubkey_from_byte(0xE4); + let metro1_group0 = pubkey_from_byte(0xE5); + + let val = Feed { + account_type: AccountType::Feed, + owner, + bump_seed: 239, + code: "shreds".into(), + name: "Shreds".into(), + reference_count: 4, + metros: vec![ + (metro0, vec![metro0_group0, metro0_group1]), + (metro1, vec![metro1_group0]), + ], + }; + + let data = borsh::to_vec(&val).unwrap(); + + let meta = FixtureMeta { + name: "Feed".into(), + account_type: 18, + fields: vec![ + FieldValue { name: "AccountType".into(), value: "18".into(), typ: "u8".into() }, + FieldValue { name: "Owner".into(), value: pubkey_bs58(&owner), typ: "pubkey".into() }, + FieldValue { name: "BumpSeed".into(), value: "239".into(), typ: "u8".into() }, + FieldValue { name: "Code".into(), value: "shreds".into(), typ: "string".into() }, + FieldValue { name: "Name".into(), value: "Shreds".into(), typ: "string".into() }, + FieldValue { name: "ReferenceCount".into(), value: "4".into(), typ: "u32".into() }, + FieldValue { name: "MetrosLen".into(), value: "2".into(), typ: "u32".into() }, + FieldValue { name: "Metro0Exchange".into(), value: pubkey_bs58(&metro0), typ: "pubkey".into() }, + FieldValue { name: "Metro0GroupsLen".into(), value: "2".into(), typ: "u32".into() }, + FieldValue { name: "Metro0Group0".into(), value: pubkey_bs58(&metro0_group0), typ: "pubkey".into() }, + FieldValue { name: "Metro0Group1".into(), value: pubkey_bs58(&metro0_group1), typ: "pubkey".into() }, + FieldValue { name: "Metro1Exchange".into(), value: pubkey_bs58(&metro1), typ: "pubkey".into() }, + FieldValue { name: "Metro1GroupsLen".into(), value: "1".into(), typ: "u32".into() }, + FieldValue { name: "Metro1Group0".into(), value: pubkey_bs58(&metro1_group0), typ: "pubkey".into() }, + ], + }; + + write_fixture(dir, "feed", &data, &meta); +} + fn generate_tenant(dir: &Path) { let owner = pubkey_from_byte(0xD0); let admin_pk = pubkey_from_byte(0xD1); diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index f769e07611..4207c493af 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -32,6 +32,7 @@ export const ACCOUNT_TYPE_CONTRIBUTOR = 10; export const ACCOUNT_TYPE_ACCESS_PASS = 11; export const ACCOUNT_TYPE_TENANT = 13; export const ACCOUNT_TYPE_PERMISSION = 15; +export const ACCOUNT_TYPE_FEED = 18; // --------------------------------------------------------------------------- // Enum string mappings @@ -1043,6 +1044,13 @@ export const ACCESS_PASS_TYPE_SOLANA_RPC = 2; export const ACCESS_PASS_TYPE_OTHERS = 3; export const ACCESS_PASS_TYPE_EDGE_SEAT = 4; +// One purchased SKU seat on an EdgeSeat access pass. +export interface FeedSeat { + feedKey: PublicKey; + maxUsers: number; + currentUsers: number; +} + export interface AccessPass { accountType: number; owner: PublicKey; @@ -1051,6 +1059,7 @@ export interface AccessPass { associatedPubkey: PublicKey | null; // for SolanaValidator, SolanaRPC othersTypeName: string; // for Others variant othersKey: string; // for Others variant + feedSeats: FeedSeat[]; // for EdgeSeat variant clientIp: Uint8Array; userPayer: PublicKey; lastAccessEpoch: bigint; @@ -1075,6 +1084,7 @@ export function deserializeAccessPass(data: Uint8Array): AccessPass { let associatedPubkey: PublicKey | null = null; let othersTypeName = ""; let othersKey = ""; + const feedSeats: FeedSeat[] = []; // SolanaValidator and SolanaRPC carry an associated pubkey. if (accessPassType === 1 || accessPassType === 2) { associatedPubkey = readPubkey(r); @@ -1084,7 +1094,19 @@ export function deserializeAccessPass(data: Uint8Array): AccessPass { othersTypeName = r.readString(); othersKey = r.readString(); } - // Prepaid (0) and EdgeSeat (4) carry no associated data. + // EdgeSeat carries a Vec: u32 count, then each FeedSeat is + // feed_key (32) + max_users (u16) + current_users (u16). + else if (accessPassType === 4) { + const count = r.readU32(); + for (let i = 0; i < count; i++) { + feedSeats.push({ + feedKey: readPubkey(r), + maxUsers: r.readU16(), + currentUsers: r.readU16(), + }); + } + } + // Prepaid (0) carries no associated data. const clientIp = r.readIPv4(); const userPayer = readPubkey(r); const lastAccessEpoch = r.readU64(); @@ -1107,6 +1129,7 @@ export function deserializeAccessPass(data: Uint8Array): AccessPass { associatedPubkey, othersTypeName, othersKey, + feedSeats, clientIp, userPayer, lastAccessEpoch, @@ -1186,3 +1209,51 @@ export function deserializePermission(data: Uint8Array): Permission { permissions, }; } + +// --------------------------------------------------------------------------- +// Feed +// --------------------------------------------------------------------------- + +// Maps an exchange (metro) pubkey to the multicast groups joinable from it. +export interface FeedMetro { + exchange: PublicKey; + groups: PublicKey[]; +} + +// Serviceability catalog entry: the metro(exchange) -> group-set map for one SKU. +// A Feed with an empty metros array imposes no metro restriction. +export interface Feed { + accountType: number; + owner: PublicKey; + bumpSeed: number; + code: string; + name: string; + referenceCount: number; + metros: FeedMetro[]; +} + +export function deserializeFeed(data: Uint8Array): Feed { + const r = new DefensiveReader(data); + const accountType = r.readU8(); + const owner = readPubkey(r); + const bumpSeed = r.readU8(); + const code = r.readString(); + const name = r.readString(); + const referenceCount = r.readU32(); + // metros is a Vec<(Pubkey, Vec)>: u32 count, then each entry is an + // exchange pubkey followed by a Vec of joinable groups. + const count = r.readU32(); + const metros: FeedMetro[] = []; + for (let i = 0; i < count; i++) { + metros.push({ exchange: readPubkey(r), groups: readPubkeyVec(r) }); + } + return { + accountType, + owner, + bumpSeed, + code, + name, + referenceCount, + metros, + }; +} diff --git a/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts b/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts index e19cd72cbc..fcc516f434 100644 --- a/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts +++ b/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts @@ -19,6 +19,7 @@ import { deserializeContributor, deserializeAccessPass, deserializeTenant, + deserializeFeed, } from "../state.js"; const FIXTURES_DIR = join( @@ -527,6 +528,10 @@ describe("AccessPassEdgeSeat fixture", () => { Owner: ap.owner, BumpSeed: ap.bumpSeed, AccessPassType: ap.accessPassType, + EdgeSeatFeedSeatsLen: ap.feedSeats.length, + EdgeSeatFeedSeat0FeedKey: ap.feedSeats[0]?.feedKey, + EdgeSeatFeedSeat0MaxUsers: ap.feedSeats[0]?.maxUsers, + EdgeSeatFeedSeat0CurrentUsers: ap.feedSeats[0]?.currentUsers, UserPayer: ap.userPayer, ConnectionCount: ap.connectionCount, Status: ap.status, @@ -537,9 +542,12 @@ describe("AccessPassEdgeSeat fixture", () => { MaxMulticastUsers: ap.maxMulticastUsers, }); - // EdgeSeat is tag 4 and carries no payload; the seat is the user_payer. + // EdgeSeat is tag 4 and now carries a Vec payload. expect(ap.accessPassType).toBe(4); expect(ap.associatedPubkey).toBeNull(); + expect(ap.feedSeats).toHaveLength(1); + expect(ap.feedSeats[0].maxUsers).toBe(7); + expect(ap.feedSeats[0].currentUsers).toBe(3); expect(ap.unicastUserCount).toBe(2); expect(ap.maxUnicastUsers).toBe(4); expect(ap.multicastUserCount).toBe(1); @@ -547,6 +555,38 @@ describe("AccessPassEdgeSeat fixture", () => { }); }); +describe("Feed fixture", () => { + test("deserialize", () => { + const [data, meta] = loadFixture("feed"); + const feed = deserializeFeed(data); + assertFields(meta.fields, { + AccountType: feed.accountType, + Owner: feed.owner, + BumpSeed: feed.bumpSeed, + Code: feed.code, + Name: feed.name, + ReferenceCount: feed.referenceCount, + MetrosLen: feed.metros.length, + Metro0Exchange: feed.metros[0]?.exchange, + Metro0GroupsLen: feed.metros[0]?.groups.length, + Metro0Group0: feed.metros[0]?.groups[0], + Metro0Group1: feed.metros[0]?.groups[1], + Metro1Exchange: feed.metros[1]?.exchange, + Metro1GroupsLen: feed.metros[1]?.groups.length, + Metro1Group0: feed.metros[1]?.groups[0], + }); + + expect(feed.accountType).toBe(18); + expect(feed.bumpSeed).toBe(239); + expect(feed.code).toBe("shreds"); + expect(feed.name).toBe("Shreds"); + expect(feed.referenceCount).toBe(4); + expect(feed.metros).toHaveLength(2); + expect(feed.metros[0].groups).toHaveLength(2); + expect(feed.metros[1].groups).toHaveLength(1); + }); +}); + describe("AccessPass legacy cap defaults", () => { test("pre-migration account decodes caps to 1", () => { // A pre-migration account lacks the 8 trailing cap bytes; counts default to 0 and caps to 1, diff --git a/smartcontract/sdk/go/serviceability/client.go b/smartcontract/sdk/go/serviceability/client.go index b997e668cb..c798d7c749 100644 --- a/smartcontract/sdk/go/serviceability/client.go +++ b/smartcontract/sdk/go/serviceability/client.go @@ -28,6 +28,7 @@ type ProgramData struct { ResourceExtensions []ResourceExtension Permissions []Permission Topologies []TopologyInfo + Feeds []Feed } func New(rpc RPCClient, programID solana.PublicKey) *Client { @@ -61,6 +62,7 @@ func (c *Client) GetProgramData(ctx context.Context) (*ProgramData, error) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, } for _, element := range out { @@ -145,6 +147,11 @@ func (c *Client) GetProgramData(ctx context.Context) (*ProgramData, error) { DeserializeTopologyInfo(reader, &t) t.PubKey = element.Pubkey pd.Topologies = append(pd.Topologies, t) + case FeedType: + var feed Feed + DeserializeFeed(reader, &feed) + feed.PubKey = element.Pubkey + pd.Feeds = append(pd.Feeds, feed) } } diff --git a/smartcontract/sdk/go/serviceability/client_test.go b/smartcontract/sdk/go/serviceability/client_test.go index ba9a4ad54e..b6d933ecb9 100644 --- a/smartcontract/sdk/go/serviceability/client_test.go +++ b/smartcontract/sdk/go/serviceability/client_test.go @@ -200,6 +200,7 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, }, }, { @@ -242,6 +243,7 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, }, }, { @@ -333,6 +335,7 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, }, }, { @@ -368,6 +371,7 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, }, }, { @@ -405,6 +409,7 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, }, }, { @@ -449,6 +454,7 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, }, }, { @@ -483,6 +489,7 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, }, }, { @@ -522,6 +529,7 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, }, }, { @@ -550,6 +558,7 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ResourceExtensions: []ResourceExtension{}, Permissions: []Permission{}, Topologies: []TopologyInfo{}, + Feeds: []Feed{}, }, }, } diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index ae8a67d2bc..6c6334cfd3 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -373,8 +373,22 @@ func DeserializeAccessPass(reader *ByteReader, ap *AccessPass) { // Others carries two strings (type_name, key). ap.OthersTypeName = reader.ReadString() ap.OthersKey = reader.ReadString() + case AccessPassTypeEdgeSeat: + // EdgeSeat carries a borsh Vec: u32 count, then each FeedSeat is + // feed_key (32) + max_users (u16) + current_users (u16). + count := reader.ReadU32() + if count > 0 && (count*36) <= reader.Remaining() { + ap.FeedSeats = make([]FeedSeat, count) + for i := uint32(0); i < count; i++ { + ap.FeedSeats[i] = FeedSeat{ + FeedKey: reader.ReadPubkey(), + MaxUsers: reader.ReadU16(), + CurrentUsers: reader.ReadU16(), + } + } + } } - // Prepaid and EdgeSeat carry no associated data. + // Prepaid carries no associated data. ap.ClientIp = reader.ReadIPv4() ap.UserPayer = reader.ReadPubkey() ap.LastAccessEpoch = reader.ReadU64() @@ -472,3 +486,25 @@ func DeserializeTopologyInfo(reader *ByteReader, t *TopologyInfo) { t.ReferenceCount = reader.ReadU32() // Note: t.PubKey is set from the account address in client.go after deserialization } + +func DeserializeFeed(reader *ByteReader, feed *Feed) { + feed.AccountType = AccountType(reader.ReadU8()) + feed.Owner = reader.ReadPubkey() + feed.BumpSeed = reader.ReadU8() + feed.Code = reader.ReadString() + feed.Name = reader.ReadString() + feed.ReferenceCount = reader.ReadU32() + // metros is a borsh Vec<(Pubkey, Vec)>: u32 count, then each entry is an + // exchange pubkey (32) followed by a Vec of joinable groups. + count := reader.ReadU32() + if count > 0 && count <= reader.Remaining() { + feed.Metros = make([]FeedMetro, 0, count) + for i := uint32(0); i < count; i++ { + feed.Metros = append(feed.Metros, FeedMetro{ + Exchange: reader.ReadPubkey(), + Groups: reader.ReadPubkeySlice(), + }) + } + } + // Note: feed.PubKey is set from the account address in client.go after deserialization +} diff --git a/smartcontract/sdk/go/serviceability/fixture_test.go b/smartcontract/sdk/go/serviceability/fixture_test.go index 01078a5a45..4518ff7a7e 100644 --- a/smartcontract/sdk/go/serviceability/fixture_test.go +++ b/smartcontract/sdk/go/serviceability/fixture_test.go @@ -205,9 +205,13 @@ func TestFixtureAccessPassEdgeSeat(t *testing.T) { var ap serviceability.AccessPass serviceability.DeserializeAccessPass(serviceability.NewByteReader(data), &ap) - // EdgeSeat is Rust discriminant 4 and carries no payload; the seat is the user_payer. + // EdgeSeat is Rust discriminant 4 and now carries a Vec payload. assert.Equal(t, serviceability.AccessPassTypeEdgeSeat, ap.AccessPassTypeTag) assert.Equal(t, [32]byte{}, ap.AssociatedPubkey) + require.Len(t, ap.FeedSeats, 1) + assert.Equal(t, byte(0xB2), ap.FeedSeats[0].FeedKey[0]) + assert.Equal(t, uint16(7), ap.FeedSeats[0].MaxUsers) + assert.Equal(t, uint16(3), ap.FeedSeats[0].CurrentUsers) assert.Equal(t, uint8(2), ap.Flags) // ALLOW_MULTIPLE_IP assert.Equal(t, uint16(2), ap.UnicastUserCount) assert.Equal(t, uint16(4), ap.MaxUnicastUsers) @@ -215,6 +219,29 @@ func TestFixtureAccessPassEdgeSeat(t *testing.T) { assert.Equal(t, uint16(3), ap.MaxMulticastUsers) } +func TestFixtureFeed(t *testing.T) { + data, meta := loadFixture(t, "feed") + require.Equal(t, "Feed", meta.Name) + + var feed serviceability.Feed + serviceability.DeserializeFeed(serviceability.NewByteReader(data), &feed) + + assert.Equal(t, serviceability.FeedType, feed.AccountType) + assert.Equal(t, byte(0xE0), feed.Owner[0]) + assert.Equal(t, uint8(239), feed.BumpSeed) + assert.Equal(t, "shreds", feed.Code) + assert.Equal(t, "Shreds", feed.Name) + assert.Equal(t, uint32(4), feed.ReferenceCount) + require.Len(t, feed.Metros, 2) + assert.Equal(t, byte(0xE1), feed.Metros[0].Exchange[0]) + require.Len(t, feed.Metros[0].Groups, 2) + assert.Equal(t, byte(0xE2), feed.Metros[0].Groups[0][0]) + assert.Equal(t, byte(0xE3), feed.Metros[0].Groups[1][0]) + assert.Equal(t, byte(0xE4), feed.Metros[1].Exchange[0]) + require.Len(t, feed.Metros[1].Groups, 1) + assert.Equal(t, byte(0xE5), feed.Metros[1].Groups[0][0]) +} + // A pre-migration account (lacking the 8 trailing cap bytes) decodes with counts 0 and caps 1, // matching the Rust program's TryFrom unwrap_or defaults. func TestFixtureAccessPassLegacyCapDefaults(t *testing.T) { diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index e94ecbad71..f1dac9fc21 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -28,6 +28,7 @@ const ( PermissionType AccountType = 15 IndexType AccountType = 16 TopologyType AccountType = 17 + FeedType AccountType = 18 ) type LocationStatus uint8 @@ -1123,14 +1124,24 @@ func (s AccessPassStatus) String() string { } } +// FeedSeat is one purchased SKU seat on an EdgeSeat access pass. FeedKey is the pubkey of +// the serviceability Feed account; MaxUsers is the per-feed concurrent-user cap; CurrentUsers +// is the live count. +type FeedSeat struct { + FeedKey [32]byte + MaxUsers uint16 + CurrentUsers uint16 +} + type AccessPass struct { AccountType AccountType Owner [32]byte BumpSeed uint8 AccessPassTypeTag AccessPassTypeTag - AssociatedPubkey [32]byte // for SolanaValidator, SolanaRPC - OthersTypeName string // for Others variant - OthersKey string // for Others variant + AssociatedPubkey [32]byte // for SolanaValidator, SolanaRPC + OthersTypeName string // for Others variant + OthersKey string // for Others variant + FeedSeats []FeedSeat // for EdgeSeat variant ClientIp [4]uint8 UserPayer [32]byte LastAccessEpoch uint64 @@ -1401,3 +1412,22 @@ type TopologyInfo struct { ReferenceCount uint32 PubKey [32]byte } + +// FeedMetro maps an exchange (metro) pubkey to the multicast groups joinable from it. +type FeedMetro struct { + Exchange [32]byte + Groups [][32]byte +} + +// Feed is a serviceability catalog entry: the metro(exchange) → group-set map for one SKU. +// A Feed with an empty Metros slice imposes no metro restriction. +type Feed struct { + AccountType AccountType + Owner [32]byte + BumpSeed uint8 + Code string + Name string + ReferenceCount uint32 + Metros []FeedMetro + PubKey [32]byte +}