From 4891bd470808f14a3dc1ee31302d54cc4de056b4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 8 Jul 2026 16:42:30 +0700 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] =?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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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. |