diff --git a/CHANGELOG.md b/CHANGELOG.md index 0622f6f35..9b59ec3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to this project will be documented in this file. - SDK - revdist Python SDK migrated to the async solana-py RPC API (solana-py 0.40.0 removed the sync `Client`). The `Client` read methods (`fetch_config`, `fetch_distribution`, etc.) are now coroutines and must be awaited; `new_rpc_client` returns an `AsyncClient`. (#3945) +- Serviceability + - The `AccessPass` `EdgeSeat` variant now carries a `Vec` payload (`feed_key` + per-feed cap) instead of being a bare marker. This changes the `AccessPass` borsh layout for EdgeSeat passes. (#1700) + +### Added + +- Serviceability + - `Feed` account: a catalog mapping `metro(exchange) → group-set`, managed by a catalog admin (`FEED_AUTHORITY` Permission or `FOUNDATION`) via `CreateFeed`/`UpdateFeed`/`DeleteFeed`. A feed with no metros imposes no restriction. (#1700) + - `SetAccessPassFeeds` provisions feed_keys (SKU seats) onto an EdgeSeat pass; the oracle calls it via its `ACCESS_PASS_ADMIN` Permission. (#1700) + - EdgeSeat multicast connect is metro-gated: a device whose exchange is not covered by any of the pass's feeds is rejected with `MetroMismatch`, and the matching feed's per-feed cap is enforced. (#1700) ### Changes 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/cli/src/accesspass/list.rs b/smartcontract/cli/src/accesspass/list.rs index ca93e7022..1a38cf568 100644 --- a/smartcontract/cli/src/accesspass/list.rs +++ b/smartcontract/cli/src/accesspass/list.rs @@ -141,7 +141,9 @@ fn accesspass_type_short(t: &AccessPassType) -> String { crate::util::abbreviate_prefix(key) ) } - AccessPassType::Prepaid | AccessPassType::EdgeSeat => t.to_string(), + // Compact form: the discriminant only (feed details are shown in the get/JSON views). + AccessPassType::EdgeSeat(_) => t.to_discriminant_string(), + AccessPassType::Prepaid => t.to_string(), } } @@ -212,7 +214,7 @@ impl ListAccessPassCliCommand { // Filter access passes by EdgeSeat type if self.edge_seat { access_passes.retain(|(_, access_pass)| { - matches!(access_pass.accesspass_type, AccessPassType::EdgeSeat) + matches!(access_pass.accesspass_type, AccessPassType::EdgeSeat(_)) }); } // Filter access passes by client IP @@ -640,7 +642,7 @@ mod tests { "prepaid" ); assert_eq!( - super::accesspass_type_short(&AccessPassType::EdgeSeat), + super::accesspass_type_short(&AccessPassType::EdgeSeat(vec![])), "edge_seat" ); // Others: type_name kept, embedded key truncated to a copyable prefix. diff --git a/smartcontract/cli/src/accesspass/set.rs b/smartcontract/cli/src/accesspass/set.rs index 9c24e71c5..4ceeeba63 100644 --- a/smartcontract/cli/src/accesspass/set.rs +++ b/smartcontract/cli/src/accesspass/set.rs @@ -100,7 +100,7 @@ impl SetAccessPassCliCommand { "Others access pass type requires --others-name and --others-key " ), }, - CliAccessPassType::EdgeSeat => AccessPassType::EdgeSeat, + CliAccessPassType::EdgeSeat => AccessPassType::EdgeSeat(vec![]), }; // Convert tenant code to PDA if provided @@ -941,7 +941,7 @@ mod tests { client .expect_set_accesspass() .with(predicate::eq(SetAccessPassCommand { - accesspass_type: AccessPassType::EdgeSeat, + accesspass_type: AccessPassType::EdgeSeat(vec![]), client_ip, user_payer: payer, last_access_epoch: 11, diff --git a/smartcontract/cli/src/cli/command.rs b/smartcontract/cli/src/cli/command.rs index ec845049c..24b52c451 100644 --- a/smartcontract/cli/src/cli/command.rs +++ b/smartcontract/cli/src/cli/command.rs @@ -20,6 +20,7 @@ use crate::{ contributor::{ContributorCliCommand, ContributorCommands}, device::{DeviceCliCommand, DeviceCommands, InterfaceCommands}, exchange::{ExchangeCliCommand, ExchangeCommands}, + feed::{FeedCliCommand, FeedCommands}, globalconfig::{ AirdropCommands, AuthorityCommands, FeatureFlagsCommands, FoundationAllowlistCommands, GlobalConfigCliCommand, GlobalConfigCommands, QaAllowlistCommands, @@ -62,6 +63,8 @@ pub enum ServiceabilityCommand { Location(LocationCliCommand), /// Manage exchanges Exchange(ExchangeCliCommand), + /// Manage feeds (metro→group-set catalog) + Feed(FeedCliCommand), /// Manage contributors Contributor(ContributorCliCommand), /// Manage permissions @@ -170,6 +173,13 @@ impl ServiceabilityCommand { ExchangeCommands::Get(args) => args.execute(ctx, client, out).await, ExchangeCommands::Delete(args) => args.execute(ctx, client, out).await, }, + Self::Feed(cmd) => match cmd.command { + FeedCommands::Create(args) => args.execute(ctx, client, out).await, + FeedCommands::Update(args) => args.execute(ctx, client, out).await, + FeedCommands::List(args) => args.execute(ctx, client, out).await, + FeedCommands::Get(args) => args.execute(ctx, client, out).await, + FeedCommands::Delete(args) => args.execute(ctx, client, out).await, + }, Self::Contributor(cmd) => match cmd.command { ContributorCommands::Create(args) => args.execute(ctx, client, out).await, ContributorCommands::Update(args) => args.execute(ctx, client, out).await, diff --git a/smartcontract/cli/src/cli/feed.rs b/smartcontract/cli/src/cli/feed.rs new file mode 100644 index 000000000..0bc7dde8a --- /dev/null +++ b/smartcontract/cli/src/cli/feed.rs @@ -0,0 +1,28 @@ +use clap::{Args, Subcommand}; + +use crate::feed::{create::*, delete::*, get::*, list::*, update::*}; + +#[derive(Args, Debug)] +pub struct FeedCliCommand { + #[command(subcommand)] + pub command: FeedCommands, +} + +#[derive(Debug, Subcommand)] +pub enum FeedCommands { + /// Create a new feed (catalog entry) + #[clap()] + Create(CreateFeedCliCommand), + /// Update a feed's name or metro map + #[clap()] + Update(UpdateFeedCliCommand), + /// List all feeds + #[clap()] + List(ListFeedCliCommand), + /// Get details for a specific feed + #[clap()] + Get(GetFeedCliCommand), + /// Delete a feed (must have no references) + #[clap()] + Delete(DeleteFeedCliCommand), +} diff --git a/smartcontract/cli/src/cli/mod.rs b/smartcontract/cli/src/cli/mod.rs index c8ecf4d31..666e659a2 100644 --- a/smartcontract/cli/src/cli/mod.rs +++ b/smartcontract/cli/src/cli/mod.rs @@ -15,6 +15,7 @@ pub use command::ServiceabilityCommand; pub mod contributor; pub mod device; pub mod exchange; +pub mod feed; pub mod globalconfig; pub mod link; pub mod location; diff --git a/smartcontract/cli/src/doublezerocommand.rs b/smartcontract/cli/src/doublezerocommand.rs index 78894668d..4ddb1e37e 100644 --- a/smartcontract/cli/src/doublezerocommand.rs +++ b/smartcontract/cli/src/doublezerocommand.rs @@ -38,6 +38,10 @@ use doublezero_sdk::{ list::ListExchangeCommand, setdevice::SetDeviceExchangeCommand, update::UpdateExchangeCommand, }, + feed::{ + create::CreateFeedCommand, delete::DeleteFeedCommand, get::GetFeedCommand, + list::ListFeedCommand, update::UpdateFeedCommand, + }, globalconfig::set::SetGlobalConfigCommand, globalstate::{ init::InitGlobalStateCommand, setairdrop::SetAirdropCommand, @@ -106,7 +110,7 @@ use doublezero_sdk::{ }, }, telemetry::LinkLatencyStats, - DZClient, DZTransaction, Device, DoubleZeroClient, Exchange, GetGlobalConfigCommand, + DZClient, DZTransaction, Device, DoubleZeroClient, Exchange, Feed, GetGlobalConfigCommand, GetGlobalStateCommand, GlobalConfig, GlobalState, Link, Location, MulticastGroup, ResourceExtensionOwned, TopologyInfo, User, }; @@ -181,6 +185,12 @@ pub trait CliCommand { fn delete_exchange(&self, cmd: DeleteExchangeCommand) -> eyre::Result; fn setdevice_exchange(&self, cmd: SetDeviceExchangeCommand) -> eyre::Result; + fn create_feed(&self, cmd: CreateFeedCommand) -> eyre::Result<(Signature, Pubkey)>; + fn get_feed(&self, cmd: GetFeedCommand) -> eyre::Result<(Pubkey, Feed)>; + fn list_feed(&self, cmd: ListFeedCommand) -> eyre::Result>; + fn update_feed(&self, cmd: UpdateFeedCommand) -> eyre::Result; + fn delete_feed(&self, cmd: DeleteFeedCommand) -> eyre::Result; + fn create_contributor( &self, cmd: CreateContributorCommand, @@ -496,6 +506,21 @@ impl CliCommand for CliCommandImpl<'_> { fn setdevice_exchange(&self, cmd: SetDeviceExchangeCommand) -> eyre::Result { cmd.execute(self.client) } + fn create_feed(&self, cmd: CreateFeedCommand) -> eyre::Result<(Signature, Pubkey)> { + cmd.execute(self.client) + } + fn get_feed(&self, cmd: GetFeedCommand) -> eyre::Result<(Pubkey, Feed)> { + cmd.execute(self.client) + } + fn list_feed(&self, cmd: ListFeedCommand) -> eyre::Result> { + cmd.execute(self.client) + } + fn update_feed(&self, cmd: UpdateFeedCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn delete_feed(&self, cmd: DeleteFeedCommand) -> eyre::Result { + cmd.execute(self.client) + } fn create_contributor( &self, cmd: CreateContributorCommand, diff --git a/smartcontract/cli/src/feed/create.rs b/smartcontract/cli/src/feed/create.rs new file mode 100644 index 000000000..a138ac909 --- /dev/null +++ b/smartcontract/cli/src/feed/create.rs @@ -0,0 +1,42 @@ +use crate::{doublezerocommand::CliCommand, feed::parse_metro, validators::validate_code}; +use clap::Args; +use doublezero_cli_core::{print_signature, require, CliContext, RequirementCheck}; +use doublezero_sdk::commands::feed::create::CreateFeedCommand; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct CreateFeedCliCommand { + /// Unique code for the feed (immutable; used as the PDA seed) + #[arg(long, value_parser = validate_code)] + pub code: String, + /// Human-readable name for the feed + #[arg(long)] + pub name: String, + /// Metro mapping `EXCHANGE_PK=GROUP_PK[,GROUP_PK...]` (repeatable). Omit for a feed with no + /// metro restriction (reachable from any exchange). + #[arg(long = "metro", value_parser = parse_metro)] + pub metros: Vec<(Pubkey, Vec)>, +} + +impl CreateFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + require!( + client, + RequirementCheck::KEYPAIR | RequirementCheck::BALANCE + ); + + let (signature, _pubkey) = client.create_feed(CreateFeedCommand { + code: self.code, + name: self.name, + metros: self.metros, + })?; + + print_signature(out, &signature) + } +} diff --git a/smartcontract/cli/src/feed/delete.rs b/smartcontract/cli/src/feed/delete.rs new file mode 100644 index 000000000..502ede7dd --- /dev/null +++ b/smartcontract/cli/src/feed/delete.rs @@ -0,0 +1,33 @@ +use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; +use clap::Args; +use doublezero_cli_core::{print_signature, require, CliContext, RequirementCheck}; +use doublezero_sdk::commands::feed::{delete::DeleteFeedCommand, get::GetFeedCommand}; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct DeleteFeedCliCommand { + /// Feed pubkey or code to delete + #[arg(long, value_parser = validate_pubkey_or_code)] + pub pubkey: String, +} + +impl DeleteFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + require!( + client, + RequirementCheck::KEYPAIR | RequirementCheck::BALANCE + ); + + let (pubkey, _feed) = client.get_feed(GetFeedCommand { + pubkey_or_code: self.pubkey, + })?; + + let signature = client.delete_feed(DeleteFeedCommand { pubkey })?; + print_signature(out, &signature) + } +} diff --git a/smartcontract/cli/src/feed/get.rs b/smartcontract/cli/src/feed/get.rs new file mode 100644 index 000000000..f8dd453f8 --- /dev/null +++ b/smartcontract/cli/src/feed/get.rs @@ -0,0 +1,52 @@ +use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; +use clap::Args; +use doublezero_cli_core::{render_record, CliContext, OutputFormat}; +use doublezero_sdk::commands::feed::get::GetFeedCommand; +use serde::Serialize; +use std::io::Write; +use tabled::Tabled; + +#[derive(Args, Debug)] +pub struct GetFeedCliCommand { + /// Feed pubkey or code to get details for + #[arg(long, value_parser = validate_pubkey_or_code)] + pub pubkey: String, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Tabled, Serialize)] +struct FeedDisplay { + pub account: String, + pub code: String, + pub name: String, + /// Number of metros (exchanges) in the feed map. Empty ⇒ no metro restriction. + pub metros: usize, + pub reference_count: u32, + pub owner: String, +} + +impl GetFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + let (pubkey, feed) = client.get_feed(GetFeedCommand { + pubkey_or_code: self.pubkey, + })?; + + let display = FeedDisplay { + account: pubkey.to_string(), + code: feed.code, + name: feed.name, + metros: feed.metros.len(), + reference_count: feed.reference_count, + owner: feed.owner.to_string(), + }; + + render_record(out, &display, OutputFormat::from_flags(self.json, false)) + } +} diff --git a/smartcontract/cli/src/feed/list.rs b/smartcontract/cli/src/feed/list.rs new file mode 100644 index 000000000..0b041a69e --- /dev/null +++ b/smartcontract/cli/src/feed/list.rs @@ -0,0 +1,62 @@ +use crate::doublezerocommand::CliCommand; +use clap::Args; +use doublezero_cli_core::{render_collection, CliContext, OutputFormat}; +use doublezero_program_common::serializer; +use doublezero_sdk::commands::feed::list::ListFeedCommand; +use serde::Serialize; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; +use tabled::Tabled; + +#[derive(Args, Debug)] +pub struct ListFeedCliCommand { + /// Output in JSON format + #[arg(long, default_value_t = false)] + pub json: bool, + /// Output in compact JSON format + #[arg(long, default_value_t = false)] + pub json_compact: bool, +} + +#[derive(Tabled, Serialize)] +pub struct FeedDisplay { + #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] + pub account: Pubkey, + pub code: String, + pub name: String, + pub metros: usize, + pub reference_count: u32, + #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] + pub owner: Pubkey, +} + +impl ListFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + let feeds = client.list_feed(ListFeedCommand)?; + + let mut displays: Vec = feeds + .into_iter() + .map(|(pubkey, feed)| FeedDisplay { + account: pubkey, + code: feed.code, + name: feed.name, + metros: feed.metros.len(), + reference_count: feed.reference_count, + owner: feed.owner, + }) + .collect(); + + displays.sort_by(|a, b| a.code.cmp(&b.code)); + + render_collection( + out, + displays, + OutputFormat::from_flags(self.json, self.json_compact), + ) + } +} diff --git a/smartcontract/cli/src/feed/mod.rs b/smartcontract/cli/src/feed/mod.rs new file mode 100644 index 000000000..b0edab372 --- /dev/null +++ b/smartcontract/cli/src/feed/mod.rs @@ -0,0 +1,49 @@ +pub mod create; +pub mod delete; +pub mod get; +pub mod list; +pub mod update; + +use solana_sdk::pubkey::Pubkey; +use std::str::FromStr; + +/// Parse a `--metro` argument of the form `EXCHANGE_PK=GROUP_PK[,GROUP_PK...]` into +/// `(exchange_pk, [group_pk, ...])`. An empty group list (`EXCHANGE_PK=`) is allowed. +pub fn parse_metro(s: &str) -> Result<(Pubkey, Vec), String> { + let (exchange, groups) = s + .split_once('=') + .ok_or_else(|| format!("expected EXCHANGE_PK=GROUP_PK[,GROUP_PK...], got '{s}'"))?; + let exchange_pk = + Pubkey::from_str(exchange.trim()).map_err(|e| format!("invalid exchange pubkey: {e}"))?; + let group_pks = groups + .split(',') + .map(str::trim) + .filter(|g| !g.is_empty()) + .map(|g| Pubkey::from_str(g).map_err(|e| format!("invalid group pubkey '{g}': {e}"))) + .collect::, _>>()?; + Ok((exchange_pk, group_pks)) +} + +#[cfg(test)] +mod tests { + use super::parse_metro; + use solana_sdk::pubkey::Pubkey; + + #[test] + fn test_parse_metro() { + let ex = Pubkey::new_unique(); + let g1 = Pubkey::new_unique(); + let g2 = Pubkey::new_unique(); + let (e, gs) = parse_metro(&format!("{ex}={g1},{g2}")).unwrap(); + assert_eq!(e, ex); + assert_eq!(gs, vec![g1, g2]); + + // No groups is allowed. + let (e, gs) = parse_metro(&format!("{ex}=")).unwrap(); + assert_eq!(e, ex); + assert!(gs.is_empty()); + + assert!(parse_metro("not-a-pair").is_err()); + assert!(parse_metro("bad=alsoBad").is_err()); + } +} diff --git a/smartcontract/cli/src/feed/update.rs b/smartcontract/cli/src/feed/update.rs new file mode 100644 index 000000000..0c1125afc --- /dev/null +++ b/smartcontract/cli/src/feed/update.rs @@ -0,0 +1,55 @@ +use crate::{ + doublezerocommand::CliCommand, feed::parse_metro, validators::validate_pubkey_or_code, +}; +use clap::Args; +use doublezero_cli_core::{print_signature, require, CliContext, RequirementCheck}; +use doublezero_sdk::commands::feed::{get::GetFeedCommand, update::UpdateFeedCommand}; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct UpdateFeedCliCommand { + /// Feed pubkey or code to update + #[arg(long, value_parser = validate_pubkey_or_code)] + pub pubkey: String, + /// Updated name for the feed + #[arg(long)] + pub name: Option, + /// Replace the metro map with these `EXCHANGE_PK=GROUP_PK[,GROUP_PK...]` entries (repeatable). + /// When omitted, the metro map is left unchanged. + #[arg(long = "metro", value_parser = parse_metro)] + pub metros: Vec<(Pubkey, Vec)>, +} + +impl UpdateFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + require!( + client, + RequirementCheck::KEYPAIR | RequirementCheck::BALANCE + ); + + let (pubkey, _feed) = client.get_feed(GetFeedCommand { + pubkey_or_code: self.pubkey, + })?; + + // An empty `--metro` list leaves the map unchanged; pass Some only when entries are given. + let metros = if self.metros.is_empty() { + None + } else { + Some(self.metros) + }; + + let signature = client.update_feed(UpdateFeedCommand { + pubkey, + name: self.name, + metros, + })?; + + print_signature(out, &signature) + } +} diff --git a/smartcontract/cli/src/lib.rs b/smartcontract/cli/src/lib.rs index 534b896d5..9f18752d4 100644 --- a/smartcontract/cli/src/lib.rs +++ b/smartcontract/cli/src/lib.rs @@ -12,6 +12,7 @@ pub mod device; pub mod doublezerocommand; pub mod exchange; pub mod export; +pub mod feed; pub mod formatters; pub mod globalconfig; pub mod helpers; diff --git a/smartcontract/cli/src/tenant/delete.rs b/smartcontract/cli/src/tenant/delete.rs index fc0b89cbc..e7efcc12d 100644 --- a/smartcontract/cli/src/tenant/delete.rs +++ b/smartcontract/cli/src/tenant/delete.rs @@ -71,7 +71,10 @@ impl DeleteTenantCliCommand { for user_pk in &tenant_users { spinner.set_message(format!("Deleting user {user_pk}")); - client.delete_user(DeleteUserCommand { pubkey: *user_pk })?; + client.delete_user(DeleteUserCommand { + pubkey: *user_pk, + feed_pk: None, + })?; spinner.inc(1); } diff --git a/smartcontract/cli/src/user/create_subscribe.rs b/smartcontract/cli/src/user/create_subscribe.rs index 541e41f54..b19c3cfae 100644 --- a/smartcontract/cli/src/user/create_subscribe.rs +++ b/smartcontract/cli/src/user/create_subscribe.rs @@ -111,6 +111,7 @@ impl CreateSubscribeUserCliCommand { .ok_or(eyre::eyre!("Subscriber is required if publisher is not"))?, tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: owner_pk, + feed_pk: None, })?; writeln!(out, "Signature: {signature}",)?; @@ -234,6 +235,7 @@ mod tests { mgroup_pk: mgroup_pubkey, tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: None, + feed_pk: None, })) .times(1) .returning(move |_| Ok((signature, pda_pubkey))); diff --git a/smartcontract/cli/src/user/delete.rs b/smartcontract/cli/src/user/delete.rs index 16fcb358c..a16ce1b2a 100644 --- a/smartcontract/cli/src/user/delete.rs +++ b/smartcontract/cli/src/user/delete.rs @@ -27,7 +27,10 @@ impl DeleteUserCliCommand { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; let pubkey = Pubkey::from_str(&self.pubkey)?; - let signature = client.delete_user(DeleteUserCommand { pubkey })?; + let signature = client.delete_user(DeleteUserCommand { + pubkey, + feed_pk: None, + })?; writeln!(out, "Signature: {signature}",)?; Ok(()) @@ -100,7 +103,10 @@ mod tests { client .expect_delete_user() - .with(predicate::eq(DeleteUserCommand { pubkey: pda_pubkey })) + .with(predicate::eq(DeleteUserCommand { + pubkey: pda_pubkey, + feed_pk: None, + })) .returning(move |_| Ok(signature)); /*****************************************************************************************************/ diff --git a/smartcontract/cli/src/user/get.rs b/smartcontract/cli/src/user/get.rs index 0373d2ab9..fc9b1d522 100644 --- a/smartcontract/cli/src/user/get.rs +++ b/smartcontract/cli/src/user/get.rs @@ -359,7 +359,10 @@ mod tests { client .expect_delete_user() - .with(predicate::eq(DeleteUserCommand { pubkey: pda_pubkey })) + .with(predicate::eq(DeleteUserCommand { + pubkey: pda_pubkey, + feed_pk: None, + })) .returning(move |_| Ok(signature)); // Expected success (table) diff --git a/smartcontract/cli/src/user/request_ban.rs b/smartcontract/cli/src/user/request_ban.rs index 6b8d42712..825e3bb2f 100644 --- a/smartcontract/cli/src/user/request_ban.rs +++ b/smartcontract/cli/src/user/request_ban.rs @@ -107,7 +107,10 @@ mod tests { client .expect_delete_user() - .with(predicate::eq(DeleteUserCommand { pubkey: pda_pubkey })) + .with(predicate::eq(DeleteUserCommand { + pubkey: pda_pubkey, + feed_pk: None, + })) .returning(move |_| Ok(signature)); client .expect_list_foundation_allowlist() diff --git a/smartcontract/cli/src/user/subscribe.rs b/smartcontract/cli/src/user/subscribe.rs index 59fd365ba..52858b8af 100644 --- a/smartcontract/cli/src/user/subscribe.rs +++ b/smartcontract/cli/src/user/subscribe.rs @@ -99,6 +99,8 @@ impl SubscribeUserCliCommand { client_ip: user.client_ip, publisher, subscriber, + device_pk: None, + feed_pk: None, })?; writeln!(out, "Updated roles for {group_pk}: {signature}")?; } @@ -218,6 +220,8 @@ mod tests { client_ip, publisher: false, subscriber: true, + device_pk: None, + feed_pk: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -436,6 +440,8 @@ mod tests { client_ip, publisher: false, subscriber: true, + device_pk: None, + feed_pk: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -535,6 +541,8 @@ mod tests { client_ip, publisher: true, subscriber: false, + device_pk: None, + feed_pk: None, })) .times(1) .returning(move |_| Ok(signature)); diff --git a/smartcontract/cli/src/user/update.rs b/smartcontract/cli/src/user/update.rs index bc2c32984..80a6edf3e 100644 --- a/smartcontract/cli/src/user/update.rs +++ b/smartcontract/cli/src/user/update.rs @@ -151,7 +151,10 @@ mod tests { client .expect_delete_user() - .with(predicate::eq(DeleteUserCommand { pubkey: pda_pubkey })) + .with(predicate::eq(DeleteUserCommand { + pubkey: pda_pubkey, + feed_pk: None, + })) .returning(move |_| Ok(signature)); client .expect_update_user() diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index 616171a1a..2dce8c557 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -4,7 +4,7 @@ use crate::{ processors::{ accesspass::{ check_status::process_check_status_access_pass, close::process_close_access_pass, - set::process_set_access_pass, + set::process_set_access_pass, set_feeds::process_set_access_pass_feeds, }, allowlist::{ foundation::{ @@ -36,6 +36,9 @@ use crate::{ resume::process_resume_exchange, setdevice::process_setdevice_exchange, suspend::process_suspend_exchange, update::process_update_exchange, }, + feed::{ + create::process_create_feed, delete::process_delete_feed, update::process_update_feed, + }, globalconfig::set::process_set_globalconfig, globalstate::{ initialize::initialize_global_state, setairdrop::process_set_airdrop, @@ -408,6 +411,18 @@ pub fn process_instruction( process_assign_topology_node_segments(program_id, accounts, &value)? } DoubleZeroInstruction::Deprecated111() => (), + DoubleZeroInstruction::CreateFeed(value) => { + process_create_feed(program_id, accounts, &value)? + } + DoubleZeroInstruction::UpdateFeed(value) => { + process_update_feed(program_id, accounts, &value)? + } + DoubleZeroInstruction::DeleteFeed(value) => { + process_delete_feed(program_id, accounts, &value)? + } + DoubleZeroInstruction::SetAccessPassFeeds(value) => { + process_set_access_pass_feeds(program_id, accounts, &value)? + } }; Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/error.rs b/smartcontract/programs/doublezero-serviceability/src/error.rs index 48987e23c..ae090b785 100644 --- a/smartcontract/programs/doublezero-serviceability/src/error.rs +++ b/smartcontract/programs/doublezero-serviceability/src/error.rs @@ -186,6 +186,8 @@ pub enum DoubleZeroError { AccessPassMaxUnicastUsersExceeded, // variant 89 #[error("Access pass max multicast users exceeded")] AccessPassMaxMulticastUsersExceeded, // variant 90 + #[error("Device exchange (metro) is not covered by any feed on the access pass")] + MetroMismatch, // variant 91 } impl From for ProgramError { @@ -282,6 +284,7 @@ impl From for ProgramError { DoubleZeroError::InvalidDeviceTunnelBlock => ProgramError::Custom(88), DoubleZeroError::AccessPassMaxUnicastUsersExceeded => ProgramError::Custom(89), DoubleZeroError::AccessPassMaxMulticastUsersExceeded => ProgramError::Custom(90), + DoubleZeroError::MetroMismatch => ProgramError::Custom(91), } } } @@ -379,6 +382,7 @@ impl From for DoubleZeroError { 88 => DoubleZeroError::InvalidDeviceTunnelBlock, 89 => DoubleZeroError::AccessPassMaxUnicastUsersExceeded, 90 => DoubleZeroError::AccessPassMaxMulticastUsersExceeded, + 91 => DoubleZeroError::MetroMismatch, _ => DoubleZeroError::Custom(e), } } diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 3aa763d67..b562da609 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -1,6 +1,7 @@ use crate::processors::{ accesspass::{ - check_status::CheckStatusAccessPassArgs, close::CloseAccessPassArgs, set::SetAccessPassArgs, + check_status::CheckStatusAccessPassArgs, close::CloseAccessPassArgs, + set::SetAccessPassArgs, set_feeds::SetAccessPassFeedsArgs, }, allowlist::{ foundation::{add::AddFoundationAllowlistArgs, remove::RemoveFoundationAllowlistArgs}, @@ -25,6 +26,7 @@ use crate::processors::{ create::ExchangeCreateArgs, delete::ExchangeDeleteArgs, resume::ExchangeResumeArgs, setdevice::ExchangeSetDeviceArgs, suspend::ExchangeSuspendArgs, update::ExchangeUpdateArgs, }, + feed::{create::FeedCreateArgs, delete::FeedDeleteArgs, update::FeedUpdateArgs}, globalconfig::set::SetGlobalConfigArgs, globalstate::{ setairdrop::SetAirdropArgs, setauthority::SetAuthorityArgs, @@ -244,6 +246,11 @@ pub enum DoubleZeroInstruction { AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs), // variant 110 Deprecated111(), // variant 111, (was MigrateDeviceInterfaces) + + CreateFeed(FeedCreateArgs), // variant 112 + UpdateFeed(FeedUpdateArgs), // variant 113 + DeleteFeed(FeedDeleteArgs), // variant 114 + SetAccessPassFeeds(SetAccessPassFeedsArgs), // variant 115 } impl DoubleZeroInstruction { @@ -386,6 +393,11 @@ impl DoubleZeroInstruction { 110 => Ok(Self::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs::try_from(rest).unwrap())), 111 => Ok(Self::Deprecated111()), + 112 => Ok(Self::CreateFeed(FeedCreateArgs::try_from(rest).unwrap())), + 113 => Ok(Self::UpdateFeed(FeedUpdateArgs::try_from(rest).unwrap())), + 114 => Ok(Self::DeleteFeed(FeedDeleteArgs::try_from(rest).unwrap())), + 115 => Ok(Self::SetAccessPassFeeds(SetAccessPassFeedsArgs::try_from(rest).unwrap())), + _ => Err(ProgramError::InvalidInstructionData), } } @@ -529,6 +541,11 @@ impl DoubleZeroInstruction { Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 Self::AssignTopologyNodeSegments(_) => "AssignTopologyNodeSegments".to_string(), // variant 110 Self::Deprecated111() => "Deprecated111".to_string(), // variant 111 + + Self::CreateFeed(_) => "CreateFeed".to_string(), // variant 112 + Self::UpdateFeed(_) => "UpdateFeed".to_string(), // variant 113 + Self::DeleteFeed(_) => "DeleteFeed".to_string(), // variant 114 + Self::SetAccessPassFeeds(_) => "SetAccessPassFeeds".to_string(), // variant 115 } } @@ -665,6 +682,11 @@ impl DoubleZeroInstruction { Self::ClearTopology(args) => format!("{args:?}"), // variant 109 Self::AssignTopologyNodeSegments(args) => format!("{args:?}"), // variant 110 Self::Deprecated111() => String::new(), // variant 111 + + Self::CreateFeed(args) => format!("{args:?}"), // variant 112 + Self::UpdateFeed(args) => format!("{args:?}"), // variant 113 + Self::DeleteFeed(args) => format!("{args:?}"), // variant 114 + Self::SetAccessPassFeeds(args) => format!("{args:?}"), // variant 115 } } } @@ -1341,5 +1363,40 @@ mod tests { }), "AssignTopologyNodeSegments", ); + test_instruction( + DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: "shreds".to_string(), + name: "Shreds".to_string(), + metros: vec![(Pubkey::new_unique(), vec![Pubkey::new_unique()])], + }), + "CreateFeed", + ); + test_instruction( + DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { + name: Some("Shreds".to_string()), + metros: Some(vec![(Pubkey::new_unique(), vec![Pubkey::new_unique()])]), + }), + "UpdateFeed", + ); + test_instruction( + DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {}), + "DeleteFeed", + ); + test_instruction( + DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: Ipv4Addr::UNSPECIFIED, + user_payer: Pubkey::new_unique(), + feeds: vec![doublezero_serviceability_feed_seat()], + }), + "SetAccessPassFeeds", + ); + } + + fn doublezero_serviceability_feed_seat() -> crate::state::accesspass::FeedSeat { + crate::state::accesspass::FeedSeat { + feed_key: Pubkey::new_unique(), + max_users: 4, + current_users: 0, + } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index 68e198268..d3adc1cd6 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -5,7 +5,7 @@ use solana_program::pubkey::Pubkey; use crate::{ seeds::{ SEED_ACCESS_PASS, SEED_ADMIN_GROUP_BITS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, - SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, + SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_FEED, SEED_GLOBALSTATE, SEED_INDEX, SEED_LINK, SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG, SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TOPOLOGY, SEED_TUNNEL_IDS, @@ -103,6 +103,12 @@ pub fn get_accesspass_pda( ) } +/// A Feed PDA is seeded by its human `code`, so the code is immutable (it is the key) and +/// `feed_key` == this PDA. No global index is consumed. +pub fn get_feed_pda(program_id: &Pubkey, code: &str) -> (Pubkey, u8) { + Pubkey::find_program_address(&[SEED_PREFIX, SEED_FEED, code.as_bytes()], program_id) +} + pub fn get_topology_pda(program_id: &Pubkey, name: &str) -> (Pubkey, u8) { let upper = name.to_ascii_uppercase(); Pubkey::find_program_address(&[SEED_PREFIX, SEED_TOPOLOGY, upper.as_bytes()], program_id) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/mod.rs index 18ec796b7..c16129cc8 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/mod.rs @@ -1,6 +1,7 @@ pub mod check_status; pub mod close; pub mod set; +pub mod set_feeds; use solana_program::{ account_info::AccountInfo, entrypoint::ProgramResult, msg, program::invoke_signed_unchecked, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set.rs b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set.rs index a92a21633..47d72eb90 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set.rs @@ -269,7 +269,17 @@ pub fn process_set_access_pass( // Update fields. The max caps are overwritten from args; the live counts are left // untouched so an in-flight pass keeps its current seat usage. - accesspass.accesspass_type = value.accesspass_type.clone(); + // + // EdgeSeat feed seats are owned by SetAccessPassFeeds (the oracle), not this instruction. + // SetAccessPassArgs carries no feed payload, so when both the stored and incoming types are + // EdgeSeat we preserve the provisioned seat vector instead of clobbering it (and its live + // current_users) with the incoming empty vec. + accesspass.accesspass_type = match (&accesspass.accesspass_type, &value.accesspass_type) { + (AccessPassType::EdgeSeat(existing), AccessPassType::EdgeSeat(_)) => { + AccessPassType::EdgeSeat(existing.clone()) + } + _ => value.accesspass_type.clone(), + }; accesspass.last_access_epoch = value.last_access_epoch; accesspass.flags = flags; accesspass.max_unicast_users = value.max_unicast_users; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs new file mode 100644 index 000000000..14ce24529 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs @@ -0,0 +1,148 @@ +use crate::{ + authorize::authorize, + error::DoubleZeroError, + pda::get_accesspass_pda, + serializer::try_acc_write, + state::{ + accesspass::{AccessPass, AccessPassType, FeedSeat}, + feed::Feed, + globalstate::GlobalState, + permission::permission_flags, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use core::fmt; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; +use std::net::Ipv4Addr; + +/// Provision the feed_keys (SKU seats) onto an EdgeSeat access pass. The provisioning actor is the +/// oracle, authorized via its `ACCESS_PASS_ADMIN` Permission — not the deprecated `feed_authority` +/// slot. `current_users` is preserved for feeds already present on the pass; caps come from the +/// caller (seeded from the coupon). +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone)] +pub struct SetAccessPassFeedsArgs { + #[incremental(default = Ipv4Addr::UNSPECIFIED)] + pub client_ip: Ipv4Addr, + pub user_payer: Pubkey, + pub feeds: Vec, +} + +impl fmt::Debug for SetAccessPassFeedsArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "client_ip: {}, user_payer: {}, feeds: {}", + self.client_ip, + self.user_payer, + self.feeds.len() + ) + } +} + +pub fn process_set_access_pass_feeds( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &SetAccessPassFeedsArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let accesspass_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let _system_program = next_account_info(accounts_iter)?; + + // One Feed account per requested seat, in the same order as `value.feeds`. + let mut feed_accounts = Vec::with_capacity(value.feeds.len()); + for _ in 0..value.feeds.len() { + feed_accounts.push(next_account_info(accounts_iter)?); + } + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!( + accesspass_account.owner, program_id, + "Invalid AccessPass Account Owner" + ); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert!( + accesspass_account.is_writable, + "AccessPass Account is not writable" + ); + + let (expected_pda, _) = get_accesspass_pda(program_id, &value.client_ip, &value.user_payer); + assert_eq!( + accesspass_account.key, &expected_pda, + "Invalid AccessPass PubKey" + ); + + // Provisioning actor authorized via ACCESS_PASS_ADMIN (Permission PDA or legacy fallback). + let globalstate = GlobalState::try_from(globalstate_account)?; + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::ACCESS_PASS_ADMIN, + )?; + + let mut accesspass = AccessPass::try_from(accesspass_account)?; + + // Only EdgeSeat passes carry feed seats. Require the pass to already be EdgeSeat (the oracle + // sets the type via SetAccessPass first) so this instruction can't silently convert a pass of + // another type (e.g. SolanaValidator) into an EdgeSeat. + if !matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_)) { + msg!("SetAccessPassFeeds requires an EdgeSeat access pass"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + let prior_seats = accesspass.feed_seats().to_vec(); + + // Validate each referenced Feed, preserve live counts, and bump reference_count for + // newly-referenced feeds. NOTE: feeds dropped from the pass are intentionally NOT decremented + // here — their accounts are not passed, and an over-count only makes a feed harder to delete + // (never unsafe), since reference_count solely guards DeleteFeed. + let mut new_seats = Vec::with_capacity(value.feeds.len()); + for (seat, feed_account) in value.feeds.iter().zip(feed_accounts.iter()) { + assert_eq!( + *feed_account.key, seat.feed_key, + "Feed account does not match seat feed_key" + ); + assert_eq!(feed_account.owner, program_id, "Invalid Feed Account Owner"); + let mut feed = Feed::try_from(*feed_account)?; + + let current_users = prior_seats + .iter() + .find(|s| s.feed_key == seat.feed_key) + .map(|s| s.current_users) + .unwrap_or(0); + + if !prior_seats.iter().any(|s| s.feed_key == seat.feed_key) { + assert!(feed_account.is_writable, "Feed Account is not writable"); + feed.reference_count = feed + .reference_count + .checked_add(1) + .ok_or(DoubleZeroError::InvalidIndex)?; + try_acc_write(&feed, feed_account, payer_account, accounts)?; + } + + new_seats.push(FeedSeat { + feed_key: seat.feed_key, + max_users: seat.max_users, + current_users, + }); + } + + accesspass.accesspass_type = AccessPassType::EdgeSeat(new_seats); + try_acc_write(&accesspass, accesspass_account, payer_account, accounts)?; + + msg!("Set {} feed(s) on access pass", value.feeds.len()); + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs new file mode 100644 index 000000000..9d9ae2e37 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs @@ -0,0 +1,107 @@ +use crate::{ + authorize::authorize, + error::DoubleZeroError, + pda::get_feed_pda, + seeds::{SEED_FEED, SEED_PREFIX}, + serializer::try_acc_create, + state::{ + accounttype::AccountType, feed::Feed, globalstate::GlobalState, + permission::permission_flags, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use core::fmt; +use doublezero_program_common::validate_account_code; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + program_error::ProgramError, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] +pub struct FeedCreateArgs { + pub code: String, + pub name: String, + /// `exchange_pk → group_pks`. Empty ⇒ no metro restriction. + pub metros: Vec<(Pubkey, Vec)>, +} + +impl fmt::Debug for FeedCreateArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "code: {}, name: {}, metros: {}", + self.code, + self.name, + self.metros.len() + ) + } +} + +pub fn process_create_feed( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &FeedCreateArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let feed_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let system_program = next_account_info(accounts_iter)?; + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert_eq!( + *system_program.unsigned_key(), + solana_system_interface::program::ID, + "Invalid System Program Account Owner" + ); + assert!(feed_account.is_writable, "PDA Account is not writable"); + + let code = + validate_account_code(&value.code).map_err(|_| DoubleZeroError::InvalidAccountCode)?; + + let (expected_pda, bump_seed) = get_feed_pda(program_id, &code); + assert_eq!(feed_account.key, &expected_pda, "Invalid Feed PubKey"); + + if !feed_account.data_is_empty() { + return Err(ProgramError::AccountAlreadyInitialized); + } + + // Catalog admin: FEED_AUTHORITY (Permission PDA) or FOUNDATION. + let globalstate = GlobalState::try_from(globalstate_account)?; + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::FEED_AUTHORITY | permission_flags::FOUNDATION, + )?; + + let feed = Feed { + account_type: AccountType::Feed, + owner: *payer_account.key, + bump_seed, + code: code.clone(), + name: value.name.clone(), + reference_count: 0, + metros: value.metros.clone(), + }; + + try_acc_create( + &feed, + feed_account, + payer_account, + system_program, + program_id, + &[SEED_PREFIX, SEED_FEED, code.as_bytes(), &[bump_seed]], + )?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs new file mode 100644 index 000000000..83e44c1af --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs @@ -0,0 +1,62 @@ +use crate::{ + authorize::authorize, + error::DoubleZeroError, + serializer::try_acc_close, + state::{feed::Feed, globalstate::GlobalState, permission::permission_flags}, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use core::fmt; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] +pub struct FeedDeleteArgs {} + +impl fmt::Debug for FeedDeleteArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "") + } +} + +pub fn process_delete_feed( + program_id: &Pubkey, + accounts: &[AccountInfo], + _value: &FeedDeleteArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let feed_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let _system_program = next_account_info(accounts_iter)?; + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!(feed_account.owner, program_id, "Invalid PDA Account Owner"); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert!(feed_account.is_writable, "PDA Account is not writable"); + + let globalstate = GlobalState::try_from(globalstate_account)?; + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::FEED_AUTHORITY | permission_flags::FOUNDATION, + )?; + + let feed = Feed::try_from(feed_account)?; + if feed.reference_count > 0 { + return Err(DoubleZeroError::ReferenceCountNotZero.into()); + } + + try_acc_close(feed_account, payer_account)?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs new file mode 100644 index 000000000..09d87e5a0 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs @@ -0,0 +1,97 @@ +pub mod create; +pub mod delete; +pub mod update; + +use crate::{ + error::DoubleZeroError, + state::{ + accesspass::AccessPass, + feed::{Feed, FeedMetroMatch}, + }, +}; +use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, msg, pubkey::Pubkey}; + +/// Validate the EdgeSeat feed metro gate without mutating the pass. +/// +/// For the device's `device_exchange`, the joinable groups are the matching feed's group set; a +/// device in an exchange not covered by any of the pass's feeds is rejected with `MetroMismatch`. A +/// feed with no metros imposes no restriction (reachable from any exchange, any group). +/// +/// `feed_account` must be the `Feed` referenced by one of the pass's seats. `target_mgroup` is the +/// multicast group being joined (None requires only metro coverage). +pub fn check_feed_metro_coverage( + program_id: &Pubkey, + accesspass: &AccessPass, + device_exchange: &Pubkey, + target_mgroup: Option<&Pubkey>, + feed_account: Option<&AccountInfo>, +) -> ProgramResult { + let feed_account = feed_account.ok_or(DoubleZeroError::MetroMismatch)?; + if feed_account.owner != program_id { + return Err(DoubleZeroError::InvalidAccountOwner.into()); + } + let feed = Feed::try_from(feed_account)?; + + // The feed must be one provisioned onto this pass. + if !accesspass + .feed_seats() + .iter() + .any(|s| s.feed_key == *feed_account.key) + { + msg!( + "Feed {} is not provisioned on the access pass", + feed_account.key + ); + return Err(DoubleZeroError::MetroMismatch.into()); + } + + match feed.groups_for(device_exchange) { + FeedMetroMatch::Unrestricted => {} + FeedMetroMatch::Groups(groups) => { + if let Some(group) = target_mgroup { + if !groups.contains(group) { + msg!( + "Group {} not joinable for exchange {} via feed {}", + group, + device_exchange, + feed_account.key + ); + return Err(DoubleZeroError::MetroMismatch.into()); + } + } + } + FeedMetroMatch::NotCovered => { + msg!( + "Device exchange {} not covered by feed {}", + device_exchange, + feed_account.key + ); + return Err(DoubleZeroError::MetroMismatch.into()); + } + } + + Ok(()) +} + +/// Enforce the EdgeSeat feed metro gate at connect and tick the matching feed seat against its cap. +/// Call only for EdgeSeat passes. See [`check_feed_metro_coverage`]. +pub fn enforce_feed_metro_gate( + program_id: &Pubkey, + accesspass: &mut AccessPass, + device_exchange: &Pubkey, + target_mgroup: Option<&Pubkey>, + feed_account: Option<&AccountInfo>, +) -> ProgramResult { + check_feed_metro_coverage( + program_id, + accesspass, + device_exchange, + target_mgroup, + feed_account, + )?; + // feed_account is guaranteed Some here (check returns Err otherwise). + if let Some(feed_account) = feed_account { + accesspass.try_add_feed_user(feed_account.key)?; + } + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs new file mode 100644 index 000000000..46ebe50b6 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs @@ -0,0 +1,73 @@ +use crate::{ + authorize::authorize, + serializer::try_acc_write, + state::{feed::Feed, globalstate::GlobalState, permission::permission_flags}, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use core::fmt; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + pubkey::Pubkey, +}; + +/// `code` is the PDA seed and therefore immutable; only `name` and the metro map are mutable. +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] +pub struct FeedUpdateArgs { + pub name: Option, + pub metros: Option)>>, +} + +impl fmt::Debug for FeedUpdateArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "name: {:?}, metros: {:?}", + self.name, + self.metros.as_ref().map(|m| m.len()) + ) + } +} + +pub fn process_update_feed( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &FeedUpdateArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let feed_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let _system_program = next_account_info(accounts_iter)?; + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!(feed_account.owner, program_id, "Invalid PDA Account Owner"); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert!(feed_account.is_writable, "PDA Account is not writable"); + + let globalstate = GlobalState::try_from(globalstate_account)?; + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::FEED_AUTHORITY | permission_flags::FOUNDATION, + )?; + + let mut feed = Feed::try_from(feed_account)?; + if let Some(ref name) = value.name { + feed.name = name.clone(); + } + if let Some(ref metros) = value.metros { + feed.metros = metros.clone(); + } + + try_acc_write(&feed, feed_account, payer_account, accounts)?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs index 4a4d4ec73..e02c03b93 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs @@ -3,6 +3,7 @@ pub mod allowlist; pub mod contributor; pub mod device; pub mod exchange; +pub mod feed; pub mod globalconfig; pub mod globalstate; pub mod index; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs index 61b3d251f..78ae6f581 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs @@ -3,13 +3,15 @@ use crate::{ error::DoubleZeroError, pda::{get_accesspass_pda, get_globalstate_pda, get_resource_extension_pda}, processors::{ + feed::check_feed_metro_coverage, resource::{allocate_ip, deallocate_ip}, validation::validate_program_account, }, resource::ResourceType, serializer::try_acc_write, state::{ - accesspass::AccessPass, + accesspass::{AccessPass, AccessPassType}, + device::Device, globalstate::GlobalState, multicastgroup::{MulticastGroup, MulticastGroupStatus}, permission::permission_flags, @@ -74,12 +76,16 @@ pub fn update_user_multicastgroup_roles( return Err(DoubleZeroError::InvalidStatus.into()); } - // Check allowlists for additions - if publisher && !accesspass.mgroup_pub_allowlist.contains(mgroup_account.key) { + // Check allowlists for additions. EdgeSeat passes derive joinable groups from their feeds' + // metro→group map (the feed metro gate), which supersedes the mgroup allowlist; the caller is + // responsible for running enforce_feed_metro_gate for EdgeSeat connects. + let is_edge_seat = matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_)); + if publisher && !is_edge_seat && !accesspass.mgroup_pub_allowlist.contains(mgroup_account.key) { msg!("{:?}", accesspass); return Err(DoubleZeroError::NotAllowed.into()); } - if subscriber && !accesspass.mgroup_sub_allowlist.contains(mgroup_account.key) { + if subscriber && !is_edge_seat && !accesspass.mgroup_sub_allowlist.contains(mgroup_account.key) + { msg!("{:?}", accesspass); return Err(DoubleZeroError::NotAllowed.into()); } @@ -238,6 +244,33 @@ pub fn process_update_multicastgroup_roles( } } + // Optional trailing accounts for the EdgeSeat feed metro gate: the user's device (for its + // exchange) and the Feed covering it. Read AFTER the authorize() above so they do not consume + // the optional trailing Permission account. + let device_account = accounts_iter.next(); + let feed_account = accounts_iter.next(); + + // EdgeSeat passes derive joinable groups from their feeds' metro→group map. The seat was + // ticked at connect (CreateSubscribeUser), so post-activation role adds only re-validate + // coverage; they do not re-tick. + if matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_)) + && (value.publisher || value.subscriber) + { + let device_account = device_account.ok_or(DoubleZeroError::MetroMismatch)?; + validate_program_account!(device_account, program_id, writable = false, "Device"); + if user.device_pk != *device_account.key { + return Err(ProgramError::InvalidAccountData); + } + let device = Device::try_from(device_account)?; + check_feed_metro_coverage( + program_id, + &accesspass, + &device.exchange_pk, + Some(mgroup_account.key), + feed_account, + )?; + } + let result = update_user_multicastgroup_roles( mgroup_account, &accesspass, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs index e8a159623..43ddf8bea 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs @@ -113,6 +113,9 @@ pub fn process_create_user( value.tunnel_endpoint, false, None, + // Plain CreateUser is unicast; no multicast group and no feed gate. + None, + None, )?; // Always allocate resources and activate atomically. diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs index 03ce9e92b..31291085f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs @@ -17,7 +17,10 @@ use solana_program::{ }; use std::net::Ipv4Addr; -use crate::{processors::validation::validate_program_account, serializer::try_acc_write}; +use crate::{ + processors::{feed::enforce_feed_metro_gate, validation::validate_program_account}, + serializer::try_acc_write, +}; #[derive(PartialEq)] pub enum PDAVersion { @@ -68,6 +71,10 @@ pub fn create_user_core( tunnel_endpoint: Ipv4Addr, is_publisher: bool, owner_override: Option, + // EdgeSeat multicast metro gate: the multicast group being joined (None for non-multicast + // connects) and the referenced Feed account covering the device's exchange. + target_mgroup: Option<&Pubkey>, + feed_account: Option<&AccountInfo>, ) -> Result { // Check if the payer is a signer assert!(core.payer_account.is_signer, "Payer must be a signer"); @@ -295,6 +302,21 @@ pub fn create_user_core( // returns before any account is written, so no state is persisted. accesspass.try_add_user(user_type)?; + // EdgeSeat multicast metro gate: the device's exchange must be covered by a feed on the pass, + // the target group must be joinable there, and that feed's seat is ticked. Unicast retains the + // per-category cap above and is not feed-gated. + if matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_)) + && user_type == UserType::Multicast + { + enforce_feed_metro_gate( + program_id, + &mut accesspass, + &device.exchange_pk, + target_mgroup, + feed_account, + )?; + } + // All validations passed - now update counters accesspass.connection_count += 1; accesspass.status = AccessPassStatus::Connected; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs index 7da06c3ce..d3fdac21a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs @@ -93,6 +93,10 @@ pub fn process_create_subscribe_user( let payer_account = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; + // Optional trailing Feed account for the EdgeSeat metro gate: the feed (referenced by the pass) + // that covers the device's exchange and lists the target multicast group. + let feed_account = accounts_iter.next(); + msg!("process_create_subscribe_user({:?})", value); let core_accounts = CreateUserCoreAccounts { @@ -120,6 +124,8 @@ pub fn process_create_subscribe_user( value.tunnel_endpoint, value.publisher, owner_override, + Some(mgroup_account.key), + feed_account, )?; // Subscribe user to multicast group diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs index f1a6c1894..994d1cb80 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs @@ -141,6 +141,10 @@ pub fn process_delete_user( )?; } + // Optional trailing Feed account: releases the EdgeSeat feed seat held by this user. Read AFTER + // authorize so it does not consume the optional trailing Permission account. + let feed_account = accounts_iter.next(); + let (accesspass_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); let (accesspass_dynamic_pda, _) = get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, &user.owner); @@ -180,6 +184,13 @@ pub fn process_delete_user( accesspass.connection_count = accesspass.connection_count.saturating_sub(1); // Release the per-category seat (EdgeSeat only; no-op otherwise). accesspass.remove_user(user.user_type); + // Release the feed-scoped seat for EdgeSeat multicast users (no-op if no feed supplied or + // the feed is not on the pass). + if user.user_type == UserType::Multicast { + if let Some(feed) = feed_account { + accesspass.remove_feed_user(feed.key); + } + } accesspass.status = if accesspass.connection_count > 0 { AccessPassStatus::Connected } else { diff --git a/smartcontract/programs/doublezero-serviceability/src/seeds.rs b/smartcontract/programs/doublezero-serviceability/src/seeds.rs index d4d98eae6..9dabbb0a3 100644 --- a/smartcontract/programs/doublezero-serviceability/src/seeds.rs +++ b/smartcontract/programs/doublezero-serviceability/src/seeds.rs @@ -24,3 +24,4 @@ pub const SEED_PERMISSION: &[u8] = b"permission"; pub const SEED_ADMIN_GROUP_BITS: &[u8] = b"admingroupbits"; pub const SEED_INDEX: &[u8] = b"index"; pub const SEED_TOPOLOGY: &[u8] = b"topology"; +pub const SEED_FEED: &[u8] = b"feed"; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs b/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs index 822f5f733..f7587641a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs @@ -11,6 +11,25 @@ use solana_program::{ }; use std::{fmt, net::Ipv4Addr}; +/// One purchased SKU seat on an EdgeSeat access pass. `feed_key` is the pubkey of the +/// serviceability `Feed` account (the catalog entry); `max_users` is the per-feed concurrent-user +/// cap seeded from the coupon; `current_users` is the live count, ticked at connect and released +/// at disconnect. +#[derive(BorshSerialize, BorshDeserialize, Debug, Default, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FeedSeat { + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkey_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkey_from_string" + ) + )] + pub feed_key: Pubkey, // 32 + pub max_users: u16, // 2 + pub current_users: u16, // 2 +} + #[repr(u8)] #[derive(BorshSerialize, BorshDeserialize, Debug, Default, Clone, PartialEq)] #[borsh(use_discriminant = true)] @@ -39,8 +58,13 @@ pub enum AccessPassType { Pubkey, ), Others(String, String), // (type_name, key) - // The seat is identified by the access pass `user_payer`, so the variant carries no payload. - EdgeSeat, + /// A metro-gated seat scoped to one or more feeds. Each `FeedSeat` is one SKU (feed_key + + /// per-feed cap). Provisioned by the oracle via `SetAccessPassFeeds`. + /// + /// Layout note (#1700): this variant previously carried no payload (#3865). EdgeSeat is new and + /// has no production passes, so changing the payload (same discriminant index 4) does not + /// orphan any deployed account; no migration is required. + EdgeSeat(Vec), } impl AccessPassType { @@ -50,7 +74,7 @@ impl AccessPassType { AccessPassType::SolanaValidator(_) => "solana_validator".to_string(), AccessPassType::SolanaRPC(_) => "solana_rpc".to_string(), AccessPassType::Others(type_name, _) => type_name.clone(), - AccessPassType::EdgeSeat => "edge_seat".to_string(), + AccessPassType::EdgeSeat(_) => "edge_seat".to_string(), } } } @@ -64,7 +88,7 @@ impl fmt::Display for AccessPassType { AccessPassType::Others(type_name, key) => { write!(f, "others: {} ({})", type_name, key) } - AccessPassType::EdgeSeat => write!(f, "edge_seat"), + AccessPassType::EdgeSeat(seats) => write!(f, "edge_seat: {} feed(s)", seats.len()), } } } @@ -194,7 +218,7 @@ impl fmt::Display for AccessPass { AccessPassType::Others(type_name, details) => { write!(f, "Others: {} ({})", type_name, details) } - AccessPassType::EdgeSeat => write!(f, "EdgeSeat"), + AccessPassType::EdgeSeat(seats) => write!(f, "EdgeSeat: ({} feed(s))", seats.len()), } } } @@ -273,40 +297,74 @@ impl AccessPass { /// Admit a user against the per-category seat caps. EdgeSeat-only: for all other access-pass /// types this is a no-op and always succeeds. Does NOT touch `connection_count` — that counter /// is maintained independently by the user create/delete processors. + /// + /// Per the feed-scoped supersede model (#1700): for EdgeSeat **multicast** the authoritative + /// cap is the per-feed [`FeedSeat`] (see [`Self::try_add_feed_user`]), so `max_multicast_users` + /// is no longer enforced here and is retained only for layout/back-compat. The per-category + /// **unicast** cap is still enforced. pub fn try_add_user(&mut self, user_type: UserType) -> Result<(), DoubleZeroError> { - if !matches!(self.accesspass_type, AccessPassType::EdgeSeat) { + if !matches!(self.accesspass_type, AccessPassType::EdgeSeat(_)) { return Ok(()); } match user_type { - UserType::Multicast => { - if self.multicast_user_count >= self.max_multicast_users { - return Err(DoubleZeroError::AccessPassMaxMulticastUsersExceeded); - } - self.multicast_user_count += 1; - } + // Vestigial: gated by FeedSeat caps instead. See try_add_feed_user. + UserType::Multicast => Ok(()), _ => { if self.unicast_user_count >= self.max_unicast_users { return Err(DoubleZeroError::AccessPassMaxUnicastUsersExceeded); } self.unicast_user_count += 1; + Ok(()) } } - Ok(()) } /// Release a seat held by a user. EdgeSeat-only: no-op for all other access-pass types. Does NOT - /// touch `connection_count`. + /// touch `connection_count`. Multicast release is feed-scoped (see [`Self::remove_feed_user`]). pub fn remove_user(&mut self, user_type: UserType) { - if !matches!(self.accesspass_type, AccessPassType::EdgeSeat) { + if !matches!(self.accesspass_type, AccessPassType::EdgeSeat(_)) { return; } - match user_type { - UserType::Multicast => { - self.multicast_user_count = self.multicast_user_count.saturating_sub(1); - } - _ => { - self.unicast_user_count = self.unicast_user_count.saturating_sub(1); + if user_type != UserType::Multicast { + self.unicast_user_count = self.unicast_user_count.saturating_sub(1); + } + } + + /// The feed seats provisioned on this pass (empty for non-EdgeSeat passes). + pub fn feed_seats(&self) -> &[FeedSeat] { + match &self.accesspass_type { + AccessPassType::EdgeSeat(seats) => seats, + _ => &[], + } + } + + fn feed_seat_mut(&mut self, feed_key: &Pubkey) -> Option<&mut FeedSeat> { + match &mut self.accesspass_type { + AccessPassType::EdgeSeat(seats) => seats.iter_mut().find(|s| s.feed_key == *feed_key), + _ => None, + } + } + + /// Tick the matching feed seat's `current_users` against its `max_users`. Returns + /// `MetroMismatch` if the pass carries no seat for `feed_key`, or + /// `AccessPassMaxMulticastUsersExceeded` if the feed cap is full. + pub fn try_add_feed_user(&mut self, feed_key: &Pubkey) -> Result<(), DoubleZeroError> { + match self.feed_seat_mut(feed_key) { + Some(seat) => { + if seat.current_users >= seat.max_users { + return Err(DoubleZeroError::AccessPassMaxMulticastUsersExceeded); + } + seat.current_users += 1; + Ok(()) } + None => Err(DoubleZeroError::MetroMismatch), + } + } + + /// Release a seat held against `feed_key`. No-op if the feed is not on the pass. + pub fn remove_feed_user(&mut self, feed_key: &Pubkey) { + if let Some(seat) = self.feed_seat_mut(feed_key) { + seat.current_users = seat.current_users.saturating_sub(1); } } } @@ -339,9 +397,13 @@ mod tests { let b = AccessPassType::SolanaValidator(Pubkey::default()); assert_eq!(object_length(&b).unwrap(), 33); - // EdgeSeat carries no payload: a bare discriminant byte. - let c = AccessPassType::EdgeSeat; - assert_eq!(object_length(&c).unwrap(), 1); + // EdgeSeat: discriminant byte + borsh vec length prefix (4) for an empty seat vec. + let c = AccessPassType::EdgeSeat(vec![]); + assert_eq!(object_length(&c).unwrap(), 5); + + // Each FeedSeat adds 36 bytes (32 pubkey + 2 + 2). + let d = AccessPassType::EdgeSeat(vec![FeedSeat::default()]); + assert_eq!(object_length(&d).unwrap(), 1 + 4 + 36); } #[test] @@ -479,10 +541,10 @@ mod tests { } #[test] - fn test_edge_seat_user_caps() { - let mut ap = test_accesspass(AccessPassType::EdgeSeat); + fn test_edge_seat_unicast_cap_retained() { + let mut ap = test_accesspass(AccessPassType::EdgeSeat(vec![])); - // Unicast: cap is 2. + // Unicast: per-category cap is still enforced (cap is 2). ap.try_add_user(UserType::IBRL).unwrap(); ap.try_add_user(UserType::EdgeFiltering).unwrap(); assert_eq!(ap.unicast_user_count, 2); @@ -491,27 +553,64 @@ mod tests { DoubleZeroError::AccessPassMaxUnicastUsersExceeded ); - // Multicast: cap is 1. + // Multicast: max_multicast_users is vestigial under supersede — try_add_user is a no-op + // and never errors, regardless of max_multicast_users. + ap.max_multicast_users = 0; ap.try_add_user(UserType::Multicast).unwrap(); - assert_eq!(ap.multicast_user_count, 1); - assert_eq!( - ap.try_add_user(UserType::Multicast).unwrap_err(), - DoubleZeroError::AccessPassMaxMulticastUsersExceeded - ); + ap.try_add_user(UserType::Multicast).unwrap(); + assert_eq!(ap.multicast_user_count, 0); - // remove_user frees a seat in the matching category. ap.remove_user(UserType::IBRL); assert_eq!(ap.unicast_user_count, 1); - ap.remove_user(UserType::Multicast); - assert_eq!(ap.multicast_user_count, 0); - // saturating: never underflows below 0. - ap.remove_user(UserType::Multicast); - assert_eq!(ap.multicast_user_count, 0); // connection_count is never touched by the seat helpers. assert_eq!(ap.connection_count, 0); } + #[test] + fn test_edge_seat_feed_caps() { + let feed_a = Pubkey::new_unique(); + let feed_b = Pubkey::new_unique(); + let mut ap = test_accesspass(AccessPassType::EdgeSeat(vec![ + FeedSeat { + feed_key: feed_a, + max_users: 2, + current_users: 0, + }, + FeedSeat { + feed_key: feed_b, + max_users: 1, + current_users: 0, + }, + ])); + + // feed_a admits 2 then rejects. + ap.try_add_feed_user(&feed_a).unwrap(); + ap.try_add_feed_user(&feed_a).unwrap(); + assert_eq!(ap.feed_seats()[0].current_users, 2); + assert_eq!( + ap.try_add_feed_user(&feed_a).unwrap_err(), + DoubleZeroError::AccessPassMaxMulticastUsersExceeded + ); + + // feed_b is independent. + ap.try_add_feed_user(&feed_b).unwrap(); + assert_eq!(ap.feed_seats()[1].current_users, 1); + + // Unknown feed → MetroMismatch. + assert_eq!( + ap.try_add_feed_user(&Pubkey::new_unique()).unwrap_err(), + DoubleZeroError::MetroMismatch + ); + + // Release frees a seat; saturating. + ap.remove_feed_user(&feed_a); + assert_eq!(ap.feed_seats()[0].current_users, 1); + ap.remove_feed_user(&feed_b); + ap.remove_feed_user(&feed_b); + assert_eq!(ap.feed_seats()[1].current_users, 0); + } + #[test] fn test_non_edge_seat_user_caps_are_noop() { for accesspass_type in [ diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs index 58c6e4c54..4254a6683 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs @@ -2,10 +2,11 @@ use crate::{ error::DoubleZeroError, state::{ accesspass::AccessPass, accounttype::AccountType, contributor::Contributor, device::Device, - exchange::Exchange, globalconfig::GlobalConfig, globalstate::GlobalState, index::Index, - link::Link, location::Location, multicastgroup::MulticastGroup, permission::Permission, - programconfig::ProgramConfig, resource_extension::ResourceExtensionOwned, tenant::Tenant, - topology::TopologyInfo, user::User, + exchange::Exchange, feed::Feed, globalconfig::GlobalConfig, globalstate::GlobalState, + index::Index, link::Link, location::Location, multicastgroup::MulticastGroup, + permission::Permission, programconfig::ProgramConfig, + resource_extension::ResourceExtensionOwned, tenant::Tenant, topology::TopologyInfo, + user::User, }, }; use solana_program::program_error::ProgramError; @@ -31,6 +32,7 @@ pub enum AccountData { Permission(Permission), Index(Index), Topology(TopologyInfo), + Feed(Feed), } impl AccountData { @@ -53,6 +55,7 @@ impl AccountData { AccountData::Permission(_) => "Permission", AccountData::Index(_) => "Index", AccountData::Topology(_) => "Topology", + AccountData::Feed(_) => "Feed", } } @@ -75,6 +78,7 @@ impl AccountData { AccountData::Permission(permission) => permission.to_string(), AccountData::Index(index) => index.to_string(), AccountData::Topology(topology) => topology.to_string(), + AccountData::Feed(feed) => feed.to_string(), } } @@ -205,6 +209,14 @@ impl AccountData { Err(DoubleZeroError::InvalidAccountType) } } + + pub fn get_feed(&self) -> Result { + if let AccountData::Feed(feed) = self { + Ok(feed.clone()) + } else { + Err(DoubleZeroError::InvalidAccountType) + } + } } impl TryFrom<&[u8]> for AccountData { @@ -250,6 +262,7 @@ impl TryFrom<&[u8]> for AccountData { AccountType::Topology => Ok(AccountData::Topology(TopologyInfo::try_from( bytes as &[u8], )?)), + AccountType::Feed => Ok(AccountData::Feed(Feed::try_from(bytes as &[u8])?)), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs index a4e8501aa..80b9fd157 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs @@ -25,6 +25,7 @@ pub enum AccountType { Permission = 15, Index = 16, Topology = 17, + Feed = 18, } pub trait AccountTypeInfo { @@ -54,6 +55,7 @@ impl From for AccountType { 15 => AccountType::Permission, 16 => AccountType::Index, 17 => AccountType::Topology, + 18 => AccountType::Feed, _ => AccountType::None, } } @@ -79,6 +81,7 @@ impl fmt::Display for AccountType { AccountType::Permission => write!(f, "permission"), AccountType::Index => write!(f, "index"), AccountType::Topology => write!(f, "topology"), + AccountType::Feed => write!(f, "feed"), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs new file mode 100644 index 000000000..6ad0c2114 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs @@ -0,0 +1,183 @@ +use crate::{ + error::{DoubleZeroError, Validate}, + state::accounttype::AccountType, +}; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError, pubkey::Pubkey}; +use std::fmt; + +/// Result of matching a device's exchange (metro) against a [`Feed`]'s metro map. +#[derive(Debug, PartialEq)] +pub enum FeedMetroMatch<'a> { + /// The feed has no metros: it imposes no metro restriction and is reachable from any + /// exchange (and admits any group). + Unrestricted, + /// The exchange is covered; these are the joinable multicast groups for it. + Groups(&'a [Pubkey]), + /// The feed has metros but none match the exchange. + NotCovered, +} + +/// A serviceability catalog entry: the `metro(exchange) → group-set` map for one SKU. +/// +/// The pubkey of this account (`feed_key`) is the SKU identifier carried on EdgeSeat access +/// passes. `code` is the PDA seed, so it is immutable; `name` and `metros` are mutable. +/// A feed with an empty `metros` vec imposes no metro restriction (reachable from anywhere). +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Feed { + pub account_type: AccountType, // 1 + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkey_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkey_from_string" + ) + )] + pub owner: Pubkey, // 32 + pub bump_seed: u8, // 1 + pub code: String, // 4 + len (PDA seed, immutable) + pub name: String, // 4 + len + pub reference_count: u32, // 4 - number of access passes referencing this feed + /// `exchange_pk → group_pks`. Empty ⇒ no metro restriction. + pub metros: Vec<(Pubkey, Vec)>, +} + +impl Feed { + /// Match `exchange` against this feed's metro map. See [`FeedMetroMatch`]. + pub fn groups_for(&self, exchange: &Pubkey) -> FeedMetroMatch<'_> { + if self.metros.is_empty() { + return FeedMetroMatch::Unrestricted; + } + match self.metros.iter().find(|(ex, _)| ex == exchange) { + Some((_, groups)) => FeedMetroMatch::Groups(groups), + None => FeedMetroMatch::NotCovered, + } + } +} + +impl fmt::Display for Feed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "account_type: {}, owner: {}, bump_seed: {}, code: {}, name: {}, reference_count: {}, metros: {}", + self.account_type, + self.owner, + self.bump_seed, + self.code, + self.name, + self.reference_count, + self.metros.len() + ) + } +} + +impl TryFrom<&[u8]> for Feed { + type Error = ProgramError; + + fn try_from(mut data: &[u8]) -> Result { + let out = Self { + account_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + owner: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + bump_seed: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + code: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + reference_count: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + metros: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + }; + + if out.account_type != AccountType::Feed { + return Err(ProgramError::InvalidAccountData); + } + + Ok(out) + } +} + +impl TryFrom<&AccountInfo<'_>> for Feed { + type Error = ProgramError; + + fn try_from(account: &AccountInfo) -> Result { + let data = account.try_borrow_data()?; + let res = Self::try_from(&data[..]); + if res.is_err() { + msg!("Failed to deserialize Feed: {:?}", res.as_ref().err()); + } + res + } +} + +impl Validate for Feed { + fn validate(&self) -> Result<(), DoubleZeroError> { + if self.account_type != AccountType::Feed { + msg!("Invalid account type: {}", self.account_type); + return Err(DoubleZeroError::InvalidAccountType); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feed_with(metros: Vec<(Pubkey, Vec)>) -> Feed { + Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 1, + code: "shreds".to_string(), + name: "Shreds".to_string(), + reference_count: 0, + metros, + } + } + + #[test] + fn test_feed_serialization_roundtrip() { + let val = feed_with(vec![( + Pubkey::new_unique(), + vec![Pubkey::new_unique(), Pubkey::new_unique()], + )]); + let data = borsh::to_vec(&val).unwrap(); + let val2 = Feed::try_from(&data[..]).unwrap(); + val.validate().unwrap(); + val2.validate().unwrap(); + assert_eq!(val, val2); + assert_eq!(data.len(), borsh::object_length(&val).unwrap()); + } + + #[test] + fn test_groups_for_empty_metros_is_unrestricted() { + let feed = feed_with(vec![]); + assert_eq!( + feed.groups_for(&Pubkey::new_unique()), + FeedMetroMatch::Unrestricted + ); + } + + #[test] + fn test_groups_for_covered_and_not_covered() { + let fra = Pubkey::new_unique(); + let g1 = Pubkey::new_unique(); + let g2 = Pubkey::new_unique(); + let feed = feed_with(vec![(fra, vec![g1, g2])]); + + match feed.groups_for(&fra) { + FeedMetroMatch::Groups(groups) => assert_eq!(groups, &[g1, g2]), + other => panic!("expected Groups, got {other:?}"), + } + assert_eq!( + feed.groups_for(&Pubkey::new_unique()), + FeedMetroMatch::NotCovered + ); + } + + #[test] + fn test_feed_wrong_account_type_rejected() { + let mut val = feed_with(vec![]); + val.account_type = AccountType::Exchange; + let data = borsh::to_vec(&val).unwrap(); + assert!(Feed::try_from(&data[..]).is_err()); + } +} diff --git a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs index dda96c197..809abec3f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs @@ -5,6 +5,7 @@ pub mod contributor; pub mod device; pub mod exchange; pub mod feature_flags; +pub mod feed; pub mod globalconfig; pub mod globalstate; pub mod index; diff --git a/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs b/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs index 92c2c6fc1..6b8f0fff2 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs @@ -1973,7 +1973,7 @@ async fn test_set_accesspass_refills_depleted_user_payer() { get_accesspass_pda(&program_id, &Ipv4Addr::UNSPECIFIED, &user_payer.pubkey()); let set_access_pass_args = SetAccessPassArgs { - accesspass_type: AccessPassType::EdgeSeat, + accesspass_type: AccessPassType::EdgeSeat(vec![]), client_ip: Ipv4Addr::UNSPECIFIED, last_access_epoch: u64::MAX, allow_multiple_ip: false, diff --git a/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs b/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs index e70d0417d..4d3170f6f 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs @@ -682,9 +682,10 @@ async fn try_create_user( .await } -/// EdgeSeat passes admit at most `max_unicast_users` unicast and `max_multicast_users` multicast -/// users; the (N+1)th in each category is rejected with the per-category error. The pass lives at -/// the UNSPECIFIED PDA so distinct client IPs all map to the same seat. +/// EdgeSeat passes admit at most `max_unicast_users` unicast users; the (N+1)th is rejected with +/// the per-category error. Multicast is feed-scoped (supersede): with no feeds provisioned on the +/// pass, a multicast connect is rejected with `MetroMismatch`. The pass lives at the UNSPECIFIED PDA +/// so distinct client IPs all map to the same seat. #[tokio::test] async fn test_edge_seat_user_caps_enforced() { let mut env = setup_test_env().await; @@ -699,7 +700,7 @@ async fn test_edge_seat_user_caps_enforced() { recent_blockhash, env.program_id, DoubleZeroInstruction::SetAccessPass(SetAccessPassArgs { - accesspass_type: AccessPassType::EdgeSeat, + accesspass_type: AccessPassType::EdgeSeat(vec![]), client_ip: Ipv4Addr::UNSPECIFIED, last_access_epoch: 9999, allow_multiple_ip: false, @@ -739,37 +740,28 @@ async fn test_edge_seat_user_caps_enforced() { "expected AccessPassMaxUnicastUsersExceeded (Custom(89)), got: {err:?}" ); - // Multicast is a separate category, so the first multicast user is still admitted. - try_create_user( - &mut env, - [100, 0, 0, 12].into(), - UserType::Multicast, - accesspass_pubkey, - ) - .await - .expect("first multicast user should be admitted"); - - // Second multicast user exceeds the multicast cap. + // Multicast is feed-scoped under supersede. With no feeds provisioned on the pass, a multicast + // connect is rejected with MetroMismatch (Custom(91)) rather than the legacy multicast cap. let err = try_create_user( &mut env, - [100, 0, 0, 13].into(), + [100, 0, 0, 12].into(), UserType::Multicast, accesspass_pubkey, ) .await - .expect_err("second multicast user should exceed the cap"); + .expect_err("multicast on a feedless EdgeSeat pass should be rejected"); assert!( - format!("{err:?}").contains("Custom(90)"), - "expected AccessPassMaxMulticastUsersExceeded (Custom(90)), got: {err:?}" + format!("{err:?}").contains("Custom(91)"), + "expected MetroMismatch (Custom(91)), got: {err:?}" ); - // The pass tracks one seat per category; connection_count counts both connections. + // Only the unicast connection was admitted. let pass = get_account_data(&mut env.banks_client, accesspass_pubkey) .await .unwrap() .get_accesspass() .unwrap(); assert_eq!(pass.unicast_user_count, 1); - assert_eq!(pass.multicast_user_count, 1); - assert_eq!(pass.connection_count, 2); + assert_eq!(pass.multicast_user_count, 0); + assert_eq!(pass.connection_count, 1); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs new file mode 100644 index 000000000..5f7a28b37 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs @@ -0,0 +1,483 @@ +//! Integration tests for the EdgeSeat feed metro gate (#1700). +//! +//! Scenarios: +//! - wrong-metro device rejected (MetroMismatch) +//! - right-metro joins the metro's group set +//! - multi-feed seat (matching feed admits) +//! - no-metro feed reachable from anywhere + +use doublezero_serviceability::{ + entrypoint::process_instruction, + instructions::DoubleZeroInstruction, + pda::{ + get_accesspass_pda, get_contributor_pda, get_device_pda, get_exchange_pda, get_feed_pda, + get_globalconfig_pda, get_globalstate_pda, get_location_pda, get_multicastgroup_pda, + get_resource_extension_pda, get_user_pda, + }, + processors::{ + accesspass::{set::SetAccessPassArgs, set_feeds::SetAccessPassFeedsArgs}, + contributor::create::ContributorCreateArgs, + device::{create::DeviceCreateArgs, update::DeviceUpdateArgs}, + exchange::create::ExchangeCreateArgs, + feed::create::FeedCreateArgs, + location::create::LocationCreateArgs, + multicastgroup::create::MulticastGroupCreateArgs, + user::create_subscribe::UserCreateSubscribeArgs, + }, + resource::ResourceType, + state::{ + accesspass::{AccessPassType, FeedSeat}, + device::DeviceType, + user::{UserCYOA, UserStatus, UserType}, + }, +}; +use solana_program_test::*; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signer}; +use std::net::Ipv4Addr; + +mod test_helpers; +use test_helpers::*; + +struct FeedFixture { + banks_client: BanksClient, + payer: solana_sdk::signature::Keypair, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + exchange_pubkey: Pubkey, + device_pubkey: Pubkey, + accesspass_pubkey: Pubkey, + mgroup_pubkey: Pubkey, + user_ip: Ipv4Addr, + user_tunnel_block: Pubkey, + multicast_publisher_block: Pubkey, + tunnel_ids: Pubkey, + dz_prefix_block: Pubkey, +} + +/// Build GlobalState/Config, Location, Exchange, Contributor, an Activated Device, an Activated +/// MulticastGroup, and an EdgeSeat access pass (no feeds yet — provisioned per test). +async fn setup_feed_fixture(client_ip: [u8; 4]) -> FeedFixture { + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "doublezero_serviceability", + program_id, + processor!(process_instruction), + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; + + let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let (user_tunnel_block, _, _) = + get_resource_extension_pda(&program_id, ResourceType::UserTunnelBlock); + let (multicast_publisher_block, _, _) = + get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); + + init_globalstate_and_config(&mut banks_client, program_id, &payer, recent_blockhash).await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "test".to_string(), + name: "Test Location".to_string(), + country: "us".to_string(), + lat: 0.0, + lng: 0.0, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "test".to_string(), + name: "Test Exchange".to_string(), + lat: 0.0, + lng: 0.0, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = get_contributor_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "test".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, gs.account_index + 1); + let (tunnel_ids, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_block, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "test-dev".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [100, 0, 0, 1].into(), + dz_prefixes: "110.1.0.0/24".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: None, + resource_count: 2, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids, false), + AccountMeta::new(dz_prefix_block, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDevice(DeviceUpdateArgs { + max_users: Some(128), + ..DeviceUpdateArgs::default() + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (mgroup_pubkey, _) = get_multicastgroup_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateMulticastGroup(MulticastGroupCreateArgs { + code: "group1".to_string(), + max_bandwidth: 1000, + owner: payer.pubkey(), + use_onchain_allocation: true, + }), + vec![ + AccountMeta::new(mgroup_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new( + get_resource_extension_pda(&program_id, ResourceType::MulticastGroupBlock).0, + false, + ), + ], + &payer, + ) + .await; + + // EdgeSeat access pass with no feeds yet. + let user_ip: Ipv4Addr = client_ip.into(); + let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &user_ip, &payer.pubkey()); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAccessPass(SetAccessPassArgs { + accesspass_type: AccessPassType::EdgeSeat(vec![]), + client_ip: user_ip, + last_access_epoch: 9999, + allow_multiple_ip: false, + max_unicast_users: 1, + max_multicast_users: 1, + }), + vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + ], + &payer, + ) + .await; + + FeedFixture { + banks_client, + payer, + program_id, + globalstate_pubkey, + exchange_pubkey, + device_pubkey, + accesspass_pubkey, + mgroup_pubkey, + user_ip, + user_tunnel_block, + multicast_publisher_block, + tunnel_ids, + dz_prefix_block, + } +} + +/// Create a feed (catalog admin = foundation payer) with the given metro map. +async fn create_feed( + f: &mut FeedFixture, + code: &str, + metros: Vec<(Pubkey, Vec)>, +) -> Pubkey { + let (feed_pubkey, _) = get_feed_pda(&f.program_id, code); + let recent_blockhash = f.banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut f.banks_client, + recent_blockhash, + f.program_id, + DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: code.to_string(), + name: code.to_string(), + metros, + }), + vec![ + AccountMeta::new(feed_pubkey, false), + AccountMeta::new(f.globalstate_pubkey, false), + ], + &f.payer, + ) + .await; + feed_pubkey +} + +/// Provision the given feed seats onto the access pass via SetAccessPassFeeds. +async fn set_pass_feeds(f: &mut FeedFixture, seats: Vec) { + let recent_blockhash = f.banks_client.get_latest_blockhash().await.unwrap(); + let accounts = vec![ + AccountMeta::new(f.accesspass_pubkey, false), + AccountMeta::new(f.globalstate_pubkey, false), + ]; + // Feed accounts (writable for reference_count bump) follow payer + system; they are passed as + // extra accounts. + let extra: Vec = seats + .iter() + .map(|s| AccountMeta::new(s.feed_key, false)) + .collect(); + let mut tx = create_transaction_with_extra_accounts( + f.program_id, + &DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: f.user_ip, + user_payer: f.payer.pubkey(), + feeds: seats, + }), + &accounts, + &f.payer, + &extra, + ); + tx.try_sign(&[&f.payer], recent_blockhash).unwrap(); + f.banks_client.process_transaction(tx).await.unwrap(); +} + +/// Attempt CreateSubscribeUser as a subscriber, passing `feed` as the trailing metro-gate account. +async fn try_subscribe_with_feed( + f: &mut FeedFixture, + feed: Pubkey, +) -> Result<(), BanksClientError> { + let recent_blockhash = wait_for_new_blockhash(&mut f.banks_client).await; + let (user_pubkey, _) = get_user_pda(&f.program_id, &f.user_ip, UserType::Multicast); + let accounts = vec![ + AccountMeta::new(user_pubkey, false), + AccountMeta::new(f.device_pubkey, false), + AccountMeta::new(f.mgroup_pubkey, false), + AccountMeta::new(f.accesspass_pubkey, false), + AccountMeta::new(f.globalstate_pubkey, false), + AccountMeta::new(f.user_tunnel_block, false), + AccountMeta::new(f.multicast_publisher_block, false), + AccountMeta::new(f.tunnel_ids, false), + AccountMeta::new(f.dz_prefix_block, false), + ]; + let mut tx = create_transaction_with_extra_accounts( + f.program_id, + &DoubleZeroInstruction::CreateSubscribeUser(UserCreateSubscribeArgs { + user_type: UserType::Multicast, + cyoa_type: UserCYOA::GREOverDIA, + client_ip: f.user_ip, + publisher: false, + subscriber: true, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + dz_prefix_count: 1, + owner: Pubkey::default(), + }), + &accounts, + &f.payer, + &[AccountMeta::new_readonly(feed, false)], + ); + tx.try_sign(&[&f.payer], recent_blockhash).unwrap(); + f.banks_client.process_transaction(tx).await +} + +#[tokio::test] +async fn test_right_metro_joins_group_set() { + let mut f = setup_feed_fixture([100, 0, 0, 20]).await; + let (exchange, mgroup) = (f.exchange_pubkey, f.mgroup_pubkey); + // Feed maps the device's exchange → [mgroup]. + let feed = create_feed(&mut f, "shreds", vec![(exchange, vec![mgroup])]).await; + set_pass_feeds( + &mut f, + vec![FeedSeat { + feed_key: feed, + max_users: 2, + current_users: 0, + }], + ) + .await; + + try_subscribe_with_feed(&mut f, feed) + .await + .expect("right-metro subscribe should succeed"); + + let (user_pubkey, _) = get_user_pda(&f.program_id, &f.user_ip, UserType::Multicast); + let user = get_account_data(&mut f.banks_client, user_pubkey) + .await + .expect("user exists") + .get_user() + .unwrap(); + assert_eq!(user.status, UserStatus::Activated); + assert_eq!(user.subscribers, vec![f.mgroup_pubkey]); + + // The feed seat was ticked. + let pass = get_account_data(&mut f.banks_client, f.accesspass_pubkey) + .await + .unwrap() + .get_accesspass() + .unwrap(); + assert_eq!(pass.feed_seats()[0].current_users, 1); +} + +#[tokio::test] +async fn test_wrong_metro_device_rejected() { + let mut f = setup_feed_fixture([100, 0, 0, 21]).await; + let mgroup = f.mgroup_pubkey; + // Feed covers a DIFFERENT exchange, so the device's metro is not covered. + let other_exchange = Pubkey::new_unique(); + let feed = create_feed(&mut f, "shreds", vec![(other_exchange, vec![mgroup])]).await; + set_pass_feeds( + &mut f, + vec![FeedSeat { + feed_key: feed, + max_users: 2, + current_users: 0, + }], + ) + .await; + + let err = try_subscribe_with_feed(&mut f, feed) + .await + .expect_err("wrong-metro subscribe should be rejected"); + assert!( + format!("{err:?}").contains("Custom(91)"), + "expected MetroMismatch (Custom(91)), got: {err:?}" + ); +} + +#[tokio::test] +async fn test_multi_feed_seat_matching_admits() { + let mut f = setup_feed_fixture([100, 0, 0, 22]).await; + let (exchange, mgroup) = (f.exchange_pubkey, f.mgroup_pubkey); + // Two feeds: one covering a bogus metro, one covering the device's metro. + let feed_other = create_feed(&mut f, "tokyo", vec![(Pubkey::new_unique(), vec![mgroup])]).await; + let feed_match = create_feed(&mut f, "fra", vec![(exchange, vec![mgroup])]).await; + set_pass_feeds( + &mut f, + vec![ + FeedSeat { + feed_key: feed_other, + max_users: 1, + current_users: 0, + }, + FeedSeat { + feed_key: feed_match, + max_users: 1, + current_users: 0, + }, + ], + ) + .await; + + // Subscribing with the matching feed succeeds. + try_subscribe_with_feed(&mut f, feed_match) + .await + .expect("subscribe via the matching feed should succeed"); + + let pass = get_account_data(&mut f.banks_client, f.accesspass_pubkey) + .await + .unwrap() + .get_accesspass() + .unwrap(); + let matched = pass + .feed_seats() + .iter() + .find(|s| s.feed_key == feed_match) + .unwrap(); + assert_eq!(matched.current_users, 1); +} + +#[tokio::test] +async fn test_no_metro_feed_reachable_from_anywhere() { + let mut f = setup_feed_fixture([100, 0, 0, 23]).await; + // Feed with no metros: no restriction, reachable from any exchange and any group. + let feed = create_feed(&mut f, "global", vec![]).await; + set_pass_feeds( + &mut f, + vec![FeedSeat { + feed_key: feed, + max_users: 2, + current_users: 0, + }], + ) + .await; + + try_subscribe_with_feed(&mut f, feed) + .await + .expect("no-metro feed should be reachable"); + + let (user_pubkey, _) = get_user_pda(&f.program_id, &f.user_ip, UserType::Multicast); + let user = get_account_data(&mut f.banks_client, user_pubkey) + .await + .expect("user exists") + .get_user() + .unwrap(); + assert_eq!(user.status, UserStatus::Activated); +} 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 +} diff --git a/smartcontract/sdk/rs/src/commands/accesspass/mod.rs b/smartcontract/sdk/rs/src/commands/accesspass/mod.rs index 75d37e346..e13ff5e8c 100644 --- a/smartcontract/sdk/rs/src/commands/accesspass/mod.rs +++ b/smartcontract/sdk/rs/src/commands/accesspass/mod.rs @@ -3,3 +3,4 @@ pub mod close; pub mod get; pub mod list; pub mod set; +pub mod set_feeds; diff --git a/smartcontract/sdk/rs/src/commands/accesspass/set_feeds.rs b/smartcontract/sdk/rs/src/commands/accesspass/set_feeds.rs new file mode 100644 index 000000000..8c6b85e75 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/accesspass/set_feeds.rs @@ -0,0 +1,115 @@ +use std::net::Ipv4Addr; + +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_accesspass_pda, + processors::accesspass::set_feeds::SetAccessPassFeedsArgs, state::accesspass::FeedSeat, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; + +/// Provision feed seats (SKUs) onto an EdgeSeat access pass. +/// +/// On-chain account layout (see `process_set_access_pass_feeds`): +/// `[accesspass, globalstate, payer, system, feed_0 .. feed_{N-1}, (optional permission)]` +/// +/// `DoubleZeroClient::execute_transaction` appends `[payer, system]` after the base accounts +/// supplied here, so the base list is `[accesspass, globalstate]` followed by one writable `Feed` +/// account per seat, in the same order as `feeds`. +#[derive(Debug, PartialEq, Clone)] +pub struct SetAccessPassFeedsCommand { + pub client_ip: Ipv4Addr, + pub user_payer: Pubkey, + pub feeds: Vec, +} + +impl SetAccessPassFeedsCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (accesspass_pubkey, _) = + get_accesspass_pda(&client.get_program_id(), &self.client_ip, &self.user_payer); + + let mut accounts = vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + + // One writable Feed account per seat, in the same order as `feeds`. + for seat in &self.feeds { + accounts.push(AccountMeta::new(seat.feed_key, false)); + } + + client.execute_transaction( + DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: self.client_ip, + user_payer: self.user_payer, + feeds: self.feeds.clone(), + }), + accounts, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::accesspass::set_feeds::SetAccessPassFeedsCommand, + tests::utils::create_test_client, DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_accesspass_pda, get_globalstate_pda}, + processors::accesspass::set_feeds::SetAccessPassFeedsArgs, + state::accesspass::FeedSeat, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_set_accesspass_feeds_command() { + let mut client = create_test_client(); + + let client_ip = [10, 0, 0, 1].into(); + let payer = Pubkey::new_unique(); + let feed_key = Pubkey::new_unique(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (accesspass_pubkey, _) = + get_accesspass_pda(&client.get_program_id(), &client_ip, &payer); + + let seats = vec![FeedSeat { + feed_key, + max_users: 5, + current_users: 0, + }]; + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::SetAccessPassFeeds( + SetAccessPassFeedsArgs { + client_ip, + user_payer: payer, + feeds: seats.clone(), + }, + )), + predicate::eq(vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(feed_key, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = SetAccessPassFeedsCommand { + client_ip, + user_payer: payer, + feeds: seats, + } + .execute(&client); + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/feed/create.rs b/smartcontract/sdk/rs/src/commands/feed/create.rs new file mode 100644 index 000000000..0477ed2a6 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/create.rs @@ -0,0 +1,99 @@ +use doublezero_program_common::validate_account_code; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_feed_pda, + processors::feed::create::FeedCreateArgs, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; + +#[derive(Debug, PartialEq, Clone)] +pub struct CreateFeedCommand { + pub code: String, + pub name: String, + /// `exchange_pk → group_pks`. Empty ⇒ no metro restriction. + pub metros: Vec<(Pubkey, Vec)>, +} + +impl CreateFeedCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result<(Signature, Pubkey)> { + let code = + validate_account_code(&self.code).map_err(|err| eyre::eyre!("invalid code: {err}"))?; + + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (pda_pubkey, _) = get_feed_pda(&client.get_program_id(), &code); + + // Accounts: [feed, globalstate, (payer, system appended by client)]. + client + .execute_transaction( + DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code, + name: self.name.clone(), + metros: self.metros.clone(), + }), + vec![ + AccountMeta::new(pda_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + ) + .map(|sig| (sig, pda_pubkey)) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::feed::create::CreateFeedCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_feed_pda, get_globalstate_pda}, + processors::feed::create::FeedCreateArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_feed_create_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _globalstate) = get_globalstate_pda(&client.get_program_id()); + let (pda_pubkey, _) = get_feed_pda(&client.get_program_id(), "test_feed"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: "test_feed".to_string(), + name: "Test Feed".to_string(), + metros: vec![], + })), + predicate::eq(vec![ + AccountMeta::new(pda_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let create_command = CreateFeedCommand { + code: "test_feed".to_string(), + name: "Test Feed".to_string(), + metros: vec![], + }; + + let create_invalid_command = CreateFeedCommand { + code: "test/feed".to_string(), + ..create_command.clone() + }; + + let res = create_command.execute(&client); + assert!(res.is_ok()); + + let res = create_invalid_command.execute(&client); + assert!(res.is_err()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/feed/delete.rs b/smartcontract/sdk/rs/src/commands/feed/delete.rs new file mode 100644 index 000000000..f92e53c6d --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/delete.rs @@ -0,0 +1,96 @@ +use crate::{ + commands::{feed::get::GetFeedCommand, globalstate::get::GetGlobalStateCommand}, + DoubleZeroClient, +}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, processors::feed::delete::FeedDeleteArgs, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct DeleteFeedCommand { + pub pubkey: Pubkey, +} + +impl DeleteFeedCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (_, feed) = GetFeedCommand { + pubkey_or_code: self.pubkey.to_string(), + } + .execute(client) + .map_err(|_err| eyre::eyre!("Feed not found"))?; + + if feed.reference_count > 0 { + return Err(eyre::eyre!( + "Feed cannot be deleted, it has {} references", + feed.reference_count + )); + } + + // Accounts: [feed, globalstate, (payer, system appended by client)]. + client.execute_transaction( + DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {}), + vec![ + AccountMeta::new(self.pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::feed::delete::DeleteFeedCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_feed_pda, get_globalstate_pda}, + processors::feed::delete::FeedDeleteArgs, + state::{accountdata::AccountData, accounttype::AccountType, feed::Feed}, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_feed_delete_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _globalstate) = get_globalstate_pda(&client.get_program_id()); + let (pda_pubkey, _) = get_feed_pda(&client.get_program_id(), "test_feed"); + let feed = Feed { + account_type: AccountType::Feed, + owner: Pubkey::default(), + bump_seed: 255, + code: "test_feed".to_string(), + name: "Test Feed".to_string(), + reference_count: 0, + metros: vec![], + }; + + client + .expect_get() + .with(predicate::eq(pda_pubkey)) + .returning(move |_| Ok(AccountData::Feed(feed.clone()))); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {})), + predicate::eq(vec![ + AccountMeta::new(pda_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = DeleteFeedCommand { pubkey: pda_pubkey }.execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/feed/get.rs b/smartcontract/sdk/rs/src/commands/feed/get.rs new file mode 100644 index 000000000..a452b2e19 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/get.rs @@ -0,0 +1,111 @@ +use crate::{utils::parse_pubkey, DoubleZeroClient}; +use doublezero_serviceability::state::{ + accountdata::AccountData, accounttype::AccountType, feed::Feed, +}; +use solana_sdk::pubkey::Pubkey; + +#[derive(Debug, PartialEq, Clone)] +pub struct GetFeedCommand { + pub pubkey_or_code: String, +} + +impl GetFeedCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result<(Pubkey, Feed)> { + match parse_pubkey(&self.pubkey_or_code) { + Some(pk) => match client.get(pk)? { + AccountData::Feed(feed) => Ok((pk, feed)), + _ => Err(eyre::eyre!("Invalid Account Type")), + }, + None => client + .gets(AccountType::Feed)? + .into_iter() + .find(|(_, v)| match v { + AccountData::Feed(feed) => feed.code.eq_ignore_ascii_case(&self.pubkey_or_code), + _ => false, + }) + .map(|(pk, v)| match v { + AccountData::Feed(feed) => Ok((pk, feed)), + _ => Err(eyre::eyre!("Invalid Account Type")), + }) + .unwrap_or_else(|| { + Err(eyre::eyre!( + "Feed with code {} not found", + self.pubkey_or_code + )) + }), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{commands::feed::get::GetFeedCommand, tests::utils::create_test_client}; + use doublezero_serviceability::state::{ + accountdata::AccountData, accounttype::AccountType, feed::Feed, + }; + use mockall::predicate; + use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; + + #[test] + fn test_commands_feed_get_command() { + let mut client = create_test_client(); + + let feed_pubkey = Pubkey::new_unique(); + let feed = Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 0, + code: "feed_code".to_string(), + name: "feed_name".to_string(), + reference_count: 0, + metros: vec![], + }; + + let feed2 = feed.clone(); + client + .expect_get() + .with(predicate::eq(feed_pubkey)) + .returning(move |_| Ok(AccountData::Feed(feed2.clone()))); + + let feed2 = feed.clone(); + client + .expect_gets() + .with(predicate::eq(AccountType::Feed)) + .returning(move |_| { + let mut feeds = HashMap::new(); + feeds.insert(feed_pubkey, AccountData::Feed(feed2.clone())); + Ok(feeds) + }); + + // Search by pubkey + let res = GetFeedCommand { + pubkey_or_code: feed_pubkey.to_string(), + } + .execute(&client); + assert!(res.is_ok()); + assert_eq!(res.unwrap().1.code, "feed_code".to_string()); + + // Search by code + let res = GetFeedCommand { + pubkey_or_code: "feed_code".to_string(), + } + .execute(&client); + assert!(res.is_ok()); + assert_eq!(res.unwrap().1.code, "feed_code".to_string()); + + // Search by code UPPERCASE + let res = GetFeedCommand { + pubkey_or_code: "FEED_CODE".to_string(), + } + .execute(&client); + assert!(res.is_ok()); + + // Invalid search + let res = GetFeedCommand { + pubkey_or_code: "ssssssssssss".to_string(), + } + .execute(&client); + assert!(res.is_err()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/feed/list.rs b/smartcontract/sdk/rs/src/commands/feed/list.rs new file mode 100644 index 000000000..7201fc9a8 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/list.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; + +use crate::DoubleZeroClient; +use doublezero_serviceability::{ + error::DoubleZeroError, + state::{accountdata::AccountData, accounttype::AccountType, feed::Feed}, +}; +use solana_sdk::pubkey::Pubkey; + +#[derive(Debug, PartialEq, Clone)] +pub struct ListFeedCommand; + +impl ListFeedCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result> { + client + .gets(AccountType::Feed)? + .into_iter() + .map(|(k, v)| { + if let AccountData::Feed(feed) = v { + Ok((k, feed)) + } else { + Err(DoubleZeroError::InvalidAccountType.into()) + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{commands::feed::list::ListFeedCommand, tests::utils::create_test_client}; + use doublezero_serviceability::state::{ + accountdata::AccountData, accounttype::AccountType, feed::Feed, + }; + use mockall::predicate; + use solana_sdk::pubkey::Pubkey; + + #[test] + fn test_commands_feed_list_command() { + let mut client = create_test_client(); + + let feed1_pubkey = Pubkey::new_unique(); + let feed1 = Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 0, + code: "feed1_code".to_string(), + name: "feed1_name".to_string(), + reference_count: 0, + metros: vec![], + }; + + let feed2_pubkey = Pubkey::new_unique(); + let feed2 = Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 0, + code: "feed2_code".to_string(), + name: "feed2_name".to_string(), + reference_count: 0, + metros: vec![], + }; + + client + .expect_gets() + .with(predicate::eq(AccountType::Feed)) + .returning(move |_| { + let mut feeds = HashMap::new(); + feeds.insert(feed1_pubkey, AccountData::Feed(feed1.clone())); + feeds.insert(feed2_pubkey, AccountData::Feed(feed2.clone())); + Ok(feeds) + }); + + let res = ListFeedCommand.execute(&client); + assert!(res.is_ok()); + let list = res.unwrap(); + assert!(list.len() == 2); + assert!(list.contains_key(&feed1_pubkey)); + } +} diff --git a/smartcontract/sdk/rs/src/commands/feed/mod.rs b/smartcontract/sdk/rs/src/commands/feed/mod.rs new file mode 100644 index 000000000..91259509f --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/mod.rs @@ -0,0 +1,5 @@ +pub mod create; +pub mod delete; +pub mod get; +pub mod list; +pub mod update; diff --git a/smartcontract/sdk/rs/src/commands/feed/update.rs b/smartcontract/sdk/rs/src/commands/feed/update.rs new file mode 100644 index 000000000..0775906d3 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/update.rs @@ -0,0 +1,78 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, processors::feed::update::FeedUpdateArgs, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct UpdateFeedCommand { + pub pubkey: Pubkey, + pub name: Option, + /// `exchange_pk → group_pks`. `None` leaves the metro map unchanged. + pub metros: Option)>>, +} + +impl UpdateFeedCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + // Accounts: [feed, globalstate, (payer, system appended by client)]. + client.execute_transaction( + DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { + name: self.name.clone(), + metros: self.metros.clone(), + }), + vec![ + AccountMeta::new(self.pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::feed::update::UpdateFeedCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_feed_pda, get_globalstate_pda}, + processors::feed::update::FeedUpdateArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_feed_update_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _globalstate) = get_globalstate_pda(&client.get_program_id()); + let (pda_pubkey, _) = get_feed_pda(&client.get_program_id(), "test_feed"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { + name: Some("Test Feed".to_string()), + metros: None, + })), + predicate::eq(vec![ + AccountMeta::new(pda_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = UpdateFeedCommand { + pubkey: pda_pubkey, + name: Some("Test Feed".to_string()), + metros: None, + } + .execute(&client); + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/mod.rs b/smartcontract/sdk/rs/src/commands/mod.rs index 77bc50a37..0f338a714 100644 --- a/smartcontract/sdk/rs/src/commands/mod.rs +++ b/smartcontract/sdk/rs/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod allowlist; pub mod contributor; pub mod device; pub mod exchange; +pub mod feed; pub mod globalconfig; pub mod globalstate; pub mod index; diff --git a/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs b/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs index db46505f2..4c1e1b78a 100644 --- a/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs +++ b/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs @@ -21,6 +21,11 @@ pub struct UpdateMulticastGroupRolesCommand { pub user_pk: Pubkey, pub publisher: bool, pub subscriber: bool, + /// Reserved for the EdgeSeat feed metro gate (the user's device + covering Feed). Not appended + /// by this builder — the authorized-transaction layout has no slot after the trailing + /// `[payer, system, permission]`; post-activation re-gating is deferred to #1699. + pub device_pk: Option, + pub feed_pk: Option, } impl UpdateMulticastGroupRolesCommand { @@ -65,7 +70,7 @@ impl UpdateMulticastGroupRolesCommand { &client.get_program_id(), ResourceType::MulticastPublisherBlock, ); - let accounts = vec![ + let mut accounts = vec![ AccountMeta::new(self.group_pk, false), AccountMeta::new(accesspass_pubkey, false), AccountMeta::new(self.user_pk, false), @@ -73,11 +78,15 @@ impl UpdateMulticastGroupRolesCommand { AccountMeta::new(multicast_publisher_block_ext, false), ]; - // Use the authorized path so the payer's Permission account is appended when - // it exists on-chain. Removal-only cleanup (e.g. DeleteUserCommand / - // RequestBanUserCommand) is authorized via USER_ADMIN when the payer is - // neither the access-pass owner nor a foundation member; for owner/foundation - // callers the (optional) trailing account is simply ignored on-chain. + // Use the authorized path so the payer's Permission account is appended when it exists + // on-chain. Removal-only cleanup (DeleteUserCommand / RequestBanUserCommand) is authorized + // via USER_ADMIN when the payer is neither the access-pass owner nor a foundation member. + // + // The EdgeSeat feed metro gate is enforced at connect (CreateSubscribeUser). The optional + // `device_pk`/`feed_pk` for post-activation re-gating are NOT appended here: the on-chain + // trailing `[payer, system, permission]` layout (see `assemble_instructions`) leaves no slot + // for them via this path. Post-activation re-gating is deferred to the oracle lifecycle + // (see malbeclabs/infra#1700 / doublezero #1699). client.execute_authorized_transaction( DoubleZeroInstruction::UpdateMulticastGroupRoles(UpdateMulticastGroupRolesArgs { publisher: self.publisher, @@ -241,6 +250,8 @@ mod tests { client_ip, publisher: true, subscriber: false, + device_pk: None, + feed_pk: None, } .execute(&client); diff --git a/smartcontract/sdk/rs/src/commands/tenant/delete.rs b/smartcontract/sdk/rs/src/commands/tenant/delete.rs index 017f342b5..9cdfd74b8 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/delete.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/delete.rs @@ -35,7 +35,11 @@ impl DeleteTenantCommand { .collect(); for user_pk in &tenant_users { - DeleteUserCommand { pubkey: *user_pk }.execute(client)?; + DeleteUserCommand { + pubkey: *user_pk, + feed_pk: None, + } + .execute(client)?; } // 2. Clean up access passes before waiting for reference_count to reach 0 diff --git a/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs b/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs index 448528dfc..bf907f6a4 100644 --- a/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs +++ b/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs @@ -32,6 +32,10 @@ pub struct CreateSubscribeUserCommand { /// Custom owner pubkey (foundation allowlist only). When set, the access pass /// is looked up for this owner instead of the payer. pub owner: Option, + /// Optional trailing Feed account for the EdgeSeat metro gate: the feed (referenced + /// by the pass) covering the device's exchange and listing the target multicast group. + /// Appended to the account list only when provided. + pub feed_pk: Option, } impl CreateSubscribeUserCommand { @@ -115,6 +119,11 @@ impl CreateSubscribeUserCommand { accounts.push(AccountMeta::new(dz_prefix_ext, false)); } + // Optional trailing Feed account (EdgeSeat metro gate). Appended only when provided. + if let Some(feed_pk) = self.feed_pk { + accounts.push(AccountMeta::new_readonly(feed_pk, false)); + } + client .execute_transaction( DoubleZeroInstruction::CreateSubscribeUser(UserCreateSubscribeArgs { @@ -271,6 +280,7 @@ mod tests { subscriber: false, tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: None, + feed_pk: None, } .execute(&client); diff --git a/smartcontract/sdk/rs/src/commands/user/delete.rs b/smartcontract/sdk/rs/src/commands/user/delete.rs index 3bb2a13b1..464820f57 100644 --- a/smartcontract/sdk/rs/src/commands/user/delete.rs +++ b/smartcontract/sdk/rs/src/commands/user/delete.rs @@ -20,6 +20,20 @@ use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature} #[derive(Debug, PartialEq, Clone)] pub struct DeleteUserCommand { pub pubkey: Pubkey, + /// Reserved for EdgeSeat feed-seat release. Not appended by this builder — the + /// authorized-transaction layout has no slot after the trailing `[payer, system, permission]`; + /// seat release is deferred to the oracle lifecycle (#1699). + pub feed_pk: Option, +} + +impl DeleteUserCommand { + /// Convenience constructor preserving the previous `{ pubkey }` call sites. + pub fn new(pubkey: Pubkey) -> Self { + Self { + pubkey, + feed_pk: None, + } + } } impl DeleteUserCommand { @@ -51,6 +65,8 @@ impl DeleteUserCommand { client_ip: user.client_ip, publisher: false, subscriber: false, + device_pk: None, + feed_pk: None, } .execute(client)?; } @@ -120,6 +136,10 @@ impl DeleteUserCommand { accounts.push(AccountMeta::new(user.owner, false)); + // The on-chain DeleteUser releases the EdgeSeat feed seat from an optional trailing Feed + // account, but the authorized-transaction layout (`[..., payer, system, permission]`) has no + // slot for it here, so this builder does not append one. Seat release is deferred to the + // oracle lifecycle (see malbeclabs/infra#1700 / doublezero #1699). client.execute_authorized_transaction( DoubleZeroInstruction::DeleteUser(UserDeleteArgs { dz_prefix_count: dz_prefix_count_u8, @@ -375,6 +395,7 @@ mod tests { let res = DeleteUserCommand { pubkey: user_pubkey, + feed_pk: None, } .execute(&client); @@ -592,6 +613,7 @@ mod tests { let res = DeleteUserCommand { pubkey: user_pubkey, + feed_pk: None, } .execute(&client); @@ -859,6 +881,7 @@ mod tests { let res = DeleteUserCommand { pubkey: user_pubkey, + feed_pk: None, } .execute(&client); @@ -985,6 +1008,7 @@ mod tests { let res = DeleteUserCommand { pubkey: user_pubkey, + feed_pk: None, } .execute(&client); diff --git a/smartcontract/sdk/rs/src/commands/user/requestban.rs b/smartcontract/sdk/rs/src/commands/user/requestban.rs index bfd5d9561..dacf90be5 100644 --- a/smartcontract/sdk/rs/src/commands/user/requestban.rs +++ b/smartcontract/sdk/rs/src/commands/user/requestban.rs @@ -50,6 +50,8 @@ impl RequestBanUserCommand { client_ip: user.client_ip, publisher: false, subscriber: false, + device_pk: None, + feed_pk: None, } .execute(client)?; } diff --git a/smartcontract/sdk/rs/src/lib.rs b/smartcontract/sdk/rs/src/lib.rs index e4bd9d0a7..6e68bb3da 100644 --- a/smartcontract/sdk/rs/src/lib.rs +++ b/smartcontract/sdk/rs/src/lib.rs @@ -6,9 +6,9 @@ pub use crate::config::{ pub use doublezero_serviceability::{ addresses::*, pda::{ - get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, - get_location_pda, get_multicastgroup_pda, get_permission_pda, get_resource_extension_pda, - get_tenant_pda, get_topology_pda, get_user_old_pda, + get_contributor_pda, get_device_pda, get_exchange_pda, get_feed_pda, get_globalconfig_pda, + get_link_pda, get_location_pda, get_multicastgroup_pda, get_permission_pda, + get_resource_extension_pda, get_tenant_pda, get_topology_pda, get_user_old_pda, }, programversion::ProgramVersion, resource::{IdOrIp, ResourceType}, @@ -18,6 +18,7 @@ pub use doublezero_serviceability::{ contributor::{Contributor, ContributorStatus}, device::{Device, DeviceStatus, DeviceType}, exchange::{Exchange, ExchangeStatus, BGP_COMMUNITY_MAX, BGP_COMMUNITY_MIN}, + feed::Feed, globalconfig::GlobalConfig, globalstate::GlobalState, interface::{Interface, InterfaceDeprecated, InterfaceStatus, InterfaceType, LoopbackType},