diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md new file mode 100644 index 00000000000..ad67251b8dc --- /dev/null +++ b/docs/dashpay/DIP15_INVITATIONS_SPEC.md @@ -0,0 +1,636 @@ +# 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 0 or > MAX_INVITATION_DUFFS + funding_account_index: u32, // BIP44 account supplying the L1 UTXOs + 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: Option` is `Some` only when the inviter ticked "send a +contact request back to me" (§ owner decision). Steps: (1) **bound the amount** +(`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 (from `path`), 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**. + +> **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 +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 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: + +``` +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 + 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. +- 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; 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. + +--- + +## 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 — 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; 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 + (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. 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. 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-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() +} 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..6cb0a74ec24 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -0,0 +1,618 @@ +//! 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 dpp::prelude::AssetLockProof; +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. `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); + // 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, + "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() +} + +/// 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::*; + + // 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); + } + + /// 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/rs-platform-wallet-ffi/src/invitation_persistence.rs b/packages/rs-platform-wallet-ffi/src/invitation_persistence.rs new file mode 100644 index 00000000000..7f7d7fdce5b --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/invitation_persistence.rs @@ -0,0 +1,132 @@ +//! 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, +} + +// 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 +/// 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 0d4adcf512e..5d80c33ded5 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -48,6 +48,8 @@ pub mod identity_top_up; 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; @@ -118,6 +120,8 @@ pub use identity_top_up::*; 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::*; diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 3f6bd127e4c..7729fd00edf 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -13,6 +13,7 @@ use key_wallet::bip32::ExtendedPubKey; use key_wallet::derivation_bls_bip32::ExtendedBLSPubKey; use key_wallet::derivation_slip10::ExtendedEd25519PubKey; 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; @@ -45,6 +46,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; @@ -553,6 +555,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 @@ -571,6 +587,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, @@ -1035,6 +1052,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 @@ -3107,7 +3165,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 { &[] @@ -3144,48 +3201,109 @@ 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), ?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-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-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 diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index d9ac73fc754..a57a13d85f2 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -864,6 +864,78 @@ 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. `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); + } + + fn is_empty(&self) -> bool { + self.invitations.is_empty() && self.removed.is_empty() + } +} + // --------------------------------------------------------------------------- // Token Balances // --------------------------------------------------------------------------- @@ -1238,6 +1310,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 @@ -1350,6 +1424,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 { @@ -1401,6 +1476,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 edabe333175..f65a44e309a 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -28,11 +28,12 @@ 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, - ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, - ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, + IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus, + KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey, + PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, + PlatformAddressChangeSet, PlatformWalletChangeSet, ProviderKeyAccountEntry, + ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, 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 1b81323ac74..018d03468ff 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/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 5a3444a3159..93494e13b87 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,85 @@ 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. + /// + /// 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 Ok(()); + }; + 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 Ok(()); + } + self.persister.store(PlatformWalletChangeSet { + account_address_pools: entries, + ..Default::default() + }) + } + /// Build, broadcast, and wait for an asset lock proof. /// /// This is the **unified** entry point for obtaining a funded asset lock @@ -324,6 +403,26 @@ 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. 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. let cs_built = self @@ -515,6 +614,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 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..2a32ffbadad --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -0,0 +1,580 @@ +//! 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 zeroize::Zeroizing; + +use crate::error::PlatformWalletError; + +/// URI prefix for an invitation deep link. The `dashpay://invite` scheme matches +/// the reference wallets for familiarity; the payload is our own. +const INVITATION_URI_PREFIX: &str = "dashpay://invite?data="; + +/// Max base58 chars of the `data=` value accepted **before** decoding (anti-DoS). +/// A real payload — voucher key (32 B) + an InstantSend proof (funding tx + islock, +/// ~0.5–1 KB) + small metadata — base58-encodes to roughly 1.5–2 K chars; 8192 is +/// comfortable headroom while still bounding the base58 allocation a hostile link +/// can force. Mirrors the `dapk` cap in `auto_accept::parse_dashpay_contact_uri`. +const MAX_INVITATION_DATA_B58_LEN: usize = 8192; + +/// Hard byte cap on the decoded payload (defense in depth alongside the b58 cap). +const MAX_INVITATION_PAYLOAD_BYTES: usize = 64 * 1024; + +/// Max length (bytes) of a UTF-8 string field (username / display name). DPNS +/// labels are short; this only bounds a hostile link. +const MAX_STR_BYTES: usize = 256; + +/// Current invitation payload version. +const INVITATION_PAYLOAD_VERSION: u8 = 0; + +/// Inviter contact-bootstrap info — present iff the inviter opted in to "send a +/// contact request back to me". Absent ⇒ the invitation is a pure funding voucher. +#[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 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. + 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}")))?; + + // Zeroized: `buf` holds the plaintext voucher scalar until it is base58'd + // into the (secret) URI; scrub the intermediate on drop. + let mut buf = Zeroizing::new(Vec::with_capacity(64 + asset_lock_bytes.len())); + buf.push(INVITATION_PAYLOAD_VERSION); + buf.extend_from_slice(&voucher_key.secret_bytes()); + buf.extend_from_slice(&expiry_unix.to_le_bytes()); + match inviter { + Some(info) => { + if info.username.len() > MAX_STR_BYTES + || info + .display_name + .as_ref() + .is_some_and(|d| d.len() > MAX_STR_BYTES) + { + return Err(invalid("inviter username/display name too long")); + } + 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.as_slice()).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() + ))); + } + // Zeroized: the decoded payload carries the plaintext voucher scalar (at + // offset 1..33); scrub it once parsed. + let bytes = Zeroizing::new( + bs58::decode(data) + .into_vec() + .map_err(|e| invalid(format!("invitation data is not valid base58: {e}")))?, + ); + if bytes.len() > MAX_INVITATION_PAYLOAD_BYTES { + return Err(invalid(format!( + "invitation payload too large ({} bytes; max {MAX_INVITATION_PAYLOAD_BYTES})", + bytes.len() + ))); + } + + let mut r = Reader::new(bytes.as_slice()); + let version = r.u8()?; + if version != INVITATION_PAYLOAD_VERSION { + return Err(invalid(format!( + "unsupported invitation version {version} (expected {INVITATION_PAYLOAD_VERSION})" + ))); + } + let voucher_key = SecretKey::from_slice(r.take(32)?) + .map_err(|e| invalid(format!("invalid voucher private key: {e}")))?; + let expiry_unix = r.u32()?; + let inviter = match r.u8()? { + 0 => None, + 1 => { + let identity_id = r.arr32()?; + let username = r.string()?; + let display_name = match r.u8()? { + 0 => None, + 1 => Some(r.string()?), + other => return Err(invalid(format!("invalid display-name flag {other}"))), + }; + Some(InviterInfo { + identity_id, + username, + display_name, + }) + } + other => return Err(invalid(format!("invalid inviter-present flag {other}"))), + }; + let asset_lock_bytes = r.len_prefixed(MAX_INVITATION_PAYLOAD_BYTES)?; + let (asset_lock, consumed): (AssetLockProof, usize) = + dpp::bincode::decode_from_slice(asset_lock_bytes, config::standard()) + .map_err(|e| invalid(format!("failed to decode asset-lock proof: {e}")))?; + if consumed != asset_lock_bytes.len() { + return Err(invalid("trailing bytes in embedded asset-lock proof")); + } + r.finish()?; + + Ok(ParsedInvitation { + voucher_key, + asset_lock, + expiry_unix, + inviter, + }) +} + +/// Fail-fast validation before any network call. +/// +/// Rejects a link with a zero clock read, whose advisory expiry has passed, +/// whose proof is not an InstantSend proof (per the owner's proof-type +/// decision), or whose voucher key does not control the funded credit output — +/// turning an otherwise opaque consensus rejection into a clear, local error. +/// The credit-output binding is itself consensus-enforced, so this is a UX +/// guard, not a security boundary. +pub fn validate_claimable( + invitation: &ParsedInvitation, + now_unix: u32, +) -> Result<(), PlatformWalletError> { + // A zero `now` would make the `now > expiry` test below pass for any + // positive expiry, silently treating an expired link as fresh. Reject it up + // front, mirroring the create side's non-zero timestamp guard. + if now_unix == 0 { + 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", + 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_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(); + 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; 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/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs new file mode 100644 index 00000000000..0a5ebbac8f7 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -0,0 +1,459 @@ +//! 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::{AssetLockProof, Identifier}; +use key_wallet::bip32::{ChildNumber, DerivationPath}; +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; + +use crate::error::PlatformWalletError; +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. Generous enough for identity registration plus a +/// 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 +/// 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`. +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 { + /// 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(()) +} + +/// 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. + /// + /// `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 < 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!( + "invitation amount {amount_duffs} exceeds the cap {MAX_INVITATION_DUFFS} duffs" + ))); + } + if expiry_unix == 0 { + return Err(PlatformWalletError::InvalidIdentityData( + "invitation expiry_unix must be set (non-zero)".to_string(), + )); + } + + // Build + broadcast the voucher asset lock at the invitation funding + // account (the builder auto-selects the next unused funding index and + // returns its derivation path). `identity_index` is unused for the + // `IdentityInvitation` funding type. + let (proof, path, out_point) = self + .asset_locks + .create_funded_asset_lock_proof( + amount_duffs, + funding_account_index, + AssetLockFundingType::IdentityInvitation, + 0, + asset_lock_signer, + ) + .await?; + + // The invitee's `validate_claimable` accepts only an InstantSend proof. + // `create_funded_asset_lock_proof` falls back to a ChainLock proof if the + // IS lock doesn't propagate within its 300s preference window — reject + // that here rather than emit a link the invitee would silently reject (a + // dead voucher: funds locked, no signal). The funding lock stays + // tracked/reclaimable; the inviter retries. + if !matches!(proof, AssetLockProof::Instant(_)) { + return Err(PlatformWalletError::AssetLockTransaction( + "InstantSend lock did not confirm in time (a ChainLock proof was produced); \ + the funding lock is reclaimable — please retry the invitation" + .to_string(), + )); + } + + // Export the one-time voucher private key at the funding path. This is + // the one deliberate raw-key export (the whole point of an invitation); + // it is path-gated to the invitation sub-feature inside the provider. + let mut voucher_key = crypto_provider.export_invitation_private_key(&path).await?; + + // Build the (secret) URI, then scrub the exported scalar on BOTH the + // success and the encode-error path. `secp256k1::SecretKey` has no + // Drop-zeroize, so wipe it explicitly; scrubbing before propagating an + // encode failure matters most there — on that path the key never + // legitimately left the device, so a lingering copy is the worst kind. + 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 — 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, + 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 no u32 index tail; \ + skipping the local invitation record" + ); + } + + 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 (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`, + // scrubbed on every exit path by the `WipingPrivateKey` guard. + let network = self.sdk.network; + let voucher_priv = WipingPrivateKey(PrivateKey::new(invitation.voucher_key, network)); + + let placeholder = Identity::V0(IdentityV0 { + id: Identifier::default(), + public_keys: keys_map, + balance: 0, + revision: 0, + }); + + // Submit directly. The CL-height-too-low (10506) retry only helps + // ChainLock proofs; the claim path only ever carries an InstantSend proof + // (enforced at create, re-checked by `validate_claimable`), so a + // CL-height retry would be dead code. A within-expiry IS proof either + // lands or is rejected — the inviter re-creates on the rare rejection. + let identity = placeholder + .put_to_platform_and_wait_for_response_with_private_key( + &self.sdk, + invitation.asset_lock.clone(), + &voucher_priv.0, + identity_signer, + settings, + ) + .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) + } +} + +#[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); + } +} 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..dec61178a4e 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, MAX_INVITATION_TTL_SECS}; mod payment_handler; pub(crate) use payment_handler::DashPayPaymentHandler; // Re-exported for the payments unit tests, which drive the hooks 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..124e3095eec 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,50 @@ 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(); + // 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 + || 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 +761,49 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + /// 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); + 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); 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/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 213f582e4fa..1c03ece9ec4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -1968,6 +1968,227 @@ extension ManagedPlatformWallet { return ContactRequest(handle: requestHandle) } + // 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. + /// + /// **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 + } + + /// 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. @@ -3740,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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e30fa12e5e6..fc51b28a1eb 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 @@ -6175,6 +6249,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/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/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. 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/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 99d8459348e..542a55c80c9 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,15 @@ 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", + url.host?.lowercased() == "invite" 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/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/CreateInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift new file mode 100644 index 00000000000..09ba295fcb7 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift @@ -0,0 +1,265 @@ +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 + /// 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.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 + + @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 (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 >= Double(Self.minInvitationDuffs), + 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.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. Between 0.003 and 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("Minimum 0.003 DASH — a smaller voucher can't fund identity registration. Maximum 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) + .accessibilityHidden(true) + } + 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) + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 934050587a0..b78c862d7a4 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,19 @@ struct DashPayTabView: View { @State private var showAddContact = false @State private var showAddViaQR = false + /// 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 /// the persister yet. Rendered as synthetic "Pending" rows in the @@ -111,6 +125,31 @@ 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 + } + + /// 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 } + // 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 { NavigationStack { content @@ -148,6 +187,18 @@ struct DashPayTabView: View { .disabled(activeIdentity == nil) .accessibilityIdentifier("dashpay.addViaQR") } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + if let walletId = claimWalletId { + claimInvite = ClaimInvite(walletId: walletId, initialURI: "") + } + } label: { + Image(systemName: "gift") + } + .disabled(claimWalletId == nil) + .accessibilityLabel("Claim invitation") + .accessibilityIdentifier("dashpay.claimInvitation") + } ToolbarItem(placement: .navigationBarLeading) { if let identity = activeIdentity { NavigationLink { @@ -159,6 +210,17 @@ struct DashPayTabView: View { .accessibilityIdentifier("dashpay.openIgnored") } } + ToolbarItem(placement: .navigationBarLeading) { + if let walletId = claimWalletId { + NavigationLink { + InvitationsView(walletId: walletId, network: network) + } label: { + Image(systemName: "paperplane") + } + .accessibilityLabel("Sent invitations") + .accessibilityIdentifier("dashpay.openSentInvitations") + } + } } .sheet(isPresented: $showAddViaQR) { if let identity = activeIdentity { @@ -166,6 +228,28 @@ struct DashPayTabView: View { .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 + // 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 { AddContactView( 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..f22622a6c41 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift @@ -0,0 +1,142 @@ +import SwiftDashSDK +import SwiftData +import SwiftUI + +/// "Sent invitations" list (DIP-13): every invitation this wallet created, +/// 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] + + @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)] + ) + } + + 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) + .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 + 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/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift new file mode 100644 index 00000000000..42d0096ff64 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -0,0 +1,280 @@ +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) { + // 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 { + 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 Self.isAlreadyConsumed(error) { + // Someone already claimed this voucher (or a prior reclaim + // consumed it). The consume is deterministically rejected — + // no funds are lost. Reflect the terminal state and show a + // neutral message (the claimant is intentionally not named). + invitation.statusRaw = 1 + invitation.updatedAt = Date() + try? modelContext.save() + infoMessage = "This invitation was already claimed." + } 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). 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. + 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. `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") + } + + private func formatDash(_ duffs: Int64) -> String { + String(format: "%.8f DASH", Double(duffs) / 100_000_000) + } +} 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 fc5ba172579..ebfe1fafb61 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -347,6 +347,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, 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")) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index df198d01e2e..1632a73d3de 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -291,6 +291,14 @@ 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. | +| 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` @@ -349,12 +357,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` 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" + ) + } +}