From f110ef1c4cd6a5a11c969c3fd363fbeff25cafbd Mon Sep 17 00:00:00 2001 From: Martin Sander Date: Wed, 9 Sep 2026 14:28:54 -0500 Subject: [PATCH] serviceability: authorize only new multicast roles --- CHANGELOG.md | 2 + .../processors/multicastgroup/subscribe.rs | 4 +- .../tests/multicastgroup_subscribe_test.rs | 97 ++++++++++++- .../src/commands/multicastgroup/subscribe.rs | 130 +++++++++++++++++- 4 files changed, 228 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c11527ae33..038dd6b4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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) - `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. - Solana programs (`solana/`) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs index a1bd01bafe..415dd52d97 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs @@ -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, diff --git a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs index 7d3f9379ac..4c6c6dacb1 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs @@ -9,7 +9,10 @@ use doublezero_serviceability::{ multicastgroup::{ allowlist::{ publisher::add::AddMulticastGroupPubAllowlistArgs, - subscriber::add::AddMulticastGroupSubAllowlistArgs, + subscriber::{ + add::AddMulticastGroupSubAllowlistArgs, + remove::RemoveMulticastGroupSubAllowlistArgs, + }, }, create::MulticastGroupCreateArgs, subscribe::UpdateMulticastGroupRolesArgs, @@ -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. diff --git a/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs b/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs index bd3384a467..d507f990c5 100644 --- a/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs +++ b/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs @@ -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})"); } } @@ -279,6 +285,126 @@ 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();