diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index fc1489875..28543cd4c 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 3f06e6cc6..db1a027ef 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 c920275d2..22e49a811 100644 Binary files a/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.bin and b/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.bin differ diff --git a/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.json b/sdk/serviceability/testdata/fixtures/access_pass_edge_seat.json index c4ed2fc20..1a560ed8b 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 000000000..e4033e78c Binary files /dev/null and b/sdk/serviceability/testdata/fixtures/feed.bin differ diff --git a/sdk/serviceability/testdata/fixtures/feed.json b/sdk/serviceability/testdata/fixtures/feed.json new file mode 100644 index 000000000..ac34f381a --- /dev/null +++ b/sdk/serviceability/testdata/fixtures/feed.json @@ -0,0 +1,76 @@ +{ + "name": "Feed", + "account_type": 18, + "fields": [ + { + "name": "AccountType", + "value": "18", + "typ": "u8" + }, + { + "name": "Owner", + "value": "G5QKowXuCHtvVwwEx2f1YZxCyBZf9op5oJ5ukgVvTD6F", + "typ": "pubkey" + }, + { + "name": "BumpSeed", + "value": "239", + "typ": "u8" + }, + { + "name": "Code", + "value": "shreds", + "typ": "string" + }, + { + "name": "Name", + "value": "Shreds", + "typ": "string" + }, + { + "name": "ReferenceCount", + "value": "4", + "typ": "u32" + }, + { + "name": "MetrosLen", + "value": "2", + "typ": "u32" + }, + { + "name": "Metro0Exchange", + "value": "G9JjTSFz68Pdue4DTXvTSVhSEC8RrkuzPAPoFsovB1kb", + "typ": "pubkey" + }, + { + "name": "Metro0GroupsLen", + "value": "2", + "typ": "u32" + }, + { + "name": "Metro0Group0", + "value": "GDD96vz4yxtMKLBBy3BuLRSfVChCZi1ty2hgm57utpQw", + "typ": "pubkey" + }, + { + "name": "Metro0Group1", + "value": "GH7YkRi9soP4j2JAUYTMEMBtkDFyGf7oYu1aGGRucd5H", + "typ": "pubkey" + }, + { + "name": "Metro1Exchange", + "value": "GM1xPvSEmdsn8iR8z3io8Gw81DpjycDi8mKTmTjuLRjd", + "typ": "pubkey" + }, + { + "name": "Metro1GroupsLen", + "value": "1", + "typ": "u32" + }, + { + "name": "Metro1Group0", + "value": "GQvN3RAKfUNVYQY7VYzF2CgMGEPWgZKciddMGf3u4EPy", + "typ": "pubkey" + } + ] +} \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock b/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock index 1ed0b3edb..09649e507 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock @@ -346,7 +346,7 @@ dependencies = [ [[package]] name = "doublezero-program-common" -version = "0.26.0" +version = "0.28.0" dependencies = [ "borsh 1.6.0", "byteorder", @@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "doublezero-serviceability" -version = "0.26.0" +version = "0.28.0" dependencies = [ "bitflags", "borsh 1.6.0", diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 5858da122..6f9c1fd6e 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -22,9 +22,10 @@ use doublezero_serviceability::processors::user::{ }; use doublezero_serviceability::programversion::ProgramVersion; use doublezero_serviceability::state::{ - accesspass::{AccessPass, AccessPassStatus, AccessPassType}, + accesspass::{AccessPass, AccessPassStatus, AccessPassType, FeedSeat}, accounttype::AccountType, contributor::{Contributor, ContributorStatus}, + feed::Feed, device::{Device, DeviceDesiredStatus, DeviceHealth, DeviceStatus, DeviceType}, exchange::{Exchange, ExchangeStatus}, globalconfig::GlobalConfig, @@ -96,6 +97,7 @@ fn main() { generate_access_pass(&fixtures_dir); generate_access_pass_validator(&fixtures_dir); generate_access_pass_edge_seat(&fixtures_dir); + generate_feed(&fixtures_dir); generate_tenant(&fixtures_dir); generate_resource_extension_id(&fixtures_dir); generate_resource_extension_ip(&fixtures_dir); @@ -1190,13 +1192,19 @@ fn generate_access_pass_validator(dir: &Path) { fn generate_access_pass_edge_seat(dir: &Path) { let owner = pubkey_from_byte(0xB0); let user_payer = pubkey_from_byte(0xB1); + let feed_key = pubkey_from_byte(0xB2); // EdgeSeat pass with allow_multiple_ip set and per-category caps populated. + // The EdgeSeat carries one FeedSeat (feed_key + max_users + current_users). let val = AccessPass { account_type: AccountType::AccessPass, owner, bump_seed: 242, - accesspass_type: AccessPassType::EdgeSeat, + accesspass_type: AccessPassType::EdgeSeat(vec![FeedSeat { + feed_key, + max_users: 7, + current_users: 3, + }]), client_ip: Ipv4Addr::UNSPECIFIED, user_payer, last_access_epoch: u64::MAX, @@ -1222,6 +1230,10 @@ fn generate_access_pass_edge_seat(dir: &Path) { FieldValue { name: "Owner".into(), value: pubkey_bs58(&owner), typ: "pubkey".into() }, FieldValue { name: "BumpSeed".into(), value: "242".into(), typ: "u8".into() }, FieldValue { name: "AccessPassType".into(), value: "4".into(), typ: "u8".into() }, + FieldValue { name: "EdgeSeatFeedSeatsLen".into(), value: "1".into(), typ: "u32".into() }, + FieldValue { name: "EdgeSeatFeedSeat0FeedKey".into(), value: pubkey_bs58(&feed_key), typ: "pubkey".into() }, + FieldValue { name: "EdgeSeatFeedSeat0MaxUsers".into(), value: "7".into(), typ: "u16".into() }, + FieldValue { name: "EdgeSeatFeedSeat0CurrentUsers".into(), value: "3".into(), typ: "u16".into() }, FieldValue { name: "ClientIp".into(), value: "0.0.0.0".into(), typ: "ipv4".into() }, FieldValue { name: "UserPayer".into(), value: pubkey_bs58(&user_payer), typ: "pubkey".into() }, FieldValue { name: "LastAccessEpoch".into(), value: "18446744073709551615".into(), typ: "u64".into() }, @@ -1241,6 +1253,56 @@ fn generate_access_pass_edge_seat(dir: &Path) { write_fixture(dir, "access_pass_edge_seat", &data, &meta); } +/// Borsh-encoded `Feed` account. Field order: account_type, owner, bump_seed, code, name, +/// reference_count, metros (Vec<(Pubkey, Vec)>). 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 f769e0761..4207c493a 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 e19cd72cb..fcc516f43 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 b997e668c..c798d7c74 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 ba9a4ad54..b6d933ecb 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 ae8a67d2b..6c6334cfd 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 01078a5a4..4518ff7a7 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 e94ecbad7..f1dac9fc2 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 +}