Skip to content

serviceability: tell an IP conflict apart from an already-subscribed user - #4159

Draft
Jared-dz wants to merge 2 commits into
mainfrom
2171/split-user-exists-errors
Draft

serviceability: tell an IP conflict apart from an already-subscribed user#4159
Jared-dz wants to merge 2 commits into
mainfrom
2171/split-user-exists-errors

Conversation

@Jared-dz

@Jared-dz Jared-dz commented Aug 6, 2026

Copy link
Copy Markdown

CreateUser and CreateSubscribeUser both answered AccountAlreadyInitialized for two refusals that share neither cause nor remedy, so a client could not tell which it had hit — and therefore could not tell the user which.

Condition Means Remedy Now
A user exists at the requested client IP with a different device, owner, type or tenant The User PDA is derived from (client_ip, user_type) with no device dimension, so this is what two devices claiming one IP looks like — the second derives the first one's account Pick another IP UserExistsWithDifferentAttributesCustom(105)
CreateSubscribeUser matched an existing user exactly The subscription is already in place Nothing to do SubscribeUserAlreadyExistsCustom(106)

The second is an error rather than an idempotent no-op (which is what CreateUser does) only because falling through would tick a second feed seat and push a duplicate feed_pks entry that delete would then double-release.

Both variants are appended to the end of DoubleZeroError, so no existing code shifts.

Not a seed change

The obvious-looking fix — adding a device dimension to the User PDA — is not viable in one PR: RFC-10's compatibility window forbids writing a new address space in the release that adds it, it would orphan every live User, and it would require regenerating the Go/Python/TS fixtures. It would also remove the IP-uniqueness guarantee rather than fix anything, by giving each device its own account. So this PR makes the existing behavior diagnosable and writes the invariant down at get_user_pda, where it actually lives — it is a property of the seed derivation, not a check somewhere that could be moved.

Tests

  • The three existing mismatch tests (different_device / different_tenant / different_owner) now pin Custom(105).
  • feed_subscription_test.rs's duplicate-subscribe test pins Custom(106).
  • New: test_create_subscribe_user_duplicate_is_refused_distinctly — that branch had no coverage at all. It also asserts the subscription is untouched afterwards: one seat, not two.

That new test's retry flips publisher on purpose, and the comment says why: an identical instruction compiles to identical transaction bytes against the same blockhash, so the runtime dedupes by signature and hands back the first transaction's success without ever reaching the program — the test passed while proving nothing until this was fixed. create_user_core compares owner, device, user type and tenant but not the publisher/subscriber flags, so the request still matches exactly and still lands in the duplicate branch.

Verification

cargo test -p doublezero-serviceability50 test binaries, all green (301 unit + the integration suites; these run natively via processor!, no SBF build needed). cargo clippy --all-targets clean on doublezero-serviceability and doublezero-sentinel; cargo fmt applied.

No SDK error tables mirror these codes, so nothing was regenerated. One stale comment in crates/sentinel/src/dz_ledger_writer.rs named the old error for this exact path and is corrected.

### Breaking CHANGELOG entry included, since the wire code changes for both paths.

Coordination: #4120 also touches create_subscribe.rs. This only changes the else branch's error value, so it should be a small hand-merge either way — happy to rebase after it lands.

…ibed user

CreateUser and CreateSubscribeUser both answered AccountAlreadyInitialized
for two refusals that share neither cause nor remedy:

- a user already exists at the requested client IP with a DIFFERENT device,
  owner, type or tenant. The User PDA is derived from (client_ip, user_type)
  with no device dimension, so this is what two devices claiming one IP looks
  like, and the caller has to pick another IP.
- CreateSubscribeUser matched an existing user EXACTLY. The subscription is
  already in place, so there is nothing for the caller to change. It is an
  error rather than a no-op only because falling through would tick a second
  feed seat and push a duplicate feed_pks entry that delete would
  double-release.

One code for both meant a client could not tell which it had hit, so it could
not tell the user which. They are now UserExistsWithDifferentAttributes
(Custom(105)) and SubscribeUserAlreadyExists (Custom(106)), appended to the
end of DoubleZeroError so no existing code shifts.

The IP-uniqueness invariant is now written where it lives — the get_user_pda
seed derivation — since it is a property of the address, not a check
somewhere, and adding a device dimension there would silently drop it.

Adds the missing test for the duplicate-subscribe branch, which had no
coverage at all. Its retry flips `publisher` deliberately: an identical
instruction compiles to identical transaction bytes against the same
blockhash, so the runtime dedupes it by signature and returns the first
transaction's success without reaching the program. create_user_core does not
compare that flag, so the request still matches exactly and still lands in
the duplicate branch.
@Jared-dz

Jared-dz commented Aug 6, 2026

Copy link
Copy Markdown
Author

Tracked in malbeclabs/infra#2198, part of the upstream-gap set in malbeclabs/infra#2200 (board 59).

The assertions inlined 105 and 106, so renumbering the enum would leave them
passing against codes the program no longer returns. A named constant would
only move the problem: the wire code comes from the match in
`impl From<DoubleZeroError> for ProgramError`, so anything written by hand can
drift from it.

`custom_code` in test_helpers derives the code through that conversion. Casting
the variant would be wrong here — `DoubleZeroError` has no `#[repr(u32)]` and
declaration order does not match the mapping: `InvalidExchangePubkey` is the
third variant declared but maps to `Custom(3)`, while `InvalidLocationPubkey`,
declared fifth, maps to `Custom(2)`. `as u32` would silently produce a wrong
code, so the doc comment records that.

All five sites are match arms, and a pattern cannot hold a computed value, so
the code is bound and compared in a guard — the shape `assert_custom_error` in
accesspass_dzf_locked.rs already uses. The tests passing is what proves the
derivation: a wrong mapping fails the guard and falls to the panic arm.

Also cites the PR number in the CHANGELOG entry alongside the infra ticket, as
the neighbouring entries do.
@Jared-dz

Jared-dz commented Aug 6, 2026

Copy link
Copy Markdown
Author

Pushed 23882c41 after auditing this PR against the repo's own review instructions. Two findings, both mine.

The assertions inlined 105 and 106. The testing rules ask for the code to be derived from the error enum so renumbering cannot silently rot a test. A named const would only move the problem, since the wire code comes from the match in impl From<DoubleZeroError> for ProgramError and anything hand-written can drift from it.

custom_code in test_helpers derives it through that conversion. Worth recording, and now in its doc comment: casting the variant would be wrong here. DoubleZeroError has no #[repr(u32)] and declaration order does not match the mapping — InvalidExchangePubkey is the third variant declared but maps to Custom(3), while InvalidLocationPubkey, declared fifth, maps to Custom(2). So as u32 would produce a wrong code silently.

All five sites are match arms and a pattern cannot hold a computed value, so the code is bound and compared in a guard — the shape assert_custom_error in accesspass_dzf_locked.rs already uses. The tests passing is what proves the derivation: a wrong mapping fails the guard and falls through to the panic arm.

The CHANGELOG cited only the infra ticket. Now (malbeclabs/infra#2171, #4159), as the neighbouring entries do.

Still 50 test binaries green; clippy clean on doublezero-serviceability and doublezero-sentinel.

@martinsander00
martinsander00 marked this pull request as draft August 7, 2026 03:04

@nikw9944 nikw9944 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to change before merge: SubscribeUserAlreadyExists asserts a subscription state the program never checks, and the changelog tells clients to treat it as a no-op — which will make a real subscribe get silently dropped. Two small follow-ons: a sentinel comment that still describes an unreachable path, and the Go SDK error-name table that the PR description says doesn't exist.

  • An SDK error-name table does exist, contrary to the PR description: programErrors in smartcontract/sdk/go/serviceability/errors.go:103 maps codes to names for the operator-facing log lines in executor.go:118. It stops at 90, so 91–104 already print as "unknown error code N" and 105/106 would join them. Adding the two entries (ideally backfilling 91–104) carries this PR's diagnosability goal through to Go callers.

"A user already exists at this client IP with a different device, owner, type or tenant"
)]
UserExistsWithDifferentAttributes, // variant 105
#[error("This user is already subscribed to the requested multicast group")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error tells the caller they are already subscribed to the group they asked for, but the code never checks group membership — it fires whenever any matching user account exists, so a user subscribed to nothing, or to a different group, is told they are already subscribed. create_user_core takes no mgroup account; its Ok(None) (create_core.rs:240) compares only owner, device_pk, user_type and tenant_pk, and create_subscribe.rs:147 turns that into this error. Behavior is unchanged, but CHANGELOG.md:10 tells clients it means "there is nothing to do", so one that follows it silently drops a real subscribe.

#[error("A user already exists at this client IP; change group roles with UpdateMulticastGroupRoles or SubscribeFeed")]
UserAlreadyExists, // variant 106

Worth a test hitting CreateSubscribeUser with a different group than the existing user holds.

// the atomic create+allocate+activate path. Without those accounts the user is created
// Pending and never reaches Activated, so the next poll cycle would re-attempt creation
// and trip AccountAlreadyInitialized.
// and trip SubscribeUserAlreadyExists.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment describes a fallback that cannot happen: a zero dz_prefix_count does not create a Pending user, it makes the whole instruction fail. The program rejects dz_prefix_count == 0 up front at create_subscribe.rs:71, and the count comes from device.dz_prefixes.len() (dz_ledger_reader.rs:313), so a device with no prefixes hits that hard failure rather than a duplicate on the next cycle.

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.

2 participants