Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>`. (malbeclabs/infra#2390)

## [v0.38.0](https://github.com/malbeclabs/doublezero/compare/client/v0.37.0...client/v0.38.0) - 2026-08-28

### Breaking
Expand Down
2 changes: 2 additions & 0 deletions crates/doublezero-daemon-cli/src/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2682,6 +2682,7 @@ mod tests {
name: code.to_string(),
exchange,
groups: vec![Pubkey::new_unique()],
permissionless: false,
}
}

Expand Down Expand Up @@ -3504,6 +3505,7 @@ mod tests {
name: code.to_string(),
exchange,
groups,
permissionless: false,
};
self.feeds.lock().unwrap().insert(pk, feed);
pk
Expand Down
2 changes: 2 additions & 0 deletions crates/doublezero-serviceability-instruction/src/feed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -143,6 +144,7 @@ mod tests {
FeedUpdateArgs {
name: Some("Feed".to_string()),
groups: None,
permissionless: None,
},
);
assert_eq!(update.data[0], 113);
Expand Down
7 changes: 5 additions & 2 deletions sdk/serviceability/python/serviceability/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
"""

Expand All @@ -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
Expand All @@ -1267,4 +1269,5 @@ def from_bytes(cls, data: bytes) -> Feed:
# A feed serves one metro: an exchange pubkey followed by a Vec<Pubkey> of joinable groups.
f.exchange = _read_pubkey(r)
f.groups = _read_pubkey_vec(r)
f.permissionless = r.read_bool()
return f
11 changes: 11 additions & 0 deletions sdk/serviceability/python/serviceability/tests/test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,13 +582,24 @@ def test_deserialize(self):
"GroupsLen": len(feed.groups),
"Group0": feed.groups[0],
"Group1": feed.groups[1],
"Permissionless": feed.permissionless,
},
)
assert feed.account_type == 18
assert feed.bump_seed == 239
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:
Expand Down
Binary file modified sdk/serviceability/testdata/fixtures/feed.bin
Binary file not shown.
5 changes: 5 additions & 0 deletions sdk/serviceability/testdata/fixtures/feed.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
"name": "Group1",
"value": "GH7YkRi9soP4j2JAUYTMEMBtkDFyGf7oYu1aGGRucd5H",
"typ": "pubkey"
},
{
"name": "Permissionless",
"value": "true",
"typ": "bool"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pubkey>). Two groups, so the vec decoding is exercised.
/// exchange (Pubkey), groups (Vec<Pubkey>), 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);
Expand All @@ -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();
Expand All @@ -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() },
],
};

Expand Down
8 changes: 6 additions & 2 deletions sdk/serviceability/typescript/serviceability/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -1258,6 +1260,7 @@ export function deserializeFeed(data: Uint8Array): Feed {
// A feed serves one metro: an exchange pubkey followed by a Vec<Pubkey> of joinable groups.
const exchange = readPubkey(r);
const groups = readPubkeyVec(r);
const permissionless = r.readBool();
return {
accountType,
owner,
Expand All @@ -1266,5 +1269,6 @@ export function deserializeFeed(data: Uint8Array): Feed {
name,
exchange,
groups,
permissionless,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -597,13 +597,25 @@ describe("Feed fixture", () => {
GroupsLen: feed.groups.length,
Group0: feed.groups[0],
Group1: feed.groups[1],
Permissionless: feed.permissionless,
});

expect(feed.accountType).toBe(18);
expect(feed.bumpSeed).toBe(239);
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);
});
});

Expand Down
3 changes: 3 additions & 0 deletions smartcontract/cli/src/accesspass/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ mod tests {
name: "QA Payments".to_string(),
exchange: exchange_key,
groups: vec![group_key],
permissionless: false,
};

let mgroup = MulticastGroup {
Expand Down Expand Up @@ -744,6 +745,7 @@ mod tests {
name: "Lashay 1".to_string(),
exchange: *exchange,
groups: vec![],
permissionless: false,
})
.collect();

Expand Down Expand Up @@ -848,6 +850,7 @@ mod tests {
name: "QA Payments".to_string(),
exchange: exchange_key,
groups: vec![],
permissionless: false,
};

client
Expand Down
1 change: 1 addition & 0 deletions smartcontract/cli/src/accesspass/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions smartcontract/cli/src/exchange/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ mod tests {
name: "QA Payments".to_string(),
exchange: Pubkey::new_unique(),
groups: vec![],
permissionless: false,
};

client
Expand Down
10 changes: 10 additions & 0 deletions smartcontract/cli/src/feed/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Offer this feed without an access grant. Off unless given.
#[arg(long, default_value_t = false)]
pub permissionless: bool,
}

impl CreateFeedCliCommand {
Expand Down Expand Up @@ -50,6 +53,7 @@ impl CreateFeedCliCommand {
name: self.name,
exchange,
groups,
permissionless: self.permissionless,
})?;

print_signature(out, &signature)
Expand Down Expand Up @@ -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)));
Expand All @@ -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),
);
Expand Down Expand Up @@ -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)));
Expand All @@ -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),
);
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -270,6 +279,7 @@ mod tests {
name: "Feed".to_string(),
exchange: "nope".to_string(),
groups: vec![],
permissionless: false,
}
.execute(&ctx, &client, &mut output),
);
Expand Down
1 change: 1 addition & 0 deletions smartcontract/cli/src/feed/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ mod tests {
name: "Feed".to_string(),
exchange: exchange_pk,
groups: vec![],
permissionless: false,
};
let feed_for_get = feed.clone();
client
Expand Down
1 change: 1 addition & 0 deletions smartcontract/cli/src/feed/guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ pub(crate) mod fixtures {
name: "Feed".to_string(),
exchange,
groups,
permissionless: false,
}
}

Expand Down
6 changes: 5 additions & 1 deletion smartcontract/cli/src/feed/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -85,6 +86,7 @@ impl ListFeedCliCommand {
})
.collect::<Vec<_>>()
.join(", "),
permissionless: feed.permissionless,
owner: feed.owner,
})
.collect::<Vec<FeedDisplay>>();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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"
);
}

Expand All @@ -214,6 +217,7 @@ mod tests {
name: code.to_string(),
exchange,
groups: vec![],
permissionless: false,
};

client.expect_list_feed().returning(move |_| {
Expand Down
1 change: 1 addition & 0 deletions smartcontract/cli/src/feed/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ mod tests {
name: code.to_string(),
exchange,
groups: vec![],
permissionless: false,
}
}

Expand Down
Loading
Loading