Skip to content

Commit ad91e7f

Browse files
authored
serviceability: allow pending users to subscribe to multicast groups (#3521)
## Summary - Allow `SubscribeMulticastGroup` for users in `Pending` status so that `CreateSubscribeUser` (which only takes one mgroup account) can be followed by additional subscribe calls before the activator runs - The inner `subscribe_user_to_multicastgroup` function already handles Pending users — `CreateSubscribeUser` proves this every time it runs. The standalone instruction had an extra status gate creating an inconsistency. ## Testing Verification - `test_subscribe_pending_user_succeeds` — creates a Pending user via `CreateSubscribeUser`, then subscribes via `SubscribeMulticastGroup` and verifies the user remains Pending with both publisher and subscriber lists populated - All 15 `create_subscribe_user_test` tests pass - All 13 `multicastgroup_subscribe_test` tests pass
1 parent b61be7a commit ad91e7f

4 files changed

Lines changed: 45 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file.
88

99
### Changes
1010

11+
- Smartcontract
12+
- Allow `SubscribeMulticastGroup` for users in `Pending` status so that `CreateSubscribeUser` can be followed by additional subscribe calls before the activator runs ([#3521](https://github.com/malbeclabs/doublezero/pull/3521))
13+
1114
## [v0.17.0](https://github.com/malbeclabs/doublezero/compare/client/v0.16.0...client/v0.17.0) - 2026-04-10
1215

1316
### Breaking

e2e/user_bgp_status_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,11 @@ func TestE2E_UserBGPStatus(t *testing.T) {
181181
// the user account onchain, leaving no record to check BGP status on.
182182
// With an ungraceful kill the user stays activated onchain, giving the
183183
// BGP status submitter a chance to detect the dropped session and submit Down.
184-
_, err := client.Exec(t.Context(), []string{"bash", "-c", "pkill -9 doublezerod || true"})
185-
require.NoError(t, err)
184+
//
185+
// Ignore the error: killing doublezerod (PID 1) can tear down the
186+
// container, which terminates the exec session with exit 137 before
187+
// the "|| true" runs.
188+
client.Exec(t.Context(), []string{"bash", "-c", "pkill -9 doublezerod || true"}) //nolint:errcheck
186189
}) {
187190
t.FailNow()
188191
}

smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,13 +201,15 @@ pub fn process_subscribe_multicastgroup(
201201

202202
// Parse and validate user
203203
let mut user: User = User::try_from(user_account)?;
204-
// Allow pure-unsubscribe (both false) for any status so that users
205-
// created atomically via CreateSubscribeUser can be cleaned up before
206-
// activation. Subscribe operations still require Activated/Updating.
204+
// Allow subscribe for Pending users so that CreateSubscribeUser (which
205+
// only takes one mgroup) can be followed by additional SubscribeMulticastGroup
206+
// calls before the activator runs. Also allow pure-unsubscribe (both false)
207+
// for any status so cleanup works before activation.
207208
let is_unsubscribe_only = !value.publisher && !value.subscriber;
208209
if !is_unsubscribe_only
209210
&& user.status != UserStatus::Activated
210211
&& user.status != UserStatus::Updating
212+
&& user.status != UserStatus::Pending
211213
{
212214
msg!("UserStatus: {:?}", user.status);
213215
return Err(DoubleZeroError::InvalidStatus.into());

smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,7 @@ use doublezero_serviceability::{
5151
},
5252
};
5353
use solana_program_test::*;
54-
use solana_sdk::{
55-
instruction::{AccountMeta, InstructionError},
56-
pubkey::Pubkey,
57-
signature::Signer,
58-
transaction::TransactionError,
59-
};
54+
use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signer};
6055
use std::net::Ipv4Addr;
6156

6257
mod test_helpers;
@@ -2635,9 +2630,12 @@ async fn test_unsubscribe_pending_user_created_via_create_subscribe() {
26352630
assert_eq!(mgroup.publisher_count, 0);
26362631
}
26372632

2638-
/// Subscribing (publisher: true) a Pending user must still be rejected.
2633+
/// Subscribing a Pending user must succeed so that CreateSubscribeUser (which
2634+
/// only takes one mgroup) can be followed by additional SubscribeMulticastGroup
2635+
/// calls before the activator runs. This mirrors the shred oracle flow where a
2636+
/// user is subscribed to multiple multicast groups at creation time.
26392637
#[tokio::test]
2640-
async fn test_subscribe_pending_user_still_rejected() {
2638+
async fn test_subscribe_pending_user_succeeds() {
26412639
let client_ip = [100, 0, 0, 98];
26422640
let f = setup_create_subscribe_fixture(client_ip).await;
26432641
let CreateSubscribeFixture {
@@ -2655,7 +2653,7 @@ async fn test_subscribe_pending_user_still_rejected() {
26552653
let (user_pubkey, _) = get_user_pda(&program_id, &user_ip, UserType::Multicast);
26562654
let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap();
26572655

2658-
// Create user via legacy path — user is Pending.
2656+
// Create user via legacy path — user is Pending with publisher subscription.
26592657
execute_transaction(
26602658
&mut banks_client,
26612659
recent_blockhash,
@@ -2681,15 +2679,25 @@ async fn test_subscribe_pending_user_still_rejected() {
26812679
)
26822680
.await;
26832681

2684-
// Attempting to subscribe (add) a Pending user should still fail.
2682+
let user = get_account_data(&mut banks_client, user_pubkey)
2683+
.await
2684+
.expect("User should exist")
2685+
.get_user()
2686+
.unwrap();
2687+
assert_eq!(user.status, UserStatus::Pending);
2688+
assert_eq!(user.publishers, vec![mgroup_pubkey]);
2689+
2690+
// Subscribe the Pending user as subscriber to the same group.
2691+
// Note: publisher must remain true to keep the existing subscription
2692+
// (false means "unsubscribe from publisher").
26852693
let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap();
2686-
let result = try_execute_transaction(
2694+
try_execute_transaction(
26872695
&mut banks_client,
26882696
recent_blockhash,
26892697
program_id,
26902698
DoubleZeroInstruction::SubscribeMulticastGroup(MulticastGroupSubscribeArgs {
26912699
client_ip: user_ip,
2692-
publisher: false,
2700+
publisher: true,
26932701
subscriber: true,
26942702
use_onchain_allocation: false,
26952703
}),
@@ -2700,16 +2708,19 @@ async fn test_subscribe_pending_user_still_rejected() {
27002708
],
27012709
&payer,
27022710
)
2703-
.await;
2711+
.await
2712+
.expect("Subscribe should succeed for Pending user");
27042713

2705-
assert!(
2706-
result.is_err(),
2707-
"Subscribe should still be rejected for Pending user"
2708-
);
2709-
let err = result.unwrap_err();
2714+
let user = get_account_data(&mut banks_client, user_pubkey)
2715+
.await
2716+
.expect("User should exist")
2717+
.get_user()
2718+
.unwrap();
27102719
assert_eq!(
2711-
err.unwrap(),
2712-
TransactionError::InstructionError(0, InstructionError::Custom(7)),
2713-
"Should return InvalidStatus (0x7)"
2720+
user.status,
2721+
UserStatus::Pending,
2722+
"User should remain Pending"
27142723
);
2724+
assert_eq!(user.publishers, vec![mgroup_pubkey]);
2725+
assert_eq!(user.subscribers, vec![mgroup_pubkey]);
27152726
}

0 commit comments

Comments
 (0)