Skip to content

smartcontract: fix multicast publisher/subscriber counter decrement on disconnect#3342

Merged
ben-malbeclabs merged 11 commits intomainfrom
fix/multicast-publisher-role-clean
Mar 25, 2026
Merged

smartcontract: fix multicast publisher/subscriber counter decrement on disconnect#3342
ben-malbeclabs merged 11 commits intomainfrom
fix/multicast-publisher-role-clean

Conversation

@ben-malbeclabs
Copy link
Contributor

@ben-malbeclabs ben-malbeclabs commented Mar 23, 2026

Resolves: #3331

Summary of Changes

  • Fixes a bug where multicast_publishers_count never decremented and multicast_subscribers_count over-decremented when multicast users disconnected. Every departing user — publisher or subscriber —
    was decrementing multicast_subscribers_count, because the counter logic checked !user.publishers.is_empty(), which is always false at delete time (the ReferenceCountNotZero guard requires
    publishers to be empty before deletion is allowed).
  • Adds a durable tunnel_flags: u8 field to the User struct with a CreatedAsPublisher bit, set at activation time when the user has a non-empty publishers list. The delete and closeaccount
    instructions now use this flag instead of the broken runtime check.
  • Adds an E2E regression test that connects a publisher and subscriber, then verifies device counters decrement to the correct values after each disconnects.

Diff Breakdown

Category Files Lines (+/-) Net
Core logic 4 +93 / -2 +91
Tests 2 +1808 / -2 +1806
Scaffolding 23 +79 / -12 +67
Config/build 1 +2 / -2 0

Mostly tests; the fix itself is 4 lines changed across 2 files.

Key files (click to expand)
  • smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs — integration tests for activate, delete (atomic), and closeaccount (legacy) counter paths
  • e2e/multicast_counter_lifecycle_test.go — E2E regression test: connect publisher + subscriber, disconnect each, assert correct counter values at every step
  • smartcontract/programs/doublezero-serviceability/src/state/user.rs — adds tunnel_flags: u8 field with CreatedAsPublisher bit, backward-compatible deserialization (unwrap_or_default()), and unit
    tests for old-account compat
  • smartcontract/programs/doublezero-serviceability/src/processors/user/activate.rs — sets CreatedAsPublisher flag when activating a user with a non-empty publishers list
  • smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs — replaces !user.publishers.is_empty() with TunnelFlags::is_set(user.tunnel_flags, TunnelFlags::CreatedAsPublisher)
    in atomic delete path
  • smartcontract/programs/doublezero-serviceability/src/processors/user/closeaccount.rs — same fix for legacy closeaccount path

Testing Verification

  • Unit tests for backward compatibility: old serialized accounts (without the new byte) deserialize with tunnel_flags = 0
  • Integration tests verify: (1) activate.rs sets the flag correctly for publishers and subscribers; (2) atomic delete decrements multicast_publishers_count for publishers and leaves
    multicast_subscribers_count unchanged; (3) legacy closeaccount path does the same
  • E2E test (TestE2E_Multicast_CounterLifecycle) verifies the full stack: publisher disconnect leaves subscribers_count intact; subscriber disconnect brings both counters to zero
  • make rust-lint and cargo test pass; cargo test-sbf is broken on origin/main (lockfile v4 issue, pre-existing and unrelated)

@ben-malbeclabs ben-malbeclabs self-assigned this Mar 23, 2026
@ben-malbeclabs ben-malbeclabs requested review from packethog and snormore and removed request for packethog March 23, 2026 15:10
@elitegreg
Copy link
Contributor

Now that I have a better understanding of this, I think it's right. However, and this is not a blocker (that's why I'm approving), I think calling the new field on the User "multicast_publisher" is extremely confusing. I would probably make it a u8 value called accounting_flags. Then create an enum of AccountingFlags, that has a flag called CreatedAsPublisher. Ultimately, this flag is only used for tunnel accounting on the device.

@ben-malbeclabs
Copy link
Contributor Author

Now that I have a better understanding of this, I think it's right. However, and this is not a blocker (that's why I'm approving), I think calling the new field on the User "multicast_publisher" is extremely confusing. I would probably make it a u8 value called accounting_flags. Then create an enum of AccountingFlags, that has a flag called CreatedAsPublisher. Ultimately, this flag is only used for tunnel accounting on the device.

Spoke with @elitegreg and we decided to go with his approach.

ben-malbeclabs and others added 11 commits March 25, 2026 12:05
Add a boolean `multicast_publisher` field at the end of the `User`
struct to record whether a user was activated as a multicast publisher.
This enables correct counter decrements at delete time (the publishers
list is always empty by then due to ReferenceCountNotZero).

The field uses `unwrap_or_default()` in `TryFrom<&[u8]>` so existing
onchain accounts without this byte deserialize to `false`, preserving
backward compatibility.

Also updates the `AIRDROP_USER_RENT_LAMPORTS_BYTES` constant (+1 byte
per User account) and fixes the associated size assertions.

Two new unit tests verify old-account backward compat and roundtrip.
Record user.multicast_publisher = (user_type == Multicast && !publishers.is_empty())
immediately before try_activate so that delete/closeaccount can decrement the correct
device counter even after the publishers list has been cleared.

Add two integration tests verifying the flag is true for an activated publisher and
false for a user with no publisher subscriptions.
The original fix set multicast_publisher in activate.rs using unconditional
assignment, which would reset the flag to false when the activator re-activated
a user after disconnect (publishers list is empty at that point).

Two root causes fixed:
1. create_user_core: set multicast_publisher=is_publisher at creation time,
   so the flag is established alongside the counter increment.
2. activate.rs: only set multicast_publisher on first activation (Pending status).
   Re-activation (Updating status) must never reset the flag — it must persist
   through the disconnect cycle so delete/closeaccount decrement the correct counter.

Also add proper regression tests in create_subscribe_user_test.rs that exercise
the full production path: CreateSubscribeUser(publisher=true) → activate →
disconnect (unsubscribe + re-activate with empty publishers) → delete. These
tests verify publishers_count is decremented from 1→0 (not subscribers_count).
@ben-malbeclabs ben-malbeclabs force-pushed the fix/multicast-publisher-role-clean branch from 6c65856 to 87fb6a4 Compare March 25, 2026 17:05
@ben-malbeclabs ben-malbeclabs enabled auto-merge (squash) March 25, 2026 17:06
@ben-malbeclabs ben-malbeclabs merged commit 2bd8149 into main Mar 25, 2026
33 checks passed
@ben-malbeclabs ben-malbeclabs deleted the fix/multicast-publisher-role-clean branch March 25, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multicast Counts on Devices Not Being Decremented

2 participants