From 4891bd470808f14a3dc1ee31302d54cc4de056b4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 16:42:30 +0700 Subject: [PATCH 01/74] docs(dashpay): DIP-13 invitations spec (reviewed + synced) 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 --- docs/dashpay/DIP15_INVITATIONS_SPEC.md | 603 +++++++++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100644 docs/dashpay/DIP15_INVITATIONS_SPEC.md diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md new file mode 100644 index 00000000000..c09776ceea9 --- /dev/null +++ b/docs/dashpay/DIP15_INVITATIONS_SPEC.md @@ -0,0 +1,603 @@ +# DashPay Invitations (DIP-13 sub-feature 3') — Implementation Spec + +> **Status:** REVIEWED DRAFT (2026-07-08). Four research streams + three adversarial spec reviews +> (feasibility / security / scope) folded — see §14. Core mechanic CONFIRMED against code; two +> blockers resolved (seedless voucher export → v1 slice 2; auto-accept dapk dropped for a plain +> contactRequest). **Next: sync gate with Ivan → spikes → code.** No code yet. + +Tracked as the "NEXT" item in the DashPay backlog (dashpay/platform#4020); called out in +`SPEC.md` Milestone 5 and `DIP_CONFORMANCE_GAPS.md` (Invitations = ❌ NOT STARTED). This is +the "own design pass" Milestone 5 asks for. + +--- + +## 1. Problem & goal + +DashPay onboarding today assumes the new user already **has** a Dash identity (which +requires L1 Dash to fund the ~0.0002 DASH asset lock that registers it). That is a +chicken-and-egg wall for inviting a friend who has never touched Dash: they can't receive a +payment (no identity → no contact) and can't register an identity (no funds). + +**DIP-13 "Identity Invitation Funding keys" solves this.** An existing user (the *inviter*) +pre-funds an asset lock at a dedicated derivation sub-feature, hands the one-time private key ++ the asset-lock proof to a friend (the *invitee*) as a link, and the invitee registers +**their own new identity** funded by that voucher — no L1 Dash required on the invitee's +side. The invitation optionally bootstraps the DashPay contact in the same act (the invitee's +contact request to the inviter carries a DIP-15 `autoAcceptProof`, so it auto-establishes). + +**Goal:** implement invitation **create** (inviter) and **claim** (invitee) end-to-end across +`rs-platform-wallet` + `rs-platform-wallet-ffi` + `swift-sdk` + `SwiftExampleApp`, with unit ++ integration tests, a testnet funded e2e, and QA-contract scenarios. + +### Non-goals +- **No byte-for-byte interop with the production iOS/Android DashWallet invitation link.** We + can't drive those builds in this environment (same constraint the auto-accept spec accepted: + iOS-first, DIP-faithful where the DIP defines a format, normative-for-us where it is silent). + The **on-chain** artifacts (asset lock, IdentityCreate, contactRequest) are consensus formats + and *are* interoperable; only the off-chain **link envelope** is ours. See §7 for the interop + decision once the reference format is confirmed. +- **No new on-chain artifact.** Invitations reuse the existing AssetLock special-tx, the + IdentityCreate transition, and a plain contactRequest. +- **No auto-accept bearer key in the invitation (v1).** The contact-bootstrap is a *normal* + contact request (see §2 design change); no `dapk` is embedded. +- **No invitation for identity-less inviters in v1** beyond the pure funding voucher: the + contact-bootstrap requires the inviter to hold a registered identity. A voucher from an + identity-less funder still works as pure onboarding funding; it just carries no inviter to + contact. +- **Advisory expiry, not consensus revocation.** The voucher key controls an on-chain asset + lock that never expires; the payload's `expiry` is an **advisory** bound (the claim UI refuses + a stale link; the inviter is prompted to reclaim). True "revocation" is the inviter racing to + *reclaim* the unclaimed lock (a race it can lose if the link already leaked — §8 Finding 6). A + dedicated revoke UI is a follow-up. + +--- + +## 2. The model — two roles, three on-chain acts + +1. **Inviter (Bob, has funds + identity).** + - Derives a one-time ECDSA **voucher key** at the DIP-13 invitation path + `m/9'/coin'/5'/3'/funding_index'` (sub-feature `3'`). + - Builds + broadcasts an **asset lock** paying `amount` duffs to that key, and waits for an + **InstantSend** proof (§5.1 — fast, self-contained; a short IS-scoped expiry covers + staleness). + - **Optionally ticks "send a contact request back to me"** — if checked, the link carries the + inviter's identity id + username; if not, it's a pure funding voucher. + - Emits a `dashpay://invite?...` link carrying: **voucher private key**, **asset-lock + proof (IS)**, **advisory expiry**, and *(if opted in)* **inviter identity id + username + + display name**. The voucher key is re-derivable from `funding_index`, so it is **never + persisted**; only the funding index + outpoint are tracked (for recovery + status). +2. **Invitee (Carol, no funds).** + - Opens the link → decodes (voucher key, proof, optional inviter info). + - Registers **her own new identity** with keys derived from **her** seed at + `m/9'/coin'/5'/0'/0'/identity_index'/…`, funded by the imported `(proof, voucher_key)` via + the SDK's in-process raw-key path (§5.2). No L1 Dash on Carol's side. + - **If the link carries inviter info, Carol is *asked* "establish contact with \?"** — + on confirm, a *normal* contactRequest Carol→Bob is sent via the shipped + `send_contact_request` path; Bob sees it in his Requests and accepts. Opt-in on both ends + (inviter checkbox + invitee prompt); no bearer auto-accept key is embedded. + +> **Design change from the first draft (security review Finding 1 + reference behavior).** The +> first draft embedded a DIP-15 auto-accept `dapk` in the link so the contact would auto-establish +> with zero taps on the inviter. That is **removed**: auto-accept's safety rests entirely on a +> **1-hour TTL**, which is fundamentally incompatible with an invitation that is claimed hours-to- +> days later — a link long-lived enough to be useful would be a long-lived auto-accept bearer +> credential against the inviter (anyone finding a stale/posted link could make the inviter +> publish an encrypted friendship xpub to them). The production wallets don't do this either: +> their claim flow (`sendContactRequestToInviterUsingInvitationURL`) sends a **plain** contact +> request. So v1 auto-sends a normal contactRequest; zero-tap acceptance is the inviter's own +> orthogonal auto-accept setting, not baked into the shared link. (Embedding a short-TTL dapk with +> an explicit "expired → manual request" fallback is a possible v2 nicety — deferred.) + +The consensus acts (asset lock, IdentityCreate, contactRequest) are all already implemented and +tested; invitations are the **orchestration + off-chain envelope + key-handoff** around them. + +--- + +## 3. What already exists (reuse inventory — first-hand code read) + +| Capability | Where | Reused for | +|---|---|---| +| **Invitation funding derivation** `AssetLockFundingType::IdentityInvitation` (sub-feature `3'`), `accounts.identity_invitation` xpub, storage/recovery/persistence all wired | `asset_lock/build.rs:200-216` (`peek_next_funding_address`), storage `schema/accounts.rs`, `asset_lock/sync/recovery.rs:427`, `persistence.rs:3633` | **Create**: derive the voucher key + build the voucher asset lock | +| **Full funded-asset-lock flow** `create_funded_asset_lock_proof(amount, account_index, funding_type, identity_index, signer) -> (AssetLockProof, DerivationPath, OutPoint)` (build → track → broadcast → IS wait → CL-upgrade → attach proof) | `asset_lock/build.rs:305-417` | **Create**: build the voucher lock | +| **IS→CL upgrade** `upgrade_to_chain_lock_proof(out_point, None)` | `identity/network/registration.rs:186-197,247-250` | **Create**: force a CL proof before export | +| **Register identity from a raw asset-lock private key** `Identity::put_to_platform_and_wait_for_response_with_private_key(sdk, proof, asset_lock_proof_private_key: &PrivateKey, identity_signer, settings)` | `rs-sdk/.../put_identity.rs:50-59,146+` | **Claim**: register invitee identity funded by the imported voucher — **core claim needs no new SDK code** | +| **Bare claim FFI (external proof + one-time key)** `dash_sdk_identity_put_to_platform_with_instant_lock` / `_with_chain_lock(sdk, …proof bytes…, private_key:[u8;32], signer, settings)` | `rs-sdk-ffi/src/identity/put.rs:29,211` | Lower layer under the platform-wallet `claim_invitation` wrapper (no Swift binding yet) | +| **`AssetLockProof::Instant` embeds the full tx + islock** (self-contained); `Chain` = outpoint+height (Platform resolves tx) | `asset_lock_proof/instant/…:38`, `…/chain/…:24` | **Link**: serialize the proof directly — no separate txid + L1 fetch | +| **Consensus verifies the create sig against the asset-lock output's P2PKH hash** | `identity_create/state/v0/mod.rs:222-245` | Security trust anchor (§8): holder of the voucher key == who may create the identity | +| **Seedless register (self-funded)** `register_identity_with_funding(AssetLockFunding, identity_index, keys_map, identity_signer, asset_lock_signer, …)` | `identity/network/registration.rs:121` | Template; claim uses the raw-key variant instead | +| **Sanctioned raw-scalar export (path-gated)** `ContactCryptoProvider::export_auto_accept_private_key(&path)` / resolver hook | `contact_requests.rs:63`, `mnemonic_resolver_core_signer.rs:353` | **Create**: template for the new path-gated `export_invitation_private_key` (§5.3) | +| **Send a normal contactRequest** `platform_wallet_send_contact_request_with_signer(...)` | FFI `dashpay.rs:225` | **Claim**: auto-send the plain contact-bootstrap invitee→inviter (no dapk) | +| **Register/resume identity FFI (external signer)** `platform_wallet_register_identity_with_funding_signer`, `platform_wallet_resume_identity_with_existing_asset_lock_signer` | FFI `identity_registration_funded_with_signer.rs` | Template for the new claim FFI marshaling | +| **Asset-lock build FFI + tracked-lock listing** `asset_lock_manager_build_transaction`, `create_funded_proof`, `list_tracked_locks` | FFI `asset_lock/build.rs`, `asset_lock/manager.rs` | Create FFI + inviter-side status | + +**Net: the funding-derivation family and both consensus signing paths already exist.** The new +code is (a) the create orchestration + voucher-key export, (b) the claim orchestration, (c) the +`dashpay://invite` envelope codec, (d) inviter-side invitation persistence, (e) FFI + Swift + UI. + +--- + +## 4. Interface / data flow per layer + +### 4.1 Rust — new module `wallet/identity/network/invitation.rs` (+ codec in `crypto/invitation.rs`) + +**Create (inviter):** +``` +async fn create_invitation( + &self, + amount_duffs: u64, // rejected if > MAX_INVITATION_DUFFS (§8 Finding 4) + funding_account_index: u32, // BIP44 account supplying the L1 UTXOs + invitation_index: u32, // DIP-13 funding_index' (sequential; next-unused) + inviter_identity: Option, // id + username + display_name (contact-bootstrap) + expiry_unix: u32, // advisory; ≤ now + MAX_INVITATION_TTL (§8 Finding 3) + asset_lock_signer: &AS, // MnemonicResolverCoreSigner (funding-input + credit-output) +) -> Result +``` +where `inviter_identity: Option` is `Some` only when the inviter ticked "send a +contact request back to me" (§ owner decision). Steps: (1) **bound the amount** +(`amount_duffs ≤ MAX_INVITATION_DUFFS`) and the expiry (`≤ now + MAX_INVITATION_TTL`), else err; +(2) `create_funded_asset_lock_proof(amount, funding_account_index, IdentityInvitation, +invitation_index, signer)` → `(IS proof, path, out_point)` — **keep the IS proof, no CL upgrade** +(§5.1); (3) **export the voucher private key** via the seedless resolver hook, **path-gated to +`9'/coin'/5'/3'/idx'`** (§5.3); (4) build the `Invitation` struct + `dashpay://invite` URI (§6); +(5) **persist an invitation record** through the wallet persister (§4.2) — created status, +outpoint, funding_index, amount, expiry, optional inviter info; **the voucher key is never +persisted** (re-derived from `funding_index`). + +**Claim (invitee):** +``` +async fn claim_invitation( + &self, + invitation: ParsedInvitation, // decoded from the URI + identity_index: u32, + keys_map: BTreeMap, // invitee's own new-identity keys + identity_signer: &IS, // invitee's identity-key signer + establish_contact: bool, // invitee's answer to "establish contact with ?" +) -> Result +``` +Claim **bypasses the wallet's `AssetLockFunding` machinery** — the deliberately-removed +`UseAssetLock` variant (external proof through the tracked-lock resolver) is *not* revived; the +invitee owns neither the lock's inputs nor its tracking and can't drive its IS→CL fallback, so +claim submits the imported proof directly. Steps: (1) **validate the parsed invitation before +any network act** (§8 Finding 5): proof is an **Instant** proof; the voucher pubkey is the +credit-output's P2PKH target (`proof.output() → credit_outputs[output_index]`); expiry not +past — fail loud with a specific error otherwise; (2) build the placeholder `Identity` with +`keys_map`; (3) +`placeholder.put_to_platform_and_wait_for_response_with_private_key(&sdk, invitation.proof, +&invitation.voucher_key, identity_signer, settings)` → new `Identity` — **wrap this submit in +`submit_with_cl_height_retry`** (feasibility Note A): the direct raw-key SDK call bypasses +`register_identity_with_funding`, so it doesn't inherit that helper's retry on a transient +CL-height-too-low (10506); without the wrapper a transient reject is a hard claim failure; (4) +local bookkeeping +(add to IdentityManager, breadcrumbs) — best-effort, non-propagating (mirrors +`register_identity_with_funding` Step 4); (5) if `invitation.inviter` present **and +`establish_contact`** (the invitee said yes to the prompt), **send a normal contactRequest** +invitee→inviter via the shipped `send_contact_request` path (the new invitee identity as +sender). Idempotent/re-sendable if step 5 fails after step 3 succeeds (§10). If the invitee +declines, the identity is still created — just no contact. + +### 4.2 Rust — inviter-side persistence (proper persister integration — owner decision) +**A first-class persisted invitation record, through the existing wallet persister system** +(not an ad-hoc KV blob). Follow the established DashPay changeset → persister → SwiftData-model +pattern already used for contact requests / payments (`rs-platform-wallet` changeset overlays + +`rs-platform-wallet-storage` migration + the Swift `Persistence/Models` `@Query` models — +research-swift map). Concretely: +- **Rust storage (`rs-platform-wallet-storage`):** a new `invitations` table via a migration + (mirroring `asset_locks` `V001__initial.rs:247`), columns `wallet_id, outpoint, funding_index, + amount_duffs, expiry_unix, status (created|claimed|reclaimed), inviter_opt_in, created_at, + claimed_identity_id?`. **No secret column** — the voucher key is re-derived from `funding_index` + (§5.3), never stored. +- **Rust changeset (`rs-platform-wallet`):** an `InvitationChangeSet` emitted by create/reclaim + and by the sync that flips *created → claimed* (detected by the tracked asset-lock's outpoint + being consumed on Platform / the invitee's inbound contactRequest), queued onto the persister + exactly like `AssetLockChangeSet` / the DashPay overlays. +- **Swift:** a `PersistentInvitation` SwiftData model registered in `DashModelContainer`, driving + a `@Query` "Sent invitations" list (`InvitationsView`). + +Recovery still leans on re-derivation: an unclaimed invitation's voucher key is re-derived from +its `funding_index` to re-package or reclaim (the asset-lock row already tracks the lock's +lifecycle for the actual reclaim submit). The invitations table adds the durable, queryable +*status* surface the UI needs. + +### 4.3 FFI (rs-platform-wallet-ffi) — new `invitation.rs` +- `platform_wallet_create_invitation(wallet, amount_duffs, funding_account_index, + inviter_identity_id: *const [u8;32] /*nullable*/, inviter_username: *const c_char /*nullable*/, + expiry_unix: u32, core_signer_handle, out_uri: **c_char, out_outpoint: *mut OutPointFFI) + -> Result`. **Only `core_signer_handle`** (the asset-lock/Core signer) is needed — pure voucher + creation registers no identity, so there is no identity `signer_handle` (feasibility Note B). + `now`/`expiry_unix` is passed in from Swift (FFI can't read the clock deterministically — same + convention as `build_auto_accept_qr`). +- `platform_wallet_claim_invitation(wallet, uri: *const c_char, identity_index, + identity_pubkeys, identity_pubkeys_count, signer_handle /*invitee identity signer*/, + establish_contact: bool, out_identity_id: *mut [u8;32], out_identity_handle: *mut Handle) + -> Result`. `establish_contact` is the invitee's answer to the "establish contact with + \?" prompt (only acted on if the link carries inviter info). Reuses + `decode_identity_pubkeys` + the managed-identity insert from + `identity_registration_funded_with_signer.rs`. Note: a **bare** identity-create-from-external- + proof FFI already exists one layer down — `dash_sdk_identity_put_to_platform_with_chain_lock` + / `..._with_instant_lock(sdk, …proof bytes…, private_key: *const [u8;32], signer, settings)` + (`rs-sdk-ffi/src/identity/put.rs:29,211`). We do **not** call that bare FFI from Swift for + claim: the platform-wallet `claim_invitation` wrapper is needed so the new invitee identity is + registered in the wallet's `ManagedIdentity` storage **and** the contact-bootstrap fires — it + calls `put_to_platform_and_wait_for_response_with_private_key` internally, then does bookkeeping + + the bootstrap send. (No `core_signer_handle` is needed on claim: the asset-lock signature + uses the imported raw voucher key, not a wallet-derived one.) +- `platform_wallet_list_invitations(...)` + free helpers for the inviter status list. +- String/URI input validation identical to the auto-accept FFIs (null checks, length caps). + +### 4.4 Swift (swift-sdk + SwiftExampleApp) +Current services (note: `PlatformService`/`WalletService`/`UnifiedAppState` were **removed**): +`AppState` (owns the `SDK`, network), `PlatformWalletManager` (per-network, DashPay sync +lifecycle), `ManagedPlatformWallet` (**all identity/DashPay FFI calls live here**). **All Swift +↔ Rust FFI work MUST go through the `swift-rust-ffi-engineer` agent** (repo `CLAUDE.md` rule). +The **DIP-15 auto-accept QR flow is the copy-template** for both directions. +- swift-sdk wrappers on `ManagedPlatformWallet`: + - `createInvitation(amountDuffs:fundingAccount:expiry:) async throws -> InvitationLink` + (idiom of `registerIdentityWithFunding` `ManagedPlatformWallet.swift:3370` — long-running L1 + build, so wrap with a Controller+Coordinator triad like `IdentityRegistrationController`). + - `claimInvitation(uri:identityIndex:) async throws -> ManagedIdentity` (idiom of + `sendContactRequestFromQR` `:1758`). +- SwiftExampleApp UI (under the DashPay tab, `App/Views/DashPay/`): + - **Create**: a "Create invitation" action (beside "Add me QR" in `DashPayProfileView.swift:74`) + → amount entry **+ a "send a contact request back to me" checkbox** (drives the optional + inviter info) → share sheet with the link + a QR (reuse `generateQRCode`). + - **Claim**: a toolbar button + sheet mirroring `AddViaQRSheet` (`DashPayTabView.swift:830`) + (paste/scan the `dashpay://invite` link) → register identity → **if the link carries inviter + info, prompt "establish contact with \?"** → pass the answer as `establish_contact` → + `kickDashPaySync` → the new identity (+ optional contact) land via `@Query`. + - **Invitations list** (created + status): a new `InvitationsView` (`@Query` over + `PersistentInvitation`, §4.2), reached via a toolbar `NavigationLink` (like the Ignored link + at `:151`). + - **Deep link (net-new plumbing):** no `onOpenURL`/`CFBundleURLTypes` exist today. Add the + `dashpay` URL scheme to `SwiftExampleApp/Info.plist` and `.onOpenURL { … }` on the + `WindowGroup` in `SwiftExampleAppApp.swift:105`, routing to `RootTab.dashpay` + the claim + sheet; reuse the `AddViaQRSheet` URI-parse as the model. +- `FundingType.identityInvitation = 3` already exists in Swift + (`ManagedAssetLockManager.swift:36`, `KeyWalletTypes.swift:14`). +- **Framework build:** `DashSDKFFI.xcframework` is a generated artifact (not committed); rebuild + via `packages/swift-sdk/build_ios.sh --target sim` after any FFI/header change, then the + `xcodebuild` app build (§ repo CLAUDE.md). Always clean+rebuild after header changes. + +### 4.5 QA contract +The authoritative QA contract is **`packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md`** (driven by +the `simulator-control` skill; dashboard at `dashpay.github.io/qa-dashboard-site`). Add rows to +**§4.10 DashPay** as **DP-12+** in the existing format: +`| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes |`. Planned rows: +- `DP-12 | Create invitation | Cross | Common | … | funding | DashPay → Create invitation → platform_wallet_create_invitation (builds L1 asset lock; needs testnet funds).` +- `DP-13 | Claim invitation | Platform | Common | … | | Paste/scan dashpay://invite → platform_wallet_claim_invitation → new identity + contact.` +- `DP-14 | Invite→claim e2e (two wallets) | Cross | Thorough | … | multiwallet | Create on A, claim on B, contact auto-establishes both ends (cf. DP-11).` +- `DP-15 | Reject malformed / already-claimed invitation | Platform | Uncommon | … | | Bad link + reused link both fail loudly, no side effects.` +(Secondary: the `AI_QA/` MCP playbooks — add a `QA004`-style invite→claim walkthrough if useful.) + +--- + +## 5. The three technical cruxes (de-risked first-hand; §11 spikes confirm) + +### 5.1 Proof type — DECIDED: InstantSend (owner decision 2026-07-08) +`AssetLockProof` has two variants with very different self-containment (confirmed +`asset_lock_proof/mod.rs:40`): +- **`InstantAssetLockProof { instant_lock, transaction, output_index }`** — embeds the **full + funding tx + the InstantLock**. Self-contained (Platform validates the islock against the + embedded tx). This is what the **reference iOS/Android wallets export** (`islock` + they carry + the txid and re-fetch the tx). Fast to produce (just wait for the IS lock). **Risk:** Platform + rejects an islock whose quorum has rotated or is too old relative to Platform's core height + (`is_instant_lock_proof_invalid` + the IS→CL retry in `registration.rs`). An invitation that + sits **unclaimed** for a long time can go stale. +- **`ChainAssetLockProof { core_chain_locked_height, out_point }`** — tiny (outpoint + height); + Platform resolves the tx from Core by outpoint. **No staleness window** (chain-locked is + permanent), so an unclaimed invitation stays valid indefinitely. Cost: the inviter waits for a + ChainLock at create (≈ up to a block or two, low-minutes). + +**DECISION (owner, 2026-07-08): export an InstantSend proof.** Faster create (no CL wait), matches +the reference wallets, and the `InstantAssetLockProof` embeds the full tx + islock so the link is +fully self-contained (the invitee never fetches anything from L1). `create_funded_asset_lock_proof` +returns exactly this for a fresh tx (its `validate_or_upgrade_proof` only upgrades to CL when the +tx is *old* — not the case at create), so the invitation path **keeps the IS proof, no forced CL +upgrade**. + +**Staleness mitigation = a short, IS-scoped advisory expiry (not an IS→CL upgrade in v1).** The +one real risk is that Platform rejects a *stale* islock (quorum rotated). Rather than build an +invitee-side IS→CL upgrade (which needs the embedded tx re-tracked — non-trivial, and the +external-proof `UseAssetLock` path was deliberately removed), v1 sets the invitation's advisory +`expiry` conservatively **inside the IS validity window** (default ~24h, ≤ `MAX_INVITATION_TTL`): +the claim path refuses a past-expiry link up front with a clear "invitation expired — ask the +sender for a new one," so an about-to-go-stale proof is never submitted. Cheap, no fund risk (the +inviter simply re-creates), and the inviter's asset lock is reclaimable after expiry. **Future +enhancement (not v1):** an invitee-side IS→CL upgrade from the embedded tx to extend the window to +days/weeks. *(Note: this makes the create FFI's identity-signer moot as before, and the claim's +`submit_with_cl_height_retry` wrapper — feasibility Note A — still applies to the IS submit.)* + +### 5.2 Claim is ordinary identity registration with imported funding +`put_to_platform_and_wait_for_response_with_private_key(proof, voucher_key, identity_signer)` +already does exactly what claim needs. The invitee's identity keys come from the invitee's own +seed (normal registration); only the **funding** `(proof, voucher_key)` is imported. **No new +SDK code for the core claim.** The `identity_invitation` account is an inviter-only concept — +the invitee never derives sub-feature `3'`. + +### 5.3 Exporting the voucher private key is a deliberate bearer-credential export +The architecture's invariant is "private keys never cross the FFI boundary as raw bytes," and +the signer-driven builder deliberately **withholds** the credit-output private key (it returns +`AssetLockCreditKeys::Public((pubkey, path))`, `build.rs:117`). The invitation **is** a raw-key +handoff (the whole point), so exporting it is a scoped, documented exception — exactly like the +auto-accept `dapk` blob, which already exports a bearer private key in a QR. + +**Key choice:** **HD-derived at `m/9'/coin'/5'/3'/index'`** (not a JS-style random key). HD makes +it DIP-13-recoverable — the wallet can re-derive/scan unclaimed invitation funding txs and let +the user reclaim/resend (DIP-13's explicit recommendation) — at the cost of needing an export +step. (A random ephemeral key, JS-SDK precedent `createAssetLockTransaction.ts:26`, exports +trivially but is unrecoverable; rejected.) + +**Export = a NEW seedless resolver hook, path-gated to the exact invitation sub-feature +(security review Finding 2 — normative).** The create FFI is **seedless** (it drives a +`MnemonicResolverCoreSigner`, not a resident `Wallet`), so there is no `&Wallet` to +`derive_extended_private_key` on for the real host — v1 must add a raw-scalar export on the +resolver, exactly mirroring the sanctioned precedent +`export_auto_accept_private_key(&path) -> SecretKey` (`mnemonic_resolver_core_signer.rs:353`, +`ContactCryptoProvider` `contact_requests.rs:63`). **The new `export_invitation_private_key(&path)` +MUST gate on the full path** `comps.len()==5 && comps[0]==9' && comps[2]==5' && comps[3]==3'` — +**not** merely `comps[2]==5'`, because feature `5'` is shared with identity-registration +(`5'/0'`,`5'/1'`), top-up (`5'/2'`), etc.; a loose gate would let a caller exfiltrate the user's +**own** identity-funding keys. Add a negative test mirroring +`export_auto_accept_private_key_gates_to_the_auto_accept_path`. + +**Never persist the key.** Because it is HD-derived, the inviter re-derives it from the seed +whenever it re-packages or reclaims. Storage tracks only funding index + outpoint (§4.2). The +returned URI (which *contains* the plaintext key) is treated as a secret end-to-end: no logging, +no analytics, sensitive-pasteboard flag on the Swift side (§8 Finding 3). + +> **This export hook is v1 critical path, not a follow-up (feasibility Finding 5, BLOCKING).** +> Production/example-app wallets are **seedless at steady state** (`Wallet::new_external_signable`, +> no root key — `persistence.rs:158-163`); only the *first-ever* session has a resident seed. So +> the "derive from a resident `Wallet`" idea is a **dead end**: create a wallet Monday (seed +> resident), relaunch Tuesday (external-signable) → tap "Create invitation" → +> `wallet.derive_extended_private_key(path)` errors and the existing +> `export_auto_accept_private_key` rejects the `5'/3'` path (it gates to `16'`), so **no link can +> be produced.** The fix is the new gated `export_invitation_private_key` on +> `MnemonicResolverCoreSigner` + a `ContactCryptoProvider`-style method (seedless + seed impls, +> cf. `contact_requests.rs:63/188`) + its FFI — a dedicated implementation slice (§13 slice 2). + +--- + +## 6. The `dashpay://invite` link envelope — a single versioned blob + +**Decision: one opaque, versioned payload** behind a `dashpay://invite?data=` +deep link (keeping the reference's `dashpay://invite` scheme name for familiarity), **not** the +reference's six loose query params. Rationale in §7. The payload is a small versioned struct +(serde → bincode via platform-serialization), so the envelope can evolve without breaking older +links: + +``` +InvitationPayloadV0 { + version: u8, // = 0 + voucher_key: [u8; 32], // one-time ECDSA private key (secret; zeroized) + asset_lock: AssetLockProof, // InstantSend proof per §5.1 — embeds tx + islock + expiry_unix: u32, // ADVISORY, IS-scoped (§5.1/§8 Finding 3); not consensus + inviter: Option { // present iff the inviter opted in ("send request back") + identity_id: [u8; 32], + username: String, // DPNS name (whom the invitee's contactRequest targets) + display_name: Option, + }, // NO auto-accept dapk — v1 sends a normal contactRequest, invitee-confirmed (§2) +} +``` +- Serializing the `InstantAssetLockProof` directly means the link **embeds the full funding tx + + islock**, so the invitee needs **no L1 tx fetch** (an improvement over the reference, which + carried only the txid). Link size is a few hundred bytes → base58 ~a few hundred chars: fine + for a deep link and a QR. +- **Length-cap the `data=` param before decode (§8 Finding 5, LOW).** The base58-**char** cap on + the input *before* decoding is the DoS mitigation (mirrors the `dapk` cap in + `parse_dashpay_contact_uri`). Note: `AssetLockProof`'s consensus bincode decode is **already + bounded and panic-free** on arbitrary bytes (dashcore `MAX_VEC_SIZE`, finite cursor, all + `Result`-based — verified), so the residual is only "a huge blob is fully buffered," which the + pre-decode char cap closes. A fuzz test is cheap insurance, not a blocker. +- A pure `encode_invitation_uri(&InvitationPayload) -> String` / `parse_invitation_uri(&str) -> + Result` pair in `crypto/invitation.rs`, fully unit-tested (round-trip + + every malformed rejection). A plain `https://…` fallback host can wrap the same `?data=` for + users without the app installed — deferred (no hosting in v1). + +--- + +## 7. Interop decision — **RESOLVED: ship our own self-contained envelope** +Research (research-reference, primary sources) settled this: +- The production iOS (DashSync) + Android (dash-wallet) wallets use an **identical plaintext + URL-query payload**: `du` (username), `display-name`, `avatar-url`, `assetlocktx` (**txid + only**, 64-hex), `pk` (**WIF** private key), `islock` (hex InstantLock). The invitee **fetches + the full funding tx from L1 by txid**, then registers using the embedded islock. +- That link was distributed via **Firebase Dynamic Links**, which **Google shut down + 2025-08-25** — the hosted `invitations.dashpay.io/link` short-links now **404**. So even the + production wallets' *share layer is already broken* and must be reworked. +- **The JS SDK never had an invitation API** — invitations existed only in the two native apps. + +**Conclusion:** there is little value matching a legacy wire format whose delivery mechanism is +dead. We ship our own **self-contained, versioned** envelope (§6). The **only** things we must +NOT diverge on are the **on-chain / consensus** semantics — the DIP-13 `3'` derivation and the +islock / asset-lock-proof shapes Platform consensus accepts — because those are what actually +interoperate. This mirrors the auto-accept spec's "iOS-first, DIP-faithful where defined, +normative-for-us where silent" stance. (If byte-interop with a future reworked DashWallet is ever +required, matching is a localized codec change; the on-chain acts already interoperate.) + +--- + +## 8. Security +*(Folds a 4-lens security review: no CRITICALs — the core crypto is sound; findings are must-fix +hardening + honest-framing fixes. Verified-clean floor: in-flight IdentityCreate is +non-malleable, double-claim is deterministic, the invitee never risks its own funds.)* + +- **Consensus trust anchor (why this is safe at all).** Platform validates the IdentityCreate's + outer signature against the **asset-lock output's P2PKH public-key hash** + (`identity_create/state/v0/mod.rs:222-245`) and the identity id is `hash(outpoint)` — so a + network observer who does *not* hold the voucher key cannot swap in their own keys and steal an + in-flight claim, and two racers target the *same* id (consensus commits exactly one). Every + claim-theft attack reduces to **"who holds the link."** The invitee's own identity keys sign + the per-key witnesses separately. +- **Bearer credential — must-fix hardening:** + - **Amount cap enforced in Rust (Finding 4), not just UI.** `create_invitation` rejects + `amount_duffs > MAX_INVITATION_DUFFS`. The "small blast radius" argument fails if the cap is + UI-only (bypassable by a direct FFI caller / headless host / UI bug). + - **Advisory expiry on the voucher (Finding 3).** The voucher key has no on-chain expiry, so a + leak is a *permanent* claim until consumed. The payload's `expiry_unix` bounds the practical + leak window: the claim path refuses a past-expiry link, and the inviter is prompted to reclaim + after expiry. It is advisory (not consensus), but it bounds both the leak and the reclaim + window. `expiry` is capped at `now + MAX_INVITATION_TTL` at create. + - **Single-use** (asset lock consumed on first claim → deterministic reject thereafter), funds + are the inviter's to give. +- **The link is plaintext key material — treat the URI as secret end-to-end (Finding 3).** The + create FFI returns the URI (which *contains* the voucher key) as a C string that flows through + Swift + a `dashpay://invite` deep-link handler (handlers routinely log URLs) + clipboard + (iOS Universal Clipboard syncs across devices) + the share sheet. Requirements: **no logging / + no analytics** of the URI; secret/`Zeroizing` types Rust-side; a **sensitive-pasteboard** flag + Swift-side; the voucher key is **never persisted** (re-derived from `funding_index`, §5.3). +- **Inviter self-claim / front-run is a real griefing/DoS vector against the invitee (Finding 6 — + honesty fix).** *Not* "no third-party risk." The inviter can front-run or reclaim after handoff, + denying the invitee onboarding mid-flow with no signal it was the inviter's doing. No fund theft + (funds are the inviter's), but real denial. Likewise **"reclaim = revocation" is a race the + inviter can lose** if the link already leaked — reclaim is best-effort, and the advisory expiry + is the actual bound. Documented as an accepted, honestly-stated limitation. +- **Untrusted proof on claim — validate before submit (Finding 5, LOW after re-verify).** The + `AssetLockProof` bincode decode is already bounded/panic-free; the §6 pre-decode length cap is + the DoS mitigation (keep it). The genuinely useful part is **fail-fast UX, not a security gap**: + cheap **local pre-submit checks** — the proof is an **Instant** proof (§5.1), the advisory + expiry is not past, and the **voucher pubkey-hash ∈ the selected credit output** + (`proof.output() → credit_outputs[output_index]`) — so a malformed/hostile/stale link fails with + a clear error instead of an opaque consensus reject. The + credit-output-pubkey binding is itself consensus-enforced, so this cannot be *bypassed* to steal; + it only improves the error. +- **Unauthenticated envelope (Finding 7 — documented, no v1 fix).** Nothing signs the bundle, so a + MITM on the *link channel* can substitute the whole invite. Blast radius is limited (the + contact only forms toward whatever inviter identity is in the link; an attacker can at most make + the invitee contact the attacker's own identity — achievable with a normal contact request + anyway). Reduces to "bearer-link trust = channel trust"; envelope signing wouldn't help (the + channel is the trust root). +- **Privacy (Finding 8, LOW).** Because id = `hash(outpoint)`, the inviter knows the invitee's + future identity id before they claim, and that id is inviter-chosen. Noted. +- **Malformed / hostile link:** every field size-capped before decode; a bad link fails loudly + with no side effects. + +--- + +## 9. Decisions (RESOLVED — owner, 2026-07-08) +1. **Proof type: InstantSend** (§5.1). Fast create, self-contained link; staleness covered by a + short IS-scoped advisory expiry (claim refuses past-expiry), not an IS→CL upgrade in v1. +2. **Contact-bootstrap: opt-in on both ends.** Inviter ticks "send a contact request back to me" + (→ inviter info in the link); the invitee is *asked* "establish contact with \?" at + claim and only then is a normal contactRequest sent. In v1. No auto-accept dapk (§8 Finding 1). +3. **Inviter persistence: proper wallet-persister integration** (§4.2) — a first-class + `invitations` table + changeset + `PersistentInvitation` SwiftData model, not a KV blob. In v1. +4. **Link scheme:** our own self-contained versioned blob (§7). +5. **Amount / TTL:** Rust-enforced `MAX_INVITATION_DUFFS` (default a sensible identity-reg + + small-balance amount; confirm exact duffs during spikes) and `MAX_INVITATION_TTL` bounded to + the **IS validity window** (default ~24h) since the proof is InstantSend. + +--- + +## 10. Failure modes +- **Insufficient inviter balance to fund the lock** → create fails pre-broadcast, funds + untouched (reservation released — existing `create_funded_asset_lock_proof` rejection path). +- **InstantSend lock never arrives at create** → `create_funded_asset_lock_proof`'s 300s IS wait + elapses and (for a fresh tx) it surfaces an error; the tracked lock is resumable (inviter can + retry or reclaim). We do **not** force a CL upgrade (§5.1). +- **Stale IS proof (claimed too late)** → the advisory expiry makes the claim refuse *before* the + IS lock could be rejected by Platform; the inviter re-creates. (Extending the window via an + invitee-side IS→CL upgrade is a post-v1 enhancement.) +- **Invitee claims an already-claimed / inviter-front-run link** → Platform rejects (lock + consumed); claim returns a clear "invitation already used" error; no identity created. (This is + also the inviter-front-run griefing outcome, §8 Finding 6.) +- **Malicious inviter hands a mismatched/IS/expired proof** → caught by the claim pre-submit + checks (§4.1 step 1 / §8 Finding 5) → fail loud, no blind submit. +- **Claim interrupted after identity created but before contact-bootstrap sent** → the identity + exists (self-heals into the invitee's IdentityManager on next re-sync); the contact request is + re-sendable (idempotent — the send path adopts an existing friendship). Not a data-loss path. +- **Malformed / truncated / oversize link** → parse/size-cap error, no side effects. +- **Invitee has no seed / can't derive identity keys** → claim fails before any network act. +- **Voucher never claimed AND inviter loses seed (§8 Finding 9, LOW)** → L1 Dash stranded in the + lock (asset locks are one-way). Mitigated by HD re-derivation from `funding_index` — this stays + a generic "lost your seed" problem, not invitation-specific. + +--- + +## 11. Spikes (before implementation — task #11) +1. **S1 — raw-key claim end-to-end (offline):** in a `rs-platform-wallet` integration test, + build an asset lock at `IdentityInvitation`, derive the voucher key, and drive + `put_to_platform_and_wait_for_response_with_private_key` against a mock/echo SDK to confirm + the proof + raw-key + invitee-identity-signer triple registers an identity. Confirms §5.2. +2. **S2 — seedless voucher-key export + path gate:** add `export_invitation_private_key(&path)` + on the resolver/provider mirroring `export_auto_accept_private_key` + (`mnemonic_resolver_core_signer.rs:353`), and prove the gate: it exports for + `9'/coin'/5'/3'/idx'` and **rejects** `9'/coin'/5'/0'/…` (identity-auth), `…/5'/1'/…` (reg + funding), `…/5'/2'/…` (top-up) — the Finding-2 negative test. Confirms §5.3. +3. **S3 — create keeps the IS proof + persistence round-trip:** confirm + `create_funded_asset_lock_proof(IdentityInvitation)` returns an **Instant** proof for a fresh + tx (no auto-upgrade), and that an `InvitationChangeSet` round-trips through the persister + (`created` row readable back). Confirms §5.1 + §4.2. +4. **S4 — link envelope codec:** implement + unit-test `encode/parse_invitation_uri` + (round-trip + malformed) — cheap, do first. + +--- + +## 12. Test / verification plan +- **Rust unit:** invitation URI codec (round-trip + every malformed rejection incl. the + pre-decode length cap); voucher blob round-trip; the **export-path-gate negative test** (§5.3 / + S2, Finding 2 — the blocking one: exports `5'/3'`, rejects `5'/0'`,`5'/1'`,`5'/2'`,`16'`); + create-invitation **rejects `amount > MAX_INVITATION_DUFFS` and `expiry > now+MAX_TTL`** + (Finding 3/4); claim **pre-submit checks reject** a non-Instant proof and a voucher-pubkey ∉ + credit-output (Finding 5 — fail-fast); expired-link rejection. (Optional insurance: a fuzz test + that `parse_invitation_uri` on arbitrary bytes never panics — not a blocker, decode is already + bounded.) +- **Rust integration (`rs-platform-wallet`):** the S1 offline flow as a permanent test; the + create→export→re-derive-from-`funding_index` round-trip (recovery); reclaim-unused path. +- **FFI:** null/oversize/bad-URI input validation; create→parse round-trip; claim marshaling + (identity handle inserted, id out); assert the URI is not emitted to logs. +- **Swift:** `build_ios.sh` green; wrapper unit tests for encode/decode boundaries. +- **Testnet funded e2e (task #13):** fund an inviter wallet via the **built-in faucet** + (Wallet → Receive → "request from testnet", `TestnetFaucetService` → `faucet.thepasta.org`) → + register the inviter identity + DPNS name → `create_invitation` → parse the link in a **second** + wallet with no funds → `claim_invitation` → assert the invitee identity exists on Platform and + (if bootstrap) the contact auto-establishes after the inviter's drain. This is the acceptance + gate. Can run headless (Rust integration against testnet) and/or two-simulator on-device. +- **On-device (two sims):** create on sim A, claim on sim B, contact appears on both. +- **QA contract:** the scenarios from §4.5. + +--- + +## 13. Commit slicing (implementation order) +1. `crypto/invitation.rs` codec (payload struct + `encode/parse_invitation_uri` + length cap) + + tests (S4). +2. **Voucher-key export (v1 critical path — feasibility Finding 5):** gated + `export_invitation_private_key` on `MnemonicResolverCoreSigner` (gate `9'/coin'/5'/3'/idx'`) + + `ContactCryptoProvider`-style method (seedless + seed impls) + the path-gate negative test (S2). + Without this the seedless host cannot produce a link at all. +3. `network/invitation.rs` create (slice-2 export + keep IS proof + amount/expiry caps) + claim + (raw-key submit wrapped in CL-height retry + Instant-proof pre-submit checks + optional + invitee-confirmed contactRequest) helpers + unit tests (S1). +4. **Inviter persistence (§4.2):** `invitations` migration + `InvitationChangeSet` + status sync. +5. FFI `platform_wallet_create_invitation` (core signer only) / `_claim_invitation` + (`establish_contact` param) + tests (marshaling mirrors `identity_registration_funded_with_signer.rs`). +6. swift-sdk wrappers on `ManagedPlatformWallet` + `PersistentInvitation` SwiftData model (**via + `swift-rust-ffi-engineer`**). +7. SwiftExampleApp: create sheet (amount + "send request back" checkbox), claim sheet (with the + "establish contact with \?" prompt), `InvitationsView` list, + `dashpay://invite` + deep-link handler (`Info.plist` scheme + `.onOpenURL`). +8. QA-contract rows (TEST_PLAN.md §4.10 DP-12+). +9. Testnet e2e evidence + docs (`SPEC.md` Milestone 5 as-built, `DIP_CONFORMANCE_GAPS.md` row). + +--- + +## 14. Multi-agent spec-review resolutions (2026-07-08) +Four research streams (wallet/SDK/Swift/reference) + three adversarial spec reviews +(feasibility / security / scope). Folded: +- **Feasibility — core mechanic CONFIRMED** (claim independence proven at `v0_methods.rs:65-78`; + create/CL/FFI confirmed). **One blocker: seedless voucher-key export** — the resident-`Wallet` + idea is a dead end (production wallets are `new_external_signable`); promoted to **v1 critical + slice 2** (§5.3, §13). Should-fixes folded: bounded CL wait (§5.1/§4.1), claim submit wrapped in + CL-height retry (§4.1), create FFI drops the spurious identity signer (§4.3). +- **Security — no CRITICALs.** Two blockers folded: (1) the **dapk TTL contradiction** → + auto-accept dropped, plain contactRequest bootstrap (§2); (2) **export path-gating** to + `9'/coin'/5'/3'/idx'` with a negative test (§5.3). Hardening folded: Rust amount cap, advisory + voucher expiry, secret/no-log URI (§8 Finding 3/4); honesty fixes (self-claim = griefing/DoS, + reclaim = a race — §8 Finding 6). Proof-parse worry **downgraded to LOW** on re-verify (bincode + is already bounded; the length cap is the mitigation; pre-submit checks are fail-fast UX). +- **Reference/interop** — the production link format is dead (FDL shutdown); ship our own + self-contained versioned envelope, preserve only on-chain semantics (§7). +- **Scope** — scope levers threaded (single versioned blob §6; reuse over new code throughout). +- **Owner decisions (2026-07-08, sync gate):** (1) **InstantSend** proof, not ChainLock — + staleness handled by a short IS-scoped expiry (§5.1); (2) contact-bootstrap **opt-in on both + ends** — inviter checkbox + invitee "establish contact?" prompt (§2, §4.1); (3) **proper + wallet-persister** integration for invitations, not a KV blob (§4.2). All in v1. From 1d8b49066ff66977d6c79bb2c82528d16e260572 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 16:56:14 +0700 Subject: [PATCH 02/74] feat(platform-wallet): DIP-13 invitation link codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/wallet/identity/crypto/invitation.rs | 539 ++++++++++++++++++ .../src/wallet/identity/crypto/mod.rs | 4 + 2 files changed, 543 insertions(+) create mode 100644 packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs new file mode 100644 index 00000000000..1af94aab8a4 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -0,0 +1,539 @@ +//! DashPay invitation link (`dashpay://invite`) codec — DIP-13 sub-feature 3'. +//! +//! An invitation packages a one-time ECDSA **voucher** private key together with +//! the InstantSend asset-lock proof that funds it, so an invitee with no Dash can +//! register their own identity from it. The inviter optionally includes their own +//! identity id + username so the invitee can send a contact request back. +//! +//! The link is a single versioned, self-contained blob: +//! `dashpay://invite?data=`. Only the off-chain envelope is ours; +//! the embedded `AssetLockProof` and the on-chain acts are consensus formats. +//! See `docs/dashpay/DIP15_INVITATIONS_SPEC.md`. +//! +//! The payload uses a small hand-rolled little-endian binary format (rather than +//! serde/bincode) so the codec has no dependency on the crate's optional `serde` +//! feature — create/claim need it unconditionally. +//! +//! # Security +//! +//! The `voucher_key` is **bearer money** — whoever holds the link can claim the +//! funded identity. The URI is a secret: callers MUST NOT log or persist it, and +//! the voucher key is never stored (it is HD-derived and re-derivable from the +//! funding index). Parsing is bounded before decode (base58 length cap) so a +//! hostile link can't force a large allocation, and [`validate_claimable`] fails +//! fast on a stale, wrong-type, or mismatched link before any network call. + +use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dashcore::ScriptBuf; +use dpp::bincode::config; +use dpp::prelude::AssetLockProof; + +use crate::error::PlatformWalletError; + +/// URI prefix for an invitation deep link. The `dashpay://invite` scheme matches +/// the reference wallets for familiarity; the payload is our own (§7 of the spec). +const INVITATION_URI_PREFIX: &str = "dashpay://invite?data="; + +/// Max base58 chars of the `data=` value accepted **before** decoding (anti-DoS). +/// A real payload — voucher key (32 B) + an InstantSend proof (funding tx + islock, +/// ~0.5–1 KB) + small metadata — base58-encodes to roughly 1.5–2 K chars; 8192 is +/// comfortable headroom while still bounding the base58 allocation a hostile link +/// can force. Mirrors the `dapk` cap in `auto_accept::parse_dashpay_contact_uri`. +const MAX_INVITATION_DATA_B58_LEN: usize = 8192; + +/// Hard byte cap on the decoded payload (defense in depth alongside the b58 cap). +const MAX_INVITATION_PAYLOAD_BYTES: usize = 64 * 1024; + +/// Max length (bytes) of a UTF-8 string field (username / display name). DPNS +/// labels are short; this only bounds a hostile link. +const MAX_STR_BYTES: usize = 256; + +/// Current invitation payload version. +const INVITATION_PAYLOAD_VERSION: u8 = 0; + +/// Inviter contact-bootstrap info — present iff the inviter opted in to "send a +/// contact request back to me". Absent ⇒ the invitation is a pure funding voucher. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InviterInfo { + /// The inviter's identity id (32 bytes) — the target of the invitee's + /// contact request. + pub identity_id: [u8; 32], + /// The inviter's DPNS username, shown to the invitee and used to label the + /// contact. + pub username: String, + /// Optional display name for the claim UI. + pub display_name: Option, +} + +/// A decoded invitation, ready for [`validate_claimable`] + claim. +pub struct ParsedInvitation { + /// One-time ECDSA voucher private key that funds the invitee's identity + /// create (signs the asset-lock's outer state-transition signature). + pub voucher_key: SecretKey, + /// The InstantSend asset-lock proof funding the voucher (embeds tx + islock). + pub asset_lock: AssetLockProof, + /// Advisory expiry (unix seconds). Not consensus-enforced; the claim path + /// refuses a past-expiry link so a stale IS proof is never submitted. + pub expiry_unix: u32, + /// Inviter contact-bootstrap info; `None` ⇒ pure funding voucher. + pub inviter: Option, +} + +impl std::fmt::Debug for ParsedInvitation { + /// Redacts the voucher key — the whole point of the type is to carry a + /// bearer secret, which must never reach a log. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ParsedInvitation") + .field("voucher_key", &"") + .field("expiry_unix", &self.expiry_unix) + .field("inviter", &self.inviter) + .finish_non_exhaustive() + } +} + +fn invalid(msg: impl Into) -> PlatformWalletError { + PlatformWalletError::InvalidIdentityData(msg.into()) +} + +/// The P2PKH script the voucher key controls (compressed-pubkey hash160). +fn voucher_credit_script(voucher_key: &SecretKey) -> ScriptBuf { + let secp = Secp256k1::new(); + let pubkey = PublicKey::from_secret_key(&secp, voucher_key); + let hash = dashcore::PublicKey::new(pubkey).pubkey_hash(); + ScriptBuf::new_p2pkh(&hash) +} + +// --------------------------------------------------------------------------- +// Wire encoding (little-endian, length-prefixed) +// --------------------------------------------------------------------------- + +fn put_len_prefixed(buf: &mut Vec, bytes: &[u8]) { + buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(bytes); +} + +/// Cursor over the payload bytes with bounds-checked, non-panicking reads. +struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], PlatformWalletError> { + let end = self + .pos + .checked_add(n) + .ok_or_else(|| invalid("invitation payload length overflow"))?; + if end > self.buf.len() { + return Err(invalid("invitation payload truncated")); + } + let out = &self.buf[self.pos..end]; + self.pos = end; + Ok(out) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u32(&mut self) -> Result { + let b = self.take(4)?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn arr32(&mut self) -> Result<[u8; 32], PlatformWalletError> { + let mut out = [0u8; 32]; + out.copy_from_slice(self.take(32)?); + Ok(out) + } + + fn len_prefixed(&mut self, max: usize) -> Result<&'a [u8], PlatformWalletError> { + let len = self.u32()? as usize; + if len > max { + return Err(invalid("invitation payload field exceeds size cap")); + } + self.take(len) + } + + fn string(&mut self) -> Result { + let bytes = self.len_prefixed(MAX_STR_BYTES)?; + String::from_utf8(bytes.to_vec()) + .map_err(|_| invalid("invitation payload string is not valid UTF-8")) + } + + fn finish(self) -> Result<(), PlatformWalletError> { + if self.pos != self.buf.len() { + return Err(invalid("unexpected trailing bytes in invitation payload")); + } + Ok(()) + } +} + +/// Encode an invitation into a `dashpay://invite?data=` link. +/// +/// The returned URI **contains the plaintext voucher key** — treat it as a +/// secret (do not log or persist it). +pub fn encode_invitation_uri( + voucher_key: &SecretKey, + asset_lock: &AssetLockProof, + expiry_unix: u32, + inviter: Option<&InviterInfo>, +) -> Result { + let asset_lock_bytes = dpp::bincode::encode_to_vec(asset_lock, config::standard()) + .map_err(|e| invalid(format!("failed to encode asset-lock proof: {e}")))?; + + let mut buf = Vec::with_capacity(64 + asset_lock_bytes.len()); + buf.push(INVITATION_PAYLOAD_VERSION); + buf.extend_from_slice(&voucher_key.secret_bytes()); + buf.extend_from_slice(&expiry_unix.to_le_bytes()); + match inviter { + Some(info) => { + if info.username.len() > MAX_STR_BYTES + || info + .display_name + .as_ref() + .is_some_and(|d| d.len() > MAX_STR_BYTES) + { + return Err(invalid("inviter username/display name too long")); + } + buf.push(1); + buf.extend_from_slice(&info.identity_id); + put_len_prefixed(&mut buf, info.username.as_bytes()); + match &info.display_name { + Some(d) => { + buf.push(1); + put_len_prefixed(&mut buf, d.as_bytes()); + } + None => buf.push(0), + } + } + None => buf.push(0), + } + put_len_prefixed(&mut buf, &asset_lock_bytes); + + Ok(format!( + "{INVITATION_URI_PREFIX}{}", + bs58::encode(&buf).into_string() + )) +} + +/// Parse a `dashpay://invite?data=` link into a [`ParsedInvitation`]. +/// +/// Bounds the base58 input before decoding and rejects trailing bytes, an +/// unsupported version, and malformed keys/proofs. Does **not** check expiry or +/// the credit-output binding — call [`validate_claimable`] for that before use. +pub fn parse_invitation_uri(uri: &str) -> Result { + let data = uri + .strip_prefix(INVITATION_URI_PREFIX) + .ok_or_else(|| invalid("not a dashpay://invite?data= URI"))?; + // Tolerate trailing query params after the payload (`…?data=X&foo=Y`). + let data = data.split('&').next().unwrap_or(data); + if data.len() > MAX_INVITATION_DATA_B58_LEN { + return Err(invalid(format!( + "invitation data too long ({} chars; max {MAX_INVITATION_DATA_B58_LEN})", + data.len() + ))); + } + let bytes = bs58::decode(data) + .into_vec() + .map_err(|e| invalid(format!("invitation data is not valid base58: {e}")))?; + if bytes.len() > MAX_INVITATION_PAYLOAD_BYTES { + return Err(invalid(format!( + "invitation payload too large ({} bytes; max {MAX_INVITATION_PAYLOAD_BYTES})", + bytes.len() + ))); + } + + let mut r = Reader::new(&bytes); + let version = r.u8()?; + if version != INVITATION_PAYLOAD_VERSION { + return Err(invalid(format!( + "unsupported invitation version {version} (expected {INVITATION_PAYLOAD_VERSION})" + ))); + } + let voucher_key = SecretKey::from_slice(r.take(32)?) + .map_err(|e| invalid(format!("invalid voucher private key: {e}")))?; + let expiry_unix = r.u32()?; + let inviter = match r.u8()? { + 0 => None, + 1 => { + let identity_id = r.arr32()?; + let username = r.string()?; + let display_name = match r.u8()? { + 0 => None, + 1 => Some(r.string()?), + other => return Err(invalid(format!("invalid display-name flag {other}"))), + }; + Some(InviterInfo { + identity_id, + username, + display_name, + }) + } + other => return Err(invalid(format!("invalid inviter-present flag {other}"))), + }; + let asset_lock_bytes = r.len_prefixed(MAX_INVITATION_PAYLOAD_BYTES)?; + let (asset_lock, consumed): (AssetLockProof, usize) = + dpp::bincode::decode_from_slice(asset_lock_bytes, config::standard()) + .map_err(|e| invalid(format!("failed to decode asset-lock proof: {e}")))?; + if consumed != asset_lock_bytes.len() { + return Err(invalid("trailing bytes in embedded asset-lock proof")); + } + r.finish()?; + + Ok(ParsedInvitation { + voucher_key, + asset_lock, + expiry_unix, + inviter, + }) +} + +/// Fail-fast validation before any network call (spec §8 Finding 5). +/// +/// Rejects a link whose advisory expiry has passed, whose proof is not an +/// InstantSend proof (per the owner's proof-type decision), or whose voucher key +/// does not control the funded credit output — turning an otherwise opaque +/// consensus rejection into a clear, local error. The credit-output binding is +/// itself consensus-enforced, so this is a UX guard, not a security boundary. +pub fn validate_claimable( + invitation: &ParsedInvitation, + now_unix: u32, +) -> Result<(), PlatformWalletError> { + if now_unix > invitation.expiry_unix { + return Err(invalid(format!( + "invitation expired (expiry {}, now {now_unix}) — ask the sender for a new one", + invitation.expiry_unix + ))); + } + let instant = match &invitation.asset_lock { + AssetLockProof::Instant(instant) => instant, + AssetLockProof::Chain(_) => { + return Err(invalid( + "invitation asset-lock proof must be an InstantSend proof", + )) + } + }; + let output = instant + .output() + .ok_or_else(|| invalid("asset-lock proof has no credit output at its output index"))?; + if output.script_pubkey != voucher_credit_script(&invitation.voucher_key) { + return Err(invalid( + "voucher key does not control the funded credit output", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::transaction::special_transaction::TransactionPayload; + use dashcore::{Transaction, TxOut}; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + + fn voucher() -> SecretKey { + SecretKey::from_slice(&[0x11u8; 32]).expect("valid scalar") + } + + fn inviter_info() -> InviterInfo { + InviterInfo { + identity_id: [0xAB; 32], + username: "alice".to_string(), + display_name: Some("Alice".to_string()), + } + } + + /// An InstantSend proof whose single credit output pays to `key`'s P2PKH. + fn instant_proof_paying_to(key: &SecretKey) -> AssetLockProof { + let credit = TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(key), + }; + let payload = AssetLockPayload { + version: 1, + credit_outputs: vec![credit], + }; + let tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), + }; + AssetLockProof::Instant(InstantAssetLockProof::new(InstantLock::default(), tx, 0)) + } + + fn proof_bytes(proof: &AssetLockProof) -> Vec { + dpp::bincode::encode_to_vec(proof, config::standard()).unwrap() + } + + #[test] + fn round_trip_with_inviter() { + let key = voucher(); + let proof = instant_proof_paying_to(&key); + let uri = encode_invitation_uri(&key, &proof, 1_800_000_000, Some(&inviter_info())) + .expect("encode"); + assert!(uri.starts_with(INVITATION_URI_PREFIX)); + + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert_eq!(parsed.voucher_key.secret_bytes(), key.secret_bytes()); + assert_eq!(parsed.expiry_unix, 1_800_000_000); + assert_eq!(parsed.inviter, Some(inviter_info())); + // Proof round-trips (compare re-encoded bytes — AssetLockProof is not Eq). + assert_eq!(proof_bytes(&parsed.asset_lock), proof_bytes(&proof)); + } + + #[test] + fn round_trip_pure_voucher_no_inviter() { + let key = voucher(); + let proof = instant_proof_paying_to(&key); + let uri = encode_invitation_uri(&key, &proof, 42, None).expect("encode"); + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert!(parsed.inviter.is_none()); + assert_eq!(parsed.expiry_unix, 42); + } + + #[test] + fn round_trip_inviter_without_display_name() { + let key = voucher(); + let proof = instant_proof_paying_to(&key); + let info = InviterInfo { + identity_id: [0x01; 32], + username: "bob".to_string(), + display_name: None, + }; + let uri = encode_invitation_uri(&key, &proof, 7, Some(&info)).expect("encode"); + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert_eq!(parsed.inviter, Some(info)); + } + + #[test] + fn rejects_wrong_scheme() { + assert!(parse_invitation_uri("https://invite?data=abc").is_err()); + assert!(parse_invitation_uri("dashpay://contact?data=abc").is_err()); + } + + #[test] + fn rejects_bad_base58() { + // '0','O','I','l' are not in the base58 alphabet. + let err = parse_invitation_uri("dashpay://invite?data=0OIl").unwrap_err(); + assert!(err.to_string().contains("base58")); + } + + #[test] + fn rejects_oversized_data_before_decoding() { + let huge = "z".repeat(MAX_INVITATION_DATA_B58_LEN + 1); + let uri = format!("{INVITATION_URI_PREFIX}{huge}"); + let err = parse_invitation_uri(&uri).unwrap_err(); + assert!(err.to_string().contains("too long")); + } + + #[test] + fn rejects_trailing_bytes() { + let key = voucher(); + let proof = instant_proof_paying_to(&key); + let uri = encode_invitation_uri(&key, &proof, 1, None).expect("encode"); + let data = uri.strip_prefix(INVITATION_URI_PREFIX).unwrap(); + let mut bytes = bs58::decode(data).into_vec().unwrap(); + bytes.push(0x00); + let tampered = format!( + "{INVITATION_URI_PREFIX}{}", + bs58::encode(&bytes).into_string() + ); + let err = parse_invitation_uri(&tampered).unwrap_err(); + assert!(err.to_string().contains("trailing")); + } + + #[test] + fn rejects_unsupported_version() { + let key = voucher(); + let proof = instant_proof_paying_to(&key); + let uri = encode_invitation_uri(&key, &proof, 1, None).expect("encode"); + let data = uri.strip_prefix(INVITATION_URI_PREFIX).unwrap(); + let mut bytes = bs58::decode(data).into_vec().unwrap(); + bytes[0] = 99; // corrupt the version byte + let tampered = format!( + "{INVITATION_URI_PREFIX}{}", + bs58::encode(&bytes).into_string() + ); + let err = parse_invitation_uri(&tampered).unwrap_err(); + assert!(err.to_string().contains("unsupported invitation version")); + } + + #[test] + fn rejects_truncated_payload() { + let key = voucher(); + let proof = instant_proof_paying_to(&key); + let uri = encode_invitation_uri(&key, &proof, 1, None).expect("encode"); + let data = uri.strip_prefix(INVITATION_URI_PREFIX).unwrap(); + let bytes = bs58::decode(data).into_vec().unwrap(); + // Drop the tail so the embedded proof length prefix overruns. + let truncated = format!( + "{INVITATION_URI_PREFIX}{}", + bs58::encode(&bytes[..bytes.len() - 5]).into_string() + ); + assert!(parse_invitation_uri(&truncated).is_err()); + } + + #[test] + fn validate_ok_for_fresh_matching_instant_proof() { + let key = voucher(); + let parsed = ParsedInvitation { + voucher_key: key, + asset_lock: instant_proof_paying_to(&key), + expiry_unix: 2_000_000_000, + inviter: None, + }; + assert!(validate_claimable(&parsed, 1_000_000_000).is_ok()); + } + + #[test] + fn validate_rejects_expired() { + let key = voucher(); + let parsed = ParsedInvitation { + voucher_key: key, + asset_lock: instant_proof_paying_to(&key), + expiry_unix: 1_000, + inviter: None, + }; + let err = validate_claimable(&parsed, 2_000).unwrap_err(); + assert!(err.to_string().contains("expired")); + } + + #[test] + fn validate_rejects_chain_proof() { + let key = voucher(); + let chain = AssetLockProof::Chain(ChainAssetLockProof::new(42, [0x7u8; 36])); + let parsed = ParsedInvitation { + voucher_key: key, + asset_lock: chain, + expiry_unix: 2_000_000_000, + inviter: None, + }; + let err = validate_claimable(&parsed, 1).unwrap_err(); + assert!(err.to_string().contains("InstantSend")); + } + + #[test] + fn validate_rejects_voucher_not_controlling_output() { + let key = voucher(); + let other = SecretKey::from_slice(&[0x22u8; 32]).unwrap(); + // Proof pays to `other`, but the parsed voucher key is `key`. + let parsed = ParsedInvitation { + voucher_key: key, + asset_lock: instant_proof_paying_to(&other), + expiry_unix: 2_000_000_000, + inviter: None, + }; + let err = validate_claimable(&parsed, 1).unwrap_err(); + assert!(err.to_string().contains("does not control")); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index 74b70c27732..4312c663bd1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -6,6 +6,7 @@ pub mod auto_accept; pub mod contact_info; pub mod dip14; +pub mod invitation; pub mod validation; pub use auto_accept::derive_auto_accept_private_key; @@ -17,4 +18,7 @@ pub use dip14::{ calculate_account_reference, derive_contact_payment_address, derive_contact_payment_addresses, derive_contact_xpub, unmask_account_reference, ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, }; +pub use invitation::{ + encode_invitation_uri, parse_invitation_uri, validate_claimable, InviterInfo, ParsedInvitation, +}; pub use validation::pubkey_binds_expected_key_data; From c60b3e3a8249d00da043d98059c7a83be86c57d5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 17:04:41 +0700 Subject: [PATCH 03/74] feat(platform-wallet): path-gated invitation voucher-key export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rs-platform-wallet-ffi/src/dashpay.rs | 14 ++++ .../identity/network/contact_requests.rs | 24 ++++++ .../src/wallet/identity/network/payments.rs | 9 +++ .../src/mnemonic_resolver_core_signer.rs | 78 +++++++++++++++++++ 4 files changed, 125 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 785ee47db52..a494dc005ee 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -664,6 +664,20 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { .map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string())) } + async fn export_invitation_private_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + let scalar = self + .signer + .export_invitation_private_key(path) + .map_err(|e| { + platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string()) + })?; + dashcore::secp256k1::SecretKey::from_slice(scalar.as_ref()) + .map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string())) + } + async fn account_reference( &self, path: &key_wallet::bip32::DerivationPath, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 0b5c6221149..7b34f220d6f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -65,6 +65,20 @@ pub trait ContactCryptoProvider { path: &key_wallet::bip32::DerivationPath, ) -> Result; + /// Export the raw **invitation-funding private key** at `path` (DIP-13 + /// sub-feature `3'`) — the second deliberate raw-key export (alongside + /// [`Self::export_auto_accept_private_key`]). The invitation hands this + /// one-time voucher key to the invitee so they can register their own + /// identity from the funded asset lock, so it must leave the signer. `path` + /// MUST be an invitation path (`m/9'/coin'/5'/3'/funding_index'`); the signer + /// gates on the full shape (feature `5'` is shared with the user's own + /// identity keys — see `export_invitation_private_key` on the resolver + /// signer). The only caller is [`IdentityWallet::create_invitation`]. + async fn export_invitation_private_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result; + /// DIP-15 `accountReference` for a send: the scalar at `path` (the sender's /// encryption key) keys the HMAC+mask over `compact_xpub`. Computed in the /// signer so the raw scalar never returns to platform-wallet. @@ -195,6 +209,16 @@ impl ContactCryptoProvider for SeedCryptoProvider { Ok(xprv.private_key) } + async fn export_invitation_private_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + let xprv = self.wallet.derive_extended_private_key(path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test export invitation: {e}")) + })?; + Ok(xprv.private_key) + } + async fn account_reference( &self, path: &key_wallet::bip32::DerivationPath, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 0d77468dae3..0db12b2413b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -3051,6 +3051,15 @@ mod tests { { unimplemented!("auto-accept QR is a send-path method, not exercised by the drain") } + async fn export_invitation_private_key( + &self, + _path: &key_wallet::bip32::DerivationPath, + ) -> Result + { + unimplemented!( + "invitation create is a send-path method, not exercised by the drain" + ) + } async fn account_reference( &self, _path: &key_wallet::bip32::DerivationPath, diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index c8f16cc187c..20e725c4835 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -367,6 +367,43 @@ impl MnemonicResolverCoreSigner { self.derive_priv(path) } + /// Export the raw DIP-13 invitation-funding private scalar at `path` + /// (`m/9'/coin_type'/5'/3'/funding_index'`) — the second deliberate raw-key + /// export from this signer. An invitation hands the one-time voucher key to + /// the invitee so they can register their own identity from the funded asset + /// lock, so this key must leave the signer (a scoped, documented bearer + /// credential like the auto-accept `dapk`). + /// + /// Gated to the **exact** invitation sub-feature: `path` MUST have 5 + /// components with `9'` purpose, `5'` identity feature, and `3'` invitation + /// sub-feature. This is deliberately stricter than checking the feature + /// alone — feature `5'` is shared with identity authentication (`5'/0'`), + /// registration funding (`5'/1'`), and top-up (`5'/2'`), so a looser gate + /// could be repurposed to exfiltrate the user's own identity keys. Returns + /// the 32-byte scalar `Zeroizing`-wrapped. + pub fn export_invitation_private_key( + &self, + path: &DerivationPath, + ) -> Result, MnemonicResolverSignerError> { + let purpose9 = ChildNumber::from_hardened_idx(9) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; + let feature5 = ChildNumber::from_hardened_idx(5) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; + let subfeature3 = ChildNumber::from_hardened_idx(3) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; + let comps: &[ChildNumber] = path.as_ref(); + if comps.len() != 5 + || comps[0] != purpose9 + || comps[2] != feature5 + || comps[3] != subfeature3 + { + return Err(MnemonicResolverSignerError::DerivationFailed( + "export_invitation_private_key: path is not an invitation-funding path".to_string(), + )); + } + self.derive_priv(path) + } + /// Compute the DIP-15 ECDH shared secret between our identity-encryption /// key (derived at `path`) and the contact's `peer_pubkey`, entirely /// in-process. The derived private scalar never leaves this function — @@ -717,6 +754,47 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + /// The invitation export gate is stricter than the auto-accept one: it must + /// bind the full `9'/coin'/5'/3'/idx'` shape, because feature `5'` is shared + /// with the user's own identity-auth / registration-funding / top-up keys — + /// a looser gate would be a key-exfiltration hole (spec §5.3 Finding 2). + #[test] + fn export_invitation_private_key_gates_to_the_invitation_path() { + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + // A well-formed invitation-funding path exports its 32-byte scalar. + let invitation = DerivationPath::from_str("m/9'/1'/5'/3'/0'").expect("valid path"); + let scalar = signer + .export_invitation_private_key(&invitation) + .expect("a well-formed invitation path exports its scalar"); + assert_ne!(*scalar, [0u8; 32], "exported scalar must be non-zero"); + + // Every non-invitation path MUST be rejected — especially the sibling + // sub-features that share feature `5'` (auth/registration/top-up). + for bad in [ + "m/9'/1'/5'/0'/0'/0'/0'", // identity authentication (sub-feature 0') + "m/9'/1'/5'/1'/0'", // registration funding (sub-feature 1') + "m/9'/1'/5'/2'/0'", // top-up funding (sub-feature 2') + "m/9'/1'/16'/123'", // auto-accept (feature 16', not 5') + "m/8'/1'/5'/3'/0'", // wrong purpose (comps[0] != 9') + "m/9'/1'/5'/3'", // too short (len != 5) + "m/9'/1'/5'/3'/0'/0'", // too long (len != 5) + ] { + let path = DerivationPath::from_str(bad).expect("valid path string"); + assert!( + matches!( + signer.export_invitation_private_key(&path), + Err(MnemonicResolverSignerError::DerivationFailed(_)) + ), + "non-invitation path {bad} must be rejected, not exported" + ); + } + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + #[tokio::test] async fn public_key_matches_sign_ecdsa_pubkey() { let resolver = make_resolver(english_resolve); From 9e40ee81efafba2903b329951ad7056a73671477 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 17:11:37 +0700 Subject: [PATCH 04/74] feat(platform-wallet): invitation create + claim flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/wallet/identity/network/invitation.rs | 293 ++++++++++++++++++ .../src/wallet/identity/network/mod.rs | 2 + 2 files changed, 295 insertions(+) create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs new file mode 100644 index 00000000000..cf1479804f1 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -0,0 +1,293 @@ +//! DashPay invitation create + claim flows (DIP-13 sub-feature 3'). +//! +//! - [`create_invitation`](IdentityWallet::create_invitation) (inviter): fund a +//! one-time asset-lock voucher at the invitation derivation path, export the +//! voucher key, and package a `dashpay://invite` link. +//! - [`claim_invitation`](IdentityWallet::claim_invitation) (invitee): register +//! a new identity funded by the imported voucher — ordinary identity +//! registration whose asset-lock signature uses the imported raw voucher key +//! instead of a wallet-derived one. +//! +//! The contact-bootstrap ("and now we're contacts") is intentionally NOT done +//! here: after a successful claim the UI asks the invitee whether to establish +//! contact with the sender and, if so, calls the existing contact-request path +//! ([`send_contact_request_with_external_signer`](IdentityWallet::send_contact_request_with_external_signer)). +//! See `docs/dashpay/DIP15_INVITATIONS_SPEC.md`. + +use std::collections::BTreeMap; + +use dpp::dashcore::PrivateKey; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::v0::IdentityV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose, SecurityLevel}; +use dpp::prelude::Identifier; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + +use dash_sdk::platform::transition::put_identity::PutIdentity; +use dash_sdk::platform::transition::put_settings::PutSettings; + +use crate::error::PlatformWalletError; +use crate::wallet::asset_lock::orchestration::submit_with_cl_height_retry; +use crate::wallet::identity::crypto::{encode_invitation_uri, validate_claimable}; +use crate::wallet::identity::crypto::{InviterInfo, ParsedInvitation}; +use crate::wallet::identity::network::contact_requests::ContactCryptoProvider; + +use super::*; + +/// Hard cap on the amount an invitation can lock (0.01 DASH). The voucher is a +/// bearer credential, so the blast radius of a leaked link is bounded here in +/// Rust — not just in the UI (spec §8 Finding 4). Generous enough for identity +/// registration plus a small starting balance; tune if onboarding needs more. +pub const MAX_INVITATION_DUFFS: u64 = 1_000_000; + +/// A freshly-created invitation: the shareable link plus the bookkeeping the +/// inviter tracks to reclaim an unclaimed voucher. +pub struct Invitation { + /// The `dashpay://invite?data=…` link. **Contains the voucher key** — treat + /// as a secret (never log or persist it). + pub uri: String, + /// The funding asset lock's outpoint (the tracked lock's identity). + pub out_point: dashcore::OutPoint, + /// Amount locked (duffs). + pub amount_duffs: u64, + /// Advisory expiry (unix seconds). + pub expiry_unix: u32, +} + +impl std::fmt::Debug for Invitation { + /// Redacts the URI — it embeds the bearer voucher key. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Invitation") + .field("uri", &"") + .field("out_point", &self.out_point) + .field("amount_duffs", &self.amount_duffs) + .field("expiry_unix", &self.expiry_unix) + .finish() + } +} + +/// Pre-flight the caller-supplied identity keys map: id=0 must be a MASTER-level +/// AUTHENTICATION key (it signs the IdentityCreate transition). Mirrors +/// `register_identity_with_funding`. +fn preflight_keys_map( + keys_map: &BTreeMap, +) -> Result<(), PlatformWalletError> { + if keys_map.is_empty() { + return Err(PlatformWalletError::InvalidIdentityData( + "keys_map must contain at least one identity public key".to_string(), + )); + } + match keys_map.get(&0) { + Some(k) + if k.security_level() == SecurityLevel::MASTER + && k.purpose() == Purpose::AUTHENTICATION => {} + Some(_) => { + return Err(PlatformWalletError::InvalidIdentityData( + "keys_map[0] must be a MASTER-level AUTHENTICATION key \ + (required to sign the IdentityCreate transition)" + .to_string(), + )) + } + None => { + return Err(PlatformWalletError::InvalidIdentityData( + "keys_map must include key id=0 with MASTER security level".to_string(), + )) + } + } + Ok(()) +} + +impl IdentityWallet { + /// Create a DashPay invitation: fund a one-time asset-lock voucher at the + /// DIP-13 invitation path and return a shareable `dashpay://invite` link. + /// + /// `asset_lock_signer` funds + signs the asset lock (the funding-input P2PKH + /// signatures and the credit-output pubkey); `crypto_provider` exports the + /// one-time voucher **private** key at the funding path (path-gated to the + /// invitation sub-feature — see + /// [`ContactCryptoProvider::export_invitation_private_key`]). In the FFI both + /// are the same Keychain-resolver-backed signer. + /// + /// `expiry_unix` is an advisory bound; the caller (FFI) is responsible for + /// clamping it to `now + MAX_INVITATION_TTL`. `inviter` is `Some` only when + /// the inviter opted in to the contact-bootstrap ("send a request back"). + /// + /// The proof is kept as an **InstantSend** proof (owner decision) — fast, and + /// the embedded tx + islock make the link self-contained; staleness is + /// bounded by the short advisory expiry, not a CL upgrade. + pub async fn create_invitation( + &self, + amount_duffs: u64, + funding_account_index: u32, + inviter: Option, + expiry_unix: u32, + asset_lock_signer: &AS, + crypto_provider: &CP, + ) -> Result + where + AS: ::key_wallet::signer::Signer + Send + Sync, + CP: ContactCryptoProvider + Send + Sync, + { + if amount_duffs == 0 { + return Err(PlatformWalletError::InvalidIdentityData( + "invitation amount must be greater than zero".to_string(), + )); + } + if amount_duffs > MAX_INVITATION_DUFFS { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "invitation amount {amount_duffs} exceeds the cap {MAX_INVITATION_DUFFS} duffs" + ))); + } + + // Build + broadcast the voucher asset lock at the invitation funding + // account (the builder auto-selects the next unused funding index and + // returns its derivation path). `identity_index` is unused for the + // `IdentityInvitation` funding type. + let (proof, path, out_point) = self + .asset_locks + .create_funded_asset_lock_proof( + amount_duffs, + funding_account_index, + AssetLockFundingType::IdentityInvitation, + 0, + asset_lock_signer, + ) + .await?; + + // Export the one-time voucher private key at the funding path. This is + // the one deliberate raw-key export (the whole point of an invitation); + // it is path-gated to the invitation sub-feature inside the provider. + let voucher_key = crypto_provider.export_invitation_private_key(&path).await?; + + let uri = encode_invitation_uri(&voucher_key, &proof, expiry_unix, inviter.as_ref())?; + + Ok(Invitation { + uri, + out_point, + amount_duffs, + expiry_unix, + }) + } + + /// Claim a DashPay invitation: register a NEW identity for the invitee, + /// funded by the imported voucher. + /// + /// The invitee's own identity keys (`keys_map`, derived from the invitee's + /// seed) are signed by `identity_signer`; the asset-lock's outer + /// state-transition signature is produced from the **imported voucher key** + /// (`invitation.voucher_key`) via the SDK's in-process raw-key path. The + /// invitee owns neither the lock's inputs nor its tracking, so this bypasses + /// the wallet's `AssetLockFunding` machinery entirely. + /// + /// The contact-bootstrap is a separate step: on success the UI asks the + /// invitee whether to establish contact with the sender and, if so, calls + /// the existing contact-request path. + pub async fn claim_invitation( + &self, + invitation: ParsedInvitation, + identity_index: u32, + keys_map: BTreeMap, + identity_signer: &S, + now_unix: u32, + settings: Option, + ) -> Result + where + S: Signer + Send + Sync, + { + // Fail fast on a stale / wrong-type / mismatched link before any network. + validate_claimable(&invitation, now_unix)?; + preflight_keys_map(&keys_map)?; + + // The voucher key signs the asset lock's outer ST signature (ECDSA over + // the credit-output pubkey hash). Convert to the SDK's `PrivateKey`. + let network = self.sdk.network; + let voucher_priv = PrivateKey::new(invitation.voucher_key, network); + + let placeholder = Identity::V0(IdentityV0 { + id: Identifier::default(), + public_keys: keys_map, + balance: 0, + revision: 0, + }); + + // Submit with the CL-height-too-low retry layer. The direct raw-key SDK + // call doesn't inherit `register_identity_with_funding`'s retry layers, + // so wrap it here (a transient 10506 would otherwise hard-fail the claim). + let identity = submit_with_cl_height_retry(settings, |s| { + placeholder.put_to_platform_and_wait_for_response_with_private_key( + &self.sdk, + invitation.asset_lock.clone(), + &voucher_priv, + identity_signer, + s, + ) + }) + .await + .map_err(PlatformWalletError::Sdk)?; + + // Best-effort local bookkeeping — Platform has already accepted the + // registration, so a local failure must NOT propagate (mirrors + // `register_identity_with_funding` Step 4). The identity self-heals into + // the IdentityManager on the next re-sync if this is skipped. + { + let identity_id = identity.id(); + let mut wm = self.wallet_manager.write().await; + match wm.get_wallet_info_mut(&self.wallet_id) { + Some(info) => { + match info.identity_manager.add_identity( + identity.clone(), + identity_index, + self.wallet_id, + &self.persister, + ) { + Ok(()) => { + let wallet_id = self.wallet_id; + let public_keys: Vec<(KeyID, IdentityPublicKey)> = identity + .public_keys() + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(); + if let Some(managed) = + info.identity_manager.managed_identity_mut(&identity_id) + { + managed.wallet_id = Some(wallet_id); + for (key_id, pub_key) in public_keys { + if let Err(e) = managed.add_key( + pub_key, + Some((wallet_id, identity_index, key_id)), + &self.persister, + ) { + tracing::warn!( + error = %e, + %identity_id, + "claim_invitation: identity key breadcrumb not persisted" + ); + } + } + } + } + Err(e) => { + tracing::warn!( + error = %e, + %identity_id, + "claim_invitation: identity registered on Platform but local \ + add_identity failed; it will self-heal on the next re-sync" + ); + } + } + } + None => { + tracing::warn!( + %identity_id, + "claim_invitation: identity registered on Platform but wallet info \ + was not found locally; skipping local persistence" + ); + } + } + } + + Ok(identity) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index fb3615934b0..88086179f71 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -40,6 +40,8 @@ mod contact_info; mod contact_requests; mod contacts; mod dashpay_view; +mod invitation; +pub use invitation::{Invitation, MAX_INVITATION_DUFFS}; mod payment_handler; pub(crate) use payment_handler::DashPayPaymentHandler; // Re-exported for the payments unit tests, which drive the hooks From 5039946c9a0f7da63f9d62345427dc9e11f0c15b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 17:36:38 +0700 Subject: [PATCH 05/74] fix(platform-wallet): fold invitation Rust-core review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/dashpay/DIP15_INVITATIONS_SPEC.md | 33 +++++++++---- .../src/wallet/identity/crypto/invitation.rs | 27 +++++++--- .../src/wallet/identity/network/invitation.rs | 49 ++++++++++++++----- .../src/wallet/identity/network/mod.rs | 2 +- 4 files changed, 83 insertions(+), 28 deletions(-) diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md index c09776ceea9..c097e69219f 100644 --- a/docs/dashpay/DIP15_INVITATIONS_SPEC.md +++ b/docs/dashpay/DIP15_INVITATIONS_SPEC.md @@ -294,6 +294,16 @@ returns exactly this for a fresh tx (its `validate_or_upgrade_proof` only upgrad tx is *old* — not the case at create), so the invitation path **keeps the IS proof, no forced CL upgrade**. +> **Slow-IS fallback must be enforced (Rust-core review H1).** `create_funded_asset_lock_proof` +> *also* falls back to a ChainLock proof if the IS lock doesn't propagate within its 300s +> preference window. Since the invitee's `validate_claimable` accepts only an InstantSend proof, +> `create_invitation` **must reject a returned ChainLock proof** — else it would emit a +> `dashpay://invite` link the invitee silently rejects (a dead voucher: funds locked, no signal). +> On this rare path create returns a clear error; the funding lock stays tracked/reclaimable, and +> the inviter retries. *(A future robustness option is to accept a Chain proof on claim too — +> it never goes stale — skipping the local credit-output pre-check since a Chain proof carries no +> embedded tx; deferred, as it deviates from the literal Instant-only decision.)* + **Staleness mitigation = a short, IS-scoped advisory expiry (not an IS→CL upgrade in v1).** The one real risk is that Platform rejects a *stale* islock (quorum rotated). Rather than build an invitee-side IS→CL upgrade (which needs the embedded tx re-tracked — non-trivial, and the @@ -428,17 +438,20 @@ non-malleable, double-claim is deterministic, the invitee never risks its own fu in-flight claim, and two racers target the *same* id (consensus commits exactly one). Every claim-theft attack reduces to **"who holds the link."** The invitee's own identity keys sign the per-key witnesses separately. -- **Bearer credential — must-fix hardening:** - - **Amount cap enforced in Rust (Finding 4), not just UI.** `create_invitation` rejects - `amount_duffs > MAX_INVITATION_DUFFS`. The "small blast radius" argument fails if the cap is - UI-only (bypassable by a direct FFI caller / headless host / UI bug). - - **Advisory expiry on the voucher (Finding 3).** The voucher key has no on-chain expiry, so a - leak is a *permanent* claim until consumed. The payload's `expiry_unix` bounds the practical - leak window: the claim path refuses a past-expiry link, and the inviter is prompted to reclaim - after expiry. It is advisory (not consensus), but it bounds both the leak and the reclaim - window. `expiry` is capped at `now + MAX_INVITATION_TTL` at create. +- **Bearer credential — the load-bearing leak mitigation is the amount cap + reclaim, NOT the + expiry (Rust-security-review LOW-2 honesty fix):** + - **Amount cap enforced in Rust (Finding 4).** `create_invitation` rejects + `amount_duffs > MAX_INVITATION_DUFFS` — the *actual* bound on a leaked link's blast radius + (a direct FFI caller / headless host / UI bug can't exceed it). Never UI-only. + - **Expiry is a UX / reclaim signal, not a leak bound.** A malicious *finder* of a leaked link + holds the voucher key + proof and can submit directly, **ignoring the honest UI's expiry + check** — so `expiry_unix` does not bound a leaked-link window. What it *does* do: (a) stop an + **honest** invitee from submitting an about-to-go-stale IS proof (§5.1), and (b) give the + inviter a clear reclaim-after signal. Advisory, not consensus. (The FFI sets a sensible + default expiry from `MAX_INVITATION_TTL_SECS`; clamping it in Rust is symmetry, not security.) - **Single-use** (asset lock consumed on first claim → deterministic reject thereafter), funds - are the inviter's to give. + are the inviter's to give; the inviter can race to **reclaim** an unclaimed voucher (a race it + can lose if already leaked — §8 Finding 6). - **The link is plaintext key material — treat the URI as secret end-to-end (Finding 3).** The create FFI returns the URI (which *contains* the voucher key) as a C string that flows through Swift + a `dashpay://invite` deep-link handler (handlers routinely log URLs) + clipboard diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs index 1af94aab8a4..2d3d4b7fe22 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -27,6 +27,7 @@ use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; use dashcore::ScriptBuf; use dpp::bincode::config; use dpp::prelude::AssetLockProof; +use zeroize::Zeroizing; use crate::error::PlatformWalletError; @@ -79,6 +80,14 @@ pub struct ParsedInvitation { pub inviter: Option, } +impl Drop for ParsedInvitation { + /// Scrub the voucher scalar on drop — it is literal bearer money. Mirrors + /// the resolver signer's key hygiene (`WipingSecretKey`). + fn drop(&mut self) { + self.voucher_key.non_secure_erase(); + } +} + impl std::fmt::Debug for ParsedInvitation { /// Redacts the voucher key — the whole point of the type is to carry a /// bearer secret, which must never reach a log. @@ -186,7 +195,9 @@ pub fn encode_invitation_uri( let asset_lock_bytes = dpp::bincode::encode_to_vec(asset_lock, config::standard()) .map_err(|e| invalid(format!("failed to encode asset-lock proof: {e}")))?; - let mut buf = Vec::with_capacity(64 + asset_lock_bytes.len()); + // Zeroized: `buf` holds the plaintext voucher scalar until it is base58'd + // into the (secret) URI; scrub the intermediate on drop. + let mut buf = Zeroizing::new(Vec::with_capacity(64 + asset_lock_bytes.len())); buf.push(INVITATION_PAYLOAD_VERSION); buf.extend_from_slice(&voucher_key.secret_bytes()); buf.extend_from_slice(&expiry_unix.to_le_bytes()); @@ -217,7 +228,7 @@ pub fn encode_invitation_uri( Ok(format!( "{INVITATION_URI_PREFIX}{}", - bs58::encode(&buf).into_string() + bs58::encode(buf.as_slice()).into_string() )) } @@ -238,9 +249,13 @@ pub fn parse_invitation_uri(uri: &str) -> Result MAX_INVITATION_PAYLOAD_BYTES { return Err(invalid(format!( "invitation payload too large ({} bytes; max {MAX_INVITATION_PAYLOAD_BYTES})", @@ -248,7 +263,7 @@ pub fn parse_invitation_uri(uri: &str) -> Result Date: Wed, 8 Jul 2026 17:40:08 +0700 Subject: [PATCH 06/74] test(qa): add DIP-13 invitation scenarios to the example-app QA contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 24ad1e6961a..67b5e6d2a57 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -291,6 +291,10 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | DP-09 | Publish encrypted on-chain `contactInfo` (private contact metadata) | Platform | Thorough | ✅ | | DIP-15 §10. `ContactDetailView` → edit **Alias** / **Note** / **Hide contact** (`dashpay.detail.aliasEdit` / `dashpay.detail.noteEdit` / `dashpay.detail.hideToggle`) → `saveContactInfo` → `platform_wallet_set_dashpay_contact_info_with_signer` (ECB `encToUserId` + CBC `privateData`). These fields are locally cached **and** published encrypted to Platform once the identity has **≥2 established contacts** (stated in the in-app footer) → outcomes `.published` / `.deferredUntilTwoContacts` / `.skippedWatchOnly`. | | DP-10 | Incoming-payment backfill rescan (restore-from-seed / pre-watch window) | Cross | Manual | ✅ | regression | DIP-15 §8.7 / §12.6 (on the DIP-16 SPV base). No UI trigger — automatic in DashPay sync: `reconcile_dashpay_rescan` lowers SPV `synced_height` to `min($coreHeightCreatedAt)` across new receival contacts so the filter manager backfills. Pass: a DashPay payment that landed on a contact's address **before** it was watched (restore-from-seed / second device / the offline-accept→pay window) appears after restore + SPV sync. Environment-limited (must construct the skew window); the regression pin for the §12.6 payment-loss gap. | | DP-11 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | multiwallet | A's identity sends a contact request (`DP-01`) to B's; switch to wallet B's identity and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop entirely local. | +| DP-12 | Create invitation (DIP-13) | Cross | Common | 🔌 | funding | DashPay → **Create invitation** (planned `dashpay.invite.create`, beside "Add me QR" in `DashPayProfileView`) → amount entry + **"send a contact request back to me"** checkbox → `createInvitation` → `platform_wallet_create_invitation`. Builds an **InstantSend** asset-lock voucher at the DIP-13 invitation funding path (`3'`), amount Rust-capped at `MAX_INVITATION_DUFFS`, and returns a `dashpay://invite?data=…` link rendered as a QR + share sheet. Builds an **L1 asset lock** → needs the Core SPV client running + **testnet funds** (fund via Wallet → Receive → "request from testnet"). The link embeds a one-time voucher **private key** — a bearer credential; the UI must not log it and should flag the pasteboard sensitive. `🔌` until the UI lands. | +| DP-13 | Claim invitation (DIP-13) | Platform | Common | 🔌 | | Paste/scan a `dashpay://invite` link → claim sheet (planned `dashpay.invite.claim`, mirroring `AddViaQRSheet`) → `claimInvitation` → `platform_wallet_claim_invitation`. Registers a **new identity for the invitee funded by the imported voucher** (no L1 Dash on the invitee side; the asset-lock signature uses the imported voucher key). If the link carries inviter info, prompt **"establish contact with \?"** → on confirm, send the existing contact request (`DP-01` path). New identity lands in Identities; optional contact in Contacts. `🔌` until the UI lands. | +| DP-14 | Invite → claim two-wallet e2e | Cross | Thorough | 🔌 | multiwallet | The feature's acceptance gate. Wallet A (funded, SPV running) creates an invitation (`DP-12`); wallet B (no funds) claims it (`DP-13`) → B gains a funded identity with no L1 Dash; if the inviter opted into the bootstrap **and** the invitee confirms, the contact establishes on both ends (cf. `DP-11`). Requires testnet funding + both wallets on the same network. `🔌` until the UI lands. | +| DP-15 | Reject malformed / reused / expired invitation | Platform | Uncommon | 🔌 | | Negative paths all fail loudly with a clear message and no side effects: a malformed link (wrong scheme / non-base58 / truncated), a **reused** link (asset lock already consumed → deterministic "invitation already used"), and a **past-expiry** link (`validate_claimable` refuses before any network call). `🔌` until the UI lands. | ### 4.11 System / Protocol / Diagnostics — `Domain=System` @@ -349,12 +353,12 @@ Each row's **primary home** is its §4 section, but a few rows are cross-cutting - **Document** — `DOC-01..15` - **Token** — `TOK-01..20` - **Shielded** — `SH-01..16`, `CORE-21` -- **DashPay** — `DP-01..11` +- **DashPay** — `DP-01..15` (`DP-12..15` = DIP-13 invitations, `🔌` until the UI lands) - **System / Diagnostics** — `SYS-01..08` **By tag (cross-cutting, the Tags column):** -- **multiwallet** — `CORE-14..23`, `ID-14`, `ID-15`, `TOK-17`, `DPNS-08`, `DP-11`, `DOC-15`, `SH-14`, `SH-15`, `SH-16`, `SYS-07`, `SYS-08` +- **multiwallet** — `CORE-14..23`, `ID-14`, `ID-15`, `TOK-17`, `DPNS-08`, `DP-11`, `DP-14`, `DOC-15`, `SH-14`, `SH-15`, `SH-16`, `SYS-07`, `SYS-08` - **group** — `TOK-15`, `TOK-16`, `TOK-18`, `TOK-19`, `TOK-20` - **contested** — `DPNS-05`, `DPNS-08`, `VOTE-01..07` - **withdrawal** — `ID-10`, `ADDR-04`, `SH-08`, `SH-16` From f59edf62c1ba1598ed48274768704119ea555222 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 17:46:47 +0700 Subject: [PATCH 07/74] feat(platform-wallet): invitation create + claim FFI 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 --- .../rs-platform-wallet-ffi/src/invitation.rs | 477 ++++++++++++++++++ packages/rs-platform-wallet-ffi/src/lib.rs | 2 + 2 files changed, 479 insertions(+) create mode 100644 packages/rs-platform-wallet-ffi/src/invitation.rs diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs new file mode 100644 index 00000000000..47035714aa1 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -0,0 +1,477 @@ +//! FFI bindings for DashPay invitations (DIP-13 sub-feature 3'). +//! +//! Two entry points wrap +//! [`IdentityWallet`](platform_wallet::IdentityWallet)'s invitation flow: +//! +//! - [`platform_wallet_create_invitation`] (inviter) — fund a one-time +//! asset-lock voucher at the invitation derivation path, export the voucher +//! key, and return a shareable `dashpay://invite` link. **Only the Core-side +//! resolver signer is needed** (no identity signer): this is pure voucher +//! creation, no identity is registered. The single resolver handle is used +//! twice — as the asset-lock signer (funding-input + credit-output +//! signatures) and, wrapped as a [`ContactCryptoProvider`], to export the +//! voucher private key at the path-gated invitation sub-feature. +//! - [`platform_wallet_claim_invitation`] (invitee) — parse the link and +//! register a NEW identity for the invitee funded by the imported voucher. +//! The invitee's own identity keys are signed by the supplied `SignerHandle`; +//! the asset-lock's outer signature uses the imported raw voucher key, so no +//! Core-side resolver signer is needed here. +//! +//! The link returned by create **contains the plaintext voucher key** — it is a +//! bearer credential. Callers MUST NOT log or persist it (mirrors the treatment +//! of the auto-accept `dapk` URI in [`crate::dashpay`]). +//! +//! Marshaling mirrors +//! [`crate::identity_registration_funded_with_signer`] (signer-handle `usize` +//! round-trip, `block_on_worker`, `MANAGED_IDENTITY_STORAGE` insert). + +use std::ffi::CStr; +use std::os::raw::c_char; + +use dpp::identity::accessors::IdentityGettersV0; +use platform_wallet::wallet::identity::crypto::{parse_invitation_uri, InviterInfo}; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; + +use platform_wallet::wallet::identity::network::MAX_INVITATION_TTL_SECS; + +use crate::core_wallet_types::OutPointFFI; +use crate::dashpay::resolver_contact_crypto_provider; +use crate::error::*; +use crate::handle::*; +use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI}; +use crate::runtime::block_on_worker; +use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; + +/// Create a DashPay invitation: fund a one-time asset-lock voucher at the +/// DIP-13 invitation path and return a shareable `dashpay://invite` link. +/// +/// `inviter_identity_id` / `inviter_username` are **optional**: pass a non-null +/// 32-byte `inviter_identity_id` to opt into the contact-bootstrap (the link +/// then carries the inviter so the invitee can send a contact request back), in +/// which case `inviter_username` is **required** (non-null). Pass a null +/// `inviter_identity_id` for a pure funding voucher; `inviter_username` is then +/// ignored. The optional display name is not carried through this FFI (`None`). +/// +/// `now_unix` is the current unix time in seconds, passed in from Swift (the +/// FFI can't read the clock deterministically). The advisory expiry is derived +/// as `now_unix + MAX_INVITATION_TTL_SECS` (a fixed ~24h window inside the +/// InstantSend validity bound); `now_unix == 0` is rejected to catch a failed +/// clock read (which would otherwise produce a 1970-relative expiry). +/// +/// On success writes the link to `*out_uri` (heap C string; release with +/// [`crate::platform_wallet_string_free`]) and the funding outpoint to +/// `*out_outpoint`. **The URI embeds the bearer voucher key — never log it.** +/// +/// # Safety +/// - `inviter_identity_id` is either null or points to 32 readable bytes (the +/// `*const u8` identity-id convention shared with `read_identifier` / +/// `platform_wallet_build_auto_accept_qr`). +/// - `inviter_username` is either null or a valid NUL-terminated UTF-8 C string. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// [`crate::dash_sdk_mnemonic_resolver_create`]. The caller retains ownership. +/// - `out_uri` must be a valid `*mut *mut c_char`; `out_outpoint` a valid +/// `*mut OutPointFFI`. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_create_invitation( + wallet_handle: Handle, + amount_duffs: u64, + funding_account_index: u32, + inviter_identity_id: *const u8, + inviter_username: *const c_char, + now_unix: u32, + core_signer_handle: *mut MnemonicResolverHandle, + out_uri: *mut *mut c_char, + out_outpoint: *mut OutPointFFI, +) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); + check_ptr!(out_uri); + check_ptr!(out_outpoint); + // Publish FFI-safe sentinels before any fallible work so every early return + // leaves the out-params well-defined (never uninitialized bytes a + // cleanup-on-error caller might read or free). + unsafe { + *out_uri = std::ptr::null_mut(); + *out_outpoint = OutPointFFI { + txid: [0u8; 32], + vout: 0, + }; + } + + // Reject a failed clock read up front (a zero `now` would derive a + // 1970-relative expiry). The core `create_invitation` also guards the + // resulting `expiry_unix == 0`, but catching `now == 0` here gives a + // clearer, earlier error. + if now_unix == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "now_unix must be a valid unix timestamp (non-zero)", + ); + } + // Derive the advisory expiry: a fixed ~24h window from now, inside the + // InstantSend validity bound (spec §5.1). `saturating_add` can't overflow a + // realistic `now`, but keeps the arithmetic total. + let expiry_unix = now_unix.saturating_add(MAX_INVITATION_TTL_SECS); + + // Build the optional inviter info: present iff `inviter_identity_id` is + // non-null, and the username is required in that case. + let inviter: Option = if inviter_identity_id.is_null() { + None + } else { + if inviter_username.is_null() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "inviter_username is required when inviter_identity_id is provided", + ); + } + let mut identity_id = [0u8; 32]; + unsafe { + std::ptr::copy_nonoverlapping(inviter_identity_id, identity_id.as_mut_ptr(), 32); + } + let username = + unwrap_result_or_return!(unsafe { CStr::from_ptr(inviter_username) }.to_str()) + .to_string(); + Some(InviterInfo { + identity_id, + username, + display_name: None, + }) + }; + + // Round-trip the handle through `usize` so the spawned future's capture is + // `Send + 'static` (raw pointers are `!Send`). + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + block_on_worker(async move { + // SAFETY: see the fn-level safety doc — the caller pins + // `core_signer_handle` for the duration of this call. Two views over + // the same resolver handle: one as the asset-lock/Core signer, one + // wrapped as the `ContactCryptoProvider` used to export the voucher + // key. Both are `Send + Sync` and dropped when this task completes. + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + let provider = unsafe { + resolver_contact_crypto_provider( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + identity_wallet + .create_invitation( + amount_duffs, + funding_account_index, + inviter, + expiry_unix, + &asset_lock_signer, + &provider, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let invitation = unwrap_result_or_return!(result); + + // Marshal the funding outpoint out. `Txid: AsRef<[u8]>`, matching the + // conversion convention used across this crate's changeset FFI. + let mut txid = [0u8; 32]; + txid.copy_from_slice(invitation.out_point.txid.as_ref()); + unsafe { + *out_outpoint = OutPointFFI { + txid, + vout: invitation.out_point.vout, + }; + } + + // The URI is a secret (embeds the voucher key). Do NOT log it — the error + // path below only reports the fixed interior-NUL message, never the URI. + let c_uri = match std::ffi::CString::new(invitation.uri) { + Ok(c) => c, + Err(_) => { + return PlatformWalletFFIResult::from( + "invitation URI contained an interior NUL".to_string(), + ) + } + }; + unsafe { + *out_uri = c_uri.into_raw(); + } + PlatformWalletFFIResult::ok() +} + +/// Claim a DashPay invitation: register a NEW identity for the invitee, funded +/// by the imported voucher carried in `uri`. +/// +/// `uri` is the `dashpay://invite?data=…` link; it is parsed into a +/// `ParsedInvitation` and validated (fail-fast on a stale / wrong-type / +/// mismatched link) before any network act. `identity_pubkeys` are the +/// invitee's own new-identity keys (derived from the invitee's seed), signed by +/// `signer_handle` (the Platform-side per-identity-key signer). The asset-lock's +/// outer signature is produced from the imported raw voucher key, so **no +/// Core-side resolver signer is needed**. `now_unix` is the current unix time +/// used for the advisory-expiry check (passed in from Swift — the FFI can't read +/// the clock deterministically). +/// +/// The contact-bootstrap ("establish contact with the sender?") is **not** done +/// here — the UI asks the invitee and, on confirm, calls the existing +/// contact-request path +/// ([`crate::dashpay::platform_wallet_send_contact_request_with_signer`]). +/// +/// On success writes the new identity id to `*out_identity_id` and a handle into +/// `MANAGED_IDENTITY_STORAGE` to `*out_identity_handle` (release via +/// [`crate::managed_identity_destroy`]). +/// +/// # Safety +/// - `uri` must be a valid NUL-terminated UTF-8 C string. +/// - `identity_pubkeys` must point to `identity_pubkeys_count` readable +/// `IdentityPubkeyFFI` rows (`count >= 1`). +/// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` produced +/// by `dash_sdk_signer_create_with_ctx`. The caller retains ownership. +/// - `out_identity_id` must be a valid `*mut [u8; 32]`; `out_identity_handle` a +/// valid `*mut Handle`. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_claim_invitation( + wallet_handle: Handle, + uri: *const c_char, + identity_index: u32, + identity_pubkeys: *const IdentityPubkeyFFI, + identity_pubkeys_count: usize, + signer_handle: *mut SignerHandle, + now_unix: u32, + out_identity_id: *mut [u8; 32], + out_identity_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(uri); + check_ptr!(signer_handle); + check_ptr!(identity_pubkeys); + check_ptr!(out_identity_id); + check_ptr!(out_identity_handle); + if identity_pubkeys_count == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "identity_pubkeys_count must be >= 1", + ); + } + + let uri = unwrap_result_or_return!(unsafe { CStr::from_ptr(uri) }.to_str()).to_string(); + // Decode the off-chain envelope up front (pure, no network). Structural + // validity (scheme, version, size caps, key/proof shape) is checked here; + // the claimability checks (expiry, proof type, credit-output binding) run + // inside `claim_invitation`. + let invitation = unwrap_result_or_return!(parse_invitation_uri(&uri)); + let keys_map = match decode_identity_pubkeys(identity_pubkeys, identity_pubkeys_count) { + Ok(m) => m, + Err(e) => return e, + }; + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + block_on_worker(async move { + // SAFETY: see the fn-level safety doc — the caller pins + // `signer_handle` for the duration of this call. + let identity_signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity_wallet + .claim_invitation( + invitation, + identity_index, + keys_map, + identity_signer, + now_unix, + None, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let identity = unwrap_result_or_return!(result); + let id_bytes: [u8; 32] = identity.id().to_buffer(); + unsafe { + *out_identity_id = id_bytes; + } + let managed = platform_wallet::ManagedIdentity::new(identity, identity_index); + let handle = MANAGED_IDENTITY_STORAGE.insert(managed); + unsafe { + *out_identity_handle = handle; + } + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Marshalling-boundary coverage. The invitation crypto/codec semantics are + // pinned library-side in `platform_wallet`'s `crypto::invitation` + + // `network::invitation`; these tests only exercise the FFI's null/parameter + // guards and the wallet-lookup miss path. + + /// A null `core_signer_handle` is rejected with `ErrorNullPointer` before + /// any wallet lookup (the `check_ptr!` contract). + #[test] + fn create_invitation_null_core_signer_is_null_pointer() { + let mut uri: *mut c_char = std::ptr::null_mut(); + let mut outpoint = OutPointFFI { + txid: [0u8; 32], + vout: 0, + }; + let r = unsafe { + platform_wallet_create_invitation( + 1, + 1000, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null_mut(), + &mut uri, + &mut outpoint, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// Opting into the contact-bootstrap (non-null `inviter_identity_id`) + /// without a `inviter_username` is rejected with `ErrorInvalidParameter`. + #[test] + fn create_invitation_inviter_without_username_is_invalid_parameter() { + let dummy_signer = std::ptr::dangling_mut::(); + let inviter_id = [0xABu8; 32]; + let mut uri: *mut c_char = std::ptr::null_mut(); + let mut outpoint = OutPointFFI { + txid: [0u8; 32], + vout: 0, + }; + let r = unsafe { + platform_wallet_create_invitation( + 1, + 1000, + 0, + inviter_id.as_ptr(), + std::ptr::null(), + 1_700_000_000, + dummy_signer, + &mut uri, + &mut outpoint, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + } + + /// An unknown `wallet_handle` surfaces `NotFound` via the `with_item` + /// lookup miss. A pure funding voucher (null inviter) gets past the inviter + /// build; the dangling signer is never dereferenced (the lookup fails first). + #[test] + fn create_invitation_unknown_wallet_is_not_found() { + let dummy_signer = std::ptr::dangling_mut::(); + let mut uri: *mut c_char = std::ptr::null_mut(); + let mut outpoint = OutPointFFI { + txid: [0u8; 32], + vout: 0, + }; + let r = unsafe { + platform_wallet_create_invitation( + 0xDEAD_BEEF, + 1000, + 0, + std::ptr::null(), + std::ptr::null(), + 1_700_000_000, + dummy_signer, + &mut uri, + &mut outpoint, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + } + + /// A zero `now_unix` (a failed Swift clock read) is rejected with + /// `ErrorInvalidParameter` before any wallet lookup — the derived expiry + /// would otherwise be 1970-relative. Runs after the pointer checks, so the + /// dangling signer is never dereferenced. + #[test] + fn create_invitation_zero_now_is_invalid_parameter() { + let dummy_signer = std::ptr::dangling_mut::(); + let mut uri: *mut c_char = std::ptr::null_mut(); + let mut outpoint = OutPointFFI { + txid: [0u8; 32], + vout: 0, + }; + let r = unsafe { + platform_wallet_create_invitation( + 1, + 1000, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + dummy_signer, + &mut uri, + &mut outpoint, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + } + + /// A null `uri` is rejected with `ErrorNullPointer` (the `check_ptr!` + /// contract) before any parsing. + #[test] + fn claim_invitation_null_uri_is_null_pointer() { + let dummy_signer = std::ptr::dangling_mut::(); + let dummy_pubkeys = std::ptr::dangling::(); + let mut id = [0u8; 32]; + let mut handle: Handle = 0; + let r = unsafe { + platform_wallet_claim_invitation( + 1, + std::ptr::null(), + 0, + dummy_pubkeys, + 1, + dummy_signer, + 0, + &mut id, + &mut handle, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// A malformed `uri` (wrong scheme) fails the codec parse before any wallet + /// lookup, surfacing the invalid-data error rather than a network attempt. + #[test] + fn claim_invitation_bad_uri_is_rejected() { + let dummy_signer = std::ptr::dangling_mut::(); + let dummy_pubkeys = std::ptr::dangling::(); + let bad = std::ffi::CString::new("https://not-an-invite").unwrap(); + let mut id = [0u8; 32]; + let mut handle: Handle = 0; + let r = unsafe { + platform_wallet_claim_invitation( + 1, + bad.as_ptr(), + 0, + dummy_pubkeys, + 1, + dummy_signer, + 0, + &mut id, + &mut handle, + ) + }; + // parse_invitation_uri rejects the scheme → surfaced as an error result. + assert_ne!(r.code, PlatformWalletFFIResultCode::Success); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index a2b0bf8aa76..d3760a96a8a 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -47,6 +47,7 @@ pub mod identity_top_up; pub mod identity_transfer; pub mod identity_update; pub mod identity_withdrawal; +pub mod invitation; pub mod logging; pub mod managed_identity; pub mod manager; @@ -115,6 +116,7 @@ pub use identity_top_up::*; pub use identity_transfer::*; pub use identity_update::*; pub use identity_withdrawal::*; +pub use invitation::*; pub use logging::*; pub use managed_identity::*; pub use manager::*; From 55c95371cd1bfdae3cb838ceade7f85ef44a9c34 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 18:28:30 +0700 Subject: [PATCH 08/74] fix(platform-wallet): scrub the exported voucher scalar in create_invitation 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 --- .../src/wallet/identity/network/invitation.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index aecbba5f007..216153ab9ae 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -185,10 +185,15 @@ impl IdentityWallet { // Export the one-time voucher private key at the funding path. This is // the one deliberate raw-key export (the whole point of an invitation); // it is path-gated to the invitation sub-feature inside the provider. - let voucher_key = crypto_provider.export_invitation_private_key(&path).await?; + let mut voucher_key = crypto_provider.export_invitation_private_key(&path).await?; let uri = encode_invitation_uri(&voucher_key, &proof, expiry_unix, inviter.as_ref())?; + // Scrub the exported scalar now that it lives in the (secret) URI. + // secp256k1's `SecretKey` has no Drop-zeroize, so wipe it explicitly — + // matching the resolver signer's key hygiene (review LOW-1). + voucher_key.non_secure_erase(); + Ok(Invitation { uri, out_point, From 4d39bdcf5c13bc09debe82066c62028018964005 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 18:38:52 +0700 Subject: [PATCH 09/74] feat(swift-sdk): createInvitation / claimInvitation wrappers on ManagedPlatformWallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ManagedPlatformWallet.swift | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index e4761e8d0bc..b8282b57645 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -1791,6 +1791,168 @@ extension ManagedPlatformWallet { return ContactRequest(handle: requestHandle) } + // MARK: - DashPay invitations (DIP-13) + + /// Create a DashPay invitation (DIP-13): fund a one-time asset-lock voucher + /// at the invitation derivation path and return a shareable + /// `dashpay://invite` link. + /// + /// **The returned link contains the voucher private key — it is a bearer + /// credential.** Do NOT log it, and copy it with a sensitive-pasteboard flag + /// so it isn't synced across devices. + /// + /// Pass `inviterIdentityId` + `inviterUsername` to opt into the + /// contact-bootstrap (the link then carries the inviter so the invitee can + /// send a contact request back); pass `nil` for both for a pure funding + /// voucher. `nowUnix` is the current unix time in seconds (e.g. + /// `UInt32(Date().timeIntervalSince1970)`); the advisory expiry is derived + /// Rust-side as `nowUnix + MAX_INVITATION_TTL_SECS` (~24h). A zero `nowUnix` + /// is rejected. + /// + /// Builds an L1 asset-lock transaction Rust-side from the `fundingAccount` + /// (which must have spendable Core UTXOs), so this is a long-running call. + /// Only the Core-side `MnemonicResolver` is used (no identity signer): pure + /// voucher creation registers no identity. + public func createInvitation( + amountDuffs: UInt64, + fundingAccount: UInt32, + inviterIdentityId: Identifier?, + inviterUsername: String?, + nowUnix: UInt32 + ) async throws -> String { + if inviterIdentityId != nil && inviterUsername == nil { + throw PlatformWalletError.invalidParameter( + "inviterUsername is required when inviterIdentityId is provided" + ) + } + let handle = self.handle + let coreSigner = MnemonicResolver() + // Pre-extract the inviter id bytes (nil ⇒ pure funding voucher). The FFI + // takes the `*const u8` 32-byte identity-id shape shared with + // `buildAutoAcceptQR` / `read_identifier`. + let inviterBytes: [UInt8]? = inviterIdentityId.map { id in + id.withFFIBytes { ptr in Array(UnsafeBufferPointer(start: ptr, count: 32)) } + } + let username = inviterUsername + return try await Task.detached(priority: .userInitiated) { () -> String in + var outURI: UnsafeMutablePointer? + // `out_outpoint` is required by the FFI but the funding outpoint is + // not surfaced through this wrapper (the persistence layer tracks it + // via the asset-lock manager); pass a scratch struct. + var outOutpoint = OutPointFFI() + let result: PlatformWalletFFIResult = withExtendedLifetime(coreSigner) { + () -> PlatformWalletFFIResult in + // Pin the optional inviter-id buffer + username CString, then call. + func callWithInviter( + _ idPtr: UnsafePointer? + ) -> PlatformWalletFFIResult { + ManagedPlatformWallet.withOptionalCString(username) { usernamePtr in + platform_wallet_create_invitation( + handle, + amountDuffs, + fundingAccount, + idPtr, + usernamePtr, + nowUnix, + coreSigner.handle, + &outURI, + &outOutpoint + ) + } + } + if let inviterBytes { + return inviterBytes.withUnsafeBufferPointer { bp in + callWithInviter(bp.baseAddress) + } + } else { + return callWithInviter(nil) + } + } + try result.check() + guard let outURI else { + throw PlatformWalletError.nullPointer("createInvitation returned a null URI") + } + let uri = String(cString: outURI) + platform_wallet_string_free(outURI) + return uri + }.value + } + + /// Claim a DashPay invitation (DIP-13): register a NEW identity for the + /// invitee, funded by the imported voucher carried in `uri`. + /// + /// This is ordinary identity registration whose *funding* is imported from + /// the link — so, exactly like `registerIdentityWithFunding`, the caller + /// MUST pre-derive `identityPubkeys` (the invitee's own new-identity keys) + /// AND pre-persist each key's private material to the Keychain (via + /// `prePersistIdentityKeysForRegistration`) BEFORE calling; `signer` produces + /// the per-identity-key witnesses. The asset-lock's outer signature uses the + /// imported raw voucher key, so no Core-side resolver signer is needed here. + /// + /// `nowUnix` is the current unix time, used for the link's advisory-expiry + /// check. The contact-bootstrap ("establish contact with the sender?") is + /// NOT done here — after a successful claim the UI asks the invitee and, on + /// confirm, calls `sendContactRequest` for the reciprocal. + /// + /// Returns the freshly-registered invitee `ManagedIdentity`. + public func claimInvitation( + uri: String, + identityIndex: UInt32, + identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], + signer: KeychainSigner, + nowUnix: UInt32 + ) async throws -> ManagedIdentity { + guard !identityPubkeys.isEmpty else { + throw PlatformWalletError.invalidParameter("identityPubkeys is empty") + } + let handle = self.handle + let signerHandle = signer.handle + let pubkeys = identityPubkeys + return try await Task.detached(priority: .userInitiated) { () -> ManagedIdentity in + var idTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + var outManagedHandle: Handle = NULL_HANDLE + let pubkeyBuffers: [Data] = pubkeys.map { $0.pubkeyBytes } + let result = withExtendedLifetime(signer) { + () -> PlatformWalletFFIResult in + uri.withCString { uriPtr in + ManagedPlatformWallet.withPubkeyFFIArray( + pubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_claim_invitation( + handle, + uriPtr, + identityIndex, + ffiRowsPtr, + UInt(ffiRowsCount), + signerHandle, + nowUnix, + &idTuple, + &outManagedHandle + ) + } + } + } + try result.check() + // On Success the managed-identity handle must be non-NULL; wrapping + // NULL_HANDLE would defer the failure to a harder-to-debug point. + guard outManagedHandle != NULL_HANDLE else { + throw PlatformWalletError.walletOperation( + "FFI returned success but managed-identity handle was NULL" + ) + } + return ManagedIdentity(handle: outManagedHandle) + }.value + } + /// Accept an incoming contact request using an externally-supplied /// `KeychainSigner` for the reciprocal request's document /// state-transition. From 2b2a8712b092b8284e67849079a8149abdcb8039 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 19:13:27 +0700 Subject: [PATCH 10/74] feat(platform-wallet): persist inviter-side invitation records (Rust half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migrations/V003__invitations.rs | 25 +++ .../src/sqlite/persister.rs | 3 + .../src/sqlite/schema/invitations.rs | 192 ++++++++++++++++++ .../src/sqlite/schema/mod.rs | 1 + .../src/changeset/changeset.rs | 69 +++++++ .../rs-platform-wallet/src/changeset/mod.rs | 9 +- .../rs-platform-wallet/src/wallet/apply.rs | 6 + .../src/wallet/identity/network/invitation.rs | 34 ++++ 8 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/migrations/V003__invitations.rs create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs diff --git a/packages/rs-platform-wallet-storage/migrations/V003__invitations.rs b/packages/rs-platform-wallet-storage/migrations/V003__invitations.rs new file mode 100644 index 00000000000..62eaa7d49da --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V003__invitations.rs @@ -0,0 +1,25 @@ +//! Add the `invitations` table (DIP-13 DashPay invitations). +//! +//! Inviter-side records of created invitations, powering the "Sent invitations" +//! status list and (future) reclaim of an unclaimed voucher. **No key material +//! is stored** — the one-time voucher key is HD-derived and re-derivable from +//! `funding_index` on demand. +//! +//! All fields map to explicit columns (the entry is all-primitive), so no +//! opaque lifecycle blob is needed — the row reconstructs directly. + +pub fn migration() -> String { + "CREATE TABLE invitations ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('created', 'claimed', 'reclaimed')), + funding_index INTEGER NOT NULL, + amount_duffs INTEGER NOT NULL, + expiry_unix INTEGER NOT NULL, + created_at_secs INTEGER NOT NULL, + has_inviter INTEGER NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + );" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 104af2dbe6b..61e04666dec 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1087,6 +1087,9 @@ fn apply_changeset_to_tx( if let Some(locks) = cs.asset_locks.as_ref() { schema::asset_locks::apply(tx, wallet_id, locks)?; } + if let Some(invitations) = cs.invitations.as_ref() { + schema::invitations::apply(tx, wallet_id, invitations)?; + } if let Some(balances) = cs.token_balances.as_ref() { schema::token_balances::apply(tx, wallet_id, balances)?; } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs new file mode 100644 index 00000000000..527870e1691 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs @@ -0,0 +1,192 @@ +//! `invitations` table writer + reader (DIP-13 DashPay invitations). +//! +//! Every field maps to an explicit column (the entry is all-primitive), so a +//! row reconstructs an [`InvitationEntry`] directly — no lifecycle blob. No key +//! material is stored: the voucher key is re-derived from `funding_index`. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::{InvitationChangeSet, InvitationStatus}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +// Imports used only by the test-gated reader below. +#[cfg(any(test, feature = "__test-helpers"))] +use { + dashcore::OutPoint, platform_wallet::changeset::InvitationEntry, rusqlite::Connection, + std::collections::BTreeMap, +}; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &InvitationChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.invitations.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO invitations \ + (wallet_id, outpoint, status, funding_index, amount_duffs, expiry_unix, created_at_secs, has_inviter) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \ + ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \ + status = excluded.status, \ + funding_index = excluded.funding_index, \ + amount_duffs = excluded.amount_duffs, \ + expiry_unix = excluded.expiry_unix, \ + created_at_secs = excluded.created_at_secs, \ + has_inviter = excluded.has_inviter", + )?; + for (op, entry) in &cs.invitations { + let op_bytes = blob::encode_outpoint(op)?; + stmt.execute(params![ + wallet_id.as_slice(), + &op_bytes[..], + status_str(&entry.status), + i64::from(entry.funding_index), + crate::sqlite::util::safe_cast::u64_to_i64( + "invitations.amount_duffs", + entry.amount_duffs, + )?, + i64::from(entry.expiry_unix), + i64::from(entry.created_at_secs), + i64::from(entry.has_inviter), + ])?; + } + } + if !cs.removed.is_empty() { + let mut stmt = + tx.prepare_cached("DELETE FROM invitations WHERE wallet_id = ?1 AND outpoint = ?2")?; + for op in &cs.removed { + let op_bytes = blob::encode_outpoint(op)?; + stmt.execute(params![wallet_id.as_slice(), &op_bytes[..]])?; + } + } + Ok(()) +} + +/// Single source of truth for the `invitations.status` TEXT-column domain. +/// The `CHECK (status IN …)` in `migrations/V003__invitations.rs` must list +/// exactly these values. +pub(crate) fn status_str(s: &InvitationStatus) -> &'static str { + match s { + InvitationStatus::Created => "created", + InvitationStatus::Claimed => "claimed", + InvitationStatus::Reclaimed => "reclaimed", + } +} + +#[cfg(any(test, feature = "__test-helpers"))] +fn status_from_str(s: &str) -> Result { + match s { + "created" => Ok(InvitationStatus::Created), + "claimed" => Ok(InvitationStatus::Claimed), + "reclaimed" => Ok(InvitationStatus::Reclaimed), + _ => Err(WalletStorageError::blob_decode( + "unknown invitations.status value in row", + )), + } +} + +/// Read every invitation row for a wallet, keyed by outpoint. Test/round-trip +/// helper (the production load path does not re-hydrate invitations into the +/// Rust manager; the Swift SwiftData mirror is the UI source). +#[cfg(any(test, feature = "__test-helpers"))] +pub fn read_all( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT outpoint, status, funding_index, amount_duffs, expiry_unix, created_at_secs, has_inviter \ + FROM invitations WHERE wallet_id = ?1", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + )) + })?; + let mut out = BTreeMap::new(); + for row in rows { + let (op_bytes, status, funding_index, amount, expiry, created_at, has_inviter) = row?; + let out_point = blob::decode_outpoint(&op_bytes)?; + out.insert( + out_point, + InvitationEntry { + out_point, + funding_index: funding_index as u32, + amount_duffs: amount as u64, + expiry_unix: expiry as u32, + created_at_secs: created_at as u32, + has_inviter: has_inviter != 0, + status: status_from_str(&status)?, + }, + ); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::hashes::Hash; + + fn entry(vout: u32, status: InvitationStatus) -> InvitationEntry { + InvitationEntry { + out_point: OutPoint::new(dashcore::Txid::from_byte_array([vout as u8; 32]), vout), + funding_index: vout, + amount_duffs: 100_000 + u64::from(vout), + expiry_unix: 1_800_000_000, + created_at_secs: 1_799_913_600, + has_inviter: vout.is_multiple_of(2), + status, + } + } + + #[test] + fn apply_then_read_round_trips_and_upserts_and_removes() { + let wallet_id: WalletId = [0x11; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + // The FK requires the wallet_metadata row to exist. + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + // Insert two. + let e0 = entry(0, InvitationStatus::Created); + let e1 = entry(1, InvitationStatus::Created); + let mut cs = InvitationChangeSet::default(); + cs.invitations.insert(e0.out_point, e0.clone()); + cs.invitations.insert(e1.out_point, e1.clone()); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + tx.commit().unwrap(); + } + let got = read_all(&conn, &wallet_id).unwrap(); + assert_eq!(got.len(), 2); + assert_eq!(got[&e0.out_point], e0); + + // Upsert e0 → Claimed, remove e1. + let mut cs2 = InvitationChangeSet::default(); + let e0b = entry(0, InvitationStatus::Claimed); + cs2.invitations.insert(e0b.out_point, e0b.clone()); + cs2.removed.insert(e1.out_point); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs2).unwrap(); + tx.commit().unwrap(); + } + let got = read_all(&conn, &wallet_id).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[&e0.out_point].status, InvitationStatus::Claimed); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index a2ae6da308f..41b4d82c271 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -21,6 +21,7 @@ pub mod core_state; pub mod dashpay; pub mod identities; pub mod identity_keys; +pub mod invitations; pub mod pending_contact_crypto; pub mod platform_addrs; pub mod token_balances; diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 8dc2c705427..ee614ecaefb 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -864,6 +864,71 @@ impl Merge for AssetLockChangeSet { } } +// --------------------------------------------------------------------------- +// DashPay Invitations (DIP-13) +// --------------------------------------------------------------------------- + +/// Lifecycle status of an inviter-side invitation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum InvitationStatus { + /// Created and shared; the funding asset lock is unspent. + Created, + /// The voucher was consumed — an identity was registered from it. + Claimed, + /// The inviter reclaimed the unspent voucher back into their wallet. + Reclaimed, +} + +/// A single inviter-side invitation record (DIP-13). +/// +/// **No secret is stored.** The one-time voucher private key is HD-derived and +/// re-derivable from `funding_index` on demand (for re-packaging or reclaiming an +/// unclaimed invitation); it is never persisted. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct InvitationEntry { + /// The funding asset lock's outpoint (this record's identity). + pub out_point: OutPoint, + /// DIP-13 invitation funding index (`m/9'/coin'/5'/3'/'`); + /// re-derives the voucher key. + pub funding_index: u32, + /// Amount locked in the voucher (duffs). + pub amount_duffs: u64, + /// Advisory expiry (unix seconds). + pub expiry_unix: u32, + /// Unix seconds when the invitation was created. + pub created_at_secs: u32, + /// Whether the inviter opted into the contact-bootstrap ("send a request + /// back to me"). + pub has_inviter: bool, + /// Current lifecycle status. + pub status: InvitationStatus, +} + +/// Inviter-side invitation records emitted by `create_invitation` (and, later, +/// reclaim + a status sync that flips `Created → Claimed`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct InvitationChangeSet { + /// Invitation records keyed by funding outpoint. Last write wins on merge. + pub invitations: BTreeMap, + /// Invitations removed from tracking. + pub removed: BTreeSet, +} + +impl Merge for InvitationChangeSet { + fn merge(&mut self, other: Self) { + // Last write wins — later status is higher finality. + self.invitations.extend(other.invitations); + self.removed.extend(other.removed); + } + + fn is_empty(&self) -> bool { + self.invitations.is_empty() && self.removed.is_empty() + } +} + // --------------------------------------------------------------------------- // Token Balances // --------------------------------------------------------------------------- @@ -1157,6 +1222,8 @@ pub struct PlatformWalletChangeSet { pub platform_addresses: Option, /// Asset lock lifecycle changes (created, locked, used). pub asset_locks: Option, + /// DashPay invitation (DIP-13) records — inviter-side create/reclaim. + pub invitations: Option, /// Platform token balance / watch changes. pub token_balances: Option, /// DashPay profile overlays keyed by identity ID. Applied AFTER @@ -1263,6 +1330,7 @@ impl Merge for PlatformWalletChangeSet { self.contacts.merge(other.contacts); self.platform_addresses.merge(other.platform_addresses); self.asset_locks.merge(other.asset_locks); + self.invitations.merge(other.invitations); self.token_balances.merge(other.token_balances); // DashPay overlays: LWW per identity_id. if let Some(other_profiles) = other.dashpay_profiles { @@ -1312,6 +1380,7 @@ impl Merge for PlatformWalletChangeSet { && self.contacts.is_empty() && self.platform_addresses.is_empty() && self.asset_locks.is_empty() + && self.invitations.is_empty() && self.token_balances.is_empty() && self.dashpay_profiles.as_ref().is_none_or(|m| m.is_empty()) && self diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index cbd5f53a98b..c56e7d0ccfb 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -28,10 +28,11 @@ pub use changeset::{ upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, - IdentityKeysChangeSet, KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, - PendingContactCryptoKey, PendingContactCryptoKind, PendingContactCryptoOp, - PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, - ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, + IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus, + KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey, + PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, + PlatformAddressChangeSet, PlatformWalletChangeSet, ReceivedContactRequestKey, + SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState; diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 6100e19d57c..bd42f957047 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -99,6 +99,12 @@ impl PlatformWalletInfo { token_balances, dashpay_profiles, dashpay_payments_overlay, + // DashPay invitations (DIP-13) are persistence-only here: the + // "Sent invitations" list is the Swift SwiftData mirror, and the + // Rust manager holds no in-memory invitation state in v1 (reclaim + // is future). Drop explicitly so future readers don't expect a + // replay hook. + invitations: _, // Registration-round metadata / per-account specs / // per-pool snapshots are persistence-only — the // canonical in-memory wallet state is built up at diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index 216153ab9ae..dd5e9a14b76 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -23,8 +23,11 @@ use dpp::identity::signer::Signer; use dpp::identity::v0::IdentityV0; use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose, SecurityLevel}; use dpp::prelude::{AssetLockProof, Identifier}; +use key_wallet::bip32::ChildNumber; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use crate::changeset::{InvitationChangeSet, InvitationEntry, InvitationStatus}; + use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; @@ -194,6 +197,37 @@ impl IdentityWallet { // matching the resolver signer's key hygiene (review LOW-1). voucher_key.non_secure_erase(); + // Persist an inviter-side invitation record for the "Sent invitations" + // list + (future) reclaim. No secret is stored — `funding_index` + // re-derives the voucher key. Best-effort: the link is already valid, so + // a persistence failure must not fail the create. + let funding_index = match path.as_ref().last() { + Some(ChildNumber::Hardened { index }) => *index, + _ => 0, + }; + let mut inv_cs = InvitationChangeSet::default(); + inv_cs.invitations.insert( + out_point, + InvitationEntry { + out_point, + funding_index, + amount_duffs, + expiry_unix, + created_at_secs: expiry_unix.saturating_sub(MAX_INVITATION_TTL_SECS), + has_inviter: inviter.is_some(), + status: InvitationStatus::Created, + }, + ); + if let Err(e) = self + .persister + .store(crate::changeset::PlatformWalletChangeSet { + invitations: Some(inv_cs), + ..Default::default() + }) + { + tracing::warn!(error = %e, "failed to persist invitation record; the link is still valid"); + } + Ok(Invitation { uri, out_point, From 8fd0613fe0615075d456d99b1b21ce25fe1bf2c6 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 19:22:05 +0700 Subject: [PATCH 11/74] feat(swift-sdk): add DashPay create-invitation UI (DIP-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../Views/DashPay/CreateInvitationSheet.swift | 255 ++++++++++++++++++ .../Views/DashPay/DashPayProfileView.swift | 19 ++ 2 files changed, 274 insertions(+) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift new file mode 100644 index 00000000000..2d9794c95bc --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift @@ -0,0 +1,255 @@ +import CoreImage.CIFilterBuiltins +import SwiftDashSDK +import SwiftUI +import UniformTypeIdentifiers + +/// Create a DashPay invitation (DIP-13): pick an amount, optionally opt into the +/// contact-bootstrap, fund a one-time asset-lock voucher, and share the resulting +/// `dashpay://invite` link (as text + QR) so a friend with no Dash can register +/// their own identity from it. +/// +/// The returned link **contains the voucher private key** — it is a bearer +/// credential. It is never logged, and the "Copy" action uses a local-only +/// pasteboard so it isn't mirrored across devices via Universal Clipboard. +struct CreateInvitationSheet: View { + /// The inviter's identity (the current DashPay identity). Its id + DPNS name + /// seed the optional "send a contact request back to me" info in the link. + let identity: PersistentIdentity + + @EnvironmentObject private var walletManager: PlatformWalletManager + @Environment(\.dismiss) private var dismiss + + /// 1 DASH = 100,000,000 duffs. + private static let duffsPerDash: UInt64 = 100_000_000 + /// Rust-enforced cap (`MAX_INVITATION_DUFFS`, 0.01 DASH). Mirrored here so the + /// UI rejects an over-cap amount before the FFI does. + private static let maxInvitationDuffs: UInt64 = 1_000_000 + /// BIP44 standard account that supplies the asset-lock's funding UTXOs. The + /// example app funds identity operations from account 0; the `IdentityInvitation` + /// funding type derives the voucher credit key internally (not this account). + private static let fundingAccount: UInt32 = 0 + + /// Amount to lock in the voucher, as a DASH string (decimal). Default 0.0005 + /// DASH — enough for identity registration plus a small starting balance. + @State private var amountDashText: String = "0.0005" + /// Opt into the contact-bootstrap: the link carries the inviter so the invitee + /// can send a contact request back. Requires a registered username. + @State private var sendRequestBack = true + + @State private var isCreating = false + @State private var inviteURI: String? + @State private var qrImage: UIImage? + @State private var errorMessage: String? + @State private var showShareSheet = false + @State private var didCopy = false + + /// The inviter's DPNS username, if registered. The contact-bootstrap can only + /// be offered when the inviter has a username to advertise in the link. + private var username: String? { + let name = (identity.mainDpnsName ?? identity.dpnsName)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return (name?.isEmpty == false) ? name : nil + } + + /// Parse the DASH text field into duffs, or `nil` if it isn't a valid, + /// in-range positive amount. + private var amountDuffs: UInt64? { + guard let dash = Double(amountDashText.replacingOccurrences(of: ",", with: ".")), + dash > 0 + else { return nil } + let duffs = (dash * Double(Self.duffsPerDash)).rounded() + guard duffs >= 1, duffs <= Double(Self.maxInvitationDuffs) else { return nil } + return UInt64(duffs) + } + + var body: some View { + NavigationStack { + Form { + if let inviteURI { + resultSection(uri: inviteURI) + } else { + inputSection + } + } + .navigationTitle("Invite a Friend") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(inviteURI == nil ? "Cancel" : "Done") { dismiss() } + .accessibilityIdentifier("dashpay.invite.create.done") + } + } + .sheet(isPresented: $showShareSheet) { + if let inviteURI { + ShareSheet(items: [inviteURI]) + } + } + } + } + + // MARK: - Input + + @ViewBuilder + private var inputSection: some View { + Section("Amount") { + HStack { + TextField("0.0005", text: $amountDashText) + .keyboardType(.decimalPad) + .accessibilityIdentifier("dashpay.invite.create.amount") + Text("DASH") + .foregroundColor(.secondary) + } + Text("Funds a one-time voucher your friend uses to register their identity. Max 0.01 DASH.") + .font(.caption) + .foregroundColor(.secondary) + } + + Section("Contact") { + Toggle("Send a contact request back to me", isOn: $sendRequestBack) + .disabled(username == nil) + .accessibilityIdentifier("dashpay.invite.create.sendBack") + if let username { + Text("Your friend will be asked to add \(username) after they register.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Register a username to let invitees add you back automatically.") + .font(.caption) + .foregroundColor(.orange) + } + } + + Section { + Button { + Task { await create() } + } label: { + HStack { + if isCreating { + ProgressView() + Text("Creating…") + } else { + Text("Create Invitation") + } + } + .frame(maxWidth: .infinity) + } + .disabled(isCreating || amountDuffs == nil) + .accessibilityIdentifier("dashpay.invite.create.submit") + } footer: { + if amountDuffs == nil { + Text("Enter an amount between 0.00000001 and 0.01 DASH.") + .foregroundColor(.orange) + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + } + + // MARK: - Result + + @ViewBuilder + private func resultSection(uri: String) -> some View { + Section { + VStack(spacing: 12) { + if let qrImage { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 220, height: 220) + .padding(8) + .background(Color.white) + .cornerRadius(12) + } + Text("Share this link with your friend. It funds their new identity — treat it like cash.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } + + Section { + Button { + showShareSheet = true + } label: { + Label("Share link", systemImage: "square.and.arrow.up") + } + .accessibilityIdentifier("dashpay.invite.create.share") + + Button { + copyLink(uri) + } label: { + Label(didCopy ? "Copied" : "Copy link", systemImage: didCopy ? "checkmark" : "doc.on.doc") + } + .accessibilityIdentifier("dashpay.invite.create.copy") + } footer: { + Text("The link contains a one-time key. Anyone who has it can claim the funds, so share it privately.") + } + } + + // MARK: - Actions + + private func create() async { + guard !isCreating else { return } + errorMessage = nil + guard let amountDuffs else { + errorMessage = "Enter a valid amount." + return + } + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) + else { + errorMessage = "No wallet loaded for this identity." + return + } + let optIn = sendRequestBack && username != nil + isCreating = true + defer { isCreating = false } + do { + let uri = try await wallet.createInvitation( + amountDuffs: amountDuffs, + fundingAccount: Self.fundingAccount, + inviterIdentityId: optIn ? identity.identityId : nil, + inviterUsername: optIn ? username : nil, + nowUnix: UInt32(Date().timeIntervalSince1970) + ) + qrImage = Self.makeQRCode(from: uri) + inviteURI = uri + } catch { + errorMessage = error.localizedDescription + } + } + + /// Copy the link to a **local-only** pasteboard so the bearer key isn't + /// mirrored to the user's other devices via Universal Clipboard. + private func copyLink(_ uri: String) { + UIPasteboard.general.setItems( + [[UTType.plainText.identifier: uri]], + options: [.localOnly: true] + ) + didCopy = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { didCopy = false } + } + + /// Render a string as a QR `UIImage` (native CoreImage generator, scaled 10× + /// for crispness). Mirrors the profile / receive-address QR helper. + private static func makeQRCode(from string: String) -> UIImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + guard + let output = filter.outputImage? + .transformed(by: CGAffineTransform(scaleX: 10, y: 10)), + let cgImage = context.createCGImage(output, from: output.extent) + else { return nil } + return UIImage(cgImage: cgImage) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift index b3b9aad1a98..5f383fe9c92 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift @@ -20,6 +20,9 @@ struct DashPayProfileView: View { @State private var qrURI: String? @State private var qrError: String? + /// Presents the "Invite a friend" (DIP-13 invitation create) sheet. + @State private var showCreateInvitation = false + private var displayName: String { if let name = profile?.displayName? .trimmingCharacters(in: .whitespacesAndNewlines), @@ -112,6 +115,18 @@ struct DashPayProfileView: View { } .task { await generateAutoAcceptQR() } + Section("Invite a friend (DIP-13)") { + Button { + showCreateInvitation = true + } label: { + Label("Create invitation", systemImage: "person.badge.plus") + } + .accessibilityIdentifier("dashpay.profile.createInvitation") + Text("Fund a one-time link so someone with no Dash can register their identity and add you.") + .font(.caption) + .foregroundColor(.secondary) + } + if let url = profile?.avatarUrl? .trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty { @@ -140,6 +155,10 @@ struct DashPayProfileView: View { .accessibilityIdentifier("dashpay.profile.edit") } } + .sheet(isPresented: $showCreateInvitation) { + CreateInvitationSheet(identity: identity) + .environmentObject(walletManager) + } } } From bd60102eb090dc57ccf45f5c2f28928855190782 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 19:41:18 +0700 Subject: [PATCH 12/74] feat(platform-wallet-ffi): add parse-invitation preview FFI + Swift wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ?" 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 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../rs-platform-wallet-ffi/src/invitation.rs | 133 ++++++++++++++++++ .../ManagedPlatformWallet.swift | 59 ++++++++ 2 files changed, 192 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 47035714aa1..3aabbc916a3 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -29,6 +29,7 @@ use std::ffi::CStr; use std::os::raw::c_char; use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::AssetLockProof; use platform_wallet::wallet::identity::crypto::{parse_invitation_uri, InviterInfo}; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; @@ -309,6 +310,118 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( PlatformWalletFFIResult::ok() } +/// Read-only preview of a `dashpay://invite` link — decode + surface the +/// invitation's metadata WITHOUT claiming it (no wallet handle, no network, no +/// side effects). The claim UI uses this to show the amount, sender, and expiry +/// before the user commits, and to drive the contact-bootstrap prompt. +#[repr(C)] +pub struct InvitationPreviewFFI { + /// The link decoded structurally (base58 envelope + version + fields). When + /// false, every other field is unset/zero and the link is malformed. + pub structurally_valid: bool, + /// The embedded asset-lock proof is an InstantSend proof. Claim only accepts + /// Instant proofs, so a Chain proof (`false`) is unclaimable via this path. + pub is_instant: bool, + /// The link carries inviter info (the contact-bootstrap is available). + pub has_inviter: bool, + /// Inviter identity id (32 bytes); zeroed when `has_inviter` is false. + pub inviter_id: [u8; 32], + /// Inviter DPNS username — heap C string, or null when `has_inviter` is + /// false. Free with [`crate::platform_wallet_string_free`]. + pub inviter_username: *mut c_char, + /// Amount locked in the voucher (duffs) — the Instant proof's credit-output + /// value; 0 for a non-Instant proof. + pub amount_duffs: u64, + /// Advisory expiry (unix seconds). The caller compares it against the current + /// time for an "expired" badge (the FFI stays clock-free). + pub expiry_unix: u32, +} + +impl InvitationPreviewFFI { + /// An all-unset preview — the shape returned for a malformed link + /// (`structurally_valid == false`) and the pre-work sentinel. + fn invalid() -> Self { + Self { + structurally_valid: false, + is_instant: false, + has_inviter: false, + inviter_id: [0u8; 32], + inviter_username: std::ptr::null_mut(), + amount_duffs: 0, + expiry_unix: 0, + } + } +} + +/// Decode a `dashpay://invite?data=…` link into a read-only +/// [`InvitationPreviewFFI`] — NO claim, NO network, NO wallet handle. +/// +/// A well-formed-but-invalid link (bad base58, unsupported version, truncated, +/// bad key/proof) yields `structurally_valid == false` rather than an error, so +/// the UI can render a clean "invalid invitation" state; only a null / non-UTF-8 +/// `uri` argument returns an error result. +/// +/// When `has_inviter` is set, `*out_preview.inviter_username` is a heap C string +/// the caller frees with [`crate::platform_wallet_string_free`]. +/// +/// # Safety +/// - `uri` must be a valid NUL-terminated UTF-8 C string. +/// - `out_preview` must be a valid `*mut InvitationPreviewFFI`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_parse_invitation( + uri: *const c_char, + out_preview: *mut InvitationPreviewFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_preview); + check_ptr!(uri); + // Publish the invalid sentinel before any fallible work so every early + // return leaves the out-param well-defined. + unsafe { + *out_preview = InvitationPreviewFFI::invalid(); + } + + let uri = unwrap_result_or_return!(unsafe { CStr::from_ptr(uri) }.to_str()); + + // A malformed link is a normal "invalid invitation" preview, not an FFI + // error — the UI shows it as unclaimable instead of surfacing an opaque + // failure dialog. + let parsed = match parse_invitation_uri(uri) { + Ok(p) => p, + Err(_) => return PlatformWalletFFIResult::ok(), + }; + + let is_instant = matches!(parsed.asset_lock, AssetLockProof::Instant(_)); + let amount_duffs = match &parsed.asset_lock { + AssetLockProof::Instant(instant) => instant.output().map(|o| o.value).unwrap_or(0), + AssetLockProof::Chain(_) => 0, + }; + + let (has_inviter, inviter_id, inviter_username) = match parsed.inviter.as_ref() { + Some(info) => { + // An interior NUL can't occur in a decoded UTF-8 DPNS label, but fall + // back to a null username rather than fail the whole preview. + let username = std::ffi::CString::new(info.username.clone()) + .map(|c| c.into_raw()) + .unwrap_or(std::ptr::null_mut()); + (true, info.identity_id, username) + } + None => (false, [0u8; 32], std::ptr::null_mut()), + }; + + unsafe { + *out_preview = InvitationPreviewFFI { + structurally_valid: true, + is_instant, + has_inviter, + inviter_id, + inviter_username, + amount_duffs, + expiry_unix: parsed.expiry_unix, + }; + } + PlatformWalletFFIResult::ok() +} + #[cfg(test)] mod tests { use super::*; @@ -474,4 +587,24 @@ mod tests { // parse_invitation_uri rejects the scheme → surfaced as an error result. assert_ne!(r.code, PlatformWalletFFIResultCode::Success); } + + /// A null `uri` is rejected with `ErrorNullPointer` before any parsing. + #[test] + fn parse_invitation_null_uri_is_null_pointer() { + let mut preview = InvitationPreviewFFI::invalid(); + let r = unsafe { platform_wallet_parse_invitation(std::ptr::null(), &mut preview) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// A malformed link (wrong scheme) is a clean invalid PREVIEW, not an FFI + /// error — the sheet renders it as unclaimable instead of failing. + #[test] + fn parse_invitation_malformed_is_invalid_preview_not_error() { + let bad = std::ffi::CString::new("https://not-an-invite").unwrap(); + let mut preview = InvitationPreviewFFI::invalid(); + let r = unsafe { platform_wallet_parse_invitation(bad.as_ptr(), &mut preview) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::Success); + assert!(!preview.structurally_valid); + assert!(preview.inviter_username.is_null()); + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index b8282b57645..7440c42a9bd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -1793,6 +1793,29 @@ extension ManagedPlatformWallet { // MARK: - DashPay invitations (DIP-13) + /// Read-only preview of a `dashpay://invite` link, decoded via + /// `parseInvitation(uri:)` without claiming it. Drives the claim sheet's + /// pre-claim summary + the contact-bootstrap decision. + public struct InvitationPreview: Sendable { + /// The link decoded structurally. When false, every other field is unset + /// and the link is malformed / unreadable. + public let structurallyValid: Bool + /// The embedded asset-lock proof is an InstantSend proof. Claim only + /// accepts Instant proofs, so `false` means the link is unclaimable. + public let isInstant: Bool + /// The link carries inviter info (the contact-bootstrap is available). + public let hasInviter: Bool + /// Inviter identity id (32 bytes) when `hasInviter`, else nil. + public let inviterId: Data? + /// Inviter DPNS username when `hasInviter`, else nil. + public let inviterUsername: String? + /// Amount locked in the voucher (duffs); 0 for a non-Instant proof. + public let amountDuffs: UInt64 + /// Advisory expiry (unix seconds). Compare against the current time for + /// an "expired" badge. + public let expiryUnix: UInt32 + } + /// Create a DashPay invitation (DIP-13): fund a one-time asset-lock voucher /// at the invitation derivation path and return a shareable /// `dashpay://invite` link. @@ -1953,6 +1976,42 @@ extension ManagedPlatformWallet { }.value } + /// Read-only preview of a DashPay invitation link (DIP-13): decode a + /// `dashpay://invite` URI and surface its metadata WITHOUT claiming it — no + /// network, no identity registered. The claim UI uses this to show the + /// amount, sender, and expiry before the user commits, and to decide whether + /// to offer the "establish contact with ?" bootstrap. + /// + /// A malformed link is reported as `structurallyValid == false` rather than + /// throwing, so the UI can render a clean "invalid link" state. + public func parseInvitation(uri: String) throws -> InvitationPreview { + var out = InvitationPreviewFFI() + let result = uri.withCString { uriPtr in + platform_wallet_parse_invitation(uriPtr, &out) + } + try result.check() + // The Rust side heap-allocates the username C string when the link + // carries an inviter; free it once we've copied it into Swift. + defer { + if out.inviter_username != nil { + platform_wallet_string_free(out.inviter_username) + } + } + let inviterId: Data? = out.has_inviter + ? withUnsafeBytes(of: out.inviter_id) { Data($0) } + : nil + let inviterUsername: String? = out.inviter_username.map { String(cString: $0) } + return InvitationPreview( + structurallyValid: out.structurally_valid, + isInstant: out.is_instant, + hasInviter: out.has_inviter, + inviterId: inviterId, + inviterUsername: inviterUsername, + amountDuffs: out.amount_duffs, + expiryUnix: out.expiry_unix + ) + } + /// Accept an incoming contact request using an externally-supplied /// `KeychainSigner` for the reciprocal request's document /// state-transition. From 5967661ec0addeb2b5b4cd3435d6e51d481117ba Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 19:51:42 +0700 Subject: [PATCH 13/74] feat(swift-sdk): add DashPay claim-invitation flow (DIP-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ?" 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 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../Services/IdentityRegistrationKeys.swift | 122 ++++++++ .../Views/DashPay/ClaimInvitationSheet.swift | 271 ++++++++++++++++++ .../Views/DashPay/DashPayTabView.swift | 24 ++ 3 files changed, 417 insertions(+) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationKeys.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationKeys.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationKeys.swift new file mode 100644 index 00000000000..8b8a7acf665 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationKeys.swift @@ -0,0 +1,122 @@ +import Foundation +import SwiftDashSDK + +/// Identity-registration key derivation used by the invitation-claim flow. +/// +/// Claiming an invitation is ordinary identity registration funded by the +/// imported voucher, so the invitee's keys are derived exactly as for a normal +/// new-identity registration — including the DashPay Encryption/Decryption pair +/// that lets the new identity send a contact request back to the inviter. +/// +/// `makeDashpayKeyPair` is intentionally a MIRROR of +/// `CreateIdentityView.makeDashpayKeyPair`; any change to the DashPay-key +/// derivation policy must be made in both (a future refactor should unify them). +enum IdentityRegistrationKeys { + /// DashPay data-contract id — the enc/dec keys are contract-bounded to it. + static let dashpayContractId = Data([ + 162, 161, 180, 172, 111, 239, 34, 234, + 42, 26, 104, 232, 18, 54, 68, 179, + 87, 135, 95, 107, 65, 44, 24, 16, + 146, 129, 193, 70, 231, 178, 113, 188, + ]) + + /// DashPay document type these keys are bound to — the only one in the + /// contract that declares `requiresIdentityEncryptionBoundedKey`. + static let dashpayContactRequestDocumentType = "contactRequest" + + /// Derive + persist the DashPay Encryption (kid=firstKeyId) + Decryption + /// (kid=firstKeyId+1) key pair for a registering/claiming identity, bounded + /// to DashPay's `contactRequest` document type, MEDIUM security level, + /// ECDSA-secp256k1. Each key is cross-checked against its public key and + /// written to the Keychain by pubkey hash so the signing trampoline can find + /// it, then returned as `IdentityPubkey` rows to append to the registration / + /// claim key set. + @MainActor + static func makeDashpayKeyPair( + managedWallet: ManagedPlatformWallet, + walletId: Data, + identityIndex: UInt32, + firstKeyId: UInt32, + network: Network + ) throws -> [ManagedPlatformWallet.IdentityPubkey] { + let purposes: [(keyId: UInt32, purpose: KeyPurpose)] = [ + (firstKeyId, .encryption), + (firstKeyId + 1, .decryption), + ] + let bounds: ManagedPlatformWallet.ContractBounds = .singleContractDocumentType( + id: dashpayContractId, + documentTypeName: dashpayContactRequestDocumentType + ) + let walletIdHex = walletId.toHexString() + // Overwritten by the persister callback when the identity actually lands + // on-chain; the metadata written here only needs to satisfy the keychain + // round-trip lookup by pubkey hex. + let identityIdPlaceholder = "" + + var rows: [ManagedPlatformWallet.IdentityPubkey] = [] + rows.reserveCapacity(purposes.count) + + for (keyId, purpose) in purposes { + let preview = try managedWallet.deriveIdentityAuthKeyAtSlot( + identityIndex: identityIndex, + keyId: keyId, + network: network + ) + + // Defence against derivation drift / FFI marshalling bugs. A + // mismatched DashPay key lands on Platform as a key the trampoline + // can't sign with and surfaces as an opaque "encrypted xpub" failure + // on the first contact-request flow — much harder to debug after the + // fact than failing fast here. + guard + KeyValidation.validatePrivateKeyForPublicKey( + privateKeyHex: preview.privateKeyData.toHexString(), + publicKeyHex: preview.publicKeyHex, + keyType: .ecdsaSecp256k1, + network: network + ) + else { + throw PlatformWalletError.walletOperation( + "Derived DashPay key (kid \(keyId), purpose \(purpose.name)) didn't match its public key — refusing to persist" + ) + } + + let pubKeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex( + preview.publicKeyData + ) + let metadata = IdentityPrivateKeyMetadata( + identityId: identityIdPlaceholder, + keyId: keyId, + walletId: walletIdHex, + identityIndex: identityIndex, + keyIndex: keyId, + derivationPath: preview.derivationPath, + publicKey: preview.publicKeyHex, + publicKeyHash: pubKeyHashHex, + keyType: KeyType.ecdsaSecp256k1.rawValue, + purpose: purpose.rawValue, + securityLevel: SecurityLevel.medium.rawValue + ) + guard KeychainManager.shared.storeIdentityPrivateKey( + preview.privateKeyData, + derivationPath: preview.derivationPath, + metadata: metadata + ) != nil else { + throw PlatformWalletError.walletOperation( + "Could not persist DashPay key (kid \(keyId), purpose \(purpose.name)) to Keychain" + ) + } + rows.append( + ManagedPlatformWallet.IdentityPubkey( + keyId: keyId, + keyType: .ecdsaSecp256k1, + purpose: purpose, + securityLevel: .medium, + pubkeyBytes: preview.publicKeyData, + contractBounds: bounds + ) + ) + } + return rows + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift new file mode 100644 index 00000000000..3b685d8b94c --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift @@ -0,0 +1,271 @@ +import SwiftDashSDK +import SwiftData +import SwiftUI + +/// Claim a DashPay invitation (DIP-13): register a NEW identity for the invitee, +/// funded by the imported voucher, then optionally send a contact request back +/// to the inviter. +/// +/// The invitee pastes an invitation link; a read-only preview (amount, sender, +/// expiry) is shown before they commit. Claiming derives the invitee's own +/// identity keys — including a DashPay Encryption/Decryption pair so the new +/// identity can send the contact request back — exactly like a normal +/// registration, but funds the identity from the voucher instead of a wallet +/// UTXO. Mirrors `AddViaQRSheet`'s paste + async + `KeychainSigner` idiom. +struct ClaimInvitationSheet: View { + /// The wallet the new identity is registered under (must be loaded). + let walletId: Data + /// Network the identity is registered on. + let network: Network + + @EnvironmentObject private var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + /// Existing identities on this network — used to pick the next unused + /// registration index for the new identity. + @Query private var identities: [PersistentIdentity] + + @State private var uri: String + @State private var preview: ManagedPlatformWallet.InvitationPreview? + @State private var isClaiming = false + @State private var errorMessage: String? + @State private var contactPrompt: ContactPrompt? + + /// Post-claim "establish contact with ?" prompt payload. + private struct ContactPrompt: Identifiable { + let id = UUID() + let newIdentityId: Identifier + let inviterId: Identifier + let username: String + } + + /// Default identity auth-key count (mirrors `CreateIdentityView`). The + /// DashPay enc/dec pair is appended at ids `authKeyCount` / `authKeyCount+1`. + private static let authKeyCount: UInt32 = 4 + + init(walletId: Data, network: Network, initialURI: String = "") { + self.walletId = walletId + self.network = network + _uri = State(initialValue: initialURI) + let raw = network.rawValue + _identities = Query( + filter: #Predicate { $0.networkRaw == raw } + ) + } + + var body: some View { + NavigationStack { + Form { + inputSection + if let preview { + previewSection(preview) + } + if let errorMessage { + Section { + Text(errorMessage).font(.caption).foregroundColor(.red) + } + } + } + .navigationTitle("Claim Invitation") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if isClaiming { + ProgressView() + } else { + Button("Claim") { claim() } + .disabled(!canClaim) + .accessibilityIdentifier("dashpay.invite.claim.submit") + } + } + } + .onChange(of: uri) { _, _ in refreshPreview() } + .onAppear { refreshPreview() } + .alert( + contactPrompt.map { "Add \($0.username)?" } ?? "", + isPresented: Binding( + get: { contactPrompt != nil }, + set: { if !$0 { contactPrompt = nil } } + ), + presenting: contactPrompt + ) { prompt in + Button("Add") { sendContact(prompt) } + Button("Not now", role: .cancel) { dismiss() } + } message: { _ in + Text("Send a contact request to the person who invited you.") + } + } + } + + private var trimmedURI: String { + uri.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canClaim: Bool { + guard let preview, !isClaiming, !trimmedURI.isEmpty else { return false } + return preview.structurallyValid && preview.isInstant && !isExpired(preview) + } + + private func isExpired(_ p: ManagedPlatformWallet.InvitationPreview) -> Bool { + UInt32(Date().timeIntervalSince1970) > p.expiryUnix + } + + // MARK: - Sections + + @ViewBuilder private var inputSection: some View { + Section { + TextField("dashpay://invite?data=…", text: $uri, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .lineLimit(2...4) + .accessibilityIdentifier("dashpay.invite.claim.uriField") + } header: { + Text("Paste an invitation link") + } footer: { + Text("From a friend's “Invite a friend”. It funds a brand-new identity for you.") + } + } + + @ViewBuilder + private func previewSection(_ p: ManagedPlatformWallet.InvitationPreview) -> some View { + Section("Invitation") { + if !p.structurallyValid { + Label("This link isn't a valid invitation.", systemImage: "xmark.octagon") + .foregroundColor(.red) + } else if !p.isInstant { + Label( + "This invitation can't be claimed (missing an InstantSend proof).", + systemImage: "xmark.octagon" + ) + .foregroundColor(.red) + } else { + LabeledContent("Amount", value: formatDash(p.amountDuffs)) + if isExpired(p) { + Label("Expired — ask the sender for a new link.", systemImage: "clock.badge.xmark") + .foregroundColor(.orange) + } + if p.hasInviter, let name = p.inviterUsername { + LabeledContent("From", value: name) + } + } + } + } + + // MARK: - Actions + + private func refreshPreview() { + let trimmed = trimmedURI + guard !trimmed.isEmpty else { + preview = nil + return + } + guard let wallet = walletManager.wallet(for: walletId) else { + errorMessage = "No wallet loaded." + return + } + // A malformed link surfaces as `structurallyValid == false` (not a + // throw); a genuine parse error leaves the preview nil. + preview = try? wallet.parseInvitation(uri: trimmed) + } + + private func claim() { + guard canClaim, !isClaiming, let preview else { return } + isClaiming = true + errorMessage = nil + Task { @MainActor in + defer { isClaiming = false } + do { + guard let wallet = walletManager.wallet(for: walletId) else { + errorMessage = "No wallet loaded." + return + } + let signer = KeychainSigner(modelContainer: modelContext.container) + let identityIndex = nextUnusedIdentityIndex() + + // Register the invitee's own keys exactly like a normal + // registration: master + auth keys, plus a DashPay enc/dec pair + // so the new identity can send the contact request back. + var keys = try wallet.prePersistIdentityKeysForRegistration( + identityIndex: identityIndex, + keyCount: Self.authKeyCount, + network: network + ) + keys.append(contentsOf: try IdentityRegistrationKeys.makeDashpayKeyPair( + managedWallet: wallet, + walletId: walletId, + identityIndex: identityIndex, + firstKeyId: Self.authKeyCount, + network: network + )) + + let managed = try await wallet.claimInvitation( + uri: trimmedURI, + identityIndex: identityIndex, + identityPubkeys: keys, + signer: signer, + nowUnix: UInt32(Date().timeIntervalSince1970) + ) + let newIdentityId = try managed.getId() + kickDashPaySync(walletManager) + + // Offer the contact-bootstrap when the link carried an inviter; + // otherwise the claim is done. + if preview.hasInviter, + let inviterId = preview.inviterId, + let username = preview.inviterUsername { + contactPrompt = ContactPrompt( + newIdentityId: newIdentityId, + inviterId: inviterId, + username: username + ) + } else { + dismiss() + } + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func sendContact(_ prompt: ContactPrompt) { + Task { @MainActor in + guard let wallet = walletManager.wallet(for: walletId) else { + dismiss() + return + } + do { + let signer = KeychainSigner(modelContainer: modelContext.container) + _ = try await wallet.sendContactRequest( + senderIdentityId: prompt.newIdentityId, + recipientIdentityId: prompt.inviterId, + signer: signer + ) + kickDashPaySync(walletManager) + dismiss() + } catch { + // The identity is already registered; a failed contact request + // is non-fatal and re-sendable. Surface it but keep the sheet so + // the user sees the outcome. + errorMessage = "Identity claimed, but the contact request failed: \(error.localizedDescription)" + } + } + } + + /// One past the highest used registration index on this wallet, else 0. + /// Registration keys aren't gap-limited, so "next unused" is `max + 1`. + private func nextUnusedIdentityIndex() -> UInt32 { + let used = identities + .filter { $0.wallet?.walletId == walletId } + .map(\.identityIndex) + guard let highest = used.max() else { return 0 } + return highest == UInt32.max ? UInt32.max : highest + 1 + } + + private func formatDash(_ duffs: UInt64) -> String { + String(format: "%.8f DASH", Double(duffs) / 100_000_000) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 934050587a0..12e59a86637 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -33,6 +33,7 @@ struct DashPayTabView: View { @State private var segment: DashPaySegment = .contacts @State private var showAddContact = false @State private var showAddViaQR = false + @State private var showClaimInvitation = false /// Optimistic overlay for *send*: contact ids whose request /// was just broadcast but whose outgoing row hasn't landed via @@ -111,6 +112,14 @@ struct DashPayTabView: View { return eligibleIdentities.first } + /// Wallet the "Claim invitation" flow registers the new identity under. + /// Prefers the active identity's wallet, else the first loaded wallet on + /// this network — so a fresh invitee with no identity yet can still claim. + private var claimWalletId: Data? { + activeIdentity?.wallet?.walletId + ?? walletManager.wallets.keys.sorted { $0.lexicographicallyPrecedes($1) }.first + } + var body: some View { NavigationStack { content @@ -148,6 +157,15 @@ struct DashPayTabView: View { .disabled(activeIdentity == nil) .accessibilityIdentifier("dashpay.addViaQR") } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showClaimInvitation = true + } label: { + Image(systemName: "gift") + } + .disabled(claimWalletId == nil) + .accessibilityIdentifier("dashpay.claimInvitation") + } ToolbarItem(placement: .navigationBarLeading) { if let identity = activeIdentity { NavigationLink { @@ -166,6 +184,12 @@ struct DashPayTabView: View { .environmentObject(walletManager) } } + .sheet(isPresented: $showClaimInvitation) { + if let walletId = claimWalletId { + ClaimInvitationSheet(walletId: walletId, network: network) + .environmentObject(walletManager) + } + } .sheet(isPresented: $showAddContact) { if let identity = activeIdentity { AddContactView( From 4e0dfbedef14efbd0a84821bc7b890bd441d6cbc Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 19:54:25 +0700 Subject: [PATCH 14/74] feat(swift-sdk): handle dashpay:// invitation deep links 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 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- packages/swift-sdk/SwiftExampleApp/Info.plist | 15 +++++++++++++++ .../SwiftExampleApp/SwiftExampleAppApp.swift | 14 ++++++++++++++ .../Views/DashPay/DashPayTabView.swift | 19 +++++++++++++++++-- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/Info.plist b/packages/swift-sdk/SwiftExampleApp/Info.plist index b4168b66bef..f60a5f06926 100644 --- a/packages/swift-sdk/SwiftExampleApp/Info.plist +++ b/packages/swift-sdk/SwiftExampleApp/Info.plist @@ -30,6 +30,21 @@ NSCameraUsageDescription The camera is used to scan Dash address QR codes. + + CFBundleURLTypes + + + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER).dashpay-invite + CFBundleURLSchemes + + dashpay + + + + UIApplicationSceneManifest diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 99d8459348e..d6cc4cfd86f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -23,6 +23,12 @@ final class AppUIState: ObservableObject { /// IdentityDetailView's "Contacts" row jumps to the DashPay tab with /// that identity pre-selected. @Published var selectedTab: RootTab = .sync + + /// A `dashpay://invite?data=…` link opened via `.onOpenURL`, awaiting the + /// DashPay tab to pick it up and present the claim sheet pre-filled. Cleared + /// by the tab once consumed. (The URL embeds a one-time voucher key — treat + /// it as a secret; never log it.) + @Published var pendingInviteURL: String? } @main @@ -126,6 +132,14 @@ struct SwiftExampleAppApp: App { .environmentObject(transitionState) .environmentObject(appUIState) .environment(\.modelContext, modelContainer.mainContext) + .onOpenURL { url in + // DashPay invitation deep link: route to the DashPay tab and + // hand the URL to the claim sheet. The URL carries a bearer + // voucher key, so it is NOT logged here. + guard url.scheme?.lowercased() == "dashpay" else { return } + appUIState.selectedTab = .dashpay + appUIState.pendingInviteURL = url.absoluteString + } .task { SDKLogger.log("🚀 SwiftExampleApp: Starting initialization...", minimumLevel: .medium) await bootstrap() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 12e59a86637..c24f2162242 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -13,6 +13,7 @@ struct DashPayTabView: View { @Binding var selectedTab: RootTab @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject private var appUIState: AppUIState @EnvironmentObject var appState: AppState @Environment(\.modelContext) private var modelContext @Environment(\.scenePhase) private var scenePhase @@ -34,6 +35,7 @@ struct DashPayTabView: View { @State private var showAddContact = false @State private var showAddViaQR = false @State private var showClaimInvitation = false + @State private var claimInitialURI = "" /// Optimistic overlay for *send*: contact ids whose request /// was just broadcast but whose outgoing row hasn't landed via @@ -186,10 +188,23 @@ struct DashPayTabView: View { } .sheet(isPresented: $showClaimInvitation) { if let walletId = claimWalletId { - ClaimInvitationSheet(walletId: walletId, network: network) - .environmentObject(walletManager) + ClaimInvitationSheet( + walletId: walletId, + network: network, + initialURI: claimInitialURI + ) + .environmentObject(walletManager) } } + .onChange(of: appUIState.pendingInviteURL) { _, newValue in + // A dashpay://invite link opened via the app's .onOpenURL: + // pre-fill + present the claim sheet, then clear the pending + // URL so it isn't re-triggered. + guard let urlString = newValue else { return } + claimInitialURI = urlString + showClaimInvitation = true + appUIState.pendingInviteURL = nil + } .sheet(isPresented: $showAddContact) { if let identity = activeIdentity { AddContactView( From 01cd5664794dc7348ca4b237500f8f60b73cce37 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 20:47:44 +0700 Subject: [PATCH 15/74] test(platform-wallet-storage): allow-list invitations.rs read-only reader 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 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../rs-platform-wallet-storage/tests/sqlite_compile_time.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 1363f4a6693..12c5cb863f5 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -72,6 +72,10 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "pending_contact_crypto.rs", "SELECT wallet_id, payload FROM pending_contact_crypto", ), + ( + "invitations.rs", + "SELECT outpoint, status, funding_index, amount_duffs", + ), ]; /// TC-P1-003: writer paths in `src/sqlite/schema/*.rs` must not call From a50a8065b630ff2e2db4b3ce255985538799571c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 21:01:40 +0700 Subject: [PATCH 16/74] fix(swift-sdk): present invitation claim sheet on cold-launch deep link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../SwiftExampleApp/SwiftExampleAppApp.swift | 3 +- .../Views/DashPay/CreateInvitationSheet.swift | 1 + .../Views/DashPay/DashPayTabView.swift | 34 ++++++++++++++----- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index d6cc4cfd86f..542a55c80c9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -136,7 +136,8 @@ struct SwiftExampleAppApp: App { // DashPay invitation deep link: route to the DashPay tab and // hand the URL to the claim sheet. The URL carries a bearer // voucher key, so it is NOT logged here. - guard url.scheme?.lowercased() == "dashpay" else { return } + guard url.scheme?.lowercased() == "dashpay", + url.host?.lowercased() == "invite" else { return } appUIState.selectedTab = .dashpay appUIState.pendingInviteURL = url.absoluteString } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift index 2d9794c95bc..1c06410f85d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift @@ -166,6 +166,7 @@ struct CreateInvitationSheet: View { .padding(8) .background(Color.white) .cornerRadius(12) + .accessibilityHidden(true) } Text("Share this link with your friend. It funds their new identity — treat it like cash.") .font(.caption) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index c24f2162242..c4f38bb672e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -122,6 +122,18 @@ struct DashPayTabView: View { ?? walletManager.wallets.keys.sorted { $0.lexicographicallyPrecedes($1) }.first } + /// Present the claim sheet pre-filled for a pending `dashpay://invite` link + /// (captured by the app's `.onOpenURL` into `AppUIState.pendingInviteURL`) + /// and clear it so it isn't re-triggered. Invoked on both the warm path + /// (`.onChange`) and the cold-launch path (`.onAppear`); the nil guard makes + /// the second call after the first clears it a no-op (no double-present). + private func consumePendingInviteURL() { + guard let urlString = appUIState.pendingInviteURL else { return } + claimInitialURI = urlString + showClaimInvitation = true + appUIState.pendingInviteURL = nil + } + var body: some View { NavigationStack { content @@ -166,6 +178,7 @@ struct DashPayTabView: View { Image(systemName: "gift") } .disabled(claimWalletId == nil) + .accessibilityLabel("Claim invitation") .accessibilityIdentifier("dashpay.claimInvitation") } ToolbarItem(placement: .navigationBarLeading) { @@ -196,14 +209,19 @@ struct DashPayTabView: View { .environmentObject(walletManager) } } - .onChange(of: appUIState.pendingInviteURL) { _, newValue in - // A dashpay://invite link opened via the app's .onOpenURL: - // pre-fill + present the claim sheet, then clear the pending - // URL so it isn't re-triggered. - guard let urlString = newValue else { return } - claimInitialURI = urlString - showClaimInvitation = true - appUIState.pendingInviteURL = nil + .onChange(of: appUIState.pendingInviteURL) { _, _ in + // Warm path: the app is already running, so the tab observes + // the nil→url transition set by .onOpenURL. + consumePendingInviteURL() + } + .onAppear { + // Cold-launch path: .onOpenURL fires during scene connection, + // before this tab exists (ContentView shows "Initializing…" + // until bootstrap finishes), so .onChange never sees the + // transition. Consume any already-set pending URL when the tab + // first appears (.onOpenURL forces selectedTab = .dashpay, so + // this tab does appear). + consumePendingInviteURL() } .sheet(isPresented: $showAddContact) { if let identity = activeIdentity { From 0b51001e60c749c173f26e7a0c35c67b1e5a8604 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 21:24:13 +0700 Subject: [PATCH 17/74] fix(rs-sdk-ffi): enforce fully-hardened invitation export path 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 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../src/mnemonic_resolver_core_signer.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index 20e725c4835..f85ecff1d12 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -392,10 +392,17 @@ impl MnemonicResolverCoreSigner { let subfeature3 = ChildNumber::from_hardened_idx(3) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; let comps: &[ChildNumber] = path.as_ref(); + // Every component of an invitation-funding path is hardened. Enforcing + // that on `coin_type` and `funding_index` (not just the fixed + // purpose/feature/sub-feature) keeps this raw-scalar export boundary + // exactly as strict as its documented scope: a non-hardened tail is + // never a real voucher path, so refuse to export a key for it. if comps.len() != 5 || comps[0] != purpose9 + || !matches!(comps[1], ChildNumber::Hardened { .. }) || comps[2] != feature5 || comps[3] != subfeature3 + || !matches!(comps[4], ChildNumber::Hardened { .. }) { return Err(MnemonicResolverSignerError::DerivationFailed( "export_invitation_private_key: path is not an invitation-funding path".to_string(), @@ -755,9 +762,9 @@ mod tests { } /// The invitation export gate is stricter than the auto-accept one: it must - /// bind the full `9'/coin'/5'/3'/idx'` shape, because feature `5'` is shared - /// with the user's own identity-auth / registration-funding / top-up keys — - /// a looser gate would be a key-exfiltration hole (spec §5.3 Finding 2). + /// bind the full, fully-hardened `9'/coin'/5'/3'/idx'` shape, because feature + /// `5'` is shared with the user's own identity-auth / registration-funding / + /// top-up keys — a looser gate would be a key-exfiltration hole. #[test] fn export_invitation_private_key_gates_to_the_invitation_path() { let resolver = make_resolver(english_resolve); @@ -781,6 +788,8 @@ mod tests { "m/8'/1'/5'/3'/0'", // wrong purpose (comps[0] != 9') "m/9'/1'/5'/3'", // too short (len != 5) "m/9'/1'/5'/3'/0'/0'", // too long (len != 5) + "m/9'/1'/5'/3'/0", // non-hardened funding_index (comps[4]) + "m/9'/1/5'/3'/0'", // non-hardened coin_type (comps[1]) ] { let path = DerivationPath::from_str(bad).expect("valid path string"); assert!( From 2d568b78febdb894f076d31ee442f876ce665081 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 21:24:35 +0700 Subject: [PATCH 18/74] fix(platform-wallet): fold invitation review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../rs-platform-wallet-ffi/src/invitation.rs | 12 ++- .../src/changeset/changeset.rs | 9 +- .../src/wallet/identity/crypto/invitation.rs | 40 +++++-- .../src/wallet/identity/network/invitation.rs | 102 +++++++++++------- 4 files changed, 115 insertions(+), 48 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 3aabbc916a3..6cb0a74ec24 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -111,8 +111,8 @@ pub unsafe extern "C" fn platform_wallet_create_invitation( ); } // Derive the advisory expiry: a fixed ~24h window from now, inside the - // InstantSend validity bound (spec §5.1). `saturating_add` can't overflow a - // realistic `now`, but keeps the arithmetic total. + // InstantSend validity bound. `saturating_add` can't overflow a realistic + // `now`, but keeps the arithmetic total. let expiry_unix = now_unix.saturating_add(MAX_INVITATION_TTL_SECS); // Build the optional inviter info: present iff `inviter_identity_id` is @@ -258,6 +258,14 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( check_ptr!(identity_pubkeys); check_ptr!(out_identity_id); check_ptr!(out_identity_handle); + // Publish FFI-safe sentinels before any fallible work so every early return + // leaves the out-params well-defined (matching the create/parse siblings): + // a caller that reads them without checking the result code gets zeros, not + // an uninitialized handle it might feed into `MANAGED_IDENTITY_STORAGE`. + unsafe { + *out_identity_id = [0u8; 32]; + *out_identity_handle = NULL_HANDLE; + } if identity_pubkeys_count == 0 { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index ee614ecaefb..42b72922f60 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -919,7 +919,14 @@ pub struct InvitationChangeSet { impl Merge for InvitationChangeSet { fn merge(&mut self, other: Self) { - // Last write wins — later status is higher finality. + // Last write wins — later status is higher finality. `invitations` and + // `removed` merge independently with no per-key reconciliation, and the + // sqlite writer applies inserts before deletes, so an outpoint present + // in both a merged round's insert and remove sets resolves to "removed" + // (same hazard/mitigation as `IdentityChangeSet`: emit at most one + // action per key per mutation). The only current emitter, + // `create_invitation`, is insert-only, so this is latent until reclaim / + // status-sync emitters land. self.invitations.extend(other.invitations); self.removed.extend(other.removed); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs index 2d3d4b7fe22..2a32ffbadad 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -32,7 +32,7 @@ use zeroize::Zeroizing; use crate::error::PlatformWalletError; /// URI prefix for an invitation deep link. The `dashpay://invite` scheme matches -/// the reference wallets for familiarity; the payload is our own (§7 of the spec). +/// the reference wallets for familiarity; the payload is our own. const INVITATION_URI_PREFIX: &str = "dashpay://invite?data="; /// Max base58 chars of the `data=` value accepted **before** decoding (anti-DoS). @@ -308,17 +308,26 @@ pub fn parse_invitation_uri(uri: &str) -> Result Result<(), PlatformWalletError> { + // A zero `now` would make the `now > expiry` test below pass for any + // positive expiry, silently treating an expired link as fresh. Reject it up + // front, mirroring the create side's non-zero timestamp guard. + if now_unix == 0 { + return Err(invalid( + "invitation claim requires a valid clock (now_unix is zero)", + )); + } if now_unix > invitation.expiry_unix { return Err(invalid(format!( "invitation expired (expiry {}, now {now_unix}) — ask the sender for a new one", @@ -523,6 +532,23 @@ mod tests { assert!(err.to_string().contains("expired")); } + #[test] + fn validate_rejects_zero_clock() { + // A zero clock read must be rejected up front: otherwise `now(0) > + // expiry` is false and even a long-expired link looks claimable. Here + // the expiry is well in the past, so only the zero-clock guard can + // reject it — a regression that dropped the guard would return `Ok`. + let key = voucher(); + let parsed = ParsedInvitation { + voucher_key: key, + asset_lock: instant_proof_paying_to(&key), + expiry_unix: 1_000, + inviter: None, + }; + let err = validate_claimable(&parsed, 0).unwrap_err(); + assert!(err.to_string().contains("clock")); + } + #[test] fn validate_rejects_chain_proof() { let key = voucher(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index dd5e9a14b76..811dbeae912 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -40,8 +40,8 @@ use super::*; /// Hard cap on the amount an invitation can lock (0.01 DASH). The voucher is a /// bearer credential, so the blast radius of a leaked link is bounded here in -/// Rust — not just in the UI (spec §8 Finding 4). Generous enough for identity -/// registration plus a small starting balance; tune if onboarding needs more. +/// Rust — not just in the UI. Generous enough for identity registration plus a +/// small starting balance; tune if onboarding needs more. pub const MAX_INVITATION_DUFFS: u64 = 1_000_000; /// Default TTL (24h) for an invitation's advisory expiry. The FFI sets @@ -49,9 +49,22 @@ pub const MAX_INVITATION_DUFFS: u64 = 1_000_000; /// leaked-link finder holds the voucher key and ignores it — so it bounds only /// the honest UI (don't submit an about-to-go-stale IS proof) and the reclaim /// signal, NOT a leaked-link window. The real leak bound is `MAX_INVITATION_DUFFS`. -/// See spec §8. pub const MAX_INVITATION_TTL_SECS: u32 = 24 * 60 * 60; +/// RAII scrub for the voucher `PrivateKey` copy used on the claim path. +/// `dashcore::PrivateKey` wraps a `secp256k1::SecretKey` that has no +/// Drop-zeroize, so the imported bearer scalar would otherwise linger in memory +/// after `claim_invitation` returns on every exit path (success or error). +/// Wiping it here mirrors the create path's explicit scrub of the exported +/// scalar — the voucher key is treated as bearer money end to end. +struct WipingPrivateKey(PrivateKey); + +impl Drop for WipingPrivateKey { + fn drop(&mut self) { + self.0.inner.non_secure_erase(); + } +} + /// A freshly-created invitation: the shareable link plus the bookkeeping the /// inviter tracks to reclaim an unclaimed voucher. pub struct Invitation { @@ -190,42 +203,53 @@ impl IdentityWallet { // it is path-gated to the invitation sub-feature inside the provider. let mut voucher_key = crypto_provider.export_invitation_private_key(&path).await?; - let uri = encode_invitation_uri(&voucher_key, &proof, expiry_unix, inviter.as_ref())?; - - // Scrub the exported scalar now that it lives in the (secret) URI. - // secp256k1's `SecretKey` has no Drop-zeroize, so wipe it explicitly — - // matching the resolver signer's key hygiene (review LOW-1). + // Build the (secret) URI, then scrub the exported scalar on BOTH the + // success and the encode-error path. `secp256k1::SecretKey` has no + // Drop-zeroize, so wipe it explicitly; scrubbing before propagating an + // encode failure matters most there — on that path the key never + // legitimately left the device, so a lingering copy is the worst kind. + let uri_result = encode_invitation_uri(&voucher_key, &proof, expiry_unix, inviter.as_ref()); voucher_key.non_secure_erase(); + let uri = uri_result?; // Persist an inviter-side invitation record for the "Sent invitations" // list + (future) reclaim. No secret is stored — `funding_index` - // re-derives the voucher key. Best-effort: the link is already valid, so - // a persistence failure must not fail the create. - let funding_index = match path.as_ref().last() { - Some(ChildNumber::Hardened { index }) => *index, - _ => 0, - }; - let mut inv_cs = InvitationChangeSet::default(); - inv_cs.invitations.insert( - out_point, - InvitationEntry { - out_point, - funding_index, - amount_duffs, - expiry_unix, - created_at_secs: expiry_unix.saturating_sub(MAX_INVITATION_TTL_SECS), - has_inviter: inviter.is_some(), - status: InvitationStatus::Created, - }, - ); - if let Err(e) = self - .persister - .store(crate::changeset::PlatformWalletChangeSet { - invitations: Some(inv_cs), - ..Default::default() - }) + // re-derives the voucher key, so it MUST be the real hardened index. + // If the derivation path ever has an unexpected (non-hardened) tail, + // skip the record with a warning rather than persist a wrong index that + // would re-derive the wrong key on reclaim. Best-effort either way: the + // link is already valid, so neither branch fails the create. + if let Some(ChildNumber::Hardened { + index: funding_index, + }) = path.as_ref().last().copied() { - tracing::warn!(error = %e, "failed to persist invitation record; the link is still valid"); + let mut inv_cs = InvitationChangeSet::default(); + inv_cs.invitations.insert( + out_point, + InvitationEntry { + out_point, + funding_index, + amount_duffs, + expiry_unix, + created_at_secs: expiry_unix.saturating_sub(MAX_INVITATION_TTL_SECS), + has_inviter: inviter.is_some(), + status: InvitationStatus::Created, + }, + ); + if let Err(e) = self + .persister + .store(crate::changeset::PlatformWalletChangeSet { + invitations: Some(inv_cs), + ..Default::default() + }) + { + tracing::warn!(error = %e, "failed to persist invitation record; the link is still valid"); + } + } else { + tracing::warn!( + "invitation funding path has an unexpected non-hardened tail; \ + skipping the local invitation record to avoid persisting a wrong funding index" + ); } Ok(Invitation { @@ -261,14 +285,16 @@ impl IdentityWallet { where S: Signer + Send + Sync, { - // Fail fast on a stale / wrong-type / mismatched link before any network. + // Fail fast on a stale / wrong-type / mismatched link (and a zero clock + // read) before any network. validate_claimable(&invitation, now_unix)?; preflight_keys_map(&keys_map)?; // The voucher key signs the asset lock's outer ST signature (ECDSA over - // the credit-output pubkey hash). Convert to the SDK's `PrivateKey`. + // the credit-output pubkey hash). Convert to the SDK's `PrivateKey`, + // scrubbed on every exit path by the `WipingPrivateKey` guard. let network = self.sdk.network; - let voucher_priv = PrivateKey::new(invitation.voucher_key, network); + let voucher_priv = WipingPrivateKey(PrivateKey::new(invitation.voucher_key, network)); let placeholder = Identity::V0(IdentityV0 { id: Identifier::default(), @@ -286,7 +312,7 @@ impl IdentityWallet { .put_to_platform_and_wait_for_response_with_private_key( &self.sdk, invitation.asset_lock.clone(), - &voucher_priv, + &voucher_priv.0, identity_signer, settings, ) From c2ddfcff1371aed737f11b328345586ab07a6ba4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 21:27:35 +0700 Subject: [PATCH 19/74] =?UTF-8?q?docs(dip15):=20sync=20spec=20=C2=A74.1/?= =?UTF-8?q?=C2=A76=20with=20as-built=20code=20+=20note=20scheme=20limitati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4.1 create_invitation signature: drop the explicit invitation_index param (the asset-lock builder auto-selects the next unused funding index) and add the crypto_provider param; note the fully-hardened export path. - §6 envelope: describe the hand-rolled little-endian binary encoding (not serde/bincode), correct the wire field order (asset_lock is last, length- prefixed: version, voucher_key, expiry_unix, inviter, asset_lock), and fix the encode_invitation_uri / parse_invitation_uri signatures. - New §6.1 documenting the bearer-credential / custom-scheme limitation and the Universal Links production follow-up (loss bounded by MAX_INVITATION_DUFFS). Addresses CodeRabbit: create_invitation signature drift, §6 envelope drift, and the Info.plist custom-scheme security note. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- docs/dashpay/DIP15_INVITATIONS_SPEC.md | 80 ++++++++++++++++---------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md index c097e69219f..ad67251b8dc 100644 --- a/docs/dashpay/DIP15_INVITATIONS_SPEC.md +++ b/docs/dashpay/DIP15_INVITATIONS_SPEC.md @@ -122,26 +122,27 @@ code is (a) the create orchestration + voucher-key export, (b) the claim orchest **Create (inviter):** ``` -async fn create_invitation( +async fn create_invitation( &self, - amount_duffs: u64, // rejected if > MAX_INVITATION_DUFFS (§8 Finding 4) + amount_duffs: u64, // rejected if 0 or > MAX_INVITATION_DUFFS funding_account_index: u32, // BIP44 account supplying the L1 UTXOs - invitation_index: u32, // DIP-13 funding_index' (sequential; next-unused) - inviter_identity: Option, // id + username + display_name (contact-bootstrap) - expiry_unix: u32, // advisory; ≤ now + MAX_INVITATION_TTL (§8 Finding 3) - asset_lock_signer: &AS, // MnemonicResolverCoreSigner (funding-input + credit-output) + inviter: Option, // id + username + display_name (contact-bootstrap) + expiry_unix: u32, // advisory; the FFI sets now + MAX_INVITATION_TTL_SECS + asset_lock_signer: &AS, // funds the asset-lock (funding-input + credit-output) + crypto_provider: &CP, // exports the voucher scalar (path-gated resolver) ) -> Result ``` -where `inviter_identity: Option` is `Some` only when the inviter ticked "send a +where `inviter: Option` is `Some` only when the inviter ticked "send a contact request back to me" (§ owner decision). Steps: (1) **bound the amount** -(`amount_duffs ≤ MAX_INVITATION_DUFFS`) and the expiry (`≤ now + MAX_INVITATION_TTL`), else err; -(2) `create_funded_asset_lock_proof(amount, funding_account_index, IdentityInvitation, -invitation_index, signer)` → `(IS proof, path, out_point)` — **keep the IS proof, no CL upgrade** -(§5.1); (3) **export the voucher private key** via the seedless resolver hook, **path-gated to +(`0 < amount_duffs ≤ MAX_INVITATION_DUFFS`) and the expiry (non-zero), else err; +(2) `create_funded_asset_lock_proof(amount, funding_account_index, IdentityInvitation, signer)` +→ `(IS proof, path, out_point)` — **the builder auto-selects the next unused funding index** and +returns its derivation `path`; **keep the IS proof, no CL upgrade** (§5.1); (3) **export the +voucher private key** via the seedless resolver hook, **path-gated to the fully-hardened `9'/coin'/5'/3'/idx'`** (§5.3); (4) build the `Invitation` struct + `dashpay://invite` URI (§6); (5) **persist an invitation record** through the wallet persister (§4.2) — created status, -outpoint, funding_index, amount, expiry, optional inviter info; **the voucher key is never -persisted** (re-derived from `funding_index`). +outpoint, funding_index (from `path`), amount, expiry, optional inviter info; **the voucher key is +never persisted** (re-derived from `funding_index`). **Claim (invitee):** ``` @@ -371,22 +372,22 @@ no analytics, sensitive-pasteboard flag on the Swift side (§8 Finding 3). **Decision: one opaque, versioned payload** behind a `dashpay://invite?data=` deep link (keeping the reference's `dashpay://invite` scheme name for familiarity), **not** the -reference's six loose query params. Rationale in §7. The payload is a small versioned struct -(serde → bincode via platform-serialization), so the envelope can evolve without breaking older -links: +reference's six loose query params. Rationale in §7. The payload is a small versioned blob in a +**hand-rolled little-endian binary encoding** (deliberately *not* serde/bincode — the crate's +`serde` feature is optional and off, and `AssetLockProof` is internally-tagged so bincode-serde +rejects it), so the envelope can evolve without breaking older links. The as-built wire order +(see `crypto/invitation.rs`) is: ``` -InvitationPayloadV0 { - version: u8, // = 0 - voucher_key: [u8; 32], // one-time ECDSA private key (secret; zeroized) - asset_lock: AssetLockProof, // InstantSend proof per §5.1 — embeds tx + islock - expiry_unix: u32, // ADVISORY, IS-scoped (§5.1/§8 Finding 3); not consensus - inviter: Option { // present iff the inviter opted in ("send request back") - identity_id: [u8; 32], - username: String, // DPNS name (whom the invitee's contactRequest targets) - display_name: Option, - }, // NO auto-accept dapk — v1 sends a normal contactRequest, invitee-confirmed (§2) -} +wire = version:u8 // = 0 + ‖ voucher_key:[u8; 32] // one-time ECDSA private key (secret; zeroized) + ‖ expiry_unix:u32(LE) // ADVISORY, IS-scoped (§5.1); not consensus + ‖ inviter_present:u8 // 0 = none, 1 = InviterInfo follows + [ identity_id:[u8; 32] + ‖ username:len-prefixed // DPNS name (whom the invitee's contactRequest targets) + ‖ display_present:u8 [ display_name:len-prefixed ] ] + ‖ asset_lock:len-prefixed // InstantSend proof (§5.1) — embeds tx + islock; LAST, length-prefixed + // NO auto-accept dapk — v1 sends a normal contactRequest, invitee-confirmed (§2) ``` - Serializing the `InstantAssetLockProof` directly means the link **embeds the full funding tx + islock**, so the invitee needs **no L1 tx fetch** (an improvement over the reference, which @@ -398,10 +399,29 @@ InvitationPayloadV0 { bounded and panic-free** on arbitrary bytes (dashcore `MAX_VEC_SIZE`, finite cursor, all `Result`-based — verified), so the residual is only "a huge blob is fully buffered," which the pre-decode char cap closes. A fuzz test is cheap insurance, not a blocker. -- A pure `encode_invitation_uri(&InvitationPayload) -> String` / `parse_invitation_uri(&str) -> - Result` pair in `crypto/invitation.rs`, fully unit-tested (round-trip + +- The codec pair in `crypto/invitation.rs` is + `encode_invitation_uri(voucher_key: &SecretKey, asset_lock: &AssetLockProof, expiry_unix: u32, + inviter: Option<&InviterInfo>) -> Result` and + `parse_invitation_uri(uri: &str) -> Result`, fully unit-tested (round-trip + every malformed rejection). A plain `https://…` fallback host can wrap the same `?data=` for - users without the app installed — deferred (no hosting in v1). + users without the app installed — deferred (no hosting in v1; see §6.1). + +### 6.1 Transport security & the custom-scheme limitation + +The `data=` payload is a **bearer credential**: whoever reads the plaintext link controls the +voucher and can claim (front-run) it. Because the app registers the `dashpay://` **custom URL +scheme**, any other app that also registers `dashpay` can intercept an invite link on the same +device and steal the claim. The load-bearing mitigation is therefore **economic, not transport**: +`MAX_INVITATION_DUFFS` caps the loss at 0.01 DASH, and the inviter can reclaim an unclaimed voucher +(best-effort race). The advisory expiry does **not** bound a leak (a leaked-link holder ignores it). + +A hardened production transport would use **Universal Links** (HTTPS + a hosted +`apple-app-site-association`, `associated-domains` entitlement) or another verified handoff so the +OS can't hand the link to an impostor app. That is **out of scope for this example app** — it +needs hosting infrastructure the sample doesn't have, and the amount cap already bounds the blast +radius — but it is the recommended path for the production wallet and is tracked as a follow-up. +The `?data=` shape is transport-agnostic, so moving from the custom scheme to a Universal Link is a +routing change, not an envelope change. --- From a001d269c5a8024c563783a728b8a750b386b42a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 21:31:01 +0700 Subject: [PATCH 20/74] fix(swift-sdk): re-present invitation claim sheet on a second deep link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second dashpay://invite link arriving while the claim sheet is already open was dropped: ClaimInvitationSheet seeds its `uri` @State once from initialURI at init, so setting showClaimInvitation=true when already true is a no-op for .sheet(isPresented:) and the new URI never reaches the presented sheet (repro: tap "gift" to open, then a link arrives → ignored). Drive the claim sheet via .sheet(item:) keyed on an Identifiable `ClaimInvite` (fresh id per invocation): assigning a new value re-presents the sheet — even one already open — re-seeded with the new URI. Also clear the bearer pendingInviteURL immediately in consumePendingInviteURL (don't linger a secret in @Published), and only present when a wallet is loaded. CodeRabbit finding (DashPayTabView). SwiftExampleApp xcodebuild green (iPhone 17, arm64). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../Views/DashPay/DashPayTabView.swift | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index c4f38bb672e..d547132e1d9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -34,8 +34,19 @@ struct DashPayTabView: View { @State private var segment: DashPaySegment = .contacts @State private var showAddContact = false @State private var showAddViaQR = false - @State private var showClaimInvitation = false - @State private var claimInitialURI = "" + + /// Drives the claim sheet via `.sheet(item:)`. A fresh value (new `id`) + /// re-presents the sheet — so a second `dashpay://invite` link arriving while + /// the sheet is already open re-seeds it with the new URI instead of being + /// dropped (`.sheet(isPresented:)` can't re-seed an already-presented sheet + /// whose `uri` is seeded once at init). + private struct ClaimInvite: Identifiable { + let id = UUID() + let walletId: Data + let initialURI: String + } + + @State private var claimInvite: ClaimInvite? /// Optimistic overlay for *send*: contact ids whose request /// was just broadcast but whose outgoing row hasn't landed via @@ -129,9 +140,14 @@ struct DashPayTabView: View { /// the second call after the first clears it a no-op (no double-present). private func consumePendingInviteURL() { guard let urlString = appUIState.pendingInviteURL else { return } - claimInitialURI = urlString - showClaimInvitation = true + // 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, so a second invite link arriving mid-claim + // re-seeds it with the new URI instead of being dropped. + claimInvite = ClaimInvite(walletId: walletId, initialURI: urlString) } var body: some View { @@ -173,7 +189,9 @@ struct DashPayTabView: View { } ToolbarItem(placement: .navigationBarTrailing) { Button { - showClaimInvitation = true + if let walletId = claimWalletId { + claimInvite = ClaimInvite(walletId: walletId, initialURI: "") + } } label: { Image(systemName: "gift") } @@ -199,15 +217,13 @@ struct DashPayTabView: View { .environmentObject(walletManager) } } - .sheet(isPresented: $showClaimInvitation) { - if let walletId = claimWalletId { - ClaimInvitationSheet( - walletId: walletId, - network: network, - initialURI: claimInitialURI - ) - .environmentObject(walletManager) - } + .sheet(item: $claimInvite) { invite in + ClaimInvitationSheet( + walletId: invite.walletId, + network: network, + initialURI: invite.initialURI + ) + .environmentObject(walletManager) } .onChange(of: appUIState.pendingInviteURL) { _, _ in // Warm path: the app is already running, so the tab observes From 10fbc579567eb7143beb8eae0d8d15864817778b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 13:35:33 +0700 Subject: [PATCH 21/74] fix(platform-wallet): persist asset-lock account used-index across restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IdentityInvitation / IdentityRegistration / IdentityTopUp / asset-lock top-up funding accounts fund credit outputs that live only in an asset-lock special-tx payload; the on-chain output is an OP_RETURN burn, so these addresses never appear as UTXOs and SPV can never rediscover their used indices. The build path marked the funding index used only in memory, and the load path dropped these accounts' persisted pools (`_ => None`), so `funding_index` reset to 0 on every app restart. For IdentityInvitation that reused the EXPORTED one-time voucher key across invitations — one leaked link could then claim every same-key invite (asset-lock proofs are public on-chain). Fix (platform-only, reuses the existing account_address_pools persist/restore that already works for funds accounts): - build.rs: after a build marks the funding index used, snapshot the asset-lock funding accounts' address pools into the account_address_pools changeset. - persistence.rs restore routing: route asset-lock account pools back to their managed account (via ManagedAccountRefMut) instead of dropping them, so restore_address_pool rebuilds highest_used → next_unused is monotonic. Fixes all asset-lock funding accounts uniformly (registration/topup share the reset but are harmless there — their keys never leave the device). Compiles clean; existing tests green (15 asset_lock build + 146 platform-wallet-ffi). Follow-up (next commit): red->green regression test pinning index monotonicity across a persist->restore round-trip + multi-agent review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../rs-platform-wallet-ffi/src/persistence.rs | 105 ++++++++++++++---- .../src/wallet/asset_lock/build.rs | 85 ++++++++++++++ 2 files changed, 168 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 4d614581c2f..6fa154bdf90 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -11,6 +11,7 @@ use key_wallet::account::{Account, AccountType, StandardAccountType}; use key_wallet::bip32::DerivationPath; use key_wallet::bip32::ExtendedPubKey; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, PublicKeyType}; +use key_wallet::managed_account::managed_account_ref::ManagedAccountRefMut; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -2967,7 +2968,6 @@ fn build_wallet_start_state( // restored wallet can hold a UTXO whose address the signer can't // map back to a derivation path, breaking core-to-core spends. { - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; let pool_entries: &[AccountAddressPoolFFI] = if entry.core_address_pools.is_null() || entry.core_address_pools_count == 0 { &[] @@ -3004,43 +3004,104 @@ fn build_wallet_start_state( continue; } }; - let funds = match account_type { + // Route the persisted pool back to its managed account. Wrapped in + // `ManagedAccountRefMut` because funds accounts and the asset-lock + // key-accounts are distinct types that share `ManagedAccountTrait`. + // + // The asset-lock funding accounts (registration / top-up / + // invitation / …) MUST be restored here: their credit outputs are + // OP_RETURN-payload outputs that never appear as on-chain UTXOs, so + // SPV can never rediscover their used indices — the persisted pool + // is the ONLY thing that carries the next-unused index across a + // restart. Dropping them (the old `_ => None`) reset the pool to + // index 0 every launch; for `IdentityInvitation` that reused the + // EXPORTED one-time voucher key across invitations (a bearer-key + // reuse: one leaked link could then claim every same-key invite). + let funds: Option = match account_type { AccountType::Standard { index, standard_account_type: StandardAccountType::BIP44Account, - } => wallet_info.accounts.standard_bip44_accounts.get_mut(&index), + } => wallet_info + .accounts + .standard_bip44_accounts + .get_mut(&index) + .map(ManagedAccountRefMut::Funds), AccountType::Standard { index, standard_account_type: StandardAccountType::BIP32Account, - } => wallet_info.accounts.standard_bip32_accounts.get_mut(&index), - AccountType::CoinJoin { index } => { - wallet_info.accounts.coinjoin_accounts.get_mut(&index) - } + } => wallet_info + .accounts + .standard_bip32_accounts + .get_mut(&index) + .map(ManagedAccountRefMut::Funds), + AccountType::CoinJoin { index } => wallet_info + .accounts + .coinjoin_accounts + .get_mut(&index) + .map(ManagedAccountRefMut::Funds), AccountType::DashpayReceivingFunds { index, user_identity_id, friend_identity_id, - } => wallet_info.accounts.dashpay_receival_accounts.get_mut( - &key_wallet::account::account_collection::DashpayAccountKey { - index, - user_identity_id, - friend_identity_id, - }, - ), + } => wallet_info + .accounts + .dashpay_receival_accounts + .get_mut( + &key_wallet::account::account_collection::DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }, + ) + .map(ManagedAccountRefMut::Funds), AccountType::DashpayExternalAccount { index, user_identity_id, friend_identity_id, - } => wallet_info.accounts.dashpay_external_accounts.get_mut( - &key_wallet::account::account_collection::DashpayAccountKey { - index, - user_identity_id, - friend_identity_id, - }, - ), + } => wallet_info + .accounts + .dashpay_external_accounts + .get_mut( + &key_wallet::account::account_collection::DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }, + ) + .map(ManagedAccountRefMut::Funds), + AccountType::IdentityRegistration => wallet_info + .accounts + .identity_registration + .as_mut() + .map(ManagedAccountRefMut::Keys), + AccountType::IdentityTopUp { registration_index } => wallet_info + .accounts + .identity_topup + .get_mut(®istration_index) + .map(ManagedAccountRefMut::Keys), + AccountType::IdentityTopUpNotBoundToIdentity => wallet_info + .accounts + .identity_topup_not_bound + .as_mut() + .map(ManagedAccountRefMut::Keys), + AccountType::IdentityInvitation => wallet_info + .accounts + .identity_invitation + .as_mut() + .map(ManagedAccountRefMut::Keys), + AccountType::AssetLockAddressTopUp => wallet_info + .accounts + .asset_lock_address_topup + .as_mut() + .map(ManagedAccountRefMut::Keys), + AccountType::AssetLockShieldedAddressTopUp => wallet_info + .accounts + .asset_lock_shielded_address_topup + .as_mut() + .map(ManagedAccountRefMut::Keys), _ => None, }; - let Some(funds_account) = funds else { + let Some(mut funds_account) = funds else { pools_dropped += 1; tracing::warn!( wallet_id = %hex::encode(entry.wallet_id), diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 5a3444a3159..5dd1039f09d 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -265,6 +265,83 @@ impl AssetLockManager { }) } + /// Persist the asset-lock funding accounts' address-pool snapshots so a + /// consumed `funding_index` survives an app restart. + /// + /// The `IdentityRegistration` / `IdentityTopUp` / `IdentityInvitation` / + /// asset-lock-top-up accounts fund credit outputs that live only in an + /// asset-lock special-tx payload; the on-chain output is an `OP_RETURN` + /// burn, so these addresses never appear as UTXOs and SPV can never + /// rediscover their used indices. Without persisting the pool, the + /// in-memory `mark_used` is lost on restart and `next_unused` resets to 0 — + /// which for `IdentityInvitation` reuses the EXPORTED one-time voucher key + /// across invitations (a bearer-key reuse: one leaked link could then claim + /// every same-key invite). The pool round-trips through the existing + /// `account_address_pools` persist path and is rebuilt by + /// `restore_address_pool` on load. Funds accounts are skipped — they already + /// persist their pools via the normal address-sync path. Best-effort. + async fn persist_asset_lock_account_pools(&self) { + use crate::changeset::{AccountAddressPoolEntry, PlatformWalletChangeSet}; + use key_wallet::account::AccountType; + + let entries: Vec = { + let wm = self.wallet_manager.read().await; + let Some(wallet_info) = wm.get_wallet_info(&self.wallet_id) else { + return; + }; + wallet_info + .core_wallet + .all_managed_accounts() + .iter() + .filter(|managed| { + matches!( + managed.managed_account_type().to_account_type(), + AccountType::IdentityRegistration + | AccountType::IdentityTopUp { .. } + | AccountType::IdentityTopUpNotBoundToIdentity + | AccountType::IdentityInvitation + | AccountType::AssetLockAddressTopUp + | AccountType::AssetLockShieldedAddressTopUp + ) + }) + .flat_map(|managed| { + let account_type = managed.managed_account_type().to_account_type(); + managed + .managed_account_type() + .address_pools() + .into_iter() + .filter_map(move |pool| { + let addresses: Vec = + pool.addresses.values().cloned().collect(); + if addresses.is_empty() { + return None; + } + Some(AccountAddressPoolEntry { + account_type, + pool_type: pool.pool_type, + addresses, + }) + }) + .collect::>() + }) + .collect() + }; + + if entries.is_empty() { + return; + } + if let Err(e) = self.persister.store(PlatformWalletChangeSet { + account_address_pools: entries, + ..Default::default() + }) { + tracing::error!( + error = %e, + "failed to persist asset-lock account pool snapshot; \ + funding index may reset on restart" + ); + } + } + /// Build, broadcast, and wait for an asset lock proof. /// /// This is the **unified** entry point for obtaining a funded asset lock @@ -324,6 +401,14 @@ impl AssetLockManager { let txid = tx.txid(); let out_point = OutPoint::new(txid, 0); + // Persist the funding account's address pool now that the build marked + // its index used. These asset-lock accounts fund OP_RETURN-payload + // credit outputs that never appear as on-chain UTXOs, so SPV can never + // rediscover the used index — the persisted pool is the only thing that + // carries `funding_index` across a restart. Best-effort, like the + // tracking changeset below. + self.persist_asset_lock_account_pools().await; + // 2. Track as Built and queue the changeset onto the persister // so a crash after broadcast leaves a row we can recover from. let cs_built = self From 74675fca4dd5da2efbb956d8ba6ac592ad2a34a4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 13:42:36 +0700 Subject: [PATCH 22/74] test(platform-wallet): pin asset-lock funding-index persistence Regression test for the voucher-key-reuse fix: a build must persist the IdentityInvitation account's address-pool snapshot with the used funding index, so funding_index survives a restart instead of resetting to 0 and reusing the exported one-time voucher key. Red->green verified: with self.persist_asset_lock_account_pools() disabled the test FAILS ('a build must persist the IdentityInvitation account's pool...'); with it enabled it PASSES. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../src/wallet/asset_lock/build.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 5dd1039f09d..5374c7f1eb5 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -600,6 +600,50 @@ mod tests { (manager, signer, persistence) } + /// Regression: a build must persist the funding account's address-pool + /// snapshot with the newly-used index. The asset-lock funding accounts fund + /// OP_RETURN-payload credit outputs that never appear as on-chain UTXOs, so + /// SPV can't rediscover the used index — the persisted pool is the only + /// thing that carries `funding_index` across a restart. Before the fix the + /// pool was never emitted, so `funding_index` reset to 0 each launch and (for + /// `IdentityInvitation`) the EXPORTED one-time voucher key was reused across + /// invitations. The pool snapshot is emitted right after the tx is built, + /// before broadcast, so a rejected broadcast still exercises it. + #[tokio::test] + async fn asset_lock_build_persists_funding_account_used_index() { + use key_wallet::account::AccountType; + + let (manager, signer, persistence) = + funded_asset_lock_manager(Arc::new(AlwaysRejectedBroadcaster)).await; + + let _ = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer, + ) + .await; + + let stored = persistence + .stored + .lock() + .expect("capturing persistence mutex"); + let persisted_invitation_used = stored.iter().any(|cs| { + cs.account_address_pools.iter().any(|entry| { + matches!(entry.account_type, AccountType::IdentityInvitation) + && entry.addresses.iter().any(|a| a.used) + }) + }); + assert!( + persisted_invitation_used, + "a build must persist the IdentityInvitation account's pool with the used \ + funding index; without it funding_index resets on restart and the exported \ + voucher key is reused across invitations" + ); + } + /// A definitively rejected asset-lock broadcast must untrack the `Built` /// row (in-memory and via the changeset's `removed` set) and release the /// funding reservation, so nothing can resume the dead transaction and a From 55937e15c1c6bc837b057166506d19a64d603e55 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 13:57:02 +0700 Subject: [PATCH 23/74] fix(platform-wallet): gate invitation broadcast on funding-index persist Fold multi-agent review of the funding-index-persistence fix: - Security-gate the IdentityInvitation path (rust-quality H1 + security-auditor): persist_asset_lock_account_pools now returns Result, and create_funded_asset_lock_proof aborts BEFORE broadcast if the invitation's funding-index persist fails. The voucher key is exported into a bearer link, so broadcasting an invitation whose used-index wasn't durably recorded would re-open the reuse window on the next restart; failing before broadcast is harmless (no tx on the wire). Other asset-lock accounts keep their keys on-device, so they stay best-effort. - Document the concurrent-build residual (security-auditor Finding 1): the snapshot re-locks after the build lock drops, so per-wallet asset-lock builds must be serialized (the UI creates invitations one-at-a-time); self-healing otherwise. - Correct the now-inaccurate restore warn: 'no matching funds account' -> 'no matching managed account' (the routed accounts are keys-accounts too). Reviews confirmed the gap-limit-past-window case is handled (next_unused scans by highest_generated, not gap_limit) and routing covers all six asset-lock types. fmt + clippy --all-features clean; asset_lock tests green (16). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../rs-platform-wallet-ffi/src/persistence.rs | 2 +- .../src/wallet/asset_lock/build.rs | 42 ++++++++++++------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 6fa154bdf90..3c88b7f8c86 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3106,7 +3106,7 @@ fn build_wallet_start_state( tracing::warn!( wallet_id = %hex::encode(entry.wallet_id), ?account_type, - "load: skipping persisted address pool with no matching funds account" + "load: skipping persisted address pool with no matching managed account" ); continue; }; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 5374c7f1eb5..93494e13b87 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -280,14 +280,22 @@ impl AssetLockManager { /// `account_address_pools` persist path and is rebuilt by /// `restore_address_pool` on load. Funds accounts are skipped — they already /// persist their pools via the normal address-sync path. Best-effort. - async fn persist_asset_lock_account_pools(&self) { + /// + /// The snapshot re-acquires a read lock after the build's write lock is + /// released, so callers must serialize asset-lock builds per wallet (the app + /// creates invitations one-at-a-time from the UI); two concurrent builds on + /// one wallet could otherwise persist a stale snapshot that drops the higher + /// burned index — self-healing on the next build, but a residual to respect. + async fn persist_asset_lock_account_pools( + &self, + ) -> Result<(), crate::changeset::PersistenceError> { use crate::changeset::{AccountAddressPoolEntry, PlatformWalletChangeSet}; use key_wallet::account::AccountType; let entries: Vec = { let wm = self.wallet_manager.read().await; let Some(wallet_info) = wm.get_wallet_info(&self.wallet_id) else { - return; + return Ok(()); }; wallet_info .core_wallet @@ -328,18 +336,12 @@ impl AssetLockManager { }; if entries.is_empty() { - return; + return Ok(()); } - if let Err(e) = self.persister.store(PlatformWalletChangeSet { + self.persister.store(PlatformWalletChangeSet { account_address_pools: entries, ..Default::default() - }) { - tracing::error!( - error = %e, - "failed to persist asset-lock account pool snapshot; \ - funding index may reset on restart" - ); - } + }) } /// Build, broadcast, and wait for an asset lock proof. @@ -405,9 +407,21 @@ impl AssetLockManager { // its index used. These asset-lock accounts fund OP_RETURN-payload // credit outputs that never appear as on-chain UTXOs, so SPV can never // rediscover the used index — the persisted pool is the only thing that - // carries `funding_index` across a restart. Best-effort, like the - // tracking changeset below. - self.persist_asset_lock_account_pools().await; + // carries `funding_index` across a restart. For an INVITATION this write + // is a security gate: the voucher key is exported into a bearer link, so + // a failed persist would let the next restart reuse this index/key. + // Aborting BEFORE broadcast is harmless (no tx on the wire); the other + // asset-lock accounts keep their keys on-device, so they stay best-effort. + if let Err(e) = self.persist_asset_lock_account_pools().await { + tracing::error!(error = %e, "failed to persist asset-lock funding index"); + if funding_type == AssetLockFundingType::IdentityInvitation { + return Err(PlatformWalletError::AssetLockTransaction(format!( + "aborted before broadcast: could not durably record the invitation \ + funding index (broadcasting anyway would risk voucher-key reuse on \ + restart): {e}" + ))); + } + } // 2. Track as Built and queue the changeset onto the persister // so a crash after broadcast leaves a row we can recover from. From 096e52d325f606b945688df73d0dc82c60a5cf09 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 14:17:32 +0700 Subject: [PATCH 24/74] feat(platform-wallet-ffi): topup-from-existing-asset-lock FFI (invitation reclaim) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds platform_wallet_topup_identity_with_existing_asset_lock_signer — tops up an EXISTING identity by consuming an already-tracked asset lock (IdentityTopUp via AssetLockFunding::FromExistingAssetLock), the net-new primitive for the inviter's 'reclaim into my existing identity' path. Mirrors the existing resume-register FFI; register-into-a-NEW-identity reclaim reuses that resume FFI unchanged. Design note: the adversarial reclaim review recommended an IMPORT-based reclaim because the resume/FromExistingAssetLock path looked the credit address up in the funding pool, which reset past funding_index>=5 on restart. The voucher-key-reuse fix (this branch) now persists+restores that pool, so the resume path works across restart again — and it is simpler than import-based (it re-derives the voucher key + acquires the proof internally via the inviter's own signer, no export/import). So reclaim is resume-based: register reuses the resume FFI, topup uses this new sister FFI. Reclaim recovers value as Platform credits (the L1 DASH is an OP_RETURN burn). Compiles clean (cargo check platform-wallet-ffi --all-features); fmt clean. Follow-up: marshaling unit test (mirrors the proven resume FFI) + Swift reclaim UI (ffi-swift) + DP-17/18/19 testnet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- ...dentity_registration_funded_with_signer.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs index 5815898af57..3c234751b19 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs @@ -17,6 +17,7 @@ use dashcore::hashes::Hash; use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; use platform_wallet::AssetLockFunding; use rs_sdk_ffi::{SignerHandle, VTableSigner}; @@ -241,3 +242,80 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc *out_identity_handle = handle; PlatformWalletFFIResult::ok() } + +/// Top up an EXISTING identity from an already-tracked asset lock — the +/// inviter's "reclaim into an existing identity" path. +/// +/// Sister to [`platform_wallet_resume_identity_with_existing_asset_lock_signer`] +/// (which registers a NEW identity from the lock): this consumes the lock as an +/// IdentityTopUp against `identity_id` instead. Used to reclaim an unclaimed +/// DashPay invitation voucher — the value comes back as Platform **credits** on +/// the inviter's own identity (the on-chain DASH is an OP_RETURN burn, so there +/// is nothing to spend back on L1). No per-identity-key signer is needed (a +/// top-up creates no keys); only the Core-side asset-lock signature, produced by +/// the inviter's own resolver, which re-derives the voucher key at the invitation +/// funding path. The `FromExistingAssetLock` resume + IS→CL fallback logic lives +/// in `top_up_identity_with_funding`; this FFI is a thin marshaler. +/// +/// # Safety +/// - `out_point` must be a valid, non-null `*const OutPointFFI`; the caller +/// retains ownership. +/// - `identity_id` must point to 32 readable bytes. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`; the caller retains ownership. +/// - `out_new_balance` must be a valid `*mut u64`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_topup_identity_with_existing_asset_lock_signer( + wallet_handle: Handle, + out_point: *const OutPointFFI, + identity_id: *const [u8; 32], + core_signer_handle: *mut MnemonicResolverHandle, + out_new_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_point); + check_ptr!(identity_id); + check_ptr!(core_signer_handle); + check_ptr!(out_new_balance); + // FFI-safe sentinel before any fallible work. + *out_new_balance = 0; + + let out_point_ffi = *out_point; + let reclaim_outpoint = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array(out_point_ffi.txid), + vout: out_point_ffi.vout, + }; + let identity_id = Identifier::from(*identity_id); + + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.sdk().network; + block_on_worker(async move { + // SAFETY: see the fn-level safety doc — the handle is pinned alive + // for the duration of this FFI call. + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + identity_wallet + .top_up_identity_with_funding( + &identity_id, + AssetLockFunding::FromExistingAssetLock { + out_point: reclaim_outpoint, + }, + &asset_lock_signer, + None, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let new_balance = unwrap_result_or_return!(result); + *out_new_balance = new_balance; + PlatformWalletFFIResult::ok() +} From ce6a04d00c5f7a0a5eea5deb312dbe0e78972d49 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 14:28:56 +0700 Subject: [PATCH 25/74] feat(platform-wallet-ffi): invitation persistence callback (Sent-invitations bridge) Rust half of the 'Sent invitations' persistence bridge. Adds the FFI seam that forwards InvitationChangeSet out of FFIPersister to the host so the app can show sent invitations (the create/reclaim flows already emit the changeset; FFIPersister was silently dropping the invitations sub-field). - invitation_persistence.rs: InvitationEntryFFI (all-POD #[repr(C)]: out_point, funding_index, amount_duffs, expiry_unix, created_at_secs, has_inviter, status) + build_invitation_entries (no owned buffers / no storage Vec, unlike the asset-lock template) + wildcard-free status_to_u8. Unit tests pin the field round-trip + the 0/1/2 status discriminants. - persistence.rs: on_persist_invitations_fn appended at the END of PersistenceCallbacks (stable layout) + Default None; fired in FFIPersister::store next to the asset-lock block with the same non-empty guard + null-when-empty pointers. A non-zero return flips the round to rollback (round-global). Swift half (PersistentInvitation @Model + shim + InvitationsView) is ffi-swift's lane, gated on a build_ios.sh header regen. Compiles clean; fmt + clippy clean; 2 unit tests green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../src/invitation_persistence.rs | 125 ++++++++++++++++++ packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../rs-platform-wallet-ffi/src/persistence.rs | 57 ++++++++ 3 files changed, 184 insertions(+) create mode 100644 packages/rs-platform-wallet-ffi/src/invitation_persistence.rs diff --git a/packages/rs-platform-wallet-ffi/src/invitation_persistence.rs b/packages/rs-platform-wallet-ffi/src/invitation_persistence.rs new file mode 100644 index 00000000000..a05b982754d --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/invitation_persistence.rs @@ -0,0 +1,125 @@ +//! FFI types for forwarding +//! [`InvitationChangeSet`](platform_wallet::changeset::InvitationChangeSet) +//! out of [`FFIPersister`](crate::persistence::FFIPersister) to Swift. +//! +//! Mirrors the shape of `asset_lock_persistence`, but every field of an +//! [`InvitationEntry`] is plain-old-data (no transaction / proof buffers), so +//! unlike [`AssetLockEntryFFI`](crate::asset_lock_persistence::AssetLockEntryFFI) +//! there is **no** parallel storage `Vec` to keep alive and **no** +//! `unsafe impl Send/Sync` — the struct is fully self-contained. Swift maps each +//! upsert onto a `PersistentInvitation` row keyed by the outpoint and deletes +//! rows for each removed outpoint. + +use platform_wallet::changeset::{InvitationEntry, InvitationStatus}; + +/// Flat, all-POD C mirror of one [`InvitationEntry`]. +/// +/// Field order places `amount_duffs` (u64) on an 8-byte boundary +/// (`32 + 4 = 36`, then `funding_index` at 36 lands the u64 at 40), so the +/// struct has no internal padding. Do not reorder without re-checking padding +/// on both sides. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct InvitationEntryFFI { + /// Outpoint of the funded voucher: 32-byte raw txid followed by 4-byte + /// little-endian vout. Same encoding as `AssetLockEntryFFI.out_point`. + pub out_point: [u8; 36], + /// DIP-13 funding index the voucher key is derived from (`…/3'/idx'`). + pub funding_index: u32, + /// Voucher amount in duffs (1 DASH = 1e8 duffs). + pub amount_duffs: u64, + /// Advisory expiry (unix seconds). + pub expiry_unix: u32, + /// Creation time (unix seconds). + pub created_at_secs: u32, + /// `1` if the link carries inviter info (contact-bootstrap), else `0`. + /// A `u8` — not a `bool` — so a foreign byte value can never be UB. + pub has_inviter: u8, + /// Discriminant of [`InvitationStatus`]: + /// 0 = Created, 1 = Claimed, 2 = Reclaimed. + pub status: u8, +} + +/// Build the flat FFI entries from the changeset entries. +/// +/// All-POD, so — unlike `build_asset_lock_entries` — there is no parallel +/// storage `Vec` and nothing to keep alive beyond the returned `Vec` itself +/// (which the callback dispatcher holds for the FFI window). +pub fn build_invitation_entries(entries: &[&InvitationEntry]) -> Vec { + entries + .iter() + .map(|entry| InvitationEntryFFI { + out_point: crate::asset_lock_persistence::outpoint_to_bytes(&entry.out_point), + funding_index: entry.funding_index, + amount_duffs: entry.amount_duffs, + expiry_unix: entry.expiry_unix, + created_at_secs: entry.created_at_secs, + has_inviter: u8::from(entry.has_inviter), + status: status_to_u8(&entry.status), + }) + .collect() +} + +/// Discriminant mapping for [`InvitationStatus`]. Wildcard-free so adding a +/// variant is a compile error rather than a silent mis-map. Pinned by a test. +pub fn status_to_u8(status: &InvitationStatus) -> u8 { + match status { + InvitationStatus::Created => 0, + InvitationStatus::Claimed => 1, + InvitationStatus::Reclaimed => 2, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::hashes::Hash; + + fn entry(vout: u32, status: InvitationStatus) -> InvitationEntry { + InvitationEntry { + out_point: dashcore::OutPoint::new( + dashcore::Txid::from_byte_array([vout as u8; 32]), + vout, + ), + funding_index: vout, + amount_duffs: 500_000 + u64::from(vout), + expiry_unix: 1_800_000_000, + created_at_secs: 1_799_913_600, + has_inviter: vout.is_multiple_of(2), + status, + } + } + + #[test] + fn status_to_u8_pins_discriminants() { + assert_eq!(status_to_u8(&InvitationStatus::Created), 0); + assert_eq!(status_to_u8(&InvitationStatus::Claimed), 1); + assert_eq!(status_to_u8(&InvitationStatus::Reclaimed), 2); + } + + #[test] + fn build_invitation_entries_round_trips_every_field() { + let e0 = entry(0, InvitationStatus::Created); + let e1 = entry(1, InvitationStatus::Reclaimed); + let refs = [&e0, &e1]; + let ffi = build_invitation_entries(&refs); + + assert_eq!(ffi.len(), 2); + // e0 + assert_eq!( + ffi[0].out_point, + crate::asset_lock_persistence::outpoint_to_bytes(&e0.out_point) + ); + assert_eq!(ffi[0].funding_index, 0); + assert_eq!(ffi[0].amount_duffs, 500_000); + assert_eq!(ffi[0].expiry_unix, 1_800_000_000); + assert_eq!(ffi[0].created_at_secs, 1_799_913_600); + assert_eq!(ffi[0].has_inviter, 1); // vout 0 is even + assert_eq!(ffi[0].status, 0); + // e1 + assert_eq!(ffi[1].funding_index, 1); + assert_eq!(ffi[1].amount_duffs, 500_001); + assert_eq!(ffi[1].has_inviter, 0); // vout 1 is odd + assert_eq!(ffi[1].status, 2); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index d3760a96a8a..23ad9f173b0 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -48,6 +48,7 @@ pub mod identity_transfer; pub mod identity_update; pub mod identity_withdrawal; pub mod invitation; +pub mod invitation_persistence; pub mod logging; pub mod managed_identity; pub mod manager; @@ -81,6 +82,7 @@ pub mod xpub_render; // Re-exports pub use asset_lock::*; pub use asset_lock_persistence::*; +pub use invitation_persistence::*; pub use contact::*; pub use contact_persistence::*; pub use contact_request::*; diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 3c88b7f8c86..9ccf37fbb4d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -43,6 +43,7 @@ use crate::identity_persistence::{ free_identity_entry_ffi, free_identity_key_entry_ffi, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, }; +use crate::invitation_persistence::{build_invitation_entries, InvitationEntryFFI}; use crate::platform_address_types::AddressBalanceEntryFFI; use crate::token_persistence::{TokenBalanceRemovalFFI, TokenBalanceUpsertFFI}; use crate::wallet_registration_persistence::AccountAddressPoolFFI; @@ -551,6 +552,20 @@ pub struct PersistenceCallbacks { removed_count: usize, ) -> i32, >, + /// Forwards `InvitationChangeSet` (DIP-13 sent-invitation records) to the + /// host. Appended at the END so the struct layout stays stable. Same + /// upserts + `[u8;36]` removal shape as `on_persist_asset_locks_fn`; the + /// entries are all-POD so there is no owned-buffer lifetime to manage. + pub on_persist_invitations_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + upserts_ptr: *const InvitationEntryFFI, + upserts_count: usize, + removed_ptr: *const [u8; 36], + removed_count: usize, + ) -> i32, + >, } // SAFETY: The context pointer is managed by the FFI caller who must ensure @@ -569,6 +584,7 @@ impl Default for PersistenceCallbacks { on_persist_address_balances_fn: None, on_persist_wallet_changeset_fn: None, on_persist_asset_locks_fn: None, + on_persist_invitations_fn: None, on_persist_sync_state_fn: None, on_persist_account_registrations_fn: None, on_load_wallet_list_fn: None, @@ -1028,6 +1044,47 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Send invitation changeset — DIP-13 sent-invitation records, one + // upsert row per funded voucher (keyed by outpoint) plus outpoint + // tombstones. All-POD entries, so no owned-buffer storage to pin. + // Maps onto Swift's `PersistentInvitation` rows. + if let Some(ref inv_cs) = changeset.invitations { + if let Some(cb) = self.callbacks.on_persist_invitations_fn { + let upsert_refs: Vec<&platform_wallet::changeset::InvitationEntry> = + inv_cs.invitations.values().collect(); + let upserts = build_invitation_entries(&upsert_refs); + let removed: Vec<[u8; 36]> = inv_cs.removed.iter().map(outpoint_to_bytes).collect(); + if !upserts.is_empty() || !removed.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + drop(upserts); + if result != 0 { + eprintln!( + "Invitation persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + } + // Send DashPay contact-request changeset. // // The flat upsert array is built by walking every source From 4e4928653763d0a6cdc1187005a9a53e2ff15ce7 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 14:48:27 +0700 Subject: [PATCH 26/74] test(platform-wallet-ffi): pin InvitationEntryFFI ABI size (layout guard) Adds the crate-standard compile-time layout assertion (const _: [u8; 64] = [0u8; size_of::()]) that every other *EntryFFI persistence struct carries. Enforces the reorder/padding invariant the struct's doc already warns about, so a future field change that shifts the layout is a compile error instead of a silent ABI desync against the Swift-imported struct. Verified: cargo check green (size == 64). Folds the sole should-fix from the rust-quality review of the invitation bridge + reclaim-topup FFIs (both otherwise ship-able, no correctness defects). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../rs-platform-wallet-ffi/src/invitation_persistence.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/invitation_persistence.rs b/packages/rs-platform-wallet-ffi/src/invitation_persistence.rs index a05b982754d..7f7d7fdce5b 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation_persistence.rs @@ -40,6 +40,13 @@ pub struct InvitationEntryFFI { pub status: u8, } +// Pin the ABI size so a future field reorder/add that changes the layout is a +// compile error rather than a silent desync against the Swift-imported struct +// (matches the layout-assert convention used for every other `*EntryFFI`). +// `[u8;36]`@0, u32@36, u64@40, u32@48, u32@52, u8@56, u8@57 → data ends @58, +// struct align 8 → size 64. +const _: [u8; 64] = [0u8; std::mem::size_of::()]; + /// Build the flat FFI entries from the changeset entries. /// /// All-POD, so — unlike `build_asset_lock_entries` — there is no parallel From 9a6188c206af88f7e09ea522006e1e0a1f046f75 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 14:56:03 +0700 Subject: [PATCH 27/74] docs(dip15): reviewed Sent-invitations persistence + reclaim spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design spec for the 'Sent invitations' persistence bridge (FFI→SwiftData) and voucher reclaim, produced via research → 3-agent review → owner sync. Covers the InvitationEntryFFI push-callback bridge (§3-§5), the resume-based reclaim (§8: FromExistingAssetLock topup/register, recovers value as credits), and the failure-mode table (T1 outpoint-key seam, T3 round-global return, threading). Includes the rawOutPoint companion column agreed for commit 1 so commit-2 reclaim reads the raw outpoint without a decode/migration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../INVITATIONS_PERSISTENCE_SWIFT_SPEC.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md diff --git a/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md b/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md new file mode 100644 index 00000000000..e2795b4bc4f --- /dev/null +++ b/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md @@ -0,0 +1,294 @@ +# Sent-Invitations persistence — Swift/SwiftData half (DIP-13 follow-up) + +## 1. Problem + +An inviter creates invitations (`create_invitation`), but the iOS **SwiftExampleApp** +has no "Sent invitations" list — a created invitation is invisible in the app after the +share sheet closes. The Rust half already emits the data: `create_invitation` builds an +`InvitationChangeSet` and calls `self.persister.store(PlatformWalletChangeSet { invitations: +Some(cs), .. })`. The app's persister (`FFIPersister`) never forwards that sub-field to the +host, so it never reaches SwiftData or any view. + +**Goal:** (a) surface each `InvitationEntry` into SwiftData and render a "Sent invitations" list +(amount, status, expiry, inviter-flag), so the inviter can see what they sent; and (b) let the +inviter **reclaim** an unclaimed voucher — recovering its value as **Platform credits** in an +identity (see §8; the L1 DASH is burned at create-time and cannot return to the wallet). + +**Non-goals:** a Rust→Swift *load/rehydrate* path (SwiftData is the UI source; no resume); +cross-device sync; changing the create/claim flows; **any L1 "DASH back to wallet"** (impossible — +the funding is an `OP_RETURN` burn). + +## 2. Chosen approach — clone the `asset_locks` push-callback path + +The app persists wallet state through a **C callback vtable** (`PersistenceCallbacks`): during +the Rust persister's `store()` round, each `PlatformWalletChangeSet` sub-field is projected +into a flat `#[repr(C)]` struct-per-entry and pushed to the host via an `on_persist__fn` +callback, bracketed by `on_changeset_begin`/`on_changeset_end` (one atomic round → one +SwiftData `save()`). `InvitationChangeSet` is structurally identical to `AssetLockChangeSet` +(`BTreeMap` upserts + `BTreeSet` removals), so we mirror the +asset-lock wiring 1:1. + +**Rejected alternative — the `dashpay_payments_overlay` pull-getter.** That domain is fetched +on demand off a live `ManagedIdentity` handle precisely because it already round-trips through +identity persistence and wanted *no* new persister callback or SwiftData path. Invitations are +a genuinely new persisted domain flowing through `store()`, so the push-callback route is the +correct one (confirmed by the payments model's own migration note: "the persister doesn't +project payment history"). + +**Simplification vs. the template.** `InvitationEntry` is all-POD (`out_point`, three `u32`s, +one `u64`, a `bool`, an enum). Unlike `AssetLockEntry` it carries **no owned byte buffers** +(`transaction_bytes`/`proof_bytes`), so the Rust side needs **no parallel `…Storage` Vec** and +**no pointer-lifetime management** — the FFI struct is self-contained POD. + +## 3. Interface & data flow + +### 3.1 Rust FFI (new `invitation_persistence.rs` + edits to `persistence.rs`, `lib.rs`) + +```rust +// Field order is load-bearing: 36+4 lands amount_duffs (u64) on an 8-byte +// boundary, so the struct has ZERO internal padding (size 64, align 8). Do not +// reorder or insert a field without re-checking padding on both sides. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct InvitationEntryFFI { + pub out_point: [u8; 36], // 32-byte raw txid ‖ 4-byte LE vout (reuse outpoint_to_bytes) + pub funding_index: u32, + pub amount_duffs: u64, + pub expiry_unix: u32, + pub created_at_secs: u32, + pub has_inviter: u8, // bool → 0/1 — u8 NOT bool on purpose (a memcpy'd byte + // ∉ {0,1} is instant UB for Rust bool). Do not "clean up". + pub status: u8, // Created=0, Claimed=1, Reclaimed=2 (status_to_u8) +} +// No `unsafe impl Send/Sync` — InvitationEntryFFI has no pointer fields (unlike +// AssetLockEntryFFI). Do not cargo-cult the asset-lock unsafe impls. + +// Appended at the END of PersistenceCallbacks (after the feature-gated shielded +// fields, so layout stays stable). Mirrors on_persist_asset_locks_fn exactly. +on_persist_invitations_fn: Option i32>, // return is round-global (see §4 T3): mirror the template — always 0. +``` + +- `build_invitation_entries(&[&InvitationEntry]) -> Vec` — POD projection, + **no storage Vec** (nothing to keep alive). Reuse `outpoint_to_bytes` for `out_point`. +- `status_to_u8(&InvitationStatus) -> u8` — a **wildcard-free exhaustive** match (no `_ =>`), so + a future variant is a compile error. Pinned by a unit test (0/1/2). +- In `FFIPersister::store()`, next to the asset-lock block, add + `if let Some(ref inv_cs) = changeset.invitations { … }` — with the **same non-empty guard** + the asset-lock block uses (`if !upserts.is_empty() || !removed.is_empty()`), bind the + `Vec` and removals to `let` locals (never `.as_ptr()` on a temporary), + pass `std::ptr::null()` when a count is 0 (an empty `Vec::as_ptr()` is dangling-nonnull), fire + the callback, capture its `i32`, then `drop(upserts); drop(removed)` for parity with the + template. +- Register the module: `mod invitation_persistence;` in `lib.rs`; make the struct/fn `pub`. +- `PersistenceCallbacks::Default` is a **manual impl** (not derived) — add + `on_persist_invitations_fn: None` there; omitting it is a compile error (fail-loud). + +### 3.2 Swift ingestion (`PlatformWalletPersistenceHandler.swift`) + +- `@convention(c)`-compatible free function `persistInvitationsCallback(context, walletIdPtr, + upsertsPtr: UnsafePointer?, upsertsCount, removedPtr: + UnsafePointer?, removedCount) -> Int32`. Recover the handler via + `Unmanaged.fromOpaque(context).takeUnretainedValue()`, **deep-copy every FFI row into owned + Swift values before returning** (Rust frees the buffers on return), build + `[InvitationEntrySnapshot]` + `[Data]` removals, call `handler.persistInvitations(...)`. + **Consume the cbindgen-regenerated `InvitationEntryFFI`** from the header — never a hand-written + Swift mirror. Mirror the template's error discipline: wrap in `try?`, **always return 0** (see + §4 T3 — the return is round-global; a failed invitation write must NOT abort the whole round). +- **Outpoint key (T1 — the critical seam):** compute `outPointHex = + PersistentAssetLock.encodeOutPoint(rawBytes:)` **once** in the shim for each upsert snapshot, + and store that exact display string (`:`) as + `PersistentInvitation.outPointHex` (the `@Attribute(.unique)` key). The removal path derives the + identical string from the same `encodeOutPoint`. Both paths MUST use `encodeOutPoint` verbatim — + never hand-roll the vout decode (ARM64 misaligned-load trap; `encodeOutPoint` already byte-copies + into an aligned local). +- Wire `cb.on_persist_invitations_fn = persistInvitationsCallback` in `makeCallbacks()`. + **(Feasibility-review's #1 silent-failure mode: forgetting this line compiles clean and the app + runs — invitations just never appear. The sim acceptance test in §5 is the only gate that catches + it.)** +- `persistInvitations(walletId:, upserts:, removed:)` runs entirely inside `onQueue` (serial + queue confining `backgroundContext`), body **inline** — do NOT call any public `onQueue`-wrapping + method from inside it (recursive `serialQueue.sync` deadlocks). Upsert = fetch by unique + `outPointHex` → mutate fields incl. **`walletId` on BOTH the insert and the update branch** + (the view's `@Query` filters on it) + `updatedAt = Date()`, else insert; remove = + `encodeOutPoint(rawBytes:)` then fetch-and-delete. `outPointHex` is globally unique (unscoped by + wallet) — correct here because on-chain outpoints are globally unique (unlike 20-byte address + hashes that force a wallet-scoped predicate elsewhere). **No `save()` here** — `endChangeset` + commits the round. + +### 3.3 SwiftData model (`PersistentInvitation.swift`) + registration + +```swift +@Model final class PersistentInvitation { + #Index([\.walletId]) + @Attribute(.unique) var outPointHex: String // ":" — the T1 key + var rawOutPoint: Data // 36B txid‖vout(LE) verbatim from + // InvitationEntryFFI.out_point; commit-2 + // reclaim reads it directly to build the + // OutPointFFI (no reverse-encodeOutPoint + // decode / no migration). Default empty Data. + var walletId: Data + var fundingIndexRaw: Int + var amountDuffs: Int64 + var expiryUnix: Int + var createdAtSecs: Int + var hasInviter: Bool + var statusRaw: Int // enums as Int for #Predicate + var createdAt: Date + var updatedAt: Date +} +``` + +Append `PersistentInvitation.self` to `DashModelContainer.modelTypes`. Reuse +`PersistentAssetLock.encodeOutPoint` (ARM64 misaligned-load-safe) for the outpoint key. + +### 3.4 UI (`InvitationsView.swift`, linked from `DashPayTabView`) + +`@Query`-filtered list (mirror `ContactRequestsView`): filter by `walletId`, sort by +`createdAtSecs` desc, render short outpoint + amount + a status badge + expiry. The +`shortOutPointDisplay` / `statusLabel` helpers live **inline in `InvitationsView.swift`** (a +private extension) — not a separate `…Display.swift` file; extract to a shared file only if a +second consumer appears (the asset-lock display file was extracted precisely because it had +multi-view duplication, which invitations don't yet). The status→label switch maps an unknown +`statusRaw` to an explicit `.unknown` case (the Swift `Int` side has no compiler exhaustiveness, +unlike the Rust match). Entry point: a "Sent invitations" `NavigationLink` in `DashPayTabView`. + +## 4. Failure modes & mitigations + +| # | Risk | Mitigation | +|---|---|---| +| **T1** | **Outpoint key-form mismatch (highest-risk, latent).** If the upsert keys `outPointHex` on anything other than the `encodeOutPoint` display form, a future reclaim/status-sync delete (which looks up via `encodeOutPoint`) silently matches nothing → orphaned rows. Latent because reclaim is a v1 non-goal, so it passes all v1 testing. | Shim computes `outPointHex = encodeOutPoint(rawBytes:)` **once** for the upsert; the unique key IS that string; removal derives the identical string from the same fn. Test the seam now: add a create→reclaim→row-deleted round-trip test even though reclaim isn't shipped. | +| **T2** | **Round overlap.** One shared `backgroundContext` + one `inChangeset` bool; if two `store()` rounds ran concurrently, one round's `save()` would commit the other's half-applied writes. Inherited by all 8 existing kinds. | State + rely on the invariant: `store()` rounds are serialized per persister (never concurrent), and invitations introduces **no** new `store()`-driving path. If that invariant doesn't hold in `platform_wallet`, it's a **pre-existing** bug to file separately — not fixed here. | +| **T3** | **Round-global rollback.** The callback's `i32` is round-global: a non-zero return rolls back the **entire** round (discarding unrelated asset-lock/identity writes) and makes `store()` return `Err`. | Mirror the template: handler uses `try?`, shim **always returns 0**; a failed invitation write is silently skipped, never a round abort. (There is no per-kind rollback.) | +| — | `ModelContext` not thread-safe; callbacks on Tokio threads | All reads/writes through `onQueue`; body **inline**, never re-enter `onQueue`; never `save()` in the per-kind handler. | +| — | Rust frees FFI buffers on return | Shim deep-copies every row to owned Swift values **before** returning. Trivial (all POD). `let`-bound Vecs + null-when-empty on the Rust side. | +| — | ARM64 misaligned load on vout @offset 32 | Reuse `encodeOutPoint` verbatim (byte-copies into an aligned local); never hand-roll vout decode. | +| — | New `@Model` breaks the store | Additive migration: new type + non-optional columns with defaults; no `DashSchemaV1` version bump (dev stores recreate), matching the file's documented precedent. | +| **T4** | **Status drift.** Two independent encodings (FFI `u8`, sqlite `status_str`); the Swift `Int` side has no exhaustiveness. | Rust `status_to_u8` is wildcard-free (future variant = compile error); Swift maps unknown `statusRaw` → `.unknown`; unit test pins 0/1/2. (Not "one source of truth" — two encodings, each guarded.) | + +## 5. Test / verification plan + +- **Rust:** unit test `build_invitation_entries` round-trips each field (POD projection) + a + test pinning `status_to_u8` values (0/1/2, wildcard-free). `cargo test -p platform-wallet-ffi` + green; `clippy --all-features` + `fmt` clean. Note: **the Rust tests exercise the projection in + isolation only — they cannot catch a broken FFI wire-up or a stale header.** +- **Swift build:** `build_ios.sh` (rebuild the xcframework so the regenerated header carries + `InvitationEntryFFI` + the new vtable field — mandatory, not just for the symbol; a stale header + = vtable mismatch = crash) + SwiftExampleApp `xcodebuild` (iPhone 17, arm64) green. +- **Sim verification — REQUIRED acceptance gate (not optional).** This is the *only* check that + catches the two most likely failures (forgetting the `makeCallbacks()` wiring → invitations + silently never appear; stale header → crash). Create an invitation in the app → assert a row in + `ZPERSISTENTINVITATION` via `sqlite3` on the SwiftData store **and** in `InvitationsView`, with + the outpoint hex matching the created voucher. Then drive a second `store()` touching the same + outpoint (or re-create) to confirm **upsert-in-place**, not a duplicate row. Reuse the proven + DP-14 flow. +- **T1 seam test:** even though reclaim is a v1 non-goal, add a create→(simulated + reclaim/removal)→row-deleted assertion so the upsert-key ↔ removal-key form is exercised before + reclaim ships. Otherwise the seam ships untested and bites when reclaim lands. +- **QA rows:** add DP-16 ("Sent invitations list reflects a created invitation; upsert-in-place on + status change") to `TEST_PLAN.md` §4.10. + +## 6. Delivery + +**Same PR — #4041 / branch `feat/dip15-dashpay-invitations`** (owner-decided 2026-07-09: the +feature is cohesive create→claim→see-sent→reclaim, and #4041 isn't merged yet, so fragmenting it +into a stacked PR is needless ceremony). Trade-off accepted: this re-triggers CI + the review +bots on the new diff (fine — the PR is awaiting approval anyway). FFI + Swift split with the +swift-rust-ffi engineer: Rust FFI projection/callback + reclaim primitive (my side), Swift model ++ shim + handler + views (ffi-swift). Requires a `build_ios.sh` window (new FFI symbols), so +builds are coordinated to avoid contention. + +## 7. Resolved decisions (owner sync, 2026-07-09/10) + +1. **Base branch:** same PR #4041 (see §6). +2. **Scope:** display **and reclaim** (§8). +3. **Rehydrate:** push-only, no Rust→Swift load path — the already-decided architecture (the Rust + storage layer states "the production load path does not re-hydrate invitations into the Rust + manager; the Swift SwiftData mirror is the UI source", + `packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs:92-93`). A SwiftData wipe + loses only list *visibility*, never funds or key re-derivability (`funding_index` still derives + the voucher key). +4. **Reclaim semantics:** recover value as **Platform credits**, not L1 DASH (the DASH is burned + at create). Confirmed acceptable; UI copy must say "recover as identity credits." +5. **Reclaim target:** user **chooses at reclaim time** — top up an existing identity OR register + a new one from the voucher. + +--- + +## 8. Reclaim an unclaimed voucher + +### 8.1 What reclaim is (and isn't) + +The invitation's DASH is **burned into an `OP_RETURN`** at create time (asset-lock special-tx: +the on-chain output is a single OP_RETURN carrying the total; the credit output — P2PKH to the +one-time key — exists only in the tx *payload* as a Platform-side authorization, never as an L1 +UTXO). So there is **nothing on L1 to spend back**. "Reclaim" therefore means: **the inviter +consumes the still-unclaimed voucher into a Platform identity of their own, recovering the value +as credits.** Mechanically it's "claim your own invitation." (Evidence: OP_RETURN burn at +`transaction_builder.rs:352-356`, pinned by `asset_lock_builder.rs:713-723`; credit-output P2PKH +built at `build.rs:80-84`; stored outpoint `(txid, 0)` at `build.rs:324-325`.) + +### 8.2 Primitive (reuses existing building blocks) + +Consume the tracked voucher lock via `AssetLockFunding::FromExistingAssetLock { out_point }` +(the invitation's stored funding outpoint). The **inviter's own wallet signer** re-derives the +voucher key at `m/9'/coin'/5'/3'/funding_index'` itself (`resume_asset_lock` → +`rederive_credit_output_path`, `recovery.rs:371-453`) — **no key export/import** (unlike the +invitee's claim). Two targets (user picks): + +- **Top up an existing identity:** `top_up_identity_with_funding(identity_id, + FromExistingAssetLock { out_point }, asset_lock_signer, settings)` (`registration.rs:388`). +- **Register a new identity:** `register_identity_with_funding(FromExistingAssetLock { out_point }, + …)` (`registration.rs:121`) — the exact helper the existing "Resumable Registrations" flow uses. + +New Rust surface is thin: a `reclaim_invitation(out_point, target, identity_signer, +asset_lock_signer, now_unix, settings)` dispatcher in `network/invitation.rs` that calls the +right helper and returns the resulting `Identity`. No new *core* mechanic. + +### 8.3 FFI + Swift + +- **FFI:** one new `platform_wallet_reclaim_invitation(wallet, out_point[36], target_kind: u8 + {0=topup,1=register}, identity_id[32] (topup) | identity_index: u32 (register), signer, + now_unix, settings, out_identity_id[32], out_handle)`. + - The `register` arm can mirror the existing + `platform_wallet_resume_identity_with_existing_asset_lock_signer` + (`identity_registration_funded_with_signer.rs:162`) verbatim, pointed at the invitation's + outpoint. + - The `topup` arm is **net-new** at the FFI layer — no existing FFI tops up from an *existing* + asset lock (only `platform_wallet_top_up_from_addresses_with_signer`, which funds a NEW lock). + The Rust primitive exists; wrap it. +- **Swift:** a "Reclaim" action on each `Created` sent-invitations row → a small sheet: **"Recover + this invitation's value as credits"** with the target choice (pick an existing identity to top + up, or "register a new identity"). On success, **the Swift side flips its own + `PersistentInvitation.statusRaw` to `Reclaimed`** (SwiftData is the UI source — no Rust re-emit + needed; the display-half persistence bridge is only for the create-time push). Copy is explicit: + "recovered as identity credits", never "DASH returned". + +### 8.4 Failure modes (reclaim-specific) + +| Risk | Mitigation | +|---|---| +| **Consume race** — invitee claims the same voucher at the same moment | No L1 double-spend (no shared UTXO). Platform records consumed outpoints and deterministically rejects the second consume with `IdentityAssetLockTransactionOutPointAlreadyConsumed` (`verify_is_not_spent/v0/mod.rs:37-55`). Loser wastes a small ST fee, no funds lost. On that error the Swift side sets the row to `Claimed` (someone claimed it) and shows a benign "already claimed by your friend" message. | +| **User expects L1 DASH back** | UI copy says "recover as identity credits"; the reclaim sheet states the value returns as credits, not spendable DASH. | +| **Reclaim after app restart** (the common case — inviter reclaims days later) | Works: `FromExistingAssetLock` resumes the tracked lock; if the in-memory IS proof was lost on restart it falls back to SPV re-derivation (slower, still correct). **Verify** the SQLite `asset_locks` load re-attaches the proof (spike open-item #1) — perf only, not correctness. | +| **No expiry gate** | Reclaim is allowed anytime (protocol has no timelock; expiry is advisory). Product choice whether to nudge "wait until expiry"; default: allow immediately, since an invitee can claim past expiry anyway. | +| **Partial consumption remainder** | Invitation amounts are small and a single consume takes the whole value; verify the topup consumes the full voucher (no stranded remainder) — spike open-item #3. | +| **Status lifecycle now has a real emitter** | `Reclaimed`/`Claimed` are written by the Swift UI on the local row (not through the Rust changeset), so the `InvitationChangeSet::merge` insert-vs-tombstone hazard (§4-adjacent) stays latent — create is still the only Rust emitter. | + +### 8.5 Reclaim test plan (adds to §5) + +- **Rust:** unit-test `reclaim_invitation` dispatch (topup vs register) selects the right helper + + `FromExistingAssetLock`. FFI marshaling test for `platform_wallet_reclaim_invitation` + (out-param sentinels, target-kind dispatch, nullable id/index). +- **Testnet e2e (DP-17):** create an invitation → do NOT claim it → reclaim it into (a) an existing + identity (topup: assert the identity's credit balance rises by ~the voucher value) and, in a + second run, (b) a new identity (register: assert a new identity funded by the voucher). Verify + on-chain via platform-explorer that the outpoint is consumed. Then attempt a second reclaim/claim + of the same outpoint → assert the deterministic `AlreadyConsumed` rejection surfaces as the + benign "already claimed" state. Row status → `Reclaimed`. +- **QA rows:** DP-17 (reclaim topup), DP-18 (reclaim register-new), DP-19 (reclaim-vs-claim race → + AlreadyConsumed) in `TEST_PLAN.md` §4.10. From c1f0c4585d4a0cbf1a2df124ba39d29bbfcab753 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 14:59:40 +0700 Subject: [PATCH 28/74] feat(swift-sdk): Sent-invitations SwiftData bridge + list (DIP-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface each created invitation into SwiftData + a "Sent invitations" list. Mirrors the asset_locks push-callback path, simpler (InvitationEntry is all-POD). - PersistentInvitation @Model (outPointHex @unique T1 key + rawOutPoint:Data 36B companion for reclaim, walletId #Index, fundingIndexRaw/amountDuffs/expiryUnix/ createdAtSecs/hasInviter/statusRaw) + registered in DashModelContainer.modelTypes - InvitationEntrySnapshot + persistInvitations handler + persistInvitationsCallback shim (deep-copy POD; encodeOutPoint key on BOTH the upsert and removal paths — the T1 seam; always-return-0 since the callback return is round-global; inline-on-onQueue, no save(); walletId set on both insert+update branches) + cb.on_persist_invitations_fn wired in makeCallbacks() - InvitationsView (@Query walletId, sort createdAtSecs desc, amount + status badge with an .unknown fallback, expiry) + a "Sent invitations" nav link in DashPayTabView - InvitationPersistenceTests: create → status-change (upsert-in-place, no dup) → removal (row deleted) — pins the T1 upsert-key ↔ removal-key seam before reclaim ships its removal path - TEST_PLAN DP-16 No secret column (voucher re-derived from funding_index); no Rust→Swift rehydrate (SwiftData is the UI source). build_ios.sh header regen + SwiftExampleApp xcodebuild green (iPhone 17, arm64). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../Persistence/DashModelContainer.swift | 3 +- .../Models/PersistentInvitation.swift | 105 ++++++++++++++ .../PlatformWalletPersistenceHandler.swift | 134 ++++++++++++++++++ .../Views/DashPay/DashPayTabView.swift | 11 ++ .../Views/DashPay/InvitationsView.swift | 119 ++++++++++++++++ .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 1 + .../InvitationPersistenceTests.swift | 90 ++++++++++++ 7 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ec4f97ff99b..cf463b4d4cd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -36,7 +36,8 @@ public enum DashModelContainer { PersistentShieldedOutgoingNote.self, PersistentShieldedSyncState.self, PersistentShieldedActivity.self, - PersistentAssetLock.self + PersistentAssetLock.self, + PersistentInvitation.self ] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift new file mode 100644 index 00000000000..73313ab248d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift @@ -0,0 +1,105 @@ +import Foundation +import SwiftData + +/// SwiftData model for a single **created** DashPay invitation (DIP-13), +/// one row per `(walletId, outpoint)`. Upserted by the +/// `on_persist_invitations_fn` persister callback whenever +/// `create_invitation` flushes an `InvitationChangeSet`; the "Sent +/// invitations" list (`InvitationsView`) reads it via `@Query`. +/// +/// SwiftData is the UI source of truth — there is **no** Rust→Swift +/// rehydrate path (the `funding_index` re-derives the voucher key), so a +/// store wipe loses only list *visibility*, never funds. **No secret +/// column:** the one-time voucher key is never stored. +/// +/// Mirrors the `on_persist_asset_locks_fn` / `PersistentAssetLock` +/// push-callback path, but simpler: `InvitationEntry` is all-POD (no owned +/// byte buffers), so there is no `…Storage` Vec and no pointer-lifetime +/// management on either side. +@Model +public final class PersistentInvitation { + /// Index `walletId` so the per-wallet "Sent invitations" `@Query` hits an + /// index instead of scanning the whole table. + #Index([\.walletId]) + + /// 36-byte outpoint encoded as `:` — identical form + /// to `PersistentAssetLock.outPointHex`, produced by + /// `PersistentAssetLock.encodeOutPoint`. The unique key (the T1 seam): + /// the upsert stores this exact string and a removal derives the identical + /// string from the same function. Globally unique (unscoped by wallet) — + /// on-chain outpoints are globally unique. + @Attribute(.unique) public var outPointHex: String + + /// Raw 36-byte outpoint (`txid_le ‖ vout_le`), stored alongside + /// `outPointHex` so the reclaim flow can rebuild an `OutPointFFI` directly + /// without a reverse-decode of the display string (the T1 misaligned-load + /// error class we specifically avoid). The shim already has these bytes + /// in-hand from `InvitationEntryFFI.out_point`. + public var rawOutPoint: Data + + /// 32-byte wallet id that created this invitation. Drives the view's + /// `@Query` filter (set on BOTH the insert and the update branch). + public var walletId: Data + + /// DIP-13 invitation funding index (`m/9'/coin'/5'/3'/'`) — the + /// handle that re-derives the voucher key and drives recovery/reclaim. + /// Stored as `Int` for `#Predicate`-friendliness. + public var fundingIndexRaw: Int + + /// Amount locked in the voucher (duffs). `Int64` for predicate-friendliness. + public var amountDuffs: Int64 + + /// Advisory expiry (unix seconds); not consensus-enforced. + public var expiryUnix: Int + + /// Creation time (unix seconds), from the Rust changeset. + public var createdAtSecs: Int + + /// Whether the link carried inviter info (contact-bootstrap opted in). + public var hasInviter: Bool + + /// Invitation status discriminant: 0 = Created, 1 = Claimed, 2 = Reclaimed. + /// Stored as `Int` so `#Predicate` matches raw values directly (the Swift + /// `Int` side has no compiler exhaustiveness — the view maps an unknown + /// value to an explicit `.unknown` label). + public var statusRaw: Int + + /// Record timestamps. + public var createdAt: Date + public var updatedAt: Date + + public init( + outPointHex: String, + rawOutPoint: Data, + walletId: Data, + fundingIndexRaw: Int, + amountDuffs: Int64, + expiryUnix: Int, + createdAtSecs: Int, + hasInviter: Bool, + statusRaw: Int + ) { + self.outPointHex = outPointHex + self.rawOutPoint = rawOutPoint + self.walletId = walletId + self.fundingIndexRaw = fundingIndexRaw + self.amountDuffs = amountDuffs + self.expiryUnix = expiryUnix + self.createdAtSecs = createdAtSecs + self.hasInviter = hasInviter + self.statusRaw = statusRaw + self.createdAt = Date() + self.updatedAt = Date() + } +} + +// MARK: - Queries + +extension PersistentInvitation { + /// Per-wallet predicate. Indexed scan via the `walletId` index. + public static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index c7f67912379..bee321f6c2f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -215,6 +215,63 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Persist created/updated invitations (Sent-invitations bridge). Mirrors + /// `persistAssetLocks`, simpler — POD entries, no owned buffers. Runs + /// entirely on `onQueue`, body **inline** (never re-enter `onQueue` — a + /// recursive `serialQueue.sync` deadlocks); **no `save()` here** + /// (`endChangeset` commits the round). Sets `walletId` on BOTH the insert + /// and update branch (the view's `@Query` filters on it). The removal path + /// keys via the same `encodeOutPoint` display form the upsert stores (the + /// T1 seam), so an upsert and a later removal of the same outpoint match. + func persistInvitations( + walletId: Data, + upserts: [InvitationEntrySnapshot], + removed: [Data] + ) { + onQueue { + for entry in upserts { + let outPointHex = entry.outPointHex + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outPointHex == outPointHex } + ) + if let existing = try? backgroundContext.fetch(descriptor).first { + existing.walletId = walletId + existing.rawOutPoint = entry.rawOutPoint + existing.fundingIndexRaw = entry.fundingIndexRaw + existing.amountDuffs = entry.amountDuffs + existing.expiryUnix = entry.expiryUnix + existing.createdAtSecs = entry.createdAtSecs + existing.hasInviter = entry.hasInviter + existing.statusRaw = entry.statusRaw + existing.updatedAt = Date() + } else { + let record = PersistentInvitation( + outPointHex: outPointHex, + rawOutPoint: entry.rawOutPoint, + walletId: walletId, + fundingIndexRaw: entry.fundingIndexRaw, + amountDuffs: entry.amountDuffs, + expiryUnix: entry.expiryUnix, + createdAtSecs: entry.createdAtSecs, + hasInviter: entry.hasInviter, + statusRaw: entry.statusRaw + ) + backgroundContext.insert(record) + } + } + + for rawOutPoint in removed { + let hex = PersistentAssetLock.encodeOutPoint(rawBytes: rawOutPoint) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outPointHex == hex } + ) + if let existing = try? backgroundContext.fetch(descriptor).first { + backgroundContext.delete(existing) + } + } + } + } + /// Load all persisted tracked asset locks for a wallet — used by /// the wallet load path to rebuild `unused_asset_locks` on the /// Rust side so an in-flight registration that was interrupted by @@ -263,6 +320,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { public let proofBytes: Data? } + /// Owned snapshot of an `InvitationEntryFFI` row. All-POD — the callback + /// copies the outpoint bytes into owned `Data` (`rawOutPoint`) and + /// precomputes the display-form key (`outPointHex`) before invoking the + /// handler, so the handler runs against pure-Swift values regardless of when + /// the Rust-side buffer is reclaimed. + public struct InvitationEntrySnapshot { + public let outPointHex: String + public let rawOutPoint: Data + public let fundingIndexRaw: Int + public let amountDuffs: Int64 + public let expiryUnix: Int + public let createdAtSecs: Int + public let hasInviter: Bool + public let statusRaw: Int + } + /// Load all cached platform-address balances for a wallet. Tuple /// shape matches the Rust-side `AddressBalanceEntryFFI` layout so /// the load-wallet-list path can re-seed the provider on startup @@ -1064,6 +1137,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { cb.on_load_shielded_activity_fn = loadShieldedActivityCallback cb.on_load_shielded_activity_free_fn = loadShieldedActivityFreeCallback cb.on_persist_asset_locks_fn = persistAssetLocksCallback + cb.on_persist_invitations_fn = persistInvitationsCallback cb.on_get_core_tx_record_fn = getCoreTxRecordCallback cb.on_get_core_tx_record_free_fn = getCoreTxRecordFreeCallback return cb @@ -6133,6 +6207,66 @@ private func persistTokenBalancesCallback( /// Swift-owned `Data` snapshots before invoking the handler so the /// Rust-side `_storage` Vec can release the byte buffers as soon as /// this trampoline returns. +/// C shim for `on_persist_invitations_fn`. Deep-copies every all-POD +/// `InvitationEntryFFI` row into an owned `InvitationEntrySnapshot` (precomputing +/// `rawOutPoint` + the `encodeOutPoint` display key) and every removed-outpoint +/// tuple into owned `Data` before invoking the handler, so the Rust side can +/// reclaim its buffers the moment we return. Mirrors `persistAssetLocksCallback`. +/// **Always returns 0** — the return is round-global (a non-zero rolls back the +/// WHOLE `store()` round, discarding unrelated writes), so a failed invitation +/// write is silently skipped rather than aborting the round. +private func persistInvitationsCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + upsertsPtr: UnsafePointer?, + upsertsCount: UInt, + removedPtr: UnsafePointer?, + removedCount: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var upserts: [PlatformWalletPersistenceHandler.InvitationEntrySnapshot] = [] + if upsertsCount > 0, let upsertsPtr = upsertsPtr { + upserts.reserveCapacity(Int(upsertsCount)) + for i in 0.. 0, let removedPtr = removedPtr { + removed.reserveCapacity(Int(removedCount)) + for i in 0..?, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index d547132e1d9..8a2a30adb86 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -210,6 +210,17 @@ struct DashPayTabView: View { .accessibilityIdentifier("dashpay.openIgnored") } } + ToolbarItem(placement: .navigationBarLeading) { + if let walletId = claimWalletId { + NavigationLink { + InvitationsView(walletId: walletId) + } label: { + Image(systemName: "paperplane") + } + .accessibilityLabel("Sent invitations") + .accessibilityIdentifier("dashpay.openSentInvitations") + } + } } .sheet(isPresented: $showAddViaQR) { if let identity = activeIdentity { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift new file mode 100644 index 00000000000..d8be4a5f8b3 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift @@ -0,0 +1,119 @@ +import SwiftDashSDK +import SwiftData +import SwiftUI + +/// "Sent invitations" list (DIP-13): every invitation this wallet created, +/// newest first. Read-only in commit 1 (reclaim is a follow-up commit). Rows are +/// `PersistentInvitation` records upserted by the `on_persist_invitations_fn` +/// bridge whenever `create_invitation` flushes its changeset. +struct InvitationsView: View { + let walletId: Data + + @Query private var invitations: [PersistentInvitation] + + init(walletId: Data) { + self.walletId = walletId + _invitations = Query( + filter: PersistentInvitation.predicate(walletId: walletId), + sort: [SortDescriptor(\PersistentInvitation.createdAtSecs, order: .reverse)] + ) + } + + var body: some View { + List { + if invitations.isEmpty { + ContentUnavailableView( + "No invitations yet", + systemImage: "gift", + description: Text("Invitations you create appear here.") + ) + } else { + ForEach(invitations) { invitation in + row(invitation) + } + } + } + .navigationTitle("Sent Invitations") + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("dashpay.invitations.list") + } + + @ViewBuilder + private func row(_ invitation: PersistentInvitation) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(formatDash(invitation.amountDuffs)) + .font(.headline) + Spacer() + statusBadge(invitation.statusRaw) + } + Text(shortOutPoint(invitation.outPointHex)) + .font(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + HStack(spacing: 8) { + if invitation.hasInviter { + Label("Contact request", systemImage: "person.crop.circle.badge.plus") + .font(.caption2) + .foregroundColor(.secondary) + } + Text(expiryText(invitation.expiryUnix)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Inline display helpers +// +// Kept private here rather than in a shared `…Display.swift` file — invitations +// have a single consumer today (extract only if a second view appears, the way +// the asset-lock display file was extracted for its multi-view duplication). + +private extension InvitationsView { + func formatDash(_ duffs: Int64) -> String { + String(format: "%.8f DASH", Double(duffs) / 100_000_000) + } + + /// `:` → `:` for compact rows. + func shortOutPoint(_ hex: String) -> String { + guard let colon = hex.lastIndex(of: ":") else { return hex } + let txid = hex[hex.startIndex.. 14 else { return hex } + return "\(txid.prefix(8))…\(txid.suffix(6))\(vout)" + } + + func expiryText(_ expiryUnix: Int) -> String { + let now = Int(Date().timeIntervalSince1970) + if now > expiryUnix { return "Expired" } + let date = Date(timeIntervalSince1970: TimeInterval(expiryUnix)) + return "Expires \(date.formatted(.relative(presentation: .named)))" + } + + @ViewBuilder + func statusBadge(_ statusRaw: Int) -> some View { + let info = statusLabel(statusRaw) + Text(info.label) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(info.color.opacity(0.15)) + .foregroundColor(info.color) + .clipShape(Capsule()) + } + + /// Maps the status discriminant to a label. An unknown value falls back to + /// an explicit "Unknown" — the Swift `Int` side has no compiler + /// exhaustiveness (unlike the wildcard-free Rust `status_to_u8`). + func statusLabel(_ statusRaw: Int) -> (label: String, color: Color) { + switch statusRaw { + case 0: return ("Created", .blue) + case 1: return ("Claimed", .green) + case 2: return ("Reclaimed", .orange) + default: return ("Unknown", .gray) + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 67b5e6d2a57..a67fb6965d8 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -295,6 +295,7 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | DP-13 | Claim invitation (DIP-13) | Platform | Common | 🔌 | | Paste/scan a `dashpay://invite` link → claim sheet (planned `dashpay.invite.claim`, mirroring `AddViaQRSheet`) → `claimInvitation` → `platform_wallet_claim_invitation`. Registers a **new identity for the invitee funded by the imported voucher** (no L1 Dash on the invitee side; the asset-lock signature uses the imported voucher key). If the link carries inviter info, prompt **"establish contact with \?"** → on confirm, send the existing contact request (`DP-01` path). New identity lands in Identities; optional contact in Contacts. `🔌` until the UI lands. | | DP-14 | Invite → claim two-wallet e2e | Cross | Thorough | 🔌 | multiwallet | The feature's acceptance gate. Wallet A (funded, SPV running) creates an invitation (`DP-12`); wallet B (no funds) claims it (`DP-13`) → B gains a funded identity with no L1 Dash; if the inviter opted into the bootstrap **and** the invitee confirms, the contact establishes on both ends (cf. `DP-11`). Requires testnet funding + both wallets on the same network. `🔌` until the UI lands. | | DP-15 | Reject malformed / reused / expired invitation | Platform | Uncommon | 🔌 | | Negative paths all fail loudly with a clear message and no side effects: a malformed link (wrong scheme / non-base58 / truncated), a **reused** link (asset lock already consumed → deterministic "invitation already used"), and a **past-expiry** link (`validate_claimable` refuses before any network call). `🔌` until the UI lands. | +| DP-16 | Sent-invitations list persists a created invitation | Platform | Common | 🔌 | | Create an invitation (`DP-12`) → assert a `PersistentInvitation` row in the SwiftData store (`ZPERSISTENTINVITATION` via `sqlite3`) whose `outPointHex` matches the created voucher's outpoint, **and** the row in the "Sent invitations" list (DashPay tab → paperplane `dashpay.openSentInvitations` → `InvitationsView` `dashpay.invitations.list`, showing amount + status badge). Then drive a second `store()` touching the same outpoint (or re-create) → **upsert-in-place**, not a duplicate row. Bridged by `on_persist_invitations_fn` → `persistInvitations` → `PersistentInvitation` (no Rust→Swift rehydrate — SwiftData is the UI source). The T1 upsert-key ↔ removal-key seam is unit-pinned in `InvitationPersistenceTests` (create→removal→row-deleted) since reclaim's removal path isn't shipped in this slice. `🔌` until the bridge + view land. | ### 4.11 System / Protocol / Diagnostics — `Domain=System` diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift new file mode 100644 index 00000000000..0e21a5667c1 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -0,0 +1,90 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +/// Coverage for the Sent-invitations persistence bridge +/// (`PlatformWalletPersistenceHandler.persistInvitations`). +/// +/// Pins the **T1 seam** (spec §4): the upsert stores `outPointHex` in the +/// `encodeOutPoint` display form, and a later removal derives the identical +/// string from the same 36-byte raw outpoint — so a create→removal round-trip +/// actually deletes the row. This seam is latent in v1 (reclaim, the only +/// removal emitter, isn't shipped yet), so it's exercised here before it ships: +/// if the upsert keyed `outPointHex` on anything other than the `encodeOutPoint` +/// form, the removal would silently match nothing and orphan the row. +@MainActor +final class InvitationPersistenceTests: XCTestCase { + private let walletId = Data(repeating: 0x01, count: 32) + // 36-byte outpoint: 32-byte txid ‖ 4-byte little-endian vout (= 0). + private let rawOutPoint = Data(repeating: 0xAB, count: 32) + Data([0, 0, 0, 0]) + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + private func snapshot(statusRaw: Int) -> PlatformWalletPersistenceHandler.InvitationEntrySnapshot { + .init( + outPointHex: PersistentAssetLock.encodeOutPoint(rawBytes: rawOutPoint), + rawOutPoint: rawOutPoint, + fundingIndexRaw: 3, + amountDuffs: 50_000, + expiryUnix: 1_800_000_000, + createdAtSecs: 1_700_000_000, + hasInviter: true, + statusRaw: statusRaw + ) + } + + private func fetchRows(_ container: ModelContainer) throws -> [PersistentInvitation] { + try ModelContext(container).fetch(FetchDescriptor()) + } + + /// Create inserts one row (fields mapped, `walletId` set), a re-upsert of the + /// same outpoint updates in place (no duplicate), and a removal keyed on the + /// raw outpoint deletes it — the T1 seam. Each round is bracketed by + /// `beginChangeset`/`endChangeset(success:)` exactly like the FFI store round. + func testUpsertThenStatusChangeThenRemovalRoundTrips() throws { + let (handler, container) = try makeHandler() + let expectedHex = PersistentAssetLock.encodeOutPoint(rawBytes: rawOutPoint) + + // 1. Create. + handler.beginChangeset(walletId: walletId) + handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: []) + _ = handler.endChangeset(walletId: walletId, success: true) + + var rows = try fetchRows(container) + XCTAssertEqual(rows.count, 1, "one invitation row after create") + XCTAssertEqual(rows.first?.outPointHex, expectedHex) + XCTAssertEqual(rows.first?.walletId, walletId, "walletId set on insert (the @Query filter)") + XCTAssertEqual(rows.first?.statusRaw, 0) + XCTAssertEqual(rows.first?.amountDuffs, 50_000) + XCTAssertEqual(rows.first?.fundingIndexRaw, 3) + XCTAssertEqual(rows.first?.hasInviter, true) + XCTAssertEqual(rows.first?.rawOutPoint, rawOutPoint, "raw outpoint stored for reclaim (no reverse-decode)") + + // 2. Status change → upsert in place, no duplicate row. + handler.beginChangeset(walletId: walletId) + handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 1)], removed: []) + _ = handler.endChangeset(walletId: walletId, success: true) + + rows = try fetchRows(container) + XCTAssertEqual(rows.count, 1, "re-upsert must update in place, not duplicate") + XCTAssertEqual(rows.first?.statusRaw, 1, "status updated in place") + + // 3. Removal keyed on the raw 36-byte outpoint deletes the row. The T1 + // seam: `persistInvitations` derives the removal key via + // `encodeOutPoint(rawBytes:)`, which must equal the upsert's stored + // `outPointHex` for the delete to match. + handler.beginChangeset(walletId: walletId) + handler.persistInvitations(walletId: walletId, upserts: [], removed: [rawOutPoint]) + _ = handler.endChangeset(walletId: walletId, success: true) + + rows = try fetchRows(container) + XCTAssertEqual( + rows.count, 0, + "removal via encodeOutPoint(rawBytes:) must match the upsert key and delete the row" + ) + } +} From 0b12d2dba34f92474362b86d1ffb19960bd43240 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 15:14:09 +0700 Subject: [PATCH 29/74] fix(rs-sdk-ffi): revert over-strict hardened invitation export gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The funded DP-16 sim run caught a regression: my earlier defense-in-depth fold (0b51001e60, from thepastaclaw's suggestion) required coin_type (comps[1]) and funding_index (comps[4]) to be Hardened in export_invitation_private_key — but real invitation funding paths use a NON-hardened coin_type, so every create now failed with 'path is not an invitation-funding path' (BIP-32 derivation failed). Revert to binding only the fixed 9'/*/5'/3'/* shape (purpose/feature/sub-feature) — the original gate that the DP-14 funded e2e proved works and that the security-auditor review explicitly blessed ('coin and funding_index are intentionally unconstrained and safe: the whole 9'/*/5'/3'/* subtree is invitation-vouchers-only'). Removes the two non-hardened negative-test cases that pinned the wrong (too-strict) behavior. Test would have caught this in CI only with a funded integration run; the unit-gate test passes either way. Invitation create was fully broken before this. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../src/mnemonic_resolver_core_signer.rs | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index f85ecff1d12..124e3095eec 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -392,17 +392,17 @@ impl MnemonicResolverCoreSigner { let subfeature3 = ChildNumber::from_hardened_idx(3) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; let comps: &[ChildNumber] = path.as_ref(); - // Every component of an invitation-funding path is hardened. Enforcing - // that on `coin_type` and `funding_index` (not just the fixed - // purpose/feature/sub-feature) keeps this raw-scalar export boundary - // exactly as strict as its documented scope: a non-hardened tail is - // never a real voucher path, so refuse to export a key for it. + // Bind the fixed purpose / feature / sub-feature only. `coin_type` + // (comps[1]) and `funding_index` (comps[4]) are deliberately left + // unconstrained: the whole `9'/*/5'/3'/*` subtree is invitation-vouchers + // only (the user's own keys live at sub-features `5'/0'`, `5'/1'`, `5'/2'`, + // all excluded by `comps[3] == 3'`), so this gate cannot exfiltrate a + // user key regardless of their hardening. Constraining them additionally + // rejects real voucher paths whose coin_type is non-hardened. if comps.len() != 5 || comps[0] != purpose9 - || !matches!(comps[1], ChildNumber::Hardened { .. }) || comps[2] != feature5 || comps[3] != subfeature3 - || !matches!(comps[4], ChildNumber::Hardened { .. }) { return Err(MnemonicResolverSignerError::DerivationFailed( "export_invitation_private_key: path is not an invitation-funding path".to_string(), @@ -761,10 +761,12 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } - /// The invitation export gate is stricter than the auto-accept one: it must - /// bind the full, fully-hardened `9'/coin'/5'/3'/idx'` shape, because feature - /// `5'` is shared with the user's own identity-auth / registration-funding / - /// top-up keys — a looser gate would be a key-exfiltration hole. + /// The invitation export gate binds the fixed `9'/*/5'/3'/*` shape (purpose, + /// feature, sub-feature), because feature `5'` is shared with the user's own + /// identity-auth / registration-funding / top-up keys — a gate that checked + /// only the feature could be repurposed to exfiltrate those keys. coin_type + /// and funding_index are intentionally unconstrained (the whole sub-feature-3' + /// subtree is vouchers-only, and real voucher paths use a non-hardened coin). #[test] fn export_invitation_private_key_gates_to_the_invitation_path() { let resolver = make_resolver(english_resolve); @@ -788,8 +790,6 @@ mod tests { "m/8'/1'/5'/3'/0'", // wrong purpose (comps[0] != 9') "m/9'/1'/5'/3'", // too short (len != 5) "m/9'/1'/5'/3'/0'/0'", // too long (len != 5) - "m/9'/1'/5'/3'/0", // non-hardened funding_index (comps[4]) - "m/9'/1/5'/3'/0'", // non-hardened coin_type (comps[1]) ] { let path = DerivationPath::from_str(bad).expect("valid path string"); assert!( From 3f498ba701da83b64f387e357fc94094810df2ea Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 16:10:05 +0700 Subject: [PATCH 30/74] fix(platform-wallet): persist invitation record for non-hardened funding tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_invitation extracted the funding index only when the derivation path's tail was Hardened, but the IdentityInvitation funding address is drawn from the account's address pool, so the tail is a NORMAL (non-hardened) index. Every real invitation therefore hit the skip branch: a valid dashpay:// link was returned but no InvitationEntry was persisted, so the "Sent invitations" list stayed empty and reclaim had no local record. Same assumed-hardened bug class as the voucher-export key gate. Accept the index from either the Hardened or Normal tail variant (256-bit and empty tails still skip — never a u32 funding index, and never produced for an address-pool path). Extract the decision into a pure funding_index_from_path helper so it is unit-testable. The stored index is display metadata only; reclaim resumes by outpoint (FromExistingAssetLock), so a missing/wrong index never affects key recovery. Regression: funding_index_extracted_from_non_hardened_tail would have caught this in CI — None before the fix, Some(42) after. Found by the funded on-device DP-16 run (0 ZPERSISTENTINVITATION rows before, 1 after). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../src/wallet/identity/network/invitation.rs | 87 ++++++++++++++++--- 1 file changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index 811dbeae912..a574e0634c2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -23,7 +23,7 @@ use dpp::identity::signer::Signer; use dpp::identity::v0::IdentityV0; use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose, SecurityLevel}; use dpp::prelude::{AssetLockProof, Identifier}; -use key_wallet::bip32::ChildNumber; +use key_wallet::bip32::{ChildNumber, DerivationPath}; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use crate::changeset::{InvitationChangeSet, InvitationEntry, InvitationStatus}; @@ -122,6 +122,24 @@ fn preflight_keys_map( Ok(()) } +/// Extract the u32 funding index from an invitation funding path's tail. +/// +/// The invitation funding address is drawn from the account's address pool, so +/// the path's tail is a NORMAL (non-hardened) 32-bit index; the gate must accept +/// it — a hardened-only requirement would drop every real invitation record, +/// since real funding tails are never hardened. A hardened tail maps to the same +/// index field and is accepted too. Returns `None` for a 256-bit index variant +/// (never a u32 funding index, and never produced for an address-pool-derived +/// path) or for an empty path; the caller then skips the local invitation record +/// rather than failing the create — the link is already valid, and reclaim +/// resumes by outpoint, so this index is display metadata, not the key source. +fn funding_index_from_path(path: &DerivationPath) -> Option { + match path.as_ref().last().copied() { + Some(ChildNumber::Hardened { index }) | Some(ChildNumber::Normal { index }) => Some(index), + _ => None, + } +} + impl IdentityWallet { /// Create a DashPay invitation: fund a one-time asset-lock voucher at the /// DIP-13 invitation path and return a shareable `dashpay://invite` link. @@ -213,16 +231,11 @@ impl IdentityWallet { let uri = uri_result?; // Persist an inviter-side invitation record for the "Sent invitations" - // list + (future) reclaim. No secret is stored — `funding_index` - // re-derives the voucher key, so it MUST be the real hardened index. - // If the derivation path ever has an unexpected (non-hardened) tail, - // skip the record with a warning rather than persist a wrong index that - // would re-derive the wrong key on reclaim. Best-effort either way: the - // link is already valid, so neither branch fails the create. - if let Some(ChildNumber::Hardened { - index: funding_index, - }) = path.as_ref().last().copied() - { + // list + (future) reclaim. No secret is stored — only the funding index, + // which is display metadata (reclaim resumes by outpoint, not by this + // index). Best-effort: the link is already valid, so skipping the record + // never fails the create. + if let Some(funding_index) = funding_index_from_path(&path) { let mut inv_cs = InvitationChangeSet::default(); inv_cs.invitations.insert( out_point, @@ -247,8 +260,8 @@ impl IdentityWallet { } } else { tracing::warn!( - "invitation funding path has an unexpected non-hardened tail; \ - skipping the local invitation record to avoid persisting a wrong funding index" + "invitation funding path has no u32 index tail; \ + skipping the local invitation record" ); } @@ -383,3 +396,51 @@ impl IdentityWallet { Ok(identity) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A real DIP-13 invitation funding path ends in a NORMAL (non-hardened) + /// address-pool index. This is the record that a hardened-only gate silently + /// dropped, leaving the "Sent invitations" list empty despite a valid link. + #[test] + fn funding_index_extracted_from_non_hardened_tail() { + let path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: 9 }, + ChildNumber::Normal { index: 1 }, // coin type — non-hardened in practice + ChildNumber::Hardened { index: 5 }, + ChildNumber::Hardened { index: 3 }, + ChildNumber::Normal { index: 42 }, // funding index — non-hardened + ]); + assert_eq!(funding_index_from_path(&path), Some(42)); + } + + /// A hardened tail carries the index in the same field, so it is accepted too. + #[test] + fn funding_index_extracted_from_hardened_tail() { + let path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: 9 }, + ChildNumber::Hardened { index: 3 }, + ChildNumber::Hardened { index: 7 }, + ]); + assert_eq!(funding_index_from_path(&path), Some(7)); + } + + /// A 256-bit index cannot be a u32 funding index — skip rather than truncate. + #[test] + fn funding_index_none_for_256bit_tail() { + let path = DerivationPath::from(vec![ + ChildNumber::Normal { index: 3 }, + ChildNumber::Normal256 { index: [0u8; 32] }, + ]); + assert_eq!(funding_index_from_path(&path), None); + } + + /// An empty path has no tail to read an index from. + #[test] + fn funding_index_none_for_empty_path() { + let path = DerivationPath::from(Vec::::new()); + assert_eq!(funding_index_from_path(&path), None); + } +} From 7b371fe788f70251f9d6307f5376f86c6d4f9ab4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 16:14:45 +0700 Subject: [PATCH 31/74] fix(SwiftExampleApp): add PersistentInvitation to Storage Explorer views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersistentInvitation was registered in DashModelContainer.modelTypes but had no Storage Explorer coverage, so check-storage-explorer.sh failed CI. Add a top-level modelRow + per-wallet count, a walletId-scoped list view (mirroring AssetLockStorageListView, since invitations are scoped by wallet not network), and a read-only detail view dumping every persisted column. No secret column exists to show — the one-time voucher key is never stored. The coverage gate itself is the red->green proof: check-storage-explorer.sh was RED (PersistentInvitation missing from all three explorer files) and is now GREEN (32/32 model types covered). No unit test — these are read-only SwiftUI dump views with no business logic. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../Views/StorageExplorerView.swift | 10 ++++ .../Views/StorageModelListViews.swift | 52 +++++++++++++++++ .../Views/StorageRecordDetailViews.swift | 58 +++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index 0363c6d3978..06ad8bc97f6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -69,6 +69,13 @@ struct StorageExplorerView: View { ) { DashpayIgnoredSenderStorageListView(network: network) } + modelRow( + "Sent Invitations", + icon: "paperplane", + type: PersistentInvitation.self + ) { + InvitationStorageListView(network: network) + } modelRow("Documents", icon: "doc.text", type: PersistentDocument.self) { DocumentStorageListView(network: network) } @@ -315,6 +322,9 @@ struct StorageExplorerView: View { filteredCount(PersistentAssetLock.self) { walletsOnNetwork.contains($0.walletId) } + filteredCount(PersistentInvitation.self) { + walletsOnNetwork.contains($0.walletId) + } // Core / Platform addresses partition the same family of // tables by account type, so they need their own counts. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index f9a377598b0..711bf43d42f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -597,6 +597,58 @@ struct DashpayPaymentStorageListView: View { } } +// MARK: - PersistentInvitation + +struct InvitationStorageListView: View { + let network: Network + @Query(sort: [SortDescriptor(\PersistentInvitation.createdAtSecs, order: .reverse)]) + private var records: [PersistentInvitation] + + @Query private var allWallets: [PersistentWallet] + + private var walletIdsOnNetwork: Set { + Set(allWallets.lazy + .filter { $0.networkRaw == network.rawValue } + .map(\.walletId)) + } + + private var scopedRecords: [PersistentInvitation] { + let ids = walletIdsOnNetwork + return records.filter { ids.contains($0.walletId) } + } + + var body: some View { + let visible = scopedRecords + List(visible) { record in + NavigationLink(destination: InvitationStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.outPointHex) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1).truncationMode(.middle) + HStack(spacing: 8) { + Text(invitationStatusLabel(record.statusRaw)) + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text(String(format: "%.8f DASH", Double(record.amountDuffs) / 100_000_000)) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + } + } + } + } + .navigationTitle("Sent Invitations (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView( + "No Invitations", + systemImage: "paperplane" + ) + } + } + } +} + // MARK: - PersistentDashpayIgnoredSender struct DashpayIgnoredSenderStorageListView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index d1ad06b82f6..3f52eedee06 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -346,6 +346,64 @@ struct DashpayPaymentStorageDetailView: View { } } +// MARK: - PersistentInvitation + +/// Human label for a `PersistentInvitation.statusRaw` discriminant +/// (0 = Created, 1 = Claimed, 2 = Reclaimed). Shared with the list view; +/// an unmapped value renders as "Unknown (n)" rather than being hidden. +func invitationStatusLabel(_ raw: Int) -> String { + switch raw { + case 0: return "Created" + case 1: return "Claimed" + case 2: return "Reclaimed" + default: return "Unknown (\(raw))" + } +} + +/// Detail view for one created DashPay invitation (DIP-13). Read-only dump +/// of every column the persister bridge writes, mirroring the other storage +/// detail views. Note there is no secret column — the one-time voucher key +/// is never stored. +struct InvitationStorageDetailView: View { + let record: PersistentInvitation + + var body: some View { + Form { + Section("Core") { + FieldRow(label: "Status", value: invitationStatusLabel(record.statusRaw)) + FieldRow( + label: "Amount", + value: String(format: "%.8f DASH", Double(record.amountDuffs) / 100_000_000) + ) + FieldRow(label: "Amount (duffs)", value: "\(record.amountDuffs)") + FieldRow(label: "Funding index", value: "\(record.fundingIndexRaw)") + FieldRow(label: "Has inviter", value: record.hasInviter ? "Yes" : "No") + } + Section("Outpoint") { + FieldRow(label: "Outpoint", value: record.outPointHex) + FieldRow( + label: "Raw outpoint", + value: record.rawOutPoint.map { String(format: "%02x", $0) }.joined() + ) + } + Section("Wallet") { + FieldRow( + label: "Wallet id", + value: record.walletId.map { String(format: "%02x", $0) }.joined() + ) + } + Section("Timestamps") { + FieldRow(label: "Expiry (unix)", value: "\(record.expiryUnix)") + FieldRow(label: "Created (unix)", value: "\(record.createdAtSecs)") + FieldRow(label: "Created", value: AppDate.formatted(record.createdAt, dateStyle: .abbreviated, timeStyle: .standard)) + FieldRow(label: "Updated", value: AppDate.formatted(record.updatedAt, dateStyle: .abbreviated, timeStyle: .standard)) + } + } + .navigationTitle("Invitation") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentDashpayIgnoredSender /// Detail view for one DashPay ignored sender (per-sender mute, From 4e3a9b0629d340ec913ccf72fe383047ff752e11 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 16:34:31 +0700 Subject: [PATCH 32/74] style(platform-wallet-ffi): sort invitation_persistence re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pub use invitation_persistence::*;` was inserted after `asset_lock_persistence` rather than its sorted position next to `pub use invitation::*;`, so `cargo fmt --check --all` failed under rustfmt 1.8.0. It only surfaced intermittently because self-hosted CI runners carry different rustfmt versions — the reorder-enforcing one flagged it, the other did not. No behavior change (re-export order is cosmetic), so no test is added; `cargo fmt --check --all` in CI is the guard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- packages/rs-platform-wallet-ffi/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 23ad9f173b0..360aee1ee88 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -82,7 +82,6 @@ pub mod xpub_render; // Re-exports pub use asset_lock::*; pub use asset_lock_persistence::*; -pub use invitation_persistence::*; pub use contact::*; pub use contact_persistence::*; pub use contact_request::*; @@ -119,6 +118,7 @@ pub use identity_transfer::*; pub use identity_update::*; pub use identity_withdrawal::*; pub use invitation::*; +pub use invitation_persistence::*; pub use logging::*; pub use managed_identity::*; pub use manager::*; From d7ebdaa51b936b7579a0256e36a295dc6f01c620 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 17:56:21 +0700 Subject: [PATCH 33/74] feat(swift-sdk): reclaim an unclaimed DashPay invitation as credits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the inviter-side reclaim flow (DIP-13 commit 2). An unclaimed voucher can be consumed into a Platform identity of the inviter's own, recovering the value as identity credits — the on-chain DASH is an OP_RETURN burn, so there is nothing to spend back on L1; reclaim is mechanically "claim your own invitation". - ManagedPlatformWallet.topUpIdentityWithExistingAssetLock: net-new Swift wrapper over platform_wallet_topup_identity_with_existing_asset_lock_signer (top up an existing identity from the tracked voucher lock). Mirrors resumeIdentityWithAssetLock's handle/resolver/out-param marshaling; no KeychainSigner (a top-up creates no keys) — only the wallet's own MnemonicResolver, which re-derives the voucher key. - ReclaimInvitationSheet: pick a target at reclaim time — top up an existing identity, or register a new one funded by the voucher (reusing resumeIdentityWithAssetLock). Copy states the value returns as identity credits, never spendable Dash. - InvitationsView: a Reclaim swipe action on Created rows presents the sheet; on success the local row flips to Reclaimed (SwiftData is the UI source, no Rust re-emit). On the deterministic already-consumed rejection (someone claimed it first) the row flips to Claimed with a neutral "already claimed" message — the claimant is intentionally not named. - TEST_PLAN: DP-17 (reclaim top-up), DP-18 (reclaim register-new), DP-19 (reclaim-vs-claim race → already-consumed) in Section 4.10. The T1 upsert-key <-> removal-key seam is already unit-pinned by InvitationPersistenceTests.testUpsertThenStatusChangeThenRemovalRoundTrips. Builds clean: build_ios.sh --target sim + SwiftExampleApp xcodebuild both rc=0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../ManagedPlatformWallet.swift | 80 ++++++ .../Views/DashPay/DashPayTabView.swift | 2 +- .../Views/DashPay/InvitationsView.swift | 31 ++- .../DashPay/ReclaimInvitationSheet.swift | 263 ++++++++++++++++++ .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 3 + 5 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 4147e3b3f94..1c03ece9ec4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3961,4 +3961,84 @@ extension ManagedPlatformWallet { return (identityId, ManagedIdentity(handle: outManagedHandle)) }.value } + + /// Top up an EXISTING identity from an already-tracked asset lock, consuming + /// it as an IdentityTopUp. Sister to `resumeIdentityWithAssetLock` (which + /// registers a NEW identity from the lock): used to reclaim an unclaimed + /// DashPay invitation voucher into one of the inviter's own identities. The + /// value comes back as Platform credits (the on-chain DASH is an OP_RETURN + /// burn, so there is nothing to spend back on L1). A top-up creates no + /// identity keys, so no `KeychainSigner` is needed — only the Core-side + /// asset-lock signature, produced by the wallet's own `MnemonicResolver`, + /// which re-derives the voucher key at the invitation funding path. + /// + /// - Returns: the identity's new credit balance reported by the FFI. + public func topUpIdentityWithExistingAssetLock( + outPointTxid: Data, + outPointVout: UInt32, + identityId: Data + ) async throws -> UInt64 { + guard outPointTxid.count == 32 else { + throw PlatformWalletError.invalidParameter( + "outPointTxid must be exactly 32 bytes (was \(outPointTxid.count))" + ) + } + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "identityId must be exactly 32 bytes (was \(identityId.count))" + ) + } + let handle = self.handle + // Same `MnemonicResolver` lifetime + vtable rationale as + // `resumeIdentityWithAssetLock`: the voucher key is re-derived per-call + // from the wallet's mnemonic, signed, and dropped; no priv key lives in + // Rust memory across operations. + let coreSigner = MnemonicResolver() + return try await Task.detached(priority: .userInitiated) { () -> UInt64 in + var txidTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + var idTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + outPointTxid.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &txidTuple) { dst in + dst.copyMemory(from: src) + } + } + identityId.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &idTuple) { dst in + dst.copyMemory(from: src) + } + } + var outPoint = OutPointFFI(txid: txidTuple, vout: outPointVout) + var newBalance: UInt64 = 0 + // Keep the FFI call inline under `withExtendedLifetime` — the same + // dangling-resolver hazard `resumeIdentityWithAssetLock` documents. + let result = withExtendedLifetime(coreSigner) { + () -> PlatformWalletFFIResult in + platform_wallet_topup_identity_with_existing_asset_lock_signer( + handle, + &outPoint, + &idTuple, + coreSigner.handle, + &newBalance + ) + } + try result.check() + return newBalance + }.value + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 8a2a30adb86..b78c862d7a4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -213,7 +213,7 @@ struct DashPayTabView: View { ToolbarItem(placement: .navigationBarLeading) { if let walletId = claimWalletId { NavigationLink { - InvitationsView(walletId: walletId) + InvitationsView(walletId: walletId, network: network) } label: { Image(systemName: "paperplane") } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift index d8be4a5f8b3..f22622a6c41 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift @@ -3,16 +3,21 @@ import SwiftData import SwiftUI /// "Sent invitations" list (DIP-13): every invitation this wallet created, -/// newest first. Read-only in commit 1 (reclaim is a follow-up commit). Rows are -/// `PersistentInvitation` records upserted by the `on_persist_invitations_fn` -/// bridge whenever `create_invitation` flushes its changeset. +/// newest first. Rows are `PersistentInvitation` records upserted by the +/// `on_persist_invitations_fn` bridge whenever `create_invitation` flushes its +/// changeset. A still-unclaimed (`Created`) row can be reclaimed — recovering the +/// voucher's value as identity credits — via a swipe action. struct InvitationsView: View { let walletId: Data + let network: Network @Query private var invitations: [PersistentInvitation] - init(walletId: Data) { + @State private var reclaimTarget: PersistentInvitation? + + init(walletId: Data, network: Network) { self.walletId = walletId + self.network = network _invitations = Query( filter: PersistentInvitation.predicate(walletId: walletId), sort: [SortDescriptor(\PersistentInvitation.createdAtSecs, order: .reverse)] @@ -30,12 +35,30 @@ struct InvitationsView: View { } else { ForEach(invitations) { invitation in row(invitation) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if invitation.statusRaw == 0 { + Button { + reclaimTarget = invitation + } label: { + Label("Reclaim", systemImage: "arrow.uturn.backward.circle") + } + .tint(.orange) + .accessibilityIdentifier("dashpay.invitations.reclaim") + } + } } } } .navigationTitle("Sent Invitations") .navigationBarTitleDisplayMode(.inline) .accessibilityIdentifier("dashpay.invitations.list") + .sheet(item: $reclaimTarget) { invitation in + ReclaimInvitationSheet( + invitation: invitation, + walletId: walletId, + network: network + ) + } } @ViewBuilder diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift new file mode 100644 index 00000000000..303a69e6e54 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -0,0 +1,263 @@ +import SwiftDashSDK +import SwiftData +import SwiftUI + +/// Reclaim an unclaimed DashPay invitation (DIP-13): the inviter consumes the +/// still-unclaimed voucher into a Platform identity of their own, recovering the +/// value as **identity credits**. The invitation's DASH was burned into an +/// `OP_RETURN` at create time, so there is nothing on L1 to spend back — reclaim +/// is mechanically "claim your own invitation". The inviter picks a target at +/// reclaim time: top up an existing identity, or register a new one funded by +/// the voucher. +/// +/// On success the row's `statusRaw` flips to Reclaimed locally — SwiftData is the +/// UI source of truth here (no Rust re-emit). If the voucher was already consumed +/// (the invitee claimed it), the reclaim is rejected deterministically and the +/// row flips to Claimed with a neutral message instead. +struct ReclaimInvitationSheet: View { + let invitation: PersistentInvitation + let walletId: Data + let network: Network + + @EnvironmentObject private var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @Query private var identities: [PersistentIdentity] + + @State private var target: Target = .topUp + @State private var selectedIdentityId: Data? + @State private var isReclaiming = false + @State private var errorMessage: String? + @State private var infoMessage: String? + + private enum Target: Hashable { + case topUp + case register + } + + /// Default identity auth-key count for the register arm (mirrors + /// `CreateIdentityView` / `ClaimInvitationSheet`). No DashPay enc/dec pair is + /// appended — a reclaim recovers funds into a fresh identity and sends no + /// contact request. + private static let authKeyCount: UInt32 = 4 + + init(invitation: PersistentInvitation, walletId: Data, network: Network) { + self.invitation = invitation + self.walletId = walletId + self.network = network + let raw = network.rawValue + _identities = Query( + filter: #Predicate { $0.networkRaw == raw } + ) + } + + var body: some View { + NavigationStack { + Form { + explainerSection + targetSection + if let infoMessage { + Section { + Text(infoMessage).font(.caption).foregroundColor(.secondary) + } + } + if let errorMessage { + Section { + Text(errorMessage).font(.caption).foregroundColor(.red) + } + } + } + .navigationTitle("Reclaim Invitation") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + if selectedIdentityId == nil { + selectedIdentityId = walletIdentities.first?.identityId + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if isReclaiming { + ProgressView() + } else { + Button("Reclaim") { reclaim() } + .disabled(!canReclaim) + .accessibilityIdentifier("dashpay.invite.reclaim.submit") + } + } + } + } + } + + // MARK: - Sections + + @ViewBuilder private var explainerSection: some View { + Section { + LabeledContent("Amount", value: formatDash(invitation.amountDuffs)) + } footer: { + Text( + "Recovers this unclaimed invitation's value as identity credits — " + + "not spendable Dash. The original amount was burned when the " + + "invitation was created." + ) + } + } + + @ViewBuilder private var targetSection: some View { + Section { + Picker("Recover into", selection: $target) { + Text("Existing identity").tag(Target.topUp) + Text("New identity").tag(Target.register) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("dashpay.invite.reclaim.target") + + switch target { + case .topUp: + if walletIdentities.isEmpty { + Text("No identities on this wallet yet — register a new one instead.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Picker("Identity", selection: $selectedIdentityId) { + ForEach(walletIdentities, id: \.identityId) { identity in + Text(identity.identityIdBase58.prefix(12) + "…") + .tag(Optional(identity.identityId)) + } + } + .accessibilityIdentifier("dashpay.invite.reclaim.identityPicker") + } + case .register: + Text("A brand-new identity funded by this voucher.") + .font(.caption) + .foregroundColor(.secondary) + } + } header: { + Text("Recover into") + } + } + + // MARK: - Derived state + + /// Identities owned by this wallet on this network (the topup targets). + private var walletIdentities: [PersistentIdentity] { + identities.filter { $0.wallet?.walletId == walletId } + } + + private var canReclaim: Bool { + guard !isReclaiming else { return false } + switch target { + case .topUp: + return selectedIdentityId != nil + case .register: + return true + } + } + + // MARK: - Actions + + private func reclaim() { + guard canReclaim, !isReclaiming else { return } + isReclaiming = true + errorMessage = nil + infoMessage = nil + Task { @MainActor in + defer { isReclaiming = false } + do { + guard let wallet = walletManager.wallet(for: walletId) else { + errorMessage = "No wallet loaded." + return + } + let (txid, vout) = try outPointParts() + + switch target { + case .topUp: + guard let identityId = selectedIdentityId else { + errorMessage = "Pick an identity to top up." + return + } + _ = try await wallet.topUpIdentityWithExistingAssetLock( + outPointTxid: txid, + outPointVout: vout, + identityId: identityId + ) + case .register: + let signer = KeychainSigner(modelContainer: modelContext.container) + let identityIndex = nextUnusedIdentityIndex() + let keys = try wallet.prePersistIdentityKeysForRegistration( + identityIndex: identityIndex, + keyCount: Self.authKeyCount, + network: network + ) + _ = try await wallet.resumeIdentityWithAssetLock( + outPointTxid: txid, + outPointVout: vout, + identityIndex: identityIndex, + identityPubkeys: keys, + signer: signer + ) + } + + // SwiftData is the UI source: flip the local row to Reclaimed. + invitation.statusRaw = 2 + invitation.updatedAt = Date() + try? modelContext.save() + dismiss() + } catch { + if isAlreadyConsumed(error) { + // Someone already claimed this voucher (or a prior reclaim + // consumed it). The consume is deterministically rejected — + // no funds are lost. Reflect the terminal state and show a + // neutral message (the claimant is intentionally not named). + invitation.statusRaw = 1 + invitation.updatedAt = Date() + try? modelContext.save() + infoMessage = "This invitation was already claimed." + } else { + errorMessage = error.localizedDescription + } + } + } + } + + /// Split the stored 36-byte outpoint (`txid_le ‖ vout_le`) into the 32-byte + /// txid and the little-endian vout. Rebuilt from `rawOutPoint` directly (not + /// decoded from the display string) to avoid a reverse-parse misalignment. + private func outPointParts() throws -> (txid: Data, vout: UInt32) { + let raw = invitation.rawOutPoint + guard raw.count == 36 else { + throw PlatformWalletError.invalidParameter( + "rawOutPoint must be 36 bytes (was \(raw.count))" + ) + } + let txid = raw.prefix(32) + let voutBytes = raw.suffix(4) + let vout = voutBytes.reversed().reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + return (Data(txid), vout) + } + + /// One past the highest used registration index on this wallet, else 0. + /// Matches `ClaimInvitationSheet.nextUnusedIdentityIndex`. + private func nextUnusedIdentityIndex() -> UInt32 { + let used = walletIdentities.map(\.identityIndex) + guard let highest = used.max() else { return 0 } + return highest == UInt32.max ? UInt32.max : highest + 1 + } + + /// Whether an error is the deterministic "asset lock outpoint already + /// consumed" rejection (consensus code 10504), whose Display is + /// "Asset lock transaction … already completely used". + private func isAlreadyConsumed(_ error: Error) -> Bool { + let text = error.localizedDescription.lowercased() + return text.contains("already completely used") + || text.contains("alreadyconsumed") + || text.contains("already consumed") + } + + private func formatDash(_ duffs: Int64) -> String { + String(format: "%.8f DASH", Double(duffs) / 100_000_000) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 370797e0960..1632a73d3de 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -296,6 +296,9 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | DP-14 | Invite → claim two-wallet e2e | Cross | Thorough | 🔌 | multiwallet | The feature's acceptance gate. Wallet A (funded, SPV running) creates an invitation (`DP-12`); wallet B (no funds) claims it (`DP-13`) → B gains a funded identity with no L1 Dash; if the inviter opted into the bootstrap **and** the invitee confirms, the contact establishes on both ends (cf. `DP-11`). Requires testnet funding + both wallets on the same network. `🔌` until the UI lands. | | DP-15 | Reject malformed / reused / expired invitation | Platform | Uncommon | 🔌 | | Negative paths all fail loudly with a clear message and no side effects: a malformed link (wrong scheme / non-base58 / truncated), a **reused** link (asset lock already consumed → deterministic "invitation already used"), and a **past-expiry** link (`validate_claimable` refuses before any network call). `🔌` until the UI lands. | | DP-16 | Sent-invitations list persists a created invitation | Platform | Common | 🔌 | | Create an invitation (`DP-12`) → assert a `PersistentInvitation` row in the SwiftData store (`ZPERSISTENTINVITATION` via `sqlite3`) whose `outPointHex` matches the created voucher's outpoint, **and** the row in the "Sent invitations" list (DashPay tab → paperplane `dashpay.openSentInvitations` → `InvitationsView` `dashpay.invitations.list`, showing amount + status badge). Then drive a second `store()` touching the same outpoint (or re-create) → **upsert-in-place**, not a duplicate row. Bridged by `on_persist_invitations_fn` → `persistInvitations` → `PersistentInvitation` (no Rust→Swift rehydrate — SwiftData is the UI source). The T1 upsert-key ↔ removal-key seam is unit-pinned in `InvitationPersistenceTests` (create→removal→row-deleted) since reclaim's removal path isn't shipped in this slice. `🔌` until the bridge + view land. | +| DP-17 | Reclaim an unclaimed invitation into an existing identity (top-up) | Platform | Common | 🔌 | funding | Create an invitation (`DP-12`) and do **not** claim it. In "Sent invitations" swipe a `Created` row → **Reclaim** (`dashpay.invitations.reclaim`) → sheet (`dashpay.invite.reclaim.submit`), target **Existing identity** (`dashpay.invite.reclaim.identityPicker`) → `topUpIdentityWithExistingAssetLock` → `platform_wallet_topup_identity_with_existing_asset_lock_signer` consumes the voucher as an IdentityTopUp via `FromExistingAssetLock`. Assert the target identity's credit balance rises by ~the voucher value, the row's status badge flips to **Reclaimed** (`ZSTATUSRAW=2`), and the outpoint reads consumed on-chain (platform-explorer). Value returns as **credits**, never L1 Dash. Needs SPV running + the voucher's funding tracked. `🔌` until the UI lands. | +| DP-18 | Reclaim an unclaimed invitation by registering a new identity | Platform | Uncommon | 🔌 | funding | As `DP-17` but target **New identity** (segmented control) → `resumeIdentityWithAssetLock` → `platform_wallet_resume_identity_with_existing_asset_lock_signer` registers a brand-new identity funded by the voucher (no DashPay enc/dec pair — a reclaim sends no contact request). Assert a new funded identity lands in Identities, the row flips to **Reclaimed** (`ZSTATUSRAW=2`), and the outpoint reads consumed on-chain. `🔌` until the UI lands. | +| DP-19 | Reclaim vs claim race → deterministic already-consumed | Platform | Uncommon | 🔌 | multiwallet | Claim an invitation (`DP-13`) from wallet B, then attempt to **Reclaim** the same voucher from the inviter (or reclaim twice). The second consume is deterministically rejected (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, "already completely used"); the reclaim sheet detects it and shows the **neutral** "This invitation was already claimed." message (the claimant is intentionally not named), flipping the row to **Claimed** (`ZSTATUSRAW=1`). No funds lost (no shared L1 UTXO); the loser wastes only a small ST fee. `🔌` until the UI lands. | ### 4.11 System / Protocol / Diagnostics — `Domain=System` From 3029ffeb6098b911e2085e49b75f1ed4d83e20a1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 19:20:45 +0700 Subject: [PATCH 34/74] feat(platform-wallet): enforce a minimum invitation amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A funded testnet run showed that an invitation locking the previous UI default of 0.0005 DASH (50,000,000 credits) can be neither claimed nor reclaimed: creating an identity — which the invitee's claim does, and which a register-target reclaim does — needs ~0.00228 DASH in credits, and even a top-up-target reclaim needs slightly more than the voucher held. The state transition is rejected with IdentityAssetLockTransactionOutPointNotEnoughBalanceError, so a sub-threshold voucher produces a dead invitation. Add MIN_INVITATION_DUFFS (0.003 DASH) and reject amounts below it at creation, mirroring the existing MAX_INVITATION_DUFFS cap. Enforcing the floor at create time fixes both the claim and reclaim paths at once, since an under-funded voucher can no longer be minted. The floor sits above the identity-registration minimum with margin so the new identity keeps a small usable starting balance. No unit test: this mirrors the untested MAX_INVITATION_DUFFS range guard, and the binding evidence is the funded run that surfaced the floor; a follow-up funded e2e confirms a viable-amount invitation is claimable and reclaimable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../src/wallet/identity/network/invitation.rs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index a574e0634c2..0a5ebbac8f7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -44,6 +44,17 @@ use super::*; /// small starting balance; tune if onboarding needs more. pub const MAX_INVITATION_DUFFS: u64 = 1_000_000; +/// Floor on the amount an invitation can lock (0.003 DASH). A voucher funds a +/// Platform identity operation, and creating an identity — which is what the +/// invitee's claim does, and what a register-target reclaim does — requires the +/// asset lock to carry at least the identity-registration minimum (~0.00228 DASH +/// in credits on the current network; the state transition is rejected with +/// `IdentityAssetLockTransactionOutPointNotEnoughBalanceError` below it). A +/// voucher under this floor produces an invitation that can be neither claimed +/// nor reclaimed, so reject it at creation. Set above the bare floor to leave the +/// new identity a small usable starting balance; tune if the floor changes. +pub const MIN_INVITATION_DUFFS: u64 = 300_000; + /// Default TTL (24h) for an invitation's advisory expiry. The FFI sets /// `expiry_unix = now + MAX_INVITATION_TTL_SECS`. The expiry is **advisory** — a /// leaked-link finder holds the voucher key and ignores it — so it bounds only @@ -171,10 +182,12 @@ impl IdentityWallet { AS: ::key_wallet::signer::Signer + Send + Sync, CP: ContactCryptoProvider + Send + Sync, { - if amount_duffs == 0 { - return Err(PlatformWalletError::InvalidIdentityData( - "invitation amount must be greater than zero".to_string(), - )); + if amount_duffs < MIN_INVITATION_DUFFS { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "invitation amount {amount_duffs} is below the minimum {MIN_INVITATION_DUFFS} \ + duffs; a smaller voucher cannot fund identity registration, so the invitation \ + could be neither claimed nor reclaimed" + ))); } if amount_duffs > MAX_INVITATION_DUFFS { return Err(PlatformWalletError::InvalidIdentityData(format!( From 261e2f2f7fae70dd76adc80170d116b92fcfa74f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 19:33:10 +0700 Subject: [PATCH 35/74] fix(swift-sdk): raise invitation default above the claimable floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A voucher locking the old UI default 0.0005 DASH (50,000,000 credits) could be neither claimed nor reclaimed: creating an identity (the invitee's claim, and the register-target reclaim) needs ~228,000,000 credits, and even a top-up-target reclaim needs slightly more than the voucher held — surfacing as IdentityAssetLockTransactionOutPointNotEnoughBalanceError. - Raise the create default from 0.0005 to 0.005 DASH (comfortably above the ~0.00228 DASH registration floor, leaving the invitee a usable balance). - Enforce a 0.003 DASH minimum (mirrors the new Rust MIN_INVITATION_DUFFS = 300,000 duffs). Rust remains the source of truth and rejects a sub-min amount at create; this is UX parity so the submit gate + hint reject it first. Placeholder and range copy updated; the existing MAX (0.01) mirror is unchanged. Builds clean: build_ios.sh --target sim + SwiftExampleApp xcodebuild rc=0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../Views/DashPay/CreateInvitationSheet.swift | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift index 1c06410f85d..09ba295fcb7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift @@ -24,14 +24,21 @@ struct CreateInvitationSheet: View { /// Rust-enforced cap (`MAX_INVITATION_DUFFS`, 0.01 DASH). Mirrored here so the /// UI rejects an over-cap amount before the FFI does. private static let maxInvitationDuffs: UInt64 = 1_000_000 + /// Rust-enforced floor (`MIN_INVITATION_DUFFS`, 0.003 DASH). A smaller voucher + /// can't fund identity registration (which needs ~0.00228 DASH) plus the + /// asset-lock overhead, so it could be neither claimed nor reclaimed. Rust is + /// the source of truth and rejects a sub-min amount at create; this mirror + /// just rejects it in the UI first. + private static let minInvitationDuffs: UInt64 = 300_000 /// BIP44 standard account that supplies the asset-lock's funding UTXOs. The /// example app funds identity operations from account 0; the `IdentityInvitation` /// funding type derives the voucher credit key internally (not this account). private static let fundingAccount: UInt32 = 0 - /// Amount to lock in the voucher, as a DASH string (decimal). Default 0.0005 - /// DASH — enough for identity registration plus a small starting balance. - @State private var amountDashText: String = "0.0005" + /// Amount to lock in the voucher, as a DASH string (decimal). Default 0.005 + /// DASH — comfortably above the ~0.00228 DASH identity-registration floor, + /// leaving the invitee a usable starting balance. + @State private var amountDashText: String = "0.005" /// Opt into the contact-bootstrap: the link carries the inviter so the invitee /// can send a contact request back. Requires a registered username. @State private var sendRequestBack = true @@ -52,13 +59,15 @@ struct CreateInvitationSheet: View { } /// Parse the DASH text field into duffs, or `nil` if it isn't a valid, - /// in-range positive amount. + /// in-range positive amount (within `[minInvitationDuffs, maxInvitationDuffs]`). private var amountDuffs: UInt64? { guard let dash = Double(amountDashText.replacingOccurrences(of: ",", with: ".")), dash > 0 else { return nil } let duffs = (dash * Double(Self.duffsPerDash)).rounded() - guard duffs >= 1, duffs <= Double(Self.maxInvitationDuffs) else { return nil } + guard duffs >= Double(Self.minInvitationDuffs), + duffs <= Double(Self.maxInvitationDuffs) + else { return nil } return UInt64(duffs) } @@ -93,13 +102,13 @@ struct CreateInvitationSheet: View { private var inputSection: some View { Section("Amount") { HStack { - TextField("0.0005", text: $amountDashText) + TextField("0.005", text: $amountDashText) .keyboardType(.decimalPad) .accessibilityIdentifier("dashpay.invite.create.amount") Text("DASH") .foregroundColor(.secondary) } - Text("Funds a one-time voucher your friend uses to register their identity. Max 0.01 DASH.") + Text("Funds a one-time voucher your friend uses to register their identity. Between 0.003 and 0.01 DASH.") .font(.caption) .foregroundColor(.secondary) } @@ -137,7 +146,7 @@ struct CreateInvitationSheet: View { .accessibilityIdentifier("dashpay.invite.create.submit") } footer: { if amountDuffs == nil { - Text("Enter an amount between 0.00000001 and 0.01 DASH.") + Text("Minimum 0.003 DASH — a smaller voucher can't fund identity registration. Maximum 0.01 DASH.") .foregroundColor(.orange) } } From d14c02ecb0068b96a3e8a85ec9f4b6aae06f8349 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 19:33:26 +0700 Subject: [PATCH 36/74] fix(swift-sdk): narrow already-consumed classifier to canonical Display + test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reclaim sheet flips a row to Claimed and shows a benign "already claimed" message when the consume is rejected as already-consumed. The SDK surfaces a consensus error as "SDK error: Protocol error: ", so the canonical Display of IdentityAssetLockTransactionOutPointAlreadyConsumed — "…already completely used" — appears verbatim. - Narrow isAlreadyConsumed to match ONLY "already completely used". Drop the broad "already consumed" / "alreadyconsumed" phrases: they never occur in the real Display and only widened false-positive risk (misclassifying an unrelated failure — e.g. the not-enough-credits error, which shares the "Asset lock transaction …" prefix — as benign, wrongly flipping a live invitation to Claimed). A typed FFI result code is the robust long-term fix. - Refactor the check into a pure static seam isAlreadyConsumed(message:). - Add ReclaimInvitationClassifierTests: the real already-consumed Display classifies true; the real not-enough-credits error and a transport error classify false; the dropped broad phrases classify false. Pins the false-positive safety and guards against Display wording drift. Builds clean + tests pass: build_ios.sh + SwiftExampleApp xcodebuild + the new test all rc=0 (5/5 cases pass). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../DashPay/ReclaimInvitationSheet.swift | 27 +++++--- .../ReclaimInvitationClassifierTests.swift | 61 +++++++++++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index 303a69e6e54..1ade93d48ef 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -207,7 +207,7 @@ struct ReclaimInvitationSheet: View { try? modelContext.save() dismiss() } catch { - if isAlreadyConsumed(error) { + if Self.isAlreadyConsumed(error) { // Someone already claimed this voucher (or a prior reclaim // consumed it). The consume is deterministically rejected — // no funds are lost. Reflect the terminal state and show a @@ -248,13 +248,24 @@ struct ReclaimInvitationSheet: View { } /// Whether an error is the deterministic "asset lock outpoint already - /// consumed" rejection (consensus code 10504), whose Display is - /// "Asset lock transaction … already completely used". - private func isAlreadyConsumed(_ error: Error) -> Bool { - let text = error.localizedDescription.lowercased() - return text.contains("already completely used") - || text.contains("alreadyconsumed") - || text.contains("already consumed") + /// consumed" rejection (consensus code 10504). The SDK surfaces a consensus + /// error as `"SDK error: Protocol error: "`, so + /// the canonical Display of + /// `IdentityAssetLockTransactionOutPointAlreadyConsumedError` — + /// "Asset lock transaction … already completely used" — appears verbatim. + /// Matched on that exact phrase ONLY: broader phrases like "already consumed" + /// never occur in the real Display and would only widen false-positive risk + /// (misclassifying an unrelated failure as a benign "already claimed", which + /// would wrongly flip the row to Claimed). A typed FFI result code is the + /// robust long-term fix. + static func isAlreadyConsumed(_ error: Error) -> Bool { + isAlreadyConsumed(message: error.localizedDescription) + } + + /// Pure classifier over the surfaced error message — the testable seam for + /// the false-positive-safety unit test. + static func isAlreadyConsumed(message: String) -> Bool { + message.lowercased().contains("already completely used") } private func formatDash(_ duffs: Int64) -> String { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift new file mode 100644 index 00000000000..f26df0a710d --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import SwiftExampleApp + +/// Pins `ReclaimInvitationSheet.isAlreadyConsumed(message:)` — the classifier +/// that decides whether a failed reclaim is the benign "voucher already claimed" +/// case (flip the row to Claimed, show a neutral message) versus a real error +/// (surface it). +/// +/// The SDK surfaces a consensus error as +/// `"SDK error: Protocol error: "`, so the match is +/// keyed on the exact canonical Display of +/// `IdentityAssetLockTransactionOutPointAlreadyConsumedError` — +/// "…already completely used". The critical safety property is the **absence** +/// of false positives: a different asset-lock failure (notably the +/// not-enough-credits error, which shares the "Asset lock transaction …" prefix) +/// must NOT be misclassified as already-consumed, or the UI would wrongly flip a +/// still-live invitation to Claimed. +final class ReclaimInvitationClassifierTests: XCTestCase { + + /// The real already-consumed rejection, as surfaced to Swift. + func test_alreadyConsumedDisplay_classifiedTrue() { + let message = "SDK error: Protocol error: Asset lock transaction " + + "3ff8e26d02e53f97a5f06b12327f40fc10cb859077e2788362c5d93032850ff0 " + + "output 0 already completely used" + XCTAssertTrue(ReclaimInvitationSheet.isAlreadyConsumed(message: message)) + } + + /// Case-insensitive: consensus Display wording can be re-cased upstream. + func test_alreadyConsumed_caseInsensitive() { + XCTAssertTrue( + ReclaimInvitationSheet.isAlreadyConsumed(message: "ALREADY COMPLETELY USED") + ) + } + + /// The not-enough-credits error shares the "Asset lock transaction …" prefix + /// but is a DIFFERENT failure (the voucher is still live). Must be false — + /// this is the false-positive the narrowed classifier exists to prevent. + func test_notEnoughCreditsError_classifiedFalse() { + let message = "SDK error: Protocol error: Asset lock transaction " + + "3ff8e26d02e53f97a5f06b12327f40fc10cb859077e2788362c5d93032850ff0 " + + "output 0 only has 50000000 credits left out of 50000000 initial " + + "credits on the asset lock but needs 50500000 credits to start processing" + XCTAssertFalse(ReclaimInvitationSheet.isAlreadyConsumed(message: message)) + } + + /// An unrelated transport failure must not be swallowed as "already claimed". + func test_networkError_classifiedFalse() { + XCTAssertFalse( + ReclaimInvitationSheet.isAlreadyConsumed( + message: "SDK error: Transport error: connection refused" + ) + ) + } + + /// The broad phrases dropped from the classifier must NOT match on their own + /// — they never appear in the real Display and would widen false positives. + func test_droppedBroadPhrases_classifiedFalse() { + XCTAssertFalse(ReclaimInvitationSheet.isAlreadyConsumed(message: "already consumed")) + XCTAssertFalse(ReclaimInvitationSheet.isAlreadyConsumed(message: "AlreadyConsumed")) + } +} From 139a4787f93b0a2a0d7442a228a787bc758504f1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 21:19:20 +0700 Subject: [PATCH 37/74] fix(swift-sdk): disable Cancel during an in-flight invitation reclaim The reclaim runs in a fire-and-forget Task while a ProgressView replaces the Reclaim button. Cancel stayed tappable throughout, so dismissing the sheet mid-reclaim let the Task keep running and mutate the row's status + save after the sheet was gone, and re-opening could launch an overlapping reclaim. Gate Cancel on the in-flight flag, mirroring the submit button. No funds were ever at risk (a second consume is deterministically rejected on-chain); this closes the stale-write/overlap window. Builds clean: build_ios.sh --target sim + SwiftExampleApp xcodebuild rc=0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../Views/DashPay/ReclaimInvitationSheet.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index 1ade93d48ef..f2ac829f781 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -77,7 +77,12 @@ struct ReclaimInvitationSheet: View { } .toolbar { ToolbarItem(placement: .cancellationAction) { + // Gated while a reclaim is in flight: dismissing mid-flight + // would leave the fire-and-forget Task to mutate the row and + // save after the sheet is gone, and a re-open could launch an + // overlapping reclaim. Mirrors the Reclaim submit gate. Button("Cancel") { dismiss() } + .disabled(isReclaiming) } ToolbarItem(placement: .confirmationAction) { if isReclaiming { From 250976ab1611b4127da52e47339f29eb1ded27fa Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 10 Jul 2026 21:55:47 +0700 Subject: [PATCH 38/74] docs(swift-example-app): add QA004 invitation-reclaim AI-QA playbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an executable AI-QA scenario for the inviter-side reclaim flow, matching the QA001–QA003 playbook structure: reclaim-topup into an existing identity, reclaim-register into a new identity, the already-consumed neutral-message path, and the minimum-amount guard. Uses the shipped accessibility ids (dashpay.openSentInvitations, dashpay.invitations.reclaim, dashpay.invite.reclaim.{target,submit,identityPicker}) and the ZPERSISTENTINVITATION status transitions (Created→Reclaimed / Created→Claimed) observed on a funded testnet run. Linked from README_AI_QA. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../AI_QA/QA004_invitation_reclaim.md | 30 +++++++++++++++++++ .../SwiftExampleApp/AI_QA/README_AI_QA.md | 1 + 2 files changed, 31 insertions(+) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md new file mode 100644 index 00000000000..a301e1c6144 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md @@ -0,0 +1,30 @@ +# QA004 – Reclaim an Unclaimed Invitation + +**Objective:** Validate the inviter-side reclaim flow: an unclaimed invitation voucher can be recovered as Platform **credits** into either an existing identity (top-up) or a new identity (register), the local row transitions correctly, an already-consumed voucher surfaces a neutral message, and the minimum-amount guard blocks vouchers too small to fund an identity. + +## Preconditions + +- App launched in the simulator; Core SPV sync **running and caught up** to the testnet tip (Sync tab → `Start`; invitation create/reclaim need an InstantSend lock on the funding tx). Confirm headers/filters read `N/N` (equal), not `0/—`. +- A funded testnet wallet with spendable Dash (a reclaim create burns the voucher amount plus fees; budget ≥ 0.02 DASH to run every step). +- At least one existing Platform identity on the current network (the top-up target). Its credit balance is read from SwiftData `ZPERSISTENTIDENTITY.ZBALANCE`. +- DashPay tab selected (`DashPay` tab-bar item), a DashPay profile present (`dashpay.identityPicker`). + +## Steps + +1. **Create a reclaimable invitation.** Open create-invitation (DashPay tab → the create/gift affordance → `CreateInvitationSheet`). Leave the amount at its default (`0.005` DASH) and submit. Wait for the InstantSend lock to confirm, then screenshot `AI_QA/output/QA004_created.png`. Read `ZPERSISTENTINVITATION` via `sqlite3` and record the new row's `ZOUTPOINTHEX`, `ZAMOUNTDUFFS` (`500000`), and `ZSTATUSRAW` (`0` = Created). +2. **Open Sent Invitations.** Tap `dashpay.openSentInvitations` (paperplane). `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_sent_list.json` and confirm `dashpay.invitations.list` shows the new row with amount `0.00500000 DASH` and a `Created` badge. +3. **Reclaim → top-up (existing identity).** Swipe the `Created` row left to reveal `dashpay.invitations.reclaim`; tap it. In the sheet, confirm the body copy reads "…as identity credits — not spendable Dash." Leave the target on **Existing identity** (`dashpay.invite.reclaim.target`), pick the target via `dashpay.invite.reclaim.identityPicker`, and tap `dashpay.invite.reclaim.submit`. Wait for the Platform state transition to confirm. Screenshot `AI_QA/output/QA004_reclaim_topup.png`. +4. **Verify the top-up.** Re-read SwiftData: the target identity's `ZBALANCE` rose by ~the voucher value (in credits; 1 duff ≈ 1000 credits), and the reclaimed row's `ZSTATUSRAW` is now `2` (Reclaimed). Cross-check the outpoint reads **consumed** on-chain (platform-explorer). +5. **Reclaim → register (new identity).** Repeat steps 1–3 with a **second** invitation, but in the sheet switch the target to **New identity** (right segment of `dashpay.invite.reclaim.target`) — the identity picker is replaced by "A brand-new identity funded by this voucher." Submit. After it confirms, confirm a new funded identity appears in the Identities tab and the row's `ZSTATUSRAW` is `2` (Reclaimed). Screenshot `AI_QA/output/QA004_reclaim_register.png`. +6. **Already-consumed handling.** Take a row whose voucher is already consumed on-chain (either the register-arm race, or force-quit the app, set that row's `ZSTATUSRAW` back to `0` via `sqlite3` while the app is terminated, relaunch, and reclaim it again). Tap `dashpay.invitations.reclaim` → submit. `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_already_consumed.json`. +7. **Minimum-amount guard.** Open `CreateInvitationSheet`, set the amount below the floor (e.g. `0.001`), and confirm the create button is disabled with the sub-minimum hint. Screenshot `AI_QA/output/QA004_min_guard.png`. + +## Expected Results + +- **Step 1** create succeeds only because the default is `0.005` DASH; the persisted row is `Created` (`ZSTATUSRAW=0`) with `ZAMOUNTDUFFS=500000` and a 36-byte `ZRAWOUTPOINT`. +- **Step 3–4 (top-up)** the sheet states value returns as credits, never L1 Dash; on success the target identity's credit balance rises by ~the voucher value and the row badge flips to **Reclaimed** (`ZSTATUSRAW=2`). No L1 Dash is returned (the on-chain amount was an `OP_RETURN` burn at create time). +- **Step 5 (register)** a brand-new funded identity lands in Identities and the row flips to **Reclaimed** (`ZSTATUSRAW=2`); no contact request is sent (a reclaim carries no DashPay enc/dec pair). +- **Step 6 (already-consumed)** the second consume is deterministically rejected (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, Display "…already completely used"); the sheet shows the **neutral** message "This invitation was already claimed." — the claimant is **not** named — and flips the row to **Claimed** (`ZSTATUSRAW=1`). No funds are lost. +- **Step 7 (min guard)** creating below the minimum is blocked in-UI with "Minimum 0.003 DASH — a smaller voucher can't fund identity registration."; the Rust layer independently rejects a sub-minimum amount (`MIN_INVITATION_DUFFS`), so the floor holds even if the UI guard is bypassed. + +Fail the QA if: a reclaim returns L1 Dash instead of credits; a failed or non-consumed reclaim flips the row status (a non-consumed error must leave it `Created`); the already-consumed path names the claimant or shows a raw error instead of the neutral message; or an invitation below `0.003` DASH can be created. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/README_AI_QA.md b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/README_AI_QA.md index b306e1361ca..1f3d9e9923a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/README_AI_QA.md +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/README_AI_QA.md @@ -20,5 +20,6 @@ This folder documents interactive QA scenarios that can be executed by an agent - [QA001 – Wallet Sync Bars After Fresh Start](QA001_wallet_sync_progress.md) - [QA002 – Resume Sync After Pause](QA002_resume_sync.md) - [QA003 – Clear Sync Data Resets Progress](QA003_clear_sync_resets.md) +- [QA004 – Reclaim an Unclaimed Invitation](QA004_invitation_reclaim.md) Add more scenarios as regressions are discovered or new flows require coverage. Follow the structure used in the existing playbooks. From fb31b53b689d52548b9e8c4196369d920e80fd7f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 11 Jul 2026 07:36:34 +0700 Subject: [PATCH 39/74] fix(swift-example-app): make the reclaim classifier nonisolated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Swift SDK build + tests (warnings as errors)` CI job compiles the test target under strict concurrency, where calling the main-actor-isolated `ReclaimInvitationSheet.isAlreadyConsumed(message:)` (a static member of a @MainActor View) from the nonisolated `ReclaimInvitationClassifierTests` is an error. The looser local build only warned, so it slipped through. Mark both `isAlreadyConsumed` overloads `nonisolated` — they are pure string classifiers touching no view state, so this is correct in every concurrency mode and lets the test (and any caller) invoke them off the main actor. Verified: xcodebuild build-for-testing on the SwiftExampleApp scheme compiles SwiftExampleAppTests cleanly (TEST BUILD SUCCEEDED). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../Views/DashPay/ReclaimInvitationSheet.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index f2ac829f781..42d0096ff64 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -263,13 +263,14 @@ struct ReclaimInvitationSheet: View { /// (misclassifying an unrelated failure as a benign "already claimed", which /// would wrongly flip the row to Claimed). A typed FFI result code is the /// robust long-term fix. - static func isAlreadyConsumed(_ error: Error) -> Bool { + nonisolated static func isAlreadyConsumed(_ error: Error) -> Bool { isAlreadyConsumed(message: error.localizedDescription) } /// Pure classifier over the surfaced error message — the testable seam for - /// the false-positive-safety unit test. - static func isAlreadyConsumed(message: String) -> Bool { + /// the false-positive-safety unit test. `nonisolated` so the test (and any + /// caller) can invoke it off the main actor; it touches no view state. + nonisolated static func isAlreadyConsumed(message: String) -> Bool { message.lowercased().contains("already completely used") } From a0161d0fb433b69c5d53ed54b158fb6115641518 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 11 Jul 2026 20:40:27 +0700 Subject: [PATCH 40/74] refactor(swift-example-app): move Create invitation to the Sent Invitations screen Consolidate DIP-13 invitation create, list, and reclaim onto the paperplane (Sent Invitations) screen. InvitationsView now takes the active identity and presents CreateInvitationSheet from a toolbar + button; the Profile's create button and its section are removed. Co-Authored-By: Claude Opus 4.8 --- .../Views/DashPay/DashPayProfileView.swift | 19 ----------------- .../Views/DashPay/DashPayTabView.swift | 9 ++++++-- .../Views/DashPay/InvitationsView.swift | 21 +++++++++++++++++-- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift index 5f383fe9c92..b3b9aad1a98 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift @@ -20,9 +20,6 @@ struct DashPayProfileView: View { @State private var qrURI: String? @State private var qrError: String? - /// Presents the "Invite a friend" (DIP-13 invitation create) sheet. - @State private var showCreateInvitation = false - private var displayName: String { if let name = profile?.displayName? .trimmingCharacters(in: .whitespacesAndNewlines), @@ -115,18 +112,6 @@ struct DashPayProfileView: View { } .task { await generateAutoAcceptQR() } - Section("Invite a friend (DIP-13)") { - Button { - showCreateInvitation = true - } label: { - Label("Create invitation", systemImage: "person.badge.plus") - } - .accessibilityIdentifier("dashpay.profile.createInvitation") - Text("Fund a one-time link so someone with no Dash can register their identity and add you.") - .font(.caption) - .foregroundColor(.secondary) - } - if let url = profile?.avatarUrl? .trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty { @@ -155,10 +140,6 @@ struct DashPayProfileView: View { .accessibilityIdentifier("dashpay.profile.edit") } } - .sheet(isPresented: $showCreateInvitation) { - CreateInvitationSheet(identity: identity) - .environmentObject(walletManager) - } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index b78c862d7a4..8607b96ffdf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -211,9 +211,14 @@ struct DashPayTabView: View { } } ToolbarItem(placement: .navigationBarLeading) { - if let walletId = claimWalletId { + if let identity = activeIdentity, + let walletId = identity.wallet?.walletId { NavigationLink { - InvitationsView(walletId: walletId, network: network) + InvitationsView( + walletId: walletId, + network: network, + identity: identity + ) } label: { Image(systemName: "paperplane") } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift index f22622a6c41..40c7bd9b885 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift @@ -10,14 +10,17 @@ import SwiftUI struct InvitationsView: View { let walletId: Data let network: Network + let identity: PersistentIdentity @Query private var invitations: [PersistentInvitation] @State private var reclaimTarget: PersistentInvitation? + @State private var showCreateInvitation = false - init(walletId: Data, network: Network) { + init(walletId: Data, network: Network, identity: PersistentIdentity) { self.walletId = walletId self.network = network + self.identity = identity _invitations = Query( filter: PersistentInvitation.predicate(walletId: walletId), sort: [SortDescriptor(\PersistentInvitation.createdAtSecs, order: .reverse)] @@ -30,7 +33,7 @@ struct InvitationsView: View { ContentUnavailableView( "No invitations yet", systemImage: "gift", - description: Text("Invitations you create appear here.") + description: Text("Tap + to invite a friend.") ) } else { ForEach(invitations) { invitation in @@ -52,6 +55,17 @@ struct InvitationsView: View { .navigationTitle("Sent Invitations") .navigationBarTitleDisplayMode(.inline) .accessibilityIdentifier("dashpay.invitations.list") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showCreateInvitation = true + } label: { + Image(systemName: "plus.circle") + } + .accessibilityLabel("Create invitation") + .accessibilityIdentifier("dashpay.invitations.create") + } + } .sheet(item: $reclaimTarget) { invitation in ReclaimInvitationSheet( invitation: invitation, @@ -59,6 +73,9 @@ struct InvitationsView: View { network: network ) } + .sheet(isPresented: $showCreateInvitation) { + CreateInvitationSheet(identity: identity) + } } @ViewBuilder From d8ab2f89125e16529383da47ea4ef247626f7b94 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 11 Jul 2026 21:05:49 +0700 Subject: [PATCH 41/74] docs(swift-example-app): point QA004 + DP-12 at the new Create-invitation location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create-invitation moved from the Profile to the Sent Invitations screen (paperplane → + button, `dashpay.invitations.create`). Update the QA004 playbook steps and the TEST_PLAN DP-12 row so they describe the current entry point instead of the removed `DashPayProfileView` button. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md | 4 ++-- packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md index a301e1c6144..4268254d3e6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md @@ -11,13 +11,13 @@ ## Steps -1. **Create a reclaimable invitation.** Open create-invitation (DashPay tab → the create/gift affordance → `CreateInvitationSheet`). Leave the amount at its default (`0.005` DASH) and submit. Wait for the InstantSend lock to confirm, then screenshot `AI_QA/output/QA004_created.png`. Read `ZPERSISTENTINVITATION` via `sqlite3` and record the new row's `ZOUTPOINTHEX`, `ZAMOUNTDUFFS` (`500000`), and `ZSTATUSRAW` (`0` = Created). +1. **Create a reclaimable invitation.** Open create-invitation: DashPay tab → paperplane (`dashpay.openSentInvitations`) → the **+** button (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet`. Leave the amount at its default (`0.005` DASH) and submit. Wait for the InstantSend lock to confirm, then screenshot `AI_QA/output/QA004_created.png`. Read `ZPERSISTENTINVITATION` via `sqlite3` and record the new row's `ZOUTPOINTHEX`, `ZAMOUNTDUFFS` (`500000`), and `ZSTATUSRAW` (`0` = Created). 2. **Open Sent Invitations.** Tap `dashpay.openSentInvitations` (paperplane). `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_sent_list.json` and confirm `dashpay.invitations.list` shows the new row with amount `0.00500000 DASH` and a `Created` badge. 3. **Reclaim → top-up (existing identity).** Swipe the `Created` row left to reveal `dashpay.invitations.reclaim`; tap it. In the sheet, confirm the body copy reads "…as identity credits — not spendable Dash." Leave the target on **Existing identity** (`dashpay.invite.reclaim.target`), pick the target via `dashpay.invite.reclaim.identityPicker`, and tap `dashpay.invite.reclaim.submit`. Wait for the Platform state transition to confirm. Screenshot `AI_QA/output/QA004_reclaim_topup.png`. 4. **Verify the top-up.** Re-read SwiftData: the target identity's `ZBALANCE` rose by ~the voucher value (in credits; 1 duff ≈ 1000 credits), and the reclaimed row's `ZSTATUSRAW` is now `2` (Reclaimed). Cross-check the outpoint reads **consumed** on-chain (platform-explorer). 5. **Reclaim → register (new identity).** Repeat steps 1–3 with a **second** invitation, but in the sheet switch the target to **New identity** (right segment of `dashpay.invite.reclaim.target`) — the identity picker is replaced by "A brand-new identity funded by this voucher." Submit. After it confirms, confirm a new funded identity appears in the Identities tab and the row's `ZSTATUSRAW` is `2` (Reclaimed). Screenshot `AI_QA/output/QA004_reclaim_register.png`. 6. **Already-consumed handling.** Take a row whose voucher is already consumed on-chain (either the register-arm race, or force-quit the app, set that row's `ZSTATUSRAW` back to `0` via `sqlite3` while the app is terminated, relaunch, and reclaim it again). Tap `dashpay.invitations.reclaim` → submit. `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_already_consumed.json`. -7. **Minimum-amount guard.** Open `CreateInvitationSheet`, set the amount below the floor (e.g. `0.001`), and confirm the create button is disabled with the sub-minimum hint. Screenshot `AI_QA/output/QA004_min_guard.png`. +7. **Minimum-amount guard.** Open `CreateInvitationSheet` (paperplane → **+** `dashpay.invitations.create`), set the amount below the floor (e.g. `0.001`), and confirm the create button is disabled with the sub-minimum hint. Screenshot `AI_QA/output/QA004_min_guard.png`. ## Expected Results diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 1632a73d3de..9b5d3614d32 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -291,7 +291,7 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | DP-09 | Publish encrypted on-chain `contactInfo` (private contact metadata) | Platform | Thorough | ✅ | | DIP-15 §10. `ContactDetailView` → edit **Alias** / **Note** / **Hide contact** (`dashpay.detail.aliasEdit` / `dashpay.detail.noteEdit` / `dashpay.detail.hideToggle`) → `saveContactInfo` → `platform_wallet_set_dashpay_contact_info_with_signer` (ECB `encToUserId` + CBC `privateData`). These fields are locally cached **and** published encrypted to Platform once the identity has **≥2 established contacts** (stated in the in-app footer) → outcomes `.published` / `.deferredUntilTwoContacts` / `.skippedWatchOnly`. | | DP-10 | Incoming-payment backfill rescan (restore-from-seed / pre-watch window) | Cross | Manual | ✅ | regression | DIP-15 §8.7 / §12.6 (on the DIP-16 SPV base). No UI trigger — automatic in DashPay sync: `reconcile_dashpay_rescan` lowers SPV `synced_height` to `min($coreHeightCreatedAt)` across new receival contacts so the filter manager backfills. Pass: a DashPay payment that landed on a contact's address **before** it was watched (restore-from-seed / second device / the offline-accept→pay window) appears after restore + SPV sync. Environment-limited (must construct the skew window); the regression pin for the §12.6 payment-loss gap. | | DP-11 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | multiwallet | A's identity sends a contact request (`DP-01`) to B's; switch to wallet B's identity and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop entirely local. | -| DP-12 | Create invitation (DIP-13) | Cross | Common | 🔌 | funding | DashPay → **Create invitation** (planned `dashpay.invite.create`, beside "Add me QR" in `DashPayProfileView`) → amount entry + **"send a contact request back to me"** checkbox → `createInvitation` → `platform_wallet_create_invitation`. Builds an **InstantSend** asset-lock voucher at the DIP-13 invitation funding path (`3'`), amount Rust-capped at `MAX_INVITATION_DUFFS`, and returns a `dashpay://invite?data=…` link rendered as a QR + share sheet. Builds an **L1 asset lock** → needs the Core SPV client running + **testnet funds** (fund via Wallet → Receive → "request from testnet"). The link embeds a one-time voucher **private key** — a bearer credential; the UI must not log it and should flag the pasteboard sensitive. `🔌` until the UI lands. | +| DP-12 | Create invitation (DIP-13) | Cross | Common | 🔌 | funding | DashPay → paperplane (`dashpay.openSentInvitations`) → **+** (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet` → amount entry + **"send a contact request back to me"** checkbox → `createInvitation` → `platform_wallet_create_invitation`. Builds an **InstantSend** asset-lock voucher at the DIP-13 invitation funding path (`3'`), amount Rust-capped at `MAX_INVITATION_DUFFS`, and returns a `dashpay://invite?data=…` link rendered as a QR + share sheet. Builds an **L1 asset lock** → needs the Core SPV client running + **testnet funds** (fund via Wallet → Receive → "request from testnet"). The link embeds a one-time voucher **private key** — a bearer credential; the UI must not log it and should flag the pasteboard sensitive. `🔌` until the UI lands. | | DP-13 | Claim invitation (DIP-13) | Platform | Common | 🔌 | | Paste/scan a `dashpay://invite` link → claim sheet (planned `dashpay.invite.claim`, mirroring `AddViaQRSheet`) → `claimInvitation` → `platform_wallet_claim_invitation`. Registers a **new identity for the invitee funded by the imported voucher** (no L1 Dash on the invitee side; the asset-lock signature uses the imported voucher key). If the link carries inviter info, prompt **"establish contact with \?"** → on confirm, send the existing contact request (`DP-01` path). New identity lands in Identities; optional contact in Contacts. `🔌` until the UI lands. | | DP-14 | Invite → claim two-wallet e2e | Cross | Thorough | 🔌 | multiwallet | The feature's acceptance gate. Wallet A (funded, SPV running) creates an invitation (`DP-12`); wallet B (no funds) claims it (`DP-13`) → B gains a funded identity with no L1 Dash; if the inviter opted into the bootstrap **and** the invitee confirms, the contact establishes on both ends (cf. `DP-11`). Requires testnet funding + both wallets on the same network. `🔌` until the UI lands. | | DP-15 | Reject malformed / reused / expired invitation | Platform | Uncommon | 🔌 | | Negative paths all fail loudly with a clear message and no side effects: a malformed link (wrong scheme / non-base58 / truncated), a **reused** link (asset lock already consumed → deterministic "invitation already used"), and a **past-expiry** link (`validate_claimable` refuses before any network call). `🔌` until the UI lands. | From cc199692f0912ee0fc70b6f7b15c1b83e017e481 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 12 Jul 2026 10:50:52 +0700 Subject: [PATCH 42/74] =?UTF-8?q?docs(dip15):=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20fence=20language=20+=20stale=20reclaim-scope=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DIP15_INVITATIONS_SPEC: add a `text` language to the wire-format fence (markdown-lint). - INVITATIONS_PERSISTENCE_SWIFT_SPEC: reclaim ships in this PR, so drop the stale "v1 non-goal" wording on the T1 seam-test note; the seam is covered by InvitationPersistenceTests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- docs/dashpay/DIP15_INVITATIONS_SPEC.md | 2 +- docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md index ad67251b8dc..723f6f64a80 100644 --- a/docs/dashpay/DIP15_INVITATIONS_SPEC.md +++ b/docs/dashpay/DIP15_INVITATIONS_SPEC.md @@ -378,7 +378,7 @@ reference's six loose query params. Rationale in §7. The payload is a small ver rejects it), so the envelope can evolve without breaking older links. The as-built wire order (see `crypto/invitation.rs`) is: -``` +```text wire = version:u8 // = 0 ‖ voucher_key:[u8; 32] // one-time ECDSA private key (secret; zeroized) ‖ expiry_unix:u32(LE) // ADVISORY, IS-scoped (§5.1); not consensus diff --git a/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md b/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md index e2795b4bc4f..87b34a63bfa 100644 --- a/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md +++ b/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md @@ -186,9 +186,9 @@ unlike the Rust match). Entry point: a "Sent invitations" `NavigationLink` in `D the outpoint hex matching the created voucher. Then drive a second `store()` touching the same outpoint (or re-create) to confirm **upsert-in-place**, not a duplicate row. Reuse the proven DP-14 flow. -- **T1 seam test:** even though reclaim is a v1 non-goal, add a create→(simulated - reclaim/removal)→row-deleted assertion so the upsert-key ↔ removal-key form is exercised before - reclaim ships. Otherwise the seam ships untested and bites when reclaim lands. +- **T1 seam test:** a create→(simulated reclaim/removal)→row-deleted assertion pins the + upsert-key ↔ removal-key form. Reclaim ships in this PR (§8), and this seam is covered by + `InvitationPersistenceTests`; keep the assertion so a future key-form drift is caught. - **QA rows:** add DP-16 ("Sent invitations list reflects a created invitation; upsert-in-place on status change") to `TEST_PLAN.md` §4.10. From 539d721677b532a86b29604acabf9efaf58594cf Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 12 Jul 2026 11:05:29 +0700 Subject: [PATCH 43/74] fix(swift-example-app): distinguish self-reclaim from foreign claim on already-consumed On an already-consumed rejection the reclaim flow used to always mark the invitation Claimed ("already claimed by someone else"). If the inviter's own reclaim landed on-chain but the app crashed before saving the terminal status, a retry hit that same rejection and mislabeled their own reclaim as a foreign claim. Persist a reclaimInFlight marker set just before the consume is submitted and cleared on the terminal save. On an already-consumed retry, read it first: a set marker means our own reclaim (recover as Reclaimed); an unset marker means someone else claimed it (Claimed, claimant not named). A non-consumed error leaves the marker set so a later retry classifies correctly. The new field carries a property-level default, making it an additive SwiftData migration. Co-Authored-By: Claude Opus 4.8 --- .../Models/PersistentInvitation.swift | 13 +++++- .../DashPay/ReclaimInvitationSheet.swift | 45 +++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift index 73313ab248d..5b2618590d1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift @@ -64,6 +64,15 @@ public final class PersistentInvitation { /// value to an explicit `.unknown` label). public var statusRaw: Int + /// Set true just before this wallet's own reclaim consume is submitted, and + /// cleared once the terminal status is saved. It survives a crash between the + /// on-chain consume and the `statusRaw = 2` save, so a retry that hits + /// "already consumed" can tell *our own* reclaim (in-flight → Reclaimed) from + /// a voucher someone else claimed (never in-flight → Claimed). Defaults to + /// `false`, making it an additive SwiftData migration (the property-level + /// default lets existing rows migrate without a mapping model). + public var reclaimInFlight: Bool = false + /// Record timestamps. public var createdAt: Date public var updatedAt: Date @@ -77,7 +86,8 @@ public final class PersistentInvitation { expiryUnix: Int, createdAtSecs: Int, hasInviter: Bool, - statusRaw: Int + statusRaw: Int, + reclaimInFlight: Bool = false ) { self.outPointHex = outPointHex self.rawOutPoint = rawOutPoint @@ -88,6 +98,7 @@ public final class PersistentInvitation { self.createdAtSecs = createdAtSecs self.hasInviter = hasInviter self.statusRaw = statusRaw + self.reclaimInFlight = reclaimInFlight self.createdAt = Date() self.updatedAt = Date() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index 42d0096ff64..ddb71a39b97 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -169,6 +169,10 @@ struct ReclaimInvitationSheet: View { isReclaiming = true errorMessage = nil infoMessage = nil + // Whether a *previous* reclaim attempt was already in flight when this + // one started. Captured before the marker is set below so the catch can + // read it (a `do`-local binding wouldn't be visible there). + var hadPriorReclaimInFlight = false Task { @MainActor in defer { isReclaiming = false } do { @@ -178,6 +182,14 @@ struct ReclaimInvitationSheet: View { } let (txid, vout) = try outPointParts() + // Persist an in-flight marker before submitting our consume, so a + // crash between the on-chain consume and the terminal save can be + // recovered on retry: a later "already consumed" that finds this + // marker set was *our own* reclaim, not a foreign claim. + hadPriorReclaimInFlight = invitation.reclaimInFlight + invitation.reclaimInFlight = true + try? modelContext.save() + switch target { case .topUp: guard let identityId = selectedIdentityId else { @@ -208,20 +220,37 @@ struct ReclaimInvitationSheet: View { // 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 { if Self.isAlreadyConsumed(error) { - // Someone already claimed this voucher (or a prior reclaim - // consumed it). The consume is deterministically rejected — - // no funds are lost. Reflect the terminal state and show a - // neutral message (the claimant is intentionally not named). - invitation.statusRaw = 1 - invitation.updatedAt = Date() - try? modelContext.save() - infoMessage = "This invitation was already claimed." + // The consume was deterministically rejected because the + // voucher is already spent — no funds are lost. The in-flight + // marker disambiguates the two ways that happens: + if hadPriorReclaimInFlight { + // Our own earlier reclaim landed on-chain but crashed + // before saving the terminal status. Recover it as + // Reclaimed and clear the marker. + invitation.statusRaw = 2 + invitation.reclaimInFlight = false + invitation.updatedAt = Date() + try? modelContext.save() + infoMessage = "This invitation was already reclaimed." + } else { + // Someone else claimed the voucher first. Reflect the + // terminal state with a neutral message (the claimant is + // intentionally not named). + invitation.statusRaw = 1 + invitation.updatedAt = Date() + try? modelContext.save() + infoMessage = "This invitation was already claimed." + } } else { + // Uncertain outcome: leave the in-flight marker set so a later + // retry that hits "already consumed" classifies it as our own + // reclaim rather than a foreign claim. errorMessage = error.localizedDescription } } From 2b47bd5c9d19da40d0a8dfbde2f600dbd872fc31 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 12 Jul 2026 11:05:42 +0700 Subject: [PATCH 44/74] fix(swift-sdk): log invitation persistence fetch failures instead of swallowing them SwiftData is the sole UI source for the Sent-invitations list (no Rust->Swift rehydrate), so a silently skipped upsert would make a created invitation vanish from the list with no trace. Replace the invitation path's try? fetches with logged do/catch so a fetch failure is at least visible in logs. The commit itself is the shared changeset save() in endChangeset, which already logs its own failures, so it is left alone. The file-wide try?-on-save convention (asset locks, identities, txs, ...) is intentionally unchanged; repo-wide persistence-error telemetry is a separate follow-up. Co-Authored-By: Claude Opus 4.8 --- .../PlatformWalletPersistenceHandler.swift | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index fc51b28a1eb..81c8a6c9bd4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -229,12 +229,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { removed: [Data] ) { onQueue { + // Log fetch failures on the invitation path rather than swallowing + // them: SwiftData is the sole UI source (no Rust→Swift rehydrate), so + // a silently skipped upsert would make a created invitation vanish + // from the list with no trace. The commit itself is the shared + // changeset `save()` in `endChangeset`, which already logs its own + // failures; the file-wide `try?`-on-save convention (asset locks, + // identities, txs, …) is intentionally left unchanged here — + // repo-wide persistence-error telemetry is a separate follow-up. for entry in upserts { let outPointHex = entry.outPointHex let descriptor = FetchDescriptor( predicate: #Predicate { $0.outPointHex == outPointHex } ) - if let existing = try? backgroundContext.fetch(descriptor).first { + let existing: PersistentInvitation? + do { + existing = try backgroundContext.fetch(descriptor).first + } catch { + print("⚠️ persistInvitations: fetch failed for outpoint \(outPointHex) — skipping upsert; this invitation may be missing from the Sent list: \(error)") + continue + } + if let existing { existing.walletId = walletId existing.rawOutPoint = entry.rawOutPoint existing.fundingIndexRaw = entry.fundingIndexRaw @@ -265,8 +280,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let descriptor = FetchDescriptor( predicate: #Predicate { $0.outPointHex == hex } ) - if let existing = try? backgroundContext.fetch(descriptor).first { - backgroundContext.delete(existing) + do { + if let existing = try backgroundContext.fetch(descriptor).first { + backgroundContext.delete(existing) + } + } catch { + print("⚠️ persistInvitations: fetch failed for removal of outpoint \(hex) — stale invitation may linger in the Sent list: \(error)") } } } From 101f941c5831ed31b136a5f169077cc0cba4ef37 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 17:23:45 +0700 Subject: [PATCH 45/74] docs(dip13): reviewed legacy-compat invitation spec (v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec for making new-stack invitations wire-compatible with the legacy dash-wallet (Android) / dashwallet-ios wallets: legacy `du`+`assetlocktx`+ `pk`(WIF)+`islock` payload, AppsFlyer OneLink transport, claim-by-fetch, and Android onboarding amount tiers — while keeping reclaim + seedless. Folds four adversarial spec reviews (feasibility / scope / security / interop-correctness). Key correction: the interop contract is field-level parity with lenient parsing (reversed-endian retry, islock="null"→chainlock, compressed WIF, output_index derived from pk), not byte-for-byte. Targets a dedicated PR stacked on the invitation branch; #4041 lands as the baseline. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md diff --git a/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md b/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md new file mode 100644 index 00000000000..ef0d79dd33c --- /dev/null +++ b/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md @@ -0,0 +1,100 @@ +# DIP-13 Invitations — Legacy Wallet Compatibility Spec (v2, review-folded) + +Status: **REVIEWED (4 spec agents) → for sync/implementation** · Direction: **full legacy interop, dedicated PR** (do not fold into #4041; #4041 lands as the green baseline). · Author: platform-wallet + +> v2 folds the four spec reviews (feasibility / scope / security / interop-correctness). See §11 for the resolution log. The headline correction: **the interop contract is "emit strict/canonical, parse leniently — exactly as tolerantly as the live Android wallet."** "Byte-for-byte parity" was wrong; it's **field-level** parity. + +## 1. Problem & goal +PR #4041 shipped a new, self-contained invitation impl (`dashpay://invite?data=`, custom scheme, reclaim) on the unified Rust/Platform-SDK stack. It is **wire-incompatible** with the two legacy wallets (`dash-wallet` Android / AppsFlyer, `dashwallet-ios` / dead Firebase), which share a **field-name-based** `du`+`assetlocktx`+`pk`(WIF)+`islock` payload. Goal: make new-stack invites **cross-claimable with the live Android wallet** by adopting the legacy payload + transport + onboarding amounts, while **keeping reclaim + seedless + one shared codebase**. + +The on-chain primitive and derivation path (`m/9'/coin'/5'/3'/idx'`) are already identical across all three — no consensus change. + +## 2. Goals / Non-goals +**Goals:** G1 payload cross-claim parity (field-level); G2 AppsFlyer OneLink transport; G3 onboarding amounts; G4 preserve reclaim + seedless. +**Non-goals:** removing the bearer model (self-custody); changing the on-chain primitive/path. (Our-own-AASA Universal Links, prior issue #4096, is superseded by the AppsFlyer decision.) + +## 3. Wire format — the interop contract (FIELD-LEVEL parity; emit strict, parse lenient) + +**Emit (canonical, what we produce):** +```text +dashpay://invite + ?du= # required + &assetlocktx= + &pk= + &islock= # or omit (see below) + [&display-name=] + [&avatar-url=] +``` +- Parse **by field name, order-independent** — the two legacy wallets differ in param order *and* in scheme/host (iOS emits `https://invitations.dashpay.io/applink?…`), so **byte equality is not the contract**. Our parser MUST accept **both** the `dashpay://invite` scheme and the `https://invitations.dashpay.io/applink` host. +- **`pk`**: WIF, **compressed** flag set (the credit-output hash uses the *compressed* pubkey — wrong compression ⇒ wrong `hash160` ⇒ claim fails), network byte `0xCC` mainnet / `0xEF` testnet (matches bitcoinj + `dashcore::PrivateKey::from_wif`). +- **`assetlocktx`**: we **emit** lowercase big-endian display hex. On **claim** we parse leniently: try as-given, then **retry byte-reversed** on a fetch miss (mirrors Android's `Sha256Hash.wrap(id).reversedBytes` retry — old iOS links are little-endian and still exist). +- **`islock`**: OPTIONAL. Two absence forms MUST be handled: param missing, **and the literal string `"null"`** (Android emits `"null"` when the lock was a **chainlock**, not instantsend; its own `isValid` passes it). `islock == "null"` ⇒ treat as "no instant lock" and reconstruct a **`ChainAssetLockProof`** at claim (see §4.3), not an instant proof. iOS ignores `islock` on claim entirely. +- **islock version**: the hex is not self-describing — the decoder MUST assume **ISDLOCK** version and fall back to **ISLOCK** (Android does exactly this). +- **Validity (lenient, superset of both wallets):** require `assetlocktx` + `pk` present/non-blank (iOS's minimum). `du` is required to *emit* but treated as optional on *parse* (iOS accepts `du`-less links). Never reject solely on a missing/`"null"` `islock`. + +## 4. The four changes + +### 4.1 Payload codec — Rust `rs-platform-wallet/src/wallet/identity/crypto/invitation.rs` +Replace the binary envelope with the query form. +- `encode_invitation_uri` → build the §3 link. WIF via `dashcore::PrivateKey::to_wif` with `compressed=true`; assert compression+network in tests. txid via `proof.transaction().txid().to_string()` (lowercase big-endian). islock via consensus-encode of `proof.instant_lock()` → hex (omit if the proof is a ChainLock). +- `parse_invitation_uri` → accept both scheme + https host; parse the six fields by name; WIF network-checked decode; handle `islock` missing/`"null"`; **do not** fail on order or on missing `islock`/`du`. +- `ParsedInvitation` drops the embedded `asset_lock`; gains `funding_txid`, `islock: Option<…>`, and keeps `voucher_key` (decoded from WIF). Amount is **no longer known pre-fetch** — the preview shows amount only after §4.3 fetch (or "—" offline). + +### 4.2 Transport — AppsFlyer OneLink (app layer) +- Wrap the inner `dashpay://invite?…` as `af_dp` in a OneLink; inbound conversion listener extracts `af_dp`/`deep_link_value`/`link` → claim flow. Keep the raw custom scheme as a **first-class parallel fallback** (QR / in-person), not an afterthought. +- **External blocker (does not gate G1/G3/G4):** OneLink brand domain (`dashpay.onelink.me`), dev key, template ID — from the Android team's AppsFlyer account; the iOS app must be added to the **same OneLink template**. Build with config placeholders; the custom-scheme path is fully testable without creds. +- **Threat-model note (§7):** AppsFlyer receives the plaintext `pk` server-side. + +### 4.3 Claim — fetch tx by txid — Rust claim path +- Add `Sdk::get_transaction(txid) -> Transaction` (~20–40 lines; the gRPC `getTransaction` is already issued in `rs-sdk/src/core/transaction.rs:176` but the tx bytes are discarded — surface them, `Transaction::consensus_decode`). +- Reconstruct the proof mirroring Android `TopUpRepository.obtainAssetLockTransaction`: + 1. Fetch tx by `assetlocktx` (with the reversed-retry from §3). + 2. **Fail-fast guards:** `hash256(fetched tx) == assetlocktx` (both orders); if an islock is present, `islock.txid == fetched tx.txid`. + 3. **Derive `output_index` (do NOT hard-code 0):** reuse the shipped `voucher_credit_script(pk)` = `P2PKH(hash160(compressed pubkey(pk)))` as the **selector** — scan the fetched tx's `credit_outputs` for the output whose `script_pubkey` matches; reject if none matches. (The link carries `pk` but not the index; hard-coding 0 fails any legacy invite whose credit output isn't at index 0.) + 4. Build the proof: `islock` present → `InstantAssetLockProof { instant_lock, transaction, output_index }`; `islock == "null"`/absent → **`ChainAssetLockProof { core_chain_locked_height, out_point, transaction }`** (fetch the confirming height; mirrors the chainlock-only legacy path). + 5. Pass into the **unchanged** `put_to_platform_and_wait_for_response_with_private_key`. +- Retry/backoff on DAPI propagation lag (islock/chainlock proves finality). Consensus enforces pk↔output, islock↔tx, identity_id↔outpoint — all fail closed; the local guards are for fast-fail + correct index, not theft prevention. + +### 4.4 Amounts — onboarding tiers — Rust constants + Swift UI +- **Normal tier (ship in v1): `0.03 DASH`** = identity + a normal DPNS name (Android `DASH_PAY_FEE`). New create **default = 0.03**. +- **Raise/drop `MAX_INVITATION_DUFFS`** — current `0.01` is **below** the normal tier, so create rejects its own default; set MAX ≥ the contested tier or drop the hard cap and gate on wallet balance (Android: `spendableBalance >= amount`). +- **Keep `MIN_INVITATION_DUFFS = 0.003`** (already == Android `DASH_PAY_INVITE_MIN`). +- **Contested tier `0.25 DASH` — DEFERRED** until contested/premium-name registration is actually wired into the new-stack claim flow (scope review F5; don't ship a price tier with no consumer). Add the tier + picker when that lands. + +## 5. Preserved (unchanged): reclaim (outpoint + `funding_index`, orthogonal to link format), seedless key handling, shared Rust core. Regression-test reclaim after the codec change. + +## 6. Decisions — resolved +- **D1 contact bootstrap:** KEEP opt-in-both-ends (prior owner decision, safer for a bearer link); accept the minor parity gap vs legacy's auto-one-way. (Revisit if product wants frictionless parity.) +- **D2 AppsFlyer creds:** external blocker for G2 only; G1/G3/G4 proceed. +- **D3 byte order:** emit big-endian; **parse lenient with reversed-retry** (do NOT drop the hack — that was a regression). +- **D4 WIF:** compressed=true, network byte `0xCC`/`0xEF`; cross-wallet WIF byte-equality fixture test. +- **D5 sequencing:** **dedicated PR** stacked on the invitation branch; #4041 lands as baseline. + +## 7. Threat model & failure modes +| Risk | Severity | Mitigation | +|---|---|---| +| **AppsFlyer discloses plaintext `pk` server-side** | MED (regression vs #4041) | Bounded by amount cap + fast reclaim (NOT the advisory expiry, which a direct holder ignores). Document AppsFlyer as an untrusted intermediary; ensure the web preview forwards only `display-name`/`avatar-url`, never the `pk`-bearing link. | +| Claim-time tx fetch fails (DAPI lag) | MED | Retry/backoff; islock/chainlock proves finality; clear "still confirming" state. | +| Wrong endianness / can't claim old iOS links | LOW | Reversed-retry on fetch miss (§4.3). | +| WIF compression/network mismatch → silent claim fail | LOW | Compressed+network round-trip test. | +| `islock="null"` chainlock invite unclaimable | MED | ChainAssetLockProof path (§4.3.4). | +| Attacker-crafted link | — | No theft (consensus fails closed); worst case a failed claim (griefing) or a valid self-controlled identity. | +| Reclaim vs new payload | — | Orthogonal; regression-tested. | + +## 8. Architecture / ownership +Rust `rs-platform-wallet`: codec (§4.1), claim-by-fetch (§4.3), amount constants (§4.4). `rs-sdk`: `get_transaction` wrapper. FFI: signature updates. Swift/SwiftExampleApp: create tier UI, AppsFlyer wrap/inbound (§4.2), claim wiring. Reclaim: untouched. + +## 9. Test plan +- **Rust unit:** WIF **compressed+network** round-trip; `assetlocktx` big-endian emit + reversed-retry parse; `islock` present / absent / `"null"` handling; field-name parse (order-independent, both scheme + https host); `output_index` selection by `pk` match; ISDLOCK→ISLOCK fallback. +- **Interop fixture:** parse a **real captured Android link** (incl. a `"null"`-islock case) and assert **field equality** (NOT byte equality) + a successful claim reconstruction; if obtainable, an old little-endian iOS link. +- **Reclaim regression:** create → reclaim (topup + register) still green post-codec. +- **Funded testnet e2e (two emulators):** new→new claim (fetch path); new→**live Android `dash-wallet`** cross-claim (the real proof, when a build is available); reclaim-vs-claim race unchanged; amount 0.03 funds identity + a normal name end-to-end. +- **CI:** `cargo fmt --all --check` + workspace tests + Swift SDK strict build. + +## 10. Rollback: revert the codec/claim/amount commits; tracked invitations (outpoint-keyed SwiftData) unaffected. + +## 11. Spec-review resolution log +- **Feasibility:** claim-by-fetch is a ~20–40 line wrapper (gRPC already called); WIF exists & network-aware; amounts trivial; AppsFlyer the only external blocker. → §4.3, §4.1, §4.4, D2/D4. +- **Interop-correctness (highest-impact):** "byte-for-byte" → **field-level**; **keep** endianness reversed-retry; handle **`islock="null"`**; **compressed** WIF; accept both scheme+https host; islock optional; ISDLOCK/ISLOCK. → §3, §4.1, §4.3, D3/D4. +- **Security:** no theft vector (consensus fails closed); **must** derive `output_index` by `pk` match (not index 0); add `txid`/`islock.txid` fail-fast guards; **`islock="null"` needs ChainAssetLockProof**; **AppsFlyer server-side `pk` disclosure** is a real regression → threat model. → §4.3, §7. +- **Scope:** amount fix is a standalone bug (ship regardless); **#4041 stays green baseline**; interop = **dedicated PR**; **defer the 0.25 contested tier**; AppsFlyer re-introduces the 3rd-party-service dependency class → keep custom-scheme fallback first-class. → §2, §4.2, §4.4, D5. From 4af14277ab1906301284325725ec6f83beb64cd3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 17:27:38 +0700 Subject: [PATCH 46/74] feat(platform-wallet): size the invitation cap for onboarding (identity + name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise MAX_INVITATION_DUFFS 0.01 → 0.05 DASH. The legacy wallets size an invitation to what the invitee spends to onboard — identity creation PLUS a DPNS name (~0.03 DASH, the Android `DASH_PAY_FEE` normal tier) — not just identity creation. The old 0.01 cap was below that, so a create at the onboarding-sized default would have hit the MAX guard and been rejected. MIN stays 0.003 (the claimability floor, == Android `DASH_PAY_INVITE_MIN`). The contested/premium-name tier (~0.25 DASH) is deferred until contested-name claim exists in the new stack; raise the cap again then. Part of the legacy-compat rework (see docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- .../src/wallet/identity/network/invitation.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index 0a5ebbac8f7..001e8cbdd45 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -38,11 +38,15 @@ use crate::wallet::identity::network::contact_requests::ContactCryptoProvider; use super::*; -/// Hard cap on the amount an invitation can lock (0.01 DASH). The voucher is a +/// Hard cap on the amount an invitation can lock (0.05 DASH). The voucher is a /// bearer credential, so the blast radius of a leaked link is bounded here in -/// Rust — not just in the UI. Generous enough for identity registration plus a -/// small starting balance; tune if onboarding needs more. -pub const MAX_INVITATION_DUFFS: u64 = 1_000_000; +/// Rust — not just in the UI. Sized for onboarding: the invitee spends the +/// voucher on identity creation **plus** a normal DPNS name (~0.03 DASH — the +/// legacy `DASH_PAY_FEE`), so the previous 0.01 cap was actually below a usable +/// invitation and rejected its own onboarding default. The contested/premium-name +/// tier (~0.25 DASH) is deferred until contested-name-via-invite claim exists; +/// raise this cap when it does. +pub const MAX_INVITATION_DUFFS: u64 = 5_000_000; /// Floor on the amount an invitation can lock (0.003 DASH). A voucher funds a /// Platform identity operation, and creating an identity — which is what the From ccc48017b2baac2976bf4424d061e90167de0fe6 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 17:51:57 +0700 Subject: [PATCH 47/74] feat(rs-sdk): add Sdk::get_transaction to fetch + decode a Core tx by id The gRPC getTransaction was already issued inside the asset-lock stream poll, but the returned transaction bytes were discarded. Surface them via a small FetchedCoreTransaction wrapper (decoded tx + height + chain/instant lock flags) so the invitation claim path can refetch a funding tx by txid and reconstruct its asset-lock proof. Co-Authored-By: Claude Opus 4.8 --- packages/rs-sdk/src/core/mod.rs | 1 + packages/rs-sdk/src/core/transaction.rs | 55 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/rs-sdk/src/core/mod.rs b/packages/rs-sdk/src/core/mod.rs index f642f3b26f6..2ef40bb0fdf 100644 --- a/packages/rs-sdk/src/core/mod.rs +++ b/packages/rs-sdk/src/core/mod.rs @@ -6,3 +6,4 @@ mod dash_core_client; mod transaction; #[cfg(feature = "mocks")] pub use dash_core_client::LowLevelDashCoreClient; +pub use transaction::FetchedCoreTransaction; diff --git a/packages/rs-sdk/src/core/transaction.rs b/packages/rs-sdk/src/core/transaction.rs index ecde0c40665..1e4b2e0427b 100644 --- a/packages/rs-sdk/src/core/transaction.rs +++ b/packages/rs-sdk/src/core/transaction.rs @@ -16,7 +16,62 @@ use rs_dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; use std::time::Duration; use tokio::time::{sleep, timeout}; +/// A Core transaction fetched by id, plus the finality metadata needed to +/// reconstruct an asset-lock proof from it (an InstantSend proof when the +/// InstantLock is known, otherwise a ChainLock proof once chain-locked). +#[derive(Clone, Debug)] +pub struct FetchedCoreTransaction { + /// The decoded transaction. + pub transaction: Transaction, + /// Height of the block the transaction was mined in (0 if unconfirmed). + pub height: u32, + /// Whether the transaction's block is ChainLocked. + pub is_chain_locked: bool, + /// Whether the transaction is InstantSend-locked. + pub is_instant_locked: bool, +} + impl Sdk { + /// Fetch a Core transaction by its id via DAPI `getTransaction`. + /// + /// `txid` is the transaction id as a hex string (big-endian display form). + /// Returns the decoded transaction plus its confirmation/lock metadata. + /// A transaction that DAPI does not know surfaces as an error (the caller + /// may retry with the id byte-reversed for old little-endian links). + pub async fn get_transaction(&self, txid: &str) -> Result { + let GetTransactionResponse { + transaction, + height, + is_chain_locked, + is_instant_locked, + .. + } = self + .execute( + GetTransactionRequest { + id: txid.to_string(), + }, + RequestSettings::default(), + ) + .await + .into_inner()?; + + if transaction.is_empty() { + return Err(Error::Generic(format!( + "transaction {txid} not found (empty response)" + ))); + } + + let transaction = Transaction::consensus_decode(&mut transaction.as_slice()) + .map_err(|e| Error::CoreError(e.into()))?; + + Ok(FetchedCoreTransaction { + transaction, + height, + is_chain_locked, + is_instant_locked, + }) + } + /// Starts the stream to listen for instant send lock messages pub async fn start_instant_send_lock_stream( &self, From 31b6ca39cdf316cb8dd15a598f7e5fc6bf1ad657 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 17:52:20 +0700 Subject: [PATCH 48/74] feat(platform-wallet): legacy-compatible invitation codec + claim-by-fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the self-contained base58 binary envelope with the legacy wallet query form so new-stack invitations are field-level cross-claimable with dash-wallet (Android) / dashwallet-ios: dashpay://invite?du=&assetlocktx=&pk=&islock= [&display-name=…][&avatar-url=…] Codec (crypto/invitation.rs): - Emit canonical: compressed network-correct WIF (0xCC/0xEF), big-endian display txid, lowercase islock hex (omitted for a ChainLock proof). - Parse lenient (the interop crux): accept both the dashpay://invite scheme and the invitations.dashpay.io/applink host, field-name based and order-independent; treat a missing islock AND the literal "null" as no instant lock; accept du-less links; require only assetlocktx + pk. - ParsedInvitation drops the embedded proof; carries funding_txid + islock_hex + voucher_key (bearer-secret scrub + redacted Debug kept). InviterInfo now mirrors the wire (username/display-name/avatar-url); the inviter identity id is not in the link (resolved from the username via DPNS at contact-bootstrap). - Replace validate_claimable with voucher_output_index: select the funded credit output by pk<->P2PKH match (never hard-code index 0). Claim (network/invitation.rs): - reconstruct_asset_lock_proof: fetch the funding tx by txid (retry byte-reversed on a miss), fail-fast that it is the funding tx and (if present) that the islock locks it, select the output index, then build an InstantAssetLockProof (islock present) or a ChainAssetLockProof (islock absent/"null", once chain-locked). Feeds the unchanged put-to-platform raw-key path. Reclaim (funding_index_from_path) is untouched and still green. Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/identity/crypto/invitation.rs | 856 ++++++++++-------- .../src/wallet/identity/crypto/mod.rs | 3 +- .../src/wallet/identity/network/invitation.rs | 154 +++- 3 files changed, 604 insertions(+), 409 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs index 2a32ffbadad..c21d9852ac7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -1,82 +1,115 @@ -//! DashPay invitation link (`dashpay://invite`) codec — DIP-13 sub-feature 3'. +//! DashPay invitation link codec — DIP-13 sub-feature 3', legacy-compatible. //! //! An invitation packages a one-time ECDSA **voucher** private key together with -//! the InstantSend asset-lock proof that funds it, so an invitee with no Dash can -//! register their own identity from it. The inviter optionally includes their own -//! identity id + username so the invitee can send a contact request back. +//! a reference to the InstantSend-locked funding transaction, so an invitee with +//! no Dash can register their own identity from it. The inviter optionally +//! includes their own DPNS username so the invitee can send a contact request +//! back. //! -//! The link is a single versioned, self-contained blob: -//! `dashpay://invite?data=`. Only the off-chain envelope is ours; -//! the embedded `AssetLockProof` and the on-chain acts are consensus formats. -//! See `docs/dashpay/DIP15_INVITATIONS_SPEC.md`. +//! The link is the **legacy query form** shared with the reference wallets +//! (`dash-wallet` Android, `dashwallet-ios`), so a link produced here is +//! field-level cross-claimable with those wallets and vice versa: //! -//! The payload uses a small hand-rolled little-endian binary format (rather than -//! serde/bincode) so the codec has no dependency on the crate's optional `serde` -//! feature — create/claim need it unconditionally. +//! ```text +//! dashpay://invite +//! ?du= +//! &assetlocktx= +//! &pk= +//! &islock= # or omitted / "null" +//! [&display-name=] +//! [&avatar-url=] +//! ``` +//! +//! The interop contract is **emit strict/canonical, parse lenient** — exactly as +//! tolerantly as the live wallets: +//! - Parse accepts both the `dashpay://invite` scheme and the +//! `https://invitations.dashpay.io/applink` host, by field name and +//! order-independent (the two legacy wallets differ in param order). +//! - `islock` is optional: a missing param **and** the literal string `"null"` +//! (which Android emits for chainlock-confirmed invites) both mean "no instant +//! lock" — the claim reconstructs a ChainLock proof instead. +//! - `assetlocktx` is kept as the raw hex string; the claim tries it as-given +//! then byte-reversed on a fetch miss, mirroring the legacy endianness retry. //! //! # Security //! //! The `voucher_key` is **bearer money** — whoever holds the link can claim the //! funded identity. The URI is a secret: callers MUST NOT log or persist it, and //! the voucher key is never stored (it is HD-derived and re-derivable from the -//! funding index). Parsing is bounded before decode (base58 length cap) so a -//! hostile link can't force a large allocation, and [`validate_claimable`] fails -//! fast on a stale, wrong-type, or mismatched link before any network call. +//! funding index). Parsing is bounded (URI length cap) so a hostile link can't +//! force a large allocation, and the WIF is network- and compression-checked so +//! a malformed key is rejected before any network call. use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; -use dashcore::ScriptBuf; -use dpp::bincode::config; +use dashcore::transaction::special_transaction::TransactionPayload; +use dashcore::{Network, PrivateKey, ScriptBuf, Transaction}; use dpp::prelude::AssetLockProof; -use zeroize::Zeroizing; use crate::error::PlatformWalletError; -/// URI prefix for an invitation deep link. The `dashpay://invite` scheme matches -/// the reference wallets for familiarity; the payload is our own. -const INVITATION_URI_PREFIX: &str = "dashpay://invite?data="; - -/// Max base58 chars of the `data=` value accepted **before** decoding (anti-DoS). -/// A real payload — voucher key (32 B) + an InstantSend proof (funding tx + islock, -/// ~0.5–1 KB) + small metadata — base58-encodes to roughly 1.5–2 K chars; 8192 is -/// comfortable headroom while still bounding the base58 allocation a hostile link -/// can force. Mirrors the `dapk` cap in `auto_accept::parse_dashpay_contact_uri`. -const MAX_INVITATION_DATA_B58_LEN: usize = 8192; - -/// Hard byte cap on the decoded payload (defense in depth alongside the b58 cap). -const MAX_INVITATION_PAYLOAD_BYTES: usize = 64 * 1024; - -/// Max length (bytes) of a UTF-8 string field (username / display name). DPNS -/// labels are short; this only bounds a hostile link. -const MAX_STR_BYTES: usize = 256; - -/// Current invitation payload version. -const INVITATION_PAYLOAD_VERSION: u8 = 0; - -/// Inviter contact-bootstrap info — present iff the inviter opted in to "send a -/// contact request back to me". Absent ⇒ the invitation is a pure funding voucher. +/// The `dashpay://invite` custom scheme — the canonical form we emit and the +/// primary form we parse (QR / in-person / deep link). +const INVITATION_SCHEME_PREFIX: &str = "dashpay://invite"; + +/// The AppsFlyer OneLink applink host the iOS reference wallet emits. Parsed as +/// a first-class alternative to the custom scheme (field-level interop). +const INVITATION_APPLINK_HOST: &str = "invitations.dashpay.io/applink"; + +/// Query parameter names — identical to the legacy wallets' contract. +const PARAM_USER: &str = "du"; +const PARAM_ASSET_LOCK_TX: &str = "assetlocktx"; +const PARAM_PRIVATE_KEY: &str = "pk"; +const PARAM_IS_LOCK: &str = "islock"; +const PARAM_DISPLAY_NAME: &str = "display-name"; +const PARAM_AVATAR_URL: &str = "avatar-url"; + +/// Android emits this literal for the `islock` value when the funding was +/// confirmed by a ChainLock rather than an InstantSend lock; treat it as "no +/// instant lock" (reconstruct a ChainLock proof at claim), NOT as hex to decode. +const IS_LOCK_NULL_SENTINEL: &str = "null"; + +/// Max chars of the whole URI accepted before parsing (anti-DoS). A real link — +/// username + txid (64) + WIF (~52) + islock hex (~400) + optional avatar url — +/// is well under 2 KB; 8192 is comfortable headroom while bounding the +/// allocation a hostile link can force. +const MAX_INVITATION_URI_LEN: usize = 8192; + +/// Max length (bytes) of a UTF-8 string field (username / display name / avatar +/// url). DPNS labels are short; this only bounds a hostile link. +const MAX_STR_BYTES: usize = 2048; + +/// Inviter contact-bootstrap info — present iff the link carries a `du` +/// (username). Absent ⇒ the invitation is a pure funding voucher. The link does +/// not carry the inviter's identity id (the legacy format has no such field); +/// the invitee resolves it from `username` via DPNS at contact-bootstrap time. #[derive(Clone, Debug, PartialEq, Eq)] pub struct InviterInfo { - /// The inviter's identity id (32 bytes) — the target of the invitee's - /// contact request. - pub identity_id: [u8; 32], - /// The inviter's DPNS username, shown to the invitee and used to label the - /// contact. + /// The inviter's DPNS username (`du`), shown to the invitee and used to + /// resolve the inviter's identity for the contact request. pub username: String, - /// Optional display name for the claim UI. + /// Optional display name (`display-name`) for the claim UI. pub display_name: Option, + /// Optional avatar url (`avatar-url`) for the claim UI. + pub avatar_url: Option, } -/// A decoded invitation, ready for [`validate_claimable`] + claim. +/// A decoded invitation, ready for claim. +/// +/// Unlike the funding proof (which is fetched at claim), everything here comes +/// straight from the link: the bearer voucher key, the funding txid to fetch, +/// and the optional InstantSend lock hex. pub struct ParsedInvitation { /// One-time ECDSA voucher private key that funds the invitee's identity /// create (signs the asset-lock's outer state-transition signature). pub voucher_key: SecretKey, - /// The InstantSend asset-lock proof funding the voucher (embeds tx + islock). - pub asset_lock: AssetLockProof, - /// Advisory expiry (unix seconds). Not consensus-enforced; the claim path - /// refuses a past-expiry link so a stale IS proof is never submitted. - pub expiry_unix: u32, - /// Inviter contact-bootstrap info; `None` ⇒ pure funding voucher. + /// The funding transaction id as carried in the link (`assetlocktx`), + /// lowercased. Kept as the raw hex string so the claim can try it as-given + /// and byte-reversed on a fetch miss (old iOS links are little-endian). + pub funding_txid: String, + /// The InstantSend lock, lowercase consensus hex — `None` when the link + /// omitted `islock` or set it to `"null"` (a ChainLock-confirmed invite). + pub islock_hex: Option, + /// Inviter contact-bootstrap info; `None` ⇒ pure funding voucher (no `du`). pub inviter: Option, } @@ -89,12 +122,13 @@ impl Drop for ParsedInvitation { } impl std::fmt::Debug for ParsedInvitation { - /// Redacts the voucher key — the whole point of the type is to carry a - /// bearer secret, which must never reach a log. + /// Redacts the voucher key and the funding txid — the whole point of the + /// type is to carry a bearer secret, which must never reach a log. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ParsedInvitation") .field("voucher_key", &"") - .field("expiry_unix", &self.expiry_unix) + .field("funding_txid", &"") + .field("has_islock", &self.islock_hex.is_some()) .field("inviter", &self.inviter) .finish_non_exhaustive() } @@ -104,7 +138,8 @@ fn invalid(msg: impl Into) -> PlatformWalletError { PlatformWalletError::InvalidIdentityData(msg.into()) } -/// The P2PKH script the voucher key controls (compressed-pubkey hash160). +/// The P2PKH script the voucher key controls (compressed-pubkey hash160). This +/// is the selector that binds the voucher key to its funded credit output. fn voucher_credit_script(voucher_key: &SecretKey) -> ScriptBuf { let secp = Secp256k1::new(); let pubkey = PublicKey::from_secret_key(&secp, voucher_key); @@ -113,244 +148,252 @@ fn voucher_credit_script(voucher_key: &SecretKey) -> ScriptBuf { } // --------------------------------------------------------------------------- -// Wire encoding (little-endian, length-prefixed) +// Percent-encoding (URI query values). Only %XX escapes — never `+` for space +// (that is form encoding; `Uri.getQueryParameter` on the legacy wallets does +// not treat `+` as space, so neither do we). // --------------------------------------------------------------------------- -fn put_len_prefixed(buf: &mut Vec, bytes: &[u8]) { - buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); - buf.extend_from_slice(bytes); -} - -/// Cursor over the payload bytes with bounds-checked, non-panicking reads. -struct Reader<'a> { - buf: &'a [u8], - pos: usize, -} - -impl<'a> Reader<'a> { - fn new(buf: &'a [u8]) -> Self { - Self { buf, pos: 0 } - } - - fn take(&mut self, n: usize) -> Result<&'a [u8], PlatformWalletError> { - let end = self - .pos - .checked_add(n) - .ok_or_else(|| invalid("invitation payload length overflow"))?; - if end > self.buf.len() { - return Err(invalid("invitation payload truncated")); +/// Percent-encode a query value, passing the RFC 3986 unreserved set through +/// untouched (which covers hex, base58 WIF, and DPNS labels) and `%`-escaping +/// everything else. Encoding an already-safe value is a no-op. +fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), } - let out = &self.buf[self.pos..end]; - self.pos = end; - Ok(out) - } - - fn u8(&mut self) -> Result { - Ok(self.take(1)?[0]) - } - - fn u32(&mut self) -> Result { - let b = self.take(4)?; - Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) - } - - fn arr32(&mut self) -> Result<[u8; 32], PlatformWalletError> { - let mut out = [0u8; 32]; - out.copy_from_slice(self.take(32)?); - Ok(out) } + out +} - fn len_prefixed(&mut self, max: usize) -> Result<&'a [u8], PlatformWalletError> { - let len = self.u32()? as usize; - if len > max { - return Err(invalid("invitation payload field exceeds size cap")); +/// Decode a percent-encoded query value (`%XX` → byte). Leaves `+` literal. +/// Errors on a malformed escape or non-UTF-8 result. +fn percent_decode(s: &str) -> Result { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hi = bytes + .get(i + 1) + .and_then(|c| (*c as char).to_digit(16)) + .ok_or_else(|| invalid("malformed percent-escape in invitation link"))?; + let lo = bytes + .get(i + 2) + .and_then(|c| (*c as char).to_digit(16)) + .ok_or_else(|| invalid("malformed percent-escape in invitation link"))?; + out.push((hi * 16 + lo) as u8); + i += 3; + } else { + out.push(bytes[i]); + i += 1; } - self.take(len) - } - - fn string(&mut self) -> Result { - let bytes = self.len_prefixed(MAX_STR_BYTES)?; - String::from_utf8(bytes.to_vec()) - .map_err(|_| invalid("invitation payload string is not valid UTF-8")) } + String::from_utf8(out).map_err(|_| invalid("invitation link field is not valid UTF-8")) +} - fn finish(self) -> Result<(), PlatformWalletError> { - if self.pos != self.buf.len() { - return Err(invalid("unexpected trailing bytes in invitation payload")); +/// Split a query string (`k1=v1&k2=v2`) into percent-decoded key/value pairs, +/// order-independent. A blank segment or a segment without `=` is skipped. +fn parse_query(query: &str) -> Result, PlatformWalletError> { + let mut pairs = Vec::new(); + for segment in query.split('&') { + if segment.is_empty() { + continue; } - Ok(()) + let Some((raw_key, raw_val)) = segment.split_once('=') else { + continue; + }; + pairs.push((percent_decode(raw_key)?, percent_decode(raw_val)?)); } + Ok(pairs) } -/// Encode an invitation into a `dashpay://invite?data=` link. +/// Look up a field by name (first match wins), returning it trimmed. `None` for +/// a missing or blank value. +fn field<'a>(pairs: &'a [(String, String)], name: &str) -> Option<&'a str> { + pairs + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.trim()) + .filter(|v| !v.is_empty()) +} + +/// Encode an invitation into a legacy-compatible `dashpay://invite?…` link. /// -/// The returned URI **contains the plaintext voucher key** — treat it as a -/// secret (do not log or persist it). +/// The returned URI **contains the plaintext voucher key** (WIF) — treat it as a +/// secret (do not log or persist it). `network` sets the WIF network byte +/// (`0xCC` mainnet / `0xEF` testnet) and the key is emitted **compressed** (the +/// credit-output hash160 uses the compressed pubkey). The funding txid and +/// InstantSend lock are read from `asset_lock`; a ChainLock proof emits no +/// `islock`. `inviter` is `Some` only when the inviter opted into the +/// contact-bootstrap (the link then carries `du`/`display-name`/`avatar-url`). pub fn encode_invitation_uri( voucher_key: &SecretKey, + network: Network, asset_lock: &AssetLockProof, - expiry_unix: u32, inviter: Option<&InviterInfo>, ) -> Result { - let asset_lock_bytes = dpp::bincode::encode_to_vec(asset_lock, config::standard()) - .map_err(|e| invalid(format!("failed to encode asset-lock proof: {e}")))?; - - // Zeroized: `buf` holds the plaintext voucher scalar until it is base58'd - // into the (secret) URI; scrub the intermediate on drop. - let mut buf = Zeroizing::new(Vec::with_capacity(64 + asset_lock_bytes.len())); - buf.push(INVITATION_PAYLOAD_VERSION); - buf.extend_from_slice(&voucher_key.secret_bytes()); - buf.extend_from_slice(&expiry_unix.to_le_bytes()); - match inviter { - Some(info) => { - if info.username.len() > MAX_STR_BYTES - || info - .display_name - .as_ref() - .is_some_and(|d| d.len() > MAX_STR_BYTES) - { - return Err(invalid("inviter username/display name too long")); + // txid (big-endian display hex) + optional islock hex from the proof. + let (funding_txid, islock_hex) = match asset_lock { + AssetLockProof::Instant(instant) => { + let txid = instant.transaction().txid().to_string(); + let islock = hex::encode(dashcore::consensus::serialize(instant.instant_lock())); + (txid, Some(islock)) + } + AssetLockProof::Chain(chain) => (chain.out_point.txid.to_string(), None), + }; + + // WIF, compressed (the default for `PrivateKey::new`), network-correct. + let wif = PrivateKey::new(*voucher_key, network).to_wif(); + + let mut query = String::new(); + let mut push = |key: &str, val: &str| { + if !query.is_empty() { + query.push('&'); + } + query.push_str(key); + query.push('='); + query.push_str(&percent_encode(val)); + }; + + // `du` is emitted first (canonical) when the inviter opted in; a pure + // funding voucher emits a `du`-less link (still parseable — iOS accepts it). + if let Some(info) = inviter { + if info.username.len() > MAX_STR_BYTES { + return Err(invalid("inviter username too long")); + } + push(PARAM_USER, &info.username); + } + push(PARAM_ASSET_LOCK_TX, &funding_txid); + push(PARAM_PRIVATE_KEY, &wif); + if let Some(islock) = &islock_hex { + push(PARAM_IS_LOCK, islock); + } + if let Some(info) = inviter { + if let Some(display_name) = &info.display_name { + if display_name.len() > MAX_STR_BYTES { + return Err(invalid("inviter display name too long")); } - buf.push(1); - buf.extend_from_slice(&info.identity_id); - put_len_prefixed(&mut buf, info.username.as_bytes()); - match &info.display_name { - Some(d) => { - buf.push(1); - put_len_prefixed(&mut buf, d.as_bytes()); - } - None => buf.push(0), + push(PARAM_DISPLAY_NAME, display_name); + } + if let Some(avatar_url) = &info.avatar_url { + if avatar_url.len() > MAX_STR_BYTES { + return Err(invalid("inviter avatar url too long")); } + push(PARAM_AVATAR_URL, avatar_url); } - None => buf.push(0), } - put_len_prefixed(&mut buf, &asset_lock_bytes); - Ok(format!( - "{INVITATION_URI_PREFIX}{}", - bs58::encode(buf.as_slice()).into_string() - )) + Ok(format!("{INVITATION_SCHEME_PREFIX}?{query}")) } -/// Parse a `dashpay://invite?data=` link into a [`ParsedInvitation`]. +/// Parse a legacy-compatible invitation link into a [`ParsedInvitation`]. /// -/// Bounds the base58 input before decoding and rejects trailing bytes, an -/// unsupported version, and malformed keys/proofs. Does **not** check expiry or -/// the credit-output binding — call [`validate_claimable`] for that before use. +/// Accepts both the `dashpay://invite` scheme and the +/// `https://invitations.dashpay.io/applink` host, by field name and +/// order-independent. Requires `assetlocktx` + `pk` (non-blank); `du` and +/// `islock` are optional (a missing/`"null"` `islock` is a ChainLock invite). +/// The WIF is network- and compression-checked. Does **not** fetch or validate +/// the funding tx — that happens at claim. pub fn parse_invitation_uri(uri: &str) -> Result { - let data = uri - .strip_prefix(INVITATION_URI_PREFIX) - .ok_or_else(|| invalid("not a dashpay://invite?data= URI"))?; - // Tolerate trailing query params after the payload (`…?data=X&foo=Y`). - let data = data.split('&').next().unwrap_or(data); - if data.len() > MAX_INVITATION_DATA_B58_LEN { + if uri.len() > MAX_INVITATION_URI_LEN { return Err(invalid(format!( - "invitation data too long ({} chars; max {MAX_INVITATION_DATA_B58_LEN})", - data.len() - ))); - } - // Zeroized: the decoded payload carries the plaintext voucher scalar (at - // offset 1..33); scrub it once parsed. - let bytes = Zeroizing::new( - bs58::decode(data) - .into_vec() - .map_err(|e| invalid(format!("invitation data is not valid base58: {e}")))?, - ); - if bytes.len() > MAX_INVITATION_PAYLOAD_BYTES { - return Err(invalid(format!( - "invitation payload too large ({} bytes; max {MAX_INVITATION_PAYLOAD_BYTES})", - bytes.len() + "invitation link too long ({} chars; max {MAX_INVITATION_URI_LEN})", + uri.len() ))); } - let mut r = Reader::new(bytes.as_slice()); - let version = r.u8()?; - if version != INVITATION_PAYLOAD_VERSION { - return Err(invalid(format!( - "unsupported invitation version {version} (expected {INVITATION_PAYLOAD_VERSION})" - ))); - } - let voucher_key = SecretKey::from_slice(r.take(32)?) - .map_err(|e| invalid(format!("invalid voucher private key: {e}")))?; - let expiry_unix = r.u32()?; - let inviter = match r.u8()? { - 0 => None, - 1 => { - let identity_id = r.arr32()?; - let username = r.string()?; - let display_name = match r.u8()? { - 0 => None, - 1 => Some(r.string()?), - other => return Err(invalid(format!("invalid display-name flag {other}"))), - }; - Some(InviterInfo { - identity_id, - username, - display_name, - }) - } - other => return Err(invalid(format!("invalid inviter-present flag {other}"))), - }; - let asset_lock_bytes = r.len_prefixed(MAX_INVITATION_PAYLOAD_BYTES)?; - let (asset_lock, consumed): (AssetLockProof, usize) = - dpp::bincode::decode_from_slice(asset_lock_bytes, config::standard()) - .map_err(|e| invalid(format!("failed to decode asset-lock proof: {e}")))?; - if consumed != asset_lock_bytes.len() { - return Err(invalid("trailing bytes in embedded asset-lock proof")); + // Accept the custom scheme or the applink host; the transport differs but + // the query contract is identical (field-level, not byte-level, parity). + let is_scheme = uri.starts_with(INVITATION_SCHEME_PREFIX); + let is_applink = uri.contains(INVITATION_APPLINK_HOST); + if !is_scheme && !is_applink { + return Err(invalid( + "not a dashpay://invite or invitations.dashpay.io/applink link", + )); } - r.finish()?; + + // Everything after the first `?`, up to an optional fragment. + let query = uri + .split_once('?') + .map(|(_, q)| q) + .ok_or_else(|| invalid("invitation link has no query parameters"))?; + let query = query.split('#').next().unwrap_or(query); + let pairs = parse_query(query)?; + + // iOS minimum: `assetlocktx` + `pk` present and non-blank. Never reject on a + // missing/`"null"` `islock` or a missing `du`. + let assetlocktx = field(&pairs, PARAM_ASSET_LOCK_TX) + .ok_or_else(|| invalid("invitation link is missing the assetlocktx field"))?; + let pk = field(&pairs, PARAM_PRIVATE_KEY) + .ok_or_else(|| invalid("invitation link is missing the pk field"))?; + + // WIF: network-checked decode (`from_wif` rejects a foreign network byte), + // compression required (the credit-output hash uses the compressed pubkey — + // an uncompressed key would produce a mismatching hash160 and a dead claim). + let private_key = PrivateKey::from_wif(pk) + .map_err(|e| invalid(format!("invitation pk is not a valid WIF key: {e}")))?; + if !private_key.compressed { + return Err(invalid("invitation pk must be a compressed WIF key")); + } + let voucher_key = private_key.inner; + + let funding_txid = assetlocktx.to_lowercase(); + + // `islock`: a missing param and the literal `"null"` both mean no instant + // lock. Kept as lowercase hex; decoded to an `InstantLock` only at claim. + let islock_hex = field(&pairs, PARAM_IS_LOCK) + .filter(|v| *v != IS_LOCK_NULL_SENTINEL) + .map(|v| v.to_lowercase()); + + // Inviter present iff `du` is present. The identity id is not in the link; + // it is resolved from the username at contact-bootstrap time. + let inviter = field(&pairs, PARAM_USER).map(|username| InviterInfo { + username: username.to_string(), + display_name: field(&pairs, PARAM_DISPLAY_NAME).map(str::to_string), + avatar_url: field(&pairs, PARAM_AVATAR_URL).map(str::to_string), + }); Ok(ParsedInvitation { voucher_key, - asset_lock, - expiry_unix, + funding_txid, + islock_hex, inviter, }) } -/// Fail-fast validation before any network call. +/// Select the funded credit output the voucher key controls. /// -/// Rejects a link with a zero clock read, whose advisory expiry has passed, -/// whose proof is not an InstantSend proof (per the owner's proof-type -/// decision), or whose voucher key does not control the funded credit output — -/// turning an otherwise opaque consensus rejection into a clear, local error. -/// The credit-output binding is itself consensus-enforced, so this is a UX -/// guard, not a security boundary. -pub fn validate_claimable( - invitation: &ParsedInvitation, - now_unix: u32, -) -> Result<(), PlatformWalletError> { - // A zero `now` would make the `now > expiry` test below pass for any - // positive expiry, silently treating an expired link as fresh. Reject it up - // front, mirroring the create side's non-zero timestamp guard. - if now_unix == 0 { +/// The link carries the voucher key but not the output index; scan the fetched +/// asset-lock transaction's credit outputs for the one whose `script_pubkey` +/// matches the voucher key's P2PKH (compressed pubkey), and return its index. +/// Rejects a transaction with no matching credit output. The pk↔output binding +/// is itself consensus-enforced, so this is a fail-fast + correct-index guard, +/// not a security boundary — but selecting (rather than hard-coding index 0) is +/// required: a legacy invite's credit output need not be at index 0. +pub fn voucher_output_index( + transaction: &Transaction, + voucher_key: &SecretKey, +) -> Result { + let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &transaction.special_transaction_payload + else { return Err(invalid( - "invitation claim requires a valid clock (now_unix is zero)", + "funding transaction is not an asset-lock special transaction", )); - } - if now_unix > invitation.expiry_unix { - return Err(invalid(format!( - "invitation expired (expiry {}, now {now_unix}) — ask the sender for a new one", - invitation.expiry_unix - ))); - } - let instant = match &invitation.asset_lock { - AssetLockProof::Instant(instant) => instant, - AssetLockProof::Chain(_) => { - return Err(invalid( - "invitation asset-lock proof must be an InstantSend proof", - )) - } }; - let output = instant - .output() - .ok_or_else(|| invalid("asset-lock proof has no credit output at its output index"))?; - if output.script_pubkey != voucher_credit_script(&invitation.voucher_key) { - return Err(invalid( - "voucher key does not control the funded credit output", - )); - } - Ok(()) + let expected = voucher_credit_script(voucher_key); + payload + .credit_outputs + .iter() + .position(|out| out.script_pubkey == expected) + .map(|idx| idx as u32) + .ok_or_else(|| { + invalid("voucher key does not control any credit output of the funding transaction") + }) } #[cfg(test)] @@ -358,9 +401,7 @@ mod tests { use super::*; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; - use dashcore::transaction::special_transaction::TransactionPayload; use dashcore::{Transaction, TxOut}; - use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; fn voucher() -> SecretKey { @@ -369,212 +410,241 @@ mod tests { fn inviter_info() -> InviterInfo { InviterInfo { - identity_id: [0xAB; 32], username: "alice".to_string(), - display_name: Some("Alice".to_string()), + display_name: Some("Alice Example".to_string()), + avatar_url: Some("https://example.com/a b.png?x=1".to_string()), } } - /// An InstantSend proof whose single credit output pays to `key`'s P2PKH. - fn instant_proof_paying_to(key: &SecretKey) -> AssetLockProof { - let credit = TxOut { + /// Build an asset-lock tx whose credit output at `index` pays the voucher + /// key (and `index` decoy outputs before it that do not). + fn asset_lock_tx_paying_voucher_at(key: &SecretKey, index: usize) -> Transaction { + let decoy = SecretKey::from_slice(&[0x22u8; 32]).unwrap(); + let mut credit_outputs = Vec::new(); + for _ in 0..index { + credit_outputs.push(TxOut { + value: 50_000, + script_pubkey: voucher_credit_script(&decoy), + }); + } + credit_outputs.push(TxOut { value: 100_000, script_pubkey: voucher_credit_script(key), - }; + }); let payload = AssetLockPayload { version: 1, - credit_outputs: vec![credit], + credit_outputs, }; - let tx = Transaction { + Transaction { version: 3, lock_time: 0, input: vec![], output: vec![], special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), - }; + } + } + + fn instant_proof_paying_voucher() -> AssetLockProof { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); AssetLockProof::Instant(InstantAssetLockProof::new(InstantLock::default(), tx, 0)) } - fn proof_bytes(proof: &AssetLockProof) -> Vec { - dpp::bincode::encode_to_vec(proof, config::standard()).unwrap() + #[test] + fn wif_round_trip_preserves_compression_and_network() { + for (network, first_byte) in [(Network::Mainnet, 204u8), (Network::Testnet, 239u8)] { + let wif = PrivateKey::new(voucher(), network).to_wif(); + let decoded = PrivateKey::from_wif(&wif).expect("wif decodes"); + assert!(decoded.compressed, "voucher WIF must be compressed"); + assert_eq!(decoded.network, network, "network preserved"); + assert_eq!(decoded.inner.secret_bytes(), voucher().secret_bytes()); + // Network byte matches bitcoinj/legacy (0xCC mainnet, 0xEF testnet). + let raw = bs58::decode(&wif).into_vec().unwrap(); + assert_eq!(raw[0], first_byte); + } } #[test] - fn round_trip_with_inviter() { - let key = voucher(); - let proof = instant_proof_paying_to(&key); - let uri = encode_invitation_uri(&key, &proof, 1_800_000_000, Some(&inviter_info())) + fn encode_parse_round_trip_with_inviter() { + let proof = instant_proof_paying_voucher(); + let info = inviter_info(); + let uri = encode_invitation_uri(&voucher(), Network::Testnet, &proof, Some(&info)) .expect("encode"); - assert!(uri.starts_with(INVITATION_URI_PREFIX)); + assert!(uri.starts_with("dashpay://invite?")); let parsed = parse_invitation_uri(&uri).expect("parse"); - assert_eq!(parsed.voucher_key.secret_bytes(), key.secret_bytes()); - assert_eq!(parsed.expiry_unix, 1_800_000_000); - assert_eq!(parsed.inviter, Some(inviter_info())); - // Proof round-trips (compare re-encoded bytes — AssetLockProof is not Eq). - assert_eq!(proof_bytes(&parsed.asset_lock), proof_bytes(&proof)); + assert_eq!(parsed.voucher_key.secret_bytes(), voucher().secret_bytes()); + assert_eq!(parsed.inviter, Some(info)); + assert!(parsed.islock_hex.is_some()); + // The parsed txid matches the proof's transaction id (big-endian). + let expected_txid = match &proof { + AssetLockProof::Instant(i) => i.transaction().txid().to_string(), + _ => unreachable!(), + }; + assert_eq!(parsed.funding_txid, expected_txid); } #[test] - fn round_trip_pure_voucher_no_inviter() { - let key = voucher(); - let proof = instant_proof_paying_to(&key); - let uri = encode_invitation_uri(&key, &proof, 42, None).expect("encode"); + fn encode_parse_round_trip_pure_voucher_no_inviter() { + let proof = instant_proof_paying_voucher(); + let uri = + encode_invitation_uri(&voucher(), Network::Mainnet, &proof, None).expect("encode"); let parsed = parse_invitation_uri(&uri).expect("parse"); - assert!(parsed.inviter.is_none()); - assert_eq!(parsed.expiry_unix, 42); + assert!(parsed.inviter.is_none(), "du-less link ⇒ no inviter"); + assert!(!uri.contains("du="), "pure voucher emits no du"); } + /// Params in a non-canonical order must still parse (field-level, not + /// byte-level, interop — the two legacy wallets differ in order). #[test] - fn round_trip_inviter_without_display_name() { - let key = voucher(); - let proof = instant_proof_paying_to(&key); - let info = InviterInfo { - identity_id: [0x01; 32], - username: "bob".to_string(), - display_name: None, - }; - let uri = encode_invitation_uri(&key, &proof, 7, Some(&info)).expect("encode"); + fn parse_is_order_independent() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!("dashpay://invite?islock=deadbeef&pk={wif}&du=bob&assetlocktx=aabbcc"); let parsed = parse_invitation_uri(&uri).expect("parse"); - assert_eq!(parsed.inviter, Some(info)); + assert_eq!(parsed.funding_txid, "aabbcc"); + assert_eq!(parsed.islock_hex.as_deref(), Some("deadbeef")); + assert_eq!(parsed.inviter.as_ref().unwrap().username, "bob"); } + /// The `https://invitations.dashpay.io/applink` host is accepted as a + /// first-class alternative to the custom scheme (iOS emits it). #[test] - fn rejects_wrong_scheme() { - assert!(parse_invitation_uri("https://invite?data=abc").is_err()); - assert!(parse_invitation_uri("dashpay://contact?data=abc").is_err()); + fn parse_accepts_applink_host() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!( + "https://invitations.dashpay.io/applink?du=carol&assetlocktx=aabb&pk={wif}&islock=cc" + ); + let parsed = parse_invitation_uri(&uri).expect("parse applink"); + assert_eq!(parsed.inviter.as_ref().unwrap().username, "carol"); + assert_eq!(parsed.funding_txid, "aabb"); } + /// `islock` present / absent / `"null"`: only a real hex value yields + /// `Some`; both a missing param and the literal `"null"` yield `None` + /// (a ChainLock-confirmed invite Android's own validator accepts). #[test] - fn rejects_bad_base58() { - // '0','O','I','l' are not in the base58 alphabet. - let err = parse_invitation_uri("dashpay://invite?data=0OIl").unwrap_err(); - assert!(err.to_string().contains("base58")); + fn parse_islock_present_absent_and_null() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let present = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=00aa11"); + assert_eq!( + parse_invitation_uri(&present) + .unwrap() + .islock_hex + .as_deref(), + Some("00aa11") + ); + + let absent = format!("dashpay://invite?assetlocktx=aa&pk={wif}"); + assert!(parse_invitation_uri(&absent).unwrap().islock_hex.is_none()); + + let null = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=null"); + assert!( + parse_invitation_uri(&null).unwrap().islock_hex.is_none(), + "islock=null must be treated as no instant lock" + ); } + /// A `du`-less link still parses (iOS accepts du-less links). #[test] - fn rejects_oversized_data_before_decoding() { - let huge = "z".repeat(MAX_INVITATION_DATA_B58_LEN + 1); - let uri = format!("{INVITATION_URI_PREFIX}{huge}"); - let err = parse_invitation_uri(&uri).unwrap_err(); - assert!(err.to_string().contains("too long")); + fn parse_accepts_du_less_link() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=bb"); + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert!(parsed.inviter.is_none()); } #[test] - fn rejects_trailing_bytes() { - let key = voucher(); - let proof = instant_proof_paying_to(&key); - let uri = encode_invitation_uri(&key, &proof, 1, None).expect("encode"); - let data = uri.strip_prefix(INVITATION_URI_PREFIX).unwrap(); - let mut bytes = bs58::decode(data).into_vec().unwrap(); - bytes.push(0x00); - let tampered = format!( - "{INVITATION_URI_PREFIX}{}", - bs58::encode(&bytes).into_string() - ); - let err = parse_invitation_uri(&tampered).unwrap_err(); - assert!(err.to_string().contains("trailing")); + fn parse_rejects_missing_pk_or_assetlocktx() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + // No pk. + assert!(parse_invitation_uri("dashpay://invite?assetlocktx=aa").is_err()); + // No assetlocktx. + assert!(parse_invitation_uri(&format!("dashpay://invite?pk={wif}")).is_err()); + // Blank assetlocktx is treated as missing. + assert!(parse_invitation_uri(&format!("dashpay://invite?assetlocktx=&pk={wif}")).is_err()); } #[test] - fn rejects_unsupported_version() { - let key = voucher(); - let proof = instant_proof_paying_to(&key); - let uri = encode_invitation_uri(&key, &proof, 1, None).expect("encode"); - let data = uri.strip_prefix(INVITATION_URI_PREFIX).unwrap(); - let mut bytes = bs58::decode(data).into_vec().unwrap(); - bytes[0] = 99; // corrupt the version byte - let tampered = format!( - "{INVITATION_URI_PREFIX}{}", - bs58::encode(&bytes).into_string() - ); - let err = parse_invitation_uri(&tampered).unwrap_err(); - assert!(err.to_string().contains("unsupported invitation version")); + fn parse_rejects_wrong_scheme_and_host() { + assert!(parse_invitation_uri("https://example.com/foo?pk=x").is_err()); + assert!(parse_invitation_uri("dashpay://contact?pk=x").is_err()); } #[test] - fn rejects_truncated_payload() { - let key = voucher(); - let proof = instant_proof_paying_to(&key); - let uri = encode_invitation_uri(&key, &proof, 1, None).expect("encode"); - let data = uri.strip_prefix(INVITATION_URI_PREFIX).unwrap(); - let bytes = bs58::decode(data).into_vec().unwrap(); - // Drop the tail so the embedded proof length prefix overruns. - let truncated = format!( - "{INVITATION_URI_PREFIX}{}", - bs58::encode(&bytes[..bytes.len() - 5]).into_string() - ); - assert!(parse_invitation_uri(&truncated).is_err()); + fn parse_rejects_uncompressed_wif() { + let uncompressed = PrivateKey::new_uncompressed(voucher(), Network::Testnet).to_wif(); + let uri = format!("dashpay://invite?assetlocktx=aa&pk={uncompressed}"); + let err = parse_invitation_uri(&uri).unwrap_err(); + assert!(err.to_string().contains("compressed")); } #[test] - fn validate_ok_for_fresh_matching_instant_proof() { - let key = voucher(); - let parsed = ParsedInvitation { - voucher_key: key, - asset_lock: instant_proof_paying_to(&key), - expiry_unix: 2_000_000_000, - inviter: None, - }; - assert!(validate_claimable(&parsed, 1_000_000_000).is_ok()); + fn parse_rejects_bad_wif() { + let err = parse_invitation_uri("dashpay://invite?assetlocktx=aa&pk=not-a-wif").unwrap_err(); + assert!(err.to_string().contains("WIF")); } #[test] - fn validate_rejects_expired() { - let key = voucher(); - let parsed = ParsedInvitation { - voucher_key: key, - asset_lock: instant_proof_paying_to(&key), - expiry_unix: 1_000, - inviter: None, - }; - let err = validate_claimable(&parsed, 2_000).unwrap_err(); - assert!(err.to_string().contains("expired")); + fn parse_rejects_oversized_uri() { + let huge = format!("dashpay://invite?pk={}", "z".repeat(MAX_INVITATION_URI_LEN)); + let err = parse_invitation_uri(&huge).unwrap_err(); + assert!(err.to_string().contains("too long")); } + /// A hand-crafted Android-style link (params in Android's emit order, with + /// display-name + avatar-url) parses to the right fields. #[test] - fn validate_rejects_zero_clock() { - // A zero clock read must be rejected up front: otherwise `now(0) > - // expiry` is false and even a long-expired link looks claimable. Here - // the expiry is well in the past, so only the zero-clock guard can - // reject it — a regression that dropped the guard would return `Ok`. + fn parse_android_style_link() { + let wif = PrivateKey::new(voucher(), Network::Mainnet).to_wif(); + // Android order: du, assetlocktx, pk, islock, display-name, avatar-url. + let uri = format!( + "dashpay://invite?du=satoshi&assetlocktx={txid}&pk={wif}&islock={islock}&display-name=Sat%20Oshi&avatar-url=https%3A%2F%2Fimg.example%2Fa.png", + txid = "e8b43025641eea4fd21190f01bd870ef90f1a8b199d8fc3376c5b62c0b1a179d", + islock = "01" + ); + let parsed = parse_invitation_uri(&uri).expect("parse android link"); + assert_eq!( + parsed.funding_txid, + "e8b43025641eea4fd21190f01bd870ef90f1a8b199d8fc3376c5b62c0b1a179d" + ); + assert_eq!(parsed.islock_hex.as_deref(), Some("01")); + let inviter = parsed.inviter.as_ref().expect("inviter"); + assert_eq!(inviter.username, "satoshi"); + assert_eq!(inviter.display_name.as_deref(), Some("Sat Oshi")); + assert_eq!( + inviter.avatar_url.as_deref(), + Some("https://img.example/a.png") + ); + } + + /// The voucher output is selected by pk↔script match, not hard-coded to 0. + #[test] + fn voucher_output_index_selects_matching_output() { let key = voucher(); - let parsed = ParsedInvitation { - voucher_key: key, - asset_lock: instant_proof_paying_to(&key), - expiry_unix: 1_000, - inviter: None, - }; - let err = validate_claimable(&parsed, 0).unwrap_err(); - assert!(err.to_string().contains("clock")); + // Voucher output sits at index 2, behind two decoy outputs. + let tx = asset_lock_tx_paying_voucher_at(&key, 2); + assert_eq!(voucher_output_index(&tx, &key).unwrap(), 2); } #[test] - fn validate_rejects_chain_proof() { + fn voucher_output_index_rejects_no_match() { let key = voucher(); - let chain = AssetLockProof::Chain(ChainAssetLockProof::new(42, [0x7u8; 36])); - let parsed = ParsedInvitation { - voucher_key: key, - asset_lock: chain, - expiry_unix: 2_000_000_000, - inviter: None, - }; - let err = validate_claimable(&parsed, 1).unwrap_err(); - assert!(err.to_string().contains("InstantSend")); + let other = SecretKey::from_slice(&[0x33u8; 32]).unwrap(); + let tx = asset_lock_tx_paying_voucher_at(&other, 0); + let err = voucher_output_index(&tx, &key).unwrap_err(); + assert!(err.to_string().contains("does not control")); } #[test] - fn validate_rejects_voucher_not_controlling_output() { + fn voucher_output_index_rejects_non_asset_lock_tx() { let key = voucher(); - let other = SecretKey::from_slice(&[0x22u8; 32]).unwrap(); - // Proof pays to `other`, but the parsed voucher key is `key`. - let parsed = ParsedInvitation { - voucher_key: key, - asset_lock: instant_proof_paying_to(&other), - expiry_unix: 2_000_000_000, - inviter: None, + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, }; - let err = validate_claimable(&parsed, 1).unwrap_err(); - assert!(err.to_string().contains("does not control")); + assert!(voucher_output_index(&tx, &key).is_err()); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index 4312c663bd1..6b00a1e5e6c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -19,6 +19,7 @@ pub use dip14::{ derive_contact_xpub, unmask_account_reference, ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, }; pub use invitation::{ - encode_invitation_uri, parse_invitation_uri, validate_claimable, InviterInfo, ParsedInvitation, + encode_invitation_uri, parse_invitation_uri, voucher_output_index, InviterInfo, + ParsedInvitation, }; pub use validation::pubkey_binds_expected_key_data; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index 001e8cbdd45..ef2a10b0107 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -16,10 +16,13 @@ use std::collections::BTreeMap; -use dpp::dashcore::PrivateKey; +use dpp::dashcore::consensus::Decodable; +use dpp::dashcore::{InstantLock, OutPoint, PrivateKey}; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; +use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dpp::identity::v0::IdentityV0; use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose, SecurityLevel}; use dpp::prelude::{AssetLockProof, Identifier}; @@ -32,7 +35,7 @@ use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; use crate::error::PlatformWalletError; -use crate::wallet::identity::crypto::{encode_invitation_uri, validate_claimable}; +use crate::wallet::identity::crypto::{encode_invitation_uri, voucher_output_index}; use crate::wallet::identity::crypto::{InviterInfo, ParsedInvitation}; use crate::wallet::identity::network::contact_requests::ContactCryptoProvider; @@ -83,8 +86,8 @@ impl Drop for WipingPrivateKey { /// A freshly-created invitation: the shareable link plus the bookkeeping the /// inviter tracks to reclaim an unclaimed voucher. pub struct Invitation { - /// The `dashpay://invite?data=…` link. **Contains the voucher key** — treat - /// as a secret (never log or persist it). + /// The `dashpay://invite?…` link (legacy query form). **Contains the voucher + /// key** (WIF) — treat as a secret (never log or persist it). pub uri: String, /// The funding asset lock's outpoint (the tracked lock's identity). pub out_point: dashcore::OutPoint, @@ -155,6 +158,20 @@ fn funding_index_from_path(path: &DerivationPath) -> Option { } } +/// Byte-reverse a txid hex string. Old iOS invitation links carry the funding +/// txid in little-endian internal order; DAPI keys `getTransaction` by the +/// big-endian display id, so the claim retries with the id reversed on a miss +/// (mirrors Android's `Sha256Hash.wrap(id).reversedBytes` retry). +fn reverse_txid_hex(txid_hex: &str) -> Result { + let mut bytes = hex::decode(txid_hex).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "invitation funding txid is not valid hex: {e}" + )) + })?; + bytes.reverse(); + Ok(hex::encode(bytes)) +} + impl IdentityWallet { /// Create a DashPay invitation: fund a one-time asset-lock voucher at the /// DIP-13 invitation path and return a shareable `dashpay://invite` link. @@ -243,7 +260,10 @@ impl IdentityWallet { // Drop-zeroize, so wipe it explicitly; scrubbing before propagating an // encode failure matters most there — on that path the key never // legitimately left the device, so a lingering copy is the worst kind. - let uri_result = encode_invitation_uri(&voucher_key, &proof, expiry_unix, inviter.as_ref()); + // The link carries the funding txid + islock (legacy query form), not + // the embedded proof; the invitee refetches the tx at claim. + let network = self.sdk.network; + let uri_result = encode_invitation_uri(&voucher_key, network, &proof, inviter.as_ref()); voucher_key.non_secure_erase(); let uri = uri_result?; @@ -293,6 +313,18 @@ impl IdentityWallet { /// Claim a DashPay invitation: register a NEW identity for the invitee, /// funded by the imported voucher. /// + /// The link carries only the voucher key + funding txid (+ optional islock), + /// not the funding proof — so this **refetches** the funding transaction + /// from Core and reconstructs the asset-lock proof, mirroring the legacy + /// Android claim (`TopUpRepository.obtainAssetLockTransaction`): + /// 1. Fetch the tx by `funding_txid`; retry byte-reversed on a miss (old iOS + /// links are little-endian). + /// 2. Fail-fast that the fetched tx is really the funding tx, and (if an + /// islock is present) that the islock locks it. + /// 3. Select the funded credit output by pk↔script match (not index 0). + /// 4. Build an `InstantAssetLockProof` when an islock is present, else a + /// `ChainAssetLockProof` once the funding tx is chain-locked. + /// /// The invitee's own identity keys (`keys_map`, derived from the invitee's /// seed) are signed by `identity_signer`; the asset-lock's outer /// state-transition signature is produced from the **imported voucher key** @@ -309,17 +341,19 @@ impl IdentityWallet { identity_index: u32, keys_map: BTreeMap, identity_signer: &S, - now_unix: u32, settings: Option, ) -> Result where S: Signer + Send + Sync, { - // Fail fast on a stale / wrong-type / mismatched link (and a zero clock - // read) before any network. - validate_claimable(&invitation, now_unix)?; preflight_keys_map(&keys_map)?; + // Reconstruct the funding asset-lock proof by refetching the tx. Consensus + // enforces pk↔output, islock↔tx, and identity_id↔outpoint, so the local + // guards below are for fast-fail + correct-index selection, not theft + // prevention (a crafted link at worst yields a failed claim). + let asset_lock = self.reconstruct_asset_lock_proof(&invitation).await?; + // The voucher key signs the asset lock's outer ST signature (ECDSA over // the credit-output pubkey hash). Convert to the SDK's `PrivateKey`, // scrubbed on every exit path by the `WipingPrivateKey` guard. @@ -333,15 +367,13 @@ impl IdentityWallet { revision: 0, }); - // Submit directly. The CL-height-too-low (10506) retry only helps - // ChainLock proofs; the claim path only ever carries an InstantSend proof - // (enforced at create, re-checked by `validate_claimable`), so a - // CL-height retry would be dead code. A within-expiry IS proof either - // lands or is rejected — the inviter re-creates on the rare rejection. + // Submit directly. An InstantSend or ChainLock proof both prove finality; + // a proof that no longer applies (e.g. the invite was already claimed) is + // rejected by consensus and surfaced to the caller. let identity = placeholder .put_to_platform_and_wait_for_response_with_private_key( &self.sdk, - invitation.asset_lock.clone(), + asset_lock, &voucher_priv.0, identity_signer, settings, @@ -412,6 +444,98 @@ impl IdentityWallet { Ok(identity) } + + /// Refetch the funding transaction and rebuild its asset-lock proof. + /// + /// Mirrors Android `TopUpRepository.obtainAssetLockTransaction`: fetch by + /// txid (retry byte-reversed on a miss), verify the fetched tx is the funding + /// tx, select the voucher's credit output, and assemble an InstantSend proof + /// (when the link carried an islock) or a ChainLock proof (islock absent / + /// `"null"` — a chainlock-confirmed invite). + async fn reconstruct_asset_lock_proof( + &self, + invitation: &ParsedInvitation, + ) -> Result { + // Fetch the funding tx; on a miss retry with the txid byte-reversed + // (old iOS links are little-endian). Propagate the reversed attempt's + // error if both fail. + let fetched = match self.sdk.get_transaction(&invitation.funding_txid).await { + Ok(tx) => tx, + Err(_) => { + let reversed = reverse_txid_hex(&invitation.funding_txid)?; + self.sdk + .get_transaction(&reversed) + .await + .map_err(PlatformWalletError::Sdk)? + } + }; + let transaction = fetched.transaction; + + // Fail-fast: the fetched tx must actually be the funding tx (either byte + // order). DAPI returns whatever tx matches the id we asked for, so this + // guards a backend that answers with an unrelated tx. + let fetched_txid = transaction.txid().to_string(); + let reversed_txid = reverse_txid_hex(&invitation.funding_txid).ok(); + if fetched_txid != invitation.funding_txid + && reversed_txid.as_deref() != Some(fetched_txid.as_str()) + { + return Err(PlatformWalletError::InvalidIdentityData( + "fetched transaction id does not match the invitation funding txid".to_string(), + )); + } + + // Select the funded credit output the voucher key controls (not index 0 + // — a legacy invite's credit output need not be first). + let output_index = voucher_output_index(&transaction, &invitation.voucher_key)?; + + match &invitation.islock_hex { + Some(islock_hex) => { + let islock_bytes = hex::decode(islock_hex).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "invitation islock is not valid hex: {e}" + )) + })?; + // The hex is not self-describing; the modern deterministic islock + // (ISDLOCK) carries its version byte, which is exactly what the + // consensus decoder reads first. + let instant_lock = InstantLock::consensus_decode(&mut islock_bytes.as_slice()) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "invitation islock could not be decoded: {e}" + )) + })?; + if instant_lock.txid != transaction.txid() { + return Err(PlatformWalletError::InvalidIdentityData( + "invitation islock does not lock the funding transaction".to_string(), + )); + } + Ok(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + transaction, + output_index, + ))) + } + None => { + // ChainLock invite: the proof references the outpoint + a + // chain-locked core height. Require the funding tx to be + // chain-locked so the proof proves finality; the inviter/invitee + // retries once the block is chain-locked otherwise. + if !fetched.is_chain_locked { + return Err(PlatformWalletError::InvalidIdentityData( + "chainlock invitation funding transaction is not yet chain-locked; \ + retry once it confirms" + .to_string(), + )); + } + let out_point = OutPoint::new(transaction.txid(), output_index); + let out_point_bytes: [u8; 36] = out_point.into(); + Ok(AssetLockProof::Chain(ChainAssetLockProof::new( + fetched.height, + out_point_bytes, + ))) + } + } + } } #[cfg(test)] From 48bbdb6bb2b7522c4d4ec525418ceee10e2ccf4f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 17:52:32 +0700 Subject: [PATCH 49/74] feat(platform-wallet-ffi): adapt invitation FFI to the legacy codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the platform-wallet signature changes, keeping the C ABI stable so the Swift wiring (a separate change) still links: - create: build InviterInfo from the username only; inviter_identity_id is now just the opt-in flag (the legacy link carries no identity id). - claim: drop the now-unused now_unix argument to the core call (the legacy link has no expiry); the extern param is retained for ABI. - parse preview: amount/expiry are unknown pre-fetch (the link carries the funding txid, not the proof) — report 0/0; is_instant now reflects islock presence; inviter_id is always zeroed (resolved from username via DPNS). Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/invitation.rs | 71 +++++++++---------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 6cb0a74ec24..bdcf1ac5256 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -29,7 +29,6 @@ use std::ffi::CStr; use std::os::raw::c_char; use dpp::identity::accessors::IdentityGettersV0; -use dpp::prelude::AssetLockProof; use platform_wallet::wallet::identity::crypto::{parse_invitation_uri, InviterInfo}; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; @@ -116,7 +115,10 @@ pub unsafe extern "C" fn platform_wallet_create_invitation( let expiry_unix = now_unix.saturating_add(MAX_INVITATION_TTL_SECS); // Build the optional inviter info: present iff `inviter_identity_id` is - // non-null, and the username is required in that case. + // non-null (the opt-in flag), and the username is required in that case. The + // legacy link carries only the inviter's username (`du`), not the identity + // id — the invitee resolves the id from the username via DPNS at + // contact-bootstrap time — so the id bytes themselves are not embedded. let inviter: Option = if inviter_identity_id.is_null() { None } else { @@ -126,17 +128,13 @@ pub unsafe extern "C" fn platform_wallet_create_invitation( "inviter_username is required when inviter_identity_id is provided", ); } - let mut identity_id = [0u8; 32]; - unsafe { - std::ptr::copy_nonoverlapping(inviter_identity_id, identity_id.as_mut_ptr(), 32); - } let username = unwrap_result_or_return!(unsafe { CStr::from_ptr(inviter_username) }.to_str()) .to_string(); Some(InviterInfo { - identity_id, username, display_name: None, + avatar_url: None, }) }; @@ -213,7 +211,7 @@ pub unsafe extern "C" fn platform_wallet_create_invitation( /// Claim a DashPay invitation: register a NEW identity for the invitee, funded /// by the imported voucher carried in `uri`. /// -/// `uri` is the `dashpay://invite?data=…` link; it is parsed into a +/// `uri` is the `dashpay://invite?…` link; it is parsed into a /// `ParsedInvitation` and validated (fail-fast on a stale / wrong-type / /// mismatched link) before any network act. `identity_pubkeys` are the /// invitee's own new-identity keys (derived from the invitee's seed), signed by @@ -273,11 +271,15 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( ); } + // `now_unix` is retained for ABI stability but unused: the legacy link + // carries no expiry, so the claim has no time-based gate. + let _ = now_unix; + let uri = unwrap_result_or_return!(unsafe { CStr::from_ptr(uri) }.to_str()).to_string(); - // Decode the off-chain envelope up front (pure, no network). Structural - // validity (scheme, version, size caps, key/proof shape) is checked here; - // the claimability checks (expiry, proof type, credit-output binding) run - // inside `claim_invitation`. + // Decode the off-chain link up front (pure, no network). Structural validity + // (scheme/host, size cap, WIF network+compression) is checked here; the + // funding tx is fetched and the credit-output binding resolved inside + // `claim_invitation`. let invitation = unwrap_result_or_return!(parse_invitation_uri(&uri)); let keys_map = match decode_identity_pubkeys(identity_pubkeys, identity_pubkeys_count) { Ok(m) => m, @@ -293,14 +295,7 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( // `signer_handle` for the duration of this call. let identity_signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; identity_wallet - .claim_invitation( - invitation, - identity_index, - keys_map, - identity_signer, - now_unix, - None, - ) + .claim_invitation(invitation, identity_index, keys_map, identity_signer, None) .await }) }); @@ -324,24 +319,25 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( /// before the user commits, and to drive the contact-bootstrap prompt. #[repr(C)] pub struct InvitationPreviewFFI { - /// The link decoded structurally (base58 envelope + version + fields). When + /// The link decoded structurally (scheme/host + required fields + WIF). When /// false, every other field is unset/zero and the link is malformed. pub structurally_valid: bool, - /// The embedded asset-lock proof is an InstantSend proof. Claim only accepts - /// Instant proofs, so a Chain proof (`false`) is unclaimable via this path. + /// The link carried an `islock`, so the claim will build an InstantSend + /// proof; `false` ⇒ a ChainLock-confirmed invite (islock absent / `"null"`). pub is_instant: bool, /// The link carries inviter info (the contact-bootstrap is available). pub has_inviter: bool, - /// Inviter identity id (32 bytes); zeroed when `has_inviter` is false. + /// Inviter identity id (32 bytes) — always zeroed: the legacy link carries + /// only the username (`du`), from which the id is resolved via DPNS. pub inviter_id: [u8; 32], /// Inviter DPNS username — heap C string, or null when `has_inviter` is /// false. Free with [`crate::platform_wallet_string_free`]. pub inviter_username: *mut c_char, - /// Amount locked in the voucher (duffs) — the Instant proof's credit-output - /// value; 0 for a non-Instant proof. + /// Amount locked in the voucher (duffs) — always 0: unknown pre-fetch (the + /// link carries the funding txid, not the proof), resolved at claim time. pub amount_duffs: u64, - /// Advisory expiry (unix seconds). The caller compares it against the current - /// time for an "expired" badge (the FFI stays clock-free). + /// Advisory expiry (unix seconds) — always 0: the legacy link carries no + /// expiry field. pub expiry_unix: u32, } @@ -361,7 +357,7 @@ impl InvitationPreviewFFI { } } -/// Decode a `dashpay://invite?data=…` link into a read-only +/// Decode a `dashpay://invite?…` link into a read-only /// [`InvitationPreviewFFI`] — NO claim, NO network, NO wallet handle. /// /// A well-formed-but-invalid link (bad base58, unsupported version, truncated, @@ -398,11 +394,11 @@ pub unsafe extern "C" fn platform_wallet_parse_invitation( Err(_) => return PlatformWalletFFIResult::ok(), }; - let is_instant = matches!(parsed.asset_lock, AssetLockProof::Instant(_)); - let amount_duffs = match &parsed.asset_lock { - AssetLockProof::Instant(instant) => instant.output().map(|o| o.value).unwrap_or(0), - AssetLockProof::Chain(_) => 0, - }; + // The legacy link carries the funding txid, not the proof — so the amount + // is unknown until the claim refetches the tx (shown as "—" pre-fetch), and + // the InstantSend-vs-ChainLock signal is the presence of the `islock` field. + let is_instant = parsed.islock_hex.is_some(); + let amount_duffs = 0; let (has_inviter, inviter_id, inviter_username) = match parsed.inviter.as_ref() { Some(info) => { @@ -411,7 +407,9 @@ pub unsafe extern "C" fn platform_wallet_parse_invitation( let username = std::ffi::CString::new(info.username.clone()) .map(|c| c.into_raw()) .unwrap_or(std::ptr::null_mut()); - (true, info.identity_id, username) + // The link has no inviter identity id (resolved from the username via + // DPNS at contact-bootstrap); report zeros. + (true, [0u8; 32], username) } None => (false, [0u8; 32], std::ptr::null_mut()), }; @@ -424,7 +422,8 @@ pub unsafe extern "C" fn platform_wallet_parse_invitation( inviter_id, inviter_username, amount_duffs, - expiry_unix: parsed.expiry_unix, + // The link carries no expiry (legacy format); 0 ⇒ "no expiry". + expiry_unix: 0, }; } PlatformWalletFFIResult::ok() From a26136b93f1df5a89ea2323d001b7baaf9920a51 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 17:56:21 +0700 Subject: [PATCH 50/74] docs(dip13): record legacy-format consequences (inviter-id/expiry off wire, ISLOCK limit) Confirmed during implementation: the legacy link carries no inviter identity-id (only the username `du`) and no expiry, so the invitee resolves the inviter via DPNS at contact-bootstrap, and the pre-network staleness gate is dropped (bound is the amount cap + reclaim). The ancient non-deterministic ISLOCK is a documented fail-closed limitation (rust-dashcore only represents modern ISDLOCK). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LecRMfPQNRrH2pYubp7wtn --- docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md b/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md index ef0d79dd33c..314922c477f 100644 --- a/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md +++ b/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md @@ -61,6 +61,11 @@ Replace the binary envelope with the query form. - **Keep `MIN_INVITATION_DUFFS = 0.003`** (already == Android `DASH_PAY_INVITE_MIN`). - **Contested tier `0.25 DASH` — DEFERRED** until contested/premium-name registration is actually wired into the new-stack claim flow (scope review F5; don't ship a price tier with no consumer). Add the tier + picker when that lands. +## 4b. Consequences of matching the legacy format (confirmed during impl) +- **No inviter identity-id on the wire.** The legacy link carries only `du` (username) — no identity id. So `InviterInfo = {username, display_name, avatar_url}`, and the **invitee resolves the inviter's identity from `du` via DPNS at contact-bootstrap** (mirrors Android `identityRepository.getUser(invite.user)`). Swift contact-bootstrap must do the DPNS lookup instead of using an embedded id. +- **No expiry on the wire.** Legacy links have no expiry; the pre-network staleness gate is dropped (the real bound is the amount cap + reclaim; expiry was advisory anyway). The inviter-side local reclaim TTL is unchanged. +- **islock ISLOCK-fallback is a documented limitation, not a gap.** We decode the modern deterministic ISDLOCK (what every live wallet emits and what we emit). rust-dashcore's `InstantLock` cannot represent the ancient non-deterministic ISLOCK; such a link fails closed (clean failed claim, `islock.txid != tx.txid` guard also catches it). Acceptable — add a manual ISLOCK parser only if a live producer is ever found. + ## 5. Preserved (unchanged): reclaim (outpoint + `funding_index`, orthogonal to link format), seedless key handling, shared Rust core. Regression-test reclaim after the codec change. ## 6. Decisions — resolved From e1cdbd470768d472c58c0f80111fdc7c870df70d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 18:09:42 +0700 Subject: [PATCH 51/74] feat(swift-example-app): wire invitation UI to the legacy-compat codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt the Swift invitation UI to the legacy query-format codec, whose parse preview no longer carries the amount, expiry, or inviter identity id (the link carries the funding txid and the inviter's DPNS username only). - CreateInvitationSheet: default amount 0.005 -> 0.03 DASH (the normal onboarding tier: identity + a DPNS name); cap mirror 0.01 -> 0.05 DASH to match MAX_INVITATION_DUFFS; min stays 0.003. Update placeholder/caption. - ClaimInvitationSheet: the amount is unknown pre-claim (shown as "—"), there is no expiry, and instant-vs-chainlock is resolved at claim time, so the only pre-claim gate is a structurally valid link (drop the amount/ expiry/instant gates; a chainlock invite is claimable via ChainAssetLock). - Contact-bootstrap: resolve the inviter's identity from their DPNS username via resolveDpnsName when the request is sent, instead of the now-zeroed embedded id (mirrors Android's identityRepository.getUser(invite.user)). - Refresh the InvitationPreview doc comments for the new field semantics. Co-Authored-By: Claude Opus 4.8 --- .../ManagedPlatformWallet.swift | 14 +++-- .../Views/DashPay/ClaimInvitationSheet.swift | 62 +++++++++---------- .../Views/DashPay/CreateInvitationSheet.swift | 19 +++--- 3 files changed, 48 insertions(+), 47 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 1c03ece9ec4..36ed772dbbd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -1977,19 +1977,21 @@ extension ManagedPlatformWallet { /// The link decoded structurally. When false, every other field is unset /// and the link is malformed / unreadable. public let structurallyValid: Bool - /// The embedded asset-lock proof is an InstantSend proof. Claim only - /// accepts Instant proofs, so `false` means the link is unclaimable. + /// The link carried an `islock`, so the claim will build an InstantSend + /// proof; `false` is a ChainLock-confirmed invite (still claimable). Not a + /// claimability gate — the proof is reconstructed at claim time. public let isInstant: Bool /// The link carries inviter info (the contact-bootstrap is available). public let hasInviter: Bool - /// Inviter identity id (32 bytes) when `hasInviter`, else nil. + /// Always nil: the legacy link carries only the inviter's username, not an + /// identity id (resolve it via `resolveDpnsName` at contact-bootstrap). public let inviterId: Data? /// Inviter DPNS username when `hasInviter`, else nil. public let inviterUsername: String? - /// Amount locked in the voucher (duffs); 0 for a non-Instant proof. + /// Always 0: the amount isn't in the link (it carries the funding txid, + /// not the proof) and is only known after the tx is fetched at claim time. public let amountDuffs: UInt64 - /// Advisory expiry (unix seconds). Compare against the current time for - /// an "expired" badge. + /// Always 0: the legacy link carries no expiry field. public let expiryUnix: UInt32 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift index 3b685d8b94c..b3bfc17e3c0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift @@ -6,8 +6,10 @@ import SwiftUI /// funded by the imported voucher, then optionally send a contact request back /// to the inviter. /// -/// The invitee pastes an invitation link; a read-only preview (amount, sender, -/// expiry) is shown before they commit. Claiming derives the invitee's own +/// The invitee pastes an invitation link; a read-only preview (sender, if the +/// link carries one) is shown before they commit — the amount isn't in the link +/// and is only known once the funding tx is fetched during the claim. Claiming +/// derives the invitee's own /// identity keys — including a DashPay Encryption/Decryption pair so the new /// identity can send the contact request back — exactly like a normal /// registration, but funds the identity from the voucher instead of a wallet @@ -32,11 +34,12 @@ struct ClaimInvitationSheet: View { @State private var errorMessage: String? @State private var contactPrompt: ContactPrompt? - /// Post-claim "establish contact with ?" prompt payload. + /// Post-claim "establish contact with ?" prompt payload. Carries the + /// inviter's DPNS username (`du`) rather than an identity id — the legacy link + /// has no id, so it is resolved via DPNS when the request is actually sent. private struct ContactPrompt: Identifiable { let id = UUID() let newIdentityId: Identifier - let inviterId: Identifier let username: String } @@ -107,18 +110,19 @@ struct ClaimInvitationSheet: View { private var canClaim: Bool { guard let preview, !isClaiming, !trimmedURI.isEmpty else { return false } - return preview.structurallyValid && preview.isInstant && !isExpired(preview) - } - - private func isExpired(_ p: ManagedPlatformWallet.InvitationPreview) -> Bool { - UInt32(Date().timeIntervalSince1970) > p.expiryUnix + // The legacy link carries only the funding txid — the amount, proof type, + // and (for a chainlock invite) the instant lock are resolved at claim time + // by fetching the tx on-chain. So the only pre-claim gate is a + // structurally valid link; instant-vs-chainlock and the exact amount are + // no longer known here, and there is no expiry to check. + return preview.structurallyValid } // MARK: - Sections @ViewBuilder private var inputSection: some View { Section { - TextField("dashpay://invite?data=…", text: $uri, axis: .vertical) + TextField("dashpay://invite?…", text: $uri, axis: .vertical) .textInputAutocapitalization(.never) .autocorrectionDisabled() .lineLimit(2...4) @@ -136,18 +140,10 @@ struct ClaimInvitationSheet: View { if !p.structurallyValid { Label("This link isn't a valid invitation.", systemImage: "xmark.octagon") .foregroundColor(.red) - } else if !p.isInstant { - Label( - "This invitation can't be claimed (missing an InstantSend proof).", - systemImage: "xmark.octagon" - ) - .foregroundColor(.red) } else { - LabeledContent("Amount", value: formatDash(p.amountDuffs)) - if isExpired(p) { - Label("Expired — ask the sender for a new link.", systemImage: "clock.badge.xmark") - .foregroundColor(.orange) - } + // The amount isn't in the link — it's read from the funding tx + // during the claim — so show a placeholder until then. + LabeledContent("Amount", value: "—") if p.hasInviter, let name = p.inviterUsername { LabeledContent("From", value: name) } @@ -212,14 +208,13 @@ struct ClaimInvitationSheet: View { let newIdentityId = try managed.getId() kickDashPaySync(walletManager) - // Offer the contact-bootstrap when the link carried an inviter; - // otherwise the claim is done. - if preview.hasInviter, - let inviterId = preview.inviterId, - let username = preview.inviterUsername { + // Offer the contact-bootstrap when the link carried an inviter + // (its username); otherwise the claim is done. The inviter's + // identity id is resolved from the username via DPNS when the + // request is sent, not from the link. + if preview.hasInviter, let username = preview.inviterUsername { contactPrompt = ContactPrompt( newIdentityId: newIdentityId, - inviterId: inviterId, username: username ) } else { @@ -238,10 +233,17 @@ struct ClaimInvitationSheet: View { return } do { + // The legacy link carries only the inviter's username, not their + // identity id — resolve it via DPNS before sending the request + // (mirrors Android's identityRepository.getUser(invite.user)). + guard let inviterId = try await wallet.resolveDpnsName(prompt.username) else { + errorMessage = "Identity claimed, but \(prompt.username) couldn't be found to add." + return + } let signer = KeychainSigner(modelContainer: modelContext.container) _ = try await wallet.sendContactRequest( senderIdentityId: prompt.newIdentityId, - recipientIdentityId: prompt.inviterId, + recipientIdentityId: inviterId, signer: signer ) kickDashPaySync(walletManager) @@ -264,8 +266,4 @@ struct ClaimInvitationSheet: View { guard let highest = used.max() else { return 0 } return highest == UInt32.max ? UInt32.max : highest + 1 } - - private func formatDash(_ duffs: UInt64) -> String { - String(format: "%.8f DASH", Double(duffs) / 100_000_000) - } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift index 09ba295fcb7..b8e32b7975b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift @@ -21,9 +21,9 @@ struct CreateInvitationSheet: View { /// 1 DASH = 100,000,000 duffs. private static let duffsPerDash: UInt64 = 100_000_000 - /// Rust-enforced cap (`MAX_INVITATION_DUFFS`, 0.01 DASH). Mirrored here so the + /// Rust-enforced cap (`MAX_INVITATION_DUFFS`, 0.05 DASH). Mirrored here so the /// UI rejects an over-cap amount before the FFI does. - private static let maxInvitationDuffs: UInt64 = 1_000_000 + private static let maxInvitationDuffs: UInt64 = 5_000_000 /// Rust-enforced floor (`MIN_INVITATION_DUFFS`, 0.003 DASH). A smaller voucher /// can't fund identity registration (which needs ~0.00228 DASH) plus the /// asset-lock overhead, so it could be neither claimed nor reclaimed. Rust is @@ -35,10 +35,11 @@ struct CreateInvitationSheet: View { /// funding type derives the voucher credit key internally (not this account). private static let fundingAccount: UInt32 = 0 - /// Amount to lock in the voucher, as a DASH string (decimal). Default 0.005 - /// DASH — comfortably above the ~0.00228 DASH identity-registration floor, - /// leaving the invitee a usable starting balance. - @State private var amountDashText: String = "0.005" + /// Amount to lock in the voucher, as a DASH string (decimal). Default 0.03 + /// DASH — the normal onboarding tier that funds a new identity *and* a normal + /// DPNS name (matches the legacy wallet's onboarding fee); a smaller voucher + /// registers an identity with no name and little usable balance. + @State private var amountDashText: String = "0.03" /// Opt into the contact-bootstrap: the link carries the inviter so the invitee /// can send a contact request back. Requires a registered username. @State private var sendRequestBack = true @@ -102,13 +103,13 @@ struct CreateInvitationSheet: View { private var inputSection: some View { Section("Amount") { HStack { - TextField("0.005", text: $amountDashText) + TextField("0.03", text: $amountDashText) .keyboardType(.decimalPad) .accessibilityIdentifier("dashpay.invite.create.amount") Text("DASH") .foregroundColor(.secondary) } - Text("Funds a one-time voucher your friend uses to register their identity. Between 0.003 and 0.01 DASH.") + Text("Funds a one-time voucher your friend uses to register their identity and a username. Between 0.003 and 0.05 DASH.") .font(.caption) .foregroundColor(.secondary) } @@ -146,7 +147,7 @@ struct CreateInvitationSheet: View { .accessibilityIdentifier("dashpay.invite.create.submit") } footer: { if amountDuffs == nil { - Text("Minimum 0.003 DASH — a smaller voucher can't fund identity registration. Maximum 0.01 DASH.") + Text("Minimum 0.003 DASH — a smaller voucher can't fund identity registration. Maximum 0.05 DASH.") .foregroundColor(.orange) } } From b723662df6dc6b0c79d72d58f6fa60ff601a97a8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 18:16:46 +0700 Subject: [PATCH 52/74] fix(rs-sdk): get_transaction returns Option; classify not-found vs transient Review follow-up: return Ok(None) for a genuinely-missing tx (empty response or gRPC NOT_FOUND) and Err only for a transient/transport failure, so the invitation claim's reversed-endian retry fires solely on a real miss and can never mask a transient error with the guaranteed-wrong reversed-id lookup. Also note is_instant_locked as deliberately informational (the proof carries the link's islock; consensus re-verifies). Co-Authored-By: Claude Opus 4.8 --- packages/rs-sdk/src/core/transaction.rs | 71 ++++++++++++++++++------- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/packages/rs-sdk/src/core/transaction.rs b/packages/rs-sdk/src/core/transaction.rs index 1e4b2e0427b..4c1134fee93 100644 --- a/packages/rs-sdk/src/core/transaction.rs +++ b/packages/rs-sdk/src/core/transaction.rs @@ -12,7 +12,9 @@ use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProo use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dpp::prelude::AssetLockProof; -use rs_dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; +use dapi_grpc::tonic::Code; +use rs_dapi_client::transport::TransportError; +use rs_dapi_client::{DapiClientError, DapiRequestExecutor, IntoInner, RequestSettings}; use std::time::Duration; use tokio::time::{sleep, timeout}; @@ -27,25 +29,39 @@ pub struct FetchedCoreTransaction { pub height: u32, /// Whether the transaction's block is ChainLocked. pub is_chain_locked: bool, - /// Whether the transaction is InstantSend-locked. + /// Whether the transaction is InstantSend-locked. Deliberately surfaced but + /// not required by the invitation claim: the proof carries the islock from + /// the link, and consensus re-verifies it — this flag is informational. pub is_instant_locked: bool, } +/// Whether an SDK error is a gRPC `NOT_FOUND` (the requested tx is unknown to +/// the node), as opposed to a transient/transport failure. Used to distinguish +/// "retry with a reversed txid" from "surface the error". +fn error_is_not_found(err: &Error) -> bool { + match err { + Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc(status))) => { + status.code() == Code::NotFound + } + Error::NoAvailableAddressesToRetry(inner) => error_is_not_found(inner), + _ => false, + } +} + impl Sdk { /// Fetch a Core transaction by its id via DAPI `getTransaction`. /// /// `txid` is the transaction id as a hex string (big-endian display form). - /// Returns the decoded transaction plus its confirmation/lock metadata. - /// A transaction that DAPI does not know surfaces as an error (the caller - /// may retry with the id byte-reversed for old little-endian links). - pub async fn get_transaction(&self, txid: &str) -> Result { - let GetTransactionResponse { - transaction, - height, - is_chain_locked, - is_instant_locked, - .. - } = self + /// Returns `Ok(Some(..))` with the decoded transaction plus its + /// confirmation/lock metadata; `Ok(None)` when the node does not know the tx + /// (empty response or gRPC `NOT_FOUND`) so the caller can retry with the id + /// byte-reversed; and `Err` for a transient/transport failure that must not + /// be masked by a doomed reversed-id retry. + pub async fn get_transaction( + &self, + txid: &str, + ) -> Result, Error> { + let response = match self .execute( GetTransactionRequest { id: txid.to_string(), @@ -53,23 +69,40 @@ impl Sdk { RequestSettings::default(), ) .await - .into_inner()?; + .into_inner() + { + Ok(response) => response, + Err(e) => { + let err: Error = e.into(); + return if error_is_not_found(&err) { + Ok(None) + } else { + Err(err) + }; + } + }; + + let GetTransactionResponse { + transaction, + height, + is_chain_locked, + is_instant_locked, + .. + } = response; if transaction.is_empty() { - return Err(Error::Generic(format!( - "transaction {txid} not found (empty response)" - ))); + return Ok(None); } let transaction = Transaction::consensus_decode(&mut transaction.as_slice()) .map_err(|e| Error::CoreError(e.into()))?; - Ok(FetchedCoreTransaction { + Ok(Some(FetchedCoreTransaction { transaction, height, is_chain_locked, is_instant_locked, - }) + })) } /// Starts the stream to listen for instant send lock messages From 83f0ee6ecc72724f62a6f6921e0b5dcb5a8451aa Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 18:17:03 +0700 Subject: [PATCH 53/74] fix(platform-wallet): harden invitation codec per adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the correctness-review interop/hardening items on the codec: - Anchor the transport gate to the real scheme/host/path via a small URL splitter instead of prefix/substring checks: only dashpay://invite (host == invite) or https://invitations.dashpay.io/applink (host + path anchored) are accepted, and the query is taken from the real URL. Rejects decoys like dashpay://inviteXYZ and https://evil/?next=…/applink&pk=… (which previously had their query parsed from the first '?'). - Validate the WIF network against the wallet at claim (network/invitation.rs): parse captures the WIF network, claim rejects a mainnet/testnet mismatch (mainnet-vs-testnet-family) with a clear error before the fetch, instead of a mysterious funding-tx miss. Correct the over-claiming parse/module docs. - Reject a duplicated required key (pk / assetlocktx): the two reference wallets bind different occurrences, so ?pk=A&pk=B is ambiguous. - Parse display-name/avatar-url independently of du (the reference wallets emit them in separate blocks) so a du-less link keeps its inviter metadata; InviterInfo.username is now optional. Note the avatar-url unencoded-'&' truncation as a known cosmetic limitation. - Consume the Option-returning get_transaction: reversed-endian retry only on a real not-found; a transient error propagates immediately. Adds tests: du-less-with-metadata, duplicate-key rejection, decoy scheme/host rejection, and WIF network-match. FFI updated (InviterInfo.username Option; preview handles a du-less link) — ABI unchanged. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/invitation.rs | 12 +- .../src/wallet/identity/crypto/invitation.rs | 284 +++++++++++++++--- .../src/wallet/identity/crypto/mod.rs | 4 +- .../src/wallet/identity/network/invitation.rs | 34 ++- 4 files changed, 279 insertions(+), 55 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index bdcf1ac5256..12f03c32063 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -132,7 +132,7 @@ pub unsafe extern "C" fn platform_wallet_create_invitation( unwrap_result_or_return!(unsafe { CStr::from_ptr(inviter_username) }.to_str()) .to_string(); Some(InviterInfo { - username, + username: Some(username), display_name: None, avatar_url: None, }) @@ -402,9 +402,13 @@ pub unsafe extern "C" fn platform_wallet_parse_invitation( let (has_inviter, inviter_id, inviter_username) = match parsed.inviter.as_ref() { Some(info) => { - // An interior NUL can't occur in a decoded UTF-8 DPNS label, but fall - // back to a null username rather than fail the whole preview. - let username = std::ffi::CString::new(info.username.clone()) + // Username is absent for a metadata-only (du-less) link; an interior + // NUL can't occur in a decoded UTF-8 DPNS label, but fall back to a + // null username rather than fail the whole preview. + let username = info + .username + .as_ref() + .and_then(|u| std::ffi::CString::new(u.clone()).ok()) .map(|c| c.into_raw()) .unwrap_or(std::ptr::null_mut()); // The link has no inviter identity id (resolved from the username via diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs index c21d9852ac7..8ca4ef08988 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -37,8 +37,9 @@ //! funded identity. The URI is a secret: callers MUST NOT log or persist it, and //! the voucher key is never stored (it is HD-derived and re-derivable from the //! funding index). Parsing is bounded (URI length cap) so a hostile link can't -//! force a large allocation, and the WIF is network- and compression-checked so -//! a malformed key is rejected before any network call. +//! force a large allocation; the WIF is decoded + compression-checked at parse, +//! and its network is validated against the wallet at claim (a wrong-network WIF +//! is a valid key on the wrong chain, caught before the funding fetch). use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; use dashcore::transaction::special_transaction::TransactionPayload; @@ -48,12 +49,18 @@ use dpp::prelude::AssetLockProof; use crate::error::PlatformWalletError; /// The `dashpay://invite` custom scheme — the canonical form we emit and the -/// primary form we parse (QR / in-person / deep link). +/// primary form we parse (QR / in-person / deep link). `invite` is the URI +/// authority (Android's `URI_PREFIX`), so the accepted host is exactly `invite`. +const INVITATION_SCHEME: &str = "dashpay"; +const INVITATION_SCHEME_HOST: &str = "invite"; +/// The full canonical prefix we emit. const INVITATION_SCHEME_PREFIX: &str = "dashpay://invite"; -/// The AppsFlyer OneLink applink host the iOS reference wallet emits. Parsed as -/// a first-class alternative to the custom scheme (field-level interop). -const INVITATION_APPLINK_HOST: &str = "invitations.dashpay.io/applink"; +/// The AppsFlyer OneLink applink the iOS reference wallet emits +/// (`https://invitations.dashpay.io/applink?…`). Parsed as a first-class +/// alternative to the custom scheme (field-level interop). +const INVITATION_APPLINK_HOST: &str = "invitations.dashpay.io"; +const INVITATION_APPLINK_PATH: &str = "/applink"; /// Query parameter names — identical to the legacy wallets' contract. const PARAM_USER: &str = "du"; @@ -78,15 +85,21 @@ const MAX_INVITATION_URI_LEN: usize = 8192; /// url). DPNS labels are short; this only bounds a hostile link. const MAX_STR_BYTES: usize = 2048; -/// Inviter contact-bootstrap info — present iff the link carries a `du` -/// (username). Absent ⇒ the invitation is a pure funding voucher. The link does +/// Inviter contact-bootstrap info — present iff the link carries any of `du`, +/// `display-name`, or `avatar-url`. Absent ⇒ pure funding voucher. The link does /// not carry the inviter's identity id (the legacy format has no such field); /// the invitee resolves it from `username` via DPNS at contact-bootstrap time. +/// +/// `username` is optional: the reference wallets emit `display-name`/`avatar-url` +/// in blocks independent of `du`, so a `du`-less link can still carry inviter +/// metadata (surfaced in the preview even though no contact request is possible +/// without a username). #[derive(Clone, Debug, PartialEq, Eq)] pub struct InviterInfo { /// The inviter's DPNS username (`du`), shown to the invitee and used to - /// resolve the inviter's identity for the contact request. - pub username: String, + /// resolve the inviter's identity for the contact request. `None` for a + /// metadata-only (`du`-less) link. + pub username: Option, /// Optional display name (`display-name`) for the claim UI. pub display_name: Option, /// Optional avatar url (`avatar-url`) for the claim UI. @@ -102,6 +115,11 @@ pub struct ParsedInvitation { /// One-time ECDSA voucher private key that funds the invitee's identity /// create (signs the asset-lock's outer state-transition signature). pub voucher_key: SecretKey, + /// The network the voucher key's WIF was encoded for (`0xCC` ⇒ Mainnet, + /// `0xEF` ⇒ the testnet family). The claim path rejects a link whose WIF + /// network does not match the wallet's, turning a wrong-network link into a + /// clear error instead of a mysterious fetch miss. + pub voucher_key_network: Network, /// The funding transaction id as carried in the link (`assetlocktx`), /// lowercased. Kept as the raw hex string so the claim can try it as-given /// and byte-reversed on a fetch miss (old iOS links are little-endian). @@ -221,6 +239,48 @@ fn field<'a>(pairs: &'a [(String, String)], name: &str) -> Option<&'a str> { .filter(|v| !v.is_empty()) } +/// The parts of an invitation URI we gate on. Extracted with a small hand parser +/// (no `url` crate in this crate's deps) so the transport check is anchored to +/// the real scheme/host/path — not a substring/prefix that a decoy URL could +/// smuggle (`dashpay://inviteXYZ`, `https://evil/?next=invitations.dashpay.io/applink&…`). +struct UriParts<'a> { + scheme: &'a str, + /// Authority host, lowercased for comparison (userinfo + port stripped). + host: String, + path: &'a str, + query: Option<&'a str>, +} + +/// Strip `userinfo@` and `:port` from an authority, leaving the bare host. +fn authority_host(authority: &str) -> &str { + let after_userinfo = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + // A bare `host:port` — take the host. (Our hosts are never bracketed IPv6.) + after_userinfo.split(':').next().unwrap_or(after_userinfo) +} + +/// Split `scheme://authority/path?query#fragment` into the parts we gate on. +/// Requires the `://` authority form (both accepted transports use it) and drops +/// the fragment. Returns `None` for a shape we don't recognize as a URI. +fn split_uri(uri: &str) -> Option> { + let (scheme, rest) = uri.split_once(':')?; + let rest = rest.strip_prefix("//")?; + let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..auth_end]; + let after_auth = &rest[auth_end..]; + // Drop the fragment, then split path from query. + let after_auth = after_auth.split('#').next().unwrap_or(after_auth); + let (path, query) = match after_auth.split_once('?') { + Some((p, q)) => (p, Some(q)), + None => (after_auth, None), + }; + Some(UriParts { + scheme, + host: authority_host(authority).to_ascii_lowercase(), + path, + query, + }) +} + /// Encode an invitation into a legacy-compatible `dashpay://invite?…` link. /// /// The returned URI **contains the plaintext voucher key** (WIF) — treat it as a @@ -259,13 +319,14 @@ pub fn encode_invitation_uri( query.push_str(&percent_encode(val)); }; - // `du` is emitted first (canonical) when the inviter opted in; a pure - // funding voucher emits a `du`-less link (still parseable — iOS accepts it). - if let Some(info) = inviter { - if info.username.len() > MAX_STR_BYTES { + // `du` is emitted first (canonical) when the inviter opted in with a + // username; a pure funding voucher emits a `du`-less link (still parseable — + // iOS accepts it). + if let Some(username) = inviter.and_then(|info| info.username.as_ref()) { + if username.len() > MAX_STR_BYTES { return Err(invalid("inviter username too long")); } - push(PARAM_USER, &info.username); + push(PARAM_USER, username); } push(PARAM_ASSET_LOCK_TX, &funding_txid); push(PARAM_PRIVATE_KEY, &wif); @@ -292,11 +353,15 @@ pub fn encode_invitation_uri( /// Parse a legacy-compatible invitation link into a [`ParsedInvitation`]. /// -/// Accepts both the `dashpay://invite` scheme and the -/// `https://invitations.dashpay.io/applink` host, by field name and -/// order-independent. Requires `assetlocktx` + `pk` (non-blank); `du` and -/// `islock` are optional (a missing/`"null"` `islock` is a ChainLock invite). -/// The WIF is network- and compression-checked. Does **not** fetch or validate +/// Accepts exactly the `dashpay://invite` scheme (host `invite`) and the +/// `https://invitations.dashpay.io/applink` URL (host + path anchored, so a +/// decoy URL that merely mentions the applink cannot smuggle a query in). Fields +/// are matched by name, order-independent. Requires `assetlocktx` + `pk` +/// (non-blank, each appearing at most once); `du` and `islock` are optional (a +/// missing/`"null"` `islock` is a ChainLock invite). The WIF is decoded and +/// compression-checked here; its **network** is captured for the claim path to +/// validate against the wallet (a wrong-network WIF is a valid key on the wrong +/// chain, so it must be caught there, not here). Does **not** fetch or validate /// the funding tx — that happens at claim. pub fn parse_invitation_uri(uri: &str) -> Result { if uri.len() > MAX_INVITATION_URI_LEN { @@ -306,24 +371,43 @@ pub fn parse_invitation_uri(uri: &str) -> Result { + parts.host == INVITATION_SCHEME_HOST && (parts.path.is_empty() || parts.path == "/") + } + "https" | "http" => { + parts.host == INVITATION_APPLINK_HOST + && parts.path.trim_end_matches('/') == INVITATION_APPLINK_PATH + } + _ => false, + }; + if !accepted { return Err(invalid( - "not a dashpay://invite or invitations.dashpay.io/applink link", + "not a dashpay://invite or https://invitations.dashpay.io/applink link", )); } - // Everything after the first `?`, up to an optional fragment. - let query = uri - .split_once('?') - .map(|(_, q)| q) + let query = parts + .query .ok_or_else(|| invalid("invitation link has no query parameters"))?; - let query = query.split('#').next().unwrap_or(query); let pairs = parse_query(query)?; + // A duplicated required key is ambiguous and dangerous: the two reference + // wallets bind different occurrences (iOS the last, Android the first), so a + // `?pk=A&pk=B` link would claim different keys per wallet. Reject it. + for key in [PARAM_PRIVATE_KEY, PARAM_ASSET_LOCK_TX] { + if pairs.iter().filter(|(k, _)| k == key).count() > 1 { + return Err(invalid(format!( + "invitation link has a duplicated `{key}` field" + ))); + } + } + // iOS minimum: `assetlocktx` + `pk` present and non-blank. Never reject on a // missing/`"null"` `islock` or a missing `du`. let assetlocktx = field(&pairs, PARAM_ASSET_LOCK_TX) @@ -331,15 +415,18 @@ pub fn parse_invitation_uri(uri: &str) -> Result Result bool { + matches!(pk_network, Network::Mainnet) == matches!(wallet_network, Network::Mainnet) +} + /// Select the funded credit output the voucher key controls. /// /// The link carries the voucher key but not the output index; scan the fetched @@ -410,7 +525,7 @@ mod tests { fn inviter_info() -> InviterInfo { InviterInfo { - username: "alice".to_string(), + username: Some("alice".to_string()), display_name: Some("Alice Example".to_string()), avatar_url: Some("https://example.com/a b.png?x=1".to_string()), } @@ -503,7 +618,10 @@ mod tests { let parsed = parse_invitation_uri(&uri).expect("parse"); assert_eq!(parsed.funding_txid, "aabbcc"); assert_eq!(parsed.islock_hex.as_deref(), Some("deadbeef")); - assert_eq!(parsed.inviter.as_ref().unwrap().username, "bob"); + assert_eq!( + parsed.inviter.as_ref().unwrap().username.as_deref(), + Some("bob") + ); } /// The `https://invitations.dashpay.io/applink` host is accepted as a @@ -515,7 +633,10 @@ mod tests { "https://invitations.dashpay.io/applink?du=carol&assetlocktx=aabb&pk={wif}&islock=cc" ); let parsed = parse_invitation_uri(&uri).expect("parse applink"); - assert_eq!(parsed.inviter.as_ref().unwrap().username, "carol"); + assert_eq!( + parsed.inviter.as_ref().unwrap().username.as_deref(), + Some("carol") + ); assert_eq!(parsed.funding_txid, "aabb"); } @@ -609,7 +730,7 @@ mod tests { ); assert_eq!(parsed.islock_hex.as_deref(), Some("01")); let inviter = parsed.inviter.as_ref().expect("inviter"); - assert_eq!(inviter.username, "satoshi"); + assert_eq!(inviter.username.as_deref(), Some("satoshi")); assert_eq!(inviter.display_name.as_deref(), Some("Sat Oshi")); assert_eq!( inviter.avatar_url.as_deref(), @@ -647,4 +768,77 @@ mod tests { }; assert!(voucher_output_index(&tx, &key).is_err()); } + + /// A `du`-less link that still carries `display-name`/`avatar-url` must + /// surface them (the reference wallets emit those independently of `du`). + #[test] + fn parse_du_less_link_keeps_metadata() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!( + "dashpay://invite?assetlocktx=aa&pk={wif}&display-name=No%20User&avatar-url=https%3A%2F%2Fimg%2Fx.png" + ); + let parsed = parse_invitation_uri(&uri).expect("parse"); + let inviter = parsed.inviter.as_ref().expect("metadata-only inviter"); + assert!(inviter.username.is_none(), "no du ⇒ no username"); + assert_eq!(inviter.display_name.as_deref(), Some("No User")); + assert_eq!(inviter.avatar_url.as_deref(), Some("https://img/x.png")); + } + + /// A duplicated required key is ambiguous (iOS binds the last, Android the + /// first) — reject rather than claim a wallet-dependent key. + #[test] + fn parse_rejects_duplicate_required_keys() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let other = PrivateKey::new( + SecretKey::from_slice(&[0x44u8; 32]).unwrap(), + Network::Testnet, + ) + .to_wif(); + let dup_pk = format!("dashpay://invite?assetlocktx=aa&pk={wif}&pk={other}"); + assert!(parse_invitation_uri(&dup_pk) + .unwrap_err() + .to_string() + .contains("duplicated")); + + let dup_tx = format!("dashpay://invite?assetlocktx=aa&assetlocktx=bb&pk={wif}"); + assert!(parse_invitation_uri(&dup_tx) + .unwrap_err() + .to_string() + .contains("duplicated")); + } + + /// The scheme/host gate must be anchored: a suffixed scheme host + /// (`dashpay://inviteXYZ`) and a decoy URL that merely *mentions* the applink + /// host in its own query must both be rejected — otherwise the decoy's query + /// (with a `pk`/`assetlocktx`) would be parsed. + #[test] + fn parse_rejects_decoy_scheme_and_host() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + // Suffixed authority — not exactly `invite`. + let suffixed = format!("dashpay://inviteXYZ?assetlocktx=aa&pk={wif}"); + assert!(parse_invitation_uri(&suffixed).is_err()); + // Decoy host: the real host is evil.example; the applink string only + // appears inside the decoy's own query value. + let decoy = format!( + "https://evil.example/r?next=invitations.dashpay.io/applink&assetlocktx=aa&pk={wif}" + ); + assert!(parse_invitation_uri(&decoy).is_err()); + // Wrong path on the right host. + let wrong_path = format!("https://invitations.dashpay.io/evil?assetlocktx=aa&pk={wif}"); + assert!(parse_invitation_uri(&wrong_path).is_err()); + } + + /// WIF network compatibility is mainnet-vs-not: a testnet WIF is rejected on + /// a mainnet wallet, but accepted on any testnet-family wallet. + #[test] + fn wif_network_matches_is_mainnet_vs_testnet_family() { + assert!(wif_network_matches(Network::Mainnet, Network::Mainnet)); + assert!(wif_network_matches(Network::Testnet, Network::Testnet)); + // WIF decodes 0xEF to Testnet; a devnet/regtest wallet must still accept. + assert!(wif_network_matches(Network::Testnet, Network::Devnet)); + assert!(wif_network_matches(Network::Testnet, Network::Regtest)); + // Cross mainnet/testnet is rejected (the wrong-chain paste). + assert!(!wif_network_matches(Network::Testnet, Network::Mainnet)); + assert!(!wif_network_matches(Network::Mainnet, Network::Testnet)); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index 6b00a1e5e6c..c0a0687b44b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -19,7 +19,7 @@ pub use dip14::{ derive_contact_xpub, unmask_account_reference, ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, }; pub use invitation::{ - encode_invitation_uri, parse_invitation_uri, voucher_output_index, InviterInfo, - ParsedInvitation, + encode_invitation_uri, parse_invitation_uri, voucher_output_index, wif_network_matches, + InviterInfo, ParsedInvitation, }; pub use validation::pubkey_binds_expected_key_data; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index ef2a10b0107..bd689fa1e76 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -35,7 +35,9 @@ use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; use crate::error::PlatformWalletError; -use crate::wallet::identity::crypto::{encode_invitation_uri, voucher_output_index}; +use crate::wallet::identity::crypto::{ + encode_invitation_uri, voucher_output_index, wif_network_matches, +}; use crate::wallet::identity::crypto::{InviterInfo, ParsedInvitation}; use crate::wallet::identity::network::contact_requests::ContactCryptoProvider; @@ -348,6 +350,16 @@ impl IdentityWallet { { preflight_keys_map(&keys_map)?; + // Reject a wrong-network link before any network work: a testnet WIF is a + // valid key on the wrong chain, so it would otherwise surface as a + // confusing funding-tx fetch miss rather than a clear "wrong network". + if !wif_network_matches(invitation.voucher_key_network, self.sdk.network) { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "invitation is for the {:?} network but this wallet is on {:?}", + invitation.voucher_key_network, self.sdk.network + ))); + } + // Reconstruct the funding asset-lock proof by refetching the tx. Consensus // enforces pk↔output, islock↔tx, and identity_id↔outpoint, so the local // guards below are for fast-fail + correct-index selection, not theft @@ -459,14 +471,28 @@ impl IdentityWallet { // Fetch the funding tx; on a miss retry with the txid byte-reversed // (old iOS links are little-endian). Propagate the reversed attempt's // error if both fail. - let fetched = match self.sdk.get_transaction(&invitation.funding_txid).await { - Ok(tx) => tx, - Err(_) => { + let fetched = match self + .sdk + .get_transaction(&invitation.funding_txid) + .await + .map_err(PlatformWalletError::Sdk)? + { + Some(tx) => tx, + // Not found as-given: retry with the txid byte-reversed (old iOS + // links are little-endian). A transient error is NOT retried — it + // propagated above — so the reversed lookup never masks it. + None => { let reversed = reverse_txid_hex(&invitation.funding_txid)?; self.sdk .get_transaction(&reversed) .await .map_err(PlatformWalletError::Sdk)? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "invitation funding transaction not found (tried both byte orders)" + .to_string(), + ) + })? } }; let transaction = fetched.transaction; From 57f2a9852a243c32ad28944f3de6b4781acdf2fa Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 19:59:49 +0700 Subject: [PATCH 54/74] style: rustfmt the changeset re-export merge resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v4.1-dev merge resolution in changeset/mod.rs hand-merged the `pub use changeset::{…}` list (union of HighestUsedIndexes + Invitation*); rustfmt re-wraps the final line. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/changeset/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 6a467f6b34c..2ddac5121d9 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -33,8 +33,7 @@ pub use changeset::{ PendingContactCryptoKey, PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, - ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, - WalletMetadataEntry, + ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState; From b7d79f737e80f77aefb7a67a4eccd6df8cd213e2 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 22:12:49 +0700 Subject: [PATCH 55/74] fix(platform-wallet): record invitation before fallible steps; propagate persist error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings B1/B3/S1 on #4041. B3+B1: persist the inviter-side InvitationEntry immediately after the funding is confirmed IS-locked, BEFORE the fallible voucher-key export + URI encode — and propagate the store() error instead of a best-effort warn. Previously three post-broadcast Err paths (IS→CL rejection, key export, over-long-field encode) returned before the record was written, orphaning a funded voucher: the DASH is OP_RETURN-burned, so with no persisted row it was invisible in the Sent-invitations reclaim UI. Recording first (and failing loudly if it can't be recorded) keeps a funded voucher reclaimable. Coupled with the Swift persistInvitations callback now signaling failure, create_invitation surfaces a funded-but-unrecorded voucher as a hard error rather than reporting success. S1: thread an explicit created_at_secs (the FFI's now_unix) instead of back-computing it from expiry_unix - MAX_INVITATION_TTL_SECS, which produced a 1970-relative timestamp for any caller passing a custom expiry_unix. platform-wallet + platform-wallet-ffi invitation tests: 24/24 + 10/10. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/invitation.rs | 1 + .../src/wallet/identity/network/invitation.rs | 68 +++++++++++-------- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 12f03c32063..782b6331285 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -172,6 +172,7 @@ pub unsafe extern "C" fn platform_wallet_create_invitation( funding_account_index, inviter, expiry_unix, + now_unix, &asset_lock_signer, &provider, ) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index bd689fa1e76..e06cf594ca9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -192,12 +192,14 @@ impl IdentityWallet { /// The proof is kept as an **InstantSend** proof (owner decision) — fast, and /// the embedded tx + islock make the link self-contained; staleness is /// bounded by the short advisory expiry, not a CL upgrade. + #[allow(clippy::too_many_arguments)] pub async fn create_invitation( &self, amount_duffs: u64, funding_account_index: u32, inviter: Option, expiry_unix: u32, + created_at_secs: u32, asset_lock_signer: &AS, crypto_provider: &CP, ) -> Result @@ -252,28 +254,18 @@ impl IdentityWallet { )); } - // Export the one-time voucher private key at the funding path. This is - // the one deliberate raw-key export (the whole point of an invitation); - // it is path-gated to the invitation sub-feature inside the provider. - let mut voucher_key = crypto_provider.export_invitation_private_key(&path).await?; - - // Build the (secret) URI, then scrub the exported scalar on BOTH the - // success and the encode-error path. `secp256k1::SecretKey` has no - // Drop-zeroize, so wipe it explicitly; scrubbing before propagating an - // encode failure matters most there — on that path the key never - // legitimately left the device, so a lingering copy is the worst kind. - // The link carries the funding txid + islock (legacy query form), not - // the embedded proof; the invitee refetches the tx at claim. - let network = self.sdk.network; - let uri_result = encode_invitation_uri(&voucher_key, network, &proof, inviter.as_ref()); - voucher_key.non_secure_erase(); - let uri = uri_result?; - - // Persist an inviter-side invitation record for the "Sent invitations" - // list + (future) reclaim. No secret is stored — only the funding index, - // which is display metadata (reclaim resumes by outpoint, not by this - // index). Best-effort: the link is already valid, so skipping the record - // never fails the create. + // Persist the inviter-side invitation record NOW — after the funding is + // confirmed valid (IS-locked) but BEFORE the fallible voucher-key export + // and URI encode below. Ordering matters: those later steps can fail on an + // already-funded voucher (a too-long username rejected by the encoder, an + // export error), and the "Sent invitations"/reclaim UI lists only persisted + // records — so recording the row first is what keeps a funded-but-not-yet- + // linked voucher reclaimable. The store is REQUIRED, not best-effort: a + // funded voucher we cannot record is a hard failure to surface, not a silent + // success. No secret is stored — only the funding index (display metadata; + // reclaim resumes by outpoint). If the funding path carries no u32 index + // tail (a structural can't-happen for the invitation account), warn-skip: + // an untrackable row can't be reclaimed either way, and the link is valid. if let Some(funding_index) = funding_index_from_path(&path) { let mut inv_cs = InvitationChangeSet::default(); inv_cs.invitations.insert( @@ -283,20 +275,23 @@ impl IdentityWallet { funding_index, amount_duffs, expiry_unix, - created_at_secs: expiry_unix.saturating_sub(MAX_INVITATION_TTL_SECS), + created_at_secs, has_inviter: inviter.is_some(), status: InvitationStatus::Created, }, ); - if let Err(e) = self - .persister + self.persister .store(crate::changeset::PlatformWalletChangeSet { invitations: Some(inv_cs), ..Default::default() }) - { - tracing::warn!(error = %e, "failed to persist invitation record; the link is still valid"); - } + .map_err(|e| { + PlatformWalletError::AssetLockTransaction(format!( + "the invitation voucher is funded but its record could not be \ + persisted (it would be missing from Sent invitations and \ + unreclaimable): {e}" + )) + })?; } else { tracing::warn!( "invitation funding path has no u32 index tail; \ @@ -304,6 +299,23 @@ impl IdentityWallet { ); } + // Export the one-time voucher private key at the funding path. This is + // the one deliberate raw-key export (the whole point of an invitation); + // it is path-gated to the invitation sub-feature inside the provider. + let mut voucher_key = crypto_provider.export_invitation_private_key(&path).await?; + + // Build the (secret) URI, then scrub the exported scalar on BOTH the + // success and the encode-error path. `secp256k1::SecretKey` has no + // Drop-zeroize, so wipe it explicitly; scrubbing before propagating an + // encode failure matters most there — on that path the key never + // legitimately left the device, so a lingering copy is the worst kind. + // The link carries the funding txid + islock (legacy query form), not + // the embedded proof; the invitee refetches the tx at claim. + let network = self.sdk.network; + let uri_result = encode_invitation_uri(&voucher_key, network, &proof, inviter.as_ref()); + voucher_key.non_secure_erase(); + let uri = uri_result?; + Ok(Invitation { uri, out_point, From e51c38f0551a1b3ca4a9fb88d0383e57191741e9 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 22:13:07 +0700 Subject: [PATCH 56/74] fix(swift-sdk): persistence callbacks signal failure (invitations; scoped voucher gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings B1/B2 on #4041. B1: persistInvitations returns a success Bool and its callback returns nonzero when any upsert/removal is skipped, so a dropped invitation upsert makes store() return Err (SwiftData is the sole UI source with no rehydrate — a silent skip would vanish a funded voucher). The Rust create_invitation now surfaces it. B2: persistAccountAddresses returns false ONLY for a missing IdentityInvitation account (AccountTypeTagFFI = 5); the pool callback accumulates across all pools (no early return) and returns nonzero iff an invitation pool failed. This makes the pre-broadcast funding-index durability gate actually abort (the callback previously always returned 0, defeating the gate → a restart could reuse the one-time bearer voucher key). Strictness is scoped to the invitation account so a transient miss during ordinary address sync — e.g. a first-registration account staged but not yet committed in the same round — cannot permanently wedge sync. Tests (InvitationPersistenceTests, swift test): invitation-account miss → failure, non-invitation miss → tolerant, clean upsert → success. 4/4 green. Co-Authored-By: Claude Opus 4.8 --- .../PlatformWalletPersistenceHandler.swift | 66 ++++++++++++++----- .../InvitationPersistenceTests.swift | 62 +++++++++++++++++ 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 23e2128f046..c4a8776efb5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -223,20 +223,25 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// and update branch (the view's `@Query` filters on it). The removal path /// keys via the same `encodeOutPoint` display form the upsert stores (the /// T1 seam), so an upsert and a later removal of the same outpoint match. + /// Returns `true` iff every upsert/removal was applied. A `false` return + /// drives the callback to signal `store()` failure so the Rust caller + /// (`create_invitation`) surfaces a funded-but-unrecorded voucher instead of + /// reporting success — SwiftData is the sole UI source (no Rust→Swift + /// rehydrate), so a silently skipped upsert would make a funded invitation + /// vanish from the Sent list with no trace. func persistInvitations( walletId: Data, upserts: [InvitationEntrySnapshot], removed: [Data] - ) { + ) -> Bool { onQueue { - // Log fetch failures on the invitation path rather than swallowing - // them: SwiftData is the sole UI source (no Rust→Swift rehydrate), so - // a silently skipped upsert would make a created invitation vanish - // from the list with no trace. The commit itself is the shared - // changeset `save()` in `endChangeset`, which already logs its own - // failures; the file-wide `try?`-on-save convention (asset locks, - // identities, txs, …) is intentionally left unchanged here — - // repo-wide persistence-error telemetry is a separate follow-up. + // A fetch failure on any row drops that mutation; report it so the + // round rolls back rather than half-committing. The commit itself is + // the shared changeset `save()` in `endChangeset`; the file-wide + // `try?`-on-save convention (asset locks, identities, txs, …) is + // intentionally left unchanged here — repo-wide persistence-error + // telemetry is a separate follow-up. + var allPersisted = true for entry in upserts { let outPointHex = entry.outPointHex let descriptor = FetchDescriptor( @@ -247,6 +252,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { existing = try backgroundContext.fetch(descriptor).first } catch { print("⚠️ persistInvitations: fetch failed for outpoint \(outPointHex) — skipping upsert; this invitation may be missing from the Sent list: \(error)") + allPersisted = false continue } if let existing { @@ -286,8 +292,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } catch { print("⚠️ persistInvitations: fetch failed for removal of outpoint \(hex) — stale invitation may linger in the Sent list: \(error)") + allPersisted = false } } + return allPersisted } } @@ -2656,14 +2664,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Parent linkage uses the same lookup key as /// `persistAccount(walletId:spec:)` so the row reliably maps to /// the right `PersistentAccount`. + /// Returns `true` iff the addresses were durably staged (or the account is a + /// non-security-critical type whose transient misses stay tolerant). Returns + /// `false` ONLY when an `IdentityInvitation` (type tag 5) account is missing — + /// that account is registered at wallet-setup and its funding-index pool is the + /// only durable record of the one-time voucher key's derivation index, so a + /// miss is a genuine anomaly. Signaling it drives `store() -> Err`, which makes + /// the pre-broadcast gate abort (preventing voucher-key reuse). For every other + /// account type the tolerant skip is kept: a transient miss during ordinary + /// address sync (e.g. a same-round, not-yet-committed first registration) must + /// NOT roll back and wedge the whole persistence round. func persistAccountAddresses( walletId: Data, accountKey: AccountLookupKey, entries: [CoreAddressEntrySnapshot] - ) { + ) -> Bool { onQueue { guard let account = fetchAccount(walletId: walletId, key: accountKey) else { - return + return accountKey.typeTag != Self.identityInvitationTypeTag } // DIP-17 PlatformPayment pool addresses land in @@ -2676,7 +2694,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: walletId, entries: entries ) - return + return true } for entry in entries { @@ -2731,9 +2749,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } if !self.inChangeset { try? backgroundContext.save() } + return true } // onQueue } + /// The `AccountTypeTagFFI::IdentityInvitation` discriminant. The invitation + /// funding pool is the only durable record of the one-time voucher key's + /// derivation index, so its persistence is treated as a hard gate (see + /// `persistAccountAddresses`), unlike every other account type. + private static let identityInvitationTypeTag: UInt32 = 5 + /// Upsert PlatformPayment entries into `PersistentPlatformAddress`. /// Called only when the address-emit target account is a DIP-17 /// PlatformPayment account (type tag 14). The Rust side emits the @@ -5934,6 +5959,7 @@ private func persistAccountAddressPoolsCallback( return 0 } + var allOk = true for i in 0.. PlatformWalletPersistenceHandler.AccountLookupKey { + .init( + typeTag: typeTag, + index: 0, + standardTag: 0, + registrationIndex: 0, + keyClass: 0, + userIdentityId: Data(count: 32), + friendIdentityId: Data(count: 32) + ) + } + + private func addressSnapshot() -> PlatformWalletPersistenceHandler.CoreAddressEntrySnapshot { + .init( + address: "yTestAddress0000000000000000000000000", + publicKey: Data(count: 33), + poolTypeTag: 0, + addressIndex: 0, + isUsed: false, + balance: 0, + derivationPath: "m/9'/1'/5'/3'/0'" + ) + } + + /// A missing `IdentityInvitation` (tag 5) account is a hard failure: that + /// account's funding pool is the only durable record of the one-time voucher + /// key's derivation index, so a persist that stages nothing MUST signal + /// failure (nonzero) — which drives `store() -> Err` and aborts the + /// pre-broadcast gate, preventing voucher-key reuse. + func testPersistAccountAddressesFailsForMissingInvitationAccount() throws { + let (handler, _) = try makeHandler() + let ok = handler.persistAccountAddresses( + walletId: walletId, accountKey: lookupKey(typeTag: 5), entries: [addressSnapshot()]) + XCTAssertFalse(ok, "a missing IdentityInvitation account must signal failure") + } + + /// A missing NON-invitation account (e.g. Standard, tag 0) stays tolerant. + /// Returning false here would roll back the whole round and could permanently + /// wedge ordinary address sync (a first-registration account staged but not + /// yet committed in the same round) — so the strictness is scoped to tag 5. + func testPersistAccountAddressesTolerantForMissingNonInvitationAccount() throws { + let (handler, _) = try makeHandler() + let ok = handler.persistAccountAddresses( + walletId: walletId, accountKey: lookupKey(typeTag: 0), entries: [addressSnapshot()]) + XCTAssertTrue(ok, "a missing non-invitation account must stay tolerant (no sync wedge)") + } } From bbc0d277dc74a4118847e44bc59f5bbf2c3081fd Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 22:13:27 +0700 Subject: [PATCH 57/74] fix(swift-example-app): keep reclaim UI without an identity; safer reclaim marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings B4/S3/S4/N1 on #4041. B4: gate the Sent-invitations paperplane on claimWalletId (any loaded wallet), not activeIdentity, and make InvitationsView.identity optional (only the '+' create needs an identity). Deleting the last identity no longer hides the reclaim UI for a wallet that still holds funded, reclaimable invitations. S3: set the reclaimInFlight crash-recovery marker only immediately before each on-chain consume — never before pre-broadcast local work (register's key pre-persist). A local failure no longer leaves the marker set, which would have misclassified a later genuine foreign claim as our own self-reclaim. S4: extract the terminal-state decision into a pure nonisolated classifyReclaimFailure(error:hadPriorReclaimInFlight:) and unit-test all three outcomes (Reclaimed / Claimed / error). markInFlight is @MainActor (a nested func is nonisolated by default under Swift 6). N1: freeze the submitted URI at claim start and disable the field while claiming. Co-Authored-By: Claude Opus 4.8 --- .../Views/DashPay/ClaimInvitationSheet.swift | 7 +- .../Views/DashPay/DashPayTabView.swift | 9 +- .../Views/DashPay/InvitationsView.swift | 29 ++++-- .../DashPay/ReclaimInvitationSheet.swift | 96 +++++++++++++------ .../ReclaimInvitationClassifierTests.swift | 49 ++++++++++ 5 files changed, 146 insertions(+), 44 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift index b3bfc17e3c0..14ab56ce601 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift @@ -126,6 +126,7 @@ struct ClaimInvitationSheet: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() .lineLimit(2...4) + .disabled(isClaiming) .accessibilityIdentifier("dashpay.invite.claim.uriField") } header: { Text("Paste an invitation link") @@ -170,6 +171,10 @@ struct ClaimInvitationSheet: View { private func claim() { guard canClaim, !isClaiming, let preview else { return } + // Freeze the URI alongside the already-frozen `preview` so the claim + // submits exactly what was previewed (the field is also disabled while + // claiming, so this is belt-and-suspenders coherence). + let submittedURI = trimmedURI isClaiming = true errorMessage = nil Task { @MainActor in @@ -199,7 +204,7 @@ struct ClaimInvitationSheet: View { )) let managed = try await wallet.claimInvitation( - uri: trimmedURI, + uri: submittedURI, identityIndex: identityIndex, identityPubkeys: keys, signer: signer, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 8607b96ffdf..f48a71477ff 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -211,13 +211,16 @@ struct DashPayTabView: View { } } ToolbarItem(placement: .navigationBarLeading) { - if let identity = activeIdentity, - let walletId = identity.wallet?.walletId { + // Gate on the wallet, not the active identity: invitations + // are wallet-scoped and reclaiming needs no identity, so + // deleting the last identity must not hide the reclaim UI for + // a wallet that still holds funded (reclaimable) invitations. + if let walletId = claimWalletId { NavigationLink { InvitationsView( walletId: walletId, network: network, - identity: identity + identity: activeIdentity ) } label: { Image(systemName: "paperplane") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift index 40c7bd9b885..7b077c4180a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift @@ -10,14 +10,18 @@ import SwiftUI struct InvitationsView: View { let walletId: Data let network: Network - let identity: PersistentIdentity + /// The active identity, if any. The reclaim list needs only `walletId`; + /// `identity` is required solely to *create* a new invitation, so it is + /// optional — a wallet whose last identity was deleted can still reclaim its + /// funded invitations, only the "+" create action is hidden. + let identity: PersistentIdentity? @Query private var invitations: [PersistentInvitation] @State private var reclaimTarget: PersistentInvitation? @State private var showCreateInvitation = false - init(walletId: Data, network: Network, identity: PersistentIdentity) { + init(walletId: Data, network: Network, identity: PersistentIdentity?) { self.walletId = walletId self.network = network self.identity = identity @@ -57,13 +61,18 @@ struct InvitationsView: View { .accessibilityIdentifier("dashpay.invitations.list") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button { - showCreateInvitation = true - } label: { - Image(systemName: "plus.circle") + // Creating an invitation needs an inviter identity; reclaiming does + // not. Hide "+" when there's no active identity so the reclaim list + // stays usable. + if identity != nil { + Button { + showCreateInvitation = true + } label: { + Image(systemName: "plus.circle") + } + .accessibilityLabel("Create invitation") + .accessibilityIdentifier("dashpay.invitations.create") } - .accessibilityLabel("Create invitation") - .accessibilityIdentifier("dashpay.invitations.create") } } .sheet(item: $reclaimTarget) { invitation in @@ -74,7 +83,9 @@ struct InvitationsView: View { ) } .sheet(isPresented: $showCreateInvitation) { - CreateInvitationSheet(identity: identity) + if let identity { + CreateInvitationSheet(identity: identity) + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index ddb71a39b97..1eed09cac2e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -182,13 +182,21 @@ struct ReclaimInvitationSheet: View { } let (txid, vout) = try outPointParts() - // Persist an in-flight marker before submitting our consume, so a - // crash between the on-chain consume and the terminal save can be - // recovered on retry: a later "already consumed" that finds this - // marker set was *our own* reclaim, not a foreign claim. - hadPriorReclaimInFlight = invitation.reclaimInFlight - invitation.reclaimInFlight = true - try? modelContext.save() + // Persist the in-flight marker ONLY immediately before the on-chain + // consume — never before pre-broadcast local work (e.g. register's + // key pre-persist). A crash between the consume and the terminal + // save is then recoverable: a later "already consumed" that finds + // this marker set was *our own* reclaim, not a foreign claim. Setting + // it earlier would let a purely local failure leave the marker set, + // misclassifying a subsequent genuine foreign claim as self-reclaim. + // `hadPriorReclaimInFlight` captures the PERSISTED prior value first. + // `@MainActor` because a nested func is nonisolated by default in + // Swift 6 and this touches main-actor `invitation`/`modelContext`. + @MainActor func markInFlight() { + hadPriorReclaimInFlight = invitation.reclaimInFlight + invitation.reclaimInFlight = true + try? modelContext.save() + } switch target { case .topUp: @@ -196,6 +204,7 @@ struct ReclaimInvitationSheet: View { errorMessage = "Pick an identity to top up." return } + markInFlight() _ = try await wallet.topUpIdentityWithExistingAssetLock( outPointTxid: txid, outPointVout: vout, @@ -204,11 +213,14 @@ struct ReclaimInvitationSheet: View { case .register: let signer = KeychainSigner(modelContainer: modelContext.container) let identityIndex = nextUnusedIdentityIndex() + // Pre-broadcast local work — BEFORE the marker is set, so a + // failure here leaves no in-flight marker. let keys = try wallet.prePersistIdentityKeysForRegistration( identityIndex: identityIndex, keyCount: Self.authKeyCount, network: network ) + markInFlight() _ = try await wallet.resumeIdentityWithAssetLock( outPointTxid: txid, outPointVout: vout, @@ -225,30 +237,29 @@ struct ReclaimInvitationSheet: View { try? modelContext.save() dismiss() } catch { - if Self.isAlreadyConsumed(error) { - // The consume was deterministically rejected because the - // voucher is already spent — no funds are lost. The in-flight - // marker disambiguates the two ways that happens: - if hadPriorReclaimInFlight { - // Our own earlier reclaim landed on-chain but crashed - // before saving the terminal status. Recover it as - // Reclaimed and clear the marker. - invitation.statusRaw = 2 - invitation.reclaimInFlight = false - invitation.updatedAt = Date() - try? modelContext.save() - infoMessage = "This invitation was already reclaimed." - } else { - // Someone else claimed the voucher first. Reflect the - // terminal state with a neutral message (the claimant is - // intentionally not named). - invitation.statusRaw = 1 - invitation.updatedAt = Date() - try? modelContext.save() - infoMessage = "This invitation was already claimed." - } - } else { - // Uncertain outcome: leave the in-flight marker set so a later + switch Self.classifyReclaimFailure( + error: error, + hadPriorReclaimInFlight: hadPriorReclaimInFlight + ) { + case .reclaimed: + // Our own earlier reclaim landed on-chain but crashed before + // saving the terminal status. Recover it as Reclaimed and clear + // the marker. + invitation.statusRaw = 2 + invitation.reclaimInFlight = false + invitation.updatedAt = Date() + try? modelContext.save() + infoMessage = "This invitation was already reclaimed." + 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.updatedAt = Date() + try? modelContext.save() + infoMessage = "This invitation was already claimed." + case .error: + // Uncertain outcome: leave the in-flight marker as-is so a later // retry that hits "already consumed" classifies it as our own // reclaim rather than a foreign claim. errorMessage = error.localizedDescription @@ -281,6 +292,29 @@ struct ReclaimInvitationSheet: View { return highest == UInt32.max ? UInt32.max : highest + 1 } + /// The terminal state a reclaim attempt resolves to. + enum ReclaimOutcome: Equatable { + /// The voucher was consumed by our own earlier (crash-interrupted) reclaim. + case reclaimed + /// The voucher was consumed by the invitee's claim. + case claimed + /// An uncertain, non-"already-consumed" failure — leave state as-is. + case error + } + + /// Pure decision for the reclaim `catch`: an "already consumed" rejection is + /// disambiguated by whether *our own* reclaim was already in flight when this + /// attempt started (persisted `reclaimInFlight` marker). Kept side-effect-free + /// and `nonisolated` so it is the unit-tested seam for all three outcomes; the + /// view maps the outcome to `statusRaw`/message/save. + nonisolated static func classifyReclaimFailure( + error: Error, + hadPriorReclaimInFlight: Bool + ) -> ReclaimOutcome { + guard isAlreadyConsumed(error) else { return .error } + return hadPriorReclaimInFlight ? .reclaimed : .claimed + } + /// Whether an error is the deterministic "asset lock outpoint already /// consumed" rejection (consensus code 10504). The SDK surfaces a consensus /// error as `"SDK error: Protocol error: "`, so diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift index f26df0a710d..f6b0ea5b342 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift @@ -58,4 +58,53 @@ final class ReclaimInvitationClassifierTests: XCTestCase { XCTAssertFalse(ReclaimInvitationSheet.isAlreadyConsumed(message: "already consumed")) XCTAssertFalse(ReclaimInvitationSheet.isAlreadyConsumed(message: "AlreadyConsumed")) } + + // MARK: - Terminal-outcome decision (classifyReclaimFailure) + + private struct StubError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + private static let alreadyConsumed = StubError( + message: "SDK error: Protocol error: Asset lock transaction " + + "3ff8e26d02e53f97a5f06b12327f40fc10cb859077e2788362c5d93032850ff0 " + + "output 0 already completely used" + ) + + /// Already-consumed + our own reclaim was in flight ⇒ recover as Reclaimed. + func test_classify_alreadyConsumed_priorInFlight_isReclaimed() { + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.alreadyConsumed, hadPriorReclaimInFlight: true), + .reclaimed + ) + } + + /// Already-consumed + no prior reclaim ⇒ the invitee claimed it first (Claimed). + func test_classify_alreadyConsumed_noPrior_isClaimed() { + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.alreadyConsumed, hadPriorReclaimInFlight: false), + .claimed + ) + } + + /// A non-already-consumed failure is `.error` regardless of the marker — the + /// row is left as-is. This is the safety net behind the S3 marker-placement + /// fix: a pre-broadcast local failure (never already-consumed, and with the + /// marker never set) must never be mistaken for a self-reclaim or a claim. + func test_classify_otherError_isError_regardlessOfMarker() { + let other = StubError(message: "SDK error: Transport error: connection refused") + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: other, hadPriorReclaimInFlight: true), + .error + ) + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: other, hadPriorReclaimInFlight: false), + .error + ) + } } From 82532db9b048ad4f6f96a114ad4eecabcbfd688c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 22:13:43 +0700 Subject: [PATCH 58/74] docs(dip15): invitation review-fix spec; universal-link note; persistence spec sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B5: fix the stale `?data=` scheme comment in Info.plist to the legacy query form and add a known-limitation note — a custom URL scheme is interceptable, so the HTTPS universal link (invitations.dashpay.io/applink, parser-supported) is the safer transport; adoption is tracked for infra in #4096. Blast radius is capped by MAX_INVITATION_DUFFS (0.05 DASH). No code behavior change. M1/M2: update INVITATIONS_PERSISTENCE_SWIFT_SPEC to match the shipped code — the invitation callback now signals failure (not "always return 0"), and the reclaim already-consumed classification is disambiguated by the reclaimInFlight marker via the tested classifyReclaimFailure seam. Adds INVITATIONS_REVIEW_FIX_SPEC.md (the 4-agent-reviewed plan for this pass). Co-Authored-By: Claude Opus 4.8 --- .../INVITATIONS_PERSISTENCE_SWIFT_SPEC.md | 11 +- docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md | 312 ++++++++++++++++++ packages/swift-sdk/SwiftExampleApp/Info.plist | 14 +- 3 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md diff --git a/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md b/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md index 87b34a63bfa..2cbc10f48eb 100644 --- a/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md +++ b/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md @@ -97,8 +97,13 @@ on_persist_invitations_fn: Option:`) as @@ -272,7 +277,7 @@ right helper and returns the resulting `Identity`. No new *core* mechanic. | Risk | Mitigation | |---|---| -| **Consume race** — invitee claims the same voucher at the same moment | No L1 double-spend (no shared UTXO). Platform records consumed outpoints and deterministically rejects the second consume with `IdentityAssetLockTransactionOutPointAlreadyConsumed` (`verify_is_not_spent/v0/mod.rs:37-55`). Loser wastes a small ST fee, no funds lost. On that error the Swift side sets the row to `Claimed` (someone claimed it) and shows a benign "already claimed by your friend" message. | +| **Consume race** — invitee claims the same voucher at the same moment | No L1 double-spend (no shared UTXO). Platform records consumed outpoints and deterministically rejects the second consume with `IdentityAssetLockTransactionOutPointAlreadyConsumed` (`verify_is_not_spent/v0/mod.rs:37-55`). Loser wastes a small ST fee, no funds lost. On that error the Swift side disambiguates via the persisted `reclaimInFlight` marker (set only immediately before our own on-chain consume): marker set ⇒ our own crash-interrupted reclaim (row → `Reclaimed`); marker unset ⇒ the invitee claimed first (row → `Claimed`), shown with a neutral "already claimed" message (the claimant is intentionally not named). The decision is the pure `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` seam, unit-tested for all three outcomes. | | **User expects L1 DASH back** | UI copy says "recover as identity credits"; the reclaim sheet states the value returns as credits, not spendable DASH. | | **Reclaim after app restart** (the common case — inviter reclaims days later) | Works: `FromExistingAssetLock` resumes the tracked lock; if the in-memory IS proof was lost on restart it falls back to SPV re-derivation (slower, still correct). **Verify** the SQLite `asset_locks` load re-attaches the proof (spike open-item #1) — perf only, not correctness. | | **No expiry gate** | Reclaim is allowed anytime (protocol has no timelock; expiry is advisory). Product choice whether to nudge "wait until expiry"; default: allow immediately, since an invitee can claim past expiry anyway. | diff --git a/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md b/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md new file mode 100644 index 00000000000..21af17b91a9 --- /dev/null +++ b/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md @@ -0,0 +1,312 @@ +# Invitations #4041 — Review-Fix Spec + +Addresses the 12 actionable findings surfaced on PR #4041 after the legacy-compat +codec rework. Grouped by the fix's nature. Four other threads are **stale** (they +target code the rework removed) and get a reply-and-resolve, not a code change. + +Guiding constraint (Ivan, prior sync): we are **not** doing the "auto-persist on +every key-wallet mutation" redesign. Every fix below is a *targeted* change to the +existing explicit-persist path — verified tractable because the FFI persistence +machinery already propagates a per-callback failure end-to-end (see §0). + +## 0. The persistence failure-signal chain (already exists — the key enabler) + +`ManagedPersister::store()` (`rs-platform-wallet-ffi/src/persistence.rs`) runs one +callback per changeset-kind. For each it checks the returned `i32`: + +``` +let result = unsafe { cb(ctx, wallet_id, …) }; +if result != 0 { eprintln!(…); round_success = false; } +``` + +and at the end: + +``` +if !round_success { + return Err(PersistenceError::backend("one or more persistence callbacks failed; changeset was rolled back")); +} +``` + +So a **nonzero Swift callback return** already becomes `store() -> Err`, which: +- **B2**: makes `persist_asset_lock_account_pools()` return `Err`, so the pre-broadcast + gate at `build.rs:415` aborts (for `IdentityInvitation`) *before* the tx hits the wire. +- **B1**: makes the invitation-persist round return `Err` — but the current + `create_invitation` **swallows that `Err`** (best-effort `warn`, invitation.rs:291-299), + so the Swift-callback fix alone does not surface the failure. See B1↔B3 coupling below. + +`onQueue` is `serialQueue.sync` (synchronous), so the callback body's skip/guard runs +*before* the callback returns — the return value can faithfully reflect it. The whole +round is atomic: `endChangeset` does one `backgroundContext.save()` (commit) or +`rollback()`; a nonzero callback rolls back **everything** staged in the round, and +`store()` returns `Err` *before* the pending-merge, so the Rust caller re-emits the same +changeset next round — nothing is lost (verified: this is the existing contract for +identities/txs/asset-locks). + +**Scoping caveat (must-fix from review):** `on_persist_account_address_pools_fn` is the +*same* callback used for ordinary BIP44 address sync (persistence.rs:824/865), invoked up +to 3× per round. Making it strict for *everything* would turn a benign transient +`fetchAccount` miss during ordinary sync — especially a first-registration account staged +but not yet committed in the same round — into a **permanent persistence wedge**. So B2's +strictness is **scoped to the `IdentityInvitation` account type only** (see B2). Those +pools never flow through ordinary sync (the OP_RETURN credit outputs are never SPV-visible), +and the `identity_invitation` account is registered at wallet-setup (build.rs:200-216 +requires it to exist), so a miss there is a genuine anomaly — safe to fail on. + +--- + +## B1 — Invitation persist failure reported as success 🔴 + +**File:** `PlatformWalletPersistenceHandler.swift` — `persistInvitations` (226) + +`persistInvitationsCallback`. + +**Now:** on a `fetch` failure `persistInvitations` logs and `continue`s (skips the +upsert); the callback returns `0`. A funded invitation then never reaches SwiftData +(the sole UI source, no rehydrate) yet `create_invitation` reported success → the +voucher is invisible. + +**Fix (Swift half):** +1. `persistInvitations` returns `Bool` (accumulate `allPersisted`): `false` if any upsert + was skipped due to a fetch error, or any removal fetch threw. +2. `persistInvitationsCallback` returns `1` when `persistInvitations` reports a skip, + `0` otherwise → `round_success = false` → `store()` `Err`. (The invitation round is + invitation-only — `store(PlatformWalletChangeSet { invitations: Some(cs), ..default })` + — so this rollback only ever discards an invitation round; low cost.) + +**Fix (Rust half — REQUIRED, this is the coupling with B3):** the Swift half alone only +rolls the round back; `create_invitation` currently **swallows** the `store()` `Err` +(invitation.rs:291-299, best-effort `warn`). The user-visible symptom ("create reports +success while the voucher is invisible") is only fixed when the **B3 reorder makes the +invitation persist propagate** — i.e. B1 and B3 are one change: the reordered persist +(B3) returns its `Err` from `create_invitation`, and the Swift callback (B1) makes that +persist actually fail when the row can't be written. Neither half is sufficient alone. + +**Effect:** a transient failure rolls the round back (nothing half-committed) AND surfaces +to the caller (via B3) instead of silently dropping a funded invite. Existing `print` +telemetry stays. Removal-path fetch failure (line 288) is best-effort cleanup → include +it in the `allPersisted` flag for symmetry (a nonzero return just retries next round). + +--- + +## B2 — Voucher-index durability gate defeated by the pool callback 🔴 + +**File:** `PlatformWalletPersistenceHandler.swift` — `persistAccountAddressPoolsCallback` +(~5960, always `return 0`) + `persistAccountAddresses` (2659, guard-returns on +`fetchAccount` miss, `try?` on write, `Void` return). + +**Now:** even when nothing is written (missing parent account, fetch/write failure), +the callback returns `0` → `round_success` stays `true` → `store()` `Ok` → +`persist_asset_lock_account_pools()` `Ok` → the `build.rs:415` gate passes → Rust +broadcasts. A restart can then reselect the same `IdentityInvitation` funding index +and **re-export the same bearer voucher key** — the exact class of bug the gate exists +to prevent (cf. commit 55937e15c1). + +**The real fallible point (review precision):** SwiftData `insert()`/property mutation +never throw — the only fallible steps are the `fetch()` calls, and the actual hole is the +**silent `guard let account = fetchAccount(...) else { return }`** at `:2665` (and the +platform-payment sub-path's own fetches at `:2760`), which stages *nothing* and so can't +be caught by `save()`. That guard-return is the thing to signal on. + +**Fix (scoped to `IdentityInvitation` — do NOT tighten the shared callback for all types):** +1. `persistAccountAddresses` returns `Bool` success. It returns `false` **only when the + failing pool's account type is `IdentityInvitation`** (the fetchAccount miss, and a + `do/catch` on the fetches). For every other account type it keeps today's tolerant + behavior (log + return `true`), so ordinary sync — including a first-registration + account staged-but-unsaved in the same round — is unaffected. +2. `persistAccountAddressPoolsCallback` **accumulates** `allOk` across the whole + `for i in 0..The camera is used to scan Dash address QR codes. + (dashpay://invite?du=…&assetlocktx=…&pk=…&islock=…). Opening such a link + routes to the DashPay tab and pre-fills the claim sheet. + + KNOWN LIMITATION (example-app posture): an invitation link carries a + one-time bearer voucher key, and iOS does NOT grant a custom URL scheme + exclusive ownership — a co-installed app registering "dashpay" could + intercept the link and steal the voucher (blast radius capped by the + Rust-side MAX_INVITATION_DUFFS = 0.05 DASH). The safer transport is the + HTTPS universal link (invitations.dashpay.io/applink, already parsed by + the codec), which requires an AASA-verified domain. Adopting it is tracked + for the infra team in dashpay/platform#4096; until then this example app + uses the custom scheme and integrators should prefer the universal link. --> CFBundleURLTypes From f0423e09fa9f8863357b1525df57cb030aad4a46 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 10:52:49 +0700 Subject: [PATCH 59/74] fix(platform-wallet): record chainlock voucher; retry claim fetch; test proof assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rs-platform-wallet-ffi/src/invitation.rs | 8 +- .../src/wallet/identity/crypto/invitation.rs | 19 +- .../src/wallet/identity/network/invitation.rs | 360 ++++++++++++------ 3 files changed, 276 insertions(+), 111 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 782b6331285..3d299a25d3f 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -331,8 +331,12 @@ pub struct InvitationPreviewFFI { /// Inviter identity id (32 bytes) — always zeroed: the legacy link carries /// only the username (`du`), from which the id is resolved via DPNS. pub inviter_id: [u8; 32], - /// Inviter DPNS username — heap C string, or null when `has_inviter` is - /// false. Free with [`crate::platform_wallet_string_free`]. + /// Inviter DPNS username — heap C string, or null when the link carried no + /// `du` username. A null username does **not** imply `has_inviter == false`: + /// a metadata-only link (display-name/avatar but no `du`) sets + /// `has_inviter = true` with a null username. Consumers must check this + /// pointer, not `has_inviter`, before using the username. Free with + /// [`crate::platform_wallet_string_free`]. pub inviter_username: *mut c_char, /// Amount locked in the voucher (duffs) — always 0: unknown pre-fetch (the /// link carries the funding txid, not the proof), resolved at claim time. diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs index 8ca4ef08988..d8d994bf448 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -158,7 +158,8 @@ fn invalid(msg: impl Into) -> PlatformWalletError { /// The P2PKH script the voucher key controls (compressed-pubkey hash160). This /// is the selector that binds the voucher key to its funded credit output. -fn voucher_credit_script(voucher_key: &SecretKey) -> ScriptBuf { +/// `pub(crate)` so the claim-side proof-assembly tests can build a funding tx. +pub(crate) fn voucher_credit_script(voucher_key: &SecretKey) -> ScriptBuf { let secp = Secp256k1::new(); let pubkey = PublicKey::from_secret_key(&secp, voucher_key); let hash = dashcore::PublicKey::new(pubkey).pubkey_hash(); @@ -348,7 +349,21 @@ pub fn encode_invitation_uri( } } - Ok(format!("{INVITATION_SCHEME_PREFIX}?{query}")) + let uri = format!("{INVITATION_SCHEME_PREFIX}?{query}"); + // Reject a link the parser itself would reject. `MAX_STR_BYTES` bounds each + // field's RAW bytes, but percent-encoding can expand a field up to 3×, so + // several near-cap fields can push the formatted URI past + // `MAX_INVITATION_URI_LEN` (the exact cap `parse_invitation_uri` enforces). + // Fail here rather than emit a self-unparseable link — the create path + // surfaces this as a hard error *after* the funded voucher is recorded, so it + // stays reclaimable. + if uri.len() > MAX_INVITATION_URI_LEN { + return Err(invalid(format!( + "invitation link too long ({} chars; max {MAX_INVITATION_URI_LEN})", + uri.len() + ))); + } + Ok(uri) } /// Parse a legacy-compatible invitation link into a [`ParsedInvitation`]. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index e06cf594ca9..c22df96b65d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use dpp::dashcore::consensus::Decodable; -use dpp::dashcore::{InstantLock, OutPoint, PrivateKey}; +use dpp::dashcore::{InstantLock, OutPoint, PrivateKey, Transaction}; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; @@ -71,6 +71,17 @@ pub const MIN_INVITATION_DUFFS: u64 = 300_000; /// signal, NOT a leaked-link window. The real leak bound is `MAX_INVITATION_DUFFS`. pub const MAX_INVITATION_TTL_SECS: u32 = 24 * 60 * 60; +/// Claim-by-fetch bound: how many times to look up the funding tx (each attempt +/// tries both byte orders) before giving up. InstantSend/ChainLock finality does +/// not guarantee the invitee's DAPI node has indexed the tx yet, so a freshly +/// shared invitation can miss purely from propagation lag; retrying bounds that. +const CLAIM_FETCH_MAX_ATTEMPTS: u32 = 5; + +/// Fixed backoff between claim-by-fetch attempts (matches the identity-create +/// fetch-retry cadence elsewhere in the wallet). 5 attempts × 3s ≈ 12s of +/// tolerance for propagation delay before surfacing a "not found" error. +const CLAIM_FETCH_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(3); + /// RAII scrub for the voucher `PrivateKey` copy used on the claim path. /// `dashcore::PrivateKey` wraps a `secp256k1::SecretKey` that has no /// Drop-zeroize, so the imported bearer scalar would otherwise linger in memory @@ -240,32 +251,21 @@ impl IdentityWallet { ) .await?; - // The invitee's `validate_claimable` accepts only an InstantSend proof. - // `create_funded_asset_lock_proof` falls back to a ChainLock proof if the - // IS lock doesn't propagate within its 300s preference window — reject - // that here rather than emit a link the invitee would silently reject (a - // dead voucher: funds locked, no signal). The funding lock stays - // tracked/reclaimable; the inviter retries. - if !matches!(proof, AssetLockProof::Instant(_)) { - return Err(PlatformWalletError::AssetLockTransaction( - "InstantSend lock did not confirm in time (a ChainLock proof was produced); \ - the funding lock is reclaimable — please retry the invitation" - .to_string(), - )); - } - - // Persist the inviter-side invitation record NOW — after the funding is - // confirmed valid (IS-locked) but BEFORE the fallible voucher-key export - // and URI encode below. Ordering matters: those later steps can fail on an - // already-funded voucher (a too-long username rejected by the encoder, an - // export error), and the "Sent invitations"/reclaim UI lists only persisted - // records — so recording the row first is what keeps a funded-but-not-yet- - // linked voucher reclaimable. The store is REQUIRED, not best-effort: a - // funded voucher we cannot record is a hard failure to surface, not a silent + // Persist the inviter-side invitation record NOW — immediately after the + // funding succeeds, BEFORE any fallible step (the InstantSend check below, + // the voucher-key export, the URI encode). `create_funded_asset_lock_proof` + // has already spent the DASH into the OP_RETURN, so any voucher that fails a + // later step is still a *funded, reclaimable* lock; recording it first is + // what keeps it visible in the "Sent invitations"/reclaim UI. This is why + // the persist precedes the InstantSend check: a ChainLock-confirmed voucher + // is rejected below as a usable *link* (the invitee needs an InstantSend + // proof), but it remains a funded, reclaimable lock and must be recorded + // rather than orphaned. The store is REQUIRED, not best-effort: a funded + // voucher we cannot record is a hard failure to surface, not a silent // success. No secret is stored — only the funding index (display metadata; // reclaim resumes by outpoint). If the funding path carries no u32 index // tail (a structural can't-happen for the invitation account), warn-skip: - // an untrackable row can't be reclaimed either way, and the link is valid. + // an untrackable row can't be reclaimed either way. if let Some(funding_index) = funding_index_from_path(&path) { let mut inv_cs = InvitationChangeSet::default(); inv_cs.invitations.insert( @@ -299,6 +299,20 @@ impl IdentityWallet { ); } + // The invitee's `validate_claimable` accepts only an InstantSend proof. + // `create_funded_asset_lock_proof` falls back to a ChainLock proof if the + // IS lock doesn't propagate within its 300s preference window — reject + // emitting a link the invitee would silently reject (a dead voucher: funds + // locked, no signal). The funding lock was recorded just above, so it stays + // tracked/reclaimable; the inviter retries. + if !matches!(proof, AssetLockProof::Instant(_)) { + return Err(PlatformWalletError::AssetLockTransaction( + "InstantSend lock did not confirm in time (a ChainLock proof was produced); \ + the funding lock is reclaimable — please retry the invitation" + .to_string(), + )); + } + // Export the one-time voucher private key at the funding path. This is // the one deliberate raw-key export (the whole point of an invitation); // it is path-gated to the invitation sub-feature inside the provider. @@ -480,98 +494,133 @@ impl IdentityWallet { &self, invitation: &ParsedInvitation, ) -> Result { - // Fetch the funding tx; on a miss retry with the txid byte-reversed - // (old iOS links are little-endian). Propagate the reversed attempt's - // error if both fail. - let fetched = match self - .sdk - .get_transaction(&invitation.funding_txid) - .await - .map_err(PlatformWalletError::Sdk)? - { - Some(tx) => tx, - // Not found as-given: retry with the txid byte-reversed (old iOS - // links are little-endian). A transient error is NOT retried — it - // propagated above — so the reversed lookup never masks it. - None => { - let reversed = reverse_txid_hex(&invitation.funding_txid)?; - self.sdk - .get_transaction(&reversed) - .await - .map_err(PlatformWalletError::Sdk)? - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "invitation funding transaction not found (tried both byte orders)" - .to_string(), - ) - })? + // Fetch the funding tx. Two independent concerns are layered here: + // 1. Byte order — old iOS links carry the txid little-endian, so a + // canonical miss is retried byte-reversed (a compatibility fallback, + // not a temporal retry). A transient transport error is NOT masked as + // a miss — it propagates immediately (it is not a "not indexed yet" + // signal, so retrying it would only hide it). + // 2. Propagation lag — InstantSend/ChainLock finality does not guarantee + // the invitee's DAPI node has indexed the tx yet, so a freshly shared + // invitation can miss on both byte orders purely from propagation + // delay. Retry a bounded number of times with a fixed backoff before + // giving up. + let mut fetched = None; + for attempt in 0..CLAIM_FETCH_MAX_ATTEMPTS { + let hit = match self + .sdk + .get_transaction(&invitation.funding_txid) + .await + .map_err(PlatformWalletError::Sdk)? + { + Some(tx) => Some(tx), + None => { + let reversed = reverse_txid_hex(&invitation.funding_txid)?; + self.sdk + .get_transaction(&reversed) + .await + .map_err(PlatformWalletError::Sdk)? + } + }; + if let Some(tx) = hit { + fetched = Some(tx); + break; + } + if attempt + 1 < CLAIM_FETCH_MAX_ATTEMPTS { + tokio::time::sleep(CLAIM_FETCH_RETRY_DELAY).await; } - }; - let transaction = fetched.transaction; - - // Fail-fast: the fetched tx must actually be the funding tx (either byte - // order). DAPI returns whatever tx matches the id we asked for, so this - // guards a backend that answers with an unrelated tx. - let fetched_txid = transaction.txid().to_string(); - let reversed_txid = reverse_txid_hex(&invitation.funding_txid).ok(); - if fetched_txid != invitation.funding_txid - && reversed_txid.as_deref() != Some(fetched_txid.as_str()) - { - return Err(PlatformWalletError::InvalidIdentityData( - "fetched transaction id does not match the invitation funding txid".to_string(), - )); } + let fetched = fetched.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "invitation funding transaction not found (tried both byte orders across \ + repeated attempts); it may not have propagated to the queried DAPI node yet — \ + retry shortly" + .to_string(), + ) + })?; + assemble_asset_lock_proof( + fetched.transaction, + fetched.is_chain_locked, + fetched.height, + invitation, + ) + } +} - // Select the funded credit output the voucher key controls (not index 0 - // — a legacy invite's credit output need not be first). - let output_index = voucher_output_index(&transaction, &invitation.voucher_key)?; +/// Assemble the asset-lock proof from an already-fetched funding transaction — the +/// pure, testable core of the claim reconstruction (the fetch/retry lives in +/// `reconstruct_asset_lock_proof`). Validates the tx is the funding tx (either byte +/// order), selects the voucher's credit output, and builds an InstantSend proof +/// (link carried an islock) or a ChainLock proof (islock absent), requiring +/// chain-lock finality for the latter. +fn assemble_asset_lock_proof( + transaction: Transaction, + is_chain_locked: bool, + height: u32, + invitation: &ParsedInvitation, +) -> Result { + // Fail-fast: the fetched tx must actually be the funding tx (either byte + // order). DAPI returns whatever tx matches the id we asked for, so this + // guards a backend that answers with an unrelated tx. + let fetched_txid = transaction.txid().to_string(); + let reversed_txid = reverse_txid_hex(&invitation.funding_txid).ok(); + if fetched_txid != invitation.funding_txid + && reversed_txid.as_deref() != Some(fetched_txid.as_str()) + { + return Err(PlatformWalletError::InvalidIdentityData( + "fetched transaction id does not match the invitation funding txid".to_string(), + )); + } - match &invitation.islock_hex { - Some(islock_hex) => { - let islock_bytes = hex::decode(islock_hex).map_err(|e| { + // Select the funded credit output the voucher key controls (not index 0 + // — a legacy invite's credit output need not be first). + let output_index = voucher_output_index(&transaction, &invitation.voucher_key)?; + + match &invitation.islock_hex { + Some(islock_hex) => { + let islock_bytes = hex::decode(islock_hex).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "invitation islock is not valid hex: {e}" + )) + })?; + // The hex is not self-describing; the modern deterministic islock + // (ISDLOCK) carries its version byte, which is exactly what the + // consensus decoder reads first. + let instant_lock = InstantLock::consensus_decode(&mut islock_bytes.as_slice()) + .map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( - "invitation islock is not valid hex: {e}" + "invitation islock could not be decoded: {e}" )) })?; - // The hex is not self-describing; the modern deterministic islock - // (ISDLOCK) carries its version byte, which is exactly what the - // consensus decoder reads first. - let instant_lock = InstantLock::consensus_decode(&mut islock_bytes.as_slice()) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "invitation islock could not be decoded: {e}" - )) - })?; - if instant_lock.txid != transaction.txid() { - return Err(PlatformWalletError::InvalidIdentityData( - "invitation islock does not lock the funding transaction".to_string(), - )); - } - Ok(AssetLockProof::Instant(InstantAssetLockProof::new( - instant_lock, - transaction, - output_index, - ))) + if instant_lock.txid != transaction.txid() { + return Err(PlatformWalletError::InvalidIdentityData( + "invitation islock does not lock the funding transaction".to_string(), + )); } - None => { - // ChainLock invite: the proof references the outpoint + a - // chain-locked core height. Require the funding tx to be - // chain-locked so the proof proves finality; the inviter/invitee - // retries once the block is chain-locked otherwise. - if !fetched.is_chain_locked { - return Err(PlatformWalletError::InvalidIdentityData( - "chainlock invitation funding transaction is not yet chain-locked; \ - retry once it confirms" - .to_string(), - )); - } - let out_point = OutPoint::new(transaction.txid(), output_index); - let out_point_bytes: [u8; 36] = out_point.into(); - Ok(AssetLockProof::Chain(ChainAssetLockProof::new( - fetched.height, - out_point_bytes, - ))) + Ok(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + transaction, + output_index, + ))) + } + None => { + // ChainLock invite: the proof references the outpoint + a chain-locked + // core height. Require the funding tx to be chain-locked so the proof + // proves finality; the inviter/invitee retries once the block is + // chain-locked otherwise. + if !is_chain_locked { + return Err(PlatformWalletError::InvalidIdentityData( + "chainlock invitation funding transaction is not yet chain-locked; \ + retry once it confirms" + .to_string(), + )); } + let out_point = OutPoint::new(transaction.txid(), output_index); + let out_point_bytes: [u8; 36] = out_point.into(); + Ok(AssetLockProof::Chain(ChainAssetLockProof::new( + height, + out_point_bytes, + ))) } } } @@ -622,4 +671,101 @@ mod tests { let path = DerivationPath::from(Vec::::new()); assert_eq!(funding_index_from_path(&path), None); } + + // --- assemble_asset_lock_proof: the claim-time proof reconstruction guards --- + + use crate::wallet::identity::crypto::invitation::{voucher_credit_script, ParsedInvitation}; + use dpp::dashcore::consensus::Encodable; + use dpp::dashcore::secp256k1::SecretKey; + use dpp::dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + use dpp::dashcore::transaction::special_transaction::TransactionPayload; + use dpp::dashcore::{Network, TxOut}; + + fn voucher_secret() -> SecretKey { + SecretKey::from_slice(&[0x11u8; 32]).unwrap() + } + + /// An asset-lock tx whose single credit output pays the voucher key. + fn funding_tx(key: &SecretKey) -> Transaction { + let payload = AssetLockPayload { + version: 1, + credit_outputs: vec![TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(key), + }], + }; + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), + } + } + + fn parsed( + key: SecretKey, + funding_txid: String, + islock_hex: Option, + ) -> ParsedInvitation { + ParsedInvitation { + voucher_key: key, + voucher_key_network: Network::Testnet, + funding_txid, + islock_hex, + inviter: None, + } + } + + /// A backend answering with an unrelated tx (id matches neither byte order) is + /// rejected before any proof is built. + #[test] + fn assemble_rejects_txid_mismatch() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let inv = parsed(key, "00".repeat(32), None); + let err = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap_err(); + assert!(format!("{err}").contains("does not match")); + } + + /// A ChainLock invite (no islock) whose funding tx is not yet chain-locked is + /// rejected — the proof must prove finality. + #[test] + fn assemble_chainlock_requires_chain_lock() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid().to_string(); + let inv = parsed(key, txid, None); + let err = assemble_asset_lock_proof(tx, false, 100, &inv).unwrap_err(); + assert!(format!("{err}").contains("not yet chain-locked")); + } + + /// A chain-locked ChainLock invite assembles a ChainLock proof at the tx's + /// voucher output. + #[test] + fn assemble_chainlock_ok_when_locked() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid().to_string(); + let inv = parsed(key, txid, None); + let proof = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + assert!(matches!(proof, AssetLockProof::Chain(_))); + } + + /// An islock that locks a DIFFERENT tx than the funding tx is rejected (the + /// txid-binding guard), so a link can't pair a valid islock with a foreign tx. + #[test] + fn assemble_rejects_islock_for_wrong_tx() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid().to_string(); + // A default islock carries a zeroed txid, which won't equal the funding tx. + let mut islock_bytes = Vec::new(); + InstantLock::default() + .consensus_encode(&mut islock_bytes) + .unwrap(); + let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); + let err = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap_err(); + assert!(format!("{err}").contains("does not lock the funding transaction")); + } } From b613d924b90bbf3afa525515e924ac1faa2a6bad Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 10:53:59 +0700 Subject: [PATCH 60/74] fix(swift-example-app): reclaim all wallets' invitations; recover crash-interrupted reclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Views/DashPay/DashPayTabView.swift | 12 +++---- .../Views/DashPay/InvitationsView.swift | 28 ++++++++------- .../DashPay/ReclaimInvitationSheet.swift | 35 ++++++++++++++++--- .../ReclaimInvitationClassifierTests.swift | 27 ++++++++++++++ 4 files changed, 79 insertions(+), 23 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index f48a71477ff..c27a8c9bd73 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -211,14 +211,14 @@ struct DashPayTabView: View { } } ToolbarItem(placement: .navigationBarLeading) { - // Gate on the wallet, not the active identity: invitations - // are wallet-scoped and reclaiming needs no identity, so - // deleting the last identity must not hide the reclaim UI for - // a wallet that still holds funded (reclaimable) invitations. - if let walletId = claimWalletId { + // Gate on a loaded wallet, not the active identity: + // reclaiming needs no identity, so deleting the last identity + // must not hide the reclaim UI while funded (reclaimable) + // invitations remain. The list itself spans all wallets and + // reclaims each by its own walletId. + if claimWalletId != nil { NavigationLink { InvitationsView( - walletId: walletId, network: network, identity: activeIdentity ) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift index 7b077c4180a..0003159dcdb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift @@ -8,27 +8,27 @@ import SwiftUI /// changeset. A still-unclaimed (`Created`) row can be reclaimed — recovering the /// voucher's value as identity credits — via a swipe action. struct InvitationsView: View { - let walletId: Data let network: Network - /// The active identity, if any. The reclaim list needs only `walletId`; - /// `identity` is required solely to *create* a new invitation, so it is - /// optional — a wallet whose last identity was deleted can still reclaim its - /// funded invitations, only the "+" create action is hidden. + /// The active identity, if any. Reclaim needs no identity (it uses each + /// invitation's own `walletId`); `identity` is required solely to *create* a + /// new invitation, so it is optional — a wallet whose last identity was deleted + /// can still reclaim its funded invitations, only the "+" create is hidden. let identity: PersistentIdentity? - @Query private var invitations: [PersistentInvitation] + // ALL sent invitations across every loaded wallet, newest first. Not scoped to + // one wallet: the paperplane is reached from a single-wallet context, but a + // device may have several wallets loaded and each invitation is reclaimed via + // its OWN `walletId` (below), so scoping the list to one wallet would strand + // the others' funded, reclaimable invitations with no way to reach them. + @Query(sort: [SortDescriptor(\PersistentInvitation.createdAtSecs, order: .reverse)]) + private var invitations: [PersistentInvitation] @State private var reclaimTarget: PersistentInvitation? @State private var showCreateInvitation = false - init(walletId: Data, network: Network, identity: PersistentIdentity?) { - self.walletId = walletId + init(network: Network, identity: PersistentIdentity?) { self.network = network self.identity = identity - _invitations = Query( - filter: PersistentInvitation.predicate(walletId: walletId), - sort: [SortDescriptor(\PersistentInvitation.createdAtSecs, order: .reverse)] - ) } var body: some View { @@ -78,7 +78,9 @@ struct InvitationsView: View { .sheet(item: $reclaimTarget) { invitation in ReclaimInvitationSheet( invitation: invitation, - walletId: walletId, + // Reclaim targets the invitation's OWN wallet, so a multi-wallet + // list reclaims each row against the correct wallet. + walletId: invitation.walletId, network: network ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index 1eed09cac2e..db7612f8b6d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -305,14 +305,41 @@ struct ReclaimInvitationSheet: View { /// Pure decision for the reclaim `catch`: an "already consumed" rejection is /// disambiguated by whether *our own* reclaim was already in flight when this /// attempt started (persisted `reclaimInFlight` marker). Kept side-effect-free - /// and `nonisolated` so it is the unit-tested seam for all three outcomes; the - /// view maps the outcome to `statusRaw`/message/save. + /// and `nonisolated` so it is the unit-tested seam for all outcomes; the view + /// maps the outcome to `statusRaw`/message/save. nonisolated static func classifyReclaimFailure( error: Error, hadPriorReclaimInFlight: Bool ) -> ReclaimOutcome { - guard isAlreadyConsumed(error) else { return .error } - return hadPriorReclaimInFlight ? .reclaimed : .claimed + if isAlreadyConsumed(error) { + // Platform deterministically rejected the consume as already-spent. + return hadPriorReclaimInFlight ? .reclaimed : .claimed + } + // Crash-recovery for our OWN reclaim: if a prior attempt's consume landed + // on-chain but the app crashed before saving the terminal status, a retry + // resumes a lock the wallet no longer tracks and fails LOCALLY ("…is not + // tracked") — before Platform, so `isAlreadyConsumed` never sees it. With + // our in-flight marker set (a prior attempt started a consume), that + // untracked lock is our own completed reclaim; recover it rather than + // leaving the row stuck at Created with the marker set forever. The marker + // gate makes this safe: a first attempt has `hadPriorReclaimInFlight == + // false`, so an unrelated "not tracked" failure still resolves to `.error`. + if hadPriorReclaimInFlight && isLockNoLongerTracked(error) { + return .reclaimed + } + return .error + } + + /// Whether an error is the wallet's LOCAL "asset lock is no longer tracked" + /// resume-guard failure (`rs-platform-wallet` `resume_asset_lock`, Display + /// "Asset lock … is not tracked"). Distinct from the network's already-consumed + /// rejection; used only for our-own-reclaim crash recovery above. + nonisolated static func isLockNoLongerTracked(_ error: Error) -> Bool { + isLockNoLongerTracked(message: error.localizedDescription) + } + + nonisolated static func isLockNoLongerTracked(message: String) -> Bool { + message.lowercased().contains("is not tracked") } /// Whether an error is the deterministic "asset lock outpoint already diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift index f6b0ea5b342..19377c72ed0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift @@ -107,4 +107,31 @@ final class ReclaimInvitationClassifierTests: XCTestCase { .error ) } + + private static let lockNotTracked = StubError( + message: "PlatformWalletError: Asset lock " + + "3ff8e26d02e53f97a5f06b12327f40fc10cb859077e2788362c5d93032850ff0:0 is not tracked" + ) + + /// Crash-recovery: our own prior reclaim consumed the lock on-chain but the + /// terminal save was interrupted; a retry resumes an untracked lock and fails + /// LOCALLY ("…is not tracked"). With the marker set, that is our completed + /// reclaim → `.reclaimed` (not stuck at Created forever). + func test_classify_lockNotTracked_priorInFlight_isReclaimed() { + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.lockNotTracked, hadPriorReclaimInFlight: true), + .reclaimed + ) + } + + /// The same local error WITHOUT a prior in-flight marker is `.error` — a first + /// attempt that hits "not tracked" is a genuine anomaly, not a self-reclaim. + func test_classify_lockNotTracked_noPrior_isError() { + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.lockNotTracked, hadPriorReclaimInFlight: false), + .error + ) + } } From 6009566076f32859809498b1ddf3251bc3b72465 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 10:53:59 +0700 Subject: [PATCH 61/74] docs(dip15): update invitation QA/test-plan for the legacy codec + review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md | 4 ++-- packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md index 4268254d3e6..7111ba6d617 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md @@ -21,10 +21,10 @@ ## Expected Results -- **Step 1** create succeeds only because the default is `0.005` DASH; the persisted row is `Created` (`ZSTATUSRAW=0`) with `ZAMOUNTDUFFS=500000` and a 36-byte `ZRAWOUTPOINT`. +- **Step 1** create succeeds at the default `0.03` DASH; the persisted row is `Created` (`ZSTATUSRAW=0`) with `ZAMOUNTDUFFS=3000000` and a 36-byte `ZRAWOUTPOINT`. The record is persisted **before** the fallible key-export/URI-encode, so even a ChainLock-funded (non-InstantSend) voucher is recorded and reclaimable. - **Step 3–4 (top-up)** the sheet states value returns as credits, never L1 Dash; on success the target identity's credit balance rises by ~the voucher value and the row badge flips to **Reclaimed** (`ZSTATUSRAW=2`). No L1 Dash is returned (the on-chain amount was an `OP_RETURN` burn at create time). - **Step 5 (register)** a brand-new funded identity lands in Identities and the row flips to **Reclaimed** (`ZSTATUSRAW=2`); no contact request is sent (a reclaim carries no DashPay enc/dec pair). -- **Step 6 (already-consumed)** the second consume is deterministically rejected (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, Display "…already completely used"); the sheet shows the **neutral** message "This invitation was already claimed." — the claimant is **not** named — and flips the row to **Claimed** (`ZSTATUSRAW=1`). No funds are lost. +- **Step 6 (already-consumed)** the second consume is deterministically rejected (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, Display "…already completely used"). The terminal decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` seam, disambiguated by the persisted `reclaimInFlight` marker (set only immediately before the on-chain consume): marker **unset** ⇒ the invitee claimed first ⇒ **neutral** "This invitation was already claimed." (claimant **not** named) ⇒ row → **Claimed** (`ZSTATUSRAW=1`); marker **set** ⇒ our own crash-interrupted reclaim ⇒ "already reclaimed" ⇒ row → **Reclaimed** (`ZSTATUSRAW=2`). A retry that instead hits the wallet's LOCAL "…is not tracked" resume guard (our consume landed but the terminal save was interrupted) also resolves to **Reclaimed** when the marker is set — so a genuinely-reclaimed row never stays stuck at `Created`. No funds are lost. - **Step 7 (min guard)** creating below the minimum is blocked in-UI with "Minimum 0.003 DASH — a smaller voucher can't fund identity registration."; the Rust layer independently rejects a sub-minimum amount (`MIN_INVITATION_DUFFS`), so the floor holds even if the UI guard is bypassed. Fail the QA if: a reclaim returns L1 Dash instead of credits; a failed or non-consumed reclaim flips the row status (a non-consumed error must leave it `Created`); the already-consumed path names the claimant or shows a raw error instead of the neutral message; or an invitation below `0.003` DASH can be created. diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 9b5d3614d32..d360cde5e6b 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -291,10 +291,10 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | DP-09 | Publish encrypted on-chain `contactInfo` (private contact metadata) | Platform | Thorough | ✅ | | DIP-15 §10. `ContactDetailView` → edit **Alias** / **Note** / **Hide contact** (`dashpay.detail.aliasEdit` / `dashpay.detail.noteEdit` / `dashpay.detail.hideToggle`) → `saveContactInfo` → `platform_wallet_set_dashpay_contact_info_with_signer` (ECB `encToUserId` + CBC `privateData`). These fields are locally cached **and** published encrypted to Platform once the identity has **≥2 established contacts** (stated in the in-app footer) → outcomes `.published` / `.deferredUntilTwoContacts` / `.skippedWatchOnly`. | | DP-10 | Incoming-payment backfill rescan (restore-from-seed / pre-watch window) | Cross | Manual | ✅ | regression | DIP-15 §8.7 / §12.6 (on the DIP-16 SPV base). No UI trigger — automatic in DashPay sync: `reconcile_dashpay_rescan` lowers SPV `synced_height` to `min($coreHeightCreatedAt)` across new receival contacts so the filter manager backfills. Pass: a DashPay payment that landed on a contact's address **before** it was watched (restore-from-seed / second device / the offline-accept→pay window) appears after restore + SPV sync. Environment-limited (must construct the skew window); the regression pin for the §12.6 payment-loss gap. | | DP-11 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | multiwallet | A's identity sends a contact request (`DP-01`) to B's; switch to wallet B's identity and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop entirely local. | -| DP-12 | Create invitation (DIP-13) | Cross | Common | 🔌 | funding | DashPay → paperplane (`dashpay.openSentInvitations`) → **+** (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet` → amount entry + **"send a contact request back to me"** checkbox → `createInvitation` → `platform_wallet_create_invitation`. Builds an **InstantSend** asset-lock voucher at the DIP-13 invitation funding path (`3'`), amount Rust-capped at `MAX_INVITATION_DUFFS`, and returns a `dashpay://invite?data=…` link rendered as a QR + share sheet. Builds an **L1 asset lock** → needs the Core SPV client running + **testnet funds** (fund via Wallet → Receive → "request from testnet"). The link embeds a one-time voucher **private key** — a bearer credential; the UI must not log it and should flag the pasteboard sensitive. `🔌` until the UI lands. | -| DP-13 | Claim invitation (DIP-13) | Platform | Common | 🔌 | | Paste/scan a `dashpay://invite` link → claim sheet (planned `dashpay.invite.claim`, mirroring `AddViaQRSheet`) → `claimInvitation` → `platform_wallet_claim_invitation`. Registers a **new identity for the invitee funded by the imported voucher** (no L1 Dash on the invitee side; the asset-lock signature uses the imported voucher key). If the link carries inviter info, prompt **"establish contact with \?"** → on confirm, send the existing contact request (`DP-01` path). New identity lands in Identities; optional contact in Contacts. `🔌` until the UI lands. | +| DP-12 | Create invitation (DIP-13) | Cross | Common | 🔌 | funding | DashPay → paperplane (`dashpay.openSentInvitations`) → **+** (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet` → amount entry + **"send a contact request back to me"** checkbox → `createInvitation` → `platform_wallet_create_invitation`. Builds an **InstantSend** asset-lock voucher at the DIP-13 invitation funding path (`3'`), amount Rust-capped at `MAX_INVITATION_DUFFS`, and returns a **legacy-compatible** `dashpay://invite?du=…&assetlocktx=…&pk=…&islock=…` link (field-level cross-claimable with the dash-wallet iOS/Android apps) rendered as a QR + share sheet. The link carries the funding **txid** + the voucher **WIF** (`pk`) + the **islock** — not the embedded proof (the invitee refetches the tx at claim). Builds an **L1 asset lock** → needs the Core SPV client running + **testnet funds** (fund via Wallet → Receive → "request from testnet"). `pk` is a one-time voucher **private key** — a bearer credential; the UI must not log it and should flag the pasteboard sensitive. The funded record is persisted **before** the fallible key-export/URI-encode steps, so a ChainLock-funded (non-InstantSend) voucher is still recorded and reclaimable. | +| DP-13 | Claim invitation (DIP-13) | Platform | Common | 🔌 | | Paste/scan or deep-link a `dashpay://invite` link → `ClaimInvitationSheet` (`dashpay.invite.claim.*`) → `claimInvitation` → `platform_wallet_claim_invitation`. **Claim-by-fetch:** the link carries only the txid, so the SDK refetches the funding tx by id (bounded retry/backoff for DAPI propagation lag; both byte orders), reconstructs the InstantSend proof (from the link's islock) or a ChainLock proof (islock absent), and selects the voucher's credit output. Registers a **new identity for the invitee funded by the imported voucher** (no L1 Dash on the invitee side). Amount shows "—" in the preview (not on the wire). If the link carries a `du` username, prompt **"Add \?"** → on confirm, resolve the id via DPNS and send the contact request (`DP-01` path). New identity lands in Identities; optional contact in Contacts. `🔌` until the UI lands. | | DP-14 | Invite → claim two-wallet e2e | Cross | Thorough | 🔌 | multiwallet | The feature's acceptance gate. Wallet A (funded, SPV running) creates an invitation (`DP-12`); wallet B (no funds) claims it (`DP-13`) → B gains a funded identity with no L1 Dash; if the inviter opted into the bootstrap **and** the invitee confirms, the contact establishes on both ends (cf. `DP-11`). Requires testnet funding + both wallets on the same network. `🔌` until the UI lands. | -| DP-15 | Reject malformed / reused / expired invitation | Platform | Uncommon | 🔌 | | Negative paths all fail loudly with a clear message and no side effects: a malformed link (wrong scheme / non-base58 / truncated), a **reused** link (asset lock already consumed → deterministic "invitation already used"), and a **past-expiry** link (`validate_claimable` refuses before any network call). `🔌` until the UI lands. | +| DP-15 | Reject malformed / reused / wrong-network invitation | Platform | Uncommon | 🔌 | | Negative paths all fail loudly with a clear message and no side effects: a **malformed** link (wrong scheme/host, missing `pk`/`assetlocktx`, duplicate required param, non-WIF `pk`, over-length URI), a **reused** link (asset lock already consumed → deterministic "already completely used"), and a **wrong-network** WIF (a valid key on the wrong chain, caught at claim before the funding fetch). NOTE: the legacy wire carries **no expiry** field (`expiry_unix` is 0 in the preview), so there is no past-expiry rejection — the honest bound is the amount floor (`MIN_INVITATION_DUFFS`) enforced at create. `🔌` until the UI lands. | | DP-16 | Sent-invitations list persists a created invitation | Platform | Common | 🔌 | | Create an invitation (`DP-12`) → assert a `PersistentInvitation` row in the SwiftData store (`ZPERSISTENTINVITATION` via `sqlite3`) whose `outPointHex` matches the created voucher's outpoint, **and** the row in the "Sent invitations" list (DashPay tab → paperplane `dashpay.openSentInvitations` → `InvitationsView` `dashpay.invitations.list`, showing amount + status badge). Then drive a second `store()` touching the same outpoint (or re-create) → **upsert-in-place**, not a duplicate row. Bridged by `on_persist_invitations_fn` → `persistInvitations` → `PersistentInvitation` (no Rust→Swift rehydrate — SwiftData is the UI source). The T1 upsert-key ↔ removal-key seam is unit-pinned in `InvitationPersistenceTests` (create→removal→row-deleted) since reclaim's removal path isn't shipped in this slice. `🔌` until the bridge + view land. | | DP-17 | Reclaim an unclaimed invitation into an existing identity (top-up) | Platform | Common | 🔌 | funding | Create an invitation (`DP-12`) and do **not** claim it. In "Sent invitations" swipe a `Created` row → **Reclaim** (`dashpay.invitations.reclaim`) → sheet (`dashpay.invite.reclaim.submit`), target **Existing identity** (`dashpay.invite.reclaim.identityPicker`) → `topUpIdentityWithExistingAssetLock` → `platform_wallet_topup_identity_with_existing_asset_lock_signer` consumes the voucher as an IdentityTopUp via `FromExistingAssetLock`. Assert the target identity's credit balance rises by ~the voucher value, the row's status badge flips to **Reclaimed** (`ZSTATUSRAW=2`), and the outpoint reads consumed on-chain (platform-explorer). Value returns as **credits**, never L1 Dash. Needs SPV running + the voucher's funding tracked. `🔌` until the UI lands. | | DP-18 | Reclaim an unclaimed invitation by registering a new identity | Platform | Uncommon | 🔌 | funding | As `DP-17` but target **New identity** (segmented control) → `resumeIdentityWithAssetLock` → `platform_wallet_resume_identity_with_existing_asset_lock_signer` registers a brand-new identity funded by the voucher (no DashPay enc/dec pair — a reclaim sends no contact request). Assert a new funded identity lands in Identities, the row flips to **Reclaimed** (`ZSTATUSRAW=2`), and the outpoint reads consumed on-chain. `🔌` until the UI lands. | From 08bf78c0f4b2fc286e9576bcf44851dd5ca267f1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 13:51:35 +0700 Subject: [PATCH 62/74] fix(platform-wallet): durable invitation persistence gate; record invitation right after broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rs-platform-wallet-ffi/src/invitation.rs | 12 +- .../src/changeset/traits.rs | 19 ++ .../rs-platform-wallet/src/test_support.rs | 11 + .../src/wallet/asset_lock/build.rs | 223 +++++++++++++++++- .../src/wallet/identity/network/invitation.rs | 186 +++++++++++++-- .../src/wallet/persister.rs | 10 + 6 files changed, 424 insertions(+), 37 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 3d299a25d3f..8f60af2b26c 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -326,7 +326,12 @@ pub struct InvitationPreviewFFI { /// The link carried an `islock`, so the claim will build an InstantSend /// proof; `false` ⇒ a ChainLock-confirmed invite (islock absent / `"null"`). pub is_instant: bool, - /// The link carries inviter info (the contact-bootstrap is available). + /// The link carried inviter METADATA (a `du` username, display name, or + /// avatar). Presence does NOT mean the contact bootstrap is available: a + /// metadata-only link (display-name/avatar without `du`) still sets this + /// flag while `inviter_username` stays null, and the bootstrap needs the + /// username. Gate contact features on a non-null `inviter_username`, not + /// on this flag. pub has_inviter: bool, /// Inviter identity id (32 bytes) — always zeroed: the legacy link carries /// only the username (`du`), from which the id is resolved via DPNS. @@ -370,8 +375,9 @@ impl InvitationPreviewFFI { /// the UI can render a clean "invalid invitation" state; only a null / non-UTF-8 /// `uri` argument returns an error result. /// -/// When `has_inviter` is set, `*out_preview.inviter_username` is a heap C string -/// the caller frees with [`crate::platform_wallet_string_free`]. +/// When `out_preview.inviter_username` is non-null it is a heap C string the +/// caller frees with [`crate::platform_wallet_string_free`]. It can be null +/// even when `has_inviter` is set (a metadata-only link) — see the field docs. /// /// # Safety /// - `uri` must be a valid NUL-terminated UTF-8 C string. diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 9ed22a5542e..20e6571915e 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -188,6 +188,25 @@ impl PersistenceError { /// to guarantee a batch flush, it should call `flush` explicitly after all /// `store` calls and treat `store` as a best-effort buffer hint. pub trait PlatformWalletPersistence: Send + Sync { + /// Whether stored state survives a process restart once `store` + `flush` + /// return `Ok`. + /// + /// Defaults to `true` — the contract every real backend (SQLite, the FFI + /// SwiftData bridge) meets. Backends that only buffer in memory or drop + /// writes (e.g. [`NoPlatformPersistence`](crate::wallet::persister::NoPlatformPersistence)) + /// MUST override this to return `false`. + /// + /// Security-sensitive flows gate on this. Creating a DashPay invitation + /// exports a one-time bearer voucher key derived from a persisted funding + /// index; on a backend that cannot guarantee the index survives a restart, + /// the same key could be re-derived and re-exported after a relaunch, + /// letting the holder of an earlier link consume a later voucher. Such + /// flows refuse to run on a non-durable backend rather than silently + /// producing a reusable bearer secret. + fn persists_durably(&self) -> bool { + true + } + /// Buffer a changeset for later persistence. /// /// Implementations should merge into an internal per-wallet accumulator so diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 51b76637af9..2f299bbaca6 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -54,6 +54,17 @@ impl TransactionBroadcaster for RejectFirstBroadcaster { } } +/// Broadcaster that always succeeds, for flows that must run past the +/// broadcast step (e.g. the broadcast half of the funded asset-lock flow). +pub(crate) struct AlwaysOkBroadcaster; + +#[async_trait] +impl TransactionBroadcaster for AlwaysOkBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + Ok(transaction.txid()) + } +} + /// Broadcaster that always fails with a definitive pre-send rejection. pub(crate) struct AlwaysRejectedBroadcaster; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 93494e13b87..4fb1053b9c5 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -389,6 +389,37 @@ impl AssetLockManager { identity_index: u32, signer: &S, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { + let (path, out_point) = self + .broadcast_funded_asset_lock( + amount_duffs, + account_index, + funding_type, + identity_index, + signer, + ) + .await?; + let proof = self + .wait_for_funded_asset_lock_proof(&out_point, account_index) + .await?; + Ok((proof, path, out_point)) + } + + /// Broadcast half of [`Self::create_funded_asset_lock_proof`] — steps 1–4: + /// build + fund the asset-lock transaction, persist the funding account's + /// address pool, track the lifecycle row, and broadcast. Returns as soon as + /// the transaction is on the wire (status `Broadcast`), BEFORE any proof + /// wait, so a caller can durably record its own bookkeeping for the funded + /// lock (e.g. the inviter-side invitation row) between the broadcast and + /// the potentially long proof wait in + /// [`Self::wait_for_funded_asset_lock_proof`]. + pub(crate) async fn broadcast_funded_asset_lock( + &self, + amount_duffs: u64, + account_index: u32, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, + ) -> Result<(DerivationPath, OutPoint), PlatformWalletError> { // 1. Build the asset lock transaction. let (tx, path) = self .build_asset_lock_transaction( @@ -410,9 +441,19 @@ impl AssetLockManager { // carries `funding_index` across a restart. For an INVITATION this write // is a security gate: the voucher key is exported into a bearer link, so // a failed persist would let the next restart reuse this index/key. + // `store()` alone is only a buffer hint under the persistence contract + // (backends may defer I/O until `flush`), so the invitation gate also + // drives `flush()` — the contract's durability boundary — before + // anything hits the wire. // Aborting BEFORE broadcast is harmless (no tx on the wire); the other // asset-lock accounts keep their keys on-device, so they stay best-effort. - if let Err(e) = self.persist_asset_lock_account_pools().await { + let pool_durability = match self.persist_asset_lock_account_pools().await { + Ok(()) if funding_type == AssetLockFundingType::IdentityInvitation => { + self.persister.flush() + } + other => other, + }; + if let Err(e) = pool_durability { tracing::error!(error = %e, "failed to persist asset-lock funding index"); if funding_type == AssetLockFundingType::IdentityInvitation { return Err(PlatformWalletError::AssetLockTransaction(format!( @@ -485,20 +526,32 @@ impl AssetLockManager { .await?; self.queue_asset_lock_changeset(cs_broadcast); + Ok((path, out_point)) + } + + /// Proof half of [`Self::create_funded_asset_lock_proof`] — steps 5–6: + /// wait for the InstantSend/ChainLock proof of an already-broadcast asset + /// lock, upgrade it when Platform would reject it, and attach it to the + /// tracked row. + pub(crate) async fn wait_for_funded_asset_lock_proof( + &self, + out_point: &OutPoint, + account_index: u32, + ) -> Result { // 5. Wait for proof via SPV events. The 300s bound is an // InstantSend-preference window, NOT a finality timeout: on // expiry the resolver falls back to an unbounded ChainLock wait // (`upgrade_to_chain_lock_proof(None)`), so a broadcast lock is // never surfaced as "failed" just because IS was slow. let proof = self - .wait_for_proof(&out_point, Some(Duration::from_secs(300))) + .wait_for_proof(out_point, Some(Duration::from_secs(300))) .await?; // 5b. If we got an IS-lock proof, check whether the transaction is // old enough that Platform might reject it. If so, upgrade to a // ChainLock proof proactively. let proof = self - .validate_or_upgrade_proof(proof, account_index, &out_point) + .validate_or_upgrade_proof(proof, account_index, out_point) .await?; // 6. Attach proof — status matches the proof type received — @@ -508,11 +561,11 @@ impl AssetLockManager { dpp::prelude::AssetLockProof::Chain(_) => AssetLockStatus::ChainLocked, }; let cs_final = self - .advance_asset_lock_status(&out_point, status, Some(proof.clone())) + .advance_asset_lock_status(out_point, status, Some(proof.clone())) .await?; self.queue_asset_lock_changeset(cs_final); - Ok((proof, path, out_point)) + Ok(proof) } } @@ -534,7 +587,8 @@ mod tests { ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; use crate::test_support::{ - funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, + funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, + AlwaysRejectedBroadcaster, WalletSigner, }; use crate::wallet::asset_lock::manager::AssetLockManager; use crate::wallet::asset_lock::tracked::AssetLockStatus; @@ -544,10 +598,14 @@ mod tests { use crate::{AssetLockFundingType, PlatformWalletError}; /// Persistence stub that records every stored changeset so tests can - /// assert what the asset-lock flow queued. + /// assert what the asset-lock flow queued. `fail_flush` simulates a + /// backend whose durability boundary fails; `flushes` counts `flush` + /// calls so tests can assert the invitation gate drove one. #[derive(Default)] struct CapturingPersistence { stored: Mutex>, + flushes: std::sync::atomic::AtomicUsize, + fail_flush: bool, } impl CapturingPersistence { @@ -578,6 +636,11 @@ mod tests { } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + self.flushes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.fail_flush { + return Err(PersistenceError::backend("simulated flush failure")); + } Ok(()) } @@ -594,10 +657,21 @@ mod tests { WalletSigner, Arc, ) { + let persistence = Arc::new(CapturingPersistence::default()); + let (manager, signer) = + funded_asset_lock_manager_with_persistence(broadcaster, Arc::clone(&persistence)).await; + (manager, signer, persistence) + } + + /// Like [`funded_asset_lock_manager`] but over a caller-built persistence + /// stub (e.g. one with `fail_flush` set). + async fn funded_asset_lock_manager_with_persistence( + broadcaster: Arc, + persistence: Arc, + ) -> (Arc>, WalletSigner) { let (wallet_manager, wallet_id, _balance, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; - let persistence = Arc::new(CapturingPersistence::default()); let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); let manager = Arc::new(AssetLockManager::new( sdk, @@ -605,13 +679,10 @@ mod tests { wallet_id, Arc::new(Notify::new()), broadcaster, - WalletPersister::new( - wallet_id, - Arc::clone(&persistence) as Arc, - ), + WalletPersister::new(wallet_id, persistence as Arc), )); - (manager, signer, persistence) + (manager, signer) } /// Regression: a build must persist the funding account's address-pool @@ -883,4 +954,130 @@ mod tests { kept for the advanced row, got {rebuild:?}" ); } + + /// The invitation pre-broadcast gate must treat `flush()` — the + /// persistence contract's durability boundary — as part of recording the + /// funding index, and abort BEFORE broadcast when it fails. `store()` + /// alone may only buffer; an unflushed funding index can be re-selected + /// after a restart, re-exporting the same bearer voucher key. + #[tokio::test] + async fn invitation_gate_aborts_before_broadcast_when_flush_fails() { + let persistence = Arc::new(CapturingPersistence { + fail_flush: true, + ..Default::default() + }); + // The broadcaster rejects loudly: reaching it at all would surface as + // a `TransactionBroadcast` error, so the gate's own "aborted before + // broadcast" message proves nothing hit the wire. + let (manager, signer) = funded_asset_lock_manager_with_persistence( + Arc::new(AlwaysRejectedBroadcaster), + Arc::clone(&persistence), + ) + .await; + + let result = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer, + ) + .await; + match result { + Err(PlatformWalletError::AssetLockTransaction(msg)) => assert!( + msg.contains("aborted before broadcast"), + "expected the pre-broadcast durability abort, got: {msg}" + ), + other => panic!("expected the pre-broadcast durability abort, got {other:?}"), + } + assert!( + persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst) + >= 1, + "the invitation gate must have driven flush()" + ); + } + + /// Non-invitation funding types stay best-effort: their one-time keys + /// never leave the device, so a failing durability boundary must NOT gate + /// them — the flow proceeds to broadcast. + #[tokio::test] + async fn flush_failure_does_not_gate_non_invitation_funding() { + let persistence = Arc::new(CapturingPersistence { + fail_flush: true, + ..Default::default() + }); + let (manager, signer) = funded_asset_lock_manager_with_persistence( + Arc::new(AlwaysRejectedBroadcaster), + Arc::clone(&persistence), + ) + .await; + + let result = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + matches!(result, Err(PlatformWalletError::TransactionBroadcast(_))), + "a registration build must reach the broadcaster despite the flush \ + failure (best-effort persistence), got {result:?}" + ); + } + + /// The broadcast half returns as soon as the transaction is on the wire: + /// the tracked row is `Broadcast` (recoverable/resumable) and the + /// invitation funding pool was persisted AND flushed — all BEFORE any + /// proof wait (the test completing at all proves no SPV wait ran), so a + /// caller can durably record its own bookkeeping for the funded lock + /// between the broadcast and the proof wait. + #[tokio::test] + async fn broadcast_half_leaves_broadcast_row_and_flushed_pool() { + let persistence = Arc::new(CapturingPersistence::default()); + let (manager, signer) = funded_asset_lock_manager_with_persistence( + Arc::new(AlwaysOkBroadcaster), + Arc::clone(&persistence), + ) + .await; + + let (_path, out_point) = manager + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer, + ) + .await + .expect("broadcast half should succeed"); + + { + let wm = manager.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&manager.wallet_id) + .expect("wallet still present"); + let lock = info + .tracked_asset_locks + .get(&out_point) + .expect("broadcast lock must stay tracked"); + assert_eq!( + lock.status, + AssetLockStatus::Broadcast, + "the broadcast half must stop at Broadcast (no proof attached)" + ); + } + assert!( + persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst) + >= 1, + "the invitation funding pool must be flushed before broadcast" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index c22df96b65d..568697b761a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -203,6 +203,12 @@ impl IdentityWallet { /// The proof is kept as an **InstantSend** proof (owner decision) — fast, and /// the embedded tx + islock make the link self-contained; staleness is /// bounded by the short advisory expiry, not a CL upgrade. + /// + /// Requires a durably-persisting backend + /// ([`PlatformWalletPersistence::persists_durably`](crate::changeset::PlatformWalletPersistence::persists_durably)): + /// the exported voucher key is derived from the persisted funding index, so a + /// backend that drops writes would re-export the same bearer key after a + /// restart. A non-durable backend is refused before any funds move. #[allow(clippy::too_many_arguments)] pub async fn create_invitation( &self, @@ -236,13 +242,32 @@ impl IdentityWallet { )); } + // Refuse to run on a backend that cannot durably persist. The one-time + // voucher key is derived from a persisted funding index and exported + // into a bearer link; on a backend that drops writes (e.g. + // `NoPlatformPersistence`) the index resets on restart, so the SAME key + // would be re-exported to a later invitation — the holder of the earlier + // link could then consume the later voucher. Checked before any funds + // move. + if !self.persister.persists_durably() { + return Err(PlatformWalletError::Persistence( + "invitation creation requires a durable persistence backend (see \ + PlatformWalletPersistence::persists_durably): a non-durable backend \ + would re-export the same bearer voucher key after a restart" + .to_string(), + )); + } + // Build + broadcast the voucher asset lock at the invitation funding // account (the builder auto-selects the next unused funding index and // returns its derivation path). `identity_index` is unused for the - // `IdentityInvitation` funding type. - let (proof, path, out_point) = self + // `IdentityInvitation` funding type. Only the broadcast half runs here — + // the proof wait is deferred until AFTER the invitation record below is + // durably persisted, so an interruption during the (potentially long) + // proof wait can no longer orphan the funded lock from the reclaim UI. + let (path, out_point) = self .asset_locks - .create_funded_asset_lock_proof( + .broadcast_funded_asset_lock( amount_duffs, funding_account_index, AssetLockFundingType::IdentityInvitation, @@ -252,20 +277,21 @@ impl IdentityWallet { .await?; // Persist the inviter-side invitation record NOW — immediately after the - // funding succeeds, BEFORE any fallible step (the InstantSend check below, - // the voucher-key export, the URI encode). `create_funded_asset_lock_proof` - // has already spent the DASH into the OP_RETURN, so any voucher that fails a - // later step is still a *funded, reclaimable* lock; recording it first is - // what keeps it visible in the "Sent invitations"/reclaim UI. This is why - // the persist precedes the InstantSend check: a ChainLock-confirmed voucher - // is rejected below as a usable *link* (the invitee needs an InstantSend - // proof), but it remains a funded, reclaimable lock and must be recorded - // rather than orphaned. The store is REQUIRED, not best-effort: a funded - // voucher we cannot record is a hard failure to surface, not a silent - // success. No secret is stored — only the funding index (display metadata; - // reclaim resumes by outpoint). If the funding path carries no u32 index - // tail (a structural can't-happen for the invitation account), warn-skip: - // an untrackable row can't be reclaimed either way. + // broadcast succeeds, BEFORE every later fallible or slow step (the proof + // wait, the InstantSend check, the voucher-key export, the URI encode). + // The broadcast has already spent the DASH into the OP_RETURN, so any + // voucher that fails a later step is still a *funded, reclaimable* lock; + // recording it first is what keeps it visible in the "Sent + // invitations"/reclaim UI. A ChainLock-confirmed voucher is rejected + // further below as a usable *link* (the invitee needs an InstantSend + // proof), but it remains a funded, reclaimable lock and is already + // recorded here rather than orphaned. The store + flush are REQUIRED, + // not best-effort: a funded voucher we cannot durably record is a hard + // failure to surface, not a silent success. No secret is stored — only + // the funding index (display metadata; reclaim resumes by outpoint). If + // the funding path carries no u32 index tail (a structural can't-happen + // for the invitation account), warn-skip: an untrackable row can't be + // reclaimed either way. if let Some(funding_index) = funding_index_from_path(&path) { let mut inv_cs = InvitationChangeSet::default(); inv_cs.invitations.insert( @@ -285,6 +311,7 @@ impl IdentityWallet { invitations: Some(inv_cs), ..Default::default() }) + .and_then(|_| self.persister.flush()) .map_err(|e| { PlatformWalletError::AssetLockTransaction(format!( "the invitation voucher is funded but its record could not be \ @@ -299,11 +326,19 @@ impl IdentityWallet { ); } + // Wait for the funding proof. The row above is already durable, so a + // termination or failure inside this wait leaves a reclaimable, visible + // invitation rather than an orphaned lock. + let proof = self + .asset_locks + .wait_for_funded_asset_lock_proof(&out_point, funding_account_index) + .await?; + // The invitee's `validate_claimable` accepts only an InstantSend proof. - // `create_funded_asset_lock_proof` falls back to a ChainLock proof if the - // IS lock doesn't propagate within its 300s preference window — reject - // emitting a link the invitee would silently reject (a dead voucher: funds - // locked, no signal). The funding lock was recorded just above, so it stays + // The proof wait falls back to a ChainLock proof if the IS lock doesn't + // propagate within its 300s preference window — reject emitting a link + // the invitee would silently reject (a dead voucher: funds locked, no + // signal). The funding lock was recorded before the wait, so it stays // tracked/reclaimable; the inviter retries. if !matches!(proof, AssetLockProof::Instant(_)) { return Err(PlatformWalletError::AssetLockTransaction( @@ -768,4 +803,113 @@ mod tests { let err = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap_err(); assert!(format!("{err}").contains("does not lock the funding transaction")); } + + // --- create_invitation: the durable-persistence precondition --- + + mod durability_gate { + use std::sync::Arc; + + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + use crate::wallet::persister::NoPlatformPersistence; + use crate::PlatformWalletError; + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::signer::{Signer, SignerMethod}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + /// Signer that must never be reached — the durability gate fires + /// before any key material is touched or funds move. + struct UnreachableSigner; + + #[async_trait::async_trait] + impl Signer for UnreachableSigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &key_wallet::DerivationPath, + _sighash: [u8; 32], + ) -> Result< + ( + dashcore::secp256k1::ecdsa::Signature, + dashcore::secp256k1::PublicKey, + ), + Self::Error, + > { + unreachable!("the durability gate must fire before any signing") + } + + async fn public_key( + &self, + _path: &key_wallet::DerivationPath, + ) -> Result { + unreachable!("the durability gate must fire before any key derivation") + } + } + + /// A bearer voucher key must never be produced by a wallet whose + /// backend cannot durably persist the funding index: on a restart the + /// index resets and the SAME key would be re-exported to a later + /// invitation. `create_invitation` refuses up-front — before any + /// funds move or keys derive. + #[tokio::test] + async fn create_invitation_requires_durable_persistence() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(NoPlatformPersistence); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(crate::PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + handler, + )); + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::None, + Some(0), + ) + .await + .expect("wallet creation"); + let iw = wallet.identity(); + + let crypto_provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let err = iw + .create_invitation( + 1_000_000, + 0, + None, + 1, + 1, + &UnreachableSigner, + &crypto_provider, + ) + .await + .expect_err("a non-durable backend must be refused"); + + assert!( + matches!(err, PlatformWalletError::Persistence(_)), + "expected the durability refusal, got {err:?}" + ); + assert!( + format!("{err}").contains("durable persistence backend"), + "the error must explain the durable-backend requirement, got: {err}" + ); + } + } } diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index dfd889a581e..9e94e6bd834 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -38,6 +38,11 @@ impl WalletPersister { self.inner.flush(self.wallet_id) } + /// See [`PlatformWalletPersistence::persists_durably`]. + pub(crate) fn persists_durably(&self) -> bool { + self.inner.persists_durably() + } + pub(crate) fn load(&self) -> Result { self.inner.load() } @@ -58,6 +63,11 @@ impl WalletPersister { pub struct NoPlatformPersistence; impl PlatformWalletPersistence for NoPlatformPersistence { + /// Nothing is ever written, so nothing survives a restart. + fn persists_durably(&self) -> bool { + false + } + fn store( &self, _wallet_id: WalletId, From 81b0b72f8a06f2937b10161f1d9c478c17faca19 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 13:52:07 +0700 Subject: [PATCH 63/74] fix(swift-example-app): scope invitation list to loaded wallets; require marker save before consume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ManagedPlatformWallet.swift | 10 ++++++-- .../PlatformWalletPersistenceHandler.swift | 19 ++++++++------- .../Views/DashPay/InvitationsView.swift | 23 ++++++++++++++----- .../DashPay/ReclaimInvitationSheet.swift | 20 ++++++++++++---- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index ecb1713af24..e6c0350c102 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -1981,12 +1981,18 @@ extension ManagedPlatformWallet { /// proof; `false` is a ChainLock-confirmed invite (still claimable). Not a /// claimability gate — the proof is reconstructed at claim time. public let isInstant: Bool - /// The link carries inviter info (the contact-bootstrap is available). + /// The link carried inviter METADATA (a username, display name, or + /// avatar). Presence does NOT mean the contact bootstrap is available: + /// a metadata-only link (display-name/avatar without a `du` username) + /// still sets this flag while `inviterUsername` stays nil, and the + /// bootstrap needs the username. Gate contact features on a non-nil + /// `inviterUsername`, not on this flag. public let hasInviter: Bool /// Always nil: the legacy link carries only the inviter's username, not an /// identity id (resolve it via `resolveDpnsName` at contact-bootstrap). public let inviterId: Data? - /// Inviter DPNS username when `hasInviter`, else nil. + /// Inviter DPNS username when the link carried `du`, else nil — the + /// contact-bootstrap precondition (may be nil even when `hasInviter`). public let inviterUsername: String? /// Always 0: the amount isn't in the link (it carries the funding txid, /// not the proof) and is only known after the tx is fetched at claim time. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 070ed3ee909..723539e76a3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -6447,19 +6447,17 @@ private func persistTokenBalancesCallback( return 0 } -/// C shim for `on_persist_asset_locks_fn`. Copies every -/// `AssetLockEntryFFI` row + every removed-outpoint tuple into -/// Swift-owned `Data` snapshots before invoking the handler so the -/// Rust-side `_storage` Vec can release the byte buffers as soon as -/// this trampoline returns. /// C shim for `on_persist_invitations_fn`. Deep-copies every all-POD /// `InvitationEntryFFI` row into an owned `InvitationEntrySnapshot` (precomputing /// `rawOutPoint` + the `encodeOutPoint` display key) and every removed-outpoint /// tuple into owned `Data` before invoking the handler, so the Rust side can /// reclaim its buffers the moment we return. Mirrors `persistAssetLocksCallback`. -/// **Always returns 0** — the return is round-global (a non-zero rolls back the -/// WHOLE `store()` round, discarding unrelated writes), so a failed invitation -/// write is silently skipped rather than aborting the round. +/// Returns 0 when every invitation mutation was staged successfully. Returns +/// nonzero when any write was skipped, which fails the Rust persistence round +/// and rolls back the changeset — safe here because the invitation round is +/// invitation-only, so no unrelated writes are discarded — and lets +/// `create_invitation` surface the failure instead of reporting a voucher that +/// never reached SwiftData. private func persistInvitationsCallback( context: UnsafeMutableRawPointer?, walletIdPtr: UnsafePointer?, @@ -6514,6 +6512,11 @@ private func persistInvitationsCallback( return handler.persistInvitations(walletId: walletId, upserts: upserts, removed: removed) ? 0 : 1 } +/// C shim for `on_persist_asset_locks_fn`. Copies every +/// `AssetLockEntryFFI` row + every removed-outpoint tuple into +/// Swift-owned `Data` snapshots before invoking the handler so the +/// Rust-side `_storage` Vec can release the byte buffers as soon as +/// this trampoline returns. private func persistAssetLocksCallback( context: UnsafeMutableRawPointer?, walletIdPtr: UnsafePointer?, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift index 0003159dcdb..51e27eff9df 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift @@ -15,13 +15,24 @@ struct InvitationsView: View { /// can still reclaim its funded invitations, only the "+" create is hidden. let identity: PersistentIdentity? - // ALL sent invitations across every loaded wallet, newest first. Not scoped to - // one wallet: the paperplane is reached from a single-wallet context, but a - // device may have several wallets loaded and each invitation is reclaimed via - // its OWN `walletId` (below), so scoping the list to one wallet would strand - // the others' funded, reclaimable invitations with no way to reach them. + // ALL sent invitations, newest first, then filtered below to wallets the + // ACTIVE manager has loaded. Not scoped to one wallet: the paperplane is + // reached from a single-wallet context, but a device may have several + // wallets loaded and each invitation is reclaimed via its OWN `walletId` + // (below), so scoping the list to one wallet would strand the others' + // funded, reclaimable invitations with no way to reach them. @Query(sort: [SortDescriptor(\PersistentInvitation.createdAtSecs, order: .reverse)]) - private var invitations: [PersistentInvitation] + private var allInvitations: [PersistentInvitation] + + // Reclaim resolves each row's wallet through the active network's manager, + // so rows whose wallet is NOT loaded there (another network's wallet — the + // row carries no network discriminator — or a deleted wallet) would only + // render a dead "No wallet loaded" reclaim. Hide them instead. + private var invitations: [PersistentInvitation] { + allInvitations.filter { walletManager.wallet(for: $0.walletId) != nil } + } + + @EnvironmentObject private var walletManager: PlatformWalletManager @State private var reclaimTarget: PersistentInvitation? @State private var showCreateInvitation = false diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index db7612f8b6d..e894587aa7c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -190,12 +190,24 @@ struct ReclaimInvitationSheet: View { // it earlier would let a purely local failure leave the marker set, // misclassifying a subsequent genuine foreign claim as self-reclaim. // `hadPriorReclaimInFlight` captures the PERSISTED prior value first. + // The save must SUCCEED before the consume may run: an unpersisted + // marker followed by a consume + crash would strand the row (a local + // "is not tracked" retry classifies as an error, and a Platform + // "already consumed" as a foreign claim). On a failed save the + // in-memory flag is rolled back so an unrelated later save can't + // leak a marker that never reached disk, and the throw aborts the + // reclaim before anything irreversible. // `@MainActor` because a nested func is nonisolated by default in // Swift 6 and this touches main-actor `invitation`/`modelContext`. - @MainActor func markInFlight() { + @MainActor func markInFlight() throws { hadPriorReclaimInFlight = invitation.reclaimInFlight invitation.reclaimInFlight = true - try? modelContext.save() + do { + try modelContext.save() + } catch { + invitation.reclaimInFlight = hadPriorReclaimInFlight + throw error + } } switch target { @@ -204,7 +216,7 @@ struct ReclaimInvitationSheet: View { errorMessage = "Pick an identity to top up." return } - markInFlight() + try markInFlight() _ = try await wallet.topUpIdentityWithExistingAssetLock( outPointTxid: txid, outPointVout: vout, @@ -220,7 +232,7 @@ struct ReclaimInvitationSheet: View { keyCount: Self.authKeyCount, network: network ) - markInFlight() + try markInFlight() _ = try await wallet.resumeIdentityWithAssetLock( outPointTxid: txid, outPointVout: vout, From e67ecfed95db2979534d3cbf834842428e4180cb Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 13:52:09 +0700 Subject: [PATCH 64/74] docs(dip15): round-3 review-fix spec; QA004/TEST_PLAN accuracy for the new persist ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md | 73 +++++++++++++++++++ .../AI_QA/QA004_invitation_reclaim.md | 17 +++-- .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 2 +- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md b/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md index 21af17b91a9..19a0ce0310c 100644 --- a/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md +++ b/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md @@ -310,3 +310,76 @@ link transport is externally blocked (Android team creds) and tracked separately - **S1/N1:** targeted unit tests (S1: custom `expiry_unix` no longer poisons `created_at`). - **Full regression:** 24 platform-wallet + 10 ffi invitation tests + the funded sim e2e stay green; `fmt --all` + `clippy --workspace --all-features` clean; iOS build. + +--- + +# Round 2 — the 2026-07-14 review (6 findings, all accepted) + +## R1 — `store()` success is not durability 🔴 + +The pre-broadcast voucher-index gate treated `persist_asset_lock_account_pools()` +(`store()`) as proof of durability, but the trait contract explicitly allows `store` +to buffer until `flush` ("treat any store → flush sequence as potentially performing +I/O at either point"), and `NoPlatformPersistence` returns `Ok` while writing nothing. +**Fix (two halves):** +1. The `IdentityInvitation` gate in `broadcast_funded_asset_lock` drives + `persister.flush()` — the contract's durability boundary — after the pool store, + aborting before broadcast when either fails (buffering backends covered). +2. New trait capability `PlatformWalletPersistence::persists_durably()` (default + `true`; `NoPlatformPersistence` overrides `false`); `create_invitation` refuses a + non-durable backend up-front, before any funds move (no-op backends covered). + Non-invitation funding types stay best-effort (their keys never leave the device). + +## R2 — invitation row created only after proof acquisition 🔴 + +The B3 reorder persisted the row after `create_funded_asset_lock_proof` returned — +but that call itself broadcasts, then waits up to 300 s for InstantSend (unbounded +ChainLock fallback), so a termination in that window left a funded lock invisible to +the reclaim UI. **Fix:** split `create_funded_asset_lock_proof` into a +`broadcast_funded_asset_lock` half (build → pool gate → track → broadcast) and a +`wait_for_funded_asset_lock_proof` half (proof wait → upgrade → attach); +`create_invitation` persists + flushes the `InvitationEntry` between them — +immediately after broadcast, before any proof wait. The public composed API is +unchanged. Residual (accepted): a definitive `MaybeSent` broadcast ambiguity still +returns before the row exists; the typed `Broadcast` asset-lock row remains the +recovery source there, as before. + +## R3 — all-wallet invitation list shows unloaded/foreign-network rows 🟡 + +`InvitationsView`'s unfiltered `@Query` surfaced rows whose wallet is not loaded in +the ACTIVE network's manager (no network discriminator on `PersistentInvitation`; +deleted wallets keep their rows), and reclaim on those dead rows can only fail with +"No wallet loaded". **Fix:** keep the all-wallet query (multi-wallet reclaim stays) +but filter displayed rows to `walletManager.wallet(for:) != nil`. + +## R4 — reclaim marker save must precede the consume 🟡 + +`markInFlight` suppressed `modelContext.save()` failure with `try?` and both reclaim +arms immediately entered the irreversible consume; a failed save + consume + crash +strands the row (local "is not tracked" → error; Platform "already consumed" → +misclassified foreign claim). **Fix:** `markInFlight` is throwing; a failed save +rolls the in-memory flag back and aborts the reclaim before the consume. + +## R5 — `has_inviter` documented as bootstrap availability 💬 + +The ABI/Swift docs promised "contact-bootstrap available", but the parser +intentionally sets `has_inviter = true` for display-name/avatar-only links with a +null username (bootstrap impossible). **Fix (docs only):** define the flag as +inviter-METADATA presence; a non-null `inviter_username` is the bootstrap condition. + +## R6 — stale invitation-callback contract comment 💬 + +`persistInvitationsCallback`'s doc still said "always returns 0 / silently skipped" +— describing the exact data-loss behavior B1 removed. **Fix (docs only):** restate +the real contract (0 = all staged; nonzero fails the round and rolls back the +invitation-only changeset). Also re-homed the `persistAssetLocksCallback` doc block +that was stranded on top of the invitations shim. + +## Round-2 test additions + +- `invitation_gate_aborts_before_broadcast_when_flush_fails` (red on old code: the + 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` (red on old + code: it proceeded into signing — the test signer panics on any use). diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md index 7111ba6d617..bce351ca6b3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md @@ -5,26 +5,29 @@ ## Preconditions - App launched in the simulator; Core SPV sync **running and caught up** to the testnet tip (Sync tab → `Start`; invitation create/reclaim need an InstantSend lock on the funding tx). Confirm headers/filters read `N/N` (equal), not `0/—`. -- A funded testnet wallet with spendable Dash (a reclaim create burns the voucher amount plus fees; budget ≥ 0.02 DASH to run every step). +- A funded testnet wallet with spendable Dash (a reclaim create burns the voucher amount plus fees; two default-amount creates need budget ≥ 0.07 DASH to run every step). - At least one existing Platform identity on the current network (the top-up target). Its credit balance is read from SwiftData `ZPERSISTENTIDENTITY.ZBALANCE`. - DashPay tab selected (`DashPay` tab-bar item), a DashPay profile present (`dashpay.identityPicker`). ## Steps -1. **Create a reclaimable invitation.** Open create-invitation: DashPay tab → paperplane (`dashpay.openSentInvitations`) → the **+** button (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet`. Leave the amount at its default (`0.005` DASH) and submit. Wait for the InstantSend lock to confirm, then screenshot `AI_QA/output/QA004_created.png`. Read `ZPERSISTENTINVITATION` via `sqlite3` and record the new row's `ZOUTPOINTHEX`, `ZAMOUNTDUFFS` (`500000`), and `ZSTATUSRAW` (`0` = Created). -2. **Open Sent Invitations.** Tap `dashpay.openSentInvitations` (paperplane). `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_sent_list.json` and confirm `dashpay.invitations.list` shows the new row with amount `0.00500000 DASH` and a `Created` badge. +1. **Create a reclaimable invitation.** Open create-invitation: DashPay tab → paperplane (`dashpay.openSentInvitations`) → the **+** button (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet`. Leave the amount at its default (`0.03` DASH) and submit. Wait for the InstantSend lock to confirm, then screenshot `AI_QA/output/QA004_created.png`. Read `ZPERSISTENTINVITATION` via `sqlite3` and record the new row's `ZOUTPOINTHEX`, `ZAMOUNTDUFFS` (`3000000`), and `ZSTATUSRAW` (`0` = Created). +2. **Open Sent Invitations.** Tap `dashpay.openSentInvitations` (paperplane). `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_sent_list.json` and confirm `dashpay.invitations.list` shows the new row with amount `0.03000000 DASH` and a `Created` badge. 3. **Reclaim → top-up (existing identity).** Swipe the `Created` row left to reveal `dashpay.invitations.reclaim`; tap it. In the sheet, confirm the body copy reads "…as identity credits — not spendable Dash." Leave the target on **Existing identity** (`dashpay.invite.reclaim.target`), pick the target via `dashpay.invite.reclaim.identityPicker`, and tap `dashpay.invite.reclaim.submit`. Wait for the Platform state transition to confirm. Screenshot `AI_QA/output/QA004_reclaim_topup.png`. 4. **Verify the top-up.** Re-read SwiftData: the target identity's `ZBALANCE` rose by ~the voucher value (in credits; 1 duff ≈ 1000 credits), and the reclaimed row's `ZSTATUSRAW` is now `2` (Reclaimed). Cross-check the outpoint reads **consumed** on-chain (platform-explorer). 5. **Reclaim → register (new identity).** Repeat steps 1–3 with a **second** invitation, but in the sheet switch the target to **New identity** (right segment of `dashpay.invite.reclaim.target`) — the identity picker is replaced by "A brand-new identity funded by this voucher." Submit. After it confirms, confirm a new funded identity appears in the Identities tab and the row's `ZSTATUSRAW` is `2` (Reclaimed). Screenshot `AI_QA/output/QA004_reclaim_register.png`. -6. **Already-consumed handling.** Take a row whose voucher is already consumed on-chain (either the register-arm race, or force-quit the app, set that row's `ZSTATUSRAW` back to `0` via `sqlite3` while the app is terminated, relaunch, and reclaim it again). Tap `dashpay.invitations.reclaim` → submit. `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_already_consumed.json`. +6. **Already-consumed handling.** Take a row whose voucher is already consumed on-chain and reclaim it again (tap `dashpay.invitations.reclaim` → submit; `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_already_consumed.json`). Two single-device variants — pick by which classifier arm you want to exercise (force-quit the app, edit via `sqlite3` while terminated, relaunch): + - **Crash-interrupted self-reclaim** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=1` on a reclaimed row): the retry hits the wallet's LOCAL "…is not tracked" resume guard (this wallet consumed the lock itself, so the row is locally `Consumed`); with the marker set it recovers as **Reclaimed** (`ZSTATUSRAW=2`, marker cleared) with "This invitation was already reclaimed." + - **Marker-unset reset** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=0`): the same local "not tracked" failure with no marker deliberately classifies as an **error** — the row stays `Created` and a raw message is shown. This is the false-positive guard (an unrelated local failure must never flip the row), not a bug. + - The **neutral "This invitation was already claimed."** (→ `ZSTATUSRAW=1`) path requires the consume to actually reach Platform and be rejected with 10504, i.e. a voucher claimed by a *different* wallet while this one still tracks the lock — the two-device DP-19 race, not reproducible by local sqlite edits. 7. **Minimum-amount guard.** Open `CreateInvitationSheet` (paperplane → **+** `dashpay.invitations.create`), set the amount below the floor (e.g. `0.001`), and confirm the create button is disabled with the sub-minimum hint. Screenshot `AI_QA/output/QA004_min_guard.png`. ## Expected Results -- **Step 1** create succeeds at the default `0.03` DASH; the persisted row is `Created` (`ZSTATUSRAW=0`) with `ZAMOUNTDUFFS=3000000` and a 36-byte `ZRAWOUTPOINT`. The record is persisted **before** the fallible key-export/URI-encode, so even a ChainLock-funded (non-InstantSend) voucher is recorded and reclaimable. +- **Step 1** create succeeds at the default `0.03` DASH; the persisted row is `Created` (`ZSTATUSRAW=0`) with `ZAMOUNTDUFFS=3000000` and a 36-byte `ZRAWOUTPOINT`. The record is persisted **immediately after the funding broadcast** — before the InstantSend proof wait and the fallible key-export/URI-encode — so an interrupted proof wait or a ChainLock-funded (non-InstantSend) voucher is still recorded and reclaimable. - **Step 3–4 (top-up)** the sheet states value returns as credits, never L1 Dash; on success the target identity's credit balance rises by ~the voucher value and the row badge flips to **Reclaimed** (`ZSTATUSRAW=2`). No L1 Dash is returned (the on-chain amount was an `OP_RETURN` burn at create time). - **Step 5 (register)** a brand-new funded identity lands in Identities and the row flips to **Reclaimed** (`ZSTATUSRAW=2`); no contact request is sent (a reclaim carries no DashPay enc/dec pair). -- **Step 6 (already-consumed)** the second consume is deterministically rejected (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, Display "…already completely used"). The terminal decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` seam, disambiguated by the persisted `reclaimInFlight` marker (set only immediately before the on-chain consume): marker **unset** ⇒ the invitee claimed first ⇒ **neutral** "This invitation was already claimed." (claimant **not** named) ⇒ row → **Claimed** (`ZSTATUSRAW=1`); marker **set** ⇒ our own crash-interrupted reclaim ⇒ "already reclaimed" ⇒ row → **Reclaimed** (`ZSTATUSRAW=2`). A retry that instead hits the wallet's LOCAL "…is not tracked" resume guard (our consume landed but the terminal save was interrupted) also resolves to **Reclaimed** when the marker is set — so a genuinely-reclaimed row never stays stuck at `Created`. No funds are lost. -- **Step 7 (min guard)** creating below the minimum is blocked in-UI with "Minimum 0.003 DASH — a smaller voucher can't fund identity registration."; the Rust layer independently rejects a sub-minimum amount (`MIN_INVITATION_DUFFS`), so the floor holds even if the UI guard is bypassed. +- **Step 6 (already-consumed)** the terminal decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` seam, disambiguated by the persisted `reclaimInFlight` marker (set — with a **required, successful** save — only immediately before the on-chain consume; a failed marker save aborts the reclaim before anything irreversible). A Platform-rejected consume (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, Display "…already completely used"): marker **unset** ⇒ the invitee claimed first ⇒ **neutral** "This invitation was already claimed." (claimant **not** named) ⇒ row → **Claimed** (`ZSTATUSRAW=1`); marker **set** ⇒ our own crash-interrupted reclaim ⇒ "already reclaimed" ⇒ row → **Reclaimed** (`ZSTATUSRAW=2`). A retry that instead hits the wallet's LOCAL "…is not tracked" resume guard resolves to **Reclaimed** only when the marker is set (verified live: instant recovery + "This invitation was already reclaimed."); with the marker unset it is an **error** and the row stays `Created` — so a genuinely-reclaimed row never stays stuck, and an unrelated local failure never flips the row. No funds are lost. +- **Step 7 (min guard)** creating below the minimum is blocked in-UI with "Minimum 0.003 DASH — a smaller voucher can't fund identity registration. Maximum 0.05 DASH."; the Rust layer independently rejects a sub-minimum amount (`MIN_INVITATION_DUFFS`), so the floor holds even if the UI guard is bypassed. Fail the QA if: a reclaim returns L1 Dash instead of credits; a failed or non-consumed reclaim flips the row status (a non-consumed error must leave it `Created`); the already-consumed path names the claimant or shows a raw error instead of the neutral message; or an invitation below `0.003` DASH can be created. diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index d360cde5e6b..69c78f46b2c 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -291,7 +291,7 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | DP-09 | Publish encrypted on-chain `contactInfo` (private contact metadata) | Platform | Thorough | ✅ | | DIP-15 §10. `ContactDetailView` → edit **Alias** / **Note** / **Hide contact** (`dashpay.detail.aliasEdit` / `dashpay.detail.noteEdit` / `dashpay.detail.hideToggle`) → `saveContactInfo` → `platform_wallet_set_dashpay_contact_info_with_signer` (ECB `encToUserId` + CBC `privateData`). These fields are locally cached **and** published encrypted to Platform once the identity has **≥2 established contacts** (stated in the in-app footer) → outcomes `.published` / `.deferredUntilTwoContacts` / `.skippedWatchOnly`. | | DP-10 | Incoming-payment backfill rescan (restore-from-seed / pre-watch window) | Cross | Manual | ✅ | regression | DIP-15 §8.7 / §12.6 (on the DIP-16 SPV base). No UI trigger — automatic in DashPay sync: `reconcile_dashpay_rescan` lowers SPV `synced_height` to `min($coreHeightCreatedAt)` across new receival contacts so the filter manager backfills. Pass: a DashPay payment that landed on a contact's address **before** it was watched (restore-from-seed / second device / the offline-accept→pay window) appears after restore + SPV sync. Environment-limited (must construct the skew window); the regression pin for the §12.6 payment-loss gap. | | DP-11 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | multiwallet | A's identity sends a contact request (`DP-01`) to B's; switch to wallet B's identity and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop entirely local. | -| DP-12 | Create invitation (DIP-13) | Cross | Common | 🔌 | funding | DashPay → paperplane (`dashpay.openSentInvitations`) → **+** (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet` → amount entry + **"send a contact request back to me"** checkbox → `createInvitation` → `platform_wallet_create_invitation`. Builds an **InstantSend** asset-lock voucher at the DIP-13 invitation funding path (`3'`), amount Rust-capped at `MAX_INVITATION_DUFFS`, and returns a **legacy-compatible** `dashpay://invite?du=…&assetlocktx=…&pk=…&islock=…` link (field-level cross-claimable with the dash-wallet iOS/Android apps) rendered as a QR + share sheet. The link carries the funding **txid** + the voucher **WIF** (`pk`) + the **islock** — not the embedded proof (the invitee refetches the tx at claim). Builds an **L1 asset lock** → needs the Core SPV client running + **testnet funds** (fund via Wallet → Receive → "request from testnet"). `pk` is a one-time voucher **private key** — a bearer credential; the UI must not log it and should flag the pasteboard sensitive. The funded record is persisted **before** the fallible key-export/URI-encode steps, so a ChainLock-funded (non-InstantSend) voucher is still recorded and reclaimable. | +| DP-12 | Create invitation (DIP-13) | Cross | Common | 🔌 | funding | DashPay → paperplane (`dashpay.openSentInvitations`) → **+** (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet` → amount entry + **"send a contact request back to me"** checkbox → `createInvitation` → `platform_wallet_create_invitation`. Builds an **InstantSend** asset-lock voucher at the DIP-13 invitation funding path (`3'`), amount Rust-capped at `MAX_INVITATION_DUFFS`, and returns a **legacy-compatible** `dashpay://invite?du=…&assetlocktx=…&pk=…&islock=…` link (field-level cross-claimable with the dash-wallet iOS/Android apps) rendered as a QR + share sheet. The link carries the funding **txid** + the voucher **WIF** (`pk`) + the **islock** — not the embedded proof (the invitee refetches the tx at claim). Builds an **L1 asset lock** → needs the Core SPV client running + **testnet funds** (fund via Wallet → Receive → "request from testnet"). `pk` is a one-time voucher **private key** — a bearer credential; the UI must not log it and should flag the pasteboard sensitive. The funded record is persisted **immediately after the funding broadcast** — before the proof wait and the fallible key-export/URI-encode steps — so an interrupted proof wait or a ChainLock-funded (non-InstantSend) voucher is still recorded and reclaimable. | | DP-13 | Claim invitation (DIP-13) | Platform | Common | 🔌 | | Paste/scan or deep-link a `dashpay://invite` link → `ClaimInvitationSheet` (`dashpay.invite.claim.*`) → `claimInvitation` → `platform_wallet_claim_invitation`. **Claim-by-fetch:** the link carries only the txid, so the SDK refetches the funding tx by id (bounded retry/backoff for DAPI propagation lag; both byte orders), reconstructs the InstantSend proof (from the link's islock) or a ChainLock proof (islock absent), and selects the voucher's credit output. Registers a **new identity for the invitee funded by the imported voucher** (no L1 Dash on the invitee side). Amount shows "—" in the preview (not on the wire). If the link carries a `du` username, prompt **"Add \?"** → on confirm, resolve the id via DPNS and send the contact request (`DP-01` path). New identity lands in Identities; optional contact in Contacts. `🔌` until the UI lands. | | DP-14 | Invite → claim two-wallet e2e | Cross | Thorough | 🔌 | multiwallet | The feature's acceptance gate. Wallet A (funded, SPV running) creates an invitation (`DP-12`); wallet B (no funds) claims it (`DP-13`) → B gains a funded identity with no L1 Dash; if the inviter opted into the bootstrap **and** the invitee confirms, the contact establishes on both ends (cf. `DP-11`). Requires testnet funding + both wallets on the same network. `🔌` until the UI lands. | | DP-15 | Reject malformed / reused / wrong-network invitation | Platform | Uncommon | 🔌 | | Negative paths all fail loudly with a clear message and no side effects: a **malformed** link (wrong scheme/host, missing `pk`/`assetlocktx`, duplicate required param, non-WIF `pk`, over-length URI), a **reused** link (asset lock already consumed → deterministic "already completely used"), and a **wrong-network** WIF (a valid key on the wrong chain, caught at claim before the funding fetch). NOTE: the legacy wire carries **no expiry** field (`expiry_unix` is 0 in the preview), so there is no past-expiry rejection — the honest bound is the amount floor (`MIN_INVITATION_DUFFS`) enforced at create. `🔌` until the UI lands. | From 204634c839d54a00c88d5a38919f431ffd451ffb Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 14:26:18 +0700 Subject: [PATCH 65/74] fix(swift-example-app): re-inject walletManager into the toolbar-pushed InvitationsView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SwiftExampleApp/Views/DashPay/DashPayTabView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index c27a8c9bd73..f7945dfec6a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -222,6 +222,11 @@ struct DashPayTabView: View { network: network, identity: activeIdentity ) + // Re-inject explicitly: toolbar-hosted destinations + // don't reliably inherit environment objects (see + // IgnoredContactsView above), and InvitationsView + // requires the manager for its loaded-wallet filter. + .environmentObject(walletManager) } label: { Image(systemName: "paperplane") } From 81a09d81d15d899eb01925139a47e05bc08e5af9 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 15:05:43 +0700 Subject: [PATCH 66/74] docs(dip15): record the as-built deltas in the invitations spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/dashpay/DIP15_INVITATIONS_SPEC.md | 73 +++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md index 723f6f64a80..66c40a11cc3 100644 --- a/docs/dashpay/DIP15_INVITATIONS_SPEC.md +++ b/docs/dashpay/DIP15_INVITATIONS_SPEC.md @@ -1,13 +1,63 @@ # DashPay Invitations (DIP-13 sub-feature 3') — Implementation Spec -> **Status:** REVIEWED DRAFT (2026-07-08). Four research streams + three adversarial spec reviews -> (feasibility / security / scope) folded — see §14. Core mechanic CONFIRMED against code; two -> blockers resolved (seedless voucher export → v1 slice 2; auto-accept dapk dropped for a plain -> contactRequest). **Next: sync gate with Ivan → spikes → code.** No code yet. +> **Status:** SHIPPED on PR #4041 (2026-07-14) — create + claim + reclaim + persistence + UI, +> three review-fix rounds folded, funded testnet e2e green (TEST_PLAN DP-12..19, `AI_QA/QA004`). +> The original design pass (2026-07-08, §1–§14 below) is kept as rationale; **§0 records where +> the as-built implementation deliberately diverged.** Where §0 and a later section disagree, +> §0 wins. Tracked as the "NEXT" item in the DashPay backlog (dashpay/platform#4020); called out in -`SPEC.md` Milestone 5 and `DIP_CONFORMANCE_GAPS.md` (Invitations = ❌ NOT STARTED). This is -the "own design pass" Milestone 5 asks for. +`SPEC.md` Milestone 5 and `DIP_CONFORMANCE_GAPS.md`. + +--- + +## 0. As-built delta (supersedes the marked sections below) + +1. **Link envelope = the LEGACY query format, not the §6 binary blob (supersedes §6, §7).** + The 2026-07-13 legacy-compat rework (owner decision; full spec: + `INVITATIONS_LEGACY_COMPAT_SPEC.md`) replaced the hand-rolled versioned payload with the + query form shared with dash-wallet Android / dashwallet-iOS, so links are field-level + cross-claimable: `dashpay://invite?du=&assetlocktx=&pk=&islock=` + `[&display-name=…][&avatar-url=…]` (also parses `https://invitations.dashpay.io/applink?…`). + Emit strict / parse lenient. Consequences: + - The link carries the funding **txid**, not the embedded proof → **claim-by-fetch**: the + invitee refetches the funding tx by txid (bounded retry for DAPI propagation lag, both + byte orders), reconstructs the proof, and selects the credit output by matching + `voucher_credit_script(pk)`. + - **No expiry field on the wire** — the §5.1/§8/§10 "claim refuses a past-expiry link" + mechanism does not exist in the as-built claim; `expiry_unix` survives only as inviter-side + local display metadata. The economic bounds are the amount caps. + - **No inviter identity id on the wire** (`inviter_id` always zeroed) — the contact bootstrap + resolves the id from the `du` username via DPNS at claim time. `InviterInfo.username` is + `Option`: a display-name/avatar-only link is metadata-only (`has_inviter == true`, + `inviter_username == nil`, no bootstrap). + - Amount is not on the wire (claim preview shows "—"). +2. **Claim accepts ChainLock invites too (amends §5.1).** `islock` absent or literal `"null"` + ⇒ a `ChainAssetLockProof` is reconstructed (requires the funding tx to be chain-locked). + Create still emits only InstantSend links — a slow-IS ChainLock fallback at create is + rejected as a *link* but the funded lock is recorded first and stays reclaimable. +3. **Amounts (amends §5/§8/§9):** `MIN_INVITATION_DUFFS = 300_000` (0.003 DASH — a smaller + voucher can fund neither a claim nor a register-reclaim, discovered by funded e2e), + `MAX_INVITATION_DUFFS = 5_000_000` (0.05 DASH), Swift default **0.03 DASH**. +4. **Persistence as-built (amends §4.2):** the `InvitationChangeSet` flows through + `PlatformWalletPersistence::store()` to each backend — the SQLite backend's + `V003__invitations` table, and on iOS the FFI `on_persist_invitations_fn` bridge into the + SwiftData `PersistentInvitation` model (SwiftData is the UI source; no Rust rehydrate; + spec: `INVITATIONS_PERSISTENCE_SWIFT_SPEC.md`). Persist failures are signaled end-to-end + (nonzero callback → rolled-back round → `create_invitation` errors), not best-effort. +5. **Durability + ordering hardening (review rounds 1–3, spec: + `INVITATIONS_REVIEW_FIX_SPEC.md`):** the pre-broadcast gate persists **and flushes** the + invitation funding-index pool (aborting before broadcast on failure); creation refuses + non-durable backends (`PlatformWalletPersistence::persists_durably()`); the funded-asset-lock + flow is split so the invitation record is persisted immediately **after broadcast, before the + proof wait** — an interrupted create can no longer orphan a funded voucher. +6. **Reclaim shipped (extends §1 scope):** an unclaimed voucher is recovered as identity + **credits** (top-up an existing identity or register a new one; the L1 amount was + OP_RETURN-burned). Already-consumed handling is classified via the persisted + `reclaimInFlight` marker (self-reclaim crash recovery vs neutral "already claimed") — + see `AI_QA/QA004` step 6 for the exact classifier arms. +7. **QA contract as-built:** TEST_PLAN §4.10 rows **DP-12..DP-19** (not just DP-12..15) + + `AI_QA/QA004_invitation_reclaim.md`; funded e2e evidence recorded there. --- @@ -370,6 +420,10 @@ no analytics, sensitive-pasteboard flag on the Swift side (§8 Finding 3). ## 6. The `dashpay://invite` link envelope — a single versioned blob +> **SUPERSEDED (2026-07-13, §0.1):** the shipped envelope is the legacy query format +> (`du`/`assetlocktx`/`pk`/`islock` — see `INVITATIONS_LEGACY_COMPAT_SPEC.md`), not this blob. +> Kept for the design rationale it records (secret handling, caps, transport notes still apply). + **Decision: one opaque, versioned payload** behind a `dashpay://invite?data=` deep link (keeping the reference's `dashpay://invite` scheme name for familiarity), **not** the reference's six loose query params. Rationale in §7. The payload is a small versioned blob in a @@ -425,7 +479,12 @@ routing change, not an envelope change. --- -## 7. Interop decision — **RESOLVED: ship our own self-contained envelope** +## 7. Interop decision — ~~RESOLVED: ship our own self-contained envelope~~ + +> **REVERSED (2026-07-13, §0.1):** the as-built codec adopts the reference wallets' legacy +> query format for field-level cross-claimability with dash-wallet iOS/Android. The analysis +> below (dead FDL delivery, JS SDK never had invitations) remains accurate — only the +> conclusion changed, by owner decision, once cross-wallet claimability was prioritized. Research (research-reference, primary sources) settled this: - The production iOS (DashSync) + Android (dash-wallet) wallets use an **identical plaintext URL-query payload**: `du` (username), `display-name`, `avatar-url`, `assetlocktx` (**txid From ac6adbfad22026f7e1d33a58a0ad87f7e125c546 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 15:24:54 +0700 Subject: [PATCH 67/74] docs(dip15): consolidate the four invitation specs into one canonical doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/dashpay/DIP15_INVITATIONS_SPEC.md | 140 ++++++- .../dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md | 105 ----- .../INVITATIONS_PERSISTENCE_SWIFT_SPEC.md | 299 -------------- docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md | 385 ------------------ 4 files changed, 133 insertions(+), 796 deletions(-) delete mode 100644 docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md delete mode 100644 docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md delete mode 100644 docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md index 66c40a11cc3..9b6f3825617 100644 --- a/docs/dashpay/DIP15_INVITATIONS_SPEC.md +++ b/docs/dashpay/DIP15_INVITATIONS_SPEC.md @@ -14,8 +14,7 @@ Tracked as the "NEXT" item in the DashPay backlog (dashpay/platform#4020); calle ## 0. As-built delta (supersedes the marked sections below) 1. **Link envelope = the LEGACY query format, not the §6 binary blob (supersedes §6, §7).** - The 2026-07-13 legacy-compat rework (owner decision; full spec: - `INVITATIONS_LEGACY_COMPAT_SPEC.md`) replaced the hand-rolled versioned payload with the + The 2026-07-13 legacy-compat rework (owner decision; contract in §0A) replaced the hand-rolled versioned payload with the query form shared with dash-wallet Android / dashwallet-iOS, so links are field-level cross-claimable: `dashpay://invite?du=&assetlocktx=&pk=&islock=` `[&display-name=…][&avatar-url=…]` (also parses `https://invitations.dashpay.io/applink?…`). @@ -42,11 +41,10 @@ Tracked as the "NEXT" item in the DashPay backlog (dashpay/platform#4020); calle 4. **Persistence as-built (amends §4.2):** the `InvitationChangeSet` flows through `PlatformWalletPersistence::store()` to each backend — the SQLite backend's `V003__invitations` table, and on iOS the FFI `on_persist_invitations_fn` bridge into the - SwiftData `PersistentInvitation` model (SwiftData is the UI source; no Rust rehydrate; - spec: `INVITATIONS_PERSISTENCE_SWIFT_SPEC.md`). Persist failures are signaled end-to-end + SwiftData `PersistentInvitation` model (SwiftData is the UI source; no Rust rehydrate; §0B). Persist failures are signaled end-to-end (nonzero callback → rolled-back round → `create_invitation` errors), not best-effort. -5. **Durability + ordering hardening (review rounds 1–3, spec: - `INVITATIONS_REVIEW_FIX_SPEC.md`):** the pre-broadcast gate persists **and flushes** the +5. **Durability + ordering hardening (review rounds 1–3; the per-finding log lives in the + PR #4041 review threads + commit messages):** the pre-broadcast gate persists **and flushes** the invitation funding-index pool (aborting before broadcast on failure); creation refuses non-durable backends (`PlatformWalletPersistence::persists_durably()`); the funded-asset-lock flow is split so the invitation record is persisted immediately **after broadcast, before the @@ -61,6 +59,134 @@ Tracked as the "NEXT" item in the DashPay backlog (dashpay/platform#4020); calle --- +## 0A. As-built link envelope & legacy interop (absorbs the legacy-compat spec) + +The interop contract is **field-level parity with the live legacy wallets, emit +strict/canonical, parse leniently** — exactly as tolerantly as the live Android wallet. +Byte-for-byte parity is NOT the contract (the two legacy wallets differ in param order and in +scheme/host). The on-chain primitive and derivation path (`m/9'/coin'/5'/3'/idx'`) are +identical across all three wallets — no consensus change. + +### 0A.1 Wire format + +**Emit (canonical, what we produce):** +```text +dashpay://invite + ?du= # required to emit; optional on parse + &assetlocktx= + &pk= + &islock= # or omit (see below) + [&display-name=] + [&avatar-url=] +``` +- Parse **by field name, order-independent**; accept **both** the `dashpay://invite` scheme + and the `https://invitations.dashpay.io/applink` host (iOS legacy links use the latter). +- **`pk`**: WIF, **compressed** flag set (the credit-output hash uses the *compressed* + pubkey — wrong compression ⇒ wrong `hash160` ⇒ claim fails), network byte `0xCC` mainnet / + `0xEF` testnet-family. +- **`assetlocktx`**: emit lowercase big-endian display hex; on claim parse leniently — try + as-given, then **retry byte-reversed** on a fetch miss (old iOS links are little-endian). +- **`islock`**: OPTIONAL, with two absence forms — param missing **and the literal string + `"null"`** (Android emits `"null"` for a chainlock-confirmed invite). Absent/`"null"` ⇒ + reconstruct a **`ChainAssetLockProof`** at claim, never reject. The hex is not + self-describing: decode as the modern deterministic **ISDLOCK**; the ancient + non-deterministic ISLOCK is unrepresentable in rust-dashcore and fails closed (documented + limitation — no live producer exists). +- **Validity (lenient superset of both wallets):** require `assetlocktx` + `pk` + present/non-blank; never reject solely on a missing `du` or missing/`"null"` `islock`. + +### 0A.2 Claim-by-fetch + +The link carries the funding **txid**, not a proof, so claim reconstructs it (mirrors Android +`TopUpRepository.obtainAssetLockTransaction`): +1. Fetch the tx by `assetlocktx` via `Sdk::get_transaction` (bounded retry/backoff for DAPI + propagation lag; reversed-retry per §0A.1). +2. Fail-fast guards: fetched txid matches `assetlocktx` (either byte order); when an islock is + present, `islock.txid == fetched tx.txid`. +3. **Derive `output_index` by script match** — scan the fetched tx's `credit_outputs` for the + output whose `script_pubkey` == `voucher_credit_script(pk)`; never hard-code index 0. +4. Build `InstantAssetLockProof` (islock present) or `ChainAssetLockProof` (absent/`"null"`; + requires the tx to be chain-locked), then submit through the **unchanged** + `put_to_platform_and_wait_for_response_with_private_key`. + +Consensus enforces pk↔output, islock↔tx, and identity_id↔outpoint — all fail closed; the +local guards are fast-fail UX + correct index selection, not theft prevention. + +### 0A.3 Consequences of the legacy format + +- **No inviter identity id on the wire** — only `du`. `InviterInfo = {username?, display_name?, + avatar_url?}` and the invitee resolves the inviter's id from `du` via DPNS at + contact-bootstrap. A `du`-less link is metadata-only (`has_inviter == true`, + `inviter_username == nil`, no bootstrap possible). +- **No expiry on the wire** — the pre-network staleness gate is gone; the real bounds are the + amount caps + reclaim. The inviter-side local record keeps expiry for display only. +- **Amount is not on the wire** — the claim preview shows "—" pre-fetch. + +### 0A.4 Amounts (onboarding tiers) + +`MIN_INVITATION_DUFFS = 300_000` (0.003 DASH, == Android `DASH_PAY_INVITE_MIN`; a smaller +voucher can fund neither a claim nor a register-reclaim — found by funded e2e). +`MAX_INVITATION_DUFFS = 5_000_000` (0.05 DASH). Swift create default **0.03 DASH** = identity ++ a normal DPNS name (Android `DASH_PAY_FEE`). The contested-name tier (0.25) is **deferred** +until contested registration is wired into the claim flow. + +### 0A.5 Transport + +The custom `dashpay://` scheme is the shipped, first-class transport (QR / share sheet / +in-person). The legacy wallets' AppsFlyer OneLink wrapper is **externally blocked** (Android +team creds; brand domain + template) and tracked separately (#4096-adjacent); note that +OneLink discloses the plaintext `pk` to AppsFlyer server-side — an accepted, documented +regression vs a self-contained link, bounded by the amount cap + reclaim. The custom scheme's +same-device interception limitation is documented in `Info.plist` + §6.1. + +--- + +## 0B. As-built persistence & reclaim (absorbs the Swift-persistence spec) + +### 0B.1 Persistence bridge + +`InvitationChangeSet` (structurally an `asset_locks`-style `BTreeMap` upserts + +`BTreeSet` removals) flows through `PlatformWalletPersistence::store()` to each backend: the +SQLite backend's `V003__invitations` table, and on iOS the **push-callback FFI bridge** +(`on_persist_invitations_fn` → `persistInvitationsCallback` → SwiftData +`PersistentInvitation`), mirroring the asset-lock wiring. Key properties: +- **SwiftData is the UI source; push-only, no Rust→Swift rehydrate.** A SwiftData wipe loses + only list *visibility* — never funds or key re-derivability (`funding_index` re-derives the + voucher key). +- **Persist failures are signaled, never swallowed:** a skipped write returns nonzero from the + callback, failing the (invitation-only) `store()` round and surfacing an error from + `create_invitation` instead of reporting a voucher that never reached SwiftData. +- **Outpoint key seam:** both the upsert and the removal path derive the unique + `outPointHex` via `PersistentAssetLock.encodeOutPoint` verbatim (key-form drift is pinned by + `InvitationPersistenceTests`). + +### 0B.2 Reclaim + +The invitation's DASH is **burned into an `OP_RETURN`** at create time — the credit output +exists only in the tx payload as a Platform-side authorization, never as an L1 UTXO — so +"reclaim" means: **the inviter consumes the still-unclaimed voucher into a Platform identity +of their own, recovering the value as credits** (mechanically, claiming your own invitation). +UI copy always says "recovered as identity credits", never "DASH returned". + +- **Primitive:** consume the tracked lock via `FromExistingAssetLock { out_point }` — the + inviter's own signer re-derives the voucher key at `9'/coin'/5'/3'/funding_index'` + internally (no key export). Two user-picked targets: **top-up an existing identity** or + **register a new one**. +- **Race / already-consumed:** no L1 double-spend exists (no shared UTXO); Platform + deterministically rejects the second consume + (`IdentityAssetLockTransactionOutPointAlreadyConsumed` — the loser wastes only an ST fee). + The Swift side disambiguates via the persisted `reclaimInFlight` marker, which is saved + (required — the consume may not run on a failed save) only immediately before the on-chain + consume: marker set ⇒ our own crash-interrupted reclaim (row → `Reclaimed`, also recovered + from the local "is not tracked" resume guard); marker unset ⇒ the invitee claimed first + (row → `Claimed`, neutral "This invitation was already claimed." — claimant not named). + The decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` + seam; see `AI_QA/QA004` step 6 for the verified classifier arms. +- **Status lifecycle:** `Reclaimed`/`Claimed` are written by the Swift UI on the local row + (SwiftData is the UI source; create is the only Rust emitter). + +--- + ## 1. Problem & goal DashPay onboarding today assumes the new user already **has** a Dash identity (which @@ -421,7 +547,7 @@ no analytics, sensitive-pasteboard flag on the Swift side (§8 Finding 3). ## 6. The `dashpay://invite` link envelope — a single versioned blob > **SUPERSEDED (2026-07-13, §0.1):** the shipped envelope is the legacy query format -> (`du`/`assetlocktx`/`pk`/`islock` — see `INVITATIONS_LEGACY_COMPAT_SPEC.md`), not this blob. +> (`du`/`assetlocktx`/`pk`/`islock` — see §0A), not this blob. > Kept for the design rationale it records (secret handling, caps, transport notes still apply). **Decision: one opaque, versioned payload** behind a `dashpay://invite?data=` diff --git a/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md b/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md deleted file mode 100644 index 314922c477f..00000000000 --- a/docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md +++ /dev/null @@ -1,105 +0,0 @@ -# DIP-13 Invitations — Legacy Wallet Compatibility Spec (v2, review-folded) - -Status: **REVIEWED (4 spec agents) → for sync/implementation** · Direction: **full legacy interop, dedicated PR** (do not fold into #4041; #4041 lands as the green baseline). · Author: platform-wallet - -> v2 folds the four spec reviews (feasibility / scope / security / interop-correctness). See §11 for the resolution log. The headline correction: **the interop contract is "emit strict/canonical, parse leniently — exactly as tolerantly as the live Android wallet."** "Byte-for-byte parity" was wrong; it's **field-level** parity. - -## 1. Problem & goal -PR #4041 shipped a new, self-contained invitation impl (`dashpay://invite?data=`, custom scheme, reclaim) on the unified Rust/Platform-SDK stack. It is **wire-incompatible** with the two legacy wallets (`dash-wallet` Android / AppsFlyer, `dashwallet-ios` / dead Firebase), which share a **field-name-based** `du`+`assetlocktx`+`pk`(WIF)+`islock` payload. Goal: make new-stack invites **cross-claimable with the live Android wallet** by adopting the legacy payload + transport + onboarding amounts, while **keeping reclaim + seedless + one shared codebase**. - -The on-chain primitive and derivation path (`m/9'/coin'/5'/3'/idx'`) are already identical across all three — no consensus change. - -## 2. Goals / Non-goals -**Goals:** G1 payload cross-claim parity (field-level); G2 AppsFlyer OneLink transport; G3 onboarding amounts; G4 preserve reclaim + seedless. -**Non-goals:** removing the bearer model (self-custody); changing the on-chain primitive/path. (Our-own-AASA Universal Links, prior issue #4096, is superseded by the AppsFlyer decision.) - -## 3. Wire format — the interop contract (FIELD-LEVEL parity; emit strict, parse lenient) - -**Emit (canonical, what we produce):** -```text -dashpay://invite - ?du= # required - &assetlocktx= - &pk= - &islock= # or omit (see below) - [&display-name=] - [&avatar-url=] -``` -- Parse **by field name, order-independent** — the two legacy wallets differ in param order *and* in scheme/host (iOS emits `https://invitations.dashpay.io/applink?…`), so **byte equality is not the contract**. Our parser MUST accept **both** the `dashpay://invite` scheme and the `https://invitations.dashpay.io/applink` host. -- **`pk`**: WIF, **compressed** flag set (the credit-output hash uses the *compressed* pubkey — wrong compression ⇒ wrong `hash160` ⇒ claim fails), network byte `0xCC` mainnet / `0xEF` testnet (matches bitcoinj + `dashcore::PrivateKey::from_wif`). -- **`assetlocktx`**: we **emit** lowercase big-endian display hex. On **claim** we parse leniently: try as-given, then **retry byte-reversed** on a fetch miss (mirrors Android's `Sha256Hash.wrap(id).reversedBytes` retry — old iOS links are little-endian and still exist). -- **`islock`**: OPTIONAL. Two absence forms MUST be handled: param missing, **and the literal string `"null"`** (Android emits `"null"` when the lock was a **chainlock**, not instantsend; its own `isValid` passes it). `islock == "null"` ⇒ treat as "no instant lock" and reconstruct a **`ChainAssetLockProof`** at claim (see §4.3), not an instant proof. iOS ignores `islock` on claim entirely. -- **islock version**: the hex is not self-describing — the decoder MUST assume **ISDLOCK** version and fall back to **ISLOCK** (Android does exactly this). -- **Validity (lenient, superset of both wallets):** require `assetlocktx` + `pk` present/non-blank (iOS's minimum). `du` is required to *emit* but treated as optional on *parse* (iOS accepts `du`-less links). Never reject solely on a missing/`"null"` `islock`. - -## 4. The four changes - -### 4.1 Payload codec — Rust `rs-platform-wallet/src/wallet/identity/crypto/invitation.rs` -Replace the binary envelope with the query form. -- `encode_invitation_uri` → build the §3 link. WIF via `dashcore::PrivateKey::to_wif` with `compressed=true`; assert compression+network in tests. txid via `proof.transaction().txid().to_string()` (lowercase big-endian). islock via consensus-encode of `proof.instant_lock()` → hex (omit if the proof is a ChainLock). -- `parse_invitation_uri` → accept both scheme + https host; parse the six fields by name; WIF network-checked decode; handle `islock` missing/`"null"`; **do not** fail on order or on missing `islock`/`du`. -- `ParsedInvitation` drops the embedded `asset_lock`; gains `funding_txid`, `islock: Option<…>`, and keeps `voucher_key` (decoded from WIF). Amount is **no longer known pre-fetch** — the preview shows amount only after §4.3 fetch (or "—" offline). - -### 4.2 Transport — AppsFlyer OneLink (app layer) -- Wrap the inner `dashpay://invite?…` as `af_dp` in a OneLink; inbound conversion listener extracts `af_dp`/`deep_link_value`/`link` → claim flow. Keep the raw custom scheme as a **first-class parallel fallback** (QR / in-person), not an afterthought. -- **External blocker (does not gate G1/G3/G4):** OneLink brand domain (`dashpay.onelink.me`), dev key, template ID — from the Android team's AppsFlyer account; the iOS app must be added to the **same OneLink template**. Build with config placeholders; the custom-scheme path is fully testable without creds. -- **Threat-model note (§7):** AppsFlyer receives the plaintext `pk` server-side. - -### 4.3 Claim — fetch tx by txid — Rust claim path -- Add `Sdk::get_transaction(txid) -> Transaction` (~20–40 lines; the gRPC `getTransaction` is already issued in `rs-sdk/src/core/transaction.rs:176` but the tx bytes are discarded — surface them, `Transaction::consensus_decode`). -- Reconstruct the proof mirroring Android `TopUpRepository.obtainAssetLockTransaction`: - 1. Fetch tx by `assetlocktx` (with the reversed-retry from §3). - 2. **Fail-fast guards:** `hash256(fetched tx) == assetlocktx` (both orders); if an islock is present, `islock.txid == fetched tx.txid`. - 3. **Derive `output_index` (do NOT hard-code 0):** reuse the shipped `voucher_credit_script(pk)` = `P2PKH(hash160(compressed pubkey(pk)))` as the **selector** — scan the fetched tx's `credit_outputs` for the output whose `script_pubkey` matches; reject if none matches. (The link carries `pk` but not the index; hard-coding 0 fails any legacy invite whose credit output isn't at index 0.) - 4. Build the proof: `islock` present → `InstantAssetLockProof { instant_lock, transaction, output_index }`; `islock == "null"`/absent → **`ChainAssetLockProof { core_chain_locked_height, out_point, transaction }`** (fetch the confirming height; mirrors the chainlock-only legacy path). - 5. Pass into the **unchanged** `put_to_platform_and_wait_for_response_with_private_key`. -- Retry/backoff on DAPI propagation lag (islock/chainlock proves finality). Consensus enforces pk↔output, islock↔tx, identity_id↔outpoint — all fail closed; the local guards are for fast-fail + correct index, not theft prevention. - -### 4.4 Amounts — onboarding tiers — Rust constants + Swift UI -- **Normal tier (ship in v1): `0.03 DASH`** = identity + a normal DPNS name (Android `DASH_PAY_FEE`). New create **default = 0.03**. -- **Raise/drop `MAX_INVITATION_DUFFS`** — current `0.01` is **below** the normal tier, so create rejects its own default; set MAX ≥ the contested tier or drop the hard cap and gate on wallet balance (Android: `spendableBalance >= amount`). -- **Keep `MIN_INVITATION_DUFFS = 0.003`** (already == Android `DASH_PAY_INVITE_MIN`). -- **Contested tier `0.25 DASH` — DEFERRED** until contested/premium-name registration is actually wired into the new-stack claim flow (scope review F5; don't ship a price tier with no consumer). Add the tier + picker when that lands. - -## 4b. Consequences of matching the legacy format (confirmed during impl) -- **No inviter identity-id on the wire.** The legacy link carries only `du` (username) — no identity id. So `InviterInfo = {username, display_name, avatar_url}`, and the **invitee resolves the inviter's identity from `du` via DPNS at contact-bootstrap** (mirrors Android `identityRepository.getUser(invite.user)`). Swift contact-bootstrap must do the DPNS lookup instead of using an embedded id. -- **No expiry on the wire.** Legacy links have no expiry; the pre-network staleness gate is dropped (the real bound is the amount cap + reclaim; expiry was advisory anyway). The inviter-side local reclaim TTL is unchanged. -- **islock ISLOCK-fallback is a documented limitation, not a gap.** We decode the modern deterministic ISDLOCK (what every live wallet emits and what we emit). rust-dashcore's `InstantLock` cannot represent the ancient non-deterministic ISLOCK; such a link fails closed (clean failed claim, `islock.txid != tx.txid` guard also catches it). Acceptable — add a manual ISLOCK parser only if a live producer is ever found. - -## 5. Preserved (unchanged): reclaim (outpoint + `funding_index`, orthogonal to link format), seedless key handling, shared Rust core. Regression-test reclaim after the codec change. - -## 6. Decisions — resolved -- **D1 contact bootstrap:** KEEP opt-in-both-ends (prior owner decision, safer for a bearer link); accept the minor parity gap vs legacy's auto-one-way. (Revisit if product wants frictionless parity.) -- **D2 AppsFlyer creds:** external blocker for G2 only; G1/G3/G4 proceed. -- **D3 byte order:** emit big-endian; **parse lenient with reversed-retry** (do NOT drop the hack — that was a regression). -- **D4 WIF:** compressed=true, network byte `0xCC`/`0xEF`; cross-wallet WIF byte-equality fixture test. -- **D5 sequencing:** **dedicated PR** stacked on the invitation branch; #4041 lands as baseline. - -## 7. Threat model & failure modes -| Risk | Severity | Mitigation | -|---|---|---| -| **AppsFlyer discloses plaintext `pk` server-side** | MED (regression vs #4041) | Bounded by amount cap + fast reclaim (NOT the advisory expiry, which a direct holder ignores). Document AppsFlyer as an untrusted intermediary; ensure the web preview forwards only `display-name`/`avatar-url`, never the `pk`-bearing link. | -| Claim-time tx fetch fails (DAPI lag) | MED | Retry/backoff; islock/chainlock proves finality; clear "still confirming" state. | -| Wrong endianness / can't claim old iOS links | LOW | Reversed-retry on fetch miss (§4.3). | -| WIF compression/network mismatch → silent claim fail | LOW | Compressed+network round-trip test. | -| `islock="null"` chainlock invite unclaimable | MED | ChainAssetLockProof path (§4.3.4). | -| Attacker-crafted link | — | No theft (consensus fails closed); worst case a failed claim (griefing) or a valid self-controlled identity. | -| Reclaim vs new payload | — | Orthogonal; regression-tested. | - -## 8. Architecture / ownership -Rust `rs-platform-wallet`: codec (§4.1), claim-by-fetch (§4.3), amount constants (§4.4). `rs-sdk`: `get_transaction` wrapper. FFI: signature updates. Swift/SwiftExampleApp: create tier UI, AppsFlyer wrap/inbound (§4.2), claim wiring. Reclaim: untouched. - -## 9. Test plan -- **Rust unit:** WIF **compressed+network** round-trip; `assetlocktx` big-endian emit + reversed-retry parse; `islock` present / absent / `"null"` handling; field-name parse (order-independent, both scheme + https host); `output_index` selection by `pk` match; ISDLOCK→ISLOCK fallback. -- **Interop fixture:** parse a **real captured Android link** (incl. a `"null"`-islock case) and assert **field equality** (NOT byte equality) + a successful claim reconstruction; if obtainable, an old little-endian iOS link. -- **Reclaim regression:** create → reclaim (topup + register) still green post-codec. -- **Funded testnet e2e (two emulators):** new→new claim (fetch path); new→**live Android `dash-wallet`** cross-claim (the real proof, when a build is available); reclaim-vs-claim race unchanged; amount 0.03 funds identity + a normal name end-to-end. -- **CI:** `cargo fmt --all --check` + workspace tests + Swift SDK strict build. - -## 10. Rollback: revert the codec/claim/amount commits; tracked invitations (outpoint-keyed SwiftData) unaffected. - -## 11. Spec-review resolution log -- **Feasibility:** claim-by-fetch is a ~20–40 line wrapper (gRPC already called); WIF exists & network-aware; amounts trivial; AppsFlyer the only external blocker. → §4.3, §4.1, §4.4, D2/D4. -- **Interop-correctness (highest-impact):** "byte-for-byte" → **field-level**; **keep** endianness reversed-retry; handle **`islock="null"`**; **compressed** WIF; accept both scheme+https host; islock optional; ISDLOCK/ISLOCK. → §3, §4.1, §4.3, D3/D4. -- **Security:** no theft vector (consensus fails closed); **must** derive `output_index` by `pk` match (not index 0); add `txid`/`islock.txid` fail-fast guards; **`islock="null"` needs ChainAssetLockProof**; **AppsFlyer server-side `pk` disclosure** is a real regression → threat model. → §4.3, §7. -- **Scope:** amount fix is a standalone bug (ship regardless); **#4041 stays green baseline**; interop = **dedicated PR**; **defer the 0.25 contested tier**; AppsFlyer re-introduces the 3rd-party-service dependency class → keep custom-scheme fallback first-class. → §2, §4.2, §4.4, D5. diff --git a/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md b/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md deleted file mode 100644 index 2cbc10f48eb..00000000000 --- a/docs/dashpay/INVITATIONS_PERSISTENCE_SWIFT_SPEC.md +++ /dev/null @@ -1,299 +0,0 @@ -# Sent-Invitations persistence — Swift/SwiftData half (DIP-13 follow-up) - -## 1. Problem - -An inviter creates invitations (`create_invitation`), but the iOS **SwiftExampleApp** -has no "Sent invitations" list — a created invitation is invisible in the app after the -share sheet closes. The Rust half already emits the data: `create_invitation` builds an -`InvitationChangeSet` and calls `self.persister.store(PlatformWalletChangeSet { invitations: -Some(cs), .. })`. The app's persister (`FFIPersister`) never forwards that sub-field to the -host, so it never reaches SwiftData or any view. - -**Goal:** (a) surface each `InvitationEntry` into SwiftData and render a "Sent invitations" list -(amount, status, expiry, inviter-flag), so the inviter can see what they sent; and (b) let the -inviter **reclaim** an unclaimed voucher — recovering its value as **Platform credits** in an -identity (see §8; the L1 DASH is burned at create-time and cannot return to the wallet). - -**Non-goals:** a Rust→Swift *load/rehydrate* path (SwiftData is the UI source; no resume); -cross-device sync; changing the create/claim flows; **any L1 "DASH back to wallet"** (impossible — -the funding is an `OP_RETURN` burn). - -## 2. Chosen approach — clone the `asset_locks` push-callback path - -The app persists wallet state through a **C callback vtable** (`PersistenceCallbacks`): during -the Rust persister's `store()` round, each `PlatformWalletChangeSet` sub-field is projected -into a flat `#[repr(C)]` struct-per-entry and pushed to the host via an `on_persist__fn` -callback, bracketed by `on_changeset_begin`/`on_changeset_end` (one atomic round → one -SwiftData `save()`). `InvitationChangeSet` is structurally identical to `AssetLockChangeSet` -(`BTreeMap` upserts + `BTreeSet` removals), so we mirror the -asset-lock wiring 1:1. - -**Rejected alternative — the `dashpay_payments_overlay` pull-getter.** That domain is fetched -on demand off a live `ManagedIdentity` handle precisely because it already round-trips through -identity persistence and wanted *no* new persister callback or SwiftData path. Invitations are -a genuinely new persisted domain flowing through `store()`, so the push-callback route is the -correct one (confirmed by the payments model's own migration note: "the persister doesn't -project payment history"). - -**Simplification vs. the template.** `InvitationEntry` is all-POD (`out_point`, three `u32`s, -one `u64`, a `bool`, an enum). Unlike `AssetLockEntry` it carries **no owned byte buffers** -(`transaction_bytes`/`proof_bytes`), so the Rust side needs **no parallel `…Storage` Vec** and -**no pointer-lifetime management** — the FFI struct is self-contained POD. - -## 3. Interface & data flow - -### 3.1 Rust FFI (new `invitation_persistence.rs` + edits to `persistence.rs`, `lib.rs`) - -```rust -// Field order is load-bearing: 36+4 lands amount_duffs (u64) on an 8-byte -// boundary, so the struct has ZERO internal padding (size 64, align 8). Do not -// reorder or insert a field without re-checking padding on both sides. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct InvitationEntryFFI { - pub out_point: [u8; 36], // 32-byte raw txid ‖ 4-byte LE vout (reuse outpoint_to_bytes) - pub funding_index: u32, - pub amount_duffs: u64, - pub expiry_unix: u32, - pub created_at_secs: u32, - pub has_inviter: u8, // bool → 0/1 — u8 NOT bool on purpose (a memcpy'd byte - // ∉ {0,1} is instant UB for Rust bool). Do not "clean up". - pub status: u8, // Created=0, Claimed=1, Reclaimed=2 (status_to_u8) -} -// No `unsafe impl Send/Sync` — InvitationEntryFFI has no pointer fields (unlike -// AssetLockEntryFFI). Do not cargo-cult the asset-lock unsafe impls. - -// Appended at the END of PersistenceCallbacks (after the feature-gated shielded -// fields, so layout stays stable). Mirrors on_persist_asset_locks_fn exactly. -on_persist_invitations_fn: Option i32>, // return is round-global (see §4 T3): mirror the template — always 0. -``` - -- `build_invitation_entries(&[&InvitationEntry]) -> Vec` — POD projection, - **no storage Vec** (nothing to keep alive). Reuse `outpoint_to_bytes` for `out_point`. -- `status_to_u8(&InvitationStatus) -> u8` — a **wildcard-free exhaustive** match (no `_ =>`), so - a future variant is a compile error. Pinned by a unit test (0/1/2). -- In `FFIPersister::store()`, next to the asset-lock block, add - `if let Some(ref inv_cs) = changeset.invitations { … }` — with the **same non-empty guard** - the asset-lock block uses (`if !upserts.is_empty() || !removed.is_empty()`), bind the - `Vec` and removals to `let` locals (never `.as_ptr()` on a temporary), - pass `std::ptr::null()` when a count is 0 (an empty `Vec::as_ptr()` is dangling-nonnull), fire - the callback, capture its `i32`, then `drop(upserts); drop(removed)` for parity with the - template. -- Register the module: `mod invitation_persistence;` in `lib.rs`; make the struct/fn `pub`. -- `PersistenceCallbacks::Default` is a **manual impl** (not derived) — add - `on_persist_invitations_fn: None` there; omitting it is a compile error (fail-loud). - -### 3.2 Swift ingestion (`PlatformWalletPersistenceHandler.swift`) - -- `@convention(c)`-compatible free function `persistInvitationsCallback(context, walletIdPtr, - upsertsPtr: UnsafePointer?, upsertsCount, removedPtr: - UnsafePointer?, removedCount) -> Int32`. Recover the handler via - `Unmanaged.fromOpaque(context).takeUnretainedValue()`, **deep-copy every FFI row into owned - Swift values before returning** (Rust frees the buffers on return), build - `[InvitationEntrySnapshot]` + `[Data]` removals, call `handler.persistInvitations(...)`. - **Consume the cbindgen-regenerated `InvitationEntryFFI`** from the header — never a hand-written - Swift mirror. **Signal persistence failure, do not swallow it:** `persistInvitations` returns a - success `Bool` and the shim returns `1` when any upsert/removal was skipped (fetch error), `0` - otherwise. Because SwiftData is the sole UI source (no Rust→Swift rehydrate), a silently dropped - upsert would make a *funded* voucher vanish; the nonzero return makes `store()` return `Err`, and - `create_invitation` (which persists the record before its own fallible export/encode steps) - surfaces that as a hard failure instead of reporting success. The invitation round is - invitation-only, so this rollback only ever discards an invitation round. -- **Outpoint key (T1 — the critical seam):** compute `outPointHex = - PersistentAssetLock.encodeOutPoint(rawBytes:)` **once** in the shim for each upsert snapshot, - and store that exact display string (`:`) as - `PersistentInvitation.outPointHex` (the `@Attribute(.unique)` key). The removal path derives the - identical string from the same `encodeOutPoint`. Both paths MUST use `encodeOutPoint` verbatim — - never hand-roll the vout decode (ARM64 misaligned-load trap; `encodeOutPoint` already byte-copies - into an aligned local). -- Wire `cb.on_persist_invitations_fn = persistInvitationsCallback` in `makeCallbacks()`. - **(Feasibility-review's #1 silent-failure mode: forgetting this line compiles clean and the app - runs — invitations just never appear. The sim acceptance test in §5 is the only gate that catches - it.)** -- `persistInvitations(walletId:, upserts:, removed:)` runs entirely inside `onQueue` (serial - queue confining `backgroundContext`), body **inline** — do NOT call any public `onQueue`-wrapping - method from inside it (recursive `serialQueue.sync` deadlocks). Upsert = fetch by unique - `outPointHex` → mutate fields incl. **`walletId` on BOTH the insert and the update branch** - (the view's `@Query` filters on it) + `updatedAt = Date()`, else insert; remove = - `encodeOutPoint(rawBytes:)` then fetch-and-delete. `outPointHex` is globally unique (unscoped by - wallet) — correct here because on-chain outpoints are globally unique (unlike 20-byte address - hashes that force a wallet-scoped predicate elsewhere). **No `save()` here** — `endChangeset` - commits the round. - -### 3.3 SwiftData model (`PersistentInvitation.swift`) + registration - -```swift -@Model final class PersistentInvitation { - #Index([\.walletId]) - @Attribute(.unique) var outPointHex: String // ":" — the T1 key - var rawOutPoint: Data // 36B txid‖vout(LE) verbatim from - // InvitationEntryFFI.out_point; commit-2 - // reclaim reads it directly to build the - // OutPointFFI (no reverse-encodeOutPoint - // decode / no migration). Default empty Data. - var walletId: Data - var fundingIndexRaw: Int - var amountDuffs: Int64 - var expiryUnix: Int - var createdAtSecs: Int - var hasInviter: Bool - var statusRaw: Int // enums as Int for #Predicate - var createdAt: Date - var updatedAt: Date -} -``` - -Append `PersistentInvitation.self` to `DashModelContainer.modelTypes`. Reuse -`PersistentAssetLock.encodeOutPoint` (ARM64 misaligned-load-safe) for the outpoint key. - -### 3.4 UI (`InvitationsView.swift`, linked from `DashPayTabView`) - -`@Query`-filtered list (mirror `ContactRequestsView`): filter by `walletId`, sort by -`createdAtSecs` desc, render short outpoint + amount + a status badge + expiry. The -`shortOutPointDisplay` / `statusLabel` helpers live **inline in `InvitationsView.swift`** (a -private extension) — not a separate `…Display.swift` file; extract to a shared file only if a -second consumer appears (the asset-lock display file was extracted precisely because it had -multi-view duplication, which invitations don't yet). The status→label switch maps an unknown -`statusRaw` to an explicit `.unknown` case (the Swift `Int` side has no compiler exhaustiveness, -unlike the Rust match). Entry point: a "Sent invitations" `NavigationLink` in `DashPayTabView`. - -## 4. Failure modes & mitigations - -| # | Risk | Mitigation | -|---|---|---| -| **T1** | **Outpoint key-form mismatch (highest-risk, latent).** If the upsert keys `outPointHex` on anything other than the `encodeOutPoint` display form, a future reclaim/status-sync delete (which looks up via `encodeOutPoint`) silently matches nothing → orphaned rows. Latent because reclaim is a v1 non-goal, so it passes all v1 testing. | Shim computes `outPointHex = encodeOutPoint(rawBytes:)` **once** for the upsert; the unique key IS that string; removal derives the identical string from the same fn. Test the seam now: add a create→reclaim→row-deleted round-trip test even though reclaim isn't shipped. | -| **T2** | **Round overlap.** One shared `backgroundContext` + one `inChangeset` bool; if two `store()` rounds ran concurrently, one round's `save()` would commit the other's half-applied writes. Inherited by all 8 existing kinds. | State + rely on the invariant: `store()` rounds are serialized per persister (never concurrent), and invitations introduces **no** new `store()`-driving path. If that invariant doesn't hold in `platform_wallet`, it's a **pre-existing** bug to file separately — not fixed here. | -| **T3** | **Round-global rollback.** The callback's `i32` is round-global: a non-zero return rolls back the **entire** round (discarding unrelated asset-lock/identity writes) and makes `store()` return `Err`. | Mirror the template: handler uses `try?`, shim **always returns 0**; a failed invitation write is silently skipped, never a round abort. (There is no per-kind rollback.) | -| — | `ModelContext` not thread-safe; callbacks on Tokio threads | All reads/writes through `onQueue`; body **inline**, never re-enter `onQueue`; never `save()` in the per-kind handler. | -| — | Rust frees FFI buffers on return | Shim deep-copies every row to owned Swift values **before** returning. Trivial (all POD). `let`-bound Vecs + null-when-empty on the Rust side. | -| — | ARM64 misaligned load on vout @offset 32 | Reuse `encodeOutPoint` verbatim (byte-copies into an aligned local); never hand-roll vout decode. | -| — | New `@Model` breaks the store | Additive migration: new type + non-optional columns with defaults; no `DashSchemaV1` version bump (dev stores recreate), matching the file's documented precedent. | -| **T4** | **Status drift.** Two independent encodings (FFI `u8`, sqlite `status_str`); the Swift `Int` side has no exhaustiveness. | Rust `status_to_u8` is wildcard-free (future variant = compile error); Swift maps unknown `statusRaw` → `.unknown`; unit test pins 0/1/2. (Not "one source of truth" — two encodings, each guarded.) | - -## 5. Test / verification plan - -- **Rust:** unit test `build_invitation_entries` round-trips each field (POD projection) + a - test pinning `status_to_u8` values (0/1/2, wildcard-free). `cargo test -p platform-wallet-ffi` - green; `clippy --all-features` + `fmt` clean. Note: **the Rust tests exercise the projection in - isolation only — they cannot catch a broken FFI wire-up or a stale header.** -- **Swift build:** `build_ios.sh` (rebuild the xcframework so the regenerated header carries - `InvitationEntryFFI` + the new vtable field — mandatory, not just for the symbol; a stale header - = vtable mismatch = crash) + SwiftExampleApp `xcodebuild` (iPhone 17, arm64) green. -- **Sim verification — REQUIRED acceptance gate (not optional).** This is the *only* check that - catches the two most likely failures (forgetting the `makeCallbacks()` wiring → invitations - silently never appear; stale header → crash). Create an invitation in the app → assert a row in - `ZPERSISTENTINVITATION` via `sqlite3` on the SwiftData store **and** in `InvitationsView`, with - the outpoint hex matching the created voucher. Then drive a second `store()` touching the same - outpoint (or re-create) to confirm **upsert-in-place**, not a duplicate row. Reuse the proven - DP-14 flow. -- **T1 seam test:** a create→(simulated reclaim/removal)→row-deleted assertion pins the - upsert-key ↔ removal-key form. Reclaim ships in this PR (§8), and this seam is covered by - `InvitationPersistenceTests`; keep the assertion so a future key-form drift is caught. -- **QA rows:** add DP-16 ("Sent invitations list reflects a created invitation; upsert-in-place on - status change") to `TEST_PLAN.md` §4.10. - -## 6. Delivery - -**Same PR — #4041 / branch `feat/dip15-dashpay-invitations`** (owner-decided 2026-07-09: the -feature is cohesive create→claim→see-sent→reclaim, and #4041 isn't merged yet, so fragmenting it -into a stacked PR is needless ceremony). Trade-off accepted: this re-triggers CI + the review -bots on the new diff (fine — the PR is awaiting approval anyway). FFI + Swift split with the -swift-rust-ffi engineer: Rust FFI projection/callback + reclaim primitive (my side), Swift model -+ shim + handler + views (ffi-swift). Requires a `build_ios.sh` window (new FFI symbols), so -builds are coordinated to avoid contention. - -## 7. Resolved decisions (owner sync, 2026-07-09/10) - -1. **Base branch:** same PR #4041 (see §6). -2. **Scope:** display **and reclaim** (§8). -3. **Rehydrate:** push-only, no Rust→Swift load path — the already-decided architecture (the Rust - storage layer states "the production load path does not re-hydrate invitations into the Rust - manager; the Swift SwiftData mirror is the UI source", - `packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs:92-93`). A SwiftData wipe - loses only list *visibility*, never funds or key re-derivability (`funding_index` still derives - the voucher key). -4. **Reclaim semantics:** recover value as **Platform credits**, not L1 DASH (the DASH is burned - at create). Confirmed acceptable; UI copy must say "recover as identity credits." -5. **Reclaim target:** user **chooses at reclaim time** — top up an existing identity OR register - a new one from the voucher. - ---- - -## 8. Reclaim an unclaimed voucher - -### 8.1 What reclaim is (and isn't) - -The invitation's DASH is **burned into an `OP_RETURN`** at create time (asset-lock special-tx: -the on-chain output is a single OP_RETURN carrying the total; the credit output — P2PKH to the -one-time key — exists only in the tx *payload* as a Platform-side authorization, never as an L1 -UTXO). So there is **nothing on L1 to spend back**. "Reclaim" therefore means: **the inviter -consumes the still-unclaimed voucher into a Platform identity of their own, recovering the value -as credits.** Mechanically it's "claim your own invitation." (Evidence: OP_RETURN burn at -`transaction_builder.rs:352-356`, pinned by `asset_lock_builder.rs:713-723`; credit-output P2PKH -built at `build.rs:80-84`; stored outpoint `(txid, 0)` at `build.rs:324-325`.) - -### 8.2 Primitive (reuses existing building blocks) - -Consume the tracked voucher lock via `AssetLockFunding::FromExistingAssetLock { out_point }` -(the invitation's stored funding outpoint). The **inviter's own wallet signer** re-derives the -voucher key at `m/9'/coin'/5'/3'/funding_index'` itself (`resume_asset_lock` → -`rederive_credit_output_path`, `recovery.rs:371-453`) — **no key export/import** (unlike the -invitee's claim). Two targets (user picks): - -- **Top up an existing identity:** `top_up_identity_with_funding(identity_id, - FromExistingAssetLock { out_point }, asset_lock_signer, settings)` (`registration.rs:388`). -- **Register a new identity:** `register_identity_with_funding(FromExistingAssetLock { out_point }, - …)` (`registration.rs:121`) — the exact helper the existing "Resumable Registrations" flow uses. - -New Rust surface is thin: a `reclaim_invitation(out_point, target, identity_signer, -asset_lock_signer, now_unix, settings)` dispatcher in `network/invitation.rs` that calls the -right helper and returns the resulting `Identity`. No new *core* mechanic. - -### 8.3 FFI + Swift - -- **FFI:** one new `platform_wallet_reclaim_invitation(wallet, out_point[36], target_kind: u8 - {0=topup,1=register}, identity_id[32] (topup) | identity_index: u32 (register), signer, - now_unix, settings, out_identity_id[32], out_handle)`. - - The `register` arm can mirror the existing - `platform_wallet_resume_identity_with_existing_asset_lock_signer` - (`identity_registration_funded_with_signer.rs:162`) verbatim, pointed at the invitation's - outpoint. - - The `topup` arm is **net-new** at the FFI layer — no existing FFI tops up from an *existing* - asset lock (only `platform_wallet_top_up_from_addresses_with_signer`, which funds a NEW lock). - The Rust primitive exists; wrap it. -- **Swift:** a "Reclaim" action on each `Created` sent-invitations row → a small sheet: **"Recover - this invitation's value as credits"** with the target choice (pick an existing identity to top - up, or "register a new identity"). On success, **the Swift side flips its own - `PersistentInvitation.statusRaw` to `Reclaimed`** (SwiftData is the UI source — no Rust re-emit - needed; the display-half persistence bridge is only for the create-time push). Copy is explicit: - "recovered as identity credits", never "DASH returned". - -### 8.4 Failure modes (reclaim-specific) - -| Risk | Mitigation | -|---|---| -| **Consume race** — invitee claims the same voucher at the same moment | No L1 double-spend (no shared UTXO). Platform records consumed outpoints and deterministically rejects the second consume with `IdentityAssetLockTransactionOutPointAlreadyConsumed` (`verify_is_not_spent/v0/mod.rs:37-55`). Loser wastes a small ST fee, no funds lost. On that error the Swift side disambiguates via the persisted `reclaimInFlight` marker (set only immediately before our own on-chain consume): marker set ⇒ our own crash-interrupted reclaim (row → `Reclaimed`); marker unset ⇒ the invitee claimed first (row → `Claimed`), shown with a neutral "already claimed" message (the claimant is intentionally not named). The decision is the pure `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` seam, unit-tested for all three outcomes. | -| **User expects L1 DASH back** | UI copy says "recover as identity credits"; the reclaim sheet states the value returns as credits, not spendable DASH. | -| **Reclaim after app restart** (the common case — inviter reclaims days later) | Works: `FromExistingAssetLock` resumes the tracked lock; if the in-memory IS proof was lost on restart it falls back to SPV re-derivation (slower, still correct). **Verify** the SQLite `asset_locks` load re-attaches the proof (spike open-item #1) — perf only, not correctness. | -| **No expiry gate** | Reclaim is allowed anytime (protocol has no timelock; expiry is advisory). Product choice whether to nudge "wait until expiry"; default: allow immediately, since an invitee can claim past expiry anyway. | -| **Partial consumption remainder** | Invitation amounts are small and a single consume takes the whole value; verify the topup consumes the full voucher (no stranded remainder) — spike open-item #3. | -| **Status lifecycle now has a real emitter** | `Reclaimed`/`Claimed` are written by the Swift UI on the local row (not through the Rust changeset), so the `InvitationChangeSet::merge` insert-vs-tombstone hazard (§4-adjacent) stays latent — create is still the only Rust emitter. | - -### 8.5 Reclaim test plan (adds to §5) - -- **Rust:** unit-test `reclaim_invitation` dispatch (topup vs register) selects the right helper + - `FromExistingAssetLock`. FFI marshaling test for `platform_wallet_reclaim_invitation` - (out-param sentinels, target-kind dispatch, nullable id/index). -- **Testnet e2e (DP-17):** create an invitation → do NOT claim it → reclaim it into (a) an existing - identity (topup: assert the identity's credit balance rises by ~the voucher value) and, in a - second run, (b) a new identity (register: assert a new identity funded by the voucher). Verify - on-chain via platform-explorer that the outpoint is consumed. Then attempt a second reclaim/claim - of the same outpoint → assert the deterministic `AlreadyConsumed` rejection surfaces as the - benign "already claimed" state. Row status → `Reclaimed`. -- **QA rows:** DP-17 (reclaim topup), DP-18 (reclaim register-new), DP-19 (reclaim-vs-claim race → - AlreadyConsumed) in `TEST_PLAN.md` §4.10. diff --git a/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md b/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md deleted file mode 100644 index 19a0ce0310c..00000000000 --- a/docs/dashpay/INVITATIONS_REVIEW_FIX_SPEC.md +++ /dev/null @@ -1,385 +0,0 @@ -# Invitations #4041 — Review-Fix Spec - -Addresses the 12 actionable findings surfaced on PR #4041 after the legacy-compat -codec rework. Grouped by the fix's nature. Four other threads are **stale** (they -target code the rework removed) and get a reply-and-resolve, not a code change. - -Guiding constraint (Ivan, prior sync): we are **not** doing the "auto-persist on -every key-wallet mutation" redesign. Every fix below is a *targeted* change to the -existing explicit-persist path — verified tractable because the FFI persistence -machinery already propagates a per-callback failure end-to-end (see §0). - -## 0. The persistence failure-signal chain (already exists — the key enabler) - -`ManagedPersister::store()` (`rs-platform-wallet-ffi/src/persistence.rs`) runs one -callback per changeset-kind. For each it checks the returned `i32`: - -``` -let result = unsafe { cb(ctx, wallet_id, …) }; -if result != 0 { eprintln!(…); round_success = false; } -``` - -and at the end: - -``` -if !round_success { - return Err(PersistenceError::backend("one or more persistence callbacks failed; changeset was rolled back")); -} -``` - -So a **nonzero Swift callback return** already becomes `store() -> Err`, which: -- **B2**: makes `persist_asset_lock_account_pools()` return `Err`, so the pre-broadcast - gate at `build.rs:415` aborts (for `IdentityInvitation`) *before* the tx hits the wire. -- **B1**: makes the invitation-persist round return `Err` — but the current - `create_invitation` **swallows that `Err`** (best-effort `warn`, invitation.rs:291-299), - so the Swift-callback fix alone does not surface the failure. See B1↔B3 coupling below. - -`onQueue` is `serialQueue.sync` (synchronous), so the callback body's skip/guard runs -*before* the callback returns — the return value can faithfully reflect it. The whole -round is atomic: `endChangeset` does one `backgroundContext.save()` (commit) or -`rollback()`; a nonzero callback rolls back **everything** staged in the round, and -`store()` returns `Err` *before* the pending-merge, so the Rust caller re-emits the same -changeset next round — nothing is lost (verified: this is the existing contract for -identities/txs/asset-locks). - -**Scoping caveat (must-fix from review):** `on_persist_account_address_pools_fn` is the -*same* callback used for ordinary BIP44 address sync (persistence.rs:824/865), invoked up -to 3× per round. Making it strict for *everything* would turn a benign transient -`fetchAccount` miss during ordinary sync — especially a first-registration account staged -but not yet committed in the same round — into a **permanent persistence wedge**. So B2's -strictness is **scoped to the `IdentityInvitation` account type only** (see B2). Those -pools never flow through ordinary sync (the OP_RETURN credit outputs are never SPV-visible), -and the `identity_invitation` account is registered at wallet-setup (build.rs:200-216 -requires it to exist), so a miss there is a genuine anomaly — safe to fail on. - ---- - -## B1 — Invitation persist failure reported as success 🔴 - -**File:** `PlatformWalletPersistenceHandler.swift` — `persistInvitations` (226) + -`persistInvitationsCallback`. - -**Now:** on a `fetch` failure `persistInvitations` logs and `continue`s (skips the -upsert); the callback returns `0`. A funded invitation then never reaches SwiftData -(the sole UI source, no rehydrate) yet `create_invitation` reported success → the -voucher is invisible. - -**Fix (Swift half):** -1. `persistInvitations` returns `Bool` (accumulate `allPersisted`): `false` if any upsert - was skipped due to a fetch error, or any removal fetch threw. -2. `persistInvitationsCallback` returns `1` when `persistInvitations` reports a skip, - `0` otherwise → `round_success = false` → `store()` `Err`. (The invitation round is - invitation-only — `store(PlatformWalletChangeSet { invitations: Some(cs), ..default })` - — so this rollback only ever discards an invitation round; low cost.) - -**Fix (Rust half — REQUIRED, this is the coupling with B3):** the Swift half alone only -rolls the round back; `create_invitation` currently **swallows** the `store()` `Err` -(invitation.rs:291-299, best-effort `warn`). The user-visible symptom ("create reports -success while the voucher is invisible") is only fixed when the **B3 reorder makes the -invitation persist propagate** — i.e. B1 and B3 are one change: the reordered persist -(B3) returns its `Err` from `create_invitation`, and the Swift callback (B1) makes that -persist actually fail when the row can't be written. Neither half is sufficient alone. - -**Effect:** a transient failure rolls the round back (nothing half-committed) AND surfaces -to the caller (via B3) instead of silently dropping a funded invite. Existing `print` -telemetry stays. Removal-path fetch failure (line 288) is best-effort cleanup → include -it in the `allPersisted` flag for symmetry (a nonzero return just retries next round). - ---- - -## B2 — Voucher-index durability gate defeated by the pool callback 🔴 - -**File:** `PlatformWalletPersistenceHandler.swift` — `persistAccountAddressPoolsCallback` -(~5960, always `return 0`) + `persistAccountAddresses` (2659, guard-returns on -`fetchAccount` miss, `try?` on write, `Void` return). - -**Now:** even when nothing is written (missing parent account, fetch/write failure), -the callback returns `0` → `round_success` stays `true` → `store()` `Ok` → -`persist_asset_lock_account_pools()` `Ok` → the `build.rs:415` gate passes → Rust -broadcasts. A restart can then reselect the same `IdentityInvitation` funding index -and **re-export the same bearer voucher key** — the exact class of bug the gate exists -to prevent (cf. commit 55937e15c1). - -**The real fallible point (review precision):** SwiftData `insert()`/property mutation -never throw — the only fallible steps are the `fetch()` calls, and the actual hole is the -**silent `guard let account = fetchAccount(...) else { return }`** at `:2665` (and the -platform-payment sub-path's own fetches at `:2760`), which stages *nothing* and so can't -be caught by `save()`. That guard-return is the thing to signal on. - -**Fix (scoped to `IdentityInvitation` — do NOT tighten the shared callback for all types):** -1. `persistAccountAddresses` returns `Bool` success. It returns `false` **only when the - failing pool's account type is `IdentityInvitation`** (the fetchAccount miss, and a - `do/catch` on the fetches). For every other account type it keeps today's tolerant - behavior (log + return `true`), so ordinary sync — including a first-registration - account staged-but-unsaved in the same round — is unaffected. -2. `persistAccountAddressPoolsCallback` **accumulates** `allOk` across the whole - `for i in 0.. Date: Tue, 14 Jul 2026 16:07:12 +0700 Subject: [PATCH 68/74] refactor(rs-sdk-ffi): source export-gate path constants from key_wallet::dip9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/mnemonic_resolver_core_signer.rs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index 124e3095eec..157af2775e0 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -68,11 +68,21 @@ use std::os::raw::c_char; use async_trait::async_trait; use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::secp256k1::{self, Secp256k1}; +use key_wallet::dip9::{ + FEATURE_PURPOSE, FEATURE_PURPOSE_IDENTITIES, FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_INVITATIONS, +}; use key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; use key_wallet::Network; use thiserror::Error; use zeroize::Zeroizing; +/// DIP-15 auto-accept feature index (`m/9'/coin'/16'/expiry'`). Not yet a +/// `key_wallet::dip9` constant (unlike the DIP-9/DIP-13 features used below) — +/// it mirrors `DASHPAY_AUTO_ACCEPT_FEATURE` in `rs-platform-wallet`'s +/// `crypto/auto_accept.rs`, which this crate cannot depend on. Upstreaming a +/// shared constant into `key_wallet::dip9` is the proper long-term home. +const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; + use crate::mnemonic_resolver::{ mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, }; @@ -354,9 +364,9 @@ impl MnemonicResolverCoreSigner { &self, path: &DerivationPath, ) -> Result, MnemonicResolverSignerError> { - let purpose9 = ChildNumber::from_hardened_idx(9) + let purpose9 = ChildNumber::from_hardened_idx(FEATURE_PURPOSE) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; - let feature16 = ChildNumber::from_hardened_idx(16) + let feature16 = ChildNumber::from_hardened_idx(DASHPAY_AUTO_ACCEPT_FEATURE) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; let comps: &[ChildNumber] = path.as_ref(); if comps.len() != 4 || comps[0] != purpose9 || comps[2] != feature16 { @@ -385,12 +395,13 @@ impl MnemonicResolverCoreSigner { &self, path: &DerivationPath, ) -> Result, MnemonicResolverSignerError> { - let purpose9 = ChildNumber::from_hardened_idx(9) - .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; - let feature5 = ChildNumber::from_hardened_idx(5) + let purpose9 = ChildNumber::from_hardened_idx(FEATURE_PURPOSE) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; - let subfeature3 = ChildNumber::from_hardened_idx(3) + let feature5 = ChildNumber::from_hardened_idx(FEATURE_PURPOSE_IDENTITIES) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; + let subfeature3 = + ChildNumber::from_hardened_idx(FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_INVITATIONS) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; let comps: &[ChildNumber] = path.as_ref(); // Bind the fixed purpose / feature / sub-feature only. `coin_type` // (comps[1]) and `funding_index` (comps[4]) are deliberately left From 9913db925371dbd7917211850e1e811952fab4b3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 17:05:20 +0700 Subject: [PATCH 69/74] =?UTF-8?q?fix(platform-wallet,swift-example-app):?= =?UTF-8?q?=20round-4=20review=20=E2=80=94=20retry-loop=20seam;=20clear=20?= =?UTF-8?q?stale=20reclaim=20marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/wallet/identity/network/invitation.rs | 238 +++++++++++++++--- .../AI_QA/QA004_invitation_reclaim.md | 2 +- .../DashPay/ReclaimInvitationSheet.swift | 37 ++- .../ReclaimInvitationClassifierTests.swift | 67 +++++ 4 files changed, 303 insertions(+), 41 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index 568697b761a..171399056f3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -529,43 +529,18 @@ impl IdentityWallet { &self, invitation: &ParsedInvitation, ) -> Result { - // Fetch the funding tx. Two independent concerns are layered here: - // 1. Byte order — old iOS links carry the txid little-endian, so a - // canonical miss is retried byte-reversed (a compatibility fallback, - // not a temporal retry). A transient transport error is NOT masked as - // a miss — it propagates immediately (it is not a "not indexed yet" - // signal, so retrying it would only hide it). - // 2. Propagation lag — InstantSend/ChainLock finality does not guarantee - // the invitee's DAPI node has indexed the tx yet, so a freshly shared - // invitation can miss on both byte orders purely from propagation - // delay. Retry a bounded number of times with a fixed backoff before - // giving up. - let mut fetched = None; - for attempt in 0..CLAIM_FETCH_MAX_ATTEMPTS { - let hit = match self - .sdk - .get_transaction(&invitation.funding_txid) - .await - .map_err(PlatformWalletError::Sdk)? - { - Some(tx) => Some(tx), - None => { - let reversed = reverse_txid_hex(&invitation.funding_txid)?; - self.sdk - .get_transaction(&reversed) - .await - .map_err(PlatformWalletError::Sdk)? - } - }; - if let Some(tx) = hit { - fetched = Some(tx); - break; - } - if attempt + 1 < CLAIM_FETCH_MAX_ATTEMPTS { - tokio::time::sleep(CLAIM_FETCH_RETRY_DELAY).await; - } - } - let fetched = fetched.ok_or_else(|| { + let sdk = &self.sdk; + let fetched = fetch_funding_tx_with_retry( + &invitation.funding_txid, + |txid| async move { + sdk.get_transaction(&txid) + .await + .map_err(PlatformWalletError::Sdk) + }, + || tokio::time::sleep(CLAIM_FETCH_RETRY_DELAY), + ) + .await? + .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( "invitation funding transaction not found (tried both byte orders across \ repeated attempts); it may not have propagated to the queried DAPI node yet — \ @@ -582,6 +557,53 @@ impl IdentityWallet { } } +/// Fetch the claim's funding transaction with the bounded propagation retry — +/// the injectable orchestration seam of `reconstruct_asset_lock_proof` (tests +/// script `fetch`/`delay`; production passes `Sdk::get_transaction` and a +/// `tokio::time::sleep`). +/// +/// Two independent concerns are layered here: +/// 1. Byte order — old iOS links carry the txid little-endian, so a +/// canonical miss is retried byte-reversed **within the same attempt** +/// (a compatibility fallback, not a temporal retry). A transport error is +/// NOT masked as a miss — it propagates immediately (it is not a "not +/// indexed yet" signal, so retrying it would only hide it). +/// 2. Propagation lag — InstantSend/ChainLock finality does not guarantee +/// the queried DAPI node has indexed the tx yet, so a fresh invitation +/// can miss on both byte orders purely from propagation delay. Retry up +/// to [`CLAIM_FETCH_MAX_ATTEMPTS`] attempts with one `delay` between +/// attempts — and none after the last (the caller surfaces the miss +/// immediately). +/// +/// Returns `Ok(None)` only after every attempt missed on both byte orders. +async fn fetch_funding_tx_with_retry( + funding_txid: &str, + mut fetch: F, + mut delay: D, +) -> Result, PlatformWalletError> +where + F: FnMut(String) -> FFut, + FFut: std::future::Future, PlatformWalletError>>, + D: FnMut() -> DFut, + DFut: std::future::Future, +{ + for attempt in 0..CLAIM_FETCH_MAX_ATTEMPTS { + if let Some(tx) = fetch(funding_txid.to_string()).await? { + return Ok(Some(tx)); + } + // Reversed lookup is computed lazily on a canonical miss, so a hit + // never depends on the txid being reversible hex. + let reversed = reverse_txid_hex(funding_txid)?; + if let Some(tx) = fetch(reversed).await? { + return Ok(Some(tx)); + } + if attempt + 1 < CLAIM_FETCH_MAX_ATTEMPTS { + delay().await; + } + } + Ok(None) +} + /// Assemble the asset-lock proof from an already-fetched funding transaction — the /// pure, testable core of the claim reconstruction (the fetch/retry lives in /// `reconstruct_asset_lock_proof`). Validates the tx is the funding tx (either byte @@ -804,6 +826,148 @@ mod tests { assert!(format!("{err}").contains("does not lock the funding transaction")); } + // --- fetch_funding_tx_with_retry: the claim propagation-retry seam --- + + mod fetch_retry { + use std::collections::VecDeque; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::{Arc, Mutex}; + + use super::super::{ + fetch_funding_tx_with_retry, reverse_txid_hex, CLAIM_FETCH_MAX_ATTEMPTS, + }; + use crate::PlatformWalletError; + + /// A canonical 64-hex txid whose byte-reversed form differs from it. + fn canonical_txid() -> String { + format!("{}{}", "11".repeat(31), "22") + } + + /// Scripted fetch: pops the next result per call and records the txid + /// each call asked for; plus a counter-only delay. + #[allow(clippy::type_complexity)] + fn harness( + script: Vec, PlatformWalletError>>, + ) -> ( + Arc, PlatformWalletError>>>>, + Arc>>, + Arc, + ) { + ( + Arc::new(Mutex::new(script.into_iter().collect())), + Arc::new(Mutex::new(Vec::new())), + Arc::new(AtomicU32::new(0)), + ) + } + + async fn run( + script: Vec, PlatformWalletError>>, + ) -> (Result, PlatformWalletError>, Vec, u32) { + let canonical = canonical_txid(); + let (queue, calls, delays) = harness(script); + let (queue_f, calls_f, delays_f) = + (Arc::clone(&queue), Arc::clone(&calls), Arc::clone(&delays)); + let result = fetch_funding_tx_with_retry( + &canonical, + move |txid| { + let queue = Arc::clone(&queue_f); + let calls = Arc::clone(&calls_f); + async move { + calls.lock().expect("calls").push(txid); + queue + .lock() + .expect("script") + .pop_front() + .expect("script exhausted — the loop fetched more than scripted") + } + }, + move || { + let delays = Arc::clone(&delays_f); + async move { + delays.fetch_add(1, Ordering::SeqCst); + } + }, + ) + .await; + let recorded = calls.lock().expect("calls").clone(); + (result, recorded, delays.load(Ordering::SeqCst)) + } + + /// A first-call canonical hit returns immediately: one lookup, no + /// reversed fallback, no delay. + #[tokio::test] + async fn canonical_hit_returns_immediately() { + let (result, calls, delays) = run(vec![Ok(Some(7))]).await; + assert_eq!(result.expect("no error"), Some(7)); + assert_eq!(calls, vec![canonical_txid()]); + assert_eq!(delays, 0); + } + + /// A canonical miss falls back to the byte-reversed lookup WITHIN the + /// same attempt (the legacy-iOS little-endian compatibility path) — no + /// delay is spent on the byte-order fallback. + #[tokio::test] + async fn reversed_hit_within_same_attempt_no_delay() { + let (result, calls, delays) = run(vec![Ok(None), Ok(Some(9))]).await; + assert_eq!(result.expect("no error"), Some(9)); + assert_eq!( + calls, + vec![ + canonical_txid(), + reverse_txid_hex(&canonical_txid()).expect("reversible") + ] + ); + assert_eq!(delays, 0); + } + + /// A hit on a later attempt (propagation lag) succeeds after exactly + /// one delay per fully-missed attempt. + #[tokio::test] + async fn hit_after_missed_attempts_counts_one_delay_per_miss() { + // Attempts 1 and 2 miss on both orders; attempt 3 hits canonically. + let (result, calls, delays) = + run(vec![Ok(None), Ok(None), Ok(None), Ok(None), Ok(Some(3))]).await; + assert_eq!(result.expect("no error"), Some(3)); + assert_eq!(calls.len(), 5); + assert_eq!(delays, 2); + } + + /// Exhausting every attempt returns Ok(None) — a miss, not an error — + /// after 2 lookups per attempt, with NO delay after the final attempt. + #[tokio::test] + async fn exhausts_attempts_without_trailing_delay() { + let script = (0..CLAIM_FETCH_MAX_ATTEMPTS * 2) + .map(|_| Ok(None)) + .collect(); + let (result, calls, delays) = run(script).await; + assert_eq!(result.expect("misses are not errors"), None); + assert_eq!(calls.len(), (CLAIM_FETCH_MAX_ATTEMPTS * 2) as usize); + assert_eq!(delays, CLAIM_FETCH_MAX_ATTEMPTS - 1); + } + + /// A transport error is NOT masked as a miss: it propagates + /// immediately, with no further lookups and no extra delay. + #[tokio::test] + async fn transport_error_propagates_immediately() { + // Attempt 1 misses both orders (1 delay); attempt 2's canonical + // lookup errors. + let (result, calls, delays) = run(vec![ + Ok(None), + Ok(None), + Err(PlatformWalletError::InvalidIdentityData( + "simulated transport failure".to_string(), + )), + ]) + .await; + assert!( + matches!(result, Err(PlatformWalletError::InvalidIdentityData(_))), + "transport error must propagate, got {result:?}" + ); + assert_eq!(calls.len(), 3, "no lookup may follow the error"); + assert_eq!(delays, 1); + } + } + // --- create_invitation: the durable-persistence precondition --- mod durability_gate { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md index bce351ca6b3..b62b72b43eb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md @@ -18,7 +18,7 @@ 5. **Reclaim → register (new identity).** Repeat steps 1–3 with a **second** invitation, but in the sheet switch the target to **New identity** (right segment of `dashpay.invite.reclaim.target`) — the identity picker is replaced by "A brand-new identity funded by this voucher." Submit. After it confirms, confirm a new funded identity appears in the Identities tab and the row's `ZSTATUSRAW` is `2` (Reclaimed). Screenshot `AI_QA/output/QA004_reclaim_register.png`. 6. **Already-consumed handling.** Take a row whose voucher is already consumed on-chain and reclaim it again (tap `dashpay.invitations.reclaim` → submit; `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_already_consumed.json`). Two single-device variants — pick by which classifier arm you want to exercise (force-quit the app, edit via `sqlite3` while terminated, relaunch): - **Crash-interrupted self-reclaim** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=1` on a reclaimed row): the retry hits the wallet's LOCAL "…is not tracked" resume guard (this wallet consumed the lock itself, so the row is locally `Consumed`); with the marker set it recovers as **Reclaimed** (`ZSTATUSRAW=2`, marker cleared) with "This invitation was already reclaimed." - - **Marker-unset reset** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=0`): the same local "not tracked" failure with no marker deliberately classifies as an **error** — the row stays `Created` and a raw message is shown. This is the false-positive guard (an unrelated local failure must never flip the row), not a bug. + - **Marker-unset reset** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=0`): the same local "not tracked" failure with no marker deliberately classifies as an **error** — the row stays `Created` and a raw message is shown; the marker this attempt set for itself is **cleared again** (`ZRECLAIMINFLIGHT` back to `0`), so an identical retry repeats the error rather than misreading its own stale marker as a completed self-reclaim. This is the false-positive guard (an unrelated local failure must never flip the row), not a bug. - The **neutral "This invitation was already claimed."** (→ `ZSTATUSRAW=1`) path requires the consume to actually reach Platform and be rejected with 10504, i.e. a voucher claimed by a *different* wallet while this one still tracks the lock — the two-device DP-19 race, not reproducible by local sqlite edits. 7. **Minimum-amount guard.** Open `CreateInvitationSheet` (paperplane → **+** `dashpay.invitations.create`), set the amount below the floor (e.g. `0.001`), and confirm the create button is disabled with the sub-minimum hint. Screenshot `AI_QA/output/QA004_min_guard.png`. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index e894587aa7c..1ec38c40dfc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -271,9 +271,23 @@ struct ReclaimInvitationSheet: View { try? modelContext.save() infoMessage = "This invitation was already claimed." case .error: - // Uncertain outcome: leave the in-flight marker as-is so a later - // retry that hits "already consumed" classifies it as our own - // reclaim rather than a foreign claim. + if Self.shouldClearInFlightMarker( + error: error, + hadPriorReclaimInFlight: hadPriorReclaimInFlight + ) { + // This attempt set the marker itself and then failed the + // LOCAL pre-broadcast resume guard — the consume never + // started, so the marker is demonstrably stale. Clear it, + // or an identical retry would capture it as "prior in + // flight" and misreport the same local failure as a + // completed self-reclaim. + invitation.reclaimInFlight = false + try? modelContext.save() + } + // Otherwise leave the marker as-is: with a prior in-flight + // (or any error that may have reached the network) a later + // retry that hits "already consumed" must still classify as + // our own reclaim rather than a foreign claim. errorMessage = error.localizedDescription } } @@ -342,6 +356,23 @@ struct ReclaimInvitationSheet: View { return .error } + /// Whether the `.error` outcome should also CLEAR the persisted + /// `reclaimInFlight` marker. True only when this attempt set the marker + /// itself (`hadPriorReclaimInFlight == false`) and then failed the LOCAL + /// pre-broadcast "is not tracked" resume guard — proof the consume never + /// started, so the freshly-set marker is stale. Without clearing it, an + /// identical retry captures the marker as a prior in-flight and + /// misclassifies the same purely-local failure as a completed self-reclaim + /// (two-attempt false positive). Every other error keeps the marker: a + /// failure that may have reached the network must stay disambiguable as + /// our own reclaim on retry. + nonisolated static func shouldClearInFlightMarker( + error: Error, + hadPriorReclaimInFlight: Bool + ) -> Bool { + !hadPriorReclaimInFlight && isLockNoLongerTracked(error) + } + /// Whether an error is the wallet's LOCAL "asset lock is no longer tracked" /// resume-guard failure (`rs-platform-wallet` `resume_asset_lock`, Display /// "Asset lock … is not tracked"). Distinct from the network's already-consumed diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift index 19377c72ed0..559f11e8c2d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift @@ -134,4 +134,71 @@ final class ReclaimInvitationClassifierTests: XCTestCase { .error ) } + + // MARK: - Stale-marker clearing (shouldClearInFlightMarker) + + /// A first attempt that set the marker itself and then failed the LOCAL + /// pre-broadcast "not tracked" guard must clear the marker: the consume + /// never started, so the marker is demonstrably stale. + func test_shouldClear_lockNotTracked_noPrior_isTrue() { + XCTAssertTrue( + ReclaimInvitationSheet.shouldClearInFlightMarker( + error: Self.lockNotTracked, hadPriorReclaimInFlight: false) + ) + } + + /// With a PRIOR in-flight marker the same error is the crash-recovery + /// signal (classified `.reclaimed` above) — never a clear. + func test_shouldClear_lockNotTracked_prior_isFalse() { + XCTAssertFalse( + ReclaimInvitationSheet.shouldClearInFlightMarker( + error: Self.lockNotTracked, hadPriorReclaimInFlight: true) + ) + } + + /// Errors that may have reached the network keep the marker regardless — + /// a later "already consumed" must stay disambiguable as our own reclaim. + func test_shouldClear_networkishErrors_isFalse() { + let transport = StubError(message: "SDK error: Transport error: connection refused") + XCTAssertFalse( + ReclaimInvitationSheet.shouldClearInFlightMarker( + error: transport, hadPriorReclaimInFlight: false) + ) + XCTAssertFalse( + ReclaimInvitationSheet.shouldClearInFlightMarker( + error: Self.alreadyConsumed, hadPriorReclaimInFlight: false) + ) + } + + /// The two-attempt regression the clearing exists for: attempt 1 sets the + /// marker and fails locally ("not tracked", no prior) → `.error` + clear; + /// an identical retry therefore STILL sees no prior marker and stays + /// `.error` — it can never escalate a purely-local failure to `.reclaimed`. + /// (Without the clear, the retry would capture `hadPriorReclaimInFlight == + /// true` and misreport a successful self-reclaim.) + func test_twoAttempt_localFailure_neverBecomesReclaimed() { + // Attempt 1: marker freshly set by this attempt (prior == false). + var persistedMarker = false // durable value BEFORE the attempt + let attempt1Prior = persistedMarker + persistedMarker = true // markInFlight() + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.lockNotTracked, hadPriorReclaimInFlight: attempt1Prior), + .error + ) + if ReclaimInvitationSheet.shouldClearInFlightMarker( + error: Self.lockNotTracked, hadPriorReclaimInFlight: attempt1Prior + ) { + persistedMarker = false // the fix under test + } + + // Attempt 2 (identical retry): captures the durable marker as prior. + let attempt2Prior = persistedMarker + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.lockNotTracked, hadPriorReclaimInFlight: attempt2Prior), + .error, + "a repeated purely-local failure must stay an error, not become Reclaimed" + ) + } } From 0a0b3e929eedc7777ce85423194e5468f69dad13 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 17:43:14 +0700 Subject: [PATCH 70/74] build: bump rust-dashcore to ac8828c9; use the new dip9 DIP-15 constants 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 --- Cargo.lock | 52 +++++++++---------- Cargo.toml | 16 +++--- .../src/core_wallet_types.rs | 5 +- packages/rs-platform-wallet/src/spv/peers.rs | 2 +- .../src/wallet/identity/crypto/auto_accept.rs | 14 ++--- .../wallet/identity/crypto/contact_info.rs | 8 +-- .../src/mnemonic_resolver_core_signer.rs | 37 ++++++++----- 7 files changed, 74 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 747deebd3cd..27055885742 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1206,7 +1206,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "bincode", "dashcore-private", @@ -2428,7 +2428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2489,7 +2489,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" [[package]] name = "glob" @@ -3552,7 +3552,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3803,7 +3803,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b#337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ac8828c9ed025f57d9c9ca7c5356cfd50848e833#ac8828c9ed025f57d9c9ca7c5356cfd50848e833" dependencies = [ "async-trait", "bincode", @@ -4596,7 +4596,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5658,7 +5658,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5696,9 +5696,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6489,7 +6489,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6502,7 +6502,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6561,7 +6561,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7421,7 +7421,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8870,7 +8870,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d11cdaca5e3..ca7e4399d07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "337f6ccc7f45a5f7a50abf0d6fecb1ef0b786e7b" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "ac8828c9ed025f57d9c9ca7c5356cfd50848e833" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "ac8828c9ed025f57d9c9ca7c5356cfd50848e833" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "ac8828c9ed025f57d9c9ca7c5356cfd50848e833" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "ac8828c9ed025f57d9c9ca7c5356cfd50848e833" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "ac8828c9ed025f57d9c9ca7c5356cfd50848e833" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "ac8828c9ed025f57d9c9ca7c5356cfd50848e833" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "ac8828c9ed025f57d9c9ca7c5356cfd50848e833" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "ac8828c9ed025f57d9c9ca7c5356cfd50848e833" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 02682ef3c2f..d3ca45f073b 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -1187,10 +1187,11 @@ where agg.service_address = Some(provider_ip_port(p.ip_address, p.port)); agg.service_height = height; } - // ProUpServ's `platform_node_id` is a raw `Option<[u8; 20]>`. + // ProUpServ's `platform_node_id` is an `Option` + // (a 20-byte newtype); the aggregate stores the raw bytes. if let Some(node_id) = p.platform_node_id { if agg.platform_node_id.is_none() || height >= agg.platform_node_height { - agg.platform_node_id = Some(node_id); + agg.platform_node_id = Some(node_id.to_byte_array()); agg.platform_node_height = height; } } diff --git a/packages/rs-platform-wallet/src/spv/peers.rs b/packages/rs-platform-wallet/src/spv/peers.rs index 9f79ffc1dd9..cf5c6a939a0 100644 --- a/packages/rs-platform-wallet/src/spv/peers.rs +++ b/packages/rs-platform-wallet/src/spv/peers.rs @@ -187,7 +187,7 @@ mod tests { evo, EntryMasternodeType::HighPerformance { platform_http_port: 443, - platform_node_id: PubkeyHash::from_byte_array([0u8; 20]), + platform_node_id: dashcore::PlatformNodeId::from_byte_array([0u8; 20]), }, ), ]); diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs index 6585ba0bb9f..ed20b65f11e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs @@ -28,14 +28,14 @@ use dashcore::hashes::{sha256, Hash, HashEngine}; use dashcore::secp256k1::{ecdsa::Signature, Message, Secp256k1, SecretKey}; use dpp::prelude::Identifier; use key_wallet::bip32::{ChildNumber, DerivationPath}; +use key_wallet::dip9::{ + DASH_COIN_TYPE, DASH_TESTNET_COIN_TYPE, FEATURE_PURPOSE, FEATURE_PURPOSE_DASHPAY_AUTO_ACCEPT, +}; use key_wallet::wallet::Wallet; use key_wallet::Network; use crate::error::PlatformWalletError; -/// DashPay auto-accept feature index per DIP-15. -const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; - /// Default lifetime of a generated auto-accept QR, in seconds (1 hour). /// /// DIP-15 mandates only that the proof's 4-byte timestamp *is* an expiry (and the @@ -87,13 +87,13 @@ pub fn auto_accept_derivation_path( expiry: u32, ) -> Result { let coin_type: u32 = match network { - Network::Mainnet => 5, - _ => 1, + Network::Mainnet => DASH_COIN_TYPE, + _ => DASH_TESTNET_COIN_TYPE, }; Ok(DerivationPath::from(vec![ - ChildNumber::from_hardened_idx(9).expect("valid"), + ChildNumber::from_hardened_idx(FEATURE_PURPOSE).expect("valid"), ChildNumber::from_hardened_idx(coin_type).expect("valid"), - ChildNumber::from_hardened_idx(DASHPAY_AUTO_ACCEPT_FEATURE).expect("valid"), + ChildNumber::from_hardened_idx(FEATURE_PURPOSE_DASHPAY_AUTO_ACCEPT).expect("valid"), ChildNumber::from_hardened_idx(expiry).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!("Invalid expiry index: {}", e)) })?, diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs index b70b385ad68..63b07a87f11 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs @@ -41,11 +41,13 @@ use crate::wallet::identity::network::identity_auth_derivation_path_for_type; /// DIP-15 child index for the `encToUserId` encryption key (2^16 — /// "to discount other potential derivations of this key in other -/// applications"). -pub const ENC_TO_USER_ID_CHILD: u32 = 1 << 16; +/// applications"). Canonical value lives in `key_wallet::dip9`; re-exposed +/// under the local name this module's callers use. +pub const ENC_TO_USER_ID_CHILD: u32 = key_wallet::dip9::DASHPAY_CONTACT_INFO_ENC_TO_USER_ID_CHILD; /// DIP-15 child index for the `privateData` encryption key (2^16 + 1). -pub const PRIVATE_DATA_CHILD: u32 = (1 << 16) + 1; +/// Canonical value lives in `key_wallet::dip9`. +pub const PRIVATE_DATA_CHILD: u32 = key_wallet::dip9::DASHPAY_CONTACT_INFO_PRIVATE_DATA_CHILD; /// The deployed schema's `privateData` minimum length (bytes, IV included). const PRIVATE_DATA_MIN_LEN: usize = 48; diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index 157af2775e0..14eeb131476 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -69,20 +69,15 @@ use async_trait::async_trait; use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::secp256k1::{self, Secp256k1}; use key_wallet::dip9::{ - FEATURE_PURPOSE, FEATURE_PURPOSE_IDENTITIES, FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_INVITATIONS, + DASHPAY_CONTACT_INFO_ENC_TO_USER_ID_CHILD, DASHPAY_CONTACT_INFO_PRIVATE_DATA_CHILD, + FEATURE_PURPOSE, FEATURE_PURPOSE_DASHPAY_AUTO_ACCEPT, FEATURE_PURPOSE_IDENTITIES, + FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_INVITATIONS, }; use key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; use key_wallet::Network; use thiserror::Error; use zeroize::Zeroizing; -/// DIP-15 auto-accept feature index (`m/9'/coin'/16'/expiry'`). Not yet a -/// `key_wallet::dip9` constant (unlike the DIP-9/DIP-13 features used below) — -/// it mirrors `DASHPAY_AUTO_ACCEPT_FEATURE` in `rs-platform-wallet`'s -/// `crypto/auto_accept.rs`, which this crate cannot depend on. Upstreaming a -/// shared constant into `key_wallet::dip9` is the proper long-term home. -const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; - use crate::mnemonic_resolver::{ mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, }; @@ -366,7 +361,7 @@ impl MnemonicResolverCoreSigner { ) -> Result, MnemonicResolverSignerError> { let purpose9 = ChildNumber::from_hardened_idx(FEATURE_PURPOSE) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; - let feature16 = ChildNumber::from_hardened_idx(DASHPAY_AUTO_ACCEPT_FEATURE) + let feature16 = ChildNumber::from_hardened_idx(FEATURE_PURPOSE_DASHPAY_AUTO_ACCEPT) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; let comps: &[ChildNumber] = path.as_ref(); if comps.len() != 4 || comps[0] != purpose9 || comps[2] != feature16 { @@ -528,8 +523,16 @@ impl MnemonicResolverCoreSigner { private_data_plaintext: &[u8], private_data_iv: &[u8; 16], ) -> Result { - let enc_key = self.derive_contact_info_aes_key(root_path, 65536, derivation_index)?; - let priv_key = self.derive_contact_info_aes_key(root_path, 65537, derivation_index)?; + let enc_key = self.derive_contact_info_aes_key( + root_path, + DASHPAY_CONTACT_INFO_ENC_TO_USER_ID_CHILD, + derivation_index, + )?; + let priv_key = self.derive_contact_info_aes_key( + root_path, + DASHPAY_CONTACT_INFO_PRIVATE_DATA_CHILD, + derivation_index, + )?; Ok(ContactInfoSealed { enc_to_user_id: platform_encryption::encrypt_enc_to_user_id(&enc_key, contact_id), private_data: platform_encryption::encrypt_private_data( @@ -549,8 +552,16 @@ impl MnemonicResolverCoreSigner { enc_to_user_id: &[u8; 32], private_data_blob: &[u8], ) -> Result { - let enc_key = self.derive_contact_info_aes_key(root_path, 65536, derivation_index)?; - let priv_key = self.derive_contact_info_aes_key(root_path, 65537, derivation_index)?; + let enc_key = self.derive_contact_info_aes_key( + root_path, + DASHPAY_CONTACT_INFO_ENC_TO_USER_ID_CHILD, + derivation_index, + )?; + let priv_key = self.derive_contact_info_aes_key( + root_path, + DASHPAY_CONTACT_INFO_PRIVATE_DATA_CHILD, + derivation_index, + )?; let private_data = platform_encryption::decrypt_private_data(&priv_key, private_data_blob) .map_err(|e| { MnemonicResolverSignerError::DerivationFailed(format!("contactInfo decrypt: {e}")) From f1af0c9bdf42d7caf71b166fe53604c5c76661f1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 19:28:01 +0700 Subject: [PATCH 71/74] =?UTF-8?q?fix(platform-wallet):=20round-5=20review?= =?UTF-8?q?=20=E2=80=94=20fail-closed=20durability;=20non-breaking=20provi?= =?UTF-8?q?der=20trait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rs-platform-wallet-ffi/src/invitation.rs | 12 +-- .../rs-platform-wallet-ffi/src/persistence.rs | 84 +++++++++++++++++++ .../src/changeset/traits.rs | 30 ++++--- .../identity/network/contact_requests.rs | 15 +++- .../src/wallet/persister.rs | 35 +++++++- .../ManagedPlatformWallet.swift | 9 +- 6 files changed, 160 insertions(+), 25 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 8f60af2b26c..d6632cc5008 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -213,14 +213,14 @@ pub unsafe extern "C" fn platform_wallet_create_invitation( /// by the imported voucher carried in `uri`. /// /// `uri` is the `dashpay://invite?…` link; it is parsed into a -/// `ParsedInvitation` and validated (fail-fast on a stale / wrong-type / -/// mismatched link) before any network act. `identity_pubkeys` are the -/// invitee's own new-identity keys (derived from the invitee's seed), signed by +/// `ParsedInvitation` and validated (fail-fast on a wrong-type / mismatched +/// link) before any network act. `identity_pubkeys` are the invitee's own +/// new-identity keys (derived from the invitee's seed), signed by /// `signer_handle` (the Platform-side per-identity-key signer). The asset-lock's /// outer signature is produced from the imported raw voucher key, so **no -/// Core-side resolver signer is needed**. `now_unix` is the current unix time -/// used for the advisory-expiry check (passed in from Swift — the FFI can't read -/// the clock deterministically). +/// Core-side resolver signer is needed**. `now_unix` is retained for C ABI +/// compatibility but currently ignored: the legacy link carries no expiry +/// field, so claim has no time gate. /// /// The contact-bootstrap ("establish contact with the sender?") is **not** done /// here — the UI asks the invitee and, on confirm, calls the existing diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index c5da9a5f9b3..8a4d68e133f 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -646,6 +646,24 @@ impl FFIPersister { } impl PlatformWalletPersistence for FFIPersister { + /// Durable only when the host actually wired the persistence callbacks. + /// + /// Every per-kind block in [`Self::store`] is `if let Some(cb)` — with the + /// callbacks absent (e.g. a manager configured without a persistence + /// container), non-empty changesets are silently skipped while `store()` + /// still returns `Ok`, which is exactly the write-dropping shape the + /// fail-closed trait default exists to catch. Attest durability only when + /// the transaction bracket (begin/end) AND the callbacks the + /// bearer-key-sensitive invitation flow writes through (invitations + + /// account address pools) are all present; the Swift bridge wires all of + /// its callbacks together, so a partially-wired vtable stays non-durable. + fn persists_durably(&self) -> bool { + self.callbacks.on_changeset_begin_fn.is_some() + && self.callbacks.on_changeset_end_fn.is_some() + && self.callbacks.on_persist_invitations_fn.is_some() + && self.callbacks.on_persist_account_address_pools_fn.is_some() + } + fn store( &self, wallet_id: WalletId, @@ -4948,6 +4966,72 @@ mod tests { //! exercising the in-memory mutation against synthetic input. use super::*; + + // --- persists_durably: the fail-closed durability attestation --- + + unsafe extern "C" fn noop_begin(_ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + 0 + } + unsafe extern "C" fn noop_end(_ctx: *mut c_void, _wallet_id: *const u8, _success: bool) -> i32 { + 0 + } + unsafe extern "C" fn noop_pools( + _ctx: *mut c_void, + _wallet_id: *const u8, + _pools: *const AccountAddressPoolFFI, + _count: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn noop_invitations( + _ctx: *mut c_void, + _wallet_id: *const u8, + _upserts_ptr: *const InvitationEntryFFI, + _upserts_count: usize, + _removed_ptr: *const [u8; 36], + _removed_count: usize, + ) -> i32 { + 0 + } + + /// A callback-free persister (the `configure(modelContainer: nil)` shape) + /// silently drops every write, so it must NOT attest durability — this is + /// the concrete fail-open hole the fail-closed default exists to catch: + /// an unpersisted invitation funding index re-exports the same bearer + /// voucher key after a restart. + #[test] + fn callback_free_persister_is_not_durable() { + let persister = FFIPersister::new(PersistenceCallbacks::default()); + assert!(!persister.persists_durably()); + } + + /// A partially-wired vtable (commit bracket present, invitation-critical + /// callbacks absent — or vice versa) stays non-durable. + #[test] + fn partially_wired_persister_is_not_durable() { + let mut cb = PersistenceCallbacks::default(); + cb.on_changeset_begin_fn = Some(noop_begin); + cb.on_changeset_end_fn = Some(noop_end); + assert!(!FFIPersister::new(cb).persists_durably()); + + let mut cb = PersistenceCallbacks::default(); + cb.on_persist_invitations_fn = Some(noop_invitations); + cb.on_persist_account_address_pools_fn = Some(noop_pools); + assert!(!FFIPersister::new(cb).persists_durably()); + } + + /// With the transaction bracket + the invitation-critical callbacks all + /// wired (the shape the Swift bridge always produces), the persister + /// attests durability. + #[test] + fn fully_wired_persister_attests_durability() { + let mut cb = PersistenceCallbacks::default(); + cb.on_changeset_begin_fn = Some(noop_begin); + cb.on_changeset_end_fn = Some(noop_end); + cb.on_persist_account_address_pools_fn = Some(noop_pools); + cb.on_persist_invitations_fn = Some(noop_invitations); + assert!(FFIPersister::new(cb).persists_durably()); + } use dashcore::blockdata::transaction::txin::TxIn; use dashcore::blockdata::transaction::txout::TxOut; use dashcore::blockdata::transaction::Transaction; diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 20e6571915e..13f457d17cb 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -191,20 +191,26 @@ pub trait PlatformWalletPersistence: Send + Sync { /// Whether stored state survives a process restart once `store` + `flush` /// return `Ok`. /// - /// Defaults to `true` — the contract every real backend (SQLite, the FFI - /// SwiftData bridge) meets. Backends that only buffer in memory or drop - /// writes (e.g. [`NoPlatformPersistence`](crate::wallet::persister::NoPlatformPersistence)) - /// MUST override this to return `false`. + /// **Fail-closed: defaults to `false`.** This capability gates + /// security-sensitive flows, so a backend must explicitly ATTEST + /// durability by overriding this to `true` — an implementation that + /// forgets is refused those flows with a clear error instead of being + /// silently trusted. Backends that genuinely write through on + /// `store`/`flush` (the SQLite persister, a fully-wired FFI bridge) + /// override to `true`; buffer-only or write-dropping backends (e.g. + /// [`NoPlatformPersistence`](crate::wallet::persister::NoPlatformPersistence), + /// an FFI persister constructed without persistence callbacks) stay + /// `false`. /// - /// Security-sensitive flows gate on this. Creating a DashPay invitation - /// exports a one-time bearer voucher key derived from a persisted funding - /// index; on a backend that cannot guarantee the index survives a restart, - /// the same key could be re-derived and re-exported after a relaunch, - /// letting the holder of an earlier link consume a later voucher. Such - /// flows refuse to run on a non-durable backend rather than silently - /// producing a reusable bearer secret. + /// Why security-sensitive flows gate on this: creating a DashPay + /// invitation exports a one-time bearer voucher key derived from a + /// persisted funding index; on a backend that cannot guarantee the index + /// survives a restart, the same key could be re-derived and re-exported + /// after a relaunch, letting the holder of an earlier link consume a + /// later voucher. Such flows refuse a non-durable backend rather than + /// silently producing a reusable bearer secret. fn persists_durably(&self) -> bool { - true + false } /// Buffer a changeset for later persistence. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 7b34f220d6f..8074bd0a0e0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -74,10 +74,21 @@ pub trait ContactCryptoProvider { /// gates on the full shape (feature `5'` is shared with the user's own /// identity keys — see `export_invitation_private_key` on the resolver /// signer). The only caller is [`IdentityWallet::create_invitation`]. + /// + /// Defaulted to an "unsupported" error so adding this method is not a + /// source-breaking change for existing provider implementations: + /// providers that never create invitations need no override, and a + /// create attempted through one fails loudly (the invitation cannot be + /// packaged without the exported key) rather than at compile time. + /// Invitation-capable providers override with the path-gated export. async fn export_invitation_private_key( &self, - path: &key_wallet::bip32::DerivationPath, - ) -> Result; + _path: &key_wallet::bip32::DerivationPath, + ) -> Result { + Err(PlatformWalletError::InvalidIdentityData( + "invitation private-key export is not supported by this crypto provider".to_string(), + )) + } /// DIP-15 `accountReference` for a send: the scalar at `path` (the sender's /// encryption key) keys the HMAC+mask over `compact_xpub`. Computed in the diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 9e94e6bd834..70310d00438 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -63,7 +63,8 @@ impl WalletPersister { pub struct NoPlatformPersistence; impl PlatformWalletPersistence for NoPlatformPersistence { - /// Nothing is ever written, so nothing survives a restart. + /// Nothing is ever written, so nothing survives a restart. (Redundant + /// with the trait's fail-closed default — kept explicit as documentation.) fn persists_durably(&self) -> bool { false } @@ -84,3 +85,35 @@ impl PlatformWalletPersistence for NoPlatformPersistence { Ok(ClientStartState::default()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `persists_durably` is a fail-closed security capability: an + /// implementation that does NOT explicitly attest durability must read as + /// non-durable, so a backend author who forgets the override gets a loud + /// "requires durable persistence" refusal from the invitation flow + /// instead of being silently trusted with a re-exportable bearer key. + #[test] + fn durability_attestation_defaults_to_fail_closed() { + struct BareMinimum; + impl PlatformWalletPersistence for BareMinimum { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + assert!(!BareMinimum.persists_durably()); + assert!(!NoPlatformPersistence.persists_durably()); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index e6c0350c102..534f36a27b5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2097,10 +2097,11 @@ extension ManagedPlatformWallet { /// the per-identity-key witnesses. The asset-lock's outer signature uses the /// imported raw voucher key, so no Core-side resolver signer is needed here. /// - /// `nowUnix` is the current unix time, used for the link's advisory-expiry - /// check. The contact-bootstrap ("establish contact with the sender?") is - /// NOT done here — after a successful claim the UI asks the invitee and, on - /// confirm, calls `sendContactRequest` for the reciprocal. + /// `nowUnix` is retained for C ABI compatibility but currently ignored: + /// the legacy invitation link carries no expiry field, so claim has no + /// time gate. The contact-bootstrap ("establish contact with the + /// sender?") is NOT done here — after a successful claim the UI asks the + /// invitee and, on confirm, calls `sendContactRequest` for the reciprocal. /// /// Returns the freshly-registered invitee `ManagedIdentity`. public func claimInvitation( From 17578a4aa4be2b59ea5ad9027dfb756bec78b765 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 21:30:33 +0700 Subject: [PATCH 72/74] =?UTF-8?q?fix(platform-wallet,swift-example-app):?= =?UTF-8?q?=20round-6=20review=20=E2=80=94=20serialize=20funding-index=20p?= =?UTF-8?q?ersistence;=20claim-sheet=20races;=20nil=20inviterId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rs-platform-wallet/src/test_support.rs | 18 +- .../src/wallet/asset_lock/build.rs | 156 ++++++++++++++++++ .../src/wallet/asset_lock/manager.rs | 16 ++ .../ManagedPlatformWallet.swift | 8 +- .../SwiftExampleApp/SwiftExampleAppApp.swift | 8 + .../Views/DashPay/ClaimInvitationSheet.swift | 17 +- .../Views/DashPay/DashPayTabView.swift | 21 ++- 7 files changed, 236 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 2f299bbaca6..f8cd14a1072 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -93,6 +93,7 @@ impl TransactionBroadcaster for AlwaysMaybeSentBroadcaster { /// Soft signer that derives keys straight from a test wallet's seed. Stands /// in for the FFI keychain-backed signer used in production. +#[derive(Clone)] pub(crate) struct WalletSigner { wallet: Wallet, } @@ -144,6 +145,21 @@ pub(crate) async fn funded_wallet_manager( WalletId, Arc, WalletSigner, +) { + funded_wallet_manager_with_outputs(account_type, &[10_000_000]).await +} + +/// Like [`funded_wallet_manager`] but with caller-chosen funding outputs — +/// multiple outputs yield multiple spendable UTXOs, letting tests run +/// concurrent asset-lock builds that each need their own input. +pub(crate) async fn funded_wallet_manager_with_outputs( + account_type: StandardAccountType, + outputs: &[u64], +) -> ( + Arc>>, + WalletId, + Arc, + WalletSigner, ) { let mut ctx = TestWalletContext::new_random(); @@ -167,7 +183,7 @@ pub(crate) async fn funded_wallet_manager( } }; - let funding_tx = Transaction::dummy(&receive_address, 0..1, &[10_000_000]); + let funding_tx = Transaction::dummy(&receive_address, 0..1, outputs); // Chain-locked funding, not `Mempool`: asset-lock builders only // select final (confirmed / InstantSend-locked) inputs since // rust-dashcore#836, so a mempool-funded fixture leaves the diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 4fb1053b9c5..27d120d05dd 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -420,6 +420,18 @@ impl AssetLockManager { identity_index: u32, signer: &S, ) -> Result<(DerivationPath, OutPoint), PlatformWalletError> { + // Serialize build→persist so a concurrent build cannot interleave its + // pool snapshot with ours. The snapshot is collected from live wallet + // state at persist time; without this guard, build A's snapshot + // (missing B's just-marked index) can be persisted AFTER B's, + // rolling the durable used-index state back — after a restart the + // next invitation would re-select B's index and re-export the same + // bearer voucher key. Held through the persist/flush gate below and + // dropped before the broadcast (only snapshot ordering needs + // serializing; the UI's own single-flight guard is NOT sufficient — + // a dismissed sheet's unstructured task keeps running). + let build_persist_guard = self.build_persist_serial.lock().await; + // 1. Build the asset lock transaction. let (tx, path) = self .build_asset_lock_transaction( @@ -464,6 +476,10 @@ impl AssetLockManager { } } + // The durable snapshot now includes this build's index; broadcast and + // everything after it can safely run concurrently with the next build. + drop(build_persist_guard); + // 2. Track as Built and queue the changeset onto the persister // so a crash after broadcast leaves a row we can recover from. let cs_built = self @@ -955,6 +971,146 @@ mod tests { ); } + /// Persistence stub whose FIRST address-pool store blocks on a 2-party + /// barrier until the test arrives, holding that build inside its persist + /// while the other build runs. Later stores pass straight through. + struct GatedPoolPersistence { + stored: Mutex>, + first_pool_store: std::sync::Barrier, + gate_used: std::sync::atomic::AtomicBool, + } + + impl PlatformWalletPersistence for GatedPoolPersistence { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if !changeset.account_address_pools.is_empty() + && !self + .gate_used + .swap(true, std::sync::atomic::Ordering::SeqCst) + { + self.first_pool_store.wait(); + } + self.stored + .lock() + .expect("gated persistence mutex") + .push(changeset); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + /// Two concurrent invitation builds must not be able to roll the durable + /// used-index snapshot backwards. The pool snapshot is collected from live + /// wallet state at persist time; unserialized, build A's snapshot + /// (collected before B marked its index) can be persisted AFTER B's, so + /// the last durable snapshot loses B's index — after a restart the next + /// invitation re-selects it and re-exports the same bearer voucher key. + /// The barrier holds the first-persisting build inside its store while + /// the other runs: without the + /// build→persist serialization, B's fuller snapshot lands first and A's + /// stale one overwrites it (this test is red); with it, B parks until A's + /// persist completes, so snapshots are monotonic. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_invitation_builds_cannot_roll_back_the_used_index_snapshot() { + use key_wallet::account::AccountType; + + let (wallet_manager, wallet_id, _balance, signer) = + crate::test_support::funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &[10_000_000, 10_000_000], + ) + .await; + + let persistence = Arc::new(GatedPoolPersistence { + stored: Mutex::new(Vec::new()), + first_pool_store: std::sync::Barrier::new(2), + gate_used: std::sync::atomic::AtomicBool::new(false), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let manager = Arc::new(AssetLockManager::new( + sdk, + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysOkBroadcaster), + WalletPersister::new( + wallet_id, + Arc::clone(&persistence) as Arc, + ), + )); + + let manager_a = Arc::clone(&manager); + let signer_a = signer.clone(); + let a = tokio::spawn(async move { + manager_a + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer_a, + ) + .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 + }); + + // Whichever build stores its pool first is now parked at the barrier + // (inside the serialized section). Give the other build time to + // (wrongly) run its own build + persist meanwhile — with the + // serialization it parks on the mutex instead — then join the barrier + // to release the held build. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + persistence.first_pool_store.wait(); + + a.await.expect("join A").expect("build A succeeds"); + b.await.expect("join B").expect("build B succeeds"); + + // Successive persisted invitation-pool snapshots must never lose a + // used index, and both builds' indices must end up durably used. + let stored = persistence.stored.lock().expect("gated persistence mutex"); + let mut last_used = 0usize; + for cs in stored.iter() { + for entry in cs + .account_address_pools + .iter() + .filter(|e| matches!(e.account_type, AccountType::IdentityInvitation)) + { + let used = entry.addresses.iter().filter(|a| a.used).count(); + assert!( + used >= last_used, + "invitation pool snapshot rolled back: {used} used after {last_used}" + ); + last_used = used; + } + } + assert!( + last_used >= 2, + "both builds' funding indices must be durably marked used, got {last_used}" + ); + } + /// The invitation pre-broadcast gate must treat `flush()` — the /// persistence contract's durability boundary — as part of recording the /// funding index, and abort BEFORE broadcast when it fails. `store()` diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index 85bc5571c0a..3a9a01570d9 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -58,6 +58,21 @@ pub struct AssetLockManager { /// `let _cs = ...`. Every emitted changeset now flows straight /// into `queue_persist` here. pub(super) persister: WalletPersister, + /// Serializes the funding-index-critical section of + /// [`broadcast_funded_asset_lock`](Self::broadcast_funded_asset_lock) — + /// build (index allocation + in-memory mark-used) through the address-pool + /// persist/flush. Without it, two concurrent builds can interleave so that + /// the FIRST build's pool snapshot (collected before the second build + /// marked its index) is persisted LAST, rolling the durable snapshot back + /// to a state where the second index reads unused — after a restart the + /// next invitation re-selects that index and re-exports the same bearer + /// voucher key. A dedicated mutex (never held while awaiting + /// `wallet_manager`'s lock from outside this section, and never acquired + /// by code that already holds it) avoids the self-deadlock a + /// `wallet_manager.write()` guard would cause across the build→persist + /// span. Deliberately NOT held across the broadcast/proof-wait — only the + /// snapshot ordering needs serialization. + pub(super) build_persist_serial: tokio::sync::Mutex<()>, } impl AssetLockManager { @@ -77,6 +92,7 @@ impl AssetLockManager { lock_notify, broadcaster, persister, + build_persist_serial: tokio::sync::Mutex::new(()), } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 00807e56ef2..73953f9d0ab 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2193,9 +2193,11 @@ extension ManagedPlatformWallet { platform_wallet_string_free(out.inviter_username) } } - let inviterId: Data? = out.has_inviter - ? withUnsafeBytes(of: out.inviter_id) { Data($0) } - : nil + // Always nil, matching the documented contract: the legacy link + // carries no inviter identity id (the ABI's `inviter_id` is + // deliberately all-zero), and surfacing a zero sentinel here would let + // a consumer skip the required DPNS username resolution. + let inviterId: Data? = nil let inviterUsername: String? = out.inviter_username.map { String(cString: $0) } return InvitationPreview( structurallyValid: out.structurally_valid, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 99f14712ef1..1e459b4af9f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -29,6 +29,14 @@ final class AppUIState: ObservableObject { /// by the tab once consumed. (The URL embeds a one-time voucher key — treat /// it as a secret; never log it.) @Published var pendingInviteURL: String? + + /// Whether an invitation claim is currently in flight. Set by + /// `ClaimInvitationSheet` around its claim task; observed by + /// `DashPayTabView` so a second invite link arriving mid-claim DEFERS + /// (stays in `pendingInviteURL`) instead of re-presenting the sheet — + /// replacing the sheet would orphan the running claim task and let a + /// second claim race the first for the same unused identity index. + @Published var invitationClaimInFlight = false } @main diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift index 14ab56ce601..6953c89e694 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift @@ -21,6 +21,7 @@ struct ClaimInvitationSheet: View { let network: Network @EnvironmentObject private var walletManager: PlatformWalletManager + @EnvironmentObject private var appUIState: AppUIState @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @@ -74,7 +75,13 @@ struct ClaimInvitationSheet: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { + // Gated while a claim is in flight: dismissing mid-claim + // would leave the unstructured task to finish (and show its + // contact prompt) behind a gone sheet, and a re-open could + // start an overlapping claim racing the same unused + // identity index. Mirrors ReclaimInvitationSheet. Button("Cancel") { dismiss() } + .disabled(isClaiming) } ToolbarItem(placement: .confirmationAction) { if isClaiming { @@ -88,6 +95,8 @@ struct ClaimInvitationSheet: View { } .onChange(of: uri) { _, _ in refreshPreview() } .onAppear { refreshPreview() } + // Swipe-to-dismiss is gated for the same reason as Cancel above. + .interactiveDismissDisabled(isClaiming) .alert( contactPrompt.map { "Add \($0.username)?" } ?? "", isPresented: Binding( @@ -176,9 +185,15 @@ struct ClaimInvitationSheet: View { // claiming, so this is belt-and-suspenders coherence). let submittedURI = trimmedURI isClaiming = true + // Published so DashPayTabView defers a second invite link instead of + // re-presenting (and thereby recreating) this sheet mid-claim. + appUIState.invitationClaimInFlight = true errorMessage = nil Task { @MainActor in - defer { isClaiming = false } + defer { + isClaiming = false + appUIState.invitationClaimInFlight = false + } do { guard let wallet = walletManager.wallet(for: walletId) else { errorMessage = "No wallet loaded." diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index f7945dfec6a..cc730e5ce76 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -139,14 +139,21 @@ struct DashPayTabView: View { /// (`.onChange`) and the cold-launch path (`.onAppear`); the nil guard makes /// the second call after the first clears it a no-op (no double-present). private func consumePendingInviteURL() { + guard appUIState.pendingInviteURL != nil else { return } + // NEVER replace the sheet mid-claim: re-seeding recreates the view, + // orphaning the in-flight claim task, and a second claim would race + // the first for the same unused identity index. Leave the URL pending; + // the `.onChange(of: invitationClaimInFlight)` below re-consumes it + // once the claim reaches a terminal state. + guard !appUIState.invitationClaimInFlight else { return } 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, so a second invite link arriving mid-claim - // re-seeds it with the new URI instead of being dropped. + // 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) } @@ -248,6 +255,14 @@ struct DashPayTabView: View { initialURI: invite.initialURI ) .environmentObject(walletManager) + .environmentObject(appUIState) + } + .onChange(of: appUIState.invitationClaimInFlight) { _, inFlight in + // A link that arrived mid-claim was deferred (left in + // pendingInviteURL); consume it now that the claim ended. + if !inFlight { + consumePendingInviteURL() + } } .onChange(of: appUIState.pendingInviteURL) { _, _ in // Warm path: the app is already running, so the tab observes From ff6758521a73353d32771ffdcc64ce8e2736cc6c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 15 Jul 2026 00:30:47 +0700 Subject: [PATCH 73/74] =?UTF-8?q?fix(platform-wallet,swift-example-app):?= =?UTF-8?q?=20round-7=20review=20=E2=80=94=20voucher=20resume=20gate;=20SQ?= =?UTF-8?q?Lite=20durability=20attest;=20FFI=20round=20serialization;=20re?= =?UTF-8?q?claim=20ambiguity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/dashpay/DIP15_INVITATIONS_SPEC.md | 33 ++-- ...dentity_registration_funded_with_signer.rs | 16 ++ .../rs-platform-wallet-ffi/src/persistence.rs | 97 ++++++++++- .../fund_from_asset_lock.rs | 1 + .../src/shielded_send.rs | 1 + .../src/sqlite/persister.rs | 15 ++ .../tests/sqlite_trait_dispatch.rs | 32 ++++ .../src/wallet/asset_lock/build.rs | 162 ++++++++++++++++-- .../src/wallet/asset_lock/manager.rs | 12 ++ .../src/wallet/asset_lock/orchestration.rs | 32 +++- .../src/wallet/asset_lock/sync/tracking.rs | 18 ++ .../src/wallet/identity/crypto/invitation.rs | 8 +- .../src/wallet/shielded/seed_pool.rs | 5 +- .../ManagedPlatformWallet.swift | 19 +- .../AI_QA/QA004_invitation_reclaim.md | 4 +- .../Core/Views/IdentitiesContentView.swift | 9 + .../Views/CreateIdentityView.swift | 5 + .../Views/DashPay/CreateInvitationSheet.swift | 12 +- .../DashPay/ReclaimInvitationSheet.swift | 139 ++++++++++----- .../CreateIdentityResumableTests.swift | 50 ++++++ .../ReclaimInvitationClassifierTests.swift | 33 ++-- 21 files changed, 617 insertions(+), 86 deletions(-) diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md index 9b6f3825617..cae62aa8554 100644 --- a/docs/dashpay/DIP15_INVITATIONS_SPEC.md +++ b/docs/dashpay/DIP15_INVITATIONS_SPEC.md @@ -52,8 +52,11 @@ Tracked as the "NEXT" item in the DashPay backlog (dashpay/platform#4020); calle 6. **Reclaim shipped (extends §1 scope):** an unclaimed voucher is recovered as identity **credits** (top-up an existing identity or register a new one; the L1 amount was OP_RETURN-burned). Already-consumed handling is classified via the persisted - `reclaimInFlight` marker (self-reclaim crash recovery vs neutral "already claimed") — - see `AI_QA/QA004` step 6 for the exact classifier arms. + `reclaimInFlight` marker: marker unset ⇒ provably a foreign claim (neutral "already + claimed"); marker set ⇒ **explicitly ambiguous** (the marker proves only that a local + consume attempt started, not that it landed — a racing claim is indistinguishable, so the + row resolves to the conservative terminal `Claimed` with an ambiguity message, never an + inferred `Reclaimed`) — see `AI_QA/QA004` step 6 for the exact classifier arms. 7. **QA contract as-built:** TEST_PLAN §4.10 rows **DP-12..DP-19** (not just DP-12..15) + `AI_QA/QA004_invitation_reclaim.md`; funded e2e evidence recorded there. @@ -168,18 +171,28 @@ exists only in the tx payload as a Platform-side authorization, never as an L1 U of their own, recovering the value as credits** (mechanically, claiming your own invitation). UI copy always says "recovered as identity credits", never "DASH returned". -- **Primitive:** consume the tracked lock via `FromExistingAssetLock { out_point }` — the - inviter's own signer re-derives the voucher key at `9'/coin'/5'/3'/funding_index'` - internally (no key export). Two user-picked targets: **top-up an existing identity** or - **register a new one**. +- **Primitive:** consume the tracked lock via + `FromExistingAssetLock { out_point, consume_invitation_voucher: true }` — the inviter's own + signer re-derives the voucher key at `9'/coin'/5'/3'/funding_index'` internally (no key + export). Two user-picked targets: **top-up an existing identity** or **register a new + one**. The `consume_invitation_voucher` flag is the reclaim flow's **explicit + authorization**: every generic resume/top-up path passes `false` and the funding resolver + refuses `IdentityInvitation`-typed locks, so a shared voucher can never be silently + consumed into an unrelated local identity (the Swift resumable-registrations surface also + excludes `fundingTypeRaw == 3` rows). - **Race / already-consumed:** no L1 double-spend exists (no shared UTXO); Platform deterministically rejects the second consume (`IdentityAssetLockTransactionOutPointAlreadyConsumed` — the loser wastes only an ST fee). - The Swift side disambiguates via the persisted `reclaimInFlight` marker, which is saved + The Swift side classifies via the persisted `reclaimInFlight` marker, which is saved (required — the consume may not run on a failed save) only immediately before the on-chain - consume: marker set ⇒ our own crash-interrupted reclaim (row → `Reclaimed`, also recovered - from the local "is not tracked" resume guard); marker unset ⇒ the invitee claimed first - (row → `Claimed`, neutral "This invitation was already claimed." — claimant not named). + consume: marker unset ⇒ provably the invitee claimed first (row → `Claimed`, neutral + "This invitation was already claimed." — claimant not named); marker set ⇒ **explicitly + ambiguous** — the marker proves only that a local consume attempt started, not that it + landed (a racing claim between crash and retry is indistinguishable), so the row resolves + to the conservative terminal `Claimed` with an ambiguity message, never an inferred + `Reclaimed` (`Reclaimed` is written only by a success observed in-flow). The local + "is not tracked" resume-guard failure with the marker set is surfaced as an explicit + ambiguity error (status unchanged — there is no on-chain proof of consumption at all). The decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` seam; see `AI_QA/QA004` step 6 for the verified classifier arms. - **Status lifecycle:** `Reclaimed`/`Claimed` are written by the Swift UI on the local row diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs index 3c234751b19..c6f52f44662 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs @@ -147,6 +147,13 @@ pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( /// wallet-balance path — the resume logic and IS→CL fallback live /// there, not here. This FFI is a thin marshaler. /// +/// `consume_invitation_voucher` is the explicit authorization to consume an +/// `IdentityInvitation`-typed lock (a DashPay bearer voucher whose key is +/// shared in the invitation link). Pass `false` for every generic resume +/// surface — the resolver then refuses invitation locks, so a shared voucher +/// can never be silently consumed into an unrelated local identity. Only the +/// invitation reclaim flow passes `true`. +/// /// # Safety /// - `out_point` must be a valid, non-null pointer to an /// `OutPointFFI` (32-byte raw txid + u32 vout). The caller retains @@ -168,6 +175,7 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc 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, ) -> PlatformWalletFFIResult { @@ -223,6 +231,7 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc .register_identity_with_funding( AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, + consume_invitation_voucher, }, identity_index, keys_map, @@ -257,6 +266,11 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc /// funding path. The `FromExistingAssetLock` resume + IS→CL fallback logic lives /// in `top_up_identity_with_funding`; this FFI is a thin marshaler. /// +/// `consume_invitation_voucher` is the explicit authorization to consume an +/// `IdentityInvitation`-typed lock — the reclaim flow passes `true`; any +/// generic top-up surface must pass `false` and is refused invitation locks +/// by the resolver. +/// /// # Safety /// - `out_point` must be a valid, non-null `*const OutPointFFI`; the caller /// retains ownership. @@ -270,6 +284,7 @@ pub unsafe extern "C" fn platform_wallet_topup_identity_with_existing_asset_lock out_point: *const OutPointFFI, identity_id: *const [u8; 32], core_signer_handle: *mut MnemonicResolverHandle, + consume_invitation_voucher: bool, out_new_balance: *mut u64, ) -> PlatformWalletFFIResult { check_ptr!(out_point); @@ -307,6 +322,7 @@ pub unsafe extern "C" fn platform_wallet_topup_identity_with_existing_asset_lock &identity_id, AssetLockFunding::FromExistingAssetLock { out_point: reclaim_outpoint, + consume_invitation_voucher, }, &asset_lock_signer, None, diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 2f743f6eee0..1aafeee6cbc 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -17,7 +17,7 @@ use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoIn use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::AddressInfo; -use parking_lot::RwLock; +use parking_lot::{Mutex, RwLock}; use std::str::FromStr; use crate::types::{FFINetwork, Network}; @@ -633,6 +633,13 @@ impl Default for PersistenceCallbacks { pub struct FFIPersister { callbacks: PersistenceCallbacks, pending: RwLock>, + /// Serializes complete `store()` callback rounds. The host stages all + /// per-kind callbacks of a round into shared transaction state (Swift: one + /// `ModelContext` + `inChangeset` flag) that is only committed/rolled back + /// by the end callback — two concurrently interleaved rounds could commit + /// or roll back each other's staged writes, so the entire + /// begin → per-kind → end bracket must be exclusive. + store_round: Mutex<()>, } impl FFIPersister { @@ -640,6 +647,7 @@ impl FFIPersister { Self { callbacks, pending: RwLock::new(BTreeMap::new()), + store_round: Mutex::new(()), } } } @@ -668,6 +676,12 @@ impl PlatformWalletPersistence for FFIPersister { wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + // One round at a time: the host's transaction state (Swift's shared + // ModelContext / inChangeset flag) cannot distinguish interleaved + // rounds, so hold the round lock across the entire + // begin → per-kind → end bracket. + let _round_guard = self.store_round.lock(); + // Bracket the whole per-kind callback sequence with a // begin/end pair so clients (Swift, etc.) can treat the // round as a single atomic transaction: begin opens a @@ -5244,6 +5258,87 @@ mod tests { cb.on_persist_invitations_fn = Some(noop_invitations); assert!(FFIPersister::new(cb).persists_durably()); } + + // --- store(): one callback round at a time --- + + /// Shared state for [`concurrent_stores_never_interleave_rounds`]: + /// `active` counts rounds currently inside their begin→end bracket; + /// `overlap` latches if a second round ever enters while one is open. + struct RoundProbe { + active: std::sync::atomic::AtomicUsize, + overlap: std::sync::atomic::AtomicBool, + } + + unsafe extern "C" fn probing_begin(ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + let probe = &*(ctx as *const RoundProbe); + if probe + .active + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + != 0 + { + probe + .overlap + .store(true, std::sync::atomic::Ordering::SeqCst); + } + // 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)); + 0 + } + + unsafe extern "C" fn probing_end(ctx: *mut c_void, _wallet_id: *const u8, _ok: bool) -> i32 { + let probe = &*(ctx as *const RoundProbe); + probe + .active + .fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + 0 + } + + /// Two concurrent `store()` calls must not interleave their callback + /// rounds: the host stages every per-kind callback of a round into + /// shared transaction state (Swift: one ModelContext + `inChangeset` + /// flag) that the end callback commits or rolls back, so an + /// interleaved round B could be committed/rolled back by round A's + /// outcome — e.g. losing a durable invitation funding index while + /// reporting success. The `store_round` mutex serializes the whole + /// begin → per-kind → end bracket. + #[test] + fn concurrent_stores_never_interleave_rounds() { + let probe = Box::leak(Box::new(RoundProbe { + active: std::sync::atomic::AtomicUsize::new(0), + overlap: std::sync::atomic::AtomicBool::new(false), + })); + + let mut cb = PersistenceCallbacks::default(); + cb.context = probe as *const RoundProbe as *mut c_void; + cb.on_changeset_begin_fn = Some(probing_begin); + cb.on_changeset_end_fn = Some(probing_end); + let persister = std::sync::Arc::new(FFIPersister::new(cb)); + + let wallet_id: WalletId = [0x42u8; 32]; + std::thread::scope(|s| { + for _ in 0..2 { + let p = std::sync::Arc::clone(&persister); + s.spawn(move || { + p.store(wallet_id, PlatformWalletChangeSet::default()) + .expect("store must succeed"); + }); + } + }); + + assert!( + !probe.overlap.load(std::sync::atomic::Ordering::SeqCst), + "a second store() entered its callback round while another was open" + ); + assert_eq!( + probe.active.load(std::sync::atomic::Ordering::SeqCst), + 0, + "every begun round must have ended" + ); + } use dashcore::blockdata::transaction::txin::TxIn; use dashcore::blockdata::transaction::txout::TxOut; use dashcore::blockdata::transaction::Transaction; diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs index ed619cb4bf8..794ad1eaef6 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs @@ -200,6 +200,7 @@ pub unsafe extern "C" fn platform_address_wallet_resume_fund_from_asset_lock_sig .fund_from_asset_lock( AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, + consume_invitation_voucher: false, }, platform_account_index, address_map, diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index dbdead34c63..97b18b1075d 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1065,6 +1065,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset &coordinator, AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, + consume_invitation_voucher: false, }, vec![(recipient, None)], &asset_lock_signer, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 61e04666dec..0778600f575 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -816,6 +816,21 @@ impl Drop for SqlitePersister { } impl PlatformWalletPersistence for SqlitePersister { + /// Durability attestation for the security-sensitive flows gated on + /// [`PlatformWalletPersistence::persists_durably`] (e.g. DashPay + /// invitation creation, which must never re-export a bearer voucher key + /// after a restart). + /// + /// `true` in both flush modes: the trait contract is "state survives a + /// process restart once `store` + `flush` return `Ok`", and `flush` + /// always writes through in one SQLite transaction — + /// [`FlushMode::Immediate`] is durable at `store`, [`FlushMode::Manual`] + /// at the explicit `flush` the gated flows already perform before + /// anything irreversible. + fn persists_durably(&self) -> bool { + true + } + /// Merge `changeset` into the per-wallet buffer. /// /// Durability matrix: diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs b/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs index e0ad26c32d1..2ea34bbdbab 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs @@ -132,3 +132,35 @@ fn inherent_commit_writes_flushes_dirty() { assert_eq!(count_for(&a), 1); assert_eq!(count_for(&b), 1); } + +/// SQLite attests durability in BOTH flush modes, so the fail-closed +/// `persists_durably` gate on invitation creation +/// (`create_invitation` refuses non-durable backends) accepts a +/// SQLite-backed wallet. Immediate mode is durable at `store`; Manual +/// mode at the explicit `flush` the gated flow performs before +/// anything irreversible. Checked through the trait object exactly as +/// the gate reads it. A trait-default stub stays `false` (fail-closed +/// baseline the gate relies on). +#[test] +fn sqlite_attests_durability_for_invitation_gate() { + let (immediate, _tmp_a, _path_a) = fresh_persister(); + let (manual, _tmp_b, _path_b) = fresh_persister_with_mode(FlushMode::Manual); + + let immediate: Arc = Arc::new(immediate); + let manual: Arc = Arc::new(manual); + assert!( + immediate.persists_durably(), + "Immediate-mode SQLite must pass the invitation durability gate" + ); + assert!( + manual.persists_durably(), + "Manual-mode SQLite must pass the invitation durability gate \ + (flush writes through in one transaction)" + ); + + let stub: Arc = Arc::new(StoreOnlyPersister); + assert!( + !stub.persists_durably(), + "a backend that does not attest durability must stay fail-closed" + ); +} diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 27d120d05dd..9d704683761 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -430,6 +430,23 @@ impl AssetLockManager { // dropped before the broadcast (only snapshot ordering needs // serializing; the UI's own single-flight guard is NOT sufficient — // a dismissed sheet's unstructured task keeps running). + // Test-only occupancy gauge for the serialization gate (see + // `build_serial_gate`). RAII so every exit path — including the + // pre-broadcast aborts below — decrements. + #[cfg(test)] + struct GateGauge<'a>(&'a std::sync::atomic::AtomicUsize); + #[cfg(test)] + impl Drop for GateGauge<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } + } + #[cfg(test)] + let _gate_gauge = { + self.build_serial_gate + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + GateGauge(&self.build_serial_gate) + }; let build_persist_guard = self.build_persist_serial.lock().await; // 1. Build the asset lock transaction. @@ -978,6 +995,14 @@ mod tests { stored: Mutex>, first_pool_store: std::sync::Barrier, gate_used: std::sync::atomic::AtomicBool, + /// Total pool-bearing `store` calls seen (counted before parking or + /// pushing). Reaching 2 while the first store is parked proves the + /// second build persisted concurrently — the exact regression. + pool_stores_seen: std::sync::atomic::AtomicUsize, + /// Set just before the first pool store parks at the barrier, so the + /// test can spawn the second build only once the first is provably + /// inside its persist. + first_parked: std::sync::atomic::AtomicBool, } impl PlatformWalletPersistence for GatedPoolPersistence { @@ -986,12 +1011,17 @@ mod tests { _wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { - if !changeset.account_address_pools.is_empty() - && !self + if !changeset.account_address_pools.is_empty() { + self.pool_stores_seen + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if !self .gate_used .swap(true, std::sync::atomic::Ordering::SeqCst) - { - self.first_pool_store.wait(); + { + self.first_parked + .store(true, std::sync::atomic::Ordering::SeqCst); + self.first_pool_store.wait(); + } } self.stored .lock() @@ -1035,6 +1065,8 @@ mod tests { stored: Mutex::new(Vec::new()), first_pool_store: std::sync::Barrier::new(2), gate_used: std::sync::atomic::AtomicBool::new(false), + pool_stores_seen: std::sync::atomic::AtomicUsize::new(0), + first_parked: std::sync::atomic::AtomicBool::new(false), }); let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); let manager = Arc::new(AssetLockManager::new( @@ -1063,6 +1095,16 @@ mod tests { .await }); + // Spawn B only once A is provably parked inside its pool persist + // (holding `build_persist_serial`), so the interleaving is staged, + // not scheduled. + 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 @@ -1076,12 +1118,30 @@ mod tests { .await }); - // Whichever build stores its pool first is now parked at the barrier - // (inside the serialized section). Give the other build time to - // (wrongly) run its own build + persist meanwhile — with the - // serialization it parks on the mutex instead — then join the barrier - // to release the held build. - tokio::time::sleep(std::time::Duration::from_millis(300)).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(); a.await.expect("join A").expect("build A succeeds"); @@ -1236,4 +1296,86 @@ mod tests { "the invitation funding pool must be flushed before broadcast" ); } + + /// An `IdentityInvitation`-typed lock is a shared bearer voucher: the + /// funding resolver must refuse to consume it through the generic + /// `FromExistingAssetLock` path (no explicit authorization), and must + /// let the explicitly-authorized reclaim variant past the gate. Consuming + /// a voucher generically would both misdirect the funds into an unrelated + /// local identity and invalidate the invitee's already-shared claim. + #[tokio::test] + async fn generic_resume_refuses_invitation_voucher_locks() { + use crate::wallet::asset_lock::orchestration::AssetLockFunding; + + let persistence = Arc::new(CapturingPersistence::default()); + let (manager, signer) = funded_asset_lock_manager_with_persistence( + Arc::new(AlwaysOkBroadcaster), + Arc::clone(&persistence), + ) + .await; + + // A real tracked invitation voucher, stopped at Broadcast (the + // broadcast half never attaches a proof). + let (_path, out_point) = manager + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer, + ) + .await + .expect("invitation broadcast half succeeds"); + + // Unauthorized (generic) consume: refused by the gate, immediately. + let refused = manager + .resolve_funding_with_is_timeout_fallback( + AssetLockFunding::FromExistingAssetLock { + out_point, + consume_invitation_voucher: false, + }, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + match refused { + Err(PlatformWalletError::AssetLockTransaction(msg)) => assert!( + msg.contains("invitation voucher"), + "expected the voucher-refusal error, got: {msg}" + ), + Err(e) => panic!("expected the voucher-refusal error, got {e:?}"), + Ok(_) => panic!("expected the voucher-refusal error, got Ok(..)"), + } + + // Authorized (reclaim) consume: passes the gate. The lock has no + // proof yet, so the resolver proceeds into the proof wait — getting + // parked there (rather than an immediate refusal) is the positive + // signal that the gate admitted the call. + let authorized = tokio::time::timeout( + std::time::Duration::from_millis(500), + manager.resolve_funding_with_is_timeout_fallback( + AssetLockFunding::FromExistingAssetLock { + out_point, + consume_invitation_voucher: true, + }, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ), + ) + .await; + match authorized { + Err(_elapsed) => {} // parked in the proof wait — past the gate + Ok(Err(PlatformWalletError::AssetLockTransaction(msg))) + if msg.contains("invitation voucher") => + { + panic!("authorized reclaim consume must pass the voucher gate: {msg}") + } + Ok(other) => { + // Any other outcome also proves the gate admitted the call. + drop(other); + } + } + } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index 3a9a01570d9..b9c810b6d26 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -73,6 +73,16 @@ pub struct AssetLockManager { /// span. Deliberately NOT held across the broadcast/proof-wait — only the /// snapshot ordering needs serialization. pub(super) build_persist_serial: tokio::sync::Mutex<()>, + /// Test-only gauge of builds currently at or past the + /// `build_persist_serial` gate within `broadcast_funded_asset_lock` + /// (incremented before the `lock().await`, RAII-decremented on every + /// exit from the call). Lets the concurrency regression test + /// synchronize on "the second build has reached the serialization + /// gate" instead of assuming a scheduling delay — while the first + /// build holds the lock, a gauge of 2 proves the second build cannot + /// yet have collected its pool snapshot. + #[cfg(test)] + pub(super) build_serial_gate: std::sync::atomic::AtomicUsize, } impl AssetLockManager { @@ -93,6 +103,8 @@ impl AssetLockManager { broadcaster, persister, build_persist_serial: tokio::sync::Mutex::new(()), + #[cfg(test)] + build_serial_gate: std::sync::atomic::AtomicUsize::new(0), } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 953d687c182..1acd886a24b 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -151,6 +151,15 @@ pub enum AssetLockFunding { FromExistingAssetLock { /// The outpoint identifying the tracked asset lock (txid + output index). out_point: OutPoint, + /// Explicit authorization to consume an + /// [`AssetLockFundingType::IdentityInvitation`]-typed lock — a + /// DashPay invitation **bearer voucher** whose key was exported + /// into a shared link. Only the invitation reclaim flow sets this; + /// every generic resume/top-up path leaves it `false` and is + /// refused invitation locks by the resolver, so a voucher can + /// never be silently consumed into an unrelated local identity + /// (which would invalidate the invitee's already-shared claim). + consume_invitation_voucher: bool, }, } @@ -419,7 +428,28 @@ impl AssetLockManager { Err(e) => Err(e), } } - AssetLockFunding::FromExistingAssetLock { out_point } => { + AssetLockFunding::FromExistingAssetLock { + out_point, + consume_invitation_voucher, + } => { + // Invitation vouchers are bearer instruments: the credit + // output's private key was exported into a shared link, so + // consuming the lock through a generic resume/top-up would + // both misdirect the funds into a local identity and kill + // the invitee's claim. Refuse unless the caller carries the + // reclaim flow's explicit authorization. + if !consume_invitation_voucher + && self.tracked_funding_type(&out_point).await + == Some(AssetLockFundingType::IdentityInvitation) + { + return Err(PlatformWalletError::AssetLockTransaction(format!( + "asset lock {out_point} is a DashPay invitation voucher; \ + generic resume/top-up refuses to consume it (its key is \ + shared in the invitation link, and consuming it would \ + invalidate the invitee's claim) — use the invitation \ + reclaim flow, which passes explicit authorization" + ))); + } // 300s is an InstantSend-preference window, not a finality // timeout: on expiry the caller falls back to an unbounded // ChainLock wait, so a resumed broadcast lock never fails diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs index 1fef4fe1700..fd101ddba32 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs @@ -10,6 +10,24 @@ use super::super::manager::AssetLockManager; use super::super::tracked::{AssetLockStatus, TrackedAssetLock}; impl AssetLockManager { + /// The recorded [`AssetLockFundingType`] of a tracked lock, or `None` + /// when the outpoint is not tracked. Used by the funding resolver to + /// refuse consuming `IdentityInvitation` (bearer voucher) locks through + /// generic resume/top-up paths. + /// + /// [`AssetLockFundingType`]: + /// key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType + pub(crate) async fn tracked_funding_type( + &self, + out_point: &OutPoint, + ) -> Option + { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.tracked_asset_locks.get(out_point)) + .map(|lock| lock.funding_type) + } + /// Track a new asset lock in memory, returning a changeset describing /// the inserted entry. /// diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs index d8d994bf448..6060013024a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -435,13 +435,19 @@ pub fn parse_invitation_uri(uri: &str) -> Result return Err(e), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 73953f9d0ab..2b14315e1cd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3902,12 +3902,19 @@ extension ManagedPlatformWallet { /// /// Returns `(identityId, ManagedIdentity)` for the freshly /// registered identity. + /// + /// `consumeInvitationVoucher` is the explicit authorization to consume an + /// `IdentityInvitation`-typed lock (a DashPay bearer voucher whose key is + /// shared in the invitation link). Defaults to `false`: generic resume + /// surfaces are refused invitation locks by the Rust funding resolver. + /// Only the invitation reclaim flow passes `true`. public func resumeIdentityWithAssetLock( outPointTxid: Data, outPointVout: UInt32, identityIndex: UInt32, identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], - signer: KeychainSigner + signer: KeychainSigner, + consumeInvitationVoucher: Bool = false ) async throws -> (Identifier, ManagedIdentity) { guard outPointTxid.count == 32 else { throw PlatformWalletError.invalidParameter( @@ -3974,6 +3981,7 @@ extension ManagedPlatformWallet { UInt(ffiRowsCount), signerHandle, coreSigner.handle, + consumeInvitationVoucher, &idTuple, &outManagedHandle ) @@ -4003,10 +4011,16 @@ extension ManagedPlatformWallet { /// which re-derives the voucher key at the invitation funding path. /// /// - Returns: the identity's new credit balance reported by the FFI. + /// + /// `consumeInvitationVoucher` is the explicit authorization to consume an + /// `IdentityInvitation`-typed lock. Defaults to `false`: a generic top-up + /// is refused invitation locks by the Rust funding resolver. Only the + /// invitation reclaim flow passes `true`. public func topUpIdentityWithExistingAssetLock( outPointTxid: Data, outPointVout: UInt32, - identityId: Data + identityId: Data, + consumeInvitationVoucher: Bool = false ) async throws -> UInt64 { guard outPointTxid.count == 32 else { throw PlatformWalletError.invalidParameter( @@ -4064,6 +4078,7 @@ extension ManagedPlatformWallet { &outPoint, &idTuple, coreSigner.handle, + consumeInvitationVoucher, &newBalance ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md index b62b72b43eb..63554ff4f0a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md @@ -17,7 +17,7 @@ 4. **Verify the top-up.** Re-read SwiftData: the target identity's `ZBALANCE` rose by ~the voucher value (in credits; 1 duff ≈ 1000 credits), and the reclaimed row's `ZSTATUSRAW` is now `2` (Reclaimed). Cross-check the outpoint reads **consumed** on-chain (platform-explorer). 5. **Reclaim → register (new identity).** Repeat steps 1–3 with a **second** invitation, but in the sheet switch the target to **New identity** (right segment of `dashpay.invite.reclaim.target`) — the identity picker is replaced by "A brand-new identity funded by this voucher." Submit. After it confirms, confirm a new funded identity appears in the Identities tab and the row's `ZSTATUSRAW` is `2` (Reclaimed). Screenshot `AI_QA/output/QA004_reclaim_register.png`. 6. **Already-consumed handling.** Take a row whose voucher is already consumed on-chain and reclaim it again (tap `dashpay.invitations.reclaim` → submit; `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_already_consumed.json`). Two single-device variants — pick by which classifier arm you want to exercise (force-quit the app, edit via `sqlite3` while terminated, relaunch): - - **Crash-interrupted self-reclaim** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=1` on a reclaimed row): the retry hits the wallet's LOCAL "…is not tracked" resume guard (this wallet consumed the lock itself, so the row is locally `Consumed`); with the marker set it recovers as **Reclaimed** (`ZSTATUSRAW=2`, marker cleared) with "This invitation was already reclaimed." + - **Interrupted-attempt ambiguity** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=1` on a reclaimed row): the retry hits the wallet's LOCAL "…is not tracked" resume guard (this wallet consumed the lock itself, so the row is locally `Consumed`); with the marker set it surfaces an **explicitly ambiguous error** ("…may already have been consumed by that attempt — check the balance of the identity you targeted then") and deliberately does NOT flip the row: the marker proves only that a consume attempt started, never that it landed, so no inferred `Reclaimed` is written. - **Marker-unset reset** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=0`): the same local "not tracked" failure with no marker deliberately classifies as an **error** — the row stays `Created` and a raw message is shown; the marker this attempt set for itself is **cleared again** (`ZRECLAIMINFLIGHT` back to `0`), so an identical retry repeats the error rather than misreading its own stale marker as a completed self-reclaim. This is the false-positive guard (an unrelated local failure must never flip the row), not a bug. - The **neutral "This invitation was already claimed."** (→ `ZSTATUSRAW=1`) path requires the consume to actually reach Platform and be rejected with 10504, i.e. a voucher claimed by a *different* wallet while this one still tracks the lock — the two-device DP-19 race, not reproducible by local sqlite edits. 7. **Minimum-amount guard.** Open `CreateInvitationSheet` (paperplane → **+** `dashpay.invitations.create`), set the amount below the floor (e.g. `0.001`), and confirm the create button is disabled with the sub-minimum hint. Screenshot `AI_QA/output/QA004_min_guard.png`. @@ -27,7 +27,7 @@ - **Step 1** create succeeds at the default `0.03` DASH; the persisted row is `Created` (`ZSTATUSRAW=0`) with `ZAMOUNTDUFFS=3000000` and a 36-byte `ZRAWOUTPOINT`. The record is persisted **immediately after the funding broadcast** — before the InstantSend proof wait and the fallible key-export/URI-encode — so an interrupted proof wait or a ChainLock-funded (non-InstantSend) voucher is still recorded and reclaimable. - **Step 3–4 (top-up)** the sheet states value returns as credits, never L1 Dash; on success the target identity's credit balance rises by ~the voucher value and the row badge flips to **Reclaimed** (`ZSTATUSRAW=2`). No L1 Dash is returned (the on-chain amount was an `OP_RETURN` burn at create time). - **Step 5 (register)** a brand-new funded identity lands in Identities and the row flips to **Reclaimed** (`ZSTATUSRAW=2`); no contact request is sent (a reclaim carries no DashPay enc/dec pair). -- **Step 6 (already-consumed)** the terminal decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` seam, disambiguated by the persisted `reclaimInFlight` marker (set — with a **required, successful** save — only immediately before the on-chain consume; a failed marker save aborts the reclaim before anything irreversible). A Platform-rejected consume (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, Display "…already completely used"): marker **unset** ⇒ the invitee claimed first ⇒ **neutral** "This invitation was already claimed." (claimant **not** named) ⇒ row → **Claimed** (`ZSTATUSRAW=1`); marker **set** ⇒ our own crash-interrupted reclaim ⇒ "already reclaimed" ⇒ row → **Reclaimed** (`ZSTATUSRAW=2`). A retry that instead hits the wallet's LOCAL "…is not tracked" resume guard resolves to **Reclaimed** only when the marker is set (verified live: instant recovery + "This invitation was already reclaimed."); with the marker unset it is an **error** and the row stays `Created` — so a genuinely-reclaimed row never stays stuck, and an unrelated local failure never flips the row. No funds are lost. +- **Step 6 (already-consumed)** the terminal decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` seam, split by the persisted `reclaimInFlight` marker (set — with a **required, successful** save — only immediately before the on-chain consume; a failed marker save aborts the reclaim before anything irreversible). A Platform-rejected consume (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, Display "…already completely used"): marker **unset** ⇒ provably the invitee claimed first ⇒ **neutral** "This invitation was already claimed." (claimant **not** named) ⇒ row → **Claimed** (`ZSTATUSRAW=1`); marker **set** ⇒ **explicitly ambiguous** — the consumer could be our own interrupted attempt OR a racing claim, so the row resolves to the conservative terminal **Claimed** (`ZSTATUSRAW=1`, marker cleared) with the ambiguity message ("…or possibly by your own earlier interrupted reclaim…"); `Reclaimed` (`ZSTATUSRAW=2`) is written **only** by a consume this attempt observed succeeding. A retry that instead hits the wallet's LOCAL "…is not tracked" resume guard: marker set ⇒ explicit ambiguity **error**, row unchanged (no on-chain proof of consumption at all); marker unset ⇒ plain **error** and the row stays `Created` — an unrelated local failure never flips the row. No funds are lost. - **Step 7 (min guard)** creating below the minimum is blocked in-UI with "Minimum 0.003 DASH — a smaller voucher can't fund identity registration. Maximum 0.05 DASH."; the Rust layer independently rejects a sub-minimum amount (`MIN_INVITATION_DUFFS`), so the floor holds even if the UI guard is bypassed. Fail the QA if: a reclaim returns L1 Dash instead of credits; a failed or non-consumed reclaim flips the row status (a non-consumed error must leave it `Created`); the already-consumed path names the claimant or shows a raw error instead of the neutral message; or an invitation below `0.003` DASH can be created. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index b10a4039865..eb621986d0e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -435,6 +435,15 @@ struct IdentitiesContentView: View { ) -> [R] { locks.filter { lock in guard lock.statusRaw >= 1 && lock.statusRaw <= 3 else { return false } + // Invitation vouchers (fundingTypeRaw 3, IdentityInvitation) are + // bearer locks whose key is shared in the invitation link — they + // are reclaimed via the Invitations screen, never resumed as a + // local registration (which would consume the voucher into an + // unrelated identity and kill the invitee's claim). Their nominal + // identity slot is 0, so without this exclusion an unclaimed + // voucher surfaces here whenever slot 0 is free. The Rust funding + // resolver refuses them too; this keeps the row from rendering. + guard lock.fundingTypeRaw != 3 else { return false } let slot = UInt32(bitPattern: lock.identityIndexRaw) return !usedSlots.contains( UsedSlot(walletId: lock.walletId, slot: slot) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 35c84f8622b..3ed505cf120 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -43,6 +43,11 @@ protocol AssetLockResumeRow { var walletId: Data { get } var statusRaw: Int { get } var identityIndexRaw: Int32 { get } + /// Funding-type discriminant (mirrors the Rust `AssetLockFundingType`). + /// Carried through the row contract so the resumable-registrations + /// anti-join can exclude `IdentityInvitation` (3) vouchers — a shared + /// bearer lock that generic resume must never consume. + var fundingTypeRaw: Int { get } } extension PersistentAssetLock: AssetLockResumeRow {} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift index b8e32b7975b..d3cdeaf6ed5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift @@ -239,12 +239,18 @@ struct CreateInvitationSheet: View { } } - /// Copy the link to a **local-only** pasteboard so the bearer key isn't - /// mirrored to the user's other devices via Universal Clipboard. + /// Copy the link to a **local-only, expiring** pasteboard: the link embeds + /// a non-expiring bearer WIF, so it must neither mirror to other devices + /// via Universal Clipboard nor linger on the device-wide pasteboard where + /// a later-granted app could read it. 60s matches the app's existing + /// copied-WIF handling (StorageRecordDetailViews). private func copyLink(_ uri: String) { UIPasteboard.general.setItems( [[UTType.plainText.identifier: uri]], - options: [.localOnly: true] + options: [ + .localOnly: true, + .expirationDate: Date().addingTimeInterval(60), + ] ) didCopy = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { didCopy = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index 1ec38c40dfc..9ade6de6e6a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -14,6 +14,14 @@ import SwiftUI /// UI source of truth here (no Rust re-emit). If the voucher was already consumed /// (the invitee claimed it), the reclaim is rejected deterministically and the /// row flips to Claimed with a neutral message instead. +/// +/// Reclaimed is asserted ONLY on a successful consume observed by this attempt. +/// The persisted `reclaimInFlight` marker proves just that a local attempt saved +/// it before starting a consume — it is not tied to a submitted transition or +/// target, so it can never upgrade an "already consumed" rejection to Reclaimed +/// (the invitee may have claimed between the crash and the retry). Marker-set +/// failures therefore resolve to a conservative terminal Claimed with an +/// explicitly ambiguous message, or to an explanatory error — never Reclaimed. struct ReclaimInvitationSheet: View { let invitation: PersistentInvitation let walletId: Data @@ -95,6 +103,11 @@ struct ReclaimInvitationSheet: View { } } } + // Swipe-dismissal must be gated like the Cancel button: dismissing + // mid-consume leaves the unstructured task mutating the row after the + // sheet is gone, and re-opening the still-Created row would permit a + // second overlapping consume attempt against one irreversible voucher. + .interactiveDismissDisabled(isReclaiming) } // MARK: - Sections @@ -184,11 +197,12 @@ struct ReclaimInvitationSheet: View { // Persist the in-flight marker ONLY immediately before the on-chain // consume — never before pre-broadcast local work (e.g. register's - // key pre-persist). A crash between the consume and the terminal - // save is then recoverable: a later "already consumed" that finds - // this marker set was *our own* reclaim, not a foreign claim. Setting - // it earlier would let a purely local failure leave the marker set, - // misclassifying a subsequent genuine foreign claim as self-reclaim. + // key pre-persist). The marker does NOT attribute a later "already + // consumed" rejection (the invitee can race our crash-interrupted + // consume); it only downgrades the classification from "provably a + // foreign claim" to "explicitly ambiguous". Setting it earlier + // would let a purely local failure leave the marker set, degrading + // a subsequent genuine foreign claim into the ambiguous message. // `hadPriorReclaimInFlight` captures the PERSISTED prior value first. // The save must SUCCEED before the consume may run: an unpersisted // marker followed by a consume + crash would strand the row (a local @@ -220,7 +234,11 @@ struct ReclaimInvitationSheet: View { _ = try await wallet.topUpIdentityWithExistingAssetLock( outPointTxid: txid, outPointVout: vout, - identityId: identityId + identityId: identityId, + // Reclaim IS the explicitly authorized voucher-consume + // path; generic resume/top-up flows are refused + // invitation locks by the Rust funding resolver. + consumeInvitationVoucher: true ) case .register: let signer = KeychainSigner(modelContainer: modelContext.container) @@ -238,7 +256,10 @@ struct ReclaimInvitationSheet: View { outPointVout: vout, identityIndex: identityIndex, identityPubkeys: keys, - signer: signer + signer: signer, + // Reclaim IS the explicitly authorized voucher-consume + // path (see topUp arm above). + consumeInvitationVoucher: true ) } @@ -253,23 +274,42 @@ struct ReclaimInvitationSheet: View { error: error, hadPriorReclaimInFlight: hadPriorReclaimInFlight ) { - case .reclaimed: - // Our own earlier reclaim landed on-chain but crashed before - // saving the terminal status. Recover it as Reclaimed and clear - // the marker. - invitation.statusRaw = 2 - invitation.reclaimInFlight = false - invitation.updatedAt = Date() - try? modelContext.save() - infoMessage = "This invitation was already reclaimed." 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() + infoMessage = + "This invitation was already consumed — by the invitee's " + + "claim, or possibly by your own earlier interrupted " + + "reclaim. If that reclaim went through, the credits were " + + "delivered to the target you selected then." + case .untrackedAfterOwnAttempt: + // The wallet no longer tracks the voucher lock and our own + // attempt was in flight — consistent with that attempt's + // consume having landed, but there is no on-chain proof the + // voucher was consumed at all, so neither the status nor the + // marker moves. Explicitly ambiguous by design. + errorMessage = + "This voucher is no longer tracked by the wallet after an " + + "earlier interrupted reclaim attempt. It may already have " + + "been consumed by that attempt — check the balance of the " + + "identity you targeted then before retrying." case .error: if Self.shouldClearInFlightMarker( error: error, @@ -287,7 +327,7 @@ struct ReclaimInvitationSheet: View { // Otherwise leave the marker as-is: with a prior in-flight // (or any error that may have reached the network) a later // retry that hits "already consumed" must still classify as - // our own reclaim rather than a foreign claim. + // ambiguous rather than a provable foreign claim. errorMessage = error.localizedDescription } } @@ -319,39 +359,55 @@ struct ReclaimInvitationSheet: View { } /// The terminal state a reclaim attempt resolves to. + /// + /// There is deliberately NO `.reclaimed` recovery outcome: the persisted + /// `reclaimInFlight` marker proves only that a local attempt saved it + /// before starting a consume. It is not tied to a submitted transition or + /// target, so it cannot attribute an "already consumed" rejection — the + /// invitee may have claimed the voucher between our crash and the retry. + /// Reclaimed is asserted only by the success path's own observed consume. enum ReclaimOutcome: Equatable { - /// The voucher was consumed by our own earlier (crash-interrupted) reclaim. - case reclaimed - /// The voucher was consumed by the invitee's claim. + /// The voucher was consumed and no local attempt was in flight — a + /// foreign claim, unambiguously. case claimed - /// An uncertain, non-"already-consumed" failure — leave state as-is. + /// The voucher is provably consumed (deterministic Platform + /// rejection), but our own in-flight attempt makes the consumer + /// ambiguous: it could be that attempt or a racing foreign claim. + case consumedAmbiguous + /// The wallet no longer tracks the voucher lock and our own attempt + /// was in flight — consistent with that attempt's consume having + /// landed, but with no on-chain proof of consumption at all. Leave + /// status and marker untouched; surface the ambiguity. + case untrackedAfterOwnAttempt + /// An uncertain, unrelated failure — leave state as-is. case error } /// Pure decision for the reclaim `catch`: an "already consumed" rejection is - /// disambiguated by whether *our own* reclaim was already in flight when this - /// attempt started (persisted `reclaimInFlight` marker). Kept side-effect-free - /// and `nonisolated` so it is the unit-tested seam for all outcomes; the view - /// maps the outcome to `statusRaw`/message/save. + /// split by whether *our own* reclaim was already in flight when this + /// attempt started (persisted `reclaimInFlight` marker) — into a provable + /// foreign claim vs an explicitly ambiguous consumption. Kept + /// side-effect-free and `nonisolated` so it is the unit-tested seam for all + /// outcomes; the view maps the outcome to `statusRaw`/message/save. nonisolated static func classifyReclaimFailure( error: Error, hadPriorReclaimInFlight: Bool ) -> ReclaimOutcome { if isAlreadyConsumed(error) { // Platform deterministically rejected the consume as already-spent. - return hadPriorReclaimInFlight ? .reclaimed : .claimed + // With no local attempt in flight that is a foreign claim; with one + // in flight, attribution is unknowable from the marker alone. + return hadPriorReclaimInFlight ? .consumedAmbiguous : .claimed } - // Crash-recovery for our OWN reclaim: if a prior attempt's consume landed - // on-chain but the app crashed before saving the terminal status, a retry - // resumes a lock the wallet no longer tracks and fails LOCALLY ("…is not - // tracked") — before Platform, so `isAlreadyConsumed` never sees it. With - // our in-flight marker set (a prior attempt started a consume), that - // untracked lock is our own completed reclaim; recover it rather than - // leaving the row stuck at Created with the marker set forever. The marker - // gate makes this safe: a first attempt has `hadPriorReclaimInFlight == - // false`, so an unrelated "not tracked" failure still resolves to `.error`. + // A retry after our own crash-interrupted consume can also fail + // LOCALLY ("…is not tracked") — before Platform, so `isAlreadyConsumed` + // never sees it. That is consistent with our consume having landed, but + // is not proof (local tracking state is not an on-chain observation), + // so it gets its own explicitly ambiguous outcome instead of a + // Reclaimed recovery. A first attempt (`hadPriorReclaimInFlight == + // false`) hitting "not tracked" still resolves to `.error`. if hadPriorReclaimInFlight && isLockNoLongerTracked(error) { - return .reclaimed + return .untrackedAfterOwnAttempt } return .error } @@ -361,11 +417,12 @@ struct ReclaimInvitationSheet: View { /// itself (`hadPriorReclaimInFlight == false`) and then failed the LOCAL /// pre-broadcast "is not tracked" resume guard — proof the consume never /// started, so the freshly-set marker is stale. Without clearing it, an - /// identical retry captures the marker as a prior in-flight and - /// misclassifies the same purely-local failure as a completed self-reclaim - /// (two-attempt false positive). Every other error keeps the marker: a - /// failure that may have reached the network must stay disambiguable as - /// our own reclaim on retry. + /// identical retry captures the marker as a prior in-flight and degrades + /// the same purely-local failure into the ambiguous + /// `.untrackedAfterOwnAttempt` outcome (two-attempt false ambiguity). + /// Every other error keeps the marker: a failure that may have reached + /// the network must keep a later "already consumed" classified as + /// ambiguous rather than a provable foreign claim. nonisolated static func shouldClearInFlightMarker( error: Error, hadPriorReclaimInFlight: Bool diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index d24354fb320..64e3e00d14e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -38,6 +38,10 @@ final class CreateIdentityResumableTests: XCTestCase { let walletId: Data let statusRaw: Int let identityIndexRaw: Int32 + /// Defaults to 0 (IdentityRegistration) so the pre-existing cases + /// exercise the non-invitation path unchanged; the invitation + /// exclusion tests pass 3 (IdentityInvitation) explicitly. + var fundingTypeRaw: Int = 0 } private let walletA = Data(repeating: 0xA1, count: 8) @@ -105,6 +109,52 @@ final class CreateIdentityResumableTests: XCTestCase { XCTAssertEqual(result, []) } + // MARK: - invitation-voucher exclusion + + /// An `IdentityInvitation` (fundingTypeRaw 3) lock is a shared bearer + /// voucher — it must NEVER surface as a resumable registration, even at + /// a fully actionable status with its nominal slot 0 unused (the exact + /// state of every unclaimed voucher). Resuming it would consume the + /// voucher into an unrelated local identity and kill the invitee's claim; + /// vouchers are recovered only via the Invitations screen's reclaim flow. + func testInvitationVouchersAreExcludedFromResumableList() { + for status in 1...3 { + let voucher = FakeAssetLockRow( + walletId: walletA, + statusRaw: status, + identityIndexRaw: 0, + fundingTypeRaw: 3 + ) + let result = IdentitiesContentView.crossWalletResumableLocks( + in: [voucher], + usedSlots: [] + ) + XCTAssertEqual( + result, [], + "unclaimed invitation voucher at status \(status) must not be resumable" + ) + } + } + + /// The exclusion is invitation-specific: the other identity funding + /// types (0 registration / 1 top-up / 2 top-up-not-bound) keep + /// surfacing exactly as before. + func testNonInvitationFundingTypesStillSurface() { + let locks = (0...2).map { fundingType in + FakeAssetLockRow( + walletId: walletA, + statusRaw: 2, + identityIndexRaw: Int32(fundingType), // distinct free slots + fundingTypeRaw: fundingType + ) + } + let result = IdentitiesContentView.crossWalletResumableLocks( + in: locks, + usedSlots: [] + ) + XCTAssertEqual(result, locks) + } + // MARK: - anti-join func testFiltersOutLocksWhoseOwnWalletSlotIsAlreadyUsed() { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift index 559f11e8c2d..4f354f84640 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift @@ -72,12 +72,15 @@ final class ReclaimInvitationClassifierTests: XCTestCase { + "output 0 already completely used" ) - /// Already-consumed + our own reclaim was in flight ⇒ recover as Reclaimed. - func test_classify_alreadyConsumed_priorInFlight_isReclaimed() { + /// Already-consumed + our own reclaim was in flight ⇒ explicitly ambiguous, + /// NEVER `.reclaimed`: the marker only proves a local attempt started a + /// consume, not that it landed — the invitee can claim between our crash + /// and the retry, and a Reclaimed recovery would misattribute that claim. + func test_classify_alreadyConsumed_priorInFlight_isAmbiguous() { XCTAssertEqual( ReclaimInvitationSheet.classifyReclaimFailure( error: Self.alreadyConsumed, hadPriorReclaimInFlight: true), - .reclaimed + .consumedAmbiguous ) } @@ -113,15 +116,15 @@ final class ReclaimInvitationClassifierTests: XCTestCase { + "3ff8e26d02e53f97a5f06b12327f40fc10cb859077e2788362c5d93032850ff0:0 is not tracked" ) - /// Crash-recovery: our own prior reclaim consumed the lock on-chain but the - /// terminal save was interrupted; a retry resumes an untracked lock and fails - /// LOCALLY ("…is not tracked"). With the marker set, that is our completed - /// reclaim → `.reclaimed` (not stuck at Created forever). - func test_classify_lockNotTracked_priorInFlight_isReclaimed() { + /// A retry after our own crash-interrupted consume can fail LOCALLY + /// ("…is not tracked"). With the marker set that is consistent with our + /// consume having landed, but it is NOT on-chain proof — so it resolves to + /// the explicitly ambiguous `.untrackedAfterOwnAttempt`, never `.reclaimed`. + func test_classify_lockNotTracked_priorInFlight_isUntrackedAmbiguous() { XCTAssertEqual( ReclaimInvitationSheet.classifyReclaimFailure( error: Self.lockNotTracked, hadPriorReclaimInFlight: true), - .reclaimed + .untrackedAfterOwnAttempt ) } @@ -147,8 +150,9 @@ final class ReclaimInvitationClassifierTests: XCTestCase { ) } - /// With a PRIOR in-flight marker the same error is the crash-recovery - /// signal (classified `.reclaimed` above) — never a clear. + /// With a PRIOR in-flight marker the same error is the ambiguous + /// crash-recovery signal (classified `.untrackedAfterOwnAttempt` above) + /// — never a clear. func test_shouldClear_lockNotTracked_prior_isFalse() { XCTAssertFalse( ReclaimInvitationSheet.shouldClearInFlightMarker( @@ -157,7 +161,8 @@ final class ReclaimInvitationClassifierTests: XCTestCase { } /// Errors that may have reached the network keep the marker regardless — - /// a later "already consumed" must stay disambiguable as our own reclaim. + /// a later "already consumed" must stay classified as ambiguous rather + /// than a provable foreign claim. func test_shouldClear_networkishErrors_isFalse() { let transport = StubError(message: "SDK error: Transport error: connection refused") XCTAssertFalse( @@ -173,9 +178,9 @@ final class ReclaimInvitationClassifierTests: XCTestCase { /// The two-attempt regression the clearing exists for: attempt 1 sets the /// marker and fails locally ("not tracked", no prior) → `.error` + clear; /// an identical retry therefore STILL sees no prior marker and stays - /// `.error` — it can never escalate a purely-local failure to `.reclaimed`. + /// `.error` — a purely-local failure can never escalate past `.error`. /// (Without the clear, the retry would capture `hadPriorReclaimInFlight == - /// true` and misreport a successful self-reclaim.) + /// true` and degrade into the ambiguous outcome for no reason.) func test_twoAttempt_localFailure_neverBecomesReclaimed() { // Attempt 1: marker freshly set by this attempt (prior == false). var persistedMarker = false // durable value BEFORE the attempt From 7c64b1d4b054fe88c52d1ffae57bef1620610f93 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 15 Jul 2026 01:09:40 +0700 Subject: [PATCH 74/74] fix(swift-sdk): update invitation persistence test for the keyType snapshot field ff6758521a 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 --- .../SwiftDashSDKTests/InvitationPersistenceTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 53f3bfa0561..51fa476c89b 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -78,7 +78,7 @@ final class InvitationPersistenceTests: XCTestCase { // `encodeOutPoint(rawBytes:)`, which must equal the upsert's stored // `outPointHex` for the delete to match. handler.beginChangeset(walletId: walletId) - handler.persistInvitations(walletId: walletId, upserts: [], removed: [rawOutPoint]) + _ = handler.persistInvitations(walletId: walletId, upserts: [], removed: [rawOutPoint]) _ = handler.endChangeset(walletId: walletId, success: true) rows = try fetchRows(container) @@ -119,6 +119,7 @@ final class InvitationPersistenceTests: XCTestCase { .init( address: "yTestAddress0000000000000000000000000", publicKey: Data(count: 33), + keyType: 0, poolTypeTag: 0, addressIndex: 0, isUsed: false,