Skip to content

feat(platform-wallet): dip-13 dashpay invitations#4041

Merged
QuantumExplorer merged 80 commits into
v4.1-devfrom
feat/dip15-dashpay-invitations
Jul 14, 2026
Merged

feat(platform-wallet): dip-13 dashpay invitations#4041
QuantumExplorer merged 80 commits into
v4.1-devfrom
feat/dip15-dashpay-invitations

Conversation

@shumkov

@shumkov shumkov commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

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 a dashpay://invite link; 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.md Milestone 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 parses https://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's islock) or a ChainLock proof (islock absent/"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 the du username via DPNS at claim).

Rust core (rs-platform-wallet / rs-sdk):

  • Legacy link codec (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) and MAX_INVITATION_DUFFS = 5_000_000 (0.05 DASH).
  • Path-gated voucher-key export (MnemonicResolverCoreSigner::export_invitation_private_key) — the seedless raw-key export, gated to the exact 9'/coin'/5'/3'/idx' path (negative gate test).
  • Durability hardening (review rounds 1–3): the pre-broadcast gate persists and flushes the invitation funding-index pool (aborting before broadcast on failure — voucher-key reuse across restarts is the attack this kills); create_invitation refuses non-durable backends via the new defaulted PlatformWalletPersistence::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.
  • New Sdk::get_transaction (fetch + decode a Core tx by id, Option return distinguishing genuine-miss from transient) powering claim-by-fetch.

Persistence: InvitationChangeSet → each backend (SQLite V003__invitations; on iOS the on_persist_invitations_fn FFI bridge → SwiftData PersistentInvitation, the UI source). Persist failures are signaled end-to-end (nonzero callback fails the round and create_invitation errors) — 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 a Created row). Already-consumed handling is classified via the persisted reclaimInFlight marker — 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-tested classifyReclaimFailure seam.

FFI + Swift + UI: platform_wallet_create_invitation / _claim_invitation / _parse_invitation (read-only preview); ManagedPlatformWallet wrappers; 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?

  • Rust: 452 platform-wallet + 162 platform-wallet-ffi tests green (codec round-trip + malformed rejections, export path-gate negatives, claim proof-assembly guards, durability-gate tests that are red on the pre-fix code, reclaim classifier tests); cargo clippy --workspace --all-features + fmt --check --all clean.
  • Swift: SwiftExampleApp xcodebuild green (iPhone 17 / arm64); InvitationPersistenceTests + ReclaimInvitationClassifierTests (10/10) green.
  • Funded testnet e2e, on-chain verified:
    • DP-14 create→claim round-trip: inviter GEw4gdo3… (DPNS invitere2e070801.dash) → voucher link → invitee identity C9tc8ef7… registered from the imported voucher (totalTopUpsAmount = the voucher, totalDocuments = 1 contact-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.
    • DP-17 reclaim→top-up: target identity +2.99B credits, row → Reclaimed.
    • DP-18 reclaim→register: brand-new identity funded by the voucher, row → Reclaimed.
    • DP-19 claim-vs-reclaim race (two simulators): the invitee's claim won; the inviter's reclaim surfaced the real Platform IdentityAssetLockTransactionOutPointAlreadyConsumedError as the neutral "This invitation was already claimed." → row → Claimed.
    • QA004 full re-run on the final build (2026-07-14): min-amount guard (0.001 → submit disabled), create @0.03 (the invitation row lands in SwiftData seconds after broadcast — before the proof wait, evidencing the ordering fix), reclaim→top-up with the durable reclaimInFlight marker observed pre-consume, reclaim→register, and both crash-recovery classifier arms (marker set → instant Reclaimed recovery; marker unset → error, row stays Created by design).
  • QA contract: TEST_PLAN.md §4.10 rows DP-12..DP-19 + AI-QA playbook QA004_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_key carries a default "unsupported" implementation (existing providers keep compiling; only invitation-capable ones override), and PlatformWalletPersistence::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:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes (n/a — no breaking changes)
  • I have made corresponding changes to the documentation if needed

Follow-ups (out of scope, tracked)

🤖 Generated with Claude Code

https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn

Summary by CodeRabbit

  • New Features
    • Added DashPay (DIP-13) invitation link create, preview, and claim flows across the Rust wallet/FFI, Swift SDK, and example app, including QR/copy/share and optional inviter follow-up.
    • Added dashpay://invite deep-link handling and a “Sent invitations” list powered by new invitation persistence (plus persistence test coverage).
  • Bug Fixes
    • Strengthened invite parsing/validation and claim readiness checks (e.g., InstantSend-only, expiry/format, and clearer mismatch failures).
  • Documentation / Tests
    • Added the DIP-13 invitation spec and expanded invitation lifecycle documentation and test plans.

shumkov and others added 4 commits July 8, 2026 16:42
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>
@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 8, 2026
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This 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.

Changes

DashPay Invitations (DIP-13)

Layer / File(s) Summary
Invitation specification and verification plan
docs/dashpay/*
Documents invitation roles, security decisions, envelope format, persistence architecture, reclaim behavior, failure modes, and verification tasks.
Invitation URI codec and validation
packages/rs-platform-wallet/src/wallet/identity/crypto/*
Adds bounded encoding and parsing for dashpay://invite payloads and fail-fast claim validation.
Voucher-key export gating
packages/rs-sdk-ffi/..., packages/rs-platform-wallet/..., packages/rs-platform-wallet-ffi/...
Adds invitation-path-only private-key export with provider wiring and negative-path tests.
Wallet flows and persistence
packages/rs-platform-wallet/..., packages/rs-platform-wallet-storage/...
Implements invitation creation and claiming, lifecycle changesets, SQLite storage, funding-index durability, and persistence restoration.
Rust FFI and persistence bridge
packages/rs-platform-wallet-ffi/...
Exposes invitation create, claim, preview, persistence encoding, callback delivery, and identity top-up APIs.
Swift SDK persistence
packages/swift-sdk/Sources/SwiftDashSDK/Persistence/..., packages/swift-sdk/SwiftTests/...
Adds PersistentInvitation, callback ingestion, SwiftData upserts/removals, and round-trip persistence tests.
Swift SDK and ExampleApp
packages/swift-sdk/Sources/..., packages/swift-sdk/SwiftExampleApp/...
Adds SDK wrappers, identity key derivation, create/claim sheets, deep-link routing, invitation listing, storage views, toolbar/profile entry points, and DP-12–DP-16 QA cases.

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
Loading

Possibly related issues

Possibly related PRs

  • dashpay/platform#3985: Touches the shared asset-lock funding and broadcast path used by invitation creation.

Suggested reviewers: QuantumExplorer, llbartekll, ZocoLini, thepastaclaw

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: DashPay DIP-13 invitations in platform-wallet.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dip15-dashpay-invitations

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

shumkov and others added 2 commits July 8, 2026 17:36
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>
@shumkov shumkov changed the title feat(dashpay): DIP-13 invitations (create + claim) feat(platform-wallet): dip-13 dashpay invitations (create + claim) Jul 8, 2026
shumkov and others added 8 commits July 8, 2026 17:46
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
@shumkov shumkov marked this pull request as ready for review July 8, 2026 13:14
@thepastaclaw

thepastaclaw commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit e1b7449)
Canonical validated blockers: 5

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.33700% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.43%. Comparing base (97477d1) to head (e1b7449).

Files with missing lines Patch % Lines
...rm-wallet-storage/src/sqlite/schema/invitations.rs 93.28% 9 Missing ⚠️
...rs-platform-wallet-storage/src/sqlite/persister.rs 83.33% 1 Missing ⚠️
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     
Components Coverage Δ
dpp 88.44% <ø> (ø)
drive 86.14% <ø> (ø)
drive-abci 89.51% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.88% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.55% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread packages/rs-platform-wallet-ffi/src/invitation.rs
Comment thread packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs
…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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

♻️ Duplicate comments (1)
packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs (1)

268-294: 🔒 Security & Privacy | 🟠 Major

Voucher PrivateKey copy is not scrubbed in the claim path.

This was previously flagged: ParsedInvitation wipes voucher_key on drop, but PrivateKey::new at line 271 copies the scalar into a Copy + Clone wrapper with no Drop implementation. 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 suggested WipingPrivateKey wrapper 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

📥 Commits

Reviewing files that changed from the base of the PR and between 6b6c34a and 01cd566.

📒 Files selected for processing (28)
  • docs/dashpay/DIP15_INVITATIONS_SPEC.md
  • packages/rs-platform-wallet-ffi/src/dashpay.rs
  • packages/rs-platform-wallet-ffi/src/invitation.rs
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet-storage/migrations/V003__invitations.rs
  • packages/rs-platform-wallet-storage/src/sqlite/persister.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs
  • packages/rs-platform-wallet/src/changeset/changeset.rs
  • packages/rs-platform-wallet/src/changeset/mod.rs
  • packages/rs-platform-wallet/src/wallet/apply.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
  • packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift
  • packages/swift-sdk/SwiftExampleApp/Info.plist
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationKeys.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift
  • packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md

Comment thread docs/dashpay/DIP15_INVITATIONS_SPEC.md Outdated
Comment thread docs/dashpay/DIP15_INVITATIONS_SPEC.md Outdated
Comment thread packages/rs-platform-wallet-ffi/src/invitation.rs
Comment thread packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs Outdated
Comment thread packages/swift-sdk/SwiftExampleApp/Info.plist
shumkov and others added 3 commits July 8, 2026 21:01
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
shumkov and others added 4 commits July 14, 2026 10:53
…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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread packages/rs-platform-wallet/src/wallet/asset_lock/build.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs Outdated
Comment thread packages/rs-platform-wallet-ffi/src/invitation.rs Outdated
shumkov and others added 7 commits July 14, 2026 13:51
…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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs Outdated
shumkov and others added 2 commits July 14, 2026 17:05
…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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread packages/rs-platform-wallet/src/changeset/traits.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs Outdated
…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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread packages/rs-platform-wallet/src/wallet/asset_lock/build.rs
shumkov and others added 2 commits July 14, 2026 20:34
…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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/asset_lock/build.rs Outdated
…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>
@QuantumExplorer

Copy link
Copy Markdown
Member

Round-7 review fixes — ff67585

All 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 (rs-platform-wallet-storage/src/sqlite/persister.rs) — SqlitePersister now overrides persists_durably() -> true. Correct in both flush modes, since the trait contract is "state survives a restart once store + flush return Ok" and flush always writes through in one SQLite transaction (Immediate is durable at store, Manual at the explicit flush the invitation gate already drives before anything irreversible). Added sqlite_attests_durability_for_invitation_gate (sqlite_trait_dispatch.rs): both modes pass the gate through the trait object exactly as create_invitation reads it, and a trait-default stub stays fail-closed.

🔴 Invitation vouchers appear as resumable identity registrations (IdentitiesContentView.swift) — fixed in all three layers:

  • Row contract: AssetLockResumeRow now carries fundingTypeRaw; crossWalletResumableLocks excludes type 3 (IdentityInvitation), so an unclaimed voucher (nominal slot 0, actionable status) never renders as resumable. Unit tests added for both the exclusion and that funding types 0–2 keep surfacing.
  • Rust hard back-stop: AssetLockFunding::FromExistingAssetLock gained consume_invitation_voucher: bool; the funding resolver refuses IdentityInvitation-typed locks unless it is set, so no generic Swift/FFI resume, top-up, platform-address, or shielded funding path can consume a voucher even if a UI regression re-surfaces one (generic_resume_refuses_invitation_voucher_locks pins the refusal AND that the authorized variant passes the gate).
  • Authorized reclaim preserved: the two identity FFI entry points gained a consume_invitation_voucher parameter; the Swift wrappers default it to false, and only ReclaimInvitationSheet passes true.

🔴 FFI durability attestation permits interleaved persistence rounds (rs-platform-wallet-ffi/src/persistence.rs) — FFIPersister::store() now holds a store_round mutex across the entire begin → per-kind → end bracket, so complete callback rounds are serialized in Rust and the host's shared transaction state (one ModelContext + inChangeset flag) can never observe two interleaved rounds. concurrent_stores_never_interleave_rounds latches an overlap flag if a second round ever enters while one is open.

Verification: cargo fmt + clippy --all-targets --all-features clean on the three touched crates; full test suites green (platform-wallet 462, platform-wallet-ffi 170, platform-wallet-storage incl. the new gate test); build_ios.sh --target sim + SwiftExampleApp xcodebuild green; ReclaimInvitationClassifierTests + CreateIdentityResumableTests + InvitationPersistenceTests pass on the iPhone 16 Pro simulator.

🤖 Generated with Claude Code

shumkov and others added 3 commits July 15, 2026 01:09
…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
@QuantumExplorer QuantumExplorer merged commit f744652 into v4.1-dev Jul 14, 2026
24 checks passed
@QuantumExplorer QuantumExplorer deleted the feat/dip15-dashpay-invitations branch July 14, 2026 19:01

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +266 to +296
// 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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 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']

Comment on lines +149 to +157
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 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']

Comment on lines 175 to 180
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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 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']

Comment on lines +5338 to +5343
// 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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 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']

Comment on lines +1101 to +1145
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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 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']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants