fix(swift-sdk): attribute shielded registration errors to the right step and keep unconfirmed broadcasts safe#3862
Conversation
…tep and keep unconfirmed broadcasts safe The shielded (Type-20) identity registration ran as one opaque broadcast_and_wait: any failure surfaced as ShieldedBroadcastFailed, the app pinned it on the "Generating Halo 2 proof" step, and the wallet released the spent notes' reservations — even when the transition had already executed on-chain and only the result-proof confirmation failed (observed live on devnet; server side since fixed in #3859). That treated a possibly-live identity as unregistered (orphaned-identity hazard) and invited double-spends. - rs-sdk: add identity_create_from_shielded_pool_transition (build + validate only) so callers can stage broadcast and wait separately. - platform-wallet: classify wait-stage failures; on an ambiguous post-broadcast error, retry fetching the identity by its derived id (success path if found), otherwise return the new ShieldedBroadcastUnconfirmed error and leave note reservations in place (in-memory only; the next nullifier sync reconciles them). - ffi: new result codes ErrorShieldedBroadcastFailed (16) and ErrorShieldedBroadcastUnconfirmed (17); the latter also writes the derived id to out_identity_id. - swift-sdk: mirror the codes; throw a typed ShieldedIdentityCreateUnconfirmedError carrying the derived id. - example app: new .unconfirmed controller phase that holds the identity slot against re-submission and marks the slot used; the shielded progress view gains a distinct "Waiting for platform confirmation" step, attributes broadcast rejections to the broadcast step, and renders an orange confirmation-pending state instead of a red failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Warning Review limit reached
More reviews will be available in 10 minutes and 20 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more credits in the billing tab to continue. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit 6df799d) |
Resolves conflicts with #3854 (freeze failed registration step at the failure instant): the .unconfirmed and .failed transitions now also stamp terminalAt, and the shielded failed-step heuristic combines the broadcast-rejected stage attribution with the frozen terminalAt anchor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR cleanly splits broadcast and wait-stage handling for Type-20 shielded identity-create so post-broadcast confirmation failures hold the note reservation and surface an unconfirmed-with-id error, while genuine relay/CheckTx rejections continue to release. The classification, FFI wiring (codes 16/17, conditional out_identity_id), and Swift mirror are internally consistent. No in-scope blocking issues; a few defensive suggestions and one latent FFI-mapping gap worth fixing now.
🟡 3 suggestion(s)
1 additional finding(s) omitted (not in diff).
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/error.rs:194-214: Blanket From<PlatformWalletError> flattens ShieldedBroadcastFailed/Unconfirmed to ErrorUnknown
The new `ShieldedBroadcastFailed` and `ShieldedBroadcastUnconfirmed` variants are mapped to the dedicated FFI codes 16/17 only inside `platform_wallet_manager_shielded_identity_create_from_pool`'s explicit match. The blanket `From<PlatformWalletError> for PlatformWalletFFIResult` here still falls through to `ErrorUnknown`, and `unwrap_result_or_return!` routes any `Err(...).into()` through this impl. Today nothing else propagates these variants via `?`, so the bug is latent — but the first time a helper does, the host will see `ErrorUnknown` instead of code 17, `out_identity_id` will be left zeroed (the blanket impl has no out-parameter), and Swift will fall back to `PlatformWalletError.unknown(...)` rather than `ShieldedIdentityCreateUnconfirmedError`. That re-introduces exactly the orphaned-identity/double-spend hazard this PR closes. Add explicit arms here for both variants (the unconfirmed case still cannot carry the id through a blanket conversion, but at least the typed code survives), and/or document on the variants that they must always be matched explicitly at the FFI entry point.
In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:786-842: New wait-stage classification has no Rust-level test pinning the safety invariant
The whole orphaned-identity argument hinges on three branches added in this PR: `Err(StateTransitionBroadcastError)` → `ShieldedBroadcastFailed` (release reservation), other wait error + identity found by retry → success (notes finalized), other wait error + identity still missing → `ShieldedBroadcastUnconfirmed` with reservations intentionally retained. None of those have a unit/integration test in `rs-platform-wallet`. A future reorder of the `match` arms or refactor of `cancel_pending` could silently regress the wallet to the pre-PR behavior the description identifies as a double-spend/orphaned-identity hazard. A test (against a mocked `Sdk`/`Fetch`) that injects each of the three wait-error shapes and asserts which `PlatformWalletError` variant comes out — plus whether `cancel_pending` was called — would lock the invariant in.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift:222-230: FailureStage attribution treats every non-shieldedBroadcastFailed error as pre-broadcast
The new mapping pins anything that isn't exactly `PlatformWalletError.shieldedBroadcastFailed` to `.beforeBroadcast`. That's correct for the two typed variants this PR introduces, but it also catches unrelated `PlatformWalletError` variants surfacing through the FFI bridge (e.g. wallet-handle / marshalling failures) and confidently attributes them to the Halo 2 build step purely by elimination — the same misattribution this PR is fixing, just for a different error class. Gate `.beforeBroadcast` on errors that actually come from the build/proof path (e.g. `.shieldedBuildError`) and surface anything else with no attribution (or a dedicated `.unknown` stage) so the progress view falls back to its elapsed-time heuristic rather than pointing at the wrong step.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR cleanly splits broadcast and result-wait for the Type-20 shielded identity-create flow, introduces the slot-holding ShieldedBroadcastUnconfirmed path, wires terminalAt and a fetch-by-derived-id fallback, and adds Drive/ABCI proof + direct-purchase regression tests. Latest delta (2270899→9efa3363) is a v3.1-dev merge plus a small terminalAt freeze in the Swift controller. All three prior suggestions are STILL VALID; one cumulative-head suggestion is added around UI dismissibility of the .unconfirmed row.
🟡 3 suggestion(s)
1 additional finding(s) omitted (not in diff).
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/error.rs:194-214: Blanket From<PlatformWalletError> still flattens shielded broadcast codes to ErrorUnknown
Verified at head: lines 194-214 only special-case `NoSpendableInputs`/`OnlyOutputAddressesFunded`/`OnlyDustInputs` and `WalletAlreadyExists`; the two new variants `ShieldedBroadcastFailed` and `ShieldedBroadcastUnconfirmed` fall through to `ErrorUnknown`. They are mapped to codes 16/17 only inside the dedicated `platform_wallet_manager_shielded_identity_create_from_pool` match. Any future FFI entry point that propagates a `PlatformWalletError` through `?` / `.into()` will silently emit `ErrorUnknown`, defeating the slot-holding contract documented at the top of this file. Add explicit arms here (and keep the `out_identity_id` write at the dedicated entry point — only that path owns the typed id) so the codes survive future plumbing changes.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift:231-247: Non-broadcast errors are still attributed as `.beforeBroadcast` by elimination
Verified at head: the catch-all classifies every error except `PlatformWalletError.shieldedBroadcastFailed` as `.beforeBroadcast`. The `ShieldedIdentityCreateUnconfirmedError` case is caught earlier, but any other typed wallet error surfacing through the FFI bridge (invalid handle, marshalling failure, generic wallet errors) is now confidently attributed to the build/Halo 2 step. That misleads the user about which stage actually failed and, for any failure that escapes after relay acceptance, marks a possibly-live transition as a normal pre-broadcast failure — exactly the misattribution this PR is trying to clean up. Gate `.beforeBroadcast` on errors that actually come from the proof/build path, and route unknown variants to an unattributed/`unknown` stage so the progress view falls back to elapsed-time heuristics.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingRegistrationsList.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingRegistrationsList.swift:74-98: `.unconfirmed` rows are user-dismissable, which can free the identity slot for a fund-burning retry
Verified at head: `isDismissable` returns true for `.unconfirmed`, and `RegistrationCoordinator.dismiss` removes the controller. The comment frames this as harmless ("dismissing it only drops the Pending row"), but the start-gate at `RegistrationCoordinator` only inspects controllers it currently holds, and `CreateIdentityView.usedIdentityIndices` consults persisted `PersistentIdentity` rows — not the per-slot `isUsed` flag, by explicit design (`CreateIdentityView.swift:2007-2015`). If a user dismisses an `.unconfirmed` row before the identity-sync writes a `PersistentIdentity` for the derived id, the same index becomes selectable again. Re-submitting registers the same keys, hits the registered-key-hash stateful check, and burns the shielded funds to the fallback address minus penalty — the exact orphaned-identity hazard the `.unconfirmed` variant was added to prevent. Gate dismissibility on the persisted-identity row actually being present (or only allow dismiss when `controller.phase == .failed`).
…ast handling Addresses thepastaclaw review on #3862: - ffi: map ShieldedBroadcastFailed/Unconfirmed to codes 16/17 in the blanket From<PlatformWalletError> impl so future entry points that propagate via `?` can't flatten them to ErrorUnknown (the dedicated identity-create match still owns the out_identity_id write); pinned by a new unit test. - platform-wallet: extract wait_error_is_definitive_rejection so the wait-stage classification (definitive rejection vs ambiguous confirmation failure) is pinned by unit tests. - example app: drop FailureStage.beforeBroadcast — only a typed shieldedBroadcastFailed is confidently attributable; everything else stays unattributed (nil) and the progress view falls back to its elapsed-time heuristic instead of blaming the Halo 2 step by elimination. - example app: gate dismissing an .unconfirmed Pending Registrations row on the slot's PersistentIdentity row existing — while unconfirmed-and-unpersisted the live controller is the only guard keeping the identity index un-selectable (usedIdentityIndices ignores the deprecated isUsed flag), so an early dismiss could free the slot for a fund-burning duplicate registration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified the latest delta against the prior review at 9efa336: 3 of 4 prior findings are fixed (typed FFI codes preserved through the blanket From impl, failure-stage no longer attributed by elimination, .unconfirmed dismissal gated on a live PersistentIdentity row); the 4th (Rust-level test for the outer reservation-retention match) is partially addressed via unit tests for wait_error_is_definitive_rejection but the outer-match invariant itself is still untested. One new in-scope blocking issue: hasInFlightRegistrations in RegistrationCoordinator does not include .unconfirmed, so the OptionsView network-switch gate (the only thing keeping the FFI manager / coordinator alive while the slot is in-memory-only) re-enables as soon as the flow becomes .unconfirmed, allowing the same slot to lose its in-memory guard and be re-selected against an identity that is probably live on chain. Two pre-existing structural concerns (broadcast-stage transport ambiguity, persistent slot reservation across app restart) are recorded as out-of-scope follow-ups.
🔴 1 blocking | 🟡 1 suggestion(s)
1 additional finding(s) omitted (not in diff).
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift:44-53: Network-switch gate ignores .unconfirmed and can drop the only in-memory slot guard
hasInFlightRegistrations returns true only for .preparingKeys and .inFlight, but this PR introduces .unconfirmed precisely because the on-chain identity may already exist and the live IdentityRegistrationController is the only thing keeping the slot from being re-selected before sync writes a PersistentIdentity row. The OptionsView network picker uses this flag (see OptionsView.swift:805 and the wrapper viewmodifier comment at :793-799) as its .disabled(_:) gate, and PlatformWalletManager+RegistrationCoordinator.swift:23-26 documents that switching networks tears down the active PlatformWalletManager (and with it the registrationCoordinator attached as an associated object). As soon as the flow reaches .unconfirmed, the gate releases, the user can switch networks (and switch back), the in-memory controller and pending_nullifiers reservation are gone, and usedIdentityIndices — which consults only PersistentIdentity — will once again surface the same HD slot. A second submission against the same registered key set will be rejected by the registered-key-hash stateful check and route the funded spend to the fallback address minus penalty — exactly the orphaned-identity / fund-burn hazard the .unconfirmed phase was introduced to prevent. The dismissal path in PendingRegistrationsList was hardened against this; this sibling gate must match.
In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:919-932: Outer-match reservation-retention invariant for ShieldedBroadcastUnconfirmed is still untested
The latest delta adds unit tests for wait_error_is_definitive_rejection (operations.rs:1342-1368), which pin the wait-stage classifier. The complementary safety invariant — that the outer result match returns Err for ShieldedBroadcastUnconfirmed WITHOUT calling cancel_pending, while every other Err arm DOES cancel — is the actual protection against double-spending reserved notes after an ambiguous wait failure, and it has no Rust-level regression test. A small test seam that injects an unconfirmed result and asserts the selected_notes reservations remain held (and conversely, that a ShieldedBroadcastFailed result clears them) would prevent a future refactor from silently moving the unconfirmed arm into the generic cancellation branch. Partial carry-forward of prior finding #2.
…n reservation retention Addresses the second thepastaclaw review round on #3862: - example app (blocking): hasInFlightRegistrations now reads controller.phase.isActive instead of re-listing cases, so an .unconfirmed slot keeps the OptionsView network toggle disabled. Switching networks tears down the PlatformWalletManager and with it the coordinator — dropping the only in-memory guard keeping the slot un-selectable before the PersistentIdentity row lands. New unit test pins the gate-equals-isActive equivalence through the public startRegistration path. - platform-wallet: the outer-match reservation decision is now the named error_releases_note_reservation helper (false only for ShieldedBroadcastUnconfirmed), unit-tested so a refactor can't silently fold the unconfirmed arm into the generic cancellation branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
✅ DashSDKFFI.xcframework built for this PR.
SwiftPM (host the zip at a stable URL, then use): .binaryTarget(
name: "DashSDKFFI",
url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
checksum: "b44337feb32a232186b8847ae083a075d1659705aaee42dcb35bc940e39235b1"
)Xcode manual integration:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Both prior findings (network-switch gate excluding .unconfirmed; missing test for the outer-match reservation-retention invariant) are resolved on head d9283d0: the Swift gate now delegates to phase.isActive which includes .unconfirmed, and the Rust outer match has been refactored to call error_releases_note_reservation, which is directly unit-tested for retain (Unconfirmed) vs release (definitive failures). No new in-scope blocking or suggestion-level defects introduced by the incremental delta.
Reconciles with #3863, which extended the unconfirmed-broadcast handling to the other shielded spends and refined the identity-create classification itself: - adopt upstream's refined broadcast/wait classification (broadcast_definitely_failed transport triage; only a consensus `cause` makes a wait-stage StateTransitionBroadcastError definitive) and drop the superseded wait_error_is_definitive_rejection helper + tests (its cause-less-is-definitive assumption is now wrong); error_releases_note_reservation and its tests stay. - adopt upstream's ShieldedSpendUnconfirmed variant / FFI code 18 / Swift mirror, and extend the blanket From impl + mapping test to cover it alongside codes 16/17. - keep this branch's newer review-round state (FailureStage without .beforeBroadcast, gated .unconfirmed dismissal, isActive-based network-switch gate) over the older snapshots #3863 carried. - update slot-guard comments for upstream's usedIdentityIndices union of the persisted isUsed reservation: the reservation now also holds the slot across restarts, while the live controller remains a load-bearing guard because that write is best-effort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Prior findings at d9283d0 were revalidated — none existed and none carry forward. Cumulative review against the PR base and the latest delta against d9283d0 found no in-scope issues. The PR cleanly stages shielded IdentityCreateFromShieldedPool failures (build / broadcast / result-confirmation) so reservations are released only on definitive rejection, and the new typed FFI codes (16/17/18) plus the error_releases_note_reservation helper make the policy explicit and unit-tested. The Swift presentation layer correctly mirrors the new states. Codex's two FFI 'blocking' findings target deriveIdentityAuthKeyAtSlot (lines 926-936) and prePersistIdentityKeysForRegistration (lines 1056-1067), but those call sites are pre-existing in ManagedPlatformWallet.swift and are not touched by this PR — the PR's own new resolver call sites (in previewIdentityRegistrationKeys and discoverIdentities) already wrap their FFI calls in withExtendedLifetime(resolver), so they're out of scope for this PR.
Issue being fixed or feature implemented
The SwiftExampleApp shielded (Type-20
IdentityCreateFromShieldedPool) registration progress view misattributed broadcast-result errors to the "Generating Halo 2 proof" step. Observed live on devnet: a type-20 transition executed successfully on-chain, but the client'sbroadcast_and_waitfailed while fetching/verifying the execution-result proof (server side since fixed in #3859). The app showed the registration as failed at the proof step — long after the proof had been generated and the transition broadcast and executed.Worse than the cosmetic misattribution: on any error the wallet released the spent notes' reservations (
cancel_pending) and the app treated the identity as unregistered, even though it could be live on-chain — the orphaned-identity hazard. Re-registering the same keys would fail the registered-key-hash stateful check and burn the funds to the fallback address minus a penalty.What was done?
The whole flow previously ran as one opaque
broadcast_and_wait, collapsing build, broadcast, and result-confirmation failures into a singleShieldedBroadcastFailederror. It is now staged so each failure class is handled on its own terms:identity_create_from_shielded_pool_transitiontrait method (build + structural validation only); the existing one-shot helper is reimplemented on top of it, so other callers see no behavior change.operations.rs::identity_create_from_shielded_pool): broadcasts and waits as separate stages.StateTransitionBroadcastError(Platform definitively reported the transition's execution error) also behaves as before.ShieldedBroadcastUnconfirmed { identity_id, reason }error and leaves the note reservations in place —pending_nullifiersis in-memory only, so the next nullifier sync promotes them to spent if the transition landed, and an app restart frees them if it didn't. Releasing them eagerly would invite double-spend attempts against possibly-consumed notes.ErrorShieldedBroadcastFailed = 16(definitive rejection, notes released, safe to retry) andErrorShieldedBroadcastUnconfirmed = 17(broadcast accepted, result unconfirmed; uniquely,out_identity_idis also written on this code so the host can hold the slot).shieldedIdentityCreateFromPoolthrows a typedShieldedIdentityCreateUnconfirmedErrorcarrying the derived id on code 17.IdentityRegistrationControllergains an.unconfirmed(identityId:message:)phase that is active (holds the identity slot;submit()and the coordinator refuse re-entry) plus aFailureStagefor step attribution;observeControllermarks the HD slot used on.unconfirmed..unconfirmedin orange ("Confirmation pending") and keeps it until the user dismisses it; the coordinator retains it like.failedrather than purging it like.completed.All classification, fetch-retry, and reservation logic lives in Rust behind the FFI; the Swift layer only maps the typed error to presentation state, per the swift-sdk layering rules.
How Has This Been Tested?
cargo check --all-targets --all-features --testsondash-sdk,platform-wallet,platform-wallet-ffi;cargo clippyclean on all three;cargo fmt --all.cargo test -p platform-wallet --features shielded --lib: 220 passed. FFI error-mapping unit tests pass.Phase.unconfirmed.isActive == true(slot held against re-submission).Breaking Changes
None. The FFI function signature is unchanged;
out_identity_idgains a documented additional write on the new unconfirmed result code only. The one-shot rs-sdk helper keeps its exact semantics.Checklist:
For repository code-owners and collaborators only
🤖 Generated with Claude Code