feat(platform-wallet): dip-13 dashpay invitations#4041
Conversation
DashPay invitations (DIP-13 sub-feature 3'): inviter funds a one-time asset-lock voucher and shares a self-contained dashpay://invite link; the invitee registers their own identity from the imported voucher key and optionally sends a contact request back. Reviewed by 3 adversarial spec agents (feasibility/security/scope) + 4 research streams; owner-synced decisions folded: InstantSend proof (short IS-scoped expiry for staleness), opt-in-both-ends contact bootstrap, proper wallet-persister persistence. Core claim mechanic confirmed against code (put_to_platform_with_private_key); seedless path-gated voucher-key export is the one net-new critical piece. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
crypto/invitation.rs: encode/parse the dashpay://invite?data= link and a fail-fast validate_claimable pre-submit check. Self-contained versioned binary payload (voucher key + embedded InstantSend AssetLockProof + advisory expiry + optional inviter contact-bootstrap info), base58 in the URI. - Hand-rolled LE wire format (no dependency on the crate's optional serde feature); the embedded AssetLockProof rides on the always-available dpp::bincode encoding. - Bounds the base58 input before decode + a hard decoded-byte cap (anti-DoS, spec §8 Finding 5); rejects trailing bytes, bad version, truncation. - validate_claimable: rejects a past-expiry link, a non-InstantSend proof, and a voucher key that doesn't control the funded credit output (fail-fast UX over an opaque consensus reject). - Debug for ParsedInvitation redacts the voucher key. 13/13 unit tests green (round-trip, malformed rejections, validation). Spec slice 1 / spike S4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The seedless voucher-key export the create-invitation flow needs (feasibility review's one blocker): production wallets are external-signable at steady state, so the voucher key can only come from the Keychain resolver, not a resident Wallet. - rs-sdk-ffi: MnemonicResolverCoreSigner::export_invitation_private_key, gated to the EXACT DIP-13 invitation path 9'/coin'/5'/3'/idx'. Deliberately stricter than the feature check alone — feature 5' is shared with the user's own identity-auth (5'/0'), registration-funding (5'/1'), and top-up (5'/2') keys, so a looser gate would be a key-exfiltration hole. Mirrors the sanctioned export_auto_accept_private_key exception. - ContactCryptoProvider gains export_invitation_private_key (trait + FFI glue + test doubles). Negative test pins the gate: exports 5'/3', rejects 5'/0', 5'/1', 5'/2', 16', wrong purpose, and wrong length. Spec slice 2 / spike S2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
network/invitation.rs on IdentityWallet: - create_invitation: funds a one-time asset-lock voucher at the DIP-13 invitation account (InstantSend proof, owner decision), exports the path-gated voucher key, and packages a dashpay://invite link. Amount capped in Rust (MAX_INVITATION_DUFFS) so a leaked link's blast radius is bounded below the UI. - claim_invitation: registers a NEW invitee identity funded by the imported voucher — ordinary registration whose asset-lock signature uses the imported raw voucher key via the SDK's put_to_platform_with_private_key, wrapped in the CL-height-too-low retry. Bypasses the wallet's AssetLockFunding machinery (the invitee owns neither the lock's inputs nor its tracking). Best-effort IdentityManager bookkeeping mirrors register_identity_with_funding. Contact-bootstrap is deliberately separate: on success the UI asks the invitee whether to establish contact with the sender, then calls the existing contact-request path. Compile-verified against the real SDK APIs; runtime e2e rides testnet funding. Spec slice 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR implements DashPay DIP-13 invitations across Rust wallet, storage, FFI, Swift SDK, and ExampleApp layers. It adds bounded invitation links, voucher-key gating, create/claim flows, persistence callbacks and models, deep-link handling, invitation UI, and specifications with QA coverage. ChangesDashPay Invitations (DIP-13)
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Inviter
participant IdentityWallet
participant Platform
participant InviteeApp
participant SwiftData
Inviter->>IdentityWallet: create_invitation
IdentityWallet->>Platform: fund and broadcast asset-lock
IdentityWallet-->>Inviter: dashpay://invite URI
IdentityWallet->>SwiftData: persist sent invitation
InviteeApp->>IdentityWallet: parse and validate invitation
InviteeApp->>IdentityWallet: claim_invitation
IdentityWallet->>Platform: submit identity registration
Platform-->>InviteeApp: registered identity
InviteeApp->>Platform: send optional contactRequest
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ 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 |
Two adversarial reviews (correctness + blockchain-security) rated the core ship-worthy (export gate sound, codec panic-free, consensus linkage correct). Folding the findings: - H1 (high): create_invitation now rejects a ChainLock proof. create_funded_ asset_lock_proof falls back to Chain if IS doesn't propagate in 300s, and the invitee's validate_claimable accepts only Instant — so a slow-IS create would otherwise emit a link the invitee silently rejects (dead voucher). Now errors clearly; the funding lock stays reclaimable. - H1b: dropped the dead CL-height (10506) retry in claim_invitation — it only helps ChainLock proofs, which the claim path never carries. Direct submit. - LOW-1 (key hygiene): zeroize the encode buffer + decoded parse bytes; Drop on ParsedInvitation scrubs the voucher scalar (mirrors the resolver's key hygiene). - LOW-2: expiry_unix==0 guard + MAX_INVITATION_TTL_SECS (FFI clamp). Spec §8 reframed: the leaked-link bound is the Rust amount cap + reclaim, NOT the advisory expiry (a leaked-link finder ignores the UI's expiry check). 13/13 codec tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TEST_PLAN.md §4.10 DashPay: DP-12 (create invitation), DP-13 (claim), DP-14 (two-wallet invite→claim e2e, the acceptance gate), DP-15 (reject malformed / reused / expired). Marked 🔌 (not-wired) until the SwiftUI screens land; entry points + a11y ids described so they flip to ✅ when the UI ships. Indexes + multiwallet tag updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
C-ABI entry points wrapping the platform-wallet invitation flows (authored via the swift-rust-ffi-engineer agent, verified + integrated): - platform_wallet_create_invitation(wallet, amount_duffs, funding_account_index, inviter_identity_id?, inviter_username?, now_unix, core_signer_handle, out_uri, out_outpoint): derives expiry = now + MAX_INVITATION_TTL_SECS, builds the InviterInfo only when inviter_identity_id is non-null, and drives one MnemonicResolverCoreSigner as both the asset-lock signer and (wrapped) the ContactCryptoProvider that exports the voucher key. Rejects now_unix == 0. - platform_wallet_claim_invitation(wallet, uri, identity_index, identity_pubkeys, count, signer_handle, now_unix, out_identity_id, out_identity_handle): parses the link, then registers the invitee identity via claim_invitation. Out-params get FFI-safe sentinels before any fallible work. 6 marshalling-guard tests green (null pointers, bad URI, missing username, zero now, unknown wallet). No identity signer on create (pure voucher creation needs only the core signer). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…itation Completes review LOW-1: secp256k1 SecretKey has no Drop-zeroize, so wipe the exported voucher key with non_secure_erase() once it lives in the (secret) URI. Pairs with the ParsedInvitation Drop scrub on the claim side. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…edPlatformWallet Swift wrappers over the invitation FFI (authored via the swift-rust-ffi-engineer agent; verified compiling + linking): - createInvitation(amountDuffs:fundingAccount:inviterIdentityId:inviterUsername: nowUnix:) async throws -> String — returns the dashpay://invite link (secret). - claimInvitation(uri:identityIndex:identityPubkeys:signer:nowUnix:) async throws -> ManagedIdentity — registers the invitee identity from the imported voucher. Follows the established KeychainSigner + MnemonicResolver handoff + async + PlatformWalletResult.check() idiom. SwiftExampleApp xcodebuild green (0 errors, iPhone 17 / arm64 — the arm64-sim xcframework; the generic destination's x86_64 link slice is not produced by build_ios.sh --target sim). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…half) Proper wallet-persister integration for the "Sent invitations" status list + (future) reclaim — the Rust half of the persistence split (Swift SwiftData model + InvitationsView is the counterpart). - changeset: InvitationChangeSet + InvitationEntry + InvitationStatus (Created/Claimed/Reclaimed); optional `invitations` field on PlatformWalletChangeSet (clean-additive — all construction sites use ..Default). Merge = last-write-wins by outpoint. apply_changeset drops it (persistence-only; no in-memory replay in v1). - storage: V003 `invitations` table (all-primitive columns, no lifecycle blob) + schema::invitations::apply/read + persister dispatch. NO secret column — the voucher key is re-derived from funding_index (secrets_scan guardrail green). - create_invitation queues an InvitationChangeSet (status=Created, funding_index from the derivation path, created_at = expiry - TTL); best-effort so a persist failure never fails the already-valid link. platform-wallet 407 + platform-wallet-storage 130 tests green (incl. a new apply→read→upsert→remove round-trip); fmt + clippy --all-features clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add CreateInvitationSheet — amount entry + a "send a contact request back to me" toggle → ManagedPlatformWallet.createInvitation → share sheet + QR of the dashpay://invite link — reached from a new "Invite a friend" action in DashPayProfileView. The link embeds the one-time voucher key (a bearer credential), so it is never logged and the Copy action writes to a local-only pasteboard so it is not mirrored across devices via Universal Clipboard. Funds the asset lock from BIP44 standard account 0; expiry is derived Rust-side from the passed nowUnix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn
…rapper Add platform_wallet_parse_invitation(uri, out_preview) -> InvitationPreviewFFI: a read-only decode of a dashpay://invite link (no wallet handle, no network, no claim) surfacing structurally_valid / is_instant / has_inviter / inviter_id / inviter_username / amount_duffs / expiry_unix. A malformed link is a clean invalid preview (structurally_valid=false), not an error, so the claim UI can render an "invalid link" state; the clock-relative "expired" check stays in Swift. Wraps the existing crypto parse_invitation_uri. The claim sheet needs this to show the invite (amount/sender/expiry) before claiming and to drive the post-claim "establish contact with <sender>?" prompt (claimInvitation returns only the bare identity; the invite parser is otherwise Rust-internal). Adds ManagedPlatformWallet.parseInvitation(uri:) -> InvitationPreview and 2 marshaling tests (null uri, malformed→invalid-preview). 8/8 invitation FFI tests green; framework + SwiftExampleApp build green (iPhone 17, arm64). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn
ClaimInvitationSheet (reached from a new "gift" toolbar action in DashPayTabView): paste a dashpay://invite link → parseInvitation preview (amount / sender / expiry / validity) → claim. Claiming registers a NEW identity for the invitee funded by the imported voucher — ordinary identity registration (master + auth keys via prePersistIdentityKeysForRegistration, plus a DashPay enc/dec pair so the new identity can send the contact request back) — then, if the link carried an inviter, prompts "Add <sender>?" and sends a normal contact request via the shipped sendContactRequest path. The wallet is resolved from the active identity's wallet, falling back to the first loaded wallet on the network, so a fresh invitee with no identity yet can still claim. The DashPay enc/dec key derivation is factored into a shared IdentityRegistrationKeys.makeDashpayKeyPair (a mirror of CreateIdentityView.makeDashpayKeyPair — kept in sync; a follow-up should unify them) rather than duplicated inline. SwiftExampleApp xcodebuild green (iPhone 17, arm64). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn
Register the dashpay URL scheme (Info.plist CFBundleURLTypes) and add .onOpenURL on the app WindowGroup: opening a dashpay://invite link routes to the DashPay tab and hands the URL to AppUIState.pendingInviteURL. DashPayTabView observes it, pre-fills the claim sheet (ClaimInvitationSheet initialURI), and clears the pending URL. The link embeds a bearer voucher key, so it is not logged in the handler. SwiftExampleApp xcodebuild green (iPhone 17, arm64). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn
|
⛔ Blockers found — Sonnet deferred (commit e1b7449) |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## v4.1-dev #4041 +/- ##
==========================================
Coverage 87.42% 87.43%
==========================================
Files 2643 2645 +2
Lines 333632 333905 +273
==========================================
+ Hits 291684 291947 +263
- Misses 41948 41958 +10
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
Source: Codex gpt-5.5 reviewers for general, security-auditor, rust-quality, and ffi-engineer; Codex gpt-5.5 verifier. Degraded: Claude Opus reviewer lanes and Claude Opus verifier failed due extra-usage quota (resets July 10, 2026 at 8:00 AM America/Chicago). Policy gate: review_policy.enforce_backport_prereq_policy applied with no backport-prereq changes.
At the target SHA, the invitation core and FFI paths are present, but the public claim surface does not expose the inviter metadata required by the PR's documented contact-bootstrap flow. I also confirmed the new raw invitation-key export gate does not enforce the fully hardened path shape it documents, and the claim path leaves an extra voucher private-key copy unwiped.
🔴 1 blocking | 🟡 2 suggestion(s)
🤖 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/invitation.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/invitation.rs:244-253: Expose inviter metadata for the contact-bootstrap flow
The invitation codec preserves optional `InviterInfo`, but `platform_wallet_claim_invitation` parses the URI and then only writes the new identity id and managed-identity handle. The Swift wrapper `claimInvitation` returns only `ManagedIdentity`, and there is no parse/preview API in this target SHA that exposes `invitation.inviter` to Swift. The PR documents that an invitee should be prompted to establish contact and then send a reciprocal contact request to the inviter identity; without exposing the inviter identity id and username, that flow is not implementable without duplicating the private Rust URI codec in Swift.
In `packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`:
- [SUGGESTION] packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs:395-399: Enforce hardened invitation export paths
`export_invitation_private_key` documents the accepted path as `m/9'/coin'/5'/3'/funding_index'`, but the gate only checks the length plus components 0, 2, and 3. Paths such as `m/9'/1'/5'/3'/0` still pass and export a non-hardened child private key. The current create flow appears to pass a builder-generated hardened path, but this function is the advertised raw-scalar export boundary; it should reject non-hardened coin and funding-index components so the gate matches the documented one-time voucher scope.
In `packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs:268-294: Wipe the voucher PrivateKey copy in the claim path
`ParsedInvitation` wipes `voucher_key` on drop, but this block copies that secret into `dashcore::PrivateKey`. That wrapper is `Copy + Clone + Debug`, contains a public `SecretKey` field, and has no `Drop` implementation, so the claim path leaves an additional bearer-voucher scalar copy in memory after both success and error returns. This PR explicitly treats the voucher key as bearer money and scrubs the exported scalar in the create path; the claim path should give the `PrivateKey` copy the same treatment.
…ader The compile-time guard tc_p1_003_prepare_cached_in_writers scans every schema writer source for bare .prepare( calls, exempting only the read-only SELECTs enumerated in READ_ONLY_PREPARE_ALLOWED. The new test-gated invitations::read_all reader uses conn.prepare like every other per-table reader, so it needs its allow-list entry. Test would have caught this in CI: the guard was the sole workspace-test failure on this branch (11942 passed, 1 failed) - red before this entry, green after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (1)
packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs (1)
268-294: 🔒 Security & Privacy | 🟠 MajorVoucher
PrivateKeycopy is not scrubbed in the claim path.This was previously flagged:
ParsedInvitationwipesvoucher_keyon drop, butPrivateKey::newat line 271 copies the scalar into aCopy + Clonewrapper with noDropimplementation. The create path scrubs the exported scalar at line 198, but the claim path leaves an additional bearer-voucher copy in memory after both success and error returns. The suggestedWipingPrivateKeywrapper from the prior review has not been applied.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs` around lines 268 - 294, The claim path in the identity invitation flow leaves a non-wiped copy of the voucher secret in memory because `PrivateKey::new` is used directly in `invitation.rs` without any scrubbing wrapper. Update the claim flow around `voucher_priv` and `put_to_platform_and_wait_for_response_with_private_key` to use the same `WipingPrivateKey` approach as the create path, or otherwise ensure the scalar is zeroized on drop after success and error. Keep the fix localized to the invitation claim handling in `ParsedInvitation`/`PrivateKey::new` usage so the voucher key is not retained after use.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/dashpay/DIP15_INVITATIONS_SPEC.md`:
- Around line 376-404: Update §6 to match the implemented invitation URI format
in crypto/invitation.rs: describe the hand-rolled little-endian binary encoding
instead of serde/bincode, correct the wire field order to version, voucher_key,
expiry_unix, inviter, asset_lock, and align the API docs with the actual
encode_invitation_uri and parse_invitation_uri signatures and return types. Use
the existing InvitationPayloadV0, encode_invitation_uri, and
parse_invitation_uri symbols as the source of truth so the spec reflects the
as-built implementation.
- Around line 125-133: Update the create_invitation API spec to match the
implementation by removing the explicit invitation_index parameter from the
create_invitation signature and documenting that create_funded_asset_lock_proof
auto-selects the next unused funding index through its builder; keep the rest of
the parameters in create_invitation aligned with the implementation and
reference the create_invitation and create_funded_asset_lock_proof symbols so
the signature and behavior stay consistent.
In `@packages/rs-platform-wallet-ffi/src/invitation.rs`:
- Around line 245-266: The claim_invitation FFI entrypoint currently allows a
zero now_unix, which can make validate_claimable treat expired invitations as
valid; add the same non-zero time check used by create_invitation before
invoking validate_claimable. Update platform_wallet_claim_invitation to reject
now_unix == 0 with an invalid-parameter result, keeping the existing pointer and
identity_pubkeys_count guards intact.
In `@packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs`:
- Around line 204-207: The silent fallback to 0 for funding_index in
invitation.rs can make voucher key recovery impossible if the derivation path
shape is unexpected. Update the logic around the path.as_ref().last() match in
the invitation funding index derivation to emit a warning when the path is empty
or ends in a non-Hardened child, while still preserving the current fallback
behavior if needed. Use the existing invitation/voucher derivation code path to
add a loud signal tied to funding_index so unexpected DIP-13 path shapes are
visible during debugging and recovery.
In `@packages/swift-sdk/SwiftExampleApp/Info.plist`:
- Around line 33-47: The DashPay invitation deep link flow currently uses the
hijackable custom scheme registered in SwiftExampleApp’s Info.plist via
CFBundleURLTypes, which can expose the voucher private key. Replace this
scheme-based claim entry point with a Universal Links setup (HTTPS link backed
by an AASA file) or another verified handoff mechanism, and update the invite
handling path that currently targets dashpay://invite so the app routes claims
through the new secure entry point.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`:
- Around line 189-207: The current sheet presentation in DashPayTabView uses a
boolean, so a second invite URL won’t recreate ClaimInvitationSheet when it is
already open. Replace the showClaimInvitation flag with an identifiable
presentation state (for example a claim request/item) and drive the sheet with
.sheet(item:) so each pendingInviteURL change constructs a fresh
ClaimInvitationSheet with the new initial URI. Update the onChange handler for
appUIState.pendingInviteURL to assign a new request object each time, and keep
ClaimInvitationSheet’s initialization path tied to that item so its internal uri
state is seeded for every incoming invite.
---
Duplicate comments:
In `@packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs`:
- Around line 268-294: The claim path in the identity invitation flow leaves a
non-wiped copy of the voucher secret in memory because `PrivateKey::new` is used
directly in `invitation.rs` without any scrubbing wrapper. Update the claim flow
around `voucher_priv` and
`put_to_platform_and_wait_for_response_with_private_key` to use the same
`WipingPrivateKey` approach as the create path, or otherwise ensure the scalar
is zeroized on drop after success and error. Keep the fix localized to the
invitation claim handling in `ParsedInvitation`/`PrivateKey::new` usage so the
voucher key is not retained after use.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: bdc56fe1-5052-483c-aa66-65ce9fd8da1c
📒 Files selected for processing (28)
docs/dashpay/DIP15_INVITATIONS_SPEC.mdpackages/rs-platform-wallet-ffi/src/dashpay.rspackages/rs-platform-wallet-ffi/src/invitation.rspackages/rs-platform-wallet-ffi/src/lib.rspackages/rs-platform-wallet-storage/migrations/V003__invitations.rspackages/rs-platform-wallet-storage/src/sqlite/persister.rspackages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rspackages/rs-platform-wallet-storage/src/sqlite/schema/mod.rspackages/rs-platform-wallet-storage/tests/sqlite_compile_time.rspackages/rs-platform-wallet/src/changeset/changeset.rspackages/rs-platform-wallet/src/changeset/mod.rspackages/rs-platform-wallet/src/wallet/apply.rspackages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rspackages/rs-platform-wallet/src/wallet/identity/crypto/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rspackages/rs-platform-wallet/src/wallet/identity/network/invitation.rspackages/rs-platform-wallet/src/wallet/identity/network/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/payments.rspackages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rspackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swiftpackages/swift-sdk/SwiftExampleApp/Info.plistpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationKeys.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swiftpackages/swift-sdk/SwiftExampleApp/TEST_PLAN.md
Cold-launch fix (HIGH): .onOpenURL sets AppUIState.pendingInviteURL during scene connection, but on a cold launch ContentView shows "Initializing…" until bootstrap finishes, so DashPayTabView doesn't exist yet and its .onChange(of: pendingInviteURL) baselines to the already-set value and never fires — the claim sheet never appears (the common "tap invite link from Messages" path) and the bearer URL lingers in @published. Add an .onAppear consumer alongside the existing .onChange (shared consumePendingInviteURL()); the nil guard makes the second call a no-op, so no double-present. Also: gate .onOpenURL on url.host == "invite" so unrelated dashpay:// links don't yank the user to the claim sheet; add an accessibilityLabel to the icon-only "gift" claim toolbar button; mark the decorative invitation QR image accessibilityHidden. SwiftExampleApp xcodebuild green (iPhone 17, arm64). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn
The voucher-key export gate bound only the fixed purpose/feature/sub-feature components (9'/*/5'/3'/*), leaving coin_type and funding_index unconstrained, so a path like m/9'/1'/5'/3'/0 (non-hardened tail) still exported a child key. Every component of a real invitation-funding path is hardened; enforcing that on the raw-scalar export boundary keeps the runtime check exactly as strict as its documented scope. Not a live exfil hole (the whole 9'/*/5'/3'/* subtree is voucher-only), but defense-in-depth at the advertised export seam. Extends the negative gate test with the two now-rejected non-hardened cases (both pass the old gate and export, fail the new one) and drops a non-timeless spec-finding tag from the test doc. Addresses review: thepastaclaw 'Enforce hardened invitation export paths' + rust-quality reviewer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn
Key hygiene (voucher is bearer money — scrub every copy): - Scrub the exported voucher scalar on the encode-error path too, not just on success: on an encode failure the key never legitimately left the device, so a lingering copy is the worst kind. (security-auditor LOW-1) - Wrap the claim-path voucher PrivateKey in a WipingPrivateKey RAII guard so the imported scalar is erased on every exit; dashcore::PrivateKey has no Drop-zeroize. (thepastaclaw + CodeRabbit Major + security-auditor LOW-2) Defensive consistency: - Reject a zero now_unix in validate_claimable: otherwise now(0) > expiry is false and an expired link looks claimable. Mirrors the create-side non-zero timestamp guard; unit-tested (validate_rejects_zero_clock — Ok on the old code, Err now). (rust-quality + correctness + CodeRabbit) - Skip the local invitation record with a warning when the funding path has an unexpected non-hardened tail, instead of persisting funding_index=0 — a wrong index would re-derive the wrong voucher key on reclaim. (3 reviewers) - Publish FFI-safe sentinels for the claim out-params before any fallible work, matching the create/parse siblings. (correctness) - Document the InvitationChangeSet merge insert-vs-tombstone hazard, parity with IdentityChangeSet. (rust-quality) Also drops non-timeless spec/review tags from comments per the repo's comment-timeliness policy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn
…st proof assembly Second-pass review fixes on #4041. #1 (blocking): persist the invitation record immediately after funding, BEFORE the InstantSend check — a ChainLock-confirmed voucher is rejected as a *link* but is still a funded, reclaimable lock, so it must be recorded rather than orphaned. My earlier fix put the persist after the IS/CL check, which still orphaned it. #2 (blocking): claim-by-fetch now retries the funding-tx lookup (both byte orders) with a bounded backoff (5 x 3s). InstantSend/ChainLock finality doesn't guarantee the invitee's DAPI node has indexed the tx, so a freshly shared invitation could fail on ordinary propagation lag with no retry. #5: extract the post-fetch proof reconstruction into a pure `assemble_asset_lock_proof` and unit-test its guards (txid mismatch, chainlock-required, chainlock-success, islock-locks-wrong-tx) — previously zero coverage on funds-adjacent logic. #6: `encode_invitation_uri` now rejects a final URI over `MAX_INVITATION_URI_LEN` (percent-encoding can expand fields past the per-field raw cap) — the exact length its own parser enforces, so it can't emit a self-unparseable link. #7: correct the preview FFI `inviter_username` doc — a null username no longer implies has_inviter==false (metadata-only links set has_inviter with a null username). invitation tests: 28/28 (+4 assemble_*) platform-wallet, 10/10 ffi. fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sh-interrupted reclaim Second-pass review fixes on #4041. #3: the Sent-invitations list now spans ALL loaded wallets (not just the paperplane's wallet) and reclaims each row against its OWN walletId — a device with several loaded wallets no longer strands the others' funded, reclaimable invitations. InvitationsView drops its walletId param; DashPayTabView gates the paperplane on any loaded wallet. #4: classifyReclaimFailure now recovers a crash-interrupted self-reclaim: if our own consume landed on-chain but the terminal save was interrupted, a retry resumes an untracked lock and fails LOCALLY ("…is not tracked") — before Platform, so the already-consumed matcher never sees it. With the in-flight marker set, that untracked lock is our completed reclaim, so it resolves to Reclaimed instead of leaving the row stuck at Created forever. Two new unit tests (marker set -> reclaimed, unset -> error). Swift 6 build_ios succeeds; ReclaimInvitationClassifierTests all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…view fixes TEST_PLAN DP-12/13/15: replace the stale `dashpay://invite?data=…` embedded-proof format with the legacy query form (`du`/`assetlocktx`/`pk`/`islock`); DP-13 now describes claim-by-fetch (refetch tx by id, bounded retry, reconstruct proof); DP-15 drops the past-expiry path (the legacy wire carries no expiry) and covers the real negative paths (malformed/reused/wrong-network). QA004: default amount 0.005 -> 0.03 DASH; note the record is persisted before the fallible steps (chainlock voucher still reclaimable); document the reclaimInFlight marker disambiguation (self-reclaim vs foreign claim) and the crash-recovery path via the tested classifyReclaimFailure seam. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y-invitations # Conflicts: # packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift # packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
Codex-only preliminary verification at exact head 2377cf3 confirmed three blocking invitation-safety defects, two UI persistence/scoping suggestions, and two documentation nitpicks. Seven of the eight indexed prior findings are fixed; only prior-2 remains valid, while adjacent residuals for prior-10 and prior-14 are recorded as distinct new cumulative findings. Targeted tests passed: 28 platform-wallet invitation tests and 10 platform-wallet-ffi invitation tests.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (failed),gpt-5.6-sol— security-auditor (failed),gpt-5.6-sol— ffi-engineer (failed),gpt-5.6-sol— general (completed),gpt-5.6-sol— security-auditor (completed),gpt-5.6-sol— ffi-engineer (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 3 blocking | 🟡 2 suggestion(s) | 💬 2 nitpick(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/src/wallet/asset_lock/build.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:415-424: The voucher-index gate does not guarantee durable persistence
The gate treats a successful persist_asset_lock_account_pools() call as proof that the one-time invitation index survived, but that helper only calls WalletPersister::store. The PlatformWalletPersistence contract permits store to buffer until flush, Manual SQLite does exactly that, and the public NoPlatformPersistence backend returns Ok without writing anything. The shipped Swift backend currently commits synchronously, but the public Rust invitation API accepts any conforming backend. After a restart, an unpersisted index can be selected again and produce the same voucher key. A holder of the earlier link can combine that WIF with the later public funding transaction and a ChainLock-form invitation to consume the later voucher. Require a successful durability boundary before broadcast and prevent invitation creation with a backend that cannot attest durable storage.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift:432-442: Invitation vouchers still surface as resumable identity registrations
crossWalletResumableLocks filters only by status and the wallet/identity-slot anti-join. AssetLockResumeRow does not expose fundingTypeRaw, even though invitation locks are persisted with fundingTypeRaw == 3 and identity index 0. Selecting such a row reaches resumeIdentityWithAssetLock and platform_wallet_resume_identity_with_existing_asset_lock_signer; the Rust FromExistingAssetLock resolver re-derives the actual invitation account path without authorizing its funding type, then submits IdentityCreate and consumes the voucher. This can irreversibly invalidate a bearer link already shared with its intended recipient. Exclude invitation-funded locks from the generic UI and defensively reject them in the generic registration API.
In `packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs:243-252: The invitation record is still created only after proof acquisition
create_invitation cannot create its InvitationEntry until create_funded_asset_lock_proof returns the path and outpoint. That callee broadcasts at build.rs:456, then waits up to 300 seconds for InstantSend and can fall back to an unbounded ChainLock wait before returning at line 515. The asset-lock row is persisted during this interval, but the Sent Invitations and reclaim UI is driven exclusively by PersistentInvitation. Termination or proof-wait failure after broadcast therefore leaves a funded invitation lock with no intended reclaim row; excluding invitation locks from generic registration, as required by the other blocker, removes its only accidental UI path. Persist typed pending invitation state immediately after successful broadcast, or synthesize the invitation row from the persisted typed asset lock.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift:18-24: The all-wallet query includes inactive networks and unloaded wallets
The unfiltered @Query reads every PersistentInvitation in the shared SwiftData container, while the view receives only the active network's PlatformWalletManager. WalletManagerStore retains managers for inactive networks, and PersistentInvitation has no network discriminator. Wallet deletion also does not explicitly remove invitation rows. These rows remain visible but ReclaimInvitationSheet passes their walletId to the active manager, where wallet(for:) returns nil and displays “No wallet loaded.” Filter displayed rows to wallet IDs loaded in the active manager, or persist and query a network discriminator.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift:195-223: Require the reclaim marker to persist before consuming the voucher
markInFlight suppresses modelContext.save() failure with try?, and both reclaim branches immediately enter an irreversible Rust consume. If the save fails, the consume succeeds, and the process terminates before the terminal status save, the durable reclaimInFlight value remains false. On restart, a local “is not tracked” response is classified as an error without the marker, while an already-consumed Platform response is treated as a foreign claim. Make markInFlight throwing and do not call either consume API unless its save succeeds. The classifier tests cover marker values but not marker-persistence failure.
…itation right after broadcast Round-3 review blockers on #4041: 1. The pre-broadcast voucher-index gate treated store() as durability, but the persistence contract explicitly allows store to buffer until flush. The IdentityInvitation gate now drives flush() — the contract's durability boundary — and aborts before broadcast when it fails. A new PlatformWalletPersistence::persists_durably() capability (default true; NoPlatformPersistence overrides false) makes create_invitation refuse a write-dropping backend before any funds move, closing the restart voucher-key-reuse hole for non-durable backends. 2. The invitation record was created only after create_funded_asset_lock_proof returned — past the broadcast AND the up-to-300s InstantSend wait (with an unbounded ChainLock fallback), so termination in that window orphaned a funded lock from the Sent-invitations/reclaim UI. The flow is now split into broadcast_funded_asset_lock (steps 1-4) + wait_for_funded_asset_lock_proof (steps 5-6), with the composed public API unchanged; create_invitation persists + flushes the InvitationEntry between the halves, immediately after broadcast. Also reworded the has_inviter ABI docs: the flag means inviter-METADATA presence; a non-null inviter_username is the contact-bootstrap condition. Tests (red on the old code by construction): - invitation_gate_aborts_before_broadcast_when_flush_fails (old gate never flushed, so the flow reached the rejecting broadcaster) - flush_failure_does_not_gate_non_invitation_funding (scoping guard) - broadcast_half_leaves_broadcast_row_and_flushed_pool (the split's contract) - durability_gate::create_invitation_requires_durable_persistence (old code proceeded into signing — the test signer panics on any use) 452 platform-wallet + 162 ffi tests green; clippy --workspace --all-features and fmt --check clean; funded sim e2e re-run green (QA004 all steps). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ire marker save before consume Round-3 review suggestions on #4041: 1. InvitationsView keeps its all-wallet query (multi-wallet reclaim was deliberate) but now filters displayed rows to wallets loaded in the ACTIVE network's manager: PersistentInvitation has no network discriminator and deleted wallets keep their rows, so cross-network / stale rows could only render a dead "No wallet loaded" reclaim. 2. ReclaimInvitationSheet.markInFlight is now throwing: the irreversible consume can only run after the reclaimInFlight marker's save succeeded. On a failed save the in-memory flag is rolled back to the captured prior value (so a later unrelated save can't leak a marker that never reached disk) and the reclaim aborts. Previously a try?-suppressed save failure + consume + crash stranded the row (local "is not tracked" → error; Platform "already consumed" → misclassified as a foreign claim). Also doc-only fixes: the persistInvitationsCallback contract comment described the pre-B1 silently-skipped behavior (it now fails the round and rolls back the invitation-only changeset); re-homed the stranded persistAssetLocksCallback doc block; InvitationPreview.hasInviter reworded to metadata-presence (username is the bootstrap condition). Verified: SwiftExampleApp xcodebuild green; ReclaimInvitationClassifierTests 10/10; funded sim e2e (QA004) green — including a live observation of the durable reclaimInFlight=1 marker in sqlite before the consume, and the crash-recovery retry resolving to Reclaimed. No unit test for the markInFlight save-failure path itself: the seam is a SwiftData ModelContext.save() inside a view-local closure; the decision logic stays covered via the pure classifyReclaimFailure tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e new persist ordering - INVITATIONS_REVIEW_FIX_SPEC.md: round-2 section covering the 6 findings of the 2026-07-14 review (R1 durability gate, R2 broadcast-half split, R3 loaded-wallet filter, R4 throwing markInFlight, R5/R6 doc contracts) with the accepted residuals spelled out. - QA004: step 1/2 amounts were stale (0.005 → the actual 0.03 default / 3,000,000 duffs); budget precondition sized for two default creates; persist-ordering expectation updated to "immediately after broadcast, before the proof wait"; step 6 recipe corrected — the single-device sqlite reset exercises the LOCAL "not tracked" classifier arms (marker set ⇒ Reclaimed recovery, verified live; marker unset ⇒ error, row stays Created by design), while the neutral "already claimed" path requires the genuine two-device DP-19 race; min-guard hint text matched to the UI (includes the 0.05 max). - TEST_PLAN DP-12: persist-ordering wording updated the same way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed InvitationsView InvitationsView now requires PlatformWalletManager (the loaded-wallet row filter), but the paperplane NavigationLink — a toolbar-hosted destination — did not re-inject it the way the sibling IgnoredContactsView destination does. Toolbar-hosted destinations don't reliably inherit environment objects across iOS versions, so a missing object would crash on body evaluation. Worked on the iOS 26.5 simulator (verified live before and after), so this pins the codebase convention rather than a reproduced crash; review flagged it as a latent portability defect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spec still presented the 2026-07-08 design draft as current ("no code
yet"), including the superseded ?data= binary envelope (§6), the reversed
ship-our-own-format interop decision (§7), Instant-only claim acceptance,
the wire expiry mechanism, and pre-rework amount bounds. A new §0 records
where the shipped implementation deliberately diverged (legacy query
format + claim-by-fetch, ChainLock claim acceptance, no expiry/id on the
wire, 0.003/0.05/0.03 amounts, per-backend persistence incl. the SwiftData
bridge, round-1..3 durability hardening, reclaim, DP-12..19 QA rows), and
§6/§7 carry superseded banners pointing at it. Design rationale kept.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… doc The PR had accumulated four spec files (1,484 lines): the main design spec plus three follow-up docs whose headers had gone stale (the legacy-compat spec still said "dedicated PR, do not fold into #4041"; the review-fix log was pure dev-time tracking that belongs in the PR threads and commit messages, where it already lives verbatim). Now a single DIP15_INVITATIONS_SPEC.md: - §0A absorbs the legacy-compat spec's lasting content — the wire-format interop contract (emit strict/parse lenient, WIF rules, islock="null" → ChainLock, byte-order retry), claim-by-fetch, format consequences, amount tiers, transport notes. - §0B absorbs the Swift-persistence spec's lasting content — the push-callback bridge architecture (SwiftData is the UI source, push-only, failure signaled end-to-end, the outpoint-key seam) and the reclaim semantics (OP_RETURN burn → credits, FromExistingAssetLock, the reclaimInFlight marker classifier). - INVITATIONS_REVIEW_FIX_SPEC.md deleted outright: per-finding review tracking is process narration, not reference; its timeless residue (durability contract, persist ordering, S2 deadlock rationale) already lives in code comments and §0.5. Step-by-step implementation narration (FFI field layouts, wiring recipes) is dropped — it duplicates code comments and rots. Everything removed remains in git history and the PR record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…et::dip9 The raw-key export gates hardcoded the DIP-9/DIP-13 feature indexes (9'/5'/3' and 9'/16') as numeric literals even though rust-dashcore's key_wallet::dip9 is the canonical home of these constants (FEATURE_PURPOSE, FEATURE_PURPOSE_IDENTITIES, FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_INVITATIONS). Same values, wrong source of truth — a drift in the canonical definitions would silently diverge from the gates. The invitation gate now uses the dip9 constants. The DIP-15 auto-accept feature (16') has no dip9 constant yet — key-wallet doesn't define it, and this crate cannot depend on rs-platform-wallet (whose crypto/auto_accept.rs carries its own local copy) — so it becomes a named module const documenting that gap; upstreaming it into key_wallet::dip9 is a rust-dashcore follow-up. All 13 resolver-signer tests green, including both path-gate negatives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
Verified read-only at exact HEAD 61f066b. Carried-forward prior findings: prior-1 remains a blocking invitation-voucher consumption bug, while prior-2 through prior-8 are fixed; three separate carried-forward automated-review suggestions remain valid. New findings in the latest ac6adbf..61f066b delta: none—the path-constant refactor preserves the values 9, 5, 3, and 16.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— specialist (completed),gpt-5.6-sol— specialist (completed),gpt-5.6-sol— specialist (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 1 blocking | 🟡 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift:432-442: Invitation vouchers still surface as resumable identity registrations and can be irreversibly consumed
`AssetLockResumeRow` exposes only wallet, status, and identity-index fields, so `crossWalletResumableLocks` cannot distinguish ordinary registration locks from `IdentityInvitation` locks. Invitation creation persists type-3 locks at nominal identity index 0, and any such lock whose slot is locally unused enters the generic Resumable Registrations UI. The Resume action calls `resumeIdentityWithAssetLock`; neither its Swift/FFI signature nor the Rust registration entry point enforces registration funding, and Rust deliberately re-derives the key from the tracked lock's actual invitation account. The inviter can therefore consume an already-shared bearer voucher to create an unrelated local identity, permanently invalidating the invitee's link. Carry `fundingTypeRaw` through `AssetLockResumeRow` and restrict this registration-only surface to registration-funded locks.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift:339-340: Repeated local resume failures can be misreported as a successful reclaim
A first reclaim attempt persists `reclaimInFlight = true` before calling the wallet. If that call fails locally with `is not tracked`, `hadPriorReclaimInFlight` is false, so the catch returns `.error` and deliberately leaves the newly persisted marker set. An identical retry then captures the marker as true, hits the same pre-submit local failure, and these lines return `.reclaimed` even though neither attempt reached Platform. Existing tests exercise individual classifier calls but not this two-attempt state transition. Clear the marker for demonstrably pre-submit failures or carry a typed result showing whether Platform submission was attempted.
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift:375-376: DAPI-controlled error text can forge terminal invitation status
`isAlreadyConsumed` treats any rendered error containing `already completely used` as authoritative and the caller then durably records Claimed or Reclaimed. The SDK constructs `StateTransitionBroadcastError` from the DAPI response's untrusted `message` plus an optional decoded consensus `cause`, and the broadcast path returns that server error before result-proof verification. `PlatformWalletError` and the generic FFI conversion flatten it to Display text, discarding the typed cause before Swift classifies it. A malicious or compromised selected DAPI node can therefore return the phrase without a valid matching consensus error, causing a still-live voucher to be terminalized and its normal reclaim action hidden. Expose and match a typed already-consumed consensus result for the requested outpoint.
In `packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs:543-567: The propagation-retry loop has no behavioral tests
The new tests cover `assemble_asset_lock_proof` only after a transaction has been fetched. No test executes this five-attempt orchestration, leaving its blocker-fixing behavior unpinned: canonical then reversed lookup on every attempt, success after a later miss, immediate propagation of transport errors, exactly five miss attempts, and no delay after the final miss. Extract or inject transaction-fetch and delay seams and test these behaviors independently of proof assembly.
…eam; clear stale reclaim marker Two of the round-4 findings on #4041: 1. The claim-by-fetch propagation retry had no behavioral tests. Extracted fetch_funding_tx_with_retry — the injectable orchestration seam of reconstruct_asset_lock_proof (fetch + delay are now parameters; production passes Sdk::get_transaction and tokio sleep) — and pinned its contract with 5 tests: first-canonical hit, reversed fallback within the same attempt (no delay spent on byte order), one delay per fully-missed attempt, exactly MAX attempts with no trailing delay, and immediate transport-error propagation (never masked as a miss). 2. A first reclaim attempt persists reclaimInFlight=true and can then fail the LOCAL pre-broadcast "is not tracked" resume guard; the marker deliberately survived .error outcomes, so an identical retry captured it as a prior in-flight and misreported the same purely-local failure as a completed self-reclaim (two-attempt false positive). New pure shouldClearInFlightMarker seam: when the attempt set the marker itself (no prior) and the failure is the pre-broadcast local guard — proof no consume started — the marker is cleared with the error. Every other error still keeps it (crash-recovery disambiguation unchanged). Pinned by 4 new classifier tests incl. the two-attempt transition, which fails against the old keep-the-marker behavior by construction. 457 platform-wallet tests, 14 classifier tests, clippy --all-features + fmt --check clean. QA004 step-6 updated for the marker-clearing behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bumps the rust-dashcore pin 337f6ccc -> ac8828c9 (dev HEAD), picking up
rust-dashcore#886 which adds the previously missing DIP-15 derivation
constants to key_wallet::dip9. All three call sites that carried local
copies now reference the canonical constants:
- rs-sdk-ffi resolver signer: the auto-accept export gate uses
FEATURE_PURPOSE_DASHPAY_AUTO_ACCEPT (local const removed) and the
contactInfo seal/open glue uses DASHPAY_CONTACT_INFO_{ENC_TO_USER_ID,
PRIVATE_DATA}_CHILD instead of raw 65536/65537.
- rs-platform-wallet crypto/auto_accept.rs: purpose/coin/feature literals
replaced with FEATURE_PURPOSE, DASH_COIN_TYPE/DASH_TESTNET_COIN_TYPE,
and the dip9 auto-accept constant.
- rs-platform-wallet crypto/contact_info.rs: ENC_TO_USER_ID_CHILD /
PRIVATE_DATA_CHILD keep their local names but are now defined from the
dip9 constants.
The bump also picks up rust-dashcore#885 (PlatformNodeId newtype for
platform_node_id fields); adapted the two affected sites (ProUpServ
aggregation in platform-wallet-ffi via to_byte_array(); an SPV peers test
fixture constructs PlatformNodeId instead of PubkeyHash).
Workspace check + clippy --all-features + fmt clean; platform-wallet 457,
platform-wallet-ffi 162+26, resolver-signer 13 (incl. both path-gate
negatives) all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
At exact head 0a0b3e9, carried-forward prior-2 remains blocking, while the other seven mandatory prior findings are fixed. The latest delta introduces no new finding; cumulative current-PR validation confirms two additional blockers—the fail-open durability capability and the source-breaking ContactCryptoProvider change—plus one documentation nitpick. Three blocking findings require changes before merge.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— security-auditor (failed),gpt-5.6-sol— rust-quality (completed),gpt-5.6-sol— ffi-engineer (completed),gpt-5.6-sol— security-auditor (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 3 blocking | 💬 1 nitpick(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/Core/Views/IdentitiesContentView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift:436-442: Invitation vouchers still appear as resumable identity registrations
`crossWalletResumableLocks` filters only by lifecycle status and the `(walletId, identityIndex)` anti-join. `AssetLockResumeRow` omits `fundingTypeRaw`, although this PR records invitation locks with discriminator 3 (`IdentityInvitation`) and identity index 0. When that nominal identity slot is unused, the voucher appears under Resumable Registrations; the Resume action passes it to `resumeIdentityWithAssetLock` without a funding-type guard and can consume it to register an unrelated local identity, permanently invalidating the bearer link shared with the invitee. Add the funding discriminator to `AssetLockResumeRow` and exclude invitation-funded locks from this generic surface; they already have the dedicated Sent Invitations/reclaim workflow.
In `packages/rs-platform-wallet/src/changeset/traits.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/changeset/traits.rs:194-207: The invitation durability capability fails open
`create_invitation` relies on `persists_durably()` to prevent reuse of its exported bearer key, but the new capability defaults to `true`. Consequently existing buffering or no-op implementations pass unless they explicitly opt out. The repository contains a concrete affected path: `FFIPersister` inherits this default, skips nonempty invitation and address-pool changesets when their callbacks are absent, and still lets `store()` and `flush()` succeed; `PlatformWalletManager.configure(..., modelContainer: nil)` constructs exactly that callback-free persister. After restart, the invitation funding index can reset and the same private key can be exported for a later funded voucher. Make the capability fail closed by default, have durable implementations explicitly opt in, and make `FFIPersister` report non-durable or return an error when the required persist/commit/load callbacks are unavailable.
In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:77-80: Adding a required method to ContactCryptoProvider is a breaking API change
`ContactCryptoProvider` is public, re-exported from the crate root, and belongs to a publishable crate. Adding `export_invitation_private_key` without a default implementation makes every existing downstream provider implementation fail to compile. That directly contradicts the PR description's declaration that the change has no breaking changes. Supply a default unsupported implementation so existing providers retain source compatibility and invitation-capable providers can override it.
…eaking provider trait 1. `persists_durably()` failed OPEN: the `true` default silently trusted any backend that didn't opt out, and the repo contained a concrete hole — a callback-free FFIPersister (manager configured without a persistence container) skips non-empty changesets while store()/flush() still return Ok, so a restart could re-export the same bearer voucher key. The capability is now fail-closed (`false` default, durable backends explicitly attest), and FFIPersister attests only when the transaction bracket + the invitation-critical callbacks (invitations, account address pools) are all wired — the shape the Swift bridge always produces. Pinned by `durability_attestation_defaults_to_fail_closed` (a bare impl reads non-durable) and three FFIPersister wiring tests (callback-free and partially-wired stay non-durable; fully-wired attests). The callback-free case is red on the fail-open default by construction. 2. `ContactCryptoProvider::export_invitation_private_key` was a required method on a public re-exported trait — a source-breaking addition for downstream providers, contradicting the PR's no-breaking-changes declaration. It now carries a default "unsupported" implementation: existing providers keep compiling, a create attempted through one fails loudly at runtime, invitation-capable providers override. 3. Doc-only: the claim wrapper (Swift) + FFI docs promised a `now_unix` advisory-expiry check that the legacy link (no expiry on the wire) never performs; both now state the argument is retained for C ABI compatibility and currently ignored. platform-wallet 458 + platform-wallet-ffi 165 tests green; clippy --all-features + fmt clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
Carried-forward prior findings: prior-2 remains a blocking voucher-consumption path; the other nine mandatory priors are fixed at the current head. New latest-delta finding: the fail-closed durability change omitted SqlitePersister's opt-in, disabling invitation creation on the repository's durable SQLite backend. New cumulative findings: concurrent invitation builds can persist an older funding-pool snapshot, the Swift preview fabricates a zero inviter identity instead of nil, and dismissible or replaced claim sheets can overlap registrations at the same identity index. Targeted verification passed 36 platform-wallet invitation tests, 10 platform-wallet-ffi invitation tests, and 3 SQLite compile-time tests, but these five blockers require changes.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (failed_before_session),gpt-5.6-sol— general (failed),gpt-5.6-sol— general (failed),gpt-5.6-sol— general (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 5 blocking
2 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/Core/Views/IdentitiesContentView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift:432-442: Invitation vouchers still appear as resumable identity registrations
`AssetLockResumeRow` exposes no funding discriminator, and `crossWalletResumableLocks` filters only by lifecycle status and the `(walletId, identityIndex)` anti-join. Invitation locks are recorded as `IdentityInvitation` with nominal identity index 0, so one appears under Resumable Registrations whenever that slot is locally unused. The Resume action reaches `resumeIdentityWithAssetLock`; neither its Swift/FFI signature nor Rust's `FromExistingAssetLock` registration path rejects invitation funding, and the resolver re-derives the tracked lock's actual invitation key. Consuming the row therefore registers an unrelated local identity and irreversibly invalidates the bearer invitation shared with its intended recipient. Add `fundingTypeRaw` to the resumable-row contract, exclude type 3, and enforce the same restriction at the generic registration boundary.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:818-848: SQLite never opts into the new durability capability
The latest delta correctly makes `PlatformWalletPersistence::persists_durably()` default to `false`, but this `SqlitePersister` implementation does not override it. It consequently inherits `false`, and `create_invitation` rejects every SQLite-backed wallet before funding even though this backend implements transactional storage and a durable `flush` boundary. The trait documentation explicitly names SQLite as an implementation that should attest durability. Override the capability here and add a SQLite-backed invitation-gate regression test.
In `packages/rs-platform-wallet/src/wallet/asset_lock/build.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:284-344: Concurrent invitation builds can roll back the durable funding index
The wallet write lock protecting index allocation is released before the full address-pool snapshot is collected and persisted. Build A can snapshot index 0 as used, build B can then mark index 1 and persist the newer snapshot, and A can subsequently commit its older snapshot. SQLite replaces the entire `snapshot_blob` for that pool, and the address pool pre-generates unused addresses, so A's snapshot can explicitly restore index 1 to unused. After restart, the next invitation reuses index 1 and exports the same bearer key. Caller serialization is not guaranteed: `create_invitation` is a public async API, and `CreateInvitationSheet` permits cancellation or interactive dismissal while its unstructured creation task continues, allowing a newly opened sheet to start another build. Serialize build-through-flush with a dedicated per-wallet mutex that does not re-enter `wallet_manager`, or persist a monotonic used-index update.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift:2186-2188: Invitation preview fabricates a zero inviter identity
The legacy invitation link carries no inviter identity ID, the Rust preview deliberately zeroes `inviter_id`, and the public Swift `InvitationPreview.inviterId` contract says the value is always nil. This conversion instead returns `Data(repeating: 0, count: 32)` whenever any inviter metadata exists. A consumer following the optional contract can treat that sentinel as a resolved identity and skip the required DPNS username lookup. Return nil unconditionally; `hasInviter` and `inviterUsername` already represent the metadata actually present on the wire.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift:77: An in-flight invitation claim can be dismissed and overlapped
Cancel remains enabled during `isClaiming`, interactive dismissal is not disabled, and the claim runs in an unstructured task that continues after the sheet disappears. `DashPayTabView` can also replace an open claim sheet when another invitation URL arrives. A second claim started before the first identity row is persisted selects the same `nextUnusedIdentityIndex`, derives and pre-persists the same identity keys, and races two identity-create transitions. Platform's unique-public-key validation rejects one transition while the successful task's result and contact prompt may be hidden with its dismissed sheet. Keep claim ownership outside the disposable view and prevent dismissal or replacement until the operation reaches a terminal state.
…y-invitations Both sides had bumped rust-dashcore past the PlatformNodeId newtype (#885): v4.1-dev's #4120 pinned 38c689d9 (a descendant of this branch's ac8828c9, adding #889 canonical byte order + #891 tx-record positions), so the dependency block resolves to v4.1-dev's newer rev — git's auto-merge had duplicated the whole [workspace.dependencies] table, so it was reconciled by hand back to a single block. peers.rs and core_wallet_types.rs take v4.1-dev's variants of the same PlatformNodeId adaptations both sides had made independently. persistence.rs: #4120 extracted the load-side pool-restore loop into restore_core_address_pools() with funds + provider-key routing, but the extraction was based on v4.1-dev's copy and so dropped this branch's asset-lock funding-account arms (identity registration / top-up / invitation / address top-up) — the voucher-key-reuse restore fix. The merge takes the helper and re-adds those six arms (with the security rationale comment); the FFIPersister durability attestation from the round-5 fix is preserved. Post-merge: workspace check + clippy --all-features + fmt clean; platform-wallet 460 and platform-wallet-ffi 169 tests green (both suites now include the base's new provider-key restore tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nding-index persistence; claim-sheet races; nil inviterId
1. Concurrent invitation builds could roll the durable funding-index
snapshot backwards: the pool snapshot reads live wallet state at
persist time, so build A's snapshot (collected before build B marked
its index) could be persisted AFTER B's — after a restart the next
invitation re-selects B's index and re-exports the same bearer voucher
key. This was round-1's S2, dropped on a "UI serializes it" rationale
the reviewer correctly rebutted (a dismissed sheet's unstructured task
keeps running). Fix is the reserved design: a dedicated per-wallet
AssetLockManager mutex held across build → pool persist/flush (never
re-entering wallet_manager's lock, dropped before broadcast). Pinned
by concurrent_invitation_builds_cannot_roll_back_the_used_index_snapshot
— red on the unfixed code ("invitation pool snapshot rolled back: 1
used after 2", verified) — using a barrier-gated persistence stub and
a new two-UTXO fixture variant.
2. InvitationPreview fabricated a 32-byte zero inviterId whenever inviter
metadata existed, contradicting the documented always-nil contract and
inviting consumers to skip the required DPNS resolution. Now
unconditionally nil.
3. An in-flight claim could be dismissed (Cancel stayed enabled, swipe
dismissal allowed) and overlapped (a second invite link re-presented
the sheet mid-claim), letting two claims race the same unused identity
index. Cancel and interactive dismissal are now gated on isClaiming
(mirroring ReclaimInvitationSheet), and a link arriving mid-claim is
DEFERRED in pendingInviteURL (published invitationClaimInFlight flag)
and consumed when the claim reaches a terminal state.
platform-wallet 461 tests green; clippy --all-features + fmt clean;
xcframework + SwiftExampleApp rebuilt green after the merge's FFI header
refresh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
Exact-head verification confirms five in-scope blockers remain: SQLite cannot pass the durability gate, invitation vouchers enter generic registration resume, FFI persistence rounds can interleave, and two reclaim-flow races remain. The fixes for the funding-index rollback, nil inviter ID, and overlapping claim flow are correct. Three additional in-scope suggestions cover bearer-key lifetime, pasteboard expiration, and deterministic concurrency-test synchronization.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— security-auditor (completed),gpt-5.6-sol— rust-quality (completed),gpt-5.6-sol— ffi-engineer (completed),gpt-5.6-sol— ffi-engineer (failed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 5 blocking | 🟡 3 suggestion(s)
3 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-storage/src/sqlite/persister.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:818-848: SQLite never opts into the durability capability
`create_invitation` rejects backends whose `PlatformWalletPersistence::persists_durably()` returns false. The trait deliberately defaults to false, but this transactional SQLite implementation does not override it, so every SQLite-backed invitation fails before funding. The trait documentation explicitly identifies SQLite as a backend that should attest durability. Override the capability and add a SQLite-backed test for the invitation gate.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift:432-442: Invitation vouchers appear as resumable identity registrations
`AssetLockResumeRow` omits `fundingTypeRaw`, and this filter checks only lifecycle status and `(walletId, identityIndex)` occupancy. An `IdentityInvitation` lock therefore appears under Resumable Registrations when its nominal slot 0 is unused. The generic Swift and FFI resume APIs pass only the outpoint; Rust then resolves the tracked invitation lock and re-derives its voucher key. Selecting the row consumes the bearer voucher into an unrelated local identity and invalidates the shared invitation. Carry the funding discriminator through this row contract, exclude type 3 here, and prevent generic resume from accepting invitation locks while preserving the explicitly authorized reclaim path.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:659-684: FFI durability attestation permits interleaved persistence rounds
`persists_durably()` attests from callback presence, but `FFIPersister::store` has no lock spanning its begin, per-kind callbacks, and end callback. Swift serializes each callback separately while all rounds share one `ModelContext` and `inChangeset` flag. Two stores can interleave so round B stages its funding-pool or invitation update, round A fails and rolls back the shared context, and round B subsequently saves an empty context and reports success. The invitation flow can then broadcast despite losing the durable used index. Serialize each complete callback round in Rust or give each round independently scoped host transaction state.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift:336-354: The reclaim marker can misattribute a foreign claim as self-reclaim
`reclaimInFlight` proves only that a local attempt saved the marker before calling the asynchronous consume operation. It is not tied to a transition, target identity, or confirmed submission. If the app stops after saving the marker, or receives an ambiguous transport failure, the invitee can claim the voucher before the retry. The retry then maps Platform's already-consumed rejection to `.reclaimed` solely because the stale marker is true. The local not-tracked branch has the same ambiguity. Recovery needs verifiable evidence tied to this wallet's submitted transition and target; without it, the outcome must remain claimed or explicitly ambiguous.
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift:97: Reclaim remains interactively dismissible during voucher consumption
The toolbar disables Cancel while reclaiming, but swipe dismissal remains enabled. The unstructured task continues after dismissal, while reopening the Created invitation creates fresh `isReclaiming` state and permits another consume attempt, potentially against a different target. Both tasks then mutate the same marker and invitation row after one irreversible voucher consumption. Disable interactive dismissal until the operation reaches a terminal state.
In `packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs:438-444: Parsed WIF leaves its source scalar copy unwiped
`dashcore::PrivateKey` and its inner `SecretKey` are `Copy`. Copying `private_key.inner` into `voucher_key` leaves another bearer-scalar copy in the decoded source binding, while the uncompressed-WIF rejection returns before the `ParsedInvitation` wiping guard exists. Explicitly erase the source scalar on both the rejection and accepted-copy paths to match the parser's stated bearer-key wiping policy.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift:242-248: Copied bearer invitations remain on the pasteboard indefinitely
The invitation contains a non-expiring WIF controlling up to 0.05 DASH. `.localOnly` prevents Universal Clipboard synchronization but leaves the value on the device-wide general pasteboard until it is overwritten. Another application later granted pasteboard access can read the link and front-run its intended recipient. Add a short expiration, consistent with the repository's existing handling of copied WIF values.
In `packages/rs-platform-wallet/src/wallet/asset_lock/build.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:1079-1085: Concurrency regression test depends on a scheduling delay
The test releases the first persistence call after a fixed 300 ms sleep, assuming the second Tokio task has already reached and persisted its snapshot on the unfixed implementation. A sufficiently delayed second task instead runs after the release, produces monotonic snapshots, and lets the old race pass the regression test. Replace the timing assumption with explicit synchronization proving the second build reached the relevant stage before releasing the first.
…me gate; SQLite durability attest; FFI round serialization; reclaim ambiguity Round-7 review fixes (5 blocking + 3 suggestions): - SQLite persister overrides persists_durably() (true in both flush modes — flush writes through in one transaction) + trait-object gate test, so SQLite-backed wallets pass the invitation durability gate. - Invitation vouchers excluded from generic resume in all layers: AssetLockResumeRow carries fundingTypeRaw and the resumable- registrations anti-join drops type 3; AssetLockFunding:: FromExistingAssetLock gained consume_invitation_voucher and the funding resolver refuses IdentityInvitation locks without it (Rust back-stop for every generic resume/top-up/address/shielded path); the two identity FFI entry points + Swift wrappers thread the flag, default false, with only ReclaimInvitationSheet passing true. - FFIPersister::store() serializes complete callback rounds behind a store_round mutex (begin → per-kind → end is exclusive), so two rounds can no longer interleave against the host's shared ModelContext/inChangeset transaction state; overlap-latch test added. - Reclaim classifier no longer infers Reclaimed from the reclaimInFlight marker: already-consumed with the marker set resolves to an explicitly ambiguous terminal Claimed, and a marker-set local "is not tracked" failure surfaces an explicit ambiguity error with no status flip. Reclaimed is written only by an observed success. Spec §0/§0B + QA004 updated; classifier tests re-pinned. - ReclaimInvitationSheet gains interactiveDismissDisabled(isReclaiming). - Invitation WIF parse erases the decoded source scalar on both the rejection and accepted-copy paths. - Copied invite links expire off the pasteboard after 60s (matches the app's existing copied-WIF handling). - The concurrent-builds regression test replaces its 300ms sleep with state-based synchronization: the second build is spawned only after the first is parked inside its persist, and the release waits for either the second pool store (regression manifests) or the second build provably queued at the build_persist_serial gate (test-only RAII occupancy gauge). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-7 review fixes — ff67585All 8 findings from the latest review addressed (the five inline threads have per-thread replies; the three findings below had no inline thread, so they're logged here): 🔴 SQLite never opts into the durability capability ( 🔴 Invitation vouchers appear as resumable identity registrations (
🔴 FFI durability attestation permits interleaved persistence rounds ( Verification: 🤖 Generated with Claude Code |
…apshot field ff67585 added `keyType` to `CoreAddressEntrySnapshot` but missed the test helper's initializer call, breaking the warnings-as-errors Swift CI job; also mark the intentionally-ignored persistInvitations result at the removal-path assertion so the unused-result warning can't fail the build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y-invitations # Conflicts: # packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs # packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift
…into feat/dip15-dashpay-invitations
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
All eight prior findings are fixed at the exact head, but five new in-scope blockers remain in invitation recovery durability, reclaim-state persistence, fresh-install deep-link handling, wallet deletion, and C ABI compatibility. Two additional suggestions address nondeterministic or unbounded concurrency-test synchronization.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— security-auditor (completed),gpt-5.6-sol— rust-quality (completed),gpt-5.6-sol— ffi-engineer (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 5 blocking | 🟡 2 suggestion(s)
2 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/src/wallet/asset_lock/build.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:500-530: Ambiguous broadcasts can lose the only durable voucher recovery record
Invitation funding flushes the used address index, but then only queues the full `Built` asset-lock row through `queue_asset_lock_changeset`, which suppresses store errors, and does not flush it before broadcasting. In SQLite Manual mode that row remains buffered. Every DAPI failure is classified as `MaybeSent`, so an endpoint can accept the transaction and drop its response; this method returns before `create_invitation` persists its invitation row, and a restart discards the buffered transaction/outpoint metadata. The funded voucher then has no supported recovery path. A successful broadcast can produce a similar inconsistency when the Built and Broadcast callback rounds fail but the later invitation round succeeds. Store and flush the Built row before an invitation broadcast reaches the network, propagate persistence failures, and durably remove it before releasing inputs after a definitive rejection.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:1101-1145: State-based concurrency synchronization can hang indefinitely
Both polling loops and the subsequent notification wait are unbounded. If task A fails before setting `first_parked`, or task B exits before reaching either observed counter, the test hangs instead of reporting the underlying failure. Wrap these waits in `tokio::time::timeout` and inspect the spawned task results, or use channel synchronization whose sender closure becomes an explicit test failure.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift:266-296: Terminal reclaim outcomes are dismissed without durable state
After an irreversible voucher consume succeeds, the code marks the invitation Reclaimed and clears `reclaimInFlight`, but suppresses any SwiftData save failure and dismisses unconditionally. A failed save leaves durable state as Created with the required pre-consume marker still set, so restart recovery can misclassify or obscure a self-reclaim whose success was directly observed. The Claimed and consumed-ambiguous terminal branches suppress their saves similarly. Require terminal-state persistence to succeed, and surface a recoverable persistence error instead of dismissing when it does not.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift:149-157: Fresh-install invite links are discarded before wallet creation
`consumePendingInviteURL` clears the bearer URL before checking whether `claimWalletId` exists. Opening an invitation on a fresh installation therefore destroys the only copy before the user creates or loads a wallet. The view observes pending-URL, claim-in-flight, and appearance events, but it does not retry when the wallet collection becomes available. Preserve the URL until a wallet exists and trigger consumption when wallet availability changes.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3987-3992: Wallet deletion leaves invitation records behind
`deleteWalletData` explicitly purges standalone wallet-scoped records, including `PersistentAssetLock`, but never deletes `PersistentInvitation`. That model stores only a raw `walletId` and has no relationship to `PersistentWallet`, so deleting the wallet row cannot cascade to it. A full wallet wipe therefore retains private invitation history, and reimporting the deterministic wallet ID resurrects stale Created invitations after their associated asset-lock rows were deleted. Delete matching invitation rows in the same transaction and cover them in the wallet-deletion tests.
In `packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs:170-180: Voucher authorization changes existing C ABI symbols in place
The PR inserts `consume_invitation_voucher: bool` before the output parameters of two existing `#[no_mangle]` functions without changing their exported names. The resume symbol had the old signature at the PR merge base, and the top-up symbol had it at updated-target commit `97477d1c88`. A caller compiled against either old header passes an output pointer where Rust now expects a bool, shifts the remaining output pointers, and can trigger invalid-bool undefined behavior and writes through unspecified addresses. The in-tree Swift rebuild does not preserve the public C ABI. Keep the existing symbols as default-deny compatibility shims and add new authorized or versioned entry points for invitation reclaim. The public `AssetLockFunding::FromExistingAssetLock` variant also gained a required field and should receive a source-compatible authorization path.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:5338-5343: Persistence serialization test still relies on a scheduling window
The regression test detects a missing round mutex only if the second worker enters `probing_begin` during this fixed 100 ms sleep. On a loaded runner the second thread can start after the first callback completes, leaving `overlap` false even in the broken implementation. Replace the timing window with synchronization that proves the first callback remains blocked until the second worker has attempted to enter the round.
| // SwiftData is the UI source: flip the local row to Reclaimed. | ||
| invitation.statusRaw = 2 | ||
| invitation.reclaimInFlight = false | ||
| invitation.updatedAt = Date() | ||
| try? modelContext.save() | ||
| dismiss() | ||
| } catch { | ||
| switch Self.classifyReclaimFailure( | ||
| error: error, | ||
| hadPriorReclaimInFlight: hadPriorReclaimInFlight | ||
| ) { | ||
| case .claimed: | ||
| // Someone else claimed the voucher first. Reflect the terminal | ||
| // state with a neutral message (the claimant is intentionally | ||
| // not named). | ||
| invitation.statusRaw = 1 | ||
| invitation.reclaimInFlight = false | ||
| invitation.updatedAt = Date() | ||
| try? modelContext.save() | ||
| infoMessage = "This invitation was already claimed." | ||
| case .consumedAmbiguous: | ||
| // The voucher is provably consumed (Platform's deterministic | ||
| // rejection), but with our own earlier attempt in flight the | ||
| // consumer could be EITHER that attempt or a racing claim — | ||
| // the marker is not evidence tied to a submitted transition, | ||
| // so never upgrade to Reclaimed. Terminal-Claimed is the | ||
| // conservative resolution; the message states the ambiguity. | ||
| invitation.statusRaw = 1 | ||
| invitation.reclaimInFlight = false | ||
| invitation.updatedAt = Date() | ||
| try? modelContext.save() |
There was a problem hiding this comment.
🔴 Blocking: Terminal reclaim outcomes are dismissed without durable state
After an irreversible voucher consume succeeds, the code marks the invitation Reclaimed and clears reclaimInFlight, but suppresses any SwiftData save failure and dismisses unconditionally. A failed save leaves durable state as Created with the required pre-consume marker still set, so restart recovery can misclassify or obscure a self-reclaim whose success was directly observed. The Claimed and consumed-ambiguous terminal branches suppress their saves similarly. Require terminal-state persistence to succeed, and surface a recoverable persistence error instead of dismissing when it does not.
source: ['codex']
| guard let urlString = appUIState.pendingInviteURL else { return } | ||
| // Clear the bearer URL immediately so it can't linger in @Published; the | ||
| // nil-write re-fires this via .onChange, where the guard above no-ops. | ||
| appUIState.pendingInviteURL = nil | ||
| guard let walletId = claimWalletId else { return } | ||
| // A fresh ClaimInvite (new id) presents the sheet — and RE-presents it | ||
| // if one is already open IDLE, so a second invite link re-seeds it with | ||
| // the new URI instead of being dropped (mid-claim it defers above). | ||
| claimInvite = ClaimInvite(walletId: walletId, initialURI: urlString) |
There was a problem hiding this comment.
🔴 Blocking: Fresh-install invite links are discarded before wallet creation
consumePendingInviteURL clears the bearer URL before checking whether claimWalletId exists. Opening an invitation on a fresh installation therefore destroys the only copy before the user creates or loads a wallet. The view observes pending-URL, claim-in-flight, and appearance events, but it does not retry when the wallet collection becomes available. Preserve the URL until a wallet exists and trigger consumption when wallet availability changes.
source: ['codex']
| identity_pubkeys_count: usize, | ||
| signer_handle: *mut SignerHandle, | ||
| core_signer_handle: *mut MnemonicResolverHandle, | ||
| consume_invitation_voucher: bool, | ||
| out_identity_id: *mut [u8; 32], | ||
| out_identity_handle: *mut Handle, |
There was a problem hiding this comment.
🔴 Blocking: Voucher authorization changes existing C ABI symbols in place
The PR inserts consume_invitation_voucher: bool before the output parameters of two existing #[no_mangle] functions without changing their exported names. The resume symbol had the old signature at the PR merge base, and the top-up symbol had it at updated-target commit 97477d1c88. A caller compiled against either old header passes an output pointer where Rust now expects a bool, shifts the remaining output pointers, and can trigger invalid-bool undefined behavior and writes through unspecified addresses. The in-tree Swift rebuild does not preserve the public C ABI. Keep the existing symbols as default-deny compatibility shims and add new authorized or versioned entry points for invitation reclaim. The public AssetLockFunding::FromExistingAssetLock variant also gained a required field and should receive a source-compatible authorization path.
source: ['codex']
| // Hold the round open long enough that an unserialized second | ||
| // store would enter its own begin inside this bracket. The sleep | ||
| // only widens the detection window on a broken implementation — | ||
| // on the serialized one, overlap is structurally impossible and | ||
| // the invariant assert below can never flake. | ||
| std::thread::sleep(std::time::Duration::from_millis(100)); |
There was a problem hiding this comment.
🟡 Suggestion: Persistence serialization test still relies on a scheduling window
The regression test detects a missing round mutex only if the second worker enters probing_begin during this fixed 100 ms sleep. On a loaded runner the second thread can start after the first callback completes, leaving overlap false even in the broken implementation. Replace the timing window with synchronization that proves the first callback remains blocked until the second worker has attempted to enter the round.
source: ['codex']
| while !persistence | ||
| .first_parked | ||
| .load(std::sync::atomic::Ordering::SeqCst) | ||
| { | ||
| tokio::time::sleep(std::time::Duration::from_millis(5)).await; | ||
| } | ||
|
|
||
| let manager_b = Arc::clone(&manager); | ||
| let b = tokio::spawn(async move { | ||
| manager_b | ||
| .broadcast_funded_asset_lock( | ||
| 1_000_000, | ||
| 0, | ||
| AssetLockFundingType::IdentityInvitation, | ||
| 0, | ||
| &signer, | ||
| ) | ||
| .await | ||
| }); | ||
|
|
||
| // Release A only after B has provably reached the relevant stage — | ||
| // no scheduling assumption. Exactly one of two states must occur: | ||
| // - `pool_stores_seen >= 2`: B built and persisted its own (fuller) | ||
| // snapshot while A was parked — the regression manifested (an | ||
| // unserialized implementation always reaches this state, however | ||
| // slowly, so the rollback assertion below fires deterministically); | ||
| // - `build_serial_gate >= 2`: B is queued at the build→persist | ||
| // serialization gate while A still holds it, so B cannot have | ||
| // collected a snapshot yet — the fixed behavior, verified | ||
| // positively rather than by the absence of a store within a delay. | ||
| loop { | ||
| let regressed = persistence | ||
| .pool_stores_seen | ||
| .load(std::sync::atomic::Ordering::SeqCst) | ||
| >= 2; | ||
| let serialized = manager | ||
| .build_serial_gate | ||
| .load(std::sync::atomic::Ordering::SeqCst) | ||
| >= 2; | ||
| if regressed || serialized { | ||
| break; | ||
| } | ||
| tokio::time::sleep(std::time::Duration::from_millis(5)).await; | ||
| } | ||
| persistence.first_pool_store.wait(); |
There was a problem hiding this comment.
🟡 Suggestion: State-based concurrency synchronization can hang indefinitely
Both polling loops and the subsequent notification wait are unbounded. If task A fails before setting first_parked, or task B exits before reaching either observed counter, the test hangs instead of reporting the underlying failure. Wrap these waits in tokio::time::timeout and inspect the spawned task results, or use channel synchronization whose sender closure becomes an explicit test failure.
source: ['codex']
Issue being fixed or feature implemented
DashPay invitations (DIP-13 sub-feature
3') — onboard a friend who has no Dash. An existing user pre-funds a one-time asset-lock voucher and shares adashpay://invitelink; the invitee registers their own new identity funded by that voucher (no L1 Dash required) and optionally becomes a contact. The inviter can later reclaim an unclaimed voucher as identity credits. Tracked as the "NEXT" item in the DashPay backlog (#4020);SPEC.mdMilestone 5 asked for this design pass.Design + reviews + owner syncs are captured in the single canonical spec
docs/dashpay/DIP15_INVITATIONS_SPEC.md: §0 records the as-built deltas, §0A the legacy link-format/interop contract, §0B the persistence bridge + reclaim semantics; the per-finding review-fix log lives in this PR's review threads and commit messages.What was done?
Link format — legacy-compatible (cross-claimable with dash-wallet iOS/Android):
dashpay://invite?du=<username>&assetlocktx=<txid>&pk=<WIF>&islock=<hex|null>[&display-name][&avatar-url](also parseshttps://invitations.dashpay.io/applink?…). Emit strict / parse lenient. The link carries the funding txid, not an embedded proof — claim is claim-by-fetch: refetch the tx by id (bounded retry for DAPI propagation lag, both byte orders), reconstruct the InstantSend proof (from the link'sislock) or a ChainLock proof (islockabsent/"null"), and select the voucher's credit output by script match. No expiry and no inviter identity id on the wire (the id resolves from theduusername via DPNS at claim).Rust core (
rs-platform-wallet/rs-sdk):crypto/invitation.rs) +create_invitation/claim_invitation(network/invitation.rs). Amount bounds enforced at create:MIN_INVITATION_DUFFS = 300_000(0.003 DASH — a smaller voucher can fund neither a claim nor a reclaim; found by the funded e2e) andMAX_INVITATION_DUFFS = 5_000_000(0.05 DASH).MnemonicResolverCoreSigner::export_invitation_private_key) — the seedless raw-key export, gated to the exact9'/coin'/5'/3'/idx'path (negative gate test).create_invitationrefuses non-durable backends via the new defaultedPlatformWalletPersistence::persists_durably(); the funded-asset-lock flow is split (broadcast_funded_asset_lock+wait_for_funded_asset_lock_proof, composed API unchanged) so the invitation record is persisted immediately after broadcast, before the proof wait — an interrupted create can no longer orphan a funded voucher.Sdk::get_transaction(fetch + decode a Core tx by id,Optionreturn distinguishing genuine-miss from transient) powering claim-by-fetch.Persistence:
InvitationChangeSet→ each backend (SQLiteV003__invitations; on iOS theon_persist_invitations_fnFFI bridge → SwiftDataPersistentInvitation, the UI source). Persist failures are signaled end-to-end (nonzero callback fails the round andcreate_invitationerrors) — never silently dropped.Reclaim: an unclaimed voucher is recovered as identity credits (the L1 amount was OP_RETURN-burned) — top-up an existing identity or register a new one (
ReclaimInvitationSheet, swipe on aCreatedrow). Already-consumed handling is classified via the persistedreclaimInFlightmarker — saved (required, before the consume may run) only immediately before the on-chain consume — disambiguating crash-interrupted self-reclaim (→ Reclaimed) from a foreign claim (→ neutral "This invitation was already claimed.", claimant not named) via the pure, unit-testedclassifyReclaimFailureseam.FFI + Swift + UI:
platform_wallet_create_invitation/_claim_invitation/_parse_invitation(read-only preview);ManagedPlatformWalletwrappers;CreateInvitationSheet(default 0.03 DASH, floor/cap hints),ClaimInvitationSheet(+ "establish contact?" prompt),InvitationsView(all-wallet list filtered to wallets loaded in the active manager),dashpay://deep link.Key decisions (owner-synced): InstantSend proof at create (ChainLock-funded vouchers are still recorded + reclaimable, just not link-emittable) · contact-bootstrap opt-in on both ends · proper wallet-persister persistence · legacy query format for cross-wallet claimability.
How Has This Been Tested?
cargo clippy --workspace --all-features+fmt --check --allclean.InvitationPersistenceTests+ReclaimInvitationClassifierTests(10/10) green.GEw4gdo3…(DPNSinvitere2e070801.dash) → voucher link → invitee identityC9tc8ef7…registered from the imported voucher (totalTopUpsAmount= the voucher,totalDocuments = 1contact-request bootstrap), verified via testnet.platform-explorer. Re-verified after the legacy-codec rework: claim-by-fetch registered identity idx 3 with +2.82B credits from a 0.03 voucher.IdentityAssetLockTransactionOutPointAlreadyConsumedErroras the neutral "This invitation was already claimed." → row → Claimed.reclaimInFlightmarker observed pre-consume, reclaim→register, and both crash-recovery classifier arms (marker set → instant Reclaimed recovery; marker unset → error, row stays Created by design).TEST_PLAN.md§4.10 rows DP-12..DP-19 + AI-QA playbookQA004_invitation_reclaim.md.Breaking Changes
None. Reuses existing on-chain artifacts (AssetLock, IdentityCreate, contactRequest). New trait surface is additive and source-compatible:
ContactCryptoProvider::export_invitation_private_keycarries a default "unsupported" implementation (existing providers keep compiling; only invitation-capable ones override), andPlatformWalletPersistence::persists_durably()is a new defaulted method. Note its default is fail-closed (false): backends must explicitly attest durability to use the new invitation-creation flow — existing flows are unaffected, and a backend that hasn't opted in gets a clear "requires durable persistence" error from the new feature rather than a silent bearer-key-reuse hazard.Checklist:
Follow-ups (out of scope, tracked)
isAlreadyConsumedstring match; precedent fix(platform-wallet): deliver typed AddressNonceMismatch error across wallet, FFI, and Swift host #4047).Info.plist+ spec §6.1; hosting is externally blocked (Invitation links: migrate DIP-13 invitations from custom URL scheme to Universal/App Links on invitations.dashpay.io (post-Firebase) #4096).makeDashpayKeyPairunification between claim + create-identity flows.walletManager.walletswithout an app restart (example-app runtime issue observed during e2e prep; orthogonal to invitations).🤖 Generated with Claude Code
https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn
Summary by CodeRabbit
dashpay://invitedeep-link handling and a “Sent invitations” list powered by new invitation persistence (plus persistence test coverage).