From 636d8af28eb147a52a41df6bd8f7065959adb5df Mon Sep 17 00:00:00 2001 From: Jared-dz Date: Sat, 29 Aug 2026 14:30:52 -0400 Subject: [PATCH] serviceability: a permissionless flag on Feed, declared not enforced RFC-1 (malbeclabs/infra#2390) needs the storefront to know whether a feed is offered without an access grant. Today that lives in a hand-maintained file in the website repo, so a feed and the claim about it are edited in two places and drift silently. This puts the claim on the feed. The flag is DECLARATIVE. No instruction reads it, no gate consults it, and the paid gate is still the EdgeSeat FeedSeat. That is not a shortcut: this program has no purchase path to gate. The only check a serviceability flag could have short-circuited is check_feed_metro_coverage, which is the check that a connection holds a paid seat -- turning that off would make a feed free, not self-serve. Gating a purchase belongs on the shreds side, where the purchase is (malbeclabs/infra#1704, closed not-planned). No migration, no backfill, verified rather than assumed: - Accounts. Feed::try_from is hand-written and deserializes field by field with unwrap_or_default(), so the 151 accounts already on mainnet read false at EOF. Pinned by a LegacyFeed encode/decode test that also asserts the fields BEFORE the new one still land correctly -- a decoder that mis-read the missing byte would corrupt those, not just the flag. - Instructions. borsh-incremental substitutes the default only when a field consumes zero bytes, which a trailing bool either does entirely or not at all, so it can never hit the partial-read error branch. Pinned by a test that serializes CreateFeed, pops the trailing byte to get exactly what an old client emits, submits the raw bytes, and asserts the feed is created reading false. FeedUpdateArgs.permissionless is Option, not bool, because the processor rejects an args value equal to the default as a no-op: a bare false would be indistinguishable from one, leaving no way to turn the flag back off. A test flips it on at create, off, and on again. The CLI mirrors that split -- `feed create --permissionless` is a presence flag, `feed update --permissionless true|false` takes a value -- and `feed list` gains a permissionless column in the table and both JSON forms. The golden table string is rebuilt by hand. All four decoders move together, since there is no IDL: Rust, Go (ReadU8() != 0, the Tenant idiom -- there is no ReadBool), Python and TypeScript. Each is EOF-safe already, and each gains a truncation test asserting a pre-flag account reads false. feed.bin goes 154 -> 155 bytes with permissionless: true, so a decoder that skips the byte is caught rather than passing by coincidence. Also corrected while these structs were open: the four Feed doc comments called one account a SKU. A SKU is every Feed sharing a code (malbeclabs/infra#2390); one account is a feed, and its pubkey is the feed_key. Same correction as doublezerofoundation/doublezero.xyz-new#1015. Tests: 322 program lib, 9 feed integration (2 new), 437 CLI, 200 SDK, plus Go, Python and TypeScript fixture suites. cargo fmt, gofmt and clippy clean on every touched crate. --- CHANGELOG.md | 7 + crates/doublezero-daemon-cli/src/connect.rs | 2 + .../src/feed.rs | 2 + .../python/serviceability/state.py | 7 +- .../serviceability/tests/test_fixtures.py | 11 ++ sdk/serviceability/testdata/fixtures/feed.bin | Bin 154 -> 155 bytes .../testdata/fixtures/feed.json | 5 + .../fixtures/generate-fixtures/src/main.rs | 5 +- .../typescript/serviceability/state.ts | 8 +- .../serviceability/tests/fixtures.test.ts | 12 ++ smartcontract/cli/src/accesspass/get.rs | 3 + smartcontract/cli/src/accesspass/list.rs | 1 + smartcontract/cli/src/exchange/resolve.rs | 1 + smartcontract/cli/src/feed/create.rs | 10 ++ smartcontract/cli/src/feed/delete.rs | 1 + smartcontract/cli/src/feed/guard.rs | 1 + smartcontract/cli/src/feed/list.rs | 6 +- smartcontract/cli/src/feed/resolve.rs | 1 + smartcontract/cli/src/feed/update.rs | 17 ++ .../cli/src/user/create_subscribe.rs | 1 + smartcontract/cli/src/user/get.rs | 1 + .../src/instructions.rs | 2 + .../src/processors/feed/create.rs | 6 + .../src/processors/feed/update.rs | 12 +- .../src/state/feed.rs | 73 ++++++++- .../tests/feed_metro_gate_test.rs | 1 + .../tests/feed_subscription_test.rs | 1 + .../tests/feed_test.rs | 153 ++++++++++++++++++ .../tests/set_access_pass_feeds_test.rs | 2 + .../sdk/go/serviceability/deserialize.go | 2 + .../sdk/go/serviceability/fixture_test.go | 14 ++ smartcontract/sdk/go/serviceability/state.go | 9 +- .../sdk/rs/src/commands/feed/create.rs | 7 + smartcontract/sdk/rs/src/commands/feed/get.rs | 1 + .../sdk/rs/src/commands/feed/list.rs | 2 + .../sdk/rs/src/commands/feed/update.rs | 7 + .../commands/multicastgroup/subscribe_feed.rs | 1 + .../multicastgroup/unsubscribe_feed.rs | 1 + 38 files changed, 378 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc0cc36db..22c48d23b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ All notable changes to this project will be documented in this file. ### Changes +- CLI + - `doublezero feed create --permissionless` marks a feed as offered without an access grant, and `doublezero feed update --permissionless true|false` flips it afterwards. `feed list` gains a `permissionless` column, in the table and both JSON forms. The update flag takes a value rather than being a bare presence flag so that turning it off is distinguishable from leaving it alone: `FeedUpdateArgs` is compared against its default to reject a no-op update, and a bare `false` would be indistinguishable from one. (malbeclabs/infra#2390) +- Serviceability + - `Feed` carries a trailing `permissionless: bool`, set by `CreateFeed` (112) and changed by `UpdateFeed` (113). It is declarative: no instruction reads it, no gate consults it, and the paid gate for a feed is still the EdgeSeat FeedSeat. It exists so the storefront can read whether a feed is offered without a grant from the ledger instead of from a hand-maintained file, which is what a serviceability flag can honestly answer — this program has no purchase path to gate. Old accounts decode as `false` through the existing per-field `unwrap_or_default()` in `TryFrom<&[u8]>`, and old create encodings without the trailing byte decode as `false` through borsh-incremental, so no migration and no backfill is required. Deploy ordering (RFC-1): the program must deploy to all clusters before any client that emits the flag — an old program ignores the trailing byte rather than failing, so a new CLI against an old program would silently create a feed with the flag dropped. Deploy note: an existing feed grows by one byte the first time any `UpdateFeed` writes it, including a `--name`-only update that never mentions the flag, and the rent delta is charged to the signer. (malbeclabs/infra#2390) +- SDK + - The Go, Python and TypeScript `Feed` decoders read the new trailing `permissionless` byte, defaulting to `false` past end-of-input so an account written before the field decodes unchanged. `CreateFeedCommand` gains `permissionless: bool` and `UpdateFeedCommand` gains `permissionless: Option`. (malbeclabs/infra#2390) + ## [v0.38.0](https://github.com/malbeclabs/doublezero/compare/client/v0.37.0...client/v0.38.0) - 2026-08-28 ### Breaking diff --git a/crates/doublezero-daemon-cli/src/connect.rs b/crates/doublezero-daemon-cli/src/connect.rs index eec0c5285b..75b4c7c926 100644 --- a/crates/doublezero-daemon-cli/src/connect.rs +++ b/crates/doublezero-daemon-cli/src/connect.rs @@ -2682,6 +2682,7 @@ mod tests { name: code.to_string(), exchange, groups: vec![Pubkey::new_unique()], + permissionless: false, } } @@ -3504,6 +3505,7 @@ mod tests { name: code.to_string(), exchange, groups, + permissionless: false, }; self.feeds.lock().unwrap().insert(pk, feed); pk diff --git a/crates/doublezero-serviceability-instruction/src/feed.rs b/crates/doublezero-serviceability-instruction/src/feed.rs index 16ecf46051..0913cb4a00 100644 --- a/crates/doublezero-serviceability-instruction/src/feed.rs +++ b/crates/doublezero-serviceability-instruction/src/feed.rs @@ -106,6 +106,7 @@ mod tests { name: "Feed".to_string(), exchange, groups: vec![Pubkey::new_unique()], + permissionless: false, }; let ix = create_feed(&pid, &payer, args); assert_eq!(ix.data[0], 112); @@ -143,6 +144,7 @@ mod tests { FeedUpdateArgs { name: Some("Feed".to_string()), groups: None, + permissionless: None, }, ); assert_eq!(update.data[0], 113); diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 9e3a32c2b4..a5521ba9b3 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -1039,7 +1039,7 @@ def from_bytes(cls, data: bytes) -> Tenant: @dataclass class FeedSeat: - """One purchased SKU seat on an EdgeSeat access pass, carrying a feed's whole billing state. + """One purchased feed's seat on an EdgeSeat access pass, carrying a feed's whole billing state. The cap is ``max_users`` before ``window_end`` and ``max_future_users`` from ``window_end`` until ``terminates_at``, when the feed is removed from the pass. ``current_users`` is the live @@ -1242,7 +1242,7 @@ def from_bytes(cls, data: bytes) -> TopologyInfo: @dataclass class Feed: - """Serviceability catalog entry: one SKU scoped to a single metro (exchange), holding the + """Serviceability catalog entry: one feed scoped to a single metro (exchange), holding the multicast groups joinable there. One feed_key is one feed in one metro. """ @@ -1253,6 +1253,8 @@ class Feed: name: str = "" exchange: Pubkey = Pubkey.default() groups: list[Pubkey] = field(default_factory=list) + # Declarative catalog label; false on an account written before the field existed. + permissionless: bool = False pub_key: Pubkey = Pubkey.default() # set from account address after deserialization @classmethod @@ -1267,4 +1269,5 @@ def from_bytes(cls, data: bytes) -> Feed: # A feed serves one metro: an exchange pubkey followed by a Vec of joinable groups. f.exchange = _read_pubkey(r) f.groups = _read_pubkey_vec(r) + f.permissionless = r.read_bool() return f diff --git a/sdk/serviceability/python/serviceability/tests/test_fixtures.py b/sdk/serviceability/python/serviceability/tests/test_fixtures.py index b7cc5c588a..c1caeeddb4 100644 --- a/sdk/serviceability/python/serviceability/tests/test_fixtures.py +++ b/sdk/serviceability/python/serviceability/tests/test_fixtures.py @@ -582,6 +582,7 @@ def test_deserialize(self): "GroupsLen": len(feed.groups), "Group0": feed.groups[0], "Group1": feed.groups[1], + "Permissionless": feed.permissionless, }, ) assert feed.account_type == 18 @@ -589,6 +590,16 @@ def test_deserialize(self): assert feed.code == "shreds" assert feed.name == "Shreds" assert len(feed.groups) == 2 + assert feed.permissionless is True + + def test_permissionless_defaults_false_on_a_pre_flag_account(self): + # An account written before the flag existed lacks the trailing byte; it reads false, + # matching the Rust program's TryFrom unwrap_or_default. + data, _ = _load_fixture("feed") + feed = Feed.from_bytes(data[:-1]) + assert feed.permissionless is False + assert feed.code == "shreds" + assert len(feed.groups) == 2 class TestFixtureAccessPassLegacyCapDefaults: diff --git a/sdk/serviceability/testdata/fixtures/feed.bin b/sdk/serviceability/testdata/fixtures/feed.bin index c0f93f1c706dee55118fd51ff671267cea25e516..07d300b597abd79fdcf081824bd0f30e1dfbfb53 100644 GIT binary patch delta 8 PcmbQmIGb_8EJj8E43`3A delta 6 NcmbQuIE!(@EC2`60%8CF diff --git a/sdk/serviceability/testdata/fixtures/feed.json b/sdk/serviceability/testdata/fixtures/feed.json index 395c248a9c..ce972c4b38 100644 --- a/sdk/serviceability/testdata/fixtures/feed.json +++ b/sdk/serviceability/testdata/fixtures/feed.json @@ -46,6 +46,11 @@ "name": "Group1", "value": "GH7YkRi9soP4j2JAUYTMEMBtkDFyGf7oYu1aGGRucd5H", "typ": "pubkey" + }, + { + "name": "Permissionless", + "value": "true", + "typ": "bool" } ] } \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 7c406f0e3d..d4ca5a0c6f 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -1491,7 +1491,8 @@ fn generate_access_pass_edge_seat(dir: &Path) { } /// Borsh-encoded `Feed` account. Field order: account_type, owner, bump_seed, code, name, -/// exchange (Pubkey), groups (Vec). Two groups, so the vec decoding is exercised. +/// exchange (Pubkey), groups (Vec), permissionless (bool). Two groups, so the vec +/// decoding is exercised; permissionless is true so a decoder that skips the byte is caught. fn generate_feed(dir: &Path) { let owner = pubkey_from_byte(0xE0); let exchange = pubkey_from_byte(0xE1); @@ -1506,6 +1507,7 @@ fn generate_feed(dir: &Path) { name: "Shreds".into(), exchange, groups: vec![group0, group1], + permissionless: true, }; let data = borsh::to_vec(&val).unwrap(); @@ -1523,6 +1525,7 @@ fn generate_feed(dir: &Path) { FieldValue { name: "GroupsLen".into(), value: "2".into(), typ: "u32".into() }, FieldValue { name: "Group0".into(), value: pubkey_bs58(&group0), typ: "pubkey".into() }, FieldValue { name: "Group1".into(), value: pubkey_bs58(&group1), typ: "pubkey".into() }, + FieldValue { name: "Permissionless".into(), value: "true".into(), typ: "bool".into() }, ], }; diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 76e190feca..ee0f122132 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -1053,7 +1053,7 @@ 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, carrying a feed's whole billing state. The cap +// One purchased feed's seat on an EdgeSeat access pass, carrying a feed's whole billing state. The cap // is maxUsers before windowEnd and maxFutureUsers from windowEnd until terminatesAt, when the feed // is removed from the pass. currentUsers is the live count. anniversaryDay is the original start // day-of-month (1..=31) for drift-free renewals. windowEnd and terminatesAt are unix seconds. @@ -1236,7 +1236,7 @@ export function deserializePermission(data: Uint8Array): Permission { // Feed // --------------------------------------------------------------------------- -// Serviceability catalog entry: one SKU scoped to a single metro (exchange), holding the +// Serviceability catalog entry: one feed scoped to a single metro (exchange), holding the // multicast groups joinable there. One feed_key is one feed in one metro. export interface Feed { accountType: number; @@ -1246,6 +1246,8 @@ export interface Feed { name: string; exchange: PublicKey; groups: PublicKey[]; + /** Declarative catalog label; false on an account written before the field existed. */ + permissionless: boolean; } export function deserializeFeed(data: Uint8Array): Feed { @@ -1258,6 +1260,7 @@ export function deserializeFeed(data: Uint8Array): Feed { // A feed serves one metro: an exchange pubkey followed by a Vec of joinable groups. const exchange = readPubkey(r); const groups = readPubkeyVec(r); + const permissionless = r.readBool(); return { accountType, owner, @@ -1266,5 +1269,6 @@ export function deserializeFeed(data: Uint8Array): Feed { name, exchange, groups, + permissionless, }; } diff --git a/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts b/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts index 4da608ede0..ee005ae738 100644 --- a/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts +++ b/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts @@ -597,6 +597,7 @@ describe("Feed fixture", () => { GroupsLen: feed.groups.length, Group0: feed.groups[0], Group1: feed.groups[1], + Permissionless: feed.permissionless, }); expect(feed.accountType).toBe(18); @@ -604,6 +605,17 @@ describe("Feed fixture", () => { expect(feed.code).toBe("shreds"); expect(feed.name).toBe("Shreds"); expect(feed.groups).toHaveLength(2); + expect(feed.permissionless).toBe(true); + }); + + test("permissionless defaults false on a pre-flag account", () => { + // An account written before the flag existed lacks the trailing byte; it reads false, + // matching the Rust program's TryFrom unwrap_or_default. + const [data] = loadFixture("feed"); + const feed = deserializeFeed(data.slice(0, -1)); + expect(feed.permissionless).toBe(false); + expect(feed.code).toBe("shreds"); + expect(feed.groups).toHaveLength(2); }); }); diff --git a/smartcontract/cli/src/accesspass/get.rs b/smartcontract/cli/src/accesspass/get.rs index cd31005d1f..e2aa895591 100644 --- a/smartcontract/cli/src/accesspass/get.rs +++ b/smartcontract/cli/src/accesspass/get.rs @@ -457,6 +457,7 @@ mod tests { name: "QA Payments".to_string(), exchange: exchange_key, groups: vec![group_key], + permissionless: false, }; let mgroup = MulticastGroup { @@ -744,6 +745,7 @@ mod tests { name: "Lashay 1".to_string(), exchange: *exchange, groups: vec![], + permissionless: false, }) .collect(); @@ -848,6 +850,7 @@ mod tests { name: "QA Payments".to_string(), exchange: exchange_key, groups: vec![], + permissionless: false, }; client diff --git a/smartcontract/cli/src/accesspass/list.rs b/smartcontract/cli/src/accesspass/list.rs index 36188dd5ba..c0bbc0aa26 100644 --- a/smartcontract/cli/src/accesspass/list.rs +++ b/smartcontract/cli/src/accesspass/list.rs @@ -976,6 +976,7 @@ mod tests { name: "QA Payments".to_string(), exchange: Pubkey::new_unique(), groups: vec![feed_group_pubkey], + permissionless: false, }; let accesspass = AccessPass { diff --git a/smartcontract/cli/src/exchange/resolve.rs b/smartcontract/cli/src/exchange/resolve.rs index 3658dddf22..4432e2032d 100644 --- a/smartcontract/cli/src/exchange/resolve.rs +++ b/smartcontract/cli/src/exchange/resolve.rs @@ -108,6 +108,7 @@ mod tests { name: "QA Payments".to_string(), exchange: Pubkey::new_unique(), groups: vec![], + permissionless: false, }; client diff --git a/smartcontract/cli/src/feed/create.rs b/smartcontract/cli/src/feed/create.rs index d8a0ee81f3..b8d9d3ad3a 100644 --- a/smartcontract/cli/src/feed/create.rs +++ b/smartcontract/cli/src/feed/create.rs @@ -23,6 +23,9 @@ pub struct CreateFeedCliCommand { /// Multicast group pubkey or code joinable in this metro (repeatable) #[arg(long = "group", value_parser = validate_pubkey_or_code, num_args = 1..)] pub groups: Vec, + /// Offer this feed without an access grant. Off unless given. + #[arg(long, default_value_t = false)] + pub permissionless: bool, } impl CreateFeedCliCommand { @@ -50,6 +53,7 @@ impl CreateFeedCliCommand { name: self.name, exchange, groups, + permissionless: self.permissionless, })?; print_signature(out, &signature) @@ -142,6 +146,7 @@ mod tests { name: "Feed".to_string(), exchange: exchange_pk, groups: vec![group_pk], + permissionless: false, })) .times(1) .returning(move |_| Ok((signature, feed_pk))); @@ -154,6 +159,7 @@ mod tests { name: "Feed".to_string(), exchange: exchange_pk.to_string(), groups: vec![group_pk.to_string()], + permissionless: false, } .execute(&ctx, &client, &mut output), ); @@ -199,6 +205,7 @@ mod tests { name: "Feed".to_string(), exchange: exchange_pk, groups: vec![group_pk], + permissionless: false, })) .times(1) .returning(move |_| Ok((signature, feed_pk))); @@ -211,6 +218,7 @@ mod tests { name: "Feed".to_string(), exchange: "xchi".to_string(), groups: vec!["mg01".to_string()], + permissionless: false, } .execute(&ctx, &client, &mut output), ); @@ -243,6 +251,7 @@ mod tests { name: "Feed".to_string(), exchange: exchange_pk.to_string(), groups: vec!["nope".to_string()], + permissionless: false, } .execute(&ctx, &client, &mut output), ); @@ -270,6 +279,7 @@ mod tests { name: "Feed".to_string(), exchange: "nope".to_string(), groups: vec![], + permissionless: false, } .execute(&ctx, &client, &mut output), ); diff --git a/smartcontract/cli/src/feed/delete.rs b/smartcontract/cli/src/feed/delete.rs index bd2c0dfda1..bc9f65ab13 100644 --- a/smartcontract/cli/src/feed/delete.rs +++ b/smartcontract/cli/src/feed/delete.rs @@ -206,6 +206,7 @@ mod tests { name: "Feed".to_string(), exchange: exchange_pk, groups: vec![], + permissionless: false, }; let feed_for_get = feed.clone(); client diff --git a/smartcontract/cli/src/feed/guard.rs b/smartcontract/cli/src/feed/guard.rs index 566676bf42..7321a80cfb 100644 --- a/smartcontract/cli/src/feed/guard.rs +++ b/smartcontract/cli/src/feed/guard.rs @@ -394,6 +394,7 @@ pub(crate) mod fixtures { name: "Feed".to_string(), exchange, groups, + permissionless: false, } } diff --git a/smartcontract/cli/src/feed/list.rs b/smartcontract/cli/src/feed/list.rs index bc4b90aba4..312e57e52f 100644 --- a/smartcontract/cli/src/feed/list.rs +++ b/smartcontract/cli/src/feed/list.rs @@ -40,6 +40,7 @@ pub struct FeedDisplay { pub exchange: String, pub groups: usize, pub group_codes: String, + pub permissionless: bool, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } @@ -85,6 +86,7 @@ impl ListFeedCliCommand { }) .collect::>() .join(", "), + permissionless: feed.permissionless, owner: feed.owner, }) .collect::>(); @@ -130,6 +132,7 @@ mod tests { name: "QA Payments".to_string(), exchange: exchange_pk, groups: vec![mgroup_pk, unknown_mgroup_pk], + permissionless: false, }; client.expect_list_feed().returning(move |_| { let mut feeds = HashMap::new(); @@ -194,7 +197,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - " account | code | name | exchange | groups | group_codes | owner \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | qa-payments | QA Payments | xams | 2 | mg01, 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo4 | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 \n" + " account | code | name | exchange | groups | group_codes | permissionless | owner \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | qa-payments | QA Payments | xams | 2 | mg01, 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo4 | false | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 \n" ); } @@ -214,6 +217,7 @@ mod tests { name: code.to_string(), exchange, groups: vec![], + permissionless: false, }; client.expect_list_feed().returning(move |_| { diff --git a/smartcontract/cli/src/feed/resolve.rs b/smartcontract/cli/src/feed/resolve.rs index a7d005b24b..b80c86f5a6 100644 --- a/smartcontract/cli/src/feed/resolve.rs +++ b/smartcontract/cli/src/feed/resolve.rs @@ -126,6 +126,7 @@ mod tests { name: code.to_string(), exchange, groups: vec![], + permissionless: false, } } diff --git a/smartcontract/cli/src/feed/update.rs b/smartcontract/cli/src/feed/update.rs index f3645ce48c..cba268bba7 100644 --- a/smartcontract/cli/src/feed/update.rs +++ b/smartcontract/cli/src/feed/update.rs @@ -33,6 +33,10 @@ pub struct UpdateFeedCliCommand { /// outside their access pass's feeds fails and changes nothing. #[arg(long, default_value_t = false)] pub force_unsubscribe: bool, + /// Offer this feed without an access grant, or stop offering it. Takes a value so that + /// turning the flag off is distinguishable from leaving it alone. + #[arg(long)] + pub permissionless: Option, } impl UpdateFeedCliCommand { @@ -88,6 +92,7 @@ impl UpdateFeedCliCommand { pubkey, name: self.name, groups, + permissionless: self.permissionless, })?; print_signature(out, &signature) @@ -142,6 +147,7 @@ mod tests { name: None, groups: vec![g1.to_string()], force_unsubscribe: false, + permissionless: None, } .execute(&ctx, &client, &mut output), ); @@ -190,6 +196,7 @@ mod tests { pubkey: f.feed_pk, name: None, groups: Some(vec![g1]), + permissionless: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -204,6 +211,7 @@ mod tests { name: None, groups: vec![g1.to_string()], force_unsubscribe: true, + permissionless: None, } .execute(&ctx, &client, &mut output), ); @@ -252,6 +260,7 @@ mod tests { pubkey: f.feed_pk, name: None, groups: Some(vec![g1]), + permissionless: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -266,6 +275,7 @@ mod tests { name: None, groups: vec![g1.to_string()], force_unsubscribe: true, + permissionless: None, } .execute(&ctx, &client, &mut output), ); @@ -294,6 +304,7 @@ mod tests { pubkey: f.feed_pk, name: None, groups: Some(vec![g1, g2]), + permissionless: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -308,6 +319,7 @@ mod tests { name: None, groups: vec![g1.to_string(), g2.to_string()], force_unsubscribe: false, + permissionless: None, } .execute(&ctx, &client, &mut output), ); @@ -366,6 +378,7 @@ mod tests { name: None, groups: vec![g1.to_string()], force_unsubscribe: true, + permissionless: None, } .execute(&ctx, &client, &mut output), ); @@ -434,6 +447,7 @@ mod tests { name: None, groups: vec![g1.to_string()], force_unsubscribe: true, + permissionless: None, } .execute(&ctx, &client, &mut output), ); @@ -491,6 +505,7 @@ mod tests { name: "Feed".to_string(), exchange: exchange_pk, groups: vec![], + permissionless: false, }; let feed_for_get = feed.clone(); client @@ -541,6 +556,7 @@ mod tests { pubkey: feed_pk, name: Some("Feed v2".to_string()), groups: Some(vec![group_pk]), + permissionless: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -555,6 +571,7 @@ mod tests { name: Some("Feed v2".to_string()), groups: vec!["mg01".to_string()], force_unsubscribe: false, + permissionless: None, } .execute(&ctx, &client, &mut output), ); diff --git a/smartcontract/cli/src/user/create_subscribe.rs b/smartcontract/cli/src/user/create_subscribe.rs index ba7c8f9651..7e4eaabf21 100644 --- a/smartcontract/cli/src/user/create_subscribe.rs +++ b/smartcontract/cli/src/user/create_subscribe.rs @@ -503,6 +503,7 @@ mod tests { name: "Shreds NYC".to_string(), exchange: Pubkey::new_unique(), groups: vec![], + permissionless: false, }; client .expect_get_feed() diff --git a/smartcontract/cli/src/user/get.rs b/smartcontract/cli/src/user/get.rs index 0c67c1bc34..047ce31472 100644 --- a/smartcontract/cli/src/user/get.rs +++ b/smartcontract/cli/src/user/get.rs @@ -476,6 +476,7 @@ mod tests { name: "QA Payments".to_string(), exchange: exchange_key, groups: vec![group_pubkey], + permissionless: false, }; let exchange = Exchange { diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index a68995bbaa..ab8b0e0f6e 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -1390,6 +1390,7 @@ mod tests { name: "Shreds".to_string(), exchange: Pubkey::new_unique(), groups: vec![Pubkey::new_unique()], + permissionless: true, }), "CreateFeed", ); @@ -1397,6 +1398,7 @@ mod tests { DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { name: Some("Shreds".to_string()), groups: Some(vec![Pubkey::new_unique()]), + permissionless: Some(true), }), "UpdateFeed", ); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs index 65e21eefcc..07a5f3ef9e 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs @@ -34,6 +34,11 @@ pub struct FeedCreateArgs { pub exchange: Pubkey, /// Multicast groups joinable in this metro. pub groups: Vec, + /// Offer the feed without an access grant. Trailing and incremental, so a client built + /// before this field existed sends a shorter buffer and gets `false` — the same answer the + /// account gives for a feed created then. + #[incremental(default = false)] + pub permissionless: bool, } pub fn process_create_feed( @@ -93,6 +98,7 @@ pub fn process_create_feed( name: value.name.clone(), exchange: value.exchange, groups: value.groups.clone(), + permissionless: value.permissionless, }; try_acc_create( diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs index 243d9e338d..de5eebd69a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs @@ -14,12 +14,17 @@ use solana_program::{ pubkey::Pubkey, }; -/// `code` and `exchange` are the PDA seeds and therefore immutable; only `name` and the group set -/// are mutable. +/// `code` and `exchange` are the PDA seeds and therefore immutable; `name`, the group set and +/// the permissionless flag are mutable. #[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Debug, Clone, Default)] pub struct FeedUpdateArgs { pub name: Option, pub groups: Option>, + /// `Option`, not a bare `bool`, because of the no-op guard below: a bare `false` would be + /// indistinguishable from `FeedUpdateArgs::default()` and rejected, leaving no way to turn + /// the flag back off. + #[incremental(default = None)] + pub permissionless: Option, } pub fn process_update_feed( @@ -70,6 +75,9 @@ pub fn process_update_feed( if let Some(ref groups) = value.groups { feed.groups = groups.clone(); } + if let Some(permissionless) = value.permissionless { + feed.permissionless = permissionless; + } try_acc_write(&feed, feed_account, payer_account, accounts)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs index 6832019906..a5bc56652a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs @@ -6,13 +6,14 @@ use borsh::{BorshDeserialize, BorshSerialize}; use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError, pubkey::Pubkey}; use std::fmt; -/// A serviceability catalog entry: one SKU scoped to a single metro (`exchange`), holding the +/// A serviceability catalog entry: one feed scoped to a single metro (`exchange`), holding the /// multicast groups joinable there. /// -/// The pubkey of this account (`feed_key`) is the SKU identifier carried on EdgeSeat access passes. -/// `code` and `exchange` are the PDA seeds, so both are immutable; `name` and `groups` are mutable. -/// One `feed_key` is one feed in one metro (e.g. `shreds@tokyo`); a different metro is a -/// different feed account. +/// The pubkey of this account is the `feed_key` carried on EdgeSeat access passes. +/// `code` and `exchange` are the PDA seeds, so both are immutable; `name`, `groups` and +/// `permissionless` are mutable. One `feed_key` is one feed in one metro (e.g. `shreds@tokyo`); +/// a different metro is a different feed account. Every account sharing a `code` is one SKU +/// (malbeclabs/infra#2390) — a readability term for the storefront, not something stored here. #[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Feed { @@ -30,6 +31,11 @@ pub struct Feed { pub name: String, // 4 + len pub exchange: Pubkey, // 32 (PDA seed, immutable) - the metro this feed serves pub groups: Vec, // 4 + 32*len - multicast groups joinable in this metro + /// Whether this feed is offered without an access grant. Declarative: no instruction reads + /// it, and the paid gate is still the EdgeSeat FeedSeat. It is a catalog label the storefront + /// reads back from `feed list`, so `false` on an account written before this field existed is + /// the correct answer, not a missing one. + pub permissionless: bool, // 1 } impl Feed { @@ -48,14 +54,15 @@ impl fmt::Display for Feed { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, bump_seed: {}, code: {}, name: {}, exchange: {}, groups: {}", + "account_type: {}, owner: {}, bump_seed: {}, code: {}, name: {}, exchange: {}, groups: {}, permissionless: {}", self.account_type, self.owner, self.bump_seed, self.code, self.name, self.exchange, - self.groups.len() + self.groups.len(), + self.permissionless ) } } @@ -72,6 +79,8 @@ impl TryFrom<&[u8]> for Feed { name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), exchange: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), groups: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + // EOF on an account written before this field existed, which reads false. + permissionless: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Feed { @@ -118,6 +127,7 @@ mod tests { name: "Shreds".to_string(), exchange, groups, + permissionless: false, } } @@ -153,4 +163,53 @@ mod tests { let data = borsh::to_vec(&val).unwrap(); assert!(Feed::try_from(&data[..]).is_err()); } + + /// A Feed written before `permissionless` existed still decodes, reading false — the + /// per-field `unwrap_or_default()` in `TryFrom<&[u8]>` is what makes the 151 accounts + /// already on mainnet need no migration. + #[test] + fn test_feed_backward_compat_no_permissionless() { + #[derive(BorshSerialize)] + struct LegacyFeed { + account_type: AccountType, + owner: Pubkey, + bump_seed: u8, + code: String, + name: String, + exchange: Pubkey, + groups: Vec, + } + + let exchange = Pubkey::new_unique(); + let group = Pubkey::new_unique(); + let legacy = LegacyFeed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 7, + code: "shreds".to_string(), + name: "Shreds".to_string(), + exchange, + groups: vec![group], + }; + + let bytes = borsh::to_vec(&legacy).unwrap(); + let decoded = Feed::try_from(&bytes[..]).unwrap(); + + assert!(!decoded.permissionless); + // The fields before it still land where they should: a decoder that mis-read the + // missing byte would corrupt these rather than only the flag. + assert_eq!(decoded.code, "shreds"); + assert_eq!(decoded.exchange, exchange); + assert_eq!(decoded.groups, vec![group]); + } + + /// The flag round-trips when it is set, so the trailing byte is really written and read + /// rather than always defaulting. + #[test] + fn test_feed_permissionless_round_trip() { + let mut val = feed_with(Pubkey::new_unique(), vec![Pubkey::new_unique()]); + val.permissionless = true; + let data = borsh::to_vec(&val).unwrap(); + assert!(Feed::try_from(&data[..]).unwrap().permissionless); + } } diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs index cd28d39764..903c089e14 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs @@ -286,6 +286,7 @@ async fn create_feed( name: code.to_string(), exchange, groups, + permissionless: false, }), vec![ AccountMeta::new(feed_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_subscription_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_subscription_test.rs index 48f7364c4e..bac5e6fc3c 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_subscription_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_subscription_test.rs @@ -227,6 +227,7 @@ async fn create_feed(f: &mut Fixture, code: &str, exchange: Pubkey, groups: Vec< name: code.to_string(), exchange, groups, + permissionless: false, }), vec![ AccountMeta::new(feed_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_test.rs index 1589dfa55e..2f48f5dae3 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_test.rs @@ -109,6 +109,7 @@ async fn test_feed_create_get_update_delete() { name: "Shreds".to_string(), exchange, groups: groups.clone(), + permissionless: false, }), vec![ AccountMeta::new(feed_pubkey, false), @@ -139,6 +140,7 @@ async fn test_feed_create_get_update_delete() { DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { name: Some("Shreds v2".to_string()), groups: Some(new_groups.clone()), + permissionless: None, }), vec![ AccountMeta::new(feed_pubkey, false), @@ -197,6 +199,7 @@ async fn test_feed_same_code_different_exchange_allowed() { name: "Shreds".to_string(), exchange, groups: vec![Pubkey::new_unique()], + permissionless: false, }), vec![ AccountMeta::new(feed_pubkey, false), @@ -235,6 +238,7 @@ async fn test_feed_create_duplicate_rejected() { name: name.to_string(), exchange, groups: vec![Pubkey::new_unique()], + permissionless: false, }) }; @@ -288,6 +292,7 @@ async fn test_feed_create_empty_groups_rejected() { name: "Empty".to_string(), exchange, groups: vec![], + permissionless: false, }), vec![ AccountMeta::new(feed_pubkey, false), @@ -318,6 +323,7 @@ async fn test_feed_create_duplicate_group_rejected() { name: "Dup group".to_string(), exchange, groups: vec![group, group], + permissionless: false, }), vec![ AccountMeta::new(feed_pubkey, false), @@ -347,6 +353,7 @@ async fn test_feed_create_default_exchange_rejected() { name: "No default".to_string(), exchange: Pubkey::default(), groups: vec![Pubkey::new_unique()], + permissionless: false, }), vec![ AccountMeta::new(feed_pubkey, false), @@ -378,6 +385,7 @@ async fn test_feed_create_unauthorized_caller_rejected() { name: "Unauthorized".to_string(), exchange, groups: vec![Pubkey::new_unique()], + permissionless: false, }), vec![ AccountMeta::new(feed_pubkey, false), @@ -389,3 +397,148 @@ async fn test_feed_create_unauthorized_caller_rejected() { assert_custom_at_ix0(&result, custom_code(DoubleZeroError::NotAllowed)); } + +/// The pre-flag encoding (no trailing `permissionless` byte) still decodes and creates a feed — +/// wire compatibility for a CLI that has not been upgraded yet. +/// +/// This is the case the deploy ordering exists to make safe. `borsh-incremental` substitutes the +/// default only when a field consumes zero bytes, which a trailing bool either does entirely or +/// not at all, so an old client's shorter buffer lands on `false` rather than erroring. +#[tokio::test] +async fn test_feed_create_old_encoding_without_permissionless_byte() { + let (mut banks_client, program_id, payer, recent_blockhash) = init_test().await; + let globalstate_pubkey = + init_globalstate(&mut banks_client, program_id, &payer, recent_blockhash).await; + + let exchange = Pubkey::new_unique(); + let (feed_pubkey, _) = get_feed_pda(&program_id, "legacy", &exchange); + let group = Pubkey::new_unique(); + + // Serialize the current args and strip the trailing byte to get exactly what an old client + // emits. `true` so the byte is 1 and the assert below cannot pass by coincidence. + let mut data = borsh::to_vec(&DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: "legacy".to_string(), + name: "Legacy".to_string(), + exchange, + groups: vec![group], + permissionless: true, + })) + .unwrap(); + assert_eq!(data.pop(), Some(1), "last byte must be permissionless"); + + let accounts = vec![ + AccountMeta::new(feed_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new(solana_system_interface::program::ID, false), + ]; + let instruction = + solana_sdk::instruction::Instruction::new_with_bytes(program_id, &data, accounts); + let mut tx = + solana_sdk::transaction::Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client + .process_transaction(tx) + .await + .expect("the pre-flag encoding should still create a feed"); + + let feed = get_account_data(&mut banks_client, feed_pubkey) + .await + .expect("Unable to get Feed") + .get_feed() + .unwrap(); + assert_eq!(feed.code, "legacy".to_string()); + assert_eq!(feed.groups, vec![group]); + assert!( + !feed.permissionless, + "an omitted flag is false, not a decode failure" + ); +} + +/// The flag is settable at create and flippable both ways afterwards. +/// +/// Turning it back OFF is the case that pins `Option` on the update args: with a bare +/// `bool`, an args value carrying only `permissionless: false` would equal +/// `FeedUpdateArgs::default()` and be rejected by the no-op guard. +#[tokio::test] +async fn test_feed_permissionless_set_at_create_and_flipped_both_ways() { + let (mut banks_client, program_id, payer, recent_blockhash) = init_test().await; + let globalstate_pubkey = + init_globalstate(&mut banks_client, program_id, &payer, recent_blockhash).await; + + let exchange = Pubkey::new_unique(); + let (feed_pubkey, _) = get_feed_pda(&program_id, "open", &exchange); + let accounts = vec![ + AccountMeta::new(feed_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ]; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: "open".to_string(), + name: "Open".to_string(), + exchange, + groups: vec![Pubkey::new_unique()], + permissionless: true, + }), + accounts.clone(), + &payer, + ) + .await; + + let feed = get_account_data(&mut banks_client, feed_pubkey) + .await + .unwrap() + .get_feed() + .unwrap(); + assert!(feed.permissionless); + + // Off. A name-only update would leave it alone; this one names it. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { + name: None, + groups: None, + permissionless: Some(false), + }), + accounts.clone(), + &payer, + ) + .await; + assert!( + !get_account_data(&mut banks_client, feed_pubkey) + .await + .unwrap() + .get_feed() + .unwrap() + .permissionless + ); + + // And back on. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { + name: None, + groups: None, + permissionless: Some(true), + }), + accounts, + &payer, + ) + .await; + assert!( + get_account_data(&mut banks_client, feed_pubkey) + .await + .unwrap() + .get_feed() + .unwrap() + .permissionless + ); +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/set_access_pass_feeds_test.rs b/smartcontract/programs/doublezero-serviceability/tests/set_access_pass_feeds_test.rs index 6c72a1e3cc..73cf498eea 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/set_access_pass_feeds_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/set_access_pass_feeds_test.rs @@ -137,6 +137,7 @@ async fn create_feed( name: code.to_string(), exchange, groups: vec![Pubkey::new_unique()], + permissionless: false, }), vec![ AccountMeta::new(feed_pubkey, false), @@ -598,6 +599,7 @@ async fn test_cannot_set_max_users_below_current_users() { name: "Live".to_string(), exchange: feed_exchange, groups: vec![Pubkey::new_unique()], + permissionless: false, }; program_test.add_account( feed_pubkey, diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index 33dba89dfe..201a97eea9 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -512,5 +512,7 @@ func DeserializeFeed(reader *ByteReader, feed *Feed) { // groups. feed.Exchange = reader.ReadPubkey() feed.Groups = reader.ReadPubkeySlice() + // ReadU8 returns 0 past EOF, so an account written before this field reads false. + feed.Permissionless = (reader.ReadU8() != 0) // 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 7f6931624d..dd9570608e 100644 --- a/smartcontract/sdk/go/serviceability/fixture_test.go +++ b/smartcontract/sdk/go/serviceability/fixture_test.go @@ -240,6 +240,20 @@ func TestFixtureFeed(t *testing.T) { require.Len(t, feed.Groups, 2) assert.Equal(t, byte(0xE2), feed.Groups[0][0]) assert.Equal(t, byte(0xE3), feed.Groups[1][0]) + assert.True(t, feed.Permissionless) +} + +// An account written before the flag existed lacks the trailing byte. ReadU8 returns 0 past EOF, +// so it reads false — matching the Rust program's TryFrom unwrap_or_default. +func TestFixtureFeedPermissionlessDefaultsFalse(t *testing.T) { + data, _ := loadFixture(t, "feed") + + var feed serviceability.Feed + serviceability.DeserializeFeed(serviceability.NewByteReader(data[:len(data)-1]), &feed) + + assert.False(t, feed.Permissionless) + assert.Equal(t, "shreds", feed.Code) + require.Len(t, feed.Groups, 2) } func fixtureFieldValue(t *testing.T, meta fixtureMeta, name string) string { diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 155e4c01f5..794ad55e89 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -1138,7 +1138,7 @@ func (s AccessPassStatus) String() string { } } -// FeedSeat is one purchased SKU seat on an EdgeSeat access pass, carrying the feed's whole +// FeedSeat is one purchased feed's seat on an EdgeSeat access pass, carrying the feed's whole // billing lifecycle. FeedKey is the pubkey of the serviceability Feed account. The cap is // MaxUsers before WindowEnd and MaxFutureUsers from WindowEnd until TerminatesAt, when the feed // is removed from the pass. CurrentUsers is the live count. AnniversaryDay is the original start @@ -1434,7 +1434,7 @@ type TopologyInfo struct { PubKey [32]byte } -// Feed is a serviceability catalog entry: one SKU scoped to a single metro (Exchange), holding the +// Feed is a serviceability catalog entry: one feed scoped to a single metro (Exchange), holding the // multicast groups joinable there. One feed_key is one feed in one metro. type Feed struct { AccountType AccountType @@ -1444,5 +1444,8 @@ type Feed struct { Name string Exchange [32]byte Groups [][32]byte - PubKey [32]byte + // Permissionless is a declarative catalog label: no instruction reads it, and the paid gate + // is still the EdgeSeat FeedSeat. False on an account written before the field existed. + Permissionless bool + PubKey [32]byte } diff --git a/smartcontract/sdk/rs/src/commands/feed/create.rs b/smartcontract/sdk/rs/src/commands/feed/create.rs index 61387892e6..975235a2e2 100644 --- a/smartcontract/sdk/rs/src/commands/feed/create.rs +++ b/smartcontract/sdk/rs/src/commands/feed/create.rs @@ -13,6 +13,8 @@ pub struct CreateFeedCommand { pub exchange: Pubkey, /// Multicast groups joinable in this metro. pub groups: Vec, + /// Offer the feed without an access grant. + pub permissionless: bool, } impl CreateFeedCommand { @@ -31,6 +33,7 @@ impl CreateFeedCommand { name: self.name.clone(), exchange: self.exchange, groups: self.groups.clone(), + permissionless: self.permissionless, }, ); @@ -71,6 +74,7 @@ mod tests { name: "Test Feed".to_string(), exchange, groups: vec![group], + permissionless: false, }, ); client @@ -89,6 +93,7 @@ mod tests { name: "Test Feed".to_string(), exchange, groups: vec![group], + permissionless: false, }; let create_invalid_command = CreateFeedCommand { @@ -120,6 +125,7 @@ mod tests { name: "Test Feed".to_string(), exchange, groups: vec![group], + permissionless: false, }, ); let (permission_pda_pubkey, _) = get_permission_pda(&program_id, &payer); @@ -142,6 +148,7 @@ mod tests { name: "Test Feed".to_string(), exchange, groups: vec![group], + permissionless: false, }; let res = create_command.execute(&client); diff --git a/smartcontract/sdk/rs/src/commands/feed/get.rs b/smartcontract/sdk/rs/src/commands/feed/get.rs index 45e37fe462..9d690f38d8 100644 --- a/smartcontract/sdk/rs/src/commands/feed/get.rs +++ b/smartcontract/sdk/rs/src/commands/feed/get.rs @@ -85,6 +85,7 @@ mod tests { name: code.to_string(), exchange, groups: vec![Pubkey::new_unique()], + permissionless: false, } } diff --git a/smartcontract/sdk/rs/src/commands/feed/list.rs b/smartcontract/sdk/rs/src/commands/feed/list.rs index 10422e6daa..2d4a77711c 100644 --- a/smartcontract/sdk/rs/src/commands/feed/list.rs +++ b/smartcontract/sdk/rs/src/commands/feed/list.rs @@ -50,6 +50,7 @@ mod tests { name: "feed1_name".to_string(), exchange: Pubkey::new_unique(), groups: vec![Pubkey::new_unique()], + permissionless: false, }; let feed2_pubkey = Pubkey::new_unique(); @@ -61,6 +62,7 @@ mod tests { name: "feed2_name".to_string(), exchange: Pubkey::new_unique(), groups: vec![Pubkey::new_unique()], + permissionless: false, }; client diff --git a/smartcontract/sdk/rs/src/commands/feed/update.rs b/smartcontract/sdk/rs/src/commands/feed/update.rs index c506570913..c2b444dbec 100644 --- a/smartcontract/sdk/rs/src/commands/feed/update.rs +++ b/smartcontract/sdk/rs/src/commands/feed/update.rs @@ -9,6 +9,8 @@ pub struct UpdateFeedCommand { pub name: Option, /// Replacement multicast group set. `None` leaves the groups unchanged. pub groups: Option>, + /// `None` leaves the permissionless flag unchanged. + pub permissionless: Option, } impl UpdateFeedCommand { @@ -20,6 +22,7 @@ impl UpdateFeedCommand { FeedUpdateArgs { name: self.name.clone(), groups: self.groups.clone(), + permissionless: self.permissionless, }, ); @@ -60,6 +63,7 @@ mod tests { FeedUpdateArgs { name: Some("Test Feed".to_string()), groups: None, + permissionless: None, }, ); client @@ -73,6 +77,7 @@ mod tests { pubkey: pda_pubkey, name: Some("Test Feed".to_string()), groups: None, + permissionless: None, } .execute(&client); assert!(res.is_ok()); @@ -93,6 +98,7 @@ mod tests { FeedUpdateArgs { name: Some("Test Feed".to_string()), groups: None, + permissionless: None, }, ); let (permission_pda_pubkey, _) = get_permission_pda(&program_id, &payer); @@ -114,6 +120,7 @@ mod tests { pubkey: pda_pubkey, name: Some("Test Feed".to_string()), groups: None, + permissionless: None, } .execute(&client); assert!(res.is_ok()); diff --git a/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe_feed.rs b/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe_feed.rs index c00f792ada..fdbe924c91 100644 --- a/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe_feed.rs +++ b/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe_feed.rs @@ -311,6 +311,7 @@ mod tests { name: code.to_string(), exchange, groups, + permissionless: false, } } diff --git a/smartcontract/sdk/rs/src/commands/multicastgroup/unsubscribe_feed.rs b/smartcontract/sdk/rs/src/commands/multicastgroup/unsubscribe_feed.rs index 07b4d6f81f..1e4255309c 100644 --- a/smartcontract/sdk/rs/src/commands/multicastgroup/unsubscribe_feed.rs +++ b/smartcontract/sdk/rs/src/commands/multicastgroup/unsubscribe_feed.rs @@ -339,6 +339,7 @@ mod tests { name: code.to_string(), exchange: Pubkey::new_unique(), groups, + permissionless: false, } }