From 3c658da1f2b5d0bcf7cca991a4d8230c2259023f Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Sat, 21 Mar 2026 21:01:00 -0400 Subject: [PATCH 1/3] smartcontract: allow payer access pass for multicast group subscribe Accept the access pass from either the user's owner or the payer, enabling a third party (e.g. an oracle) to subscribe an existing user to a new multicast group using its own access pass. --- .../src/processors/multicastgroup/subscribe.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs index 91a00acf9a..ccffea6bc7 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs @@ -196,12 +196,20 @@ pub fn process_subscribe_multicastgroup( let accesspass = AccessPass::try_from(accesspass_account)?; - let (accesspass_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); - let (accesspass_dynamic_pda, _) = + // Accept the access pass from either the user's owner or the caller + // (payer). This allows a third party (e.g. an oracle) to subscribe an + // existing user to a new multicast group using its own access pass. + let (owner_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); + let (owner_dynamic_pda, _) = get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, &user.owner); + let (payer_pda, _) = get_accesspass_pda(program_id, &user.client_ip, payer_account.key); + let (payer_dynamic_pda, _) = + get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, payer_account.key); assert!( - accesspass_account.key == &accesspass_pda - || accesspass_account.key == &accesspass_dynamic_pda, + accesspass_account.key == &owner_pda + || accesspass_account.key == &owner_dynamic_pda + || accesspass_account.key == &payer_pda + || accesspass_account.key == &payer_dynamic_pda, "Invalid AccessPass PDA", ); From 4f4aac9e1cd192fa22154c1d2a63ca116d15d7ee Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Sun, 22 Mar 2026 18:54:32 -0400 Subject: [PATCH 2/3] smartcontract: allow payer access pass for user delete Accept the payer's access pass in DeleteUser (same pattern as SubscribeMulticastGroup). Also authorize delete when the payer matches the access pass user_payer field. SDK DeleteUserCommand tries the payer's access pass first, falling back to user.owner's. --- .../src/processors/user/delete.rs | 40 ++++++----- .../tests/user_tests.rs | 68 +++++++++++++++++++ .../sdk/rs/src/commands/user/delete.rs | 68 +++++++++++++++---- 3 files changed, 146 insertions(+), 30 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs index 5407e1c737..070f466760 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs @@ -143,33 +143,41 @@ pub fn process_delete_user( let user: User = User::try_from(user_account)?; let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) - && user.owner != *payer_account.key - { + + // Allow delete if payer is: the user owner, the access pass user_payer, + // or on the foundation allowlist. + let accesspass_user_payer = if !accesspass_account.data_is_empty() { + AccessPass::try_from(accesspass_account) + .map(|ap| ap.user_payer) + .ok() + } else { + None + }; + let is_authorized = globalstate.foundation_allowlist.contains(payer_account.key) + || user.owner == *payer_account.key + || accesspass_user_payer == Some(*payer_account.key); + if !is_authorized { return Err(DoubleZeroError::NotAllowed.into()); } - let (accesspass_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); - let (accesspass_dynamic_pda, _) = + // Accept access pass from either user.owner or payer + let (owner_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); + let (owner_dynamic_pda, _) = get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, &user.owner); - // Access Pass must exist and match the client_ip or allow_multiple_ip must be enabled + let (payer_pda, _) = get_accesspass_pda(program_id, &user.client_ip, payer_account.key); + let (payer_dynamic_pda, _) = + get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, payer_account.key); assert!( - accesspass_account.key == &accesspass_pda - || accesspass_account.key == &accesspass_dynamic_pda, + accesspass_account.key == &owner_pda + || accesspass_account.key == &owner_dynamic_pda + || accesspass_account.key == &payer_pda + || accesspass_account.key == &payer_dynamic_pda, "Invalid AccessPass PDA", ); if !accesspass_account.data_is_empty() { // Read Access Pass let mut accesspass = AccessPass::try_from(accesspass_account)?; - if accesspass.user_payer != user.owner { - msg!( - "Invalid user_payer accesspass.user_payer: {} = user_payer: {} ", - accesspass.user_payer, - user.owner - ); - return Err(DoubleZeroError::Unauthorized.into()); - } if accesspass.is_dynamic() && accesspass.client_ip == Ipv4Addr::UNSPECIFIED { accesspass.client_ip = user.client_ip; // lock to the first used IP } diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs index 6997f56f22..770e5c55e6 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs @@ -1658,3 +1658,71 @@ async fn test_user_delete_from_out_of_credits() { .unwrap(); assert_eq!(user.status, UserStatus::Deleting); } + +/// Access pass user_payer can delete the user. Also verifies a stranger cannot. +#[tokio::test] +async fn test_user_delete_by_accesspass_user_payer() { + let (mut banks_client, payer, program_id, globalstate_pubkey, user_pubkey, accesspass_pubkey) = + setup_activated_user().await; + + // Verify payer is the access pass user_payer + let accesspass = get_account_data(&mut banks_client, accesspass_pubkey) + .await + .unwrap() + .get_accesspass() + .unwrap(); + assert_eq!(accesspass.user_payer, payer.pubkey()); + + // Stranger should NOT be able to delete + let stranger = Keypair::new(); + transfer(&mut banks_client, &payer, &stranger.pubkey(), 10_000_000).await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::DeleteUser(UserDeleteArgs { + dz_prefix_count: 0, + multicast_publisher_count: 0, + }), + vec![ + AccountMeta::new(user_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &stranger, + ) + .await; + assert!(res.is_err(), "Stranger should not be able to delete user"); + + // Payer (access pass user_payer) can delete + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::DeleteUser(UserDeleteArgs { + dz_prefix_count: 0, + multicast_publisher_count: 0, + }), + vec![ + AccountMeta::new(user_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + assert!( + res.is_ok(), + "Access pass user_payer should be able to delete user" + ); + + let user = get_account_data(&mut banks_client, user_pubkey) + .await + .unwrap() + .get_user() + .unwrap(); + assert_eq!(user.status, UserStatus::Deleting); +} diff --git a/smartcontract/sdk/rs/src/commands/user/delete.rs b/smartcontract/sdk/rs/src/commands/user/delete.rs index 0c7a22d185..5178eec096 100644 --- a/smartcontract/sdk/rs/src/commands/user/delete.rs +++ b/smartcontract/sdk/rs/src/commands/user/delete.rs @@ -93,21 +93,40 @@ impl DeleteUserCommand { .map_err(|_| eyre::eyre!("Timeout waiting for user multicast unsubscribe"))?; } - let (accesspass_pk, _) = GetAccessPassCommand { - client_ip: Ipv4Addr::UNSPECIFIED, - user_payer: user.owner, - } - .execute(client)? - .or_else(|| { - GetAccessPassCommand { - client_ip: user.client_ip, - user_payer: user.owner, - } + // Look up access pass: try payer's first, then fall back to user.owner's. + // This allows a third party (e.g. oracle) to delete using its own access pass. + let payer_key = client.get_payer(); + let access_pass_lookup_keys = if payer_key == user.owner { + vec![user.owner] + } else { + vec![payer_key, user.owner] + }; + + let mut accesspass_result = None; + for key in &access_pass_lookup_keys { + let found = (GetAccessPassCommand { + client_ip: Ipv4Addr::UNSPECIFIED, + user_payer: *key, + }) .execute(client) .ok() .flatten() - }) - .ok_or_else(|| eyre::eyre!("You have no Access Pass"))?; + .or_else(|| { + (GetAccessPassCommand { + client_ip: user.client_ip, + user_payer: *key, + }) + .execute(client) + .ok() + .flatten() + }); + if let Some(result) = found { + accesspass_result = Some(result); + break; + } + } + let (accesspass_pk, _) = + accesspass_result.ok_or_else(|| eyre::eyre!("No Access Pass found"))?; let mut accounts = vec![ AccountMeta::new(self.pubkey, false), @@ -806,7 +825,28 @@ mod tests { .in_sequence(&mut seq) .returning(move |_| Ok(AccountData::User(user_final_clone.clone()))); - // Call 8a: UNSPECIFIED AccessPass lookup fails (fallback path) — DeleteUserCommand + // Call 8a: UNSPECIFIED AccessPass lookup for payer (foundation_key) — not found + let (payer_unspecified_ap, _) = + get_accesspass_pda(&program_id, &Ipv4Addr::UNSPECIFIED, &foundation_key); + let user_clone_payer_fallback = user_activated_final.clone(); + client + .expect_get() + .with(predicate::eq(payer_unspecified_ap)) + .times(1) + .in_sequence(&mut seq) + .returning(move |_| Ok(AccountData::User(user_clone_payer_fallback.clone()))); + + // Call 8b: client_ip AccessPass lookup for payer (foundation_key) — not found + let (payer_ip_ap, _) = get_accesspass_pda(&program_id, &client_ip, &foundation_key); + let user_clone_payer_fallback2 = user_activated_final.clone(); + client + .expect_get() + .with(predicate::eq(payer_ip_ap)) + .times(1) + .in_sequence(&mut seq) + .returning(move |_| Ok(AccountData::User(user_clone_payer_fallback2.clone()))); + + // Call 8c: UNSPECIFIED AccessPass lookup for user_owner — not found let user_clone_fallback2 = user_activated_final.clone(); client .expect_get() @@ -815,7 +855,7 @@ mod tests { .in_sequence(&mut seq) .returning(move |_| Ok(AccountData::User(user_clone_fallback2.clone()))); - // Call 8b: AccessPass fetch via client_ip fallback — keyed to (client_ip, user_owner) + // Call 8d: client_ip AccessPass fetch for user_owner — found let accesspass_clone2 = accesspass.clone(); client .expect_get() From 93b4c03fc0baf7848997438bd0a982c680343386 Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Sun, 22 Mar 2026 19:07:31 -0400 Subject: [PATCH 3/3] smartcontract: add feed_authority to subscriber allowlist remove auth check --- .../allowlist/subscriber/remove.rs | 1 + ...multicastgroup_allowlist_subcriber_test.rs | 164 ++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/allowlist/subscriber/remove.rs b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/allowlist/subscriber/remove.rs index a2dbd1ac2c..fcd320489f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/allowlist/subscriber/remove.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/allowlist/subscriber/remove.rs @@ -79,6 +79,7 @@ pub fn process_remove_multicast_sub_allowlist( // Check whether mgroup is authorized let is_authorized = (mgroup.owner == *payer_account.key) || globalstate.sentinel_authority_pk == *payer_account.key + || globalstate.feed_authority_pk == *payer_account.key || globalstate.foundation_allowlist.contains(payer_account.key); if !is_authorized { return Err(DoubleZeroError::NotAllowed.into()); diff --git a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs index 388d5a1f8f..92e4b8d117 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs @@ -761,3 +761,167 @@ async fn test_multicast_subscriber_allowlist_feed_authority_different_user_payer "Non-feed authority should fail when user_payer doesn't match" ); } + +/// Feed authority can remove from subscriber allowlist. +#[tokio::test] +async fn test_multicast_subscriber_allowlist_feed_authority_remove() { + let (mut banks_client, program_id, payer, recent_blockhash) = init_test().await; + + let client_ip = [100, 0, 0, 6].into(); + let user_payer = payer.pubkey(); + + let (program_config_pubkey, _) = get_program_config_pda(&program_id); + let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); + + // 1. Initialize global state + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::InitGlobalState(), + vec![ + AccountMeta::new(program_config_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // 2. Create feed authority + let feed = Keypair::new(); + transfer(&mut banks_client, &payer, &feed.pubkey(), 10_000_000_000).await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAuthority(SetAuthorityArgs { + feed_authority_pk: Some(feed.pubkey()), + ..Default::default() + }), + vec![AccountMeta::new(globalstate_pubkey, false)], + &payer, + ) + .await; + + // 3. Create and activate multicast group + let globalstate = get_account_data(&mut banks_client, globalstate_pubkey) + .await + .expect("Unable to get Account") + .get_global_state() + .unwrap(); + + let (multicastgroup_pubkey, _) = + get_multicastgroup_pda(&program_id, globalstate.account_index + 1); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateMulticastGroup(MulticastGroupCreateArgs { + code: "feed-remove".to_string(), + max_bandwidth: 1_000_000_000, + owner: payer.pubkey(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateMulticastGroup(MulticastGroupActivateArgs { + multicast_ip: [224, 254, 0, 6].into(), + }), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // 4. Payer creates access pass and adds allowlist entry + let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &client_ip, &user_payer); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAccessPass(SetAccessPassArgs { + accesspass_type: AccessPassType::Prepaid, + client_ip, + last_access_epoch: 100, + allow_multiple_ip: false, + }), + vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(user_payer, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::AddMulticastGroupSubAllowlist(AddMulticastGroupSubAllowlistArgs { + client_ip, + user_payer, + }), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let accesspass = get_account_data(&mut banks_client, accesspass_pubkey) + .await + .expect("Unable to get Account") + .get_accesspass() + .unwrap(); + assert_eq!(accesspass.mgroup_sub_allowlist.len(), 1); + + // 5. Feed authority removes subscriber allowlist entry — should succeed + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RemoveMulticastGroupSubAllowlist( + RemoveMulticastGroupSubAllowlistArgs { + client_ip, + user_payer, + }, + ), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &feed, + ) + .await; + assert!( + res.is_ok(), + "Feed authority should be able to remove from subscriber allowlist" + ); + + let accesspass = get_account_data(&mut banks_client, accesspass_pubkey) + .await + .expect("Unable to get Account") + .get_accesspass() + .unwrap(); + assert_eq!(accesspass.mgroup_sub_allowlist.len(), 0); +}