Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
- CI
- The Agave toolchain install retries, and a failed one now fails the job. Eight workflow steps across five workflows ran `sh -c "$(curl -sSfL .../install)"` once with no retry, so a transient reset from `release.anza.xyz` killed a job before it ran anything. That form also swallowed a failed fetch: the command substitution comes back empty, `sh -c ""` exits 0, and the step passed having installed nothing. The eight are now one composite action at `.github/actions/solana-toolchain` that fetches and runs as separate steps, checks `solana --version` actually runs, clears partial state between attempts, bounds every wait so a stalled handshake or hung transfer reaches the backoff instead of sitting until the job times out, and backs off. Each caller keeps the version it used before; `solana.yml` and `offchain.local-validator.yml` are on v3.0.12 and the other three on v3.0.4, which is drift worth settling separately.
- Serviceability
- `UpdateMulticastGroupRoles` authorizes only roles a user gains. An existing feed subscription no longer needs a direct subscriber allowlist entry when the user adds publishing. (malbeclabs/infra#2596)
- `write_stake_mirror` in the instruction crate and `WriteStakeMirrorCommand` in the Rust SDK, so the relayer has a caller-side surface for the instruction A6 added. The command departs from its neighbours in one way: every other one uses `append_payer_permission_account`, which attaches the caller's `Permission` account only when it already exists, because a legacy `GlobalState` key might authorize instead. No legacy key satisfies `STAKE_ORACLE`, so a missing `Permission` account is always fatal here, and the command says so locally rather than sending a transaction that can only come back `NotAllowed`. It checks what `authorize` checks, not just ownership: an account that does not decode, a suspended `Permission`, and one lacking the flag are each refused with the reason named, which matters because suspending a `Permission` is what revoking the relayer's key looks like.
- New `WriteStakeMirror` instruction (variant 119) and a `STAKE_ORACLE` permission, which is what a relayer will call to copy a builder's Solana stake onto the DZ ledger. Nothing could write a `StakeMirror` before this. The instruction refuses a write whose `source_slot` is not newer than the stored one: polling makes a repeated write harmless but says nothing about ordering, and a retry carrying an older read could otherwise walk the mirror backwards, so the program enforces it rather than trusting the caller. It also refuses to reassign a mirror to a different builder, since the builder is not a PDA seed, and it carries `feed_key` forward rather than taking it from the caller, because `CreateFeed` writes that to spend the stake and zeroing it would let one bond back two feeds. No legacy GlobalState key maps to `STAKE_ORACLE`, so a holder needs a real `Permission` account even while `require-permission-accounts` is clear. Gated on `allow-staked-feeds` with the rest of RFC-28. `STAKE_ORACLE` is grantable through `doublezero permission set --add stake-oracle`, named by `permission audit` and `bitmask_to_names`, listed in `AUTHORIZE_GATED_FLAGS` and in the Go SDK's flag constants. It is the first flag no legacy `GlobalState` key can satisfy, which the audit's own test now asserts through a `PERMISSION_ONLY_FLAGS` list rather than treating as a gap in the enumeration.
- `Feed` carries the RFC-28 stake terms: `builder`, `stake_ref`, `spec_id`, `sla_hash`, `committed_rate_bits_per_sec` and a lifecycle `status`. Setting them needs the new `allow-staked-feeds` feature flag, which no cluster has, so `CreateFeed` refuses a builder until the stake mirror and the attestor exist; without the flag the instruction behaves as before. The fields are appended rather than versioned, so a feed written before this decodes with them defaulted and `try_acc_write` resizes the account on the next update. `status` is the exception to defaulting: a short account reads `Active`, because reading it as `Pending` would pull every live catalog feed out of service. The rate is bits per second, not basis points, which is what `bps` means elsewhere in DoubleZero.
Expand All @@ -19,6 +20,7 @@ All notable changes to this project will be documented in this file.
- A feed admits a subscriber only while its status is `Active`. `SubscribeFeed` and `CreateSubscribeUser` both check it where the seat is spent, so a feed that is pending conformance, halted, or retired stops taking subscribers the moment its status changes, and retiring one needs no sweep over the access passes already holding a seat for it. The check is deliberately not in the shared coverage path, which `UnsubscribeFeed` also runs: gating there would leave a user holding a seat on a retired feed with no way to release it. New error `FeedNotActive` (123). Live subscribers are unaffected until they disconnect; evicting them is separate work.
- Test coverage for the RFC-28 publish-rights path, which needs no new instruction. A feed's multicast groups are created with `owner` set to the builder, and `AddMulticastGroupPubAllowlist` authorizes on `mgroup.owner == payer`, so the builder grants its own publish rights without the catalog admin that created the feed. The test walks it with two distinct signers.
- SDK
- Multicast role updates skip allowlist checks for roles a user already holds. A publisher addition now preserves an EdgeSeat feed subscription without requiring the same group on the direct subscriber allowlist. (malbeclabs/infra#2596)
- The Rust SDK sends program logs and errors through the log facade instead of straight to stderr. A failed transaction's program logs now arrive as one `error!` record carrying the signature, so a service embedding the SDK can capture, filter and ship them. Four `eprintln!` sites bypassed logging entirely, while the same function logged the successful path at debug level: an operator saw every transaction that worked and got raw stderr for one that failed. RFC-20 already asks modules to use the standard log macros. One behavior change for the CLI: `--log-level off` silences program logs, which previously printed whatever the level (#4299).
- `sdk/shreds/go` carries the feed subscription program's `FeedDistribution` account: how much USDC one feed collected for one calendar month. The program is a second program alongside shred subscription and had nothing in this SDK, so each consumer decoded the account at fixed byte offsets itself, lake included. The account is a bytemuck Pod read here field by field, which agrees with the Pod bytes only because the field order leaves no interior padding; `TestStructSizes` pins the 120-byte total and a new test pins every field against a real mainnet account. `Client` is built around one program ID and so gains no fetch method, and `DeserializeFeedDistribution` is exported for a caller that makes its own `getProgramAccounts` call. `make sdk-test` never ran `./sdk/shreds/go/...`, so this package's layout pins have never run in CI; it runs them now. (#4216)
- The TypeScript and Python `Feed` deserializers read the RFC-28 tail and synthesize `Active` for an account that carries no status byte, matching the Rust program. New `feed_legacy` fixture covers that path alongside the updated `feed` fixture.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,8 @@ pub fn process_update_multicastgroup_roles(
check_mgroup_allowlists(
&accesspass,
group_account.key,
value.publisher,
value.subscriber,
value.publisher && !user.publishers.contains(group_account.key),
value.subscriber && !user.subscribers.contains(group_account.key),
)?;
let result = update_user_multicastgroup_roles(
group_account,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ use doublezero_serviceability::{
multicastgroup::{
allowlist::{
publisher::add::AddMulticastGroupPubAllowlistArgs,
subscriber::add::AddMulticastGroupSubAllowlistArgs,
subscriber::{
add::AddMulticastGroupSubAllowlistArgs,
remove::RemoveMulticastGroupSubAllowlistArgs,
},
},
create::MulticastGroupCreateArgs,
subscribe::UpdateMulticastGroupRolesArgs,
Expand Down Expand Up @@ -366,6 +369,98 @@ async fn setup_fixture() -> TestFixture {
}
}

#[tokio::test]
async fn test_adds_publisher_without_reauthorizing_existing_subscriber() {
let f = setup_fixture().await;
let TestFixture {
mut banks_client,
payer,
program_id,
globalstate_pubkey,
accesspass_pubkey,
user_pubkey,
mgroup1_pubkey,
_user_ip: user_ip,
..
} = f;
let multicast_publisher_block =
get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock).0;

let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap();
execute_transaction(
&mut banks_client,
recent_blockhash,
program_id,
DoubleZeroInstruction::UpdateMulticastGroupRoles(UpdateMulticastGroupRolesArgs {
client_ip: user_ip,
publisher: false,
subscriber: true,
use_onchain_allocation: true,
extra_group_count: 0,
}),
vec![
AccountMeta::new(mgroup1_pubkey, false),
AccountMeta::new(accesspass_pubkey, false),
AccountMeta::new(user_pubkey, false),
AccountMeta::new(globalstate_pubkey, false),
AccountMeta::new(multicast_publisher_block, false),
],
&payer,
)
.await;

let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap();
execute_transaction(
&mut banks_client,
recent_blockhash,
program_id,
DoubleZeroInstruction::RemoveMulticastGroupSubAllowlist(
RemoveMulticastGroupSubAllowlistArgs {
client_ip: user_ip,
user_payer: payer.pubkey(),
},
),
vec![
AccountMeta::new(mgroup1_pubkey, false),
AccountMeta::new(accesspass_pubkey, false),
AccountMeta::new(globalstate_pubkey, false),
],
&payer,
)
.await;

let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap();
execute_transaction(
&mut banks_client,
recent_blockhash,
program_id,
DoubleZeroInstruction::UpdateMulticastGroupRoles(UpdateMulticastGroupRolesArgs {
client_ip: user_ip,
publisher: true,
subscriber: true,
use_onchain_allocation: true,
extra_group_count: 0,
}),
vec![
AccountMeta::new(mgroup1_pubkey, false),
AccountMeta::new(accesspass_pubkey, false),
AccountMeta::new(user_pubkey, false),
AccountMeta::new(globalstate_pubkey, false),
AccountMeta::new(multicast_publisher_block, false),
],
&payer,
)
.await;

let user = get_account_data(&mut banks_client, user_pubkey)
.await
.unwrap()
.get_user()
.unwrap();
assert_eq!(user.publishers, vec![mgroup1_pubkey]);
assert_eq!(user.subscribers, vec![mgroup1_pubkey]);
}

/// Foundation admin (payer != user.owner) can subscribe a user to a multicast group.
/// Regression test for the bug where process_update_multicastgroup_roles derived the AccessPass PDA
/// using payer_account.key instead of user.owner.
Expand Down
131 changes: 129 additions & 2 deletions smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,16 @@ impl UpdateMulticastGroupRolesCommand {
.ok_or_else(|| eyre::eyre!("AccessPass not found"))?;

for group_pk in &group_pks {
if self.publisher && !accesspass.mgroup_pub_allowlist.contains(group_pk) {
if self.publisher
&& !user.publishers.contains(group_pk)
&& !accesspass.mgroup_pub_allowlist.contains(group_pk)
{
eyre::bail!("User not allowed to publish multicast group ({group_pk})");
}
if self.subscriber && !accesspass.mgroup_sub_allowlist.contains(group_pk) {
if self.subscriber
&& !user.subscribers.contains(group_pk)
&& !accesspass.mgroup_sub_allowlist.contains(group_pk)
{
eyre::bail!("User not allowed to subscribe multicast group ({group_pk})");
}
}
Expand Down Expand Up @@ -279,6 +285,127 @@ mod tests {
assert!(res.is_ok());
}

#[test]
fn test_adds_publisher_without_reauthorizing_existing_subscriber() {
let mut client = create_test_client();

let program_id = client.get_program_id();
let payer = client.get_payer();
let (mgroup_pubkey, _) = get_multicastgroup_pda(&program_id, 1);
let mgroup = MulticastGroup {
account_type: AccountType::MulticastGroup,
owner: payer,
bump_seed: 0,
index: 1,
code: "test".to_string(),
max_bandwidth: 1000,
status: MulticastGroupStatus::Activated,
tenant_pk: Pubkey::default(),
multicast_ip: "223.0.0.1".parse().unwrap(),
publisher_count: 0,
subscriber_count: 1,
};
client
.expect_get()
.with(predicate::eq(mgroup_pubkey))
.returning(move |_| Ok(AccountData::MulticastGroup(mgroup.clone())));

let client_ip = Ipv4Addr::new(192, 168, 1, 10);
let user_pubkey = Pubkey::new_unique();
let user = User {
account_type: AccountType::User,
owner: payer,
bump_seed: 0,
index: 1,
tenant_pk: Pubkey::default(),
user_type: UserType::Multicast,
device_pk: mgroup_pubkey,
cyoa_type: UserCYOA::GREOverDIA,
client_ip,
dz_ip: client_ip,
tunnel_id: 0,
tunnel_net: NetworkV4::default(),
status: UserStatus::Activated,
publishers: vec![],
subscribers: vec![mgroup_pubkey],
validator_pubkey: Pubkey::default(),
tunnel_endpoint: Ipv4Addr::UNSPECIFIED,
tunnel_flags: 0,
bgp_status: Default::default(),
last_bgp_up_at: 0,
last_bgp_reported_at: 0,
bgp_rtt_ns: 0,
..Default::default()
};
client
.expect_get()
.with(predicate::eq(user_pubkey))
.returning(move |_| Ok(AccountData::User(user.clone())));

let (accesspass_pubkey, _) =
get_accesspass_pda(&program_id, &Ipv4Addr::UNSPECIFIED, &payer);
let accesspass = doublezero_serviceability::state::accesspass::AccessPass {
account_type: AccountType::AccessPass,
bump_seed: 0,
accesspass_type: doublezero_serviceability::state::accesspass::AccessPassType::EdgeSeat(
vec![],
),
client_ip: Ipv4Addr::UNSPECIFIED,
user_payer: payer,
last_access_epoch: 0,
connection_count: 1,
status: doublezero_serviceability::state::accesspass::AccessPassStatus::Connected,
owner: payer,
mgroup_pub_allowlist: vec![mgroup_pubkey],
mgroup_sub_allowlist: vec![],
tenant_allowlist: vec![],
flags: 0,
unicast_user_count: 0,
max_unicast_users: 1,
multicast_user_count: 1,
max_multicast_users: 1,
};
client
.expect_get()
.with(predicate::eq(accesspass_pubkey))
.returning(move |_| Ok(AccountData::AccessPass(accesspass.clone())));

let expected = update_multicast_group_roles(
&program_id,
&payer,
&mgroup_pubkey,
&accesspass_pubkey,
&user_pubkey,
&[],
UpdateMulticastGroupRolesArgs {
client_ip,
publisher: true,
subscriber: true,
use_onchain_allocation: true,
extra_group_count: 0,
},
);
client
.expect_send_transaction()
.with(predicate::eq(expected))
.returning(|_| Ok(Signature::new_unique()));

expect_missing_permission_account(&mut client);

let res = UpdateMulticastGroupRolesCommand {
group_pks: vec![mgroup_pubkey],
user_pk: user_pubkey,
client_ip,
publisher: true,
subscriber: true,
device_pk: None,
feed_pk: None,
}
.execute(&client);

assert!(res.is_ok());
}

#[test]
fn test_commands_multicastgroup_subscribe_extra_groups_with_permission_pda() {
let mut client = create_test_client();
Expand Down
Loading