diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md new file mode 100644 index 00000000000..cae62aa8554 --- /dev/null +++ b/docs/dashpay/DIP15_INVITATIONS_SPEC.md @@ -0,0 +1,834 @@ +# DashPay Invitations (DIP-13 sub-feature 3') — Implementation Spec + +> **Status:** SHIPPED on PR #4041 (2026-07-14) — create + claim + reclaim + persistence + UI, +> three review-fix rounds folded, funded testnet e2e green (TEST_PLAN DP-12..19, `AI_QA/QA004`). +> The original design pass (2026-07-08, §1–§14 below) is kept as rationale; **§0 records where +> the as-built implementation deliberately diverged.** Where §0 and a later section disagree, +> §0 wins. + +Tracked as the "NEXT" item in the DashPay backlog (dashpay/platform#4020); called out in +`SPEC.md` Milestone 5 and `DIP_CONFORMANCE_GAPS.md`. + +--- + +## 0. As-built delta (supersedes the marked sections below) + +1. **Link envelope = the LEGACY query format, not the §6 binary blob (supersedes §6, §7).** + The 2026-07-13 legacy-compat rework (owner decision; contract in §0A) replaced the hand-rolled versioned payload with the + query form shared with dash-wallet Android / dashwallet-iOS, so links are field-level + cross-claimable: `dashpay://invite?du=&assetlocktx=&pk=&islock=` + `[&display-name=…][&avatar-url=…]` (also parses `https://invitations.dashpay.io/applink?…`). + Emit strict / parse lenient. Consequences: + - The link carries the funding **txid**, not the embedded proof → **claim-by-fetch**: the + invitee refetches the funding tx by txid (bounded retry for DAPI propagation lag, both + byte orders), reconstructs the proof, and selects the credit output by matching + `voucher_credit_script(pk)`. + - **No expiry field on the wire** — the §5.1/§8/§10 "claim refuses a past-expiry link" + mechanism does not exist in the as-built claim; `expiry_unix` survives only as inviter-side + local display metadata. The economic bounds are the amount caps. + - **No inviter identity id on the wire** (`inviter_id` always zeroed) — the contact bootstrap + resolves the id from the `du` username via DPNS at claim time. `InviterInfo.username` is + `Option`: a display-name/avatar-only link is metadata-only (`has_inviter == true`, + `inviter_username == nil`, no bootstrap). + - Amount is not on the wire (claim preview shows "—"). +2. **Claim accepts ChainLock invites too (amends §5.1).** `islock` absent or literal `"null"` + ⇒ a `ChainAssetLockProof` is reconstructed (requires the funding tx to be chain-locked). + Create still emits only InstantSend links — a slow-IS ChainLock fallback at create is + rejected as a *link* but the funded lock is recorded first and stays reclaimable. +3. **Amounts (amends §5/§8/§9):** `MIN_INVITATION_DUFFS = 300_000` (0.003 DASH — a smaller + voucher can fund neither a claim nor a register-reclaim, discovered by funded e2e), + `MAX_INVITATION_DUFFS = 5_000_000` (0.05 DASH), Swift default **0.03 DASH**. +4. **Persistence as-built (amends §4.2):** the `InvitationChangeSet` flows through + `PlatformWalletPersistence::store()` to each backend — the SQLite backend's + `V003__invitations` table, and on iOS the FFI `on_persist_invitations_fn` bridge into the + SwiftData `PersistentInvitation` model (SwiftData is the UI source; no Rust rehydrate; §0B). Persist failures are signaled end-to-end + (nonzero callback → rolled-back round → `create_invitation` errors), not best-effort. +5. **Durability + ordering hardening (review rounds 1–3; the per-finding log lives in the + PR #4041 review threads + commit messages):** the pre-broadcast gate persists **and flushes** the + invitation funding-index pool (aborting before broadcast on failure); creation refuses + non-durable backends (`PlatformWalletPersistence::persists_durably()`); the funded-asset-lock + flow is split so the invitation record is persisted immediately **after broadcast, before the + proof wait** — an interrupted create can no longer orphan a funded voucher. +6. **Reclaim shipped (extends §1 scope):** an unclaimed voucher is recovered as identity + **credits** (top-up an existing identity or register a new one; the L1 amount was + OP_RETURN-burned). Already-consumed handling is classified via the persisted + `reclaimInFlight` marker: marker unset ⇒ provably a foreign claim (neutral "already + claimed"); marker set ⇒ **explicitly ambiguous** (the marker proves only that a local + consume attempt started, not that it landed — a racing claim is indistinguishable, so the + row resolves to the conservative terminal `Claimed` with an ambiguity message, never an + inferred `Reclaimed`) — see `AI_QA/QA004` step 6 for the exact classifier arms. +7. **QA contract as-built:** TEST_PLAN §4.10 rows **DP-12..DP-19** (not just DP-12..15) + + `AI_QA/QA004_invitation_reclaim.md`; funded e2e evidence recorded there. + +--- + +## 0A. As-built link envelope & legacy interop (absorbs the legacy-compat spec) + +The interop contract is **field-level parity with the live legacy wallets, emit +strict/canonical, parse leniently** — exactly as tolerantly as the live Android wallet. +Byte-for-byte parity is NOT the contract (the two legacy wallets differ in param order and in +scheme/host). The on-chain primitive and derivation path (`m/9'/coin'/5'/3'/idx'`) are +identical across all three wallets — no consensus change. + +### 0A.1 Wire format + +**Emit (canonical, what we produce):** +```text +dashpay://invite + ?du= # required to emit; optional on parse + &assetlocktx= + &pk= + &islock= # or omit (see below) + [&display-name=] + [&avatar-url=] +``` +- Parse **by field name, order-independent**; accept **both** the `dashpay://invite` scheme + and the `https://invitations.dashpay.io/applink` host (iOS legacy links use the latter). +- **`pk`**: WIF, **compressed** flag set (the credit-output hash uses the *compressed* + pubkey — wrong compression ⇒ wrong `hash160` ⇒ claim fails), network byte `0xCC` mainnet / + `0xEF` testnet-family. +- **`assetlocktx`**: emit lowercase big-endian display hex; on claim parse leniently — try + as-given, then **retry byte-reversed** on a fetch miss (old iOS links are little-endian). +- **`islock`**: OPTIONAL, with two absence forms — param missing **and the literal string + `"null"`** (Android emits `"null"` for a chainlock-confirmed invite). Absent/`"null"` ⇒ + reconstruct a **`ChainAssetLockProof`** at claim, never reject. The hex is not + self-describing: decode as the modern deterministic **ISDLOCK**; the ancient + non-deterministic ISLOCK is unrepresentable in rust-dashcore and fails closed (documented + limitation — no live producer exists). +- **Validity (lenient superset of both wallets):** require `assetlocktx` + `pk` + present/non-blank; never reject solely on a missing `du` or missing/`"null"` `islock`. + +### 0A.2 Claim-by-fetch + +The link carries the funding **txid**, not a proof, so claim reconstructs it (mirrors Android +`TopUpRepository.obtainAssetLockTransaction`): +1. Fetch the tx by `assetlocktx` via `Sdk::get_transaction` (bounded retry/backoff for DAPI + propagation lag; reversed-retry per §0A.1). +2. Fail-fast guards: fetched txid matches `assetlocktx` (either byte order); when an islock is + present, `islock.txid == fetched tx.txid`. +3. **Derive `output_index` by script match** — scan the fetched tx's `credit_outputs` for the + output whose `script_pubkey` == `voucher_credit_script(pk)`; never hard-code index 0. +4. Build `InstantAssetLockProof` (islock present) or `ChainAssetLockProof` (absent/`"null"`; + requires the tx to be chain-locked), then submit through the **unchanged** + `put_to_platform_and_wait_for_response_with_private_key`. + +Consensus enforces pk↔output, islock↔tx, and identity_id↔outpoint — all fail closed; the +local guards are fast-fail UX + correct index selection, not theft prevention. + +### 0A.3 Consequences of the legacy format + +- **No inviter identity id on the wire** — only `du`. `InviterInfo = {username?, display_name?, + avatar_url?}` and the invitee resolves the inviter's id from `du` via DPNS at + contact-bootstrap. A `du`-less link is metadata-only (`has_inviter == true`, + `inviter_username == nil`, no bootstrap possible). +- **No expiry on the wire** — the pre-network staleness gate is gone; the real bounds are the + amount caps + reclaim. The inviter-side local record keeps expiry for display only. +- **Amount is not on the wire** — the claim preview shows "—" pre-fetch. + +### 0A.4 Amounts (onboarding tiers) + +`MIN_INVITATION_DUFFS = 300_000` (0.003 DASH, == Android `DASH_PAY_INVITE_MIN`; a smaller +voucher can fund neither a claim nor a register-reclaim — found by funded e2e). +`MAX_INVITATION_DUFFS = 5_000_000` (0.05 DASH). Swift create default **0.03 DASH** = identity ++ a normal DPNS name (Android `DASH_PAY_FEE`). The contested-name tier (0.25) is **deferred** +until contested registration is wired into the claim flow. + +### 0A.5 Transport + +The custom `dashpay://` scheme is the shipped, first-class transport (QR / share sheet / +in-person). The legacy wallets' AppsFlyer OneLink wrapper is **externally blocked** (Android +team creds; brand domain + template) and tracked separately (#4096-adjacent); note that +OneLink discloses the plaintext `pk` to AppsFlyer server-side — an accepted, documented +regression vs a self-contained link, bounded by the amount cap + reclaim. The custom scheme's +same-device interception limitation is documented in `Info.plist` + §6.1. + +--- + +## 0B. As-built persistence & reclaim (absorbs the Swift-persistence spec) + +### 0B.1 Persistence bridge + +`InvitationChangeSet` (structurally an `asset_locks`-style `BTreeMap` upserts + +`BTreeSet` removals) flows through `PlatformWalletPersistence::store()` to each backend: the +SQLite backend's `V003__invitations` table, and on iOS the **push-callback FFI bridge** +(`on_persist_invitations_fn` → `persistInvitationsCallback` → SwiftData +`PersistentInvitation`), mirroring the asset-lock wiring. Key properties: +- **SwiftData is the UI source; push-only, no Rust→Swift rehydrate.** A SwiftData wipe loses + only list *visibility* — never funds or key re-derivability (`funding_index` re-derives the + voucher key). +- **Persist failures are signaled, never swallowed:** a skipped write returns nonzero from the + callback, failing the (invitation-only) `store()` round and surfacing an error from + `create_invitation` instead of reporting a voucher that never reached SwiftData. +- **Outpoint key seam:** both the upsert and the removal path derive the unique + `outPointHex` via `PersistentAssetLock.encodeOutPoint` verbatim (key-form drift is pinned by + `InvitationPersistenceTests`). + +### 0B.2 Reclaim + +The invitation's DASH is **burned into an `OP_RETURN`** at create time — the credit output +exists only in the tx payload as a Platform-side authorization, never as an L1 UTXO — so +"reclaim" means: **the inviter consumes the still-unclaimed voucher into a Platform identity +of their own, recovering the value as credits** (mechanically, claiming your own invitation). +UI copy always says "recovered as identity credits", never "DASH returned". + +- **Primitive:** consume the tracked lock via + `FromExistingAssetLock { out_point, consume_invitation_voucher: true }` — the inviter's own + signer re-derives the voucher key at `9'/coin'/5'/3'/funding_index'` internally (no key + export). Two user-picked targets: **top-up an existing identity** or **register a new + one**. The `consume_invitation_voucher` flag is the reclaim flow's **explicit + authorization**: every generic resume/top-up path passes `false` and the funding resolver + refuses `IdentityInvitation`-typed locks, so a shared voucher can never be silently + consumed into an unrelated local identity (the Swift resumable-registrations surface also + excludes `fundingTypeRaw == 3` rows). +- **Race / already-consumed:** no L1 double-spend exists (no shared UTXO); Platform + deterministically rejects the second consume + (`IdentityAssetLockTransactionOutPointAlreadyConsumed` — the loser wastes only an ST fee). + The Swift side classifies via the persisted `reclaimInFlight` marker, which is saved + (required — the consume may not run on a failed save) only immediately before the on-chain + consume: marker unset ⇒ provably the invitee claimed first (row → `Claimed`, neutral + "This invitation was already claimed." — claimant not named); marker set ⇒ **explicitly + ambiguous** — the marker proves only that a local consume attempt started, not that it + landed (a racing claim between crash and retry is indistinguishable), so the row resolves + to the conservative terminal `Claimed` with an ambiguity message, never an inferred + `Reclaimed` (`Reclaimed` is written only by a success observed in-flow). The local + "is not tracked" resume-guard failure with the marker set is surfaced as an explicit + ambiguity error (status unchanged — there is no on-chain proof of consumption at all). + The decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` + seam; see `AI_QA/QA004` step 6 for the verified classifier arms. +- **Status lifecycle:** `Reclaimed`/`Claimed` are written by the Swift UI on the local row + (SwiftData is the UI source; create is the only Rust emitter). + +--- + +## 1. Problem & goal + +DashPay onboarding today assumes the new user already **has** a Dash identity (which +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 + +> **SUPERSEDED (2026-07-13, §0.1):** the shipped envelope is the legacy query format +> (`du`/`assetlocktx`/`pk`/`islock` — see §0A), not this blob. +> Kept for the design rationale it records (secret handling, caps, transport notes still apply). + +**Decision: one opaque, versioned payload** behind a `dashpay://invite?data=` +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: + +```text +wire = version:u8 // = 0 + ‖ voucher_key:[u8; 32] // one-time ECDSA private key (secret; zeroized) + ‖ expiry_unix:u32(LE) // ADVISORY, IS-scoped (§5.1); not consensus + ‖ 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~~ + +> **REVERSED (2026-07-13, §0.1):** the as-built codec adopts the reference wallets' legacy +> query format for field-level cross-claimability with dash-wallet iOS/Android. The analysis +> below (dead FDL delivery, JS SDK never had invitations) remains accurate — only the +> conclusion changed, by owner decision, once cross-wallet claimability was prioritized. +Research (research-reference, primary sources) settled this: +- The production iOS (DashSync) + Android (dash-wallet) wallets use an **identical plaintext + URL-query payload**: `du` (username), `display-name`, `avatar-url`, `assetlocktx` (**txid + 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/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 67c4b71bd5b..85553aa1bb5 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -675,6 +675,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 c11c01d6f53..5b5b66ab0cc 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs @@ -147,6 +147,13 @@ pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( /// wallet-balance path — the resume logic and IS→CL fallback live /// there, not here. This FFI is a thin marshaler. /// +/// `consume_invitation_voucher` is the explicit authorization to consume an +/// `IdentityInvitation`-typed lock (a DashPay bearer voucher whose key is +/// shared in the invitation link). Pass `false` for every generic resume +/// surface — the resolver then refuses invitation locks, so a shared voucher +/// can never be silently consumed into an unrelated local identity. Only the +/// invitation reclaim flow passes `true`. +/// /// # Safety /// - `out_point` must be a valid, non-null pointer to an /// `OutPointFFI` (32-byte raw txid + u32 vout). The caller retains @@ -168,6 +175,7 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc identity_pubkeys_count: usize, signer_handle: *mut SignerHandle, core_signer_handle: *mut MnemonicResolverHandle, + consume_invitation_voucher: bool, out_identity_id: *mut [u8; 32], out_identity_handle: *mut Handle, ) -> PlatformWalletFFIResult { @@ -223,6 +231,7 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc .register_identity_with_funding( AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, + consume_invitation_voucher, }, identity_index, keys_map, @@ -253,13 +262,21 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc /// completing the top-up against `identity_id` from the stored outpoint. /// Sister to /// [`platform_wallet_resume_identity_with_existing_asset_lock_signer`], -/// which resumes the lock as a NEW-identity registration instead. +/// which resumes the lock as a NEW-identity registration instead. Also the +/// DashPay invitation "reclaim into an existing identity" path (see +/// `consume_invitation_voucher` below). /// /// The `FromExistingAssetLock` resume + IS→CL fallback logic lives in /// `top_up_identity_with_funding`; this FFI is a thin marshaler. No /// per-identity-key signer is needed (a top-up creates no keys); only the /// Core-side asset-lock signature, produced by the wallet's own resolver. /// +/// `consume_invitation_voucher` is the explicit authorization to consume an +/// `IdentityInvitation`-typed lock (a DashPay bearer voucher whose key is +/// shared in the invitation link) — the invitation reclaim flow passes +/// `true`; every generic top-up/crash-recovery surface must pass `false` +/// and is refused invitation locks by the funding resolver. +/// /// # Safety /// - `out_point` must be a valid, non-null `*const OutPointFFI`; the caller /// retains ownership. @@ -273,6 +290,7 @@ pub unsafe extern "C" fn platform_wallet_topup_identity_with_existing_asset_lock out_point: *const OutPointFFI, identity_id: *const [u8; 32], core_signer_handle: *mut MnemonicResolverHandle, + consume_invitation_voucher: bool, out_new_balance: *mut u64, ) -> PlatformWalletFFIResult { check_ptr!(out_point); @@ -310,6 +328,7 @@ pub unsafe extern "C" fn platform_wallet_topup_identity_with_existing_asset_lock &identity_id, AssetLockFunding::FromExistingAssetLock { out_point: reclaim_outpoint, + consume_invitation_voucher, }, &asset_lock_signer, None, @@ -351,6 +370,7 @@ mod topup_existing_lock_guard_tests { std::ptr::null(), &id, dangling_core_signer(), + false, &mut balance, ) }; @@ -367,6 +387,7 @@ mod topup_existing_lock_guard_tests { &op, std::ptr::null(), dangling_core_signer(), + false, &mut balance, ) }; @@ -384,6 +405,7 @@ mod topup_existing_lock_guard_tests { &op, &id, std::ptr::null_mut(), + false, &mut balance, ) }; @@ -400,6 +422,7 @@ mod topup_existing_lock_guard_tests { &op, &id, dangling_core_signer(), + false, std::ptr::null_mut(), ) }; 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..d6632cc5008 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -0,0 +1,632 @@ +//! FFI bindings for DashPay invitations (DIP-13 sub-feature 3'). +//! +//! Two entry points wrap +//! [`IdentityWallet`](platform_wallet::IdentityWallet)'s invitation flow: +//! +//! - [`platform_wallet_create_invitation`] (inviter) — fund a one-time +//! asset-lock voucher at the invitation derivation path, export the voucher +//! key, and return a shareable `dashpay://invite` link. **Only the Core-side +//! resolver signer is needed** (no identity signer): this is pure voucher +//! creation, no identity is registered. The single resolver handle is used +//! twice — as the asset-lock signer (funding-input + credit-output +//! signatures) and, wrapped as a [`ContactCryptoProvider`], to export the +//! voucher private key at the path-gated invitation sub-feature. +//! - [`platform_wallet_claim_invitation`] (invitee) — parse the link and +//! register a NEW identity for the invitee funded by the imported voucher. +//! The invitee's own identity keys are signed by the supplied `SignerHandle`; +//! the asset-lock's outer signature uses the imported raw voucher key, so no +//! Core-side resolver signer is needed here. +//! +//! The link returned by create **contains the plaintext voucher key** — it is a +//! bearer credential. Callers MUST NOT log or persist it (mirrors the treatment +//! of the auto-accept `dapk` URI in [`crate::dashpay`]). +//! +//! Marshaling mirrors +//! [`crate::identity_registration_funded_with_signer`] (signer-handle `usize` +//! round-trip, `block_on_worker`, `MANAGED_IDENTITY_STORAGE` insert). + +use std::ffi::CStr; +use std::os::raw::c_char; + +use dpp::identity::accessors::IdentityGettersV0; +use platform_wallet::wallet::identity::crypto::{parse_invitation_uri, InviterInfo}; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; + +use platform_wallet::wallet::identity::network::MAX_INVITATION_TTL_SECS; + +use crate::core_wallet_types::OutPointFFI; +use crate::dashpay::resolver_contact_crypto_provider; +use crate::error::*; +use crate::handle::*; +use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI}; +use crate::runtime::block_on_worker; +use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; + +/// Create a DashPay invitation: fund a one-time asset-lock voucher at the +/// DIP-13 invitation path and return a shareable `dashpay://invite` link. +/// +/// `inviter_identity_id` / `inviter_username` are **optional**: pass a non-null +/// 32-byte `inviter_identity_id` to opt into the contact-bootstrap (the link +/// then carries the inviter so the invitee can send a contact request back), in +/// which case `inviter_username` is **required** (non-null). Pass a null +/// `inviter_identity_id` for a pure funding voucher; `inviter_username` is then +/// ignored. The optional display name is not carried through this FFI (`None`). +/// +/// `now_unix` is the current unix time in seconds, passed in from Swift (the +/// FFI can't read the clock deterministically). The advisory expiry is derived +/// as `now_unix + MAX_INVITATION_TTL_SECS` (a fixed ~24h window inside the +/// InstantSend validity bound); `now_unix == 0` is rejected to catch a failed +/// clock read (which would otherwise produce a 1970-relative expiry). +/// +/// On success writes the link to `*out_uri` (heap C string; release with +/// [`crate::platform_wallet_string_free`]) and the funding outpoint to +/// `*out_outpoint`. **The URI embeds the bearer voucher key — never log it.** +/// +/// # Safety +/// - `inviter_identity_id` is either null or points to 32 readable bytes (the +/// `*const u8` identity-id convention shared with `read_identifier` / +/// `platform_wallet_build_auto_accept_qr`). +/// - `inviter_username` is either null or a valid NUL-terminated UTF-8 C string. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// [`crate::dash_sdk_mnemonic_resolver_create`]. The caller retains ownership. +/// - `out_uri` must be a valid `*mut *mut c_char`; `out_outpoint` a valid +/// `*mut OutPointFFI`. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_create_invitation( + wallet_handle: Handle, + amount_duffs: u64, + funding_account_index: u32, + inviter_identity_id: *const u8, + inviter_username: *const c_char, + now_unix: u32, + core_signer_handle: *mut MnemonicResolverHandle, + out_uri: *mut *mut c_char, + out_outpoint: *mut OutPointFFI, +) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); + check_ptr!(out_uri); + check_ptr!(out_outpoint); + // Publish FFI-safe sentinels before any fallible work so every early return + // leaves the out-params well-defined (never uninitialized bytes a + // cleanup-on-error caller might read or free). + unsafe { + *out_uri = std::ptr::null_mut(); + *out_outpoint = OutPointFFI { + txid: [0u8; 32], + vout: 0, + }; + } + + // Reject a failed clock read up front (a zero `now` would derive a + // 1970-relative expiry). The core `create_invitation` also guards the + // resulting `expiry_unix == 0`, but catching `now == 0` here gives a + // clearer, earlier error. + if now_unix == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "now_unix must be a valid unix timestamp (non-zero)", + ); + } + // Derive the advisory expiry: a fixed ~24h window from now, inside the + // InstantSend validity bound. `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 (the opt-in flag), and the username is required in that case. The + // legacy link carries only the inviter's username (`du`), not the identity + // id — the invitee resolves the id from the username via DPNS at + // contact-bootstrap time — so the id bytes themselves are not embedded. + let inviter: Option = if inviter_identity_id.is_null() { + None + } else { + if inviter_username.is_null() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "inviter_username is required when inviter_identity_id is provided", + ); + } + let username = + unwrap_result_or_return!(unsafe { CStr::from_ptr(inviter_username) }.to_str()) + .to_string(); + Some(InviterInfo { + username: Some(username), + display_name: None, + avatar_url: 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, + now_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?…` link; it is parsed into a +/// `ParsedInvitation` and validated (fail-fast on a wrong-type / mismatched +/// link) before any network act. `identity_pubkeys` are the invitee's own +/// new-identity keys (derived from the invitee's seed), signed by +/// `signer_handle` (the Platform-side per-identity-key signer). The asset-lock's +/// outer signature is produced from the imported raw voucher key, so **no +/// Core-side resolver signer is needed**. `now_unix` is retained for C ABI +/// compatibility but currently ignored: the legacy link carries no expiry +/// field, so claim has no time gate. +/// +/// The contact-bootstrap ("establish contact with the sender?") is **not** done +/// here — the UI asks the invitee and, on confirm, calls the existing +/// 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", + ); + } + + // `now_unix` is retained for ABI stability but unused: the legacy link + // carries no expiry, so the claim has no time-based gate. + let _ = now_unix; + + let uri = unwrap_result_or_return!(unsafe { CStr::from_ptr(uri) }.to_str()).to_string(); + // Decode the off-chain link up front (pure, no network). Structural validity + // (scheme/host, size cap, WIF network+compression) is checked here; the + // funding tx is fetched and the credit-output binding resolved inside + // `claim_invitation`. + let invitation = unwrap_result_or_return!(parse_invitation_uri(&uri)); + let keys_map = match decode_identity_pubkeys(identity_pubkeys, identity_pubkeys_count) { + Ok(m) => m, + 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, 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 (scheme/host + required fields + WIF). When + /// false, every other field is unset/zero and the link is malformed. + pub structurally_valid: bool, + /// The link carried an `islock`, so the claim will build an InstantSend + /// proof; `false` ⇒ a ChainLock-confirmed invite (islock absent / `"null"`). + pub is_instant: bool, + /// The link carried inviter METADATA (a `du` username, display name, or + /// avatar). Presence does NOT mean the contact bootstrap is available: a + /// metadata-only link (display-name/avatar without `du`) still sets this + /// flag while `inviter_username` stays null, and the bootstrap needs the + /// username. Gate contact features on a non-null `inviter_username`, not + /// on this flag. + pub has_inviter: bool, + /// Inviter identity id (32 bytes) — always zeroed: the legacy link carries + /// only the username (`du`), from which the id is resolved via DPNS. + pub inviter_id: [u8; 32], + /// Inviter DPNS username — heap C string, or null when the link carried no + /// `du` username. A null username does **not** imply `has_inviter == false`: + /// a metadata-only link (display-name/avatar but no `du`) sets + /// `has_inviter = true` with a null username. Consumers must check this + /// pointer, not `has_inviter`, before using the username. Free with + /// [`crate::platform_wallet_string_free`]. + pub inviter_username: *mut c_char, + /// Amount locked in the voucher (duffs) — always 0: unknown pre-fetch (the + /// link carries the funding txid, not the proof), resolved at claim time. + pub amount_duffs: u64, + /// Advisory expiry (unix seconds) — always 0: the legacy link carries no + /// expiry field. + 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?…` 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 `out_preview.inviter_username` is non-null it is a heap C string the +/// caller frees with [`crate::platform_wallet_string_free`]. It can be null +/// even when `has_inviter` is set (a metadata-only link) — see the field docs. +/// +/// # Safety +/// - `uri` must be a valid NUL-terminated UTF-8 C string. +/// - `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(), + }; + + // The legacy link carries the funding txid, not the proof — so the amount + // is unknown until the claim refetches the tx (shown as "—" pre-fetch), and + // the InstantSend-vs-ChainLock signal is the presence of the `islock` field. + let is_instant = parsed.islock_hex.is_some(); + let amount_duffs = 0; + + let (has_inviter, inviter_id, inviter_username) = match parsed.inviter.as_ref() { + Some(info) => { + // Username is absent for a metadata-only (du-less) link; an interior + // NUL can't occur in a decoded UTF-8 DPNS label, but fall back to a + // null username rather than fail the whole preview. + let username = info + .username + .as_ref() + .and_then(|u| std::ffi::CString::new(u.clone()).ok()) + .map(|c| c.into_raw()) + .unwrap_or(std::ptr::null_mut()); + // The link has no inviter identity id (resolved from the username via + // DPNS at contact-bootstrap); report zeros. + (true, [0u8; 32], 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, + // The link carries no expiry (legacy format); 0 ⇒ "no expiry". + expiry_unix: 0, + }; + } + 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 72eca98f43e..fc33ff62467 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -17,7 +17,7 @@ use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoIn use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::AddressInfo; -use parking_lot::RwLock; +use parking_lot::{Mutex, RwLock}; use std::str::FromStr; use crate::types::{FFINetwork, Network}; @@ -45,6 +45,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; @@ -587,6 +588,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 @@ -605,6 +620,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, @@ -657,6 +673,13 @@ impl Default for PersistenceCallbacks { pub struct FFIPersister { callbacks: PersistenceCallbacks, pending: RwLock>, + /// Serializes complete `store()` callback rounds. The host stages all + /// per-kind callbacks of a round into shared transaction state (Swift: one + /// `ModelContext` + `inChangeset` flag) that is only committed/rolled back + /// by the end callback — two concurrently interleaved rounds could commit + /// or roll back each other's staged writes, so the entire + /// begin → per-kind → end bracket must be exclusive. + store_round: Mutex<()>, } impl FFIPersister { @@ -664,16 +687,41 @@ impl FFIPersister { Self { callbacks, pending: RwLock::new(BTreeMap::new()), + store_round: Mutex::new(()), } } } impl PlatformWalletPersistence for FFIPersister { + /// Durable only when the host actually wired the persistence callbacks. + /// + /// Every per-kind block in [`Self::store`] is `if let Some(cb)` — with the + /// callbacks absent (e.g. a manager configured without a persistence + /// container), non-empty changesets are silently skipped while `store()` + /// still returns `Ok`, which is exactly the write-dropping shape the + /// fail-closed trait default exists to catch. Attest durability only when + /// the transaction bracket (begin/end) AND the callbacks the + /// bearer-key-sensitive invitation flow writes through (invitations + + /// account address pools) are all present; the Swift bridge wires all of + /// its callbacks together, so a partially-wired vtable stays non-durable. + fn persists_durably(&self) -> bool { + self.callbacks.on_changeset_begin_fn.is_some() + && self.callbacks.on_changeset_end_fn.is_some() + && self.callbacks.on_persist_invitations_fn.is_some() + && self.callbacks.on_persist_account_address_pools_fn.is_some() + } + fn store( &self, wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + // One round at a time: the host's transaction state (Swift's shared + // ModelContext / inChangeset flag) cannot distinguish interleaved + // rounds, so hold the round lock across the entire + // begin → per-kind → end bracket. + let _round_guard = self.store_round.lock(); + // Bracket the whole per-kind callback sequence with a // begin/end pair so clients (Swift, etc.) can treat the // round as a single atomic transaction: begin opens a @@ -1117,6 +1165,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 @@ -3062,6 +3151,46 @@ unsafe fn restore_core_address_pools( }, ) .map(|a| a.managed_account_type_mut()), + // Asset-lock funding key-accounts (identity registration / top-up + // / invitation / address top-up). These MUST be restored: 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 (an + // unmatched `_ => None`) resets 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). + AccountType::IdentityRegistration => wallet_info + .accounts + .identity_registration + .as_mut() + .map(|a| a.managed_account_type_mut()), + AccountType::IdentityTopUp { registration_index } => wallet_info + .accounts + .identity_topup + .get_mut(®istration_index) + .map(|a| a.managed_account_type_mut()), + AccountType::IdentityTopUpNotBoundToIdentity => wallet_info + .accounts + .identity_topup_not_bound + .as_mut() + .map(|a| a.managed_account_type_mut()), + AccountType::IdentityInvitation => wallet_info + .accounts + .identity_invitation + .as_mut() + .map(|a| a.managed_account_type_mut()), + AccountType::AssetLockAddressTopUp => wallet_info + .accounts + .asset_lock_address_topup + .as_mut() + .map(|a| a.managed_account_type_mut()), + AccountType::AssetLockShieldedAddressTopUp => wallet_info + .accounts + .asset_lock_shielded_address_topup + .as_mut() + .map(|a| a.managed_account_type_mut()), // Masternode provider key-material accounts — dedicated // `Option` fields. Restoring these // rehydrates the used-flags + beyond-gap indices of the @@ -3093,8 +3222,8 @@ unsafe fn restore_core_address_pools( tracing::warn!( wallet_id = %hex::encode(wallet_id), ?account_type, - "load: skipping persisted address pool with no matching funds or \ - provider account" + "load: skipping persisted address pool with no matching funds, \ + asset-lock funding, or provider account" ); continue; }; @@ -5118,6 +5247,153 @@ mod tests { //! exercising the in-memory mutation against synthetic input. use super::*; + + // --- persists_durably: the fail-closed durability attestation --- + + unsafe extern "C" fn noop_begin(_ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + 0 + } + unsafe extern "C" fn noop_end(_ctx: *mut c_void, _wallet_id: *const u8, _success: bool) -> i32 { + 0 + } + unsafe extern "C" fn noop_pools( + _ctx: *mut c_void, + _wallet_id: *const u8, + _pools: *const AccountAddressPoolFFI, + _count: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn noop_invitations( + _ctx: *mut c_void, + _wallet_id: *const u8, + _upserts_ptr: *const InvitationEntryFFI, + _upserts_count: usize, + _removed_ptr: *const [u8; 36], + _removed_count: usize, + ) -> i32 { + 0 + } + + /// A callback-free persister (the `configure(modelContainer: nil)` shape) + /// silently drops every write, so it must NOT attest durability — this is + /// the concrete fail-open hole the fail-closed default exists to catch: + /// an unpersisted invitation funding index re-exports the same bearer + /// voucher key after a restart. + #[test] + fn callback_free_persister_is_not_durable() { + let persister = FFIPersister::new(PersistenceCallbacks::default()); + assert!(!persister.persists_durably()); + } + + /// A partially-wired vtable (commit bracket present, invitation-critical + /// callbacks absent — or vice versa) stays non-durable. + #[test] + fn partially_wired_persister_is_not_durable() { + let mut cb = PersistenceCallbacks::default(); + cb.on_changeset_begin_fn = Some(noop_begin); + cb.on_changeset_end_fn = Some(noop_end); + assert!(!FFIPersister::new(cb).persists_durably()); + + let mut cb = PersistenceCallbacks::default(); + cb.on_persist_invitations_fn = Some(noop_invitations); + cb.on_persist_account_address_pools_fn = Some(noop_pools); + assert!(!FFIPersister::new(cb).persists_durably()); + } + + /// With the transaction bracket + the invitation-critical callbacks all + /// wired (the shape the Swift bridge always produces), the persister + /// attests durability. + #[test] + fn fully_wired_persister_attests_durability() { + let mut cb = PersistenceCallbacks::default(); + cb.on_changeset_begin_fn = Some(noop_begin); + cb.on_changeset_end_fn = Some(noop_end); + cb.on_persist_account_address_pools_fn = Some(noop_pools); + cb.on_persist_invitations_fn = Some(noop_invitations); + assert!(FFIPersister::new(cb).persists_durably()); + } + + // --- store(): one callback round at a time --- + + /// Shared state for [`concurrent_stores_never_interleave_rounds`]: + /// `active` counts rounds currently inside their begin→end bracket; + /// `overlap` latches if a second round ever enters while one is open. + struct RoundProbe { + active: std::sync::atomic::AtomicUsize, + overlap: std::sync::atomic::AtomicBool, + } + + unsafe extern "C" fn probing_begin(ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + let probe = &*(ctx as *const RoundProbe); + if probe + .active + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + != 0 + { + probe + .overlap + .store(true, std::sync::atomic::Ordering::SeqCst); + } + // Hold the round open long enough that an unserialized second + // store would enter its own begin inside this bracket. The sleep + // only widens the detection window on a broken implementation — + // on the serialized one, overlap is structurally impossible and + // the invariant assert below can never flake. + std::thread::sleep(std::time::Duration::from_millis(100)); + 0 + } + + unsafe extern "C" fn probing_end(ctx: *mut c_void, _wallet_id: *const u8, _ok: bool) -> i32 { + let probe = &*(ctx as *const RoundProbe); + probe + .active + .fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + 0 + } + + /// Two concurrent `store()` calls must not interleave their callback + /// rounds: the host stages every per-kind callback of a round into + /// shared transaction state (Swift: one ModelContext + `inChangeset` + /// flag) that the end callback commits or rolls back, so an + /// interleaved round B could be committed/rolled back by round A's + /// outcome — e.g. losing a durable invitation funding index while + /// reporting success. The `store_round` mutex serializes the whole + /// begin → per-kind → end bracket. + #[test] + fn concurrent_stores_never_interleave_rounds() { + let probe = Box::leak(Box::new(RoundProbe { + active: std::sync::atomic::AtomicUsize::new(0), + overlap: std::sync::atomic::AtomicBool::new(false), + })); + + let mut cb = PersistenceCallbacks::default(); + cb.context = probe as *const RoundProbe as *mut c_void; + cb.on_changeset_begin_fn = Some(probing_begin); + cb.on_changeset_end_fn = Some(probing_end); + let persister = std::sync::Arc::new(FFIPersister::new(cb)); + + let wallet_id: WalletId = [0x42u8; 32]; + std::thread::scope(|s| { + for _ in 0..2 { + let p = std::sync::Arc::clone(&persister); + s.spawn(move || { + p.store(wallet_id, PlatformWalletChangeSet::default()) + .expect("store must succeed"); + }); + } + }); + + assert!( + !probe.overlap.load(std::sync::atomic::Ordering::SeqCst), + "a second store() entered its callback round while another was open" + ); + assert_eq!( + probe.active.load(std::sync::atomic::Ordering::SeqCst), + 0, + "every begun round must have ended" + ); + } use dashcore::blockdata::transaction::txin::TxIn; use dashcore::blockdata::transaction::txout::TxOut; use dashcore::blockdata::transaction::Transaction; diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs index ed619cb4bf8..794ad1eaef6 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs @@ -200,6 +200,7 @@ pub unsafe extern "C" fn platform_address_wallet_resume_fund_from_asset_lock_sig .fund_from_asset_lock( AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, + consume_invitation_voucher: false, }, platform_account_index, address_map, diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 37de8bc4cc6..789995526a7 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1153,6 +1153,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset &coordinator, AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, + consume_invitation_voucher: false, }, vec![(recipient, None)], &asset_lock_signer, diff --git a/packages/rs-platform-wallet-storage/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..0778600f575 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -816,6 +816,21 @@ impl Drop for SqlitePersister { } impl PlatformWalletPersistence for SqlitePersister { + /// Durability attestation for the security-sensitive flows gated on + /// [`PlatformWalletPersistence::persists_durably`] (e.g. DashPay + /// invitation creation, which must never re-export a bearer voucher key + /// after a restart). + /// + /// `true` in both flush modes: the trait contract is "state survives a + /// process restart once `store` + `flush` return `Ok`", and `flush` + /// always writes through in one SQLite transaction — + /// [`FlushMode::Immediate`] is durable at `store`, [`FlushMode::Manual`] + /// at the explicit `flush` the gated flows already perform before + /// anything irreversible. + fn persists_durably(&self) -> bool { + true + } + /// Merge `changeset` into the per-wallet buffer. /// /// Durability matrix: @@ -1087,6 +1102,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-storage/tests/sqlite_trait_dispatch.rs b/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs index e0ad26c32d1..2ea34bbdbab 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs @@ -132,3 +132,35 @@ fn inherent_commit_writes_flushes_dirty() { assert_eq!(count_for(&a), 1); assert_eq!(count_for(&b), 1); } + +/// SQLite attests durability in BOTH flush modes, so the fail-closed +/// `persists_durably` gate on invitation creation +/// (`create_invitation` refuses non-durable backends) accepts a +/// SQLite-backed wallet. Immediate mode is durable at `store`; Manual +/// mode at the explicit `flush` the gated flow performs before +/// anything irreversible. Checked through the trait object exactly as +/// the gate reads it. A trait-default stub stays `false` (fail-closed +/// baseline the gate relies on). +#[test] +fn sqlite_attests_durability_for_invitation_gate() { + let (immediate, _tmp_a, _path_a) = fresh_persister(); + let (manual, _tmp_b, _path_b) = fresh_persister_with_mode(FlushMode::Manual); + + let immediate: Arc = Arc::new(immediate); + let manual: Arc = Arc::new(manual); + assert!( + immediate.persists_durably(), + "Immediate-mode SQLite must pass the invitation durability gate" + ); + assert!( + manual.persists_durably(), + "Manual-mode SQLite must pass the invitation durability gate \ + (flush writes through in one transaction)" + ); + + let stub: Arc = Arc::new(StoreOnlyPersister); + assert!( + !stub.persists_durably(), + "a backend that does not attest durability must stay fail-closed" + ); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index f5f5229d675..3b5fc095b74 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -965,6 +965,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 // --------------------------------------------------------------------------- @@ -1336,6 +1408,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 @@ -1448,6 +1522,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 { @@ -1499,6 +1574,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 34d097f7f85..2ddac5121d9 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -28,12 +28,12 @@ pub use changeset::{ upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, HighestUsedIndexes, IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, - IdentityKeyEntry, IdentityKeysChangeSet, KeyDerivationBreadcrumb, KeyWithBreadcrumb, - PendingContactCrypto, PendingContactCryptoKey, PendingContactCryptoKind, - PendingContactCryptoOp, PlatformAddressBalanceEntry, PlatformAddressChangeSet, - PlatformWalletChangeSet, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, - ProviderPlatformNodePubKey, ReceivedContactRequestKey, SentContactRequestKey, - TokenBalanceChangeSet, WalletMetadataEntry, + IdentityKeyEntry, 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/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 9ed22a5542e..13f457d17cb 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -188,6 +188,31 @@ impl PersistenceError { /// to guarantee a batch flush, it should call `flush` explicitly after all /// `store` calls and treat `store` as a best-effort buffer hint. pub trait PlatformWalletPersistence: Send + Sync { + /// Whether stored state survives a process restart once `store` + `flush` + /// return `Ok`. + /// + /// **Fail-closed: defaults to `false`.** This capability gates + /// security-sensitive flows, so a backend must explicitly ATTEST + /// durability by overriding this to `true` — an implementation that + /// forgets is refused those flows with a clear error instead of being + /// silently trusted. Backends that genuinely write through on + /// `store`/`flush` (the SQLite persister, a fully-wired FFI bridge) + /// override to `true`; buffer-only or write-dropping backends (e.g. + /// [`NoPlatformPersistence`](crate::wallet::persister::NoPlatformPersistence), + /// an FFI persister constructed without persistence callbacks) stay + /// `false`. + /// + /// Why security-sensitive flows gate on this: creating a DashPay + /// invitation exports a one-time bearer voucher key derived from a + /// persisted funding index; on a backend that cannot guarantee the index + /// survives a restart, the same key could be re-derived and re-exported + /// after a relaunch, letting the holder of an earlier link consume a + /// later voucher. Such flows refuse a non-durable backend rather than + /// silently producing a reusable bearer secret. + fn persists_durably(&self) -> bool { + false + } + /// Buffer a changeset for later persistence. /// /// Implementations should merge into an internal per-wallet accumulator so diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 51b76637af9..f8cd14a1072 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -54,6 +54,17 @@ impl TransactionBroadcaster for RejectFirstBroadcaster { } } +/// Broadcaster that always succeeds, for flows that must run past the +/// broadcast step (e.g. the broadcast half of the funded asset-lock flow). +pub(crate) struct AlwaysOkBroadcaster; + +#[async_trait] +impl TransactionBroadcaster for AlwaysOkBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + Ok(transaction.txid()) + } +} + /// Broadcaster that always fails with a definitive pre-send rejection. pub(crate) struct AlwaysRejectedBroadcaster; @@ -82,6 +93,7 @@ impl TransactionBroadcaster for AlwaysMaybeSentBroadcaster { /// Soft signer that derives keys straight from a test wallet's seed. Stands /// in for the FFI keychain-backed signer used in production. +#[derive(Clone)] pub(crate) struct WalletSigner { wallet: Wallet, } @@ -133,6 +145,21 @@ pub(crate) async fn funded_wallet_manager( WalletId, Arc, WalletSigner, +) { + funded_wallet_manager_with_outputs(account_type, &[10_000_000]).await +} + +/// Like [`funded_wallet_manager`] but with caller-chosen funding outputs — +/// multiple outputs yield multiple spendable UTXOs, letting tests run +/// concurrent asset-lock builds that each need their own input. +pub(crate) async fn funded_wallet_manager_with_outputs( + account_type: StandardAccountType, + outputs: &[u64], +) -> ( + Arc>>, + WalletId, + Arc, + WalletSigner, ) { let mut ctx = TestWalletContext::new_random(); @@ -156,7 +183,7 @@ pub(crate) async fn funded_wallet_manager( } }; - let funding_tx = Transaction::dummy(&receive_address, 0..1, &[10_000_000]); + let funding_tx = Transaction::dummy(&receive_address, 0..1, outputs); // Chain-locked funding, not `Mempool`: asset-lock builders only // select final (confirmed / InstantSend-locked) inputs since // rust-dashcore#836, so a mempool-funded fixture leaves the diff --git a/packages/rs-platform-wallet/src/wallet/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..9d704683761 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 @@ -310,6 +389,66 @@ impl AssetLockManager { identity_index: u32, signer: &S, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { + let (path, out_point) = self + .broadcast_funded_asset_lock( + amount_duffs, + account_index, + funding_type, + identity_index, + signer, + ) + .await?; + let proof = self + .wait_for_funded_asset_lock_proof(&out_point, account_index) + .await?; + Ok((proof, path, out_point)) + } + + /// Broadcast half of [`Self::create_funded_asset_lock_proof`] — steps 1–4: + /// build + fund the asset-lock transaction, persist the funding account's + /// address pool, track the lifecycle row, and broadcast. Returns as soon as + /// the transaction is on the wire (status `Broadcast`), BEFORE any proof + /// wait, so a caller can durably record its own bookkeeping for the funded + /// lock (e.g. the inviter-side invitation row) between the broadcast and + /// the potentially long proof wait in + /// [`Self::wait_for_funded_asset_lock_proof`]. + pub(crate) async fn broadcast_funded_asset_lock( + &self, + amount_duffs: u64, + account_index: u32, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, + ) -> Result<(DerivationPath, OutPoint), PlatformWalletError> { + // Serialize build→persist so a concurrent build cannot interleave its + // pool snapshot with ours. The snapshot is collected from live wallet + // state at persist time; without this guard, build A's snapshot + // (missing B's just-marked index) can be persisted AFTER B's, + // rolling the durable used-index state back — after a restart the + // next invitation would re-select B's index and re-export the same + // bearer voucher key. Held through the persist/flush gate below and + // dropped before the broadcast (only snapshot ordering needs + // serializing; the UI's own single-flight guard is NOT sufficient — + // a dismissed sheet's unstructured task keeps running). + // Test-only occupancy gauge for the serialization gate (see + // `build_serial_gate`). RAII so every exit path — including the + // pre-broadcast aborts below — decrements. + #[cfg(test)] + struct GateGauge<'a>(&'a std::sync::atomic::AtomicUsize); + #[cfg(test)] + impl Drop for GateGauge<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } + } + #[cfg(test)] + let _gate_gauge = { + self.build_serial_gate + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + GateGauge(&self.build_serial_gate) + }; + let build_persist_guard = self.build_persist_serial.lock().await; + // 1. Build the asset lock transaction. let (tx, path) = self .build_asset_lock_transaction( @@ -324,6 +463,40 @@ 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. + // `store()` alone is only a buffer hint under the persistence contract + // (backends may defer I/O until `flush`), so the invitation gate also + // drives `flush()` — the contract's durability boundary — before + // anything hits the wire. + // Aborting BEFORE broadcast is harmless (no tx on the wire); the other + // asset-lock accounts keep their keys on-device, so they stay best-effort. + let pool_durability = match self.persist_asset_lock_account_pools().await { + Ok(()) if funding_type == AssetLockFundingType::IdentityInvitation => { + self.persister.flush() + } + other => other, + }; + if let Err(e) = pool_durability { + tracing::error!(error = %e, "failed to persist asset-lock funding index"); + if funding_type == AssetLockFundingType::IdentityInvitation { + return Err(PlatformWalletError::AssetLockTransaction(format!( + "aborted before broadcast: could not durably record the invitation \ + funding index (broadcasting anyway would risk voucher-key reuse on \ + restart): {e}" + ))); + } + } + + // The durable snapshot now includes this build's index; broadcast and + // everything after it can safely run concurrently with the next build. + drop(build_persist_guard); + // 2. Track as Built and queue the changeset onto the persister // so a crash after broadcast leaves a row we can recover from. let cs_built = self @@ -386,20 +559,32 @@ impl AssetLockManager { .await?; self.queue_asset_lock_changeset(cs_broadcast); + Ok((path, out_point)) + } + + /// Proof half of [`Self::create_funded_asset_lock_proof`] — steps 5–6: + /// wait for the InstantSend/ChainLock proof of an already-broadcast asset + /// lock, upgrade it when Platform would reject it, and attach it to the + /// tracked row. + pub(crate) async fn wait_for_funded_asset_lock_proof( + &self, + out_point: &OutPoint, + account_index: u32, + ) -> Result { // 5. Wait for proof via SPV events. The 300s bound is an // InstantSend-preference window, NOT a finality timeout: on // expiry the resolver falls back to an unbounded ChainLock wait // (`upgrade_to_chain_lock_proof(None)`), so a broadcast lock is // never surfaced as "failed" just because IS was slow. let proof = self - .wait_for_proof(&out_point, Some(Duration::from_secs(300))) + .wait_for_proof(out_point, Some(Duration::from_secs(300))) .await?; // 5b. If we got an IS-lock proof, check whether the transaction is // old enough that Platform might reject it. If so, upgrade to a // ChainLock proof proactively. let proof = self - .validate_or_upgrade_proof(proof, account_index, &out_point) + .validate_or_upgrade_proof(proof, account_index, out_point) .await?; // 6. Attach proof — status matches the proof type received — @@ -409,11 +594,11 @@ impl AssetLockManager { dpp::prelude::AssetLockProof::Chain(_) => AssetLockStatus::ChainLocked, }; let cs_final = self - .advance_asset_lock_status(&out_point, status, Some(proof.clone())) + .advance_asset_lock_status(out_point, status, Some(proof.clone())) .await?; self.queue_asset_lock_changeset(cs_final); - Ok((proof, path, out_point)) + Ok(proof) } } @@ -435,7 +620,8 @@ mod tests { ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; use crate::test_support::{ - funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, + funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, + AlwaysRejectedBroadcaster, WalletSigner, }; use crate::wallet::asset_lock::manager::AssetLockManager; use crate::wallet::asset_lock::tracked::AssetLockStatus; @@ -445,10 +631,14 @@ mod tests { use crate::{AssetLockFundingType, PlatformWalletError}; /// Persistence stub that records every stored changeset so tests can - /// assert what the asset-lock flow queued. + /// assert what the asset-lock flow queued. `fail_flush` simulates a + /// backend whose durability boundary fails; `flushes` counts `flush` + /// calls so tests can assert the invitation gate drove one. #[derive(Default)] struct CapturingPersistence { stored: Mutex>, + flushes: std::sync::atomic::AtomicUsize, + fail_flush: bool, } impl CapturingPersistence { @@ -479,6 +669,11 @@ mod tests { } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + self.flushes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.fail_flush { + return Err(PersistenceError::backend("simulated flush failure")); + } Ok(()) } @@ -495,10 +690,21 @@ mod tests { WalletSigner, Arc, ) { + let persistence = Arc::new(CapturingPersistence::default()); + let (manager, signer) = + funded_asset_lock_manager_with_persistence(broadcaster, Arc::clone(&persistence)).await; + (manager, signer, persistence) + } + + /// Like [`funded_asset_lock_manager`] but over a caller-built persistence + /// stub (e.g. one with `fail_flush` set). + async fn funded_asset_lock_manager_with_persistence( + broadcaster: Arc, + persistence: Arc, + ) -> (Arc>, WalletSigner) { let (wallet_manager, wallet_id, _balance, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; - let persistence = Arc::new(CapturingPersistence::default()); let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); let manager = Arc::new(AssetLockManager::new( sdk, @@ -506,13 +712,54 @@ mod tests { wallet_id, Arc::new(Notify::new()), broadcaster, - WalletPersister::new( - wallet_id, - Arc::clone(&persistence) as Arc, - ), + WalletPersister::new(wallet_id, persistence as Arc), )); - (manager, signer, persistence) + (manager, signer) + } + + /// Regression: a build must persist the funding account's address-pool + /// 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` @@ -740,4 +987,395 @@ mod tests { kept for the advanced row, got {rebuild:?}" ); } + + /// Persistence stub whose FIRST address-pool store blocks on a 2-party + /// barrier until the test arrives, holding that build inside its persist + /// while the other build runs. Later stores pass straight through. + struct GatedPoolPersistence { + stored: Mutex>, + first_pool_store: std::sync::Barrier, + gate_used: std::sync::atomic::AtomicBool, + /// Total pool-bearing `store` calls seen (counted before parking or + /// pushing). Reaching 2 while the first store is parked proves the + /// second build persisted concurrently — the exact regression. + pool_stores_seen: std::sync::atomic::AtomicUsize, + /// Set just before the first pool store parks at the barrier, so the + /// test can spawn the second build only once the first is provably + /// inside its persist. + first_parked: std::sync::atomic::AtomicBool, + } + + impl PlatformWalletPersistence for GatedPoolPersistence { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if !changeset.account_address_pools.is_empty() { + self.pool_stores_seen + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if !self + .gate_used + .swap(true, std::sync::atomic::Ordering::SeqCst) + { + self.first_parked + .store(true, std::sync::atomic::Ordering::SeqCst); + self.first_pool_store.wait(); + } + } + self.stored + .lock() + .expect("gated persistence mutex") + .push(changeset); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + /// Two concurrent invitation builds must not be able to roll the durable + /// used-index snapshot backwards. The pool snapshot is collected from live + /// wallet state at persist time; unserialized, build A's snapshot + /// (collected before B marked its index) can be persisted AFTER B's, so + /// the last durable snapshot loses B's index — after a restart the next + /// invitation re-selects it and re-exports the same bearer voucher key. + /// The barrier holds the first-persisting build inside its store while + /// the other runs: without the + /// build→persist serialization, B's fuller snapshot lands first and A's + /// stale one overwrites it (this test is red); with it, B parks until A's + /// persist completes, so snapshots are monotonic. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_invitation_builds_cannot_roll_back_the_used_index_snapshot() { + use key_wallet::account::AccountType; + + let (wallet_manager, wallet_id, _balance, signer) = + crate::test_support::funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &[10_000_000, 10_000_000], + ) + .await; + + let persistence = Arc::new(GatedPoolPersistence { + stored: Mutex::new(Vec::new()), + first_pool_store: std::sync::Barrier::new(2), + gate_used: std::sync::atomic::AtomicBool::new(false), + pool_stores_seen: std::sync::atomic::AtomicUsize::new(0), + first_parked: std::sync::atomic::AtomicBool::new(false), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let manager = Arc::new(AssetLockManager::new( + sdk, + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysOkBroadcaster), + WalletPersister::new( + wallet_id, + Arc::clone(&persistence) as Arc, + ), + )); + + let manager_a = Arc::clone(&manager); + let signer_a = signer.clone(); + let a = tokio::spawn(async move { + manager_a + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer_a, + ) + .await + }); + + // Spawn B only once A is provably parked inside its pool persist + // (holding `build_persist_serial`), so the interleaving is staged, + // not scheduled. + while !persistence + .first_parked + .load(std::sync::atomic::Ordering::SeqCst) + { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + + let manager_b = Arc::clone(&manager); + let b = tokio::spawn(async move { + manager_b + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer, + ) + .await + }); + + // Release A only after B has provably reached the relevant stage — + // no scheduling assumption. Exactly one of two states must occur: + // - `pool_stores_seen >= 2`: B built and persisted its own (fuller) + // snapshot while A was parked — the regression manifested (an + // unserialized implementation always reaches this state, however + // slowly, so the rollback assertion below fires deterministically); + // - `build_serial_gate >= 2`: B is queued at the build→persist + // serialization gate while A still holds it, so B cannot have + // collected a snapshot yet — the fixed behavior, verified + // positively rather than by the absence of a store within a delay. + loop { + let regressed = persistence + .pool_stores_seen + .load(std::sync::atomic::Ordering::SeqCst) + >= 2; + let serialized = manager + .build_serial_gate + .load(std::sync::atomic::Ordering::SeqCst) + >= 2; + if regressed || serialized { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + persistence.first_pool_store.wait(); + + a.await.expect("join A").expect("build A succeeds"); + b.await.expect("join B").expect("build B succeeds"); + + // Successive persisted invitation-pool snapshots must never lose a + // used index, and both builds' indices must end up durably used. + let stored = persistence.stored.lock().expect("gated persistence mutex"); + let mut last_used = 0usize; + for cs in stored.iter() { + for entry in cs + .account_address_pools + .iter() + .filter(|e| matches!(e.account_type, AccountType::IdentityInvitation)) + { + let used = entry.addresses.iter().filter(|a| a.used).count(); + assert!( + used >= last_used, + "invitation pool snapshot rolled back: {used} used after {last_used}" + ); + last_used = used; + } + } + assert!( + last_used >= 2, + "both builds' funding indices must be durably marked used, got {last_used}" + ); + } + + /// The invitation pre-broadcast gate must treat `flush()` — the + /// persistence contract's durability boundary — as part of recording the + /// funding index, and abort BEFORE broadcast when it fails. `store()` + /// alone may only buffer; an unflushed funding index can be re-selected + /// after a restart, re-exporting the same bearer voucher key. + #[tokio::test] + async fn invitation_gate_aborts_before_broadcast_when_flush_fails() { + let persistence = Arc::new(CapturingPersistence { + fail_flush: true, + ..Default::default() + }); + // The broadcaster rejects loudly: reaching it at all would surface as + // a `TransactionBroadcast` error, so the gate's own "aborted before + // broadcast" message proves nothing hit the wire. + let (manager, signer) = funded_asset_lock_manager_with_persistence( + Arc::new(AlwaysRejectedBroadcaster), + Arc::clone(&persistence), + ) + .await; + + let result = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer, + ) + .await; + match result { + Err(PlatformWalletError::AssetLockTransaction(msg)) => assert!( + msg.contains("aborted before broadcast"), + "expected the pre-broadcast durability abort, got: {msg}" + ), + other => panic!("expected the pre-broadcast durability abort, got {other:?}"), + } + assert!( + persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst) + >= 1, + "the invitation gate must have driven flush()" + ); + } + + /// Non-invitation funding types stay best-effort: their one-time keys + /// never leave the device, so a failing durability boundary must NOT gate + /// them — the flow proceeds to broadcast. + #[tokio::test] + async fn flush_failure_does_not_gate_non_invitation_funding() { + let persistence = Arc::new(CapturingPersistence { + fail_flush: true, + ..Default::default() + }); + let (manager, signer) = funded_asset_lock_manager_with_persistence( + Arc::new(AlwaysRejectedBroadcaster), + Arc::clone(&persistence), + ) + .await; + + let result = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + matches!(result, Err(PlatformWalletError::TransactionBroadcast(_))), + "a registration build must reach the broadcaster despite the flush \ + failure (best-effort persistence), got {result:?}" + ); + } + + /// The broadcast half returns as soon as the transaction is on the wire: + /// the tracked row is `Broadcast` (recoverable/resumable) and the + /// invitation funding pool was persisted AND flushed — all BEFORE any + /// proof wait (the test completing at all proves no SPV wait ran), so a + /// caller can durably record its own bookkeeping for the funded lock + /// between the broadcast and the proof wait. + #[tokio::test] + async fn broadcast_half_leaves_broadcast_row_and_flushed_pool() { + let persistence = Arc::new(CapturingPersistence::default()); + let (manager, signer) = funded_asset_lock_manager_with_persistence( + Arc::new(AlwaysOkBroadcaster), + Arc::clone(&persistence), + ) + .await; + + let (_path, out_point) = manager + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer, + ) + .await + .expect("broadcast half should succeed"); + + { + let wm = manager.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&manager.wallet_id) + .expect("wallet still present"); + let lock = info + .tracked_asset_locks + .get(&out_point) + .expect("broadcast lock must stay tracked"); + assert_eq!( + lock.status, + AssetLockStatus::Broadcast, + "the broadcast half must stop at Broadcast (no proof attached)" + ); + } + assert!( + persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst) + >= 1, + "the invitation funding pool must be flushed before broadcast" + ); + } + + /// An `IdentityInvitation`-typed lock is a shared bearer voucher: the + /// funding resolver must refuse to consume it through the generic + /// `FromExistingAssetLock` path (no explicit authorization), and must + /// let the explicitly-authorized reclaim variant past the gate. Consuming + /// a voucher generically would both misdirect the funds into an unrelated + /// local identity and invalidate the invitee's already-shared claim. + #[tokio::test] + async fn generic_resume_refuses_invitation_voucher_locks() { + use crate::wallet::asset_lock::orchestration::AssetLockFunding; + + let persistence = Arc::new(CapturingPersistence::default()); + let (manager, signer) = funded_asset_lock_manager_with_persistence( + Arc::new(AlwaysOkBroadcaster), + Arc::clone(&persistence), + ) + .await; + + // A real tracked invitation voucher, stopped at Broadcast (the + // broadcast half never attaches a proof). + let (_path, out_point) = manager + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer, + ) + .await + .expect("invitation broadcast half succeeds"); + + // Unauthorized (generic) consume: refused by the gate, immediately. + let refused = manager + .resolve_funding_with_is_timeout_fallback( + AssetLockFunding::FromExistingAssetLock { + out_point, + consume_invitation_voucher: false, + }, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + match refused { + Err(PlatformWalletError::AssetLockTransaction(msg)) => assert!( + msg.contains("invitation voucher"), + "expected the voucher-refusal error, got: {msg}" + ), + Err(e) => panic!("expected the voucher-refusal error, got {e:?}"), + Ok(_) => panic!("expected the voucher-refusal error, got Ok(..)"), + } + + // Authorized (reclaim) consume: passes the gate. The lock has no + // proof yet, so the resolver proceeds into the proof wait — getting + // parked there (rather than an immediate refusal) is the positive + // signal that the gate admitted the call. + let authorized = tokio::time::timeout( + std::time::Duration::from_millis(500), + manager.resolve_funding_with_is_timeout_fallback( + AssetLockFunding::FromExistingAssetLock { + out_point, + consume_invitation_voucher: true, + }, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ), + ) + .await; + match authorized { + Err(_elapsed) => {} // parked in the proof wait — past the gate + Ok(Err(PlatformWalletError::AssetLockTransaction(msg))) + if msg.contains("invitation voucher") => + { + panic!("authorized reclaim consume must pass the voucher gate: {msg}") + } + Ok(other) => { + // Any other outcome also proves the gate admitted the call. + drop(other); + } + } + } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index 85bc5571c0a..b9c810b6d26 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -58,6 +58,31 @@ pub struct AssetLockManager { /// `let _cs = ...`. Every emitted changeset now flows straight /// into `queue_persist` here. pub(super) persister: WalletPersister, + /// Serializes the funding-index-critical section of + /// [`broadcast_funded_asset_lock`](Self::broadcast_funded_asset_lock) — + /// build (index allocation + in-memory mark-used) through the address-pool + /// persist/flush. Without it, two concurrent builds can interleave so that + /// the FIRST build's pool snapshot (collected before the second build + /// marked its index) is persisted LAST, rolling the durable snapshot back + /// to a state where the second index reads unused — after a restart the + /// next invitation re-selects that index and re-exports the same bearer + /// voucher key. A dedicated mutex (never held while awaiting + /// `wallet_manager`'s lock from outside this section, and never acquired + /// by code that already holds it) avoids the self-deadlock a + /// `wallet_manager.write()` guard would cause across the build→persist + /// span. Deliberately NOT held across the broadcast/proof-wait — only the + /// snapshot ordering needs serialization. + pub(super) build_persist_serial: tokio::sync::Mutex<()>, + /// Test-only gauge of builds currently at or past the + /// `build_persist_serial` gate within `broadcast_funded_asset_lock` + /// (incremented before the `lock().await`, RAII-decremented on every + /// exit from the call). Lets the concurrency regression test + /// synchronize on "the second build has reached the serialization + /// gate" instead of assuming a scheduling delay — while the first + /// build holds the lock, a gauge of 2 proves the second build cannot + /// yet have collected its pool snapshot. + #[cfg(test)] + pub(super) build_serial_gate: std::sync::atomic::AtomicUsize, } impl AssetLockManager { @@ -77,6 +102,9 @@ impl AssetLockManager { lock_notify, broadcaster, persister, + build_persist_serial: tokio::sync::Mutex::new(()), + #[cfg(test)] + build_serial_gate: std::sync::atomic::AtomicUsize::new(0), } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 953d687c182..1acd886a24b 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -151,6 +151,15 @@ pub enum AssetLockFunding { FromExistingAssetLock { /// The outpoint identifying the tracked asset lock (txid + output index). out_point: OutPoint, + /// Explicit authorization to consume an + /// [`AssetLockFundingType::IdentityInvitation`]-typed lock — a + /// DashPay invitation **bearer voucher** whose key was exported + /// into a shared link. Only the invitation reclaim flow sets this; + /// every generic resume/top-up path leaves it `false` and is + /// refused invitation locks by the resolver, so a voucher can + /// never be silently consumed into an unrelated local identity + /// (which would invalidate the invitee's already-shared claim). + consume_invitation_voucher: bool, }, } @@ -419,7 +428,28 @@ impl AssetLockManager { Err(e) => Err(e), } } - AssetLockFunding::FromExistingAssetLock { out_point } => { + AssetLockFunding::FromExistingAssetLock { + out_point, + consume_invitation_voucher, + } => { + // Invitation vouchers are bearer instruments: the credit + // output's private key was exported into a shared link, so + // consuming the lock through a generic resume/top-up would + // both misdirect the funds into a local identity and kill + // the invitee's claim. Refuse unless the caller carries the + // reclaim flow's explicit authorization. + if !consume_invitation_voucher + && self.tracked_funding_type(&out_point).await + == Some(AssetLockFundingType::IdentityInvitation) + { + return Err(PlatformWalletError::AssetLockTransaction(format!( + "asset lock {out_point} is a DashPay invitation voucher; \ + generic resume/top-up refuses to consume it (its key is \ + shared in the invitation link, and consuming it would \ + invalidate the invitee's claim) — use the invitation \ + reclaim flow, which passes explicit authorization" + ))); + } // 300s is an InstantSend-preference window, not a finality // timeout: on expiry the caller falls back to an unbounded // ChainLock wait, so a resumed broadcast lock never fails diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs index 1fef4fe1700..fd101ddba32 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs @@ -10,6 +10,24 @@ use super::super::manager::AssetLockManager; use super::super::tracked::{AssetLockStatus, TrackedAssetLock}; impl AssetLockManager { + /// The recorded [`AssetLockFundingType`] of a tracked lock, or `None` + /// when the outpoint is not tracked. Used by the funding resolver to + /// refuse consuming `IdentityInvitation` (bearer voucher) locks through + /// generic resume/top-up paths. + /// + /// [`AssetLockFundingType`]: + /// key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType + pub(crate) async fn tracked_funding_type( + &self, + out_point: &OutPoint, + ) -> Option + { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.tracked_asset_locks.get(out_point)) + .map(|lock| lock.funding_type) + } + /// Track a new asset lock in memory, returning a changeset describing /// the inserted entry. /// diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs index 6585ba0bb9f..ed20b65f11e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs @@ -28,14 +28,14 @@ use dashcore::hashes::{sha256, Hash, HashEngine}; use dashcore::secp256k1::{ecdsa::Signature, Message, Secp256k1, SecretKey}; use dpp::prelude::Identifier; use key_wallet::bip32::{ChildNumber, DerivationPath}; +use key_wallet::dip9::{ + DASH_COIN_TYPE, DASH_TESTNET_COIN_TYPE, FEATURE_PURPOSE, FEATURE_PURPOSE_DASHPAY_AUTO_ACCEPT, +}; use key_wallet::wallet::Wallet; use key_wallet::Network; use crate::error::PlatformWalletError; -/// DashPay auto-accept feature index per DIP-15. -const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; - /// Default lifetime of a generated auto-accept QR, in seconds (1 hour). /// /// DIP-15 mandates only that the proof's 4-byte timestamp *is* an expiry (and the @@ -87,13 +87,13 @@ pub fn auto_accept_derivation_path( expiry: u32, ) -> Result { let coin_type: u32 = match network { - Network::Mainnet => 5, - _ => 1, + Network::Mainnet => DASH_COIN_TYPE, + _ => DASH_TESTNET_COIN_TYPE, }; Ok(DerivationPath::from(vec![ - ChildNumber::from_hardened_idx(9).expect("valid"), + ChildNumber::from_hardened_idx(FEATURE_PURPOSE).expect("valid"), ChildNumber::from_hardened_idx(coin_type).expect("valid"), - ChildNumber::from_hardened_idx(DASHPAY_AUTO_ACCEPT_FEATURE).expect("valid"), + ChildNumber::from_hardened_idx(FEATURE_PURPOSE_DASHPAY_AUTO_ACCEPT).expect("valid"), ChildNumber::from_hardened_idx(expiry).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!("Invalid expiry index: {}", e)) })?, diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs index b70b385ad68..63b07a87f11 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs @@ -41,11 +41,13 @@ use crate::wallet::identity::network::identity_auth_derivation_path_for_type; /// DIP-15 child index for the `encToUserId` encryption key (2^16 — /// "to discount other potential derivations of this key in other -/// applications"). -pub const ENC_TO_USER_ID_CHILD: u32 = 1 << 16; +/// applications"). Canonical value lives in `key_wallet::dip9`; re-exposed +/// under the local name this module's callers use. +pub const ENC_TO_USER_ID_CHILD: u32 = key_wallet::dip9::DASHPAY_CONTACT_INFO_ENC_TO_USER_ID_CHILD; /// DIP-15 child index for the `privateData` encryption key (2^16 + 1). -pub const PRIVATE_DATA_CHILD: u32 = (1 << 16) + 1; +/// Canonical value lives in `key_wallet::dip9`. +pub const PRIVATE_DATA_CHILD: u32 = key_wallet::dip9::DASHPAY_CONTACT_INFO_PRIVATE_DATA_CHILD; /// The deployed schema's `privateData` minimum length (bytes, IV included). const PRIVATE_DATA_MIN_LEN: usize = 48; diff --git a/packages/rs-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..6060013024a --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -0,0 +1,865 @@ +//! DashPay invitation link codec — DIP-13 sub-feature 3', legacy-compatible. +//! +//! An invitation packages a one-time ECDSA **voucher** private key together with +//! a reference to the InstantSend-locked funding transaction, so an invitee with +//! no Dash can register their own identity from it. The inviter optionally +//! includes their own DPNS username so the invitee can send a contact request +//! back. +//! +//! The link is the **legacy query form** shared with the reference wallets +//! (`dash-wallet` Android, `dashwallet-ios`), so a link produced here is +//! field-level cross-claimable with those wallets and vice versa: +//! +//! ```text +//! dashpay://invite +//! ?du= +//! &assetlocktx= +//! &pk= +//! &islock= # or omitted / "null" +//! [&display-name=] +//! [&avatar-url=] +//! ``` +//! +//! The interop contract is **emit strict/canonical, parse lenient** — exactly as +//! tolerantly as the live wallets: +//! - Parse accepts both the `dashpay://invite` scheme and the +//! `https://invitations.dashpay.io/applink` host, by field name and +//! order-independent (the two legacy wallets differ in param order). +//! - `islock` is optional: a missing param **and** the literal string `"null"` +//! (which Android emits for chainlock-confirmed invites) both mean "no instant +//! lock" — the claim reconstructs a ChainLock proof instead. +//! - `assetlocktx` is kept as the raw hex string; the claim tries it as-given +//! then byte-reversed on a fetch miss, mirroring the legacy endianness retry. +//! +//! # Security +//! +//! The `voucher_key` is **bearer money** — whoever holds the link can claim the +//! funded identity. The URI is a secret: callers MUST NOT log or persist it, and +//! the voucher key is never stored (it is HD-derived and re-derivable from the +//! funding index). Parsing is bounded (URI length cap) so a hostile link can't +//! force a large allocation; the WIF is decoded + compression-checked at parse, +//! and its network is validated against the wallet at claim (a wrong-network WIF +//! is a valid key on the wrong chain, caught before the funding fetch). + +use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dashcore::transaction::special_transaction::TransactionPayload; +use dashcore::{Network, PrivateKey, ScriptBuf, Transaction}; +use dpp::prelude::AssetLockProof; + +use crate::error::PlatformWalletError; + +/// The `dashpay://invite` custom scheme — the canonical form we emit and the +/// primary form we parse (QR / in-person / deep link). `invite` is the URI +/// authority (Android's `URI_PREFIX`), so the accepted host is exactly `invite`. +const INVITATION_SCHEME: &str = "dashpay"; +const INVITATION_SCHEME_HOST: &str = "invite"; +/// The full canonical prefix we emit. +const INVITATION_SCHEME_PREFIX: &str = "dashpay://invite"; + +/// The AppsFlyer OneLink applink the iOS reference wallet emits +/// (`https://invitations.dashpay.io/applink?…`). Parsed as a first-class +/// alternative to the custom scheme (field-level interop). +const INVITATION_APPLINK_HOST: &str = "invitations.dashpay.io"; +const INVITATION_APPLINK_PATH: &str = "/applink"; + +/// Query parameter names — identical to the legacy wallets' contract. +const PARAM_USER: &str = "du"; +const PARAM_ASSET_LOCK_TX: &str = "assetlocktx"; +const PARAM_PRIVATE_KEY: &str = "pk"; +const PARAM_IS_LOCK: &str = "islock"; +const PARAM_DISPLAY_NAME: &str = "display-name"; +const PARAM_AVATAR_URL: &str = "avatar-url"; + +/// Android emits this literal for the `islock` value when the funding was +/// confirmed by a ChainLock rather than an InstantSend lock; treat it as "no +/// instant lock" (reconstruct a ChainLock proof at claim), NOT as hex to decode. +const IS_LOCK_NULL_SENTINEL: &str = "null"; + +/// Max chars of the whole URI accepted before parsing (anti-DoS). A real link — +/// username + txid (64) + WIF (~52) + islock hex (~400) + optional avatar url — +/// is well under 2 KB; 8192 is comfortable headroom while bounding the +/// allocation a hostile link can force. +const MAX_INVITATION_URI_LEN: usize = 8192; + +/// Max length (bytes) of a UTF-8 string field (username / display name / avatar +/// url). DPNS labels are short; this only bounds a hostile link. +const MAX_STR_BYTES: usize = 2048; + +/// Inviter contact-bootstrap info — present iff the link carries any of `du`, +/// `display-name`, or `avatar-url`. Absent ⇒ pure funding voucher. The link does +/// not carry the inviter's identity id (the legacy format has no such field); +/// the invitee resolves it from `username` via DPNS at contact-bootstrap time. +/// +/// `username` is optional: the reference wallets emit `display-name`/`avatar-url` +/// in blocks independent of `du`, so a `du`-less link can still carry inviter +/// metadata (surfaced in the preview even though no contact request is possible +/// without a username). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InviterInfo { + /// The inviter's DPNS username (`du`), shown to the invitee and used to + /// resolve the inviter's identity for the contact request. `None` for a + /// metadata-only (`du`-less) link. + pub username: Option, + /// Optional display name (`display-name`) for the claim UI. + pub display_name: Option, + /// Optional avatar url (`avatar-url`) for the claim UI. + pub avatar_url: Option, +} + +/// A decoded invitation, ready for claim. +/// +/// Unlike the funding proof (which is fetched at claim), everything here comes +/// straight from the link: the bearer voucher key, the funding txid to fetch, +/// and the optional InstantSend lock hex. +pub struct ParsedInvitation { + /// One-time ECDSA voucher private key that funds the invitee's identity + /// create (signs the asset-lock's outer state-transition signature). + pub voucher_key: SecretKey, + /// The network the voucher key's WIF was encoded for (`0xCC` ⇒ Mainnet, + /// `0xEF` ⇒ the testnet family). The claim path rejects a link whose WIF + /// network does not match the wallet's, turning a wrong-network link into a + /// clear error instead of a mysterious fetch miss. + pub voucher_key_network: Network, + /// The funding transaction id as carried in the link (`assetlocktx`), + /// lowercased. Kept as the raw hex string so the claim can try it as-given + /// and byte-reversed on a fetch miss (old iOS links are little-endian). + pub funding_txid: String, + /// The InstantSend lock, lowercase consensus hex — `None` when the link + /// omitted `islock` or set it to `"null"` (a ChainLock-confirmed invite). + pub islock_hex: Option, + /// Inviter contact-bootstrap info; `None` ⇒ pure funding voucher (no `du`). + pub inviter: Option, +} + +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 and the funding txid — the whole point of the + /// type is to carry a bearer secret, which must never reach a log. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ParsedInvitation") + .field("voucher_key", &"") + .field("funding_txid", &"") + .field("has_islock", &self.islock_hex.is_some()) + .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). This +/// is the selector that binds the voucher key to its funded credit output. +/// `pub(crate)` so the claim-side proof-assembly tests can build a funding tx. +pub(crate) fn voucher_credit_script(voucher_key: &SecretKey) -> ScriptBuf { + let secp = Secp256k1::new(); + let pubkey = PublicKey::from_secret_key(&secp, voucher_key); + let hash = dashcore::PublicKey::new(pubkey).pubkey_hash(); + ScriptBuf::new_p2pkh(&hash) +} + +// --------------------------------------------------------------------------- +// Percent-encoding (URI query values). Only %XX escapes — never `+` for space +// (that is form encoding; `Uri.getQueryParameter` on the legacy wallets does +// not treat `+` as space, so neither do we). +// --------------------------------------------------------------------------- + +/// Percent-encode a query value, passing the RFC 3986 unreserved set through +/// untouched (which covers hex, base58 WIF, and DPNS labels) and `%`-escaping +/// everything else. Encoding an already-safe value is a no-op. +fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +/// Decode a percent-encoded query value (`%XX` → byte). Leaves `+` literal. +/// Errors on a malformed escape or non-UTF-8 result. +fn percent_decode(s: &str) -> Result { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hi = bytes + .get(i + 1) + .and_then(|c| (*c as char).to_digit(16)) + .ok_or_else(|| invalid("malformed percent-escape in invitation link"))?; + let lo = bytes + .get(i + 2) + .and_then(|c| (*c as char).to_digit(16)) + .ok_or_else(|| invalid("malformed percent-escape in invitation link"))?; + out.push((hi * 16 + lo) as u8); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).map_err(|_| invalid("invitation link field is not valid UTF-8")) +} + +/// Split a query string (`k1=v1&k2=v2`) into percent-decoded key/value pairs, +/// order-independent. A blank segment or a segment without `=` is skipped. +fn parse_query(query: &str) -> Result, PlatformWalletError> { + let mut pairs = Vec::new(); + for segment in query.split('&') { + if segment.is_empty() { + continue; + } + let Some((raw_key, raw_val)) = segment.split_once('=') else { + continue; + }; + pairs.push((percent_decode(raw_key)?, percent_decode(raw_val)?)); + } + Ok(pairs) +} + +/// Look up a field by name (first match wins), returning it trimmed. `None` for +/// a missing or blank value. +fn field<'a>(pairs: &'a [(String, String)], name: &str) -> Option<&'a str> { + pairs + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.trim()) + .filter(|v| !v.is_empty()) +} + +/// The parts of an invitation URI we gate on. Extracted with a small hand parser +/// (no `url` crate in this crate's deps) so the transport check is anchored to +/// the real scheme/host/path — not a substring/prefix that a decoy URL could +/// smuggle (`dashpay://inviteXYZ`, `https://evil/?next=invitations.dashpay.io/applink&…`). +struct UriParts<'a> { + scheme: &'a str, + /// Authority host, lowercased for comparison (userinfo + port stripped). + host: String, + path: &'a str, + query: Option<&'a str>, +} + +/// Strip `userinfo@` and `:port` from an authority, leaving the bare host. +fn authority_host(authority: &str) -> &str { + let after_userinfo = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + // A bare `host:port` — take the host. (Our hosts are never bracketed IPv6.) + after_userinfo.split(':').next().unwrap_or(after_userinfo) +} + +/// Split `scheme://authority/path?query#fragment` into the parts we gate on. +/// Requires the `://` authority form (both accepted transports use it) and drops +/// the fragment. Returns `None` for a shape we don't recognize as a URI. +fn split_uri(uri: &str) -> Option> { + let (scheme, rest) = uri.split_once(':')?; + let rest = rest.strip_prefix("//")?; + let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..auth_end]; + let after_auth = &rest[auth_end..]; + // Drop the fragment, then split path from query. + let after_auth = after_auth.split('#').next().unwrap_or(after_auth); + let (path, query) = match after_auth.split_once('?') { + Some((p, q)) => (p, Some(q)), + None => (after_auth, None), + }; + Some(UriParts { + scheme, + host: authority_host(authority).to_ascii_lowercase(), + path, + query, + }) +} + +/// Encode an invitation into a legacy-compatible `dashpay://invite?…` link. +/// +/// The returned URI **contains the plaintext voucher key** (WIF) — treat it as a +/// secret (do not log or persist it). `network` sets the WIF network byte +/// (`0xCC` mainnet / `0xEF` testnet) and the key is emitted **compressed** (the +/// credit-output hash160 uses the compressed pubkey). The funding txid and +/// InstantSend lock are read from `asset_lock`; a ChainLock proof emits no +/// `islock`. `inviter` is `Some` only when the inviter opted into the +/// contact-bootstrap (the link then carries `du`/`display-name`/`avatar-url`). +pub fn encode_invitation_uri( + voucher_key: &SecretKey, + network: Network, + asset_lock: &AssetLockProof, + inviter: Option<&InviterInfo>, +) -> Result { + // txid (big-endian display hex) + optional islock hex from the proof. + let (funding_txid, islock_hex) = match asset_lock { + AssetLockProof::Instant(instant) => { + let txid = instant.transaction().txid().to_string(); + let islock = hex::encode(dashcore::consensus::serialize(instant.instant_lock())); + (txid, Some(islock)) + } + AssetLockProof::Chain(chain) => (chain.out_point.txid.to_string(), None), + }; + + // WIF, compressed (the default for `PrivateKey::new`), network-correct. + let wif = PrivateKey::new(*voucher_key, network).to_wif(); + + let mut query = String::new(); + let mut push = |key: &str, val: &str| { + if !query.is_empty() { + query.push('&'); + } + query.push_str(key); + query.push('='); + query.push_str(&percent_encode(val)); + }; + + // `du` is emitted first (canonical) when the inviter opted in with a + // username; a pure funding voucher emits a `du`-less link (still parseable — + // iOS accepts it). + if let Some(username) = inviter.and_then(|info| info.username.as_ref()) { + if username.len() > MAX_STR_BYTES { + return Err(invalid("inviter username too long")); + } + push(PARAM_USER, username); + } + push(PARAM_ASSET_LOCK_TX, &funding_txid); + push(PARAM_PRIVATE_KEY, &wif); + if let Some(islock) = &islock_hex { + push(PARAM_IS_LOCK, islock); + } + if let Some(info) = inviter { + if let Some(display_name) = &info.display_name { + if display_name.len() > MAX_STR_BYTES { + return Err(invalid("inviter display name too long")); + } + push(PARAM_DISPLAY_NAME, display_name); + } + if let Some(avatar_url) = &info.avatar_url { + if avatar_url.len() > MAX_STR_BYTES { + return Err(invalid("inviter avatar url too long")); + } + push(PARAM_AVATAR_URL, avatar_url); + } + } + + let uri = format!("{INVITATION_SCHEME_PREFIX}?{query}"); + // Reject a link the parser itself would reject. `MAX_STR_BYTES` bounds each + // field's RAW bytes, but percent-encoding can expand a field up to 3×, so + // several near-cap fields can push the formatted URI past + // `MAX_INVITATION_URI_LEN` (the exact cap `parse_invitation_uri` enforces). + // Fail here rather than emit a self-unparseable link — the create path + // surfaces this as a hard error *after* the funded voucher is recorded, so it + // stays reclaimable. + if uri.len() > MAX_INVITATION_URI_LEN { + return Err(invalid(format!( + "invitation link too long ({} chars; max {MAX_INVITATION_URI_LEN})", + uri.len() + ))); + } + Ok(uri) +} + +/// Parse a legacy-compatible invitation link into a [`ParsedInvitation`]. +/// +/// Accepts exactly the `dashpay://invite` scheme (host `invite`) and the +/// `https://invitations.dashpay.io/applink` URL (host + path anchored, so a +/// decoy URL that merely mentions the applink cannot smuggle a query in). Fields +/// are matched by name, order-independent. Requires `assetlocktx` + `pk` +/// (non-blank, each appearing at most once); `du` and `islock` are optional (a +/// missing/`"null"` `islock` is a ChainLock invite). The WIF is decoded and +/// compression-checked here; its **network** is captured for the claim path to +/// validate against the wallet (a wrong-network WIF is a valid key on the wrong +/// chain, so it must be caught there, not here). Does **not** fetch or validate +/// the funding tx — that happens at claim. +pub fn parse_invitation_uri(uri: &str) -> Result { + if uri.len() > MAX_INVITATION_URI_LEN { + return Err(invalid(format!( + "invitation link too long ({} chars; max {MAX_INVITATION_URI_LEN})", + uri.len() + ))); + } + + // Anchor the transport gate to the real scheme/host/path, not a prefix or + // substring: `dashpay://invite` (host == `invite`) or the applink URL + // (host == invitations.dashpay.io, path == /applink). A trailing slash on + // the applink path is tolerated. + let parts = split_uri(uri).ok_or_else(|| invalid("not a valid invitation link URI"))?; + let accepted = match parts.scheme.to_ascii_lowercase().as_str() { + INVITATION_SCHEME => { + parts.host == INVITATION_SCHEME_HOST && (parts.path.is_empty() || parts.path == "/") + } + "https" | "http" => { + parts.host == INVITATION_APPLINK_HOST + && parts.path.trim_end_matches('/') == INVITATION_APPLINK_PATH + } + _ => false, + }; + if !accepted { + return Err(invalid( + "not a dashpay://invite or https://invitations.dashpay.io/applink link", + )); + } + + let query = parts + .query + .ok_or_else(|| invalid("invitation link has no query parameters"))?; + let pairs = parse_query(query)?; + + // A duplicated required key is ambiguous and dangerous: the two reference + // wallets bind different occurrences (iOS the last, Android the first), so a + // `?pk=A&pk=B` link would claim different keys per wallet. Reject it. + for key in [PARAM_PRIVATE_KEY, PARAM_ASSET_LOCK_TX] { + if pairs.iter().filter(|(k, _)| k == key).count() > 1 { + return Err(invalid(format!( + "invitation link has a duplicated `{key}` field" + ))); + } + } + + // iOS minimum: `assetlocktx` + `pk` present and non-blank. Never reject on a + // missing/`"null"` `islock` or a missing `du`. + let assetlocktx = field(&pairs, PARAM_ASSET_LOCK_TX) + .ok_or_else(|| invalid("invitation link is missing the assetlocktx field"))?; + let pk = field(&pairs, PARAM_PRIVATE_KEY) + .ok_or_else(|| invalid("invitation link is missing the pk field"))?; + + // WIF: decode + require compression (the credit-output hash uses the + // compressed pubkey — an uncompressed key would produce a mismatching + // hash160 and a dead claim). `from_wif` rejects a non-Dash network byte but + // cannot tell mainnet from testnet against *this* wallet; the claim path does + // that (see `voucher_key_network`). + // `PrivateKey`/`SecretKey` are `Copy`, so the decoded binding is a second + // bearer-scalar copy alongside `voucher_key` — erase it on every exit + // (rejection included; `ParsedInvitation`'s wipe-on-drop guard doesn't + // exist yet on that path) to keep the parser's wiping policy airtight. + let mut private_key = PrivateKey::from_wif(pk) + .map_err(|e| invalid(format!("invitation pk is not a valid WIF key: {e}")))?; + if !private_key.compressed { + private_key.inner.non_secure_erase(); + return Err(invalid("invitation pk must be a compressed WIF key")); + } + let voucher_key = private_key.inner; + let voucher_key_network = private_key.network; + private_key.inner.non_secure_erase(); + + let funding_txid = assetlocktx.to_lowercase(); + + // `islock`: a missing param and the literal `"null"` both mean no instant + // lock. Kept as lowercase hex; decoded to an `InstantLock` only at claim. + let islock_hex = field(&pairs, PARAM_IS_LOCK) + .filter(|v| *v != IS_LOCK_NULL_SENTINEL) + .map(|v| v.to_lowercase()); + + // Inviter metadata is parsed field-by-field, independent of `du` (the + // reference wallets emit `display-name`/`avatar-url` in blocks separate from + // `du`, so a `du`-less link can still carry them). Present iff any field is. + // The identity id is not in the link; it is resolved from the username at + // contact-bootstrap time. + // + // Note: an unencoded `&` inside a legacy `avatar-url` would truncate that + // field at the naive `&` split. Cosmetic (avatar only, low likelihood) — not + // worth a full nested-query parser; the required fields are unaffected. + let username = field(&pairs, PARAM_USER).map(str::to_string); + let display_name = field(&pairs, PARAM_DISPLAY_NAME).map(str::to_string); + let avatar_url = field(&pairs, PARAM_AVATAR_URL).map(str::to_string); + let inviter = if username.is_some() || display_name.is_some() || avatar_url.is_some() { + Some(InviterInfo { + username, + display_name, + avatar_url, + }) + } else { + None + }; + + Ok(ParsedInvitation { + voucher_key, + voucher_key_network, + funding_txid, + islock_hex, + inviter, + }) +} + +/// Whether a voucher WIF encoded for `pk_network` is usable on a wallet running +/// `wallet_network`. +/// +/// WIF carries only two network bytes — `0xCC` (Mainnet) and `0xEF` (the whole +/// testnet family: Testnet/Devnet/Regtest) — so the meaningful check is +/// mainnet-vs-not: a testnet/devnet/regtest wallet accepts any `0xEF` WIF, and a +/// mainnet wallet accepts only a `0xCC` WIF. This catches a testnet link pasted +/// into a mainnet wallet (a valid key on the wrong chain) as a clear error +/// rather than a mysterious funding-tx fetch miss. +pub fn wif_network_matches(pk_network: Network, wallet_network: Network) -> bool { + matches!(pk_network, Network::Mainnet) == matches!(wallet_network, Network::Mainnet) +} + +/// Select the funded credit output the voucher key controls. +/// +/// The link carries the voucher key but not the output index; scan the fetched +/// asset-lock transaction's credit outputs for the one whose `script_pubkey` +/// matches the voucher key's P2PKH (compressed pubkey), and return its index. +/// Rejects a transaction with no matching credit output. The pk↔output binding +/// is itself consensus-enforced, so this is a fail-fast + correct-index guard, +/// not a security boundary — but selecting (rather than hard-coding index 0) is +/// required: a legacy invite's credit output need not be at index 0. +pub fn voucher_output_index( + transaction: &Transaction, + voucher_key: &SecretKey, +) -> Result { + let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &transaction.special_transaction_payload + else { + return Err(invalid( + "funding transaction is not an asset-lock special transaction", + )); + }; + let expected = voucher_credit_script(voucher_key); + payload + .credit_outputs + .iter() + .position(|out| out.script_pubkey == expected) + .map(|idx| idx as u32) + .ok_or_else(|| { + invalid("voucher key does not control any credit output of the funding transaction") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::{Transaction, TxOut}; + 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 { + username: Some("alice".to_string()), + display_name: Some("Alice Example".to_string()), + avatar_url: Some("https://example.com/a b.png?x=1".to_string()), + } + } + + /// Build an asset-lock tx whose credit output at `index` pays the voucher + /// key (and `index` decoy outputs before it that do not). + fn asset_lock_tx_paying_voucher_at(key: &SecretKey, index: usize) -> Transaction { + let decoy = SecretKey::from_slice(&[0x22u8; 32]).unwrap(); + let mut credit_outputs = Vec::new(); + for _ in 0..index { + credit_outputs.push(TxOut { + value: 50_000, + script_pubkey: voucher_credit_script(&decoy), + }); + } + credit_outputs.push(TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(key), + }); + let payload = AssetLockPayload { + version: 1, + credit_outputs, + }; + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), + } + } + + fn instant_proof_paying_voucher() -> AssetLockProof { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + AssetLockProof::Instant(InstantAssetLockProof::new(InstantLock::default(), tx, 0)) + } + + #[test] + fn wif_round_trip_preserves_compression_and_network() { + for (network, first_byte) in [(Network::Mainnet, 204u8), (Network::Testnet, 239u8)] { + let wif = PrivateKey::new(voucher(), network).to_wif(); + let decoded = PrivateKey::from_wif(&wif).expect("wif decodes"); + assert!(decoded.compressed, "voucher WIF must be compressed"); + assert_eq!(decoded.network, network, "network preserved"); + assert_eq!(decoded.inner.secret_bytes(), voucher().secret_bytes()); + // Network byte matches bitcoinj/legacy (0xCC mainnet, 0xEF testnet). + let raw = bs58::decode(&wif).into_vec().unwrap(); + assert_eq!(raw[0], first_byte); + } + } + + #[test] + fn encode_parse_round_trip_with_inviter() { + let proof = instant_proof_paying_voucher(); + let info = inviter_info(); + let uri = encode_invitation_uri(&voucher(), Network::Testnet, &proof, Some(&info)) + .expect("encode"); + assert!(uri.starts_with("dashpay://invite?")); + + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert_eq!(parsed.voucher_key.secret_bytes(), voucher().secret_bytes()); + assert_eq!(parsed.inviter, Some(info)); + assert!(parsed.islock_hex.is_some()); + // The parsed txid matches the proof's transaction id (big-endian). + let expected_txid = match &proof { + AssetLockProof::Instant(i) => i.transaction().txid().to_string(), + _ => unreachable!(), + }; + assert_eq!(parsed.funding_txid, expected_txid); + } + + #[test] + fn encode_parse_round_trip_pure_voucher_no_inviter() { + let proof = instant_proof_paying_voucher(); + let uri = + encode_invitation_uri(&voucher(), Network::Mainnet, &proof, None).expect("encode"); + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert!(parsed.inviter.is_none(), "du-less link ⇒ no inviter"); + assert!(!uri.contains("du="), "pure voucher emits no du"); + } + + /// Params in a non-canonical order must still parse (field-level, not + /// byte-level, interop — the two legacy wallets differ in order). + #[test] + fn parse_is_order_independent() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!("dashpay://invite?islock=deadbeef&pk={wif}&du=bob&assetlocktx=aabbcc"); + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert_eq!(parsed.funding_txid, "aabbcc"); + assert_eq!(parsed.islock_hex.as_deref(), Some("deadbeef")); + assert_eq!( + parsed.inviter.as_ref().unwrap().username.as_deref(), + Some("bob") + ); + } + + /// The `https://invitations.dashpay.io/applink` host is accepted as a + /// first-class alternative to the custom scheme (iOS emits it). + #[test] + fn parse_accepts_applink_host() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!( + "https://invitations.dashpay.io/applink?du=carol&assetlocktx=aabb&pk={wif}&islock=cc" + ); + let parsed = parse_invitation_uri(&uri).expect("parse applink"); + assert_eq!( + parsed.inviter.as_ref().unwrap().username.as_deref(), + Some("carol") + ); + assert_eq!(parsed.funding_txid, "aabb"); + } + + /// `islock` present / absent / `"null"`: only a real hex value yields + /// `Some`; both a missing param and the literal `"null"` yield `None` + /// (a ChainLock-confirmed invite Android's own validator accepts). + #[test] + fn parse_islock_present_absent_and_null() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let present = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=00aa11"); + assert_eq!( + parse_invitation_uri(&present) + .unwrap() + .islock_hex + .as_deref(), + Some("00aa11") + ); + + let absent = format!("dashpay://invite?assetlocktx=aa&pk={wif}"); + assert!(parse_invitation_uri(&absent).unwrap().islock_hex.is_none()); + + let null = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=null"); + assert!( + parse_invitation_uri(&null).unwrap().islock_hex.is_none(), + "islock=null must be treated as no instant lock" + ); + } + + /// A `du`-less link still parses (iOS accepts du-less links). + #[test] + fn parse_accepts_du_less_link() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=bb"); + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert!(parsed.inviter.is_none()); + } + + #[test] + fn parse_rejects_missing_pk_or_assetlocktx() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + // No pk. + assert!(parse_invitation_uri("dashpay://invite?assetlocktx=aa").is_err()); + // No assetlocktx. + assert!(parse_invitation_uri(&format!("dashpay://invite?pk={wif}")).is_err()); + // Blank assetlocktx is treated as missing. + assert!(parse_invitation_uri(&format!("dashpay://invite?assetlocktx=&pk={wif}")).is_err()); + } + + #[test] + fn parse_rejects_wrong_scheme_and_host() { + assert!(parse_invitation_uri("https://example.com/foo?pk=x").is_err()); + assert!(parse_invitation_uri("dashpay://contact?pk=x").is_err()); + } + + #[test] + fn parse_rejects_uncompressed_wif() { + let uncompressed = PrivateKey::new_uncompressed(voucher(), Network::Testnet).to_wif(); + let uri = format!("dashpay://invite?assetlocktx=aa&pk={uncompressed}"); + let err = parse_invitation_uri(&uri).unwrap_err(); + assert!(err.to_string().contains("compressed")); + } + + #[test] + fn parse_rejects_bad_wif() { + let err = parse_invitation_uri("dashpay://invite?assetlocktx=aa&pk=not-a-wif").unwrap_err(); + assert!(err.to_string().contains("WIF")); + } + + #[test] + fn parse_rejects_oversized_uri() { + let huge = format!("dashpay://invite?pk={}", "z".repeat(MAX_INVITATION_URI_LEN)); + let err = parse_invitation_uri(&huge).unwrap_err(); + assert!(err.to_string().contains("too long")); + } + + /// A hand-crafted Android-style link (params in Android's emit order, with + /// display-name + avatar-url) parses to the right fields. + #[test] + fn parse_android_style_link() { + let wif = PrivateKey::new(voucher(), Network::Mainnet).to_wif(); + // Android order: du, assetlocktx, pk, islock, display-name, avatar-url. + let uri = format!( + "dashpay://invite?du=satoshi&assetlocktx={txid}&pk={wif}&islock={islock}&display-name=Sat%20Oshi&avatar-url=https%3A%2F%2Fimg.example%2Fa.png", + txid = "e8b43025641eea4fd21190f01bd870ef90f1a8b199d8fc3376c5b62c0b1a179d", + islock = "01" + ); + let parsed = parse_invitation_uri(&uri).expect("parse android link"); + assert_eq!( + parsed.funding_txid, + "e8b43025641eea4fd21190f01bd870ef90f1a8b199d8fc3376c5b62c0b1a179d" + ); + assert_eq!(parsed.islock_hex.as_deref(), Some("01")); + let inviter = parsed.inviter.as_ref().expect("inviter"); + assert_eq!(inviter.username.as_deref(), Some("satoshi")); + assert_eq!(inviter.display_name.as_deref(), Some("Sat Oshi")); + assert_eq!( + inviter.avatar_url.as_deref(), + Some("https://img.example/a.png") + ); + } + + /// The voucher output is selected by pk↔script match, not hard-coded to 0. + #[test] + fn voucher_output_index_selects_matching_output() { + let key = voucher(); + // Voucher output sits at index 2, behind two decoy outputs. + let tx = asset_lock_tx_paying_voucher_at(&key, 2); + assert_eq!(voucher_output_index(&tx, &key).unwrap(), 2); + } + + #[test] + fn voucher_output_index_rejects_no_match() { + let key = voucher(); + let other = SecretKey::from_slice(&[0x33u8; 32]).unwrap(); + let tx = asset_lock_tx_paying_voucher_at(&other, 0); + let err = voucher_output_index(&tx, &key).unwrap_err(); + assert!(err.to_string().contains("does not control")); + } + + #[test] + fn voucher_output_index_rejects_non_asset_lock_tx() { + let key = voucher(); + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + assert!(voucher_output_index(&tx, &key).is_err()); + } + + /// A `du`-less link that still carries `display-name`/`avatar-url` must + /// surface them (the reference wallets emit those independently of `du`). + #[test] + fn parse_du_less_link_keeps_metadata() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!( + "dashpay://invite?assetlocktx=aa&pk={wif}&display-name=No%20User&avatar-url=https%3A%2F%2Fimg%2Fx.png" + ); + let parsed = parse_invitation_uri(&uri).expect("parse"); + let inviter = parsed.inviter.as_ref().expect("metadata-only inviter"); + assert!(inviter.username.is_none(), "no du ⇒ no username"); + assert_eq!(inviter.display_name.as_deref(), Some("No User")); + assert_eq!(inviter.avatar_url.as_deref(), Some("https://img/x.png")); + } + + /// A duplicated required key is ambiguous (iOS binds the last, Android the + /// first) — reject rather than claim a wallet-dependent key. + #[test] + fn parse_rejects_duplicate_required_keys() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let other = PrivateKey::new( + SecretKey::from_slice(&[0x44u8; 32]).unwrap(), + Network::Testnet, + ) + .to_wif(); + let dup_pk = format!("dashpay://invite?assetlocktx=aa&pk={wif}&pk={other}"); + assert!(parse_invitation_uri(&dup_pk) + .unwrap_err() + .to_string() + .contains("duplicated")); + + let dup_tx = format!("dashpay://invite?assetlocktx=aa&assetlocktx=bb&pk={wif}"); + assert!(parse_invitation_uri(&dup_tx) + .unwrap_err() + .to_string() + .contains("duplicated")); + } + + /// The scheme/host gate must be anchored: a suffixed scheme host + /// (`dashpay://inviteXYZ`) and a decoy URL that merely *mentions* the applink + /// host in its own query must both be rejected — otherwise the decoy's query + /// (with a `pk`/`assetlocktx`) would be parsed. + #[test] + fn parse_rejects_decoy_scheme_and_host() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + // Suffixed authority — not exactly `invite`. + let suffixed = format!("dashpay://inviteXYZ?assetlocktx=aa&pk={wif}"); + assert!(parse_invitation_uri(&suffixed).is_err()); + // Decoy host: the real host is evil.example; the applink string only + // appears inside the decoy's own query value. + let decoy = format!( + "https://evil.example/r?next=invitations.dashpay.io/applink&assetlocktx=aa&pk={wif}" + ); + assert!(parse_invitation_uri(&decoy).is_err()); + // Wrong path on the right host. + let wrong_path = format!("https://invitations.dashpay.io/evil?assetlocktx=aa&pk={wif}"); + assert!(parse_invitation_uri(&wrong_path).is_err()); + } + + /// WIF network compatibility is mainnet-vs-not: a testnet WIF is rejected on + /// a mainnet wallet, but accepted on any testnet-family wallet. + #[test] + fn wif_network_matches_is_mainnet_vs_testnet_family() { + assert!(wif_network_matches(Network::Mainnet, Network::Mainnet)); + assert!(wif_network_matches(Network::Testnet, Network::Testnet)); + // WIF decodes 0xEF to Testnet; a devnet/regtest wallet must still accept. + assert!(wif_network_matches(Network::Testnet, Network::Devnet)); + assert!(wif_network_matches(Network::Testnet, Network::Regtest)); + // Cross mainnet/testnet is rejected (the wrong-chain paste). + assert!(!wif_network_matches(Network::Testnet, Network::Mainnet)); + assert!(!wif_network_matches(Network::Mainnet, Network::Testnet)); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index 74b70c27732..c0a0687b44b 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,8 @@ 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, voucher_output_index, wif_network_matches, + InviterInfo, ParsedInvitation, +}; pub use validation::pubkey_binds_expected_key_data; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 5ab18f94d0a..fc96fd6ead2 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,31 @@ 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`]. + /// + /// Defaulted to an "unsupported" error so adding this method is not a + /// source-breaking change for existing provider implementations: + /// providers that never create invitations need no override, and a + /// create attempted through one fails loudly (the invitation cannot be + /// packaged without the exported key) rather than at compile time. + /// Invitation-capable providers override with the path-gated export. + async fn export_invitation_private_key( + &self, + _path: &key_wallet::bip32::DerivationPath, + ) -> Result { + Err(PlatformWalletError::InvalidIdentityData( + "invitation private-key export is not supported by this crypto provider".to_string(), + )) + } + /// DIP-15 `accountReference` for a send: the scalar at `path` (the sender's /// encryption key) keys the HMAC+mask over `compact_xpub`. Computed in the /// signer so the raw scalar never returns to platform-wallet. @@ -195,6 +220,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..171399056f3 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -0,0 +1,1079 @@ +//! 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::consensus::Decodable; +use dpp::dashcore::{InstantLock, OutPoint, PrivateKey, Transaction}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dpp::identity::v0::IdentityV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose, SecurityLevel}; +use dpp::prelude::{AssetLockProof, Identifier}; +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, voucher_output_index, wif_network_matches, +}; +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.05 DASH). The voucher is a +/// bearer credential, so the blast radius of a leaked link is bounded here in +/// Rust — not just in the UI. Sized for onboarding: the invitee spends the +/// voucher on identity creation **plus** a normal DPNS name (~0.03 DASH — the +/// legacy `DASH_PAY_FEE`), so the previous 0.01 cap was actually below a usable +/// invitation and rejected its own onboarding default. The contested/premium-name +/// tier (~0.25 DASH) is deferred until contested-name-via-invite claim exists; +/// raise this cap when it does. +pub const MAX_INVITATION_DUFFS: u64 = 5_000_000; + +/// Floor on the amount an invitation can lock (0.003 DASH). A voucher funds a +/// Platform identity operation, and creating an identity — which is what the +/// 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; + +/// Claim-by-fetch bound: how many times to look up the funding tx (each attempt +/// tries both byte orders) before giving up. InstantSend/ChainLock finality does +/// not guarantee the invitee's DAPI node has indexed the tx yet, so a freshly +/// shared invitation can miss purely from propagation lag; retrying bounds that. +const CLAIM_FETCH_MAX_ATTEMPTS: u32 = 5; + +/// Fixed backoff between claim-by-fetch attempts (matches the identity-create +/// fetch-retry cadence elsewhere in the wallet). 5 attempts × 3s ≈ 12s of +/// tolerance for propagation delay before surfacing a "not found" error. +const CLAIM_FETCH_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(3); + +/// RAII scrub for the voucher `PrivateKey` copy used on the claim path. +/// `dashcore::PrivateKey` wraps a `secp256k1::SecretKey` that has no +/// Drop-zeroize, so the imported bearer scalar would otherwise linger in memory +/// 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?…` link (legacy query form). **Contains the voucher + /// key** (WIF) — treat as a secret (never log or persist it). + pub uri: String, + /// The funding asset lock's outpoint (the tracked lock's identity). + pub out_point: dashcore::OutPoint, + /// 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, + } +} + +/// Byte-reverse a txid hex string. Old iOS invitation links carry the funding +/// txid in little-endian internal order; DAPI keys `getTransaction` by the +/// big-endian display id, so the claim retries with the id reversed on a miss +/// (mirrors Android's `Sha256Hash.wrap(id).reversedBytes` retry). +fn reverse_txid_hex(txid_hex: &str) -> Result { + let mut bytes = hex::decode(txid_hex).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "invitation funding txid is not valid hex: {e}" + )) + })?; + bytes.reverse(); + Ok(hex::encode(bytes)) +} + +impl IdentityWallet { + /// Create a DashPay invitation: fund a one-time asset-lock voucher at the + /// DIP-13 invitation path and return a shareable `dashpay://invite` link. + /// + /// `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. + /// + /// Requires a durably-persisting backend + /// ([`PlatformWalletPersistence::persists_durably`](crate::changeset::PlatformWalletPersistence::persists_durably)): + /// the exported voucher key is derived from the persisted funding index, so a + /// backend that drops writes would re-export the same bearer key after a + /// restart. A non-durable backend is refused before any funds move. + #[allow(clippy::too_many_arguments)] + pub async fn create_invitation( + &self, + amount_duffs: u64, + funding_account_index: u32, + inviter: Option, + expiry_unix: u32, + created_at_secs: 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(), + )); + } + + // Refuse to run on a backend that cannot durably persist. The one-time + // voucher key is derived from a persisted funding index and exported + // into a bearer link; on a backend that drops writes (e.g. + // `NoPlatformPersistence`) the index resets on restart, so the SAME key + // would be re-exported to a later invitation — the holder of the earlier + // link could then consume the later voucher. Checked before any funds + // move. + if !self.persister.persists_durably() { + return Err(PlatformWalletError::Persistence( + "invitation creation requires a durable persistence backend (see \ + PlatformWalletPersistence::persists_durably): a non-durable backend \ + would re-export the same bearer voucher key after a restart" + .to_string(), + )); + } + + // Build + broadcast the voucher asset lock at the invitation funding + // account (the builder auto-selects the next unused funding index and + // returns its derivation path). `identity_index` is unused for the + // `IdentityInvitation` funding type. Only the broadcast half runs here — + // the proof wait is deferred until AFTER the invitation record below is + // durably persisted, so an interruption during the (potentially long) + // proof wait can no longer orphan the funded lock from the reclaim UI. + let (path, out_point) = self + .asset_locks + .broadcast_funded_asset_lock( + amount_duffs, + funding_account_index, + AssetLockFundingType::IdentityInvitation, + 0, + asset_lock_signer, + ) + .await?; + + // Persist the inviter-side invitation record NOW — immediately after the + // broadcast succeeds, BEFORE every later fallible or slow step (the proof + // wait, the InstantSend check, the voucher-key export, the URI encode). + // The broadcast has already spent the DASH into the OP_RETURN, so any + // voucher that fails a later step is still a *funded, reclaimable* lock; + // recording it first is what keeps it visible in the "Sent + // invitations"/reclaim UI. A ChainLock-confirmed voucher is rejected + // further below as a usable *link* (the invitee needs an InstantSend + // proof), but it remains a funded, reclaimable lock and is already + // recorded here rather than orphaned. The store + flush are REQUIRED, + // not best-effort: a funded voucher we cannot durably record is a hard + // failure to surface, not a silent success. No secret is stored — only + // the funding index (display metadata; reclaim resumes by outpoint). If + // the funding path carries no u32 index tail (a structural can't-happen + // for the invitation account), warn-skip: an untrackable row can't be + // reclaimed either way. + if let Some(funding_index) = funding_index_from_path(&path) { + let mut inv_cs = InvitationChangeSet::default(); + inv_cs.invitations.insert( + out_point, + InvitationEntry { + out_point, + funding_index, + amount_duffs, + expiry_unix, + created_at_secs, + has_inviter: inviter.is_some(), + status: InvitationStatus::Created, + }, + ); + self.persister + .store(crate::changeset::PlatformWalletChangeSet { + invitations: Some(inv_cs), + ..Default::default() + }) + .and_then(|_| self.persister.flush()) + .map_err(|e| { + PlatformWalletError::AssetLockTransaction(format!( + "the invitation voucher is funded but its record could not be \ + persisted (it would be missing from Sent invitations and \ + unreclaimable): {e}" + )) + })?; + } else { + tracing::warn!( + "invitation funding path has no u32 index tail; \ + skipping the local invitation record" + ); + } + + // Wait for the funding proof. The row above is already durable, so a + // termination or failure inside this wait leaves a reclaimable, visible + // invitation rather than an orphaned lock. + let proof = self + .asset_locks + .wait_for_funded_asset_lock_proof(&out_point, funding_account_index) + .await?; + + // The invitee's `validate_claimable` accepts only an InstantSend proof. + // The proof wait falls back to a ChainLock proof if the IS lock doesn't + // propagate within its 300s preference window — reject emitting a link + // the invitee would silently reject (a dead voucher: funds locked, no + // signal). The funding lock was recorded before the wait, so it stays + // tracked/reclaimable; the inviter retries. + if !matches!(proof, AssetLockProof::Instant(_)) { + return Err(PlatformWalletError::AssetLockTransaction( + "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. + // The link carries the funding txid + islock (legacy query form), not + // the embedded proof; the invitee refetches the tx at claim. + let network = self.sdk.network; + let uri_result = encode_invitation_uri(&voucher_key, network, &proof, inviter.as_ref()); + voucher_key.non_secure_erase(); + let uri = uri_result?; + + Ok(Invitation { + uri, + out_point, + amount_duffs, + expiry_unix, + }) + } + + /// Claim a DashPay invitation: register a NEW identity for the invitee, + /// funded by the imported voucher. + /// + /// The link carries only the voucher key + funding txid (+ optional islock), + /// not the funding proof — so this **refetches** the funding transaction + /// from Core and reconstructs the asset-lock proof, mirroring the legacy + /// Android claim (`TopUpRepository.obtainAssetLockTransaction`): + /// 1. Fetch the tx by `funding_txid`; retry byte-reversed on a miss (old iOS + /// links are little-endian). + /// 2. Fail-fast that the fetched tx is really the funding tx, and (if an + /// islock is present) that the islock locks it. + /// 3. Select the funded credit output by pk↔script match (not index 0). + /// 4. Build an `InstantAssetLockProof` when an islock is present, else a + /// `ChainAssetLockProof` once the funding tx is chain-locked. + /// + /// The invitee's own identity keys (`keys_map`, derived from the invitee's + /// seed) are signed by `identity_signer`; the asset-lock's outer + /// state-transition signature is produced from the **imported voucher key** + /// (`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, + settings: Option, + ) -> Result + where + S: Signer + Send + Sync, + { + preflight_keys_map(&keys_map)?; + + // Reject a wrong-network link before any network work: a testnet WIF is a + // valid key on the wrong chain, so it would otherwise surface as a + // confusing funding-tx fetch miss rather than a clear "wrong network". + if !wif_network_matches(invitation.voucher_key_network, self.sdk.network) { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "invitation is for the {:?} network but this wallet is on {:?}", + invitation.voucher_key_network, self.sdk.network + ))); + } + + // Reconstruct the funding asset-lock proof by refetching the tx. Consensus + // enforces pk↔output, islock↔tx, and identity_id↔outpoint, so the local + // guards below are for fast-fail + correct-index selection, not theft + // prevention (a crafted link at worst yields a failed claim). + let asset_lock = self.reconstruct_asset_lock_proof(&invitation).await?; + + // The voucher key signs the asset lock's outer ST signature (ECDSA over + // the credit-output pubkey hash). Convert to the SDK's `PrivateKey`, + // scrubbed on every exit path by the `WipingPrivateKey` guard. + 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. An InstantSend or ChainLock proof both prove finality; + // a proof that no longer applies (e.g. the invite was already claimed) is + // rejected by consensus and surfaced to the caller. + let identity = placeholder + .put_to_platform_and_wait_for_response_with_private_key( + &self.sdk, + asset_lock, + &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) + } + + /// Refetch the funding transaction and rebuild its asset-lock proof. + /// + /// Mirrors Android `TopUpRepository.obtainAssetLockTransaction`: fetch by + /// txid (retry byte-reversed on a miss), verify the fetched tx is the funding + /// tx, select the voucher's credit output, and assemble an InstantSend proof + /// (when the link carried an islock) or a ChainLock proof (islock absent / + /// `"null"` — a chainlock-confirmed invite). + async fn reconstruct_asset_lock_proof( + &self, + invitation: &ParsedInvitation, + ) -> Result { + let sdk = &self.sdk; + let fetched = fetch_funding_tx_with_retry( + &invitation.funding_txid, + |txid| async move { + sdk.get_transaction(&txid) + .await + .map_err(PlatformWalletError::Sdk) + }, + || tokio::time::sleep(CLAIM_FETCH_RETRY_DELAY), + ) + .await? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "invitation funding transaction not found (tried both byte orders across \ + repeated attempts); it may not have propagated to the queried DAPI node yet — \ + retry shortly" + .to_string(), + ) + })?; + assemble_asset_lock_proof( + fetched.transaction, + fetched.is_chain_locked, + fetched.height, + invitation, + ) + } +} + +/// Fetch the claim's funding transaction with the bounded propagation retry — +/// the injectable orchestration seam of `reconstruct_asset_lock_proof` (tests +/// script `fetch`/`delay`; production passes `Sdk::get_transaction` and a +/// `tokio::time::sleep`). +/// +/// Two independent concerns are layered here: +/// 1. Byte order — old iOS links carry the txid little-endian, so a +/// canonical miss is retried byte-reversed **within the same attempt** +/// (a compatibility fallback, not a temporal retry). A transport error is +/// NOT masked as a miss — it propagates immediately (it is not a "not +/// indexed yet" signal, so retrying it would only hide it). +/// 2. Propagation lag — InstantSend/ChainLock finality does not guarantee +/// the queried DAPI node has indexed the tx yet, so a fresh invitation +/// can miss on both byte orders purely from propagation delay. Retry up +/// to [`CLAIM_FETCH_MAX_ATTEMPTS`] attempts with one `delay` between +/// attempts — and none after the last (the caller surfaces the miss +/// immediately). +/// +/// Returns `Ok(None)` only after every attempt missed on both byte orders. +async fn fetch_funding_tx_with_retry( + funding_txid: &str, + mut fetch: F, + mut delay: D, +) -> Result, PlatformWalletError> +where + F: FnMut(String) -> FFut, + FFut: std::future::Future, PlatformWalletError>>, + D: FnMut() -> DFut, + DFut: std::future::Future, +{ + for attempt in 0..CLAIM_FETCH_MAX_ATTEMPTS { + if let Some(tx) = fetch(funding_txid.to_string()).await? { + return Ok(Some(tx)); + } + // Reversed lookup is computed lazily on a canonical miss, so a hit + // never depends on the txid being reversible hex. + let reversed = reverse_txid_hex(funding_txid)?; + if let Some(tx) = fetch(reversed).await? { + return Ok(Some(tx)); + } + if attempt + 1 < CLAIM_FETCH_MAX_ATTEMPTS { + delay().await; + } + } + Ok(None) +} + +/// Assemble the asset-lock proof from an already-fetched funding transaction — the +/// pure, testable core of the claim reconstruction (the fetch/retry lives in +/// `reconstruct_asset_lock_proof`). Validates the tx is the funding tx (either byte +/// order), selects the voucher's credit output, and builds an InstantSend proof +/// (link carried an islock) or a ChainLock proof (islock absent), requiring +/// chain-lock finality for the latter. +fn assemble_asset_lock_proof( + transaction: Transaction, + is_chain_locked: bool, + height: u32, + invitation: &ParsedInvitation, +) -> Result { + // Fail-fast: the fetched tx must actually be the funding tx (either byte + // order). DAPI returns whatever tx matches the id we asked for, so this + // guards a backend that answers with an unrelated tx. + let fetched_txid = transaction.txid().to_string(); + let reversed_txid = reverse_txid_hex(&invitation.funding_txid).ok(); + if fetched_txid != invitation.funding_txid + && reversed_txid.as_deref() != Some(fetched_txid.as_str()) + { + return Err(PlatformWalletError::InvalidIdentityData( + "fetched transaction id does not match the invitation funding txid".to_string(), + )); + } + + // Select the funded credit output the voucher key controls (not index 0 + // — a legacy invite's credit output need not be first). + let output_index = voucher_output_index(&transaction, &invitation.voucher_key)?; + + match &invitation.islock_hex { + Some(islock_hex) => { + let islock_bytes = hex::decode(islock_hex).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "invitation islock is not valid hex: {e}" + )) + })?; + // The hex is not self-describing; the modern deterministic islock + // (ISDLOCK) carries its version byte, which is exactly what the + // consensus decoder reads first. + let instant_lock = InstantLock::consensus_decode(&mut islock_bytes.as_slice()) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "invitation islock could not be decoded: {e}" + )) + })?; + if instant_lock.txid != transaction.txid() { + return Err(PlatformWalletError::InvalidIdentityData( + "invitation islock does not lock the funding transaction".to_string(), + )); + } + Ok(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + transaction, + output_index, + ))) + } + None => { + // ChainLock invite: the proof references the outpoint + a chain-locked + // core height. Require the funding tx to be chain-locked so the proof + // proves finality; the inviter/invitee retries once the block is + // chain-locked otherwise. + if !is_chain_locked { + return Err(PlatformWalletError::InvalidIdentityData( + "chainlock invitation funding transaction is not yet chain-locked; \ + retry once it confirms" + .to_string(), + )); + } + let out_point = OutPoint::new(transaction.txid(), output_index); + let out_point_bytes: [u8; 36] = out_point.into(); + Ok(AssetLockProof::Chain(ChainAssetLockProof::new( + height, + out_point_bytes, + ))) + } + } +} + +#[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); + } + + // --- assemble_asset_lock_proof: the claim-time proof reconstruction guards --- + + use crate::wallet::identity::crypto::invitation::{voucher_credit_script, ParsedInvitation}; + use dpp::dashcore::consensus::Encodable; + use dpp::dashcore::secp256k1::SecretKey; + use dpp::dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + use dpp::dashcore::transaction::special_transaction::TransactionPayload; + use dpp::dashcore::{Network, TxOut}; + + fn voucher_secret() -> SecretKey { + SecretKey::from_slice(&[0x11u8; 32]).unwrap() + } + + /// An asset-lock tx whose single credit output pays the voucher key. + fn funding_tx(key: &SecretKey) -> Transaction { + let payload = AssetLockPayload { + version: 1, + credit_outputs: vec![TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(key), + }], + }; + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), + } + } + + fn parsed( + key: SecretKey, + funding_txid: String, + islock_hex: Option, + ) -> ParsedInvitation { + ParsedInvitation { + voucher_key: key, + voucher_key_network: Network::Testnet, + funding_txid, + islock_hex, + inviter: None, + } + } + + /// A backend answering with an unrelated tx (id matches neither byte order) is + /// rejected before any proof is built. + #[test] + fn assemble_rejects_txid_mismatch() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let inv = parsed(key, "00".repeat(32), None); + let err = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap_err(); + assert!(format!("{err}").contains("does not match")); + } + + /// A ChainLock invite (no islock) whose funding tx is not yet chain-locked is + /// rejected — the proof must prove finality. + #[test] + fn assemble_chainlock_requires_chain_lock() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid().to_string(); + let inv = parsed(key, txid, None); + let err = assemble_asset_lock_proof(tx, false, 100, &inv).unwrap_err(); + assert!(format!("{err}").contains("not yet chain-locked")); + } + + /// A chain-locked ChainLock invite assembles a ChainLock proof at the tx's + /// voucher output. + #[test] + fn assemble_chainlock_ok_when_locked() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid().to_string(); + let inv = parsed(key, txid, None); + let proof = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + assert!(matches!(proof, AssetLockProof::Chain(_))); + } + + /// An islock that locks a DIFFERENT tx than the funding tx is rejected (the + /// txid-binding guard), so a link can't pair a valid islock with a foreign tx. + #[test] + fn assemble_rejects_islock_for_wrong_tx() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid().to_string(); + // A default islock carries a zeroed txid, which won't equal the funding tx. + let mut islock_bytes = Vec::new(); + InstantLock::default() + .consensus_encode(&mut islock_bytes) + .unwrap(); + let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); + let err = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap_err(); + assert!(format!("{err}").contains("does not lock the funding transaction")); + } + + // --- fetch_funding_tx_with_retry: the claim propagation-retry seam --- + + mod fetch_retry { + use std::collections::VecDeque; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::{Arc, Mutex}; + + use super::super::{ + fetch_funding_tx_with_retry, reverse_txid_hex, CLAIM_FETCH_MAX_ATTEMPTS, + }; + use crate::PlatformWalletError; + + /// A canonical 64-hex txid whose byte-reversed form differs from it. + fn canonical_txid() -> String { + format!("{}{}", "11".repeat(31), "22") + } + + /// Scripted fetch: pops the next result per call and records the txid + /// each call asked for; plus a counter-only delay. + #[allow(clippy::type_complexity)] + fn harness( + script: Vec, PlatformWalletError>>, + ) -> ( + Arc, PlatformWalletError>>>>, + Arc>>, + Arc, + ) { + ( + Arc::new(Mutex::new(script.into_iter().collect())), + Arc::new(Mutex::new(Vec::new())), + Arc::new(AtomicU32::new(0)), + ) + } + + async fn run( + script: Vec, PlatformWalletError>>, + ) -> (Result, PlatformWalletError>, Vec, u32) { + let canonical = canonical_txid(); + let (queue, calls, delays) = harness(script); + let (queue_f, calls_f, delays_f) = + (Arc::clone(&queue), Arc::clone(&calls), Arc::clone(&delays)); + let result = fetch_funding_tx_with_retry( + &canonical, + move |txid| { + let queue = Arc::clone(&queue_f); + let calls = Arc::clone(&calls_f); + async move { + calls.lock().expect("calls").push(txid); + queue + .lock() + .expect("script") + .pop_front() + .expect("script exhausted — the loop fetched more than scripted") + } + }, + move || { + let delays = Arc::clone(&delays_f); + async move { + delays.fetch_add(1, Ordering::SeqCst); + } + }, + ) + .await; + let recorded = calls.lock().expect("calls").clone(); + (result, recorded, delays.load(Ordering::SeqCst)) + } + + /// A first-call canonical hit returns immediately: one lookup, no + /// reversed fallback, no delay. + #[tokio::test] + async fn canonical_hit_returns_immediately() { + let (result, calls, delays) = run(vec![Ok(Some(7))]).await; + assert_eq!(result.expect("no error"), Some(7)); + assert_eq!(calls, vec![canonical_txid()]); + assert_eq!(delays, 0); + } + + /// A canonical miss falls back to the byte-reversed lookup WITHIN the + /// same attempt (the legacy-iOS little-endian compatibility path) — no + /// delay is spent on the byte-order fallback. + #[tokio::test] + async fn reversed_hit_within_same_attempt_no_delay() { + let (result, calls, delays) = run(vec![Ok(None), Ok(Some(9))]).await; + assert_eq!(result.expect("no error"), Some(9)); + assert_eq!( + calls, + vec![ + canonical_txid(), + reverse_txid_hex(&canonical_txid()).expect("reversible") + ] + ); + assert_eq!(delays, 0); + } + + /// A hit on a later attempt (propagation lag) succeeds after exactly + /// one delay per fully-missed attempt. + #[tokio::test] + async fn hit_after_missed_attempts_counts_one_delay_per_miss() { + // Attempts 1 and 2 miss on both orders; attempt 3 hits canonically. + let (result, calls, delays) = + run(vec![Ok(None), Ok(None), Ok(None), Ok(None), Ok(Some(3))]).await; + assert_eq!(result.expect("no error"), Some(3)); + assert_eq!(calls.len(), 5); + assert_eq!(delays, 2); + } + + /// Exhausting every attempt returns Ok(None) — a miss, not an error — + /// after 2 lookups per attempt, with NO delay after the final attempt. + #[tokio::test] + async fn exhausts_attempts_without_trailing_delay() { + let script = (0..CLAIM_FETCH_MAX_ATTEMPTS * 2) + .map(|_| Ok(None)) + .collect(); + let (result, calls, delays) = run(script).await; + assert_eq!(result.expect("misses are not errors"), None); + assert_eq!(calls.len(), (CLAIM_FETCH_MAX_ATTEMPTS * 2) as usize); + assert_eq!(delays, CLAIM_FETCH_MAX_ATTEMPTS - 1); + } + + /// A transport error is NOT masked as a miss: it propagates + /// immediately, with no further lookups and no extra delay. + #[tokio::test] + async fn transport_error_propagates_immediately() { + // Attempt 1 misses both orders (1 delay); attempt 2's canonical + // lookup errors. + let (result, calls, delays) = run(vec![ + Ok(None), + Ok(None), + Err(PlatformWalletError::InvalidIdentityData( + "simulated transport failure".to_string(), + )), + ]) + .await; + assert!( + matches!(result, Err(PlatformWalletError::InvalidIdentityData(_))), + "transport error must propagate, got {result:?}" + ); + assert_eq!(calls.len(), 3, "no lookup may follow the error"); + assert_eq!(delays, 1); + } + } + + // --- create_invitation: the durable-persistence precondition --- + + mod durability_gate { + use std::sync::Arc; + + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + use crate::wallet::persister::NoPlatformPersistence; + use crate::PlatformWalletError; + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::signer::{Signer, SignerMethod}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + /// Signer that must never be reached — the durability gate fires + /// before any key material is touched or funds move. + struct UnreachableSigner; + + #[async_trait::async_trait] + impl Signer for UnreachableSigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &key_wallet::DerivationPath, + _sighash: [u8; 32], + ) -> Result< + ( + dashcore::secp256k1::ecdsa::Signature, + dashcore::secp256k1::PublicKey, + ), + Self::Error, + > { + unreachable!("the durability gate must fire before any signing") + } + + async fn public_key( + &self, + _path: &key_wallet::DerivationPath, + ) -> Result { + unreachable!("the durability gate must fire before any key derivation") + } + } + + /// A bearer voucher key must never be produced by a wallet whose + /// backend cannot durably persist the funding index: on a restart the + /// index resets and the SAME key would be re-exported to a later + /// invitation. `create_invitation` refuses up-front — before any + /// funds move or keys derive. + #[tokio::test] + async fn create_invitation_requires_durable_persistence() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(NoPlatformPersistence); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(crate::PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + handler, + )); + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::None, + Some(0), + ) + .await + .expect("wallet creation"); + let iw = wallet.identity(); + + let crypto_provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let err = iw + .create_invitation( + 1_000_000, + 0, + None, + 1, + 1, + &UnreachableSigner, + &crypto_provider, + ) + .await + .expect_err("a non-durable backend must be refused"); + + assert!( + matches!(err, PlatformWalletError::Persistence(_)), + "expected the durability refusal, got {err:?}" + ); + assert!( + format!("{err}").contains("durable persistence backend"), + "the error must explain the durable-backend requirement, got: {err}" + ); + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 9c283873cdb..bbcc27c09e4 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 7105878ddb7..519bc9ae72b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -3059,6 +3059,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-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index dfd889a581e..70310d00438 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -38,6 +38,11 @@ impl WalletPersister { self.inner.flush(self.wallet_id) } + /// See [`PlatformWalletPersistence::persists_durably`]. + pub(crate) fn persists_durably(&self) -> bool { + self.inner.persists_durably() + } + pub(crate) fn load(&self) -> Result { self.inner.load() } @@ -58,6 +63,12 @@ impl WalletPersister { pub struct NoPlatformPersistence; impl PlatformWalletPersistence for NoPlatformPersistence { + /// Nothing is ever written, so nothing survives a restart. (Redundant + /// with the trait's fail-closed default — kept explicit as documentation.) + fn persists_durably(&self) -> bool { + false + } + fn store( &self, _wallet_id: WalletId, @@ -74,3 +85,35 @@ impl PlatformWalletPersistence for NoPlatformPersistence { Ok(ClientStartState::default()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `persists_durably` is a fail-closed security capability: an + /// implementation that does NOT explicitly attest durability must read as + /// non-durable, so a backend author who forgets the override gets a loud + /// "requires durable persistence" refusal from the invitation flow + /// instead of being silently trusted with a re-exportable bearer key. + #[test] + fn durability_attestation_defaults_to_fail_closed() { + struct BareMinimum; + impl PlatformWalletPersistence for BareMinimum { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + assert!(!BareMinimum.persists_durably()); + assert!(!NoPlatformPersistence.persists_durably()); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs index 5404f864e59..48d6f31596e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs @@ -302,7 +302,10 @@ impl PlatformWallet { "seed batch finality timed out; pausing for a core block then \ resuming the tracked lock" ); - funding = AssetLockFunding::FromExistingAssetLock { out_point }; + funding = AssetLockFunding::FromExistingAssetLock { + out_point, + consume_invitation_voucher: false, + }; tokio::time::sleep(SEED_BATCH_RETRY_PAUSE).await; } Err(e) => return Err(e), 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..14eeb131476 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -68,6 +68,11 @@ use std::os::raw::c_char; use async_trait::async_trait; use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::secp256k1::{self, Secp256k1}; +use key_wallet::dip9::{ + DASHPAY_CONTACT_INFO_ENC_TO_USER_ID_CHILD, DASHPAY_CONTACT_INFO_PRIVATE_DATA_CHILD, + FEATURE_PURPOSE, FEATURE_PURPOSE_DASHPAY_AUTO_ACCEPT, FEATURE_PURPOSE_IDENTITIES, + FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_INVITATIONS, +}; use key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; use key_wallet::Network; use thiserror::Error; @@ -354,9 +359,9 @@ impl MnemonicResolverCoreSigner { &self, path: &DerivationPath, ) -> Result, MnemonicResolverSignerError> { - let purpose9 = ChildNumber::from_hardened_idx(9) + let purpose9 = ChildNumber::from_hardened_idx(FEATURE_PURPOSE) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; - let feature16 = ChildNumber::from_hardened_idx(16) + let feature16 = ChildNumber::from_hardened_idx(FEATURE_PURPOSE_DASHPAY_AUTO_ACCEPT) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; let comps: &[ChildNumber] = path.as_ref(); if comps.len() != 4 || comps[0] != purpose9 || comps[2] != feature16 { @@ -367,6 +372,51 @@ 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(FEATURE_PURPOSE) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; + let feature5 = ChildNumber::from_hardened_idx(FEATURE_PURPOSE_IDENTITIES) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; + let subfeature3 = + ChildNumber::from_hardened_idx(FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_INVITATIONS) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; + let comps: &[ChildNumber] = path.as_ref(); + // Bind the fixed purpose / feature / sub-feature only. `coin_type` + // (comps[1]) and `funding_index` (comps[4]) are deliberately left + // 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 — @@ -473,8 +523,16 @@ impl MnemonicResolverCoreSigner { private_data_plaintext: &[u8], private_data_iv: &[u8; 16], ) -> Result { - let enc_key = self.derive_contact_info_aes_key(root_path, 65536, derivation_index)?; - let priv_key = self.derive_contact_info_aes_key(root_path, 65537, derivation_index)?; + let enc_key = self.derive_contact_info_aes_key( + root_path, + DASHPAY_CONTACT_INFO_ENC_TO_USER_ID_CHILD, + derivation_index, + )?; + let priv_key = self.derive_contact_info_aes_key( + root_path, + DASHPAY_CONTACT_INFO_PRIVATE_DATA_CHILD, + derivation_index, + )?; Ok(ContactInfoSealed { enc_to_user_id: platform_encryption::encrypt_enc_to_user_id(&enc_key, contact_id), private_data: platform_encryption::encrypt_private_data( @@ -494,8 +552,16 @@ impl MnemonicResolverCoreSigner { enc_to_user_id: &[u8; 32], private_data_blob: &[u8], ) -> Result { - let enc_key = self.derive_contact_info_aes_key(root_path, 65536, derivation_index)?; - let priv_key = self.derive_contact_info_aes_key(root_path, 65537, derivation_index)?; + let enc_key = self.derive_contact_info_aes_key( + root_path, + DASHPAY_CONTACT_INFO_ENC_TO_USER_ID_CHILD, + derivation_index, + )?; + let priv_key = self.derive_contact_info_aes_key( + root_path, + DASHPAY_CONTACT_INFO_PRIVATE_DATA_CHILD, + derivation_index, + )?; let private_data = platform_encryption::decrypt_private_data(&priv_key, private_data_blob) .map_err(|e| { MnemonicResolverSignerError::DerivationFailed(format!("contactInfo decrypt: {e}")) @@ -717,6 +783,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/rs-sdk/src/core/mod.rs b/packages/rs-sdk/src/core/mod.rs index f642f3b26f6..2ef40bb0fdf 100644 --- a/packages/rs-sdk/src/core/mod.rs +++ b/packages/rs-sdk/src/core/mod.rs @@ -6,3 +6,4 @@ mod dash_core_client; mod transaction; #[cfg(feature = "mocks")] pub use dash_core_client::LowLevelDashCoreClient; +pub use transaction::FetchedCoreTransaction; diff --git a/packages/rs-sdk/src/core/transaction.rs b/packages/rs-sdk/src/core/transaction.rs index ecde0c40665..4c1134fee93 100644 --- a/packages/rs-sdk/src/core/transaction.rs +++ b/packages/rs-sdk/src/core/transaction.rs @@ -12,11 +12,99 @@ use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProo use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dpp::prelude::AssetLockProof; -use rs_dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; +use dapi_grpc::tonic::Code; +use rs_dapi_client::transport::TransportError; +use rs_dapi_client::{DapiClientError, DapiRequestExecutor, IntoInner, RequestSettings}; use std::time::Duration; use tokio::time::{sleep, timeout}; +/// A Core transaction fetched by id, plus the finality metadata needed to +/// reconstruct an asset-lock proof from it (an InstantSend proof when the +/// InstantLock is known, otherwise a ChainLock proof once chain-locked). +#[derive(Clone, Debug)] +pub struct FetchedCoreTransaction { + /// The decoded transaction. + pub transaction: Transaction, + /// Height of the block the transaction was mined in (0 if unconfirmed). + pub height: u32, + /// Whether the transaction's block is ChainLocked. + pub is_chain_locked: bool, + /// Whether the transaction is InstantSend-locked. Deliberately surfaced but + /// not required by the invitation claim: the proof carries the islock from + /// the link, and consensus re-verifies it — this flag is informational. + pub is_instant_locked: bool, +} + +/// Whether an SDK error is a gRPC `NOT_FOUND` (the requested tx is unknown to +/// the node), as opposed to a transient/transport failure. Used to distinguish +/// "retry with a reversed txid" from "surface the error". +fn error_is_not_found(err: &Error) -> bool { + match err { + Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc(status))) => { + status.code() == Code::NotFound + } + Error::NoAvailableAddressesToRetry(inner) => error_is_not_found(inner), + _ => false, + } +} + impl Sdk { + /// Fetch a Core transaction by its id via DAPI `getTransaction`. + /// + /// `txid` is the transaction id as a hex string (big-endian display form). + /// Returns `Ok(Some(..))` with the decoded transaction plus its + /// confirmation/lock metadata; `Ok(None)` when the node does not know the tx + /// (empty response or gRPC `NOT_FOUND`) so the caller can retry with the id + /// byte-reversed; and `Err` for a transient/transport failure that must not + /// be masked by a doomed reversed-id retry. + pub async fn get_transaction( + &self, + txid: &str, + ) -> Result, Error> { + let response = match self + .execute( + GetTransactionRequest { + id: txid.to_string(), + }, + RequestSettings::default(), + ) + .await + .into_inner() + { + Ok(response) => response, + Err(e) => { + let err: Error = e.into(); + return if error_is_not_found(&err) { + Ok(None) + } else { + Err(err) + }; + } + }; + + let GetTransactionResponse { + transaction, + height, + is_chain_locked, + is_instant_locked, + .. + } = response; + + if transaction.is_empty() { + return Ok(None); + } + + let transaction = Transaction::consensus_decode(&mut transaction.as_slice()) + .map_err(|e| Error::CoreError(e.into()))?; + + Ok(Some(FetchedCoreTransaction { + transaction, + height, + is_chain_locked, + is_instant_locked, + })) + } + /// Starts the stream to listen for instant send lock messages pub async fn start_instant_send_lock_stream( &self, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 26dabc5aebe..d42b2839c0e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -38,6 +38,7 @@ public enum DashModelContainer { PersistentShieldedActivity.self, PersistentShieldedViewingKey.self, PersistentAssetLock.self, + PersistentInvitation.self, PersistentMasternode.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..5b2618590d1 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift @@ -0,0 +1,116 @@ +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 + + /// Set true just before this wallet's own reclaim consume is submitted, and + /// cleared once the terminal status is saved. It survives a crash between the + /// on-chain consume and the `statusRaw = 2` save, so a retry that hits + /// "already consumed" can tell *our own* reclaim (in-flight → Reclaimed) from + /// a voucher someone else claimed (never in-flight → Claimed). Defaults to + /// `false`, making it an additive SwiftData migration (the property-level + /// default lets existing rows migrate without a mapping model). + public var reclaimInFlight: Bool = false + + /// Record timestamps. + public var createdAt: Date + public var updatedAt: Date + + public init( + outPointHex: String, + rawOutPoint: Data, + walletId: Data, + fundingIndexRaw: Int, + amountDuffs: Int64, + expiryUnix: Int, + createdAtSecs: Int, + hasInviter: Bool, + statusRaw: Int, + reclaimInFlight: Bool = false + ) { + 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.reclaimInFlight = reclaimInFlight + 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 c1c0eae2ad9..4f5226ac821 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2006,6 +2006,238 @@ 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 link carried an `islock`, so the claim will build an InstantSend + /// proof; `false` is a ChainLock-confirmed invite (still claimable). Not a + /// claimability gate — the proof is reconstructed at claim time. + public let isInstant: Bool + /// The link carried inviter METADATA (a username, display name, or + /// avatar). Presence does NOT mean the contact bootstrap is available: + /// a metadata-only link (display-name/avatar without a `du` username) + /// still sets this flag while `inviterUsername` stays nil, and the + /// bootstrap needs the username. Gate contact features on a non-nil + /// `inviterUsername`, not on this flag. + public let hasInviter: Bool + /// Always nil: the legacy link carries only the inviter's username, not an + /// identity id (resolve it via `resolveDpnsName` at contact-bootstrap). + public let inviterId: Data? + /// Inviter DPNS username when the link carried `du`, else nil — the + /// contact-bootstrap precondition (may be nil even when `hasInviter`). + public let inviterUsername: String? + /// Always 0: the amount isn't in the link (it carries the funding txid, + /// not the proof) and is only known after the tx is fetched at claim time. + public let amountDuffs: UInt64 + /// Always 0: the legacy link carries no expiry field. + 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 retained for C ABI compatibility but currently ignored: + /// the legacy invitation link carries no expiry field, so claim has no + /// time gate. The contact-bootstrap ("establish contact with the + /// sender?") is NOT done here — after a successful claim the UI asks the + /// invitee and, on confirm, calls `sendContactRequest` for the reciprocal. + /// + /// Returns the freshly-registered invitee `ManagedIdentity`. + public func claimInvitation( + 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) + } + } + // Always nil, matching the documented contract: the legacy link + // carries no inviter identity id (the ABI's `inviter_id` is + // deliberately all-zero), and surfacing a zero sentinel here would let + // a consumer skip the required DPNS username resolution. + let inviterId: Data? = nil + let inviterUsername: String? = out.inviter_username.map { String(cString: $0) } + return InvitationPreview( + structurallyValid: out.structurally_valid, + 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. @@ -3698,12 +3930,19 @@ extension ManagedPlatformWallet { /// /// Returns `(identityId, ManagedIdentity)` for the freshly /// registered identity. + /// + /// `consumeInvitationVoucher` is the explicit authorization to consume an + /// `IdentityInvitation`-typed lock (a DashPay bearer voucher whose key is + /// shared in the invitation link). Defaults to `false`: generic resume + /// surfaces are refused invitation locks by the Rust funding resolver. + /// Only the invitation reclaim flow passes `true`. public func resumeIdentityWithAssetLock( outPointTxid: Data, outPointVout: UInt32, identityIndex: UInt32, identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], - signer: KeychainSigner + signer: KeychainSigner, + consumeInvitationVoucher: Bool = false ) async throws -> (Identifier, ManagedIdentity) { guard outPointTxid.count == 32 else { throw PlatformWalletError.invalidParameter( @@ -3770,6 +4009,7 @@ extension ManagedPlatformWallet { UInt(ffiRowsCount), signerHandle, coreSigner.handle, + consumeInvitationVoucher, &idTuple, &outManagedHandle ) @@ -3867,7 +4107,9 @@ extension ManagedPlatformWallet { /// Use case is crash recovery: a prior `topUpIdentityWithFunding` /// confirmed its lock on Core but the `IdentityTopUp` never reached /// Platform (app killed / network drop). This picks up that lock by - /// outpoint and completes the top-up against `identityId`. + /// outpoint and completes the top-up against `identityId`. It is also + /// the DashPay invitation "reclaim into an existing identity" path — + /// see `consumeInvitationVoucher`. /// /// `outPointTxid` is the 32-byte raw txid (little-endian wire order, /// same shape as `OutPointFFI.txid`; the caller decodes from @@ -3877,10 +4119,17 @@ extension ManagedPlatformWallet { /// surfaces an opaque consensus rejection — the caller should classify /// and message it ("asset lock already consumed") rather than showing /// the raw error. + /// + /// `consumeInvitationVoucher` is the explicit authorization to consume an + /// `IdentityInvitation`-typed lock (a DashPay bearer voucher whose key is + /// shared in the invitation link). Defaults to `false`: generic top-up + /// crash-recovery surfaces are refused invitation locks by the Rust + /// funding resolver. Only the invitation reclaim flow passes `true`. public func resumeTopUpWithAssetLock( identityId: Data, outPointTxid: Data, - outPointVout: UInt32 + outPointVout: UInt32, + consumeInvitationVoucher: Bool = false ) async throws -> UInt64 { guard identityId.count == 32 else { throw PlatformWalletError.invalidParameter( @@ -3934,6 +4183,7 @@ extension ManagedPlatformWallet { &outPoint, idPtr, coreSigner.handle, + consumeInvitationVoucher, &newBalance ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index b5ad365ffd8..519b32953f1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -215,6 +215,90 @@ 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. + /// Returns `true` iff every upsert/removal was applied. A `false` return + /// drives the callback to signal `store()` failure so the Rust caller + /// (`create_invitation`) surfaces a funded-but-unrecorded voucher instead of + /// reporting success — SwiftData is the sole UI source (no Rust→Swift + /// rehydrate), so a silently skipped upsert would make a funded invitation + /// vanish from the Sent list with no trace. + func persistInvitations( + walletId: Data, + upserts: [InvitationEntrySnapshot], + removed: [Data] + ) -> Bool { + onQueue { + // A fetch failure on any row drops that mutation; report it so the + // round rolls back rather than half-committing. The commit itself is + // the shared changeset `save()` in `endChangeset`; the file-wide + // `try?`-on-save convention (asset locks, identities, txs, …) is + // intentionally left unchanged here — repo-wide persistence-error + // telemetry is a separate follow-up. + var allPersisted = true + for entry in upserts { + let outPointHex = entry.outPointHex + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outPointHex == outPointHex } + ) + let existing: PersistentInvitation? + do { + existing = try backgroundContext.fetch(descriptor).first + } catch { + print("⚠️ persistInvitations: fetch failed for outpoint \(outPointHex) — skipping upsert; this invitation may be missing from the Sent list: \(error)") + allPersisted = false + continue + } + if let existing { + 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 } + ) + do { + if let existing = try backgroundContext.fetch(descriptor).first { + backgroundContext.delete(existing) + } + } catch { + print("⚠️ persistInvitations: fetch failed for removal of outpoint \(hex) — stale invitation may linger in the Sent list: \(error)") + allPersisted = false + } + } + return allPersisted + } + } + /// 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 +347,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 @@ -1112,6 +1212,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { cb.on_load_shielded_viewing_keys_fn = loadShieldedViewingKeysCallback cb.on_load_shielded_viewing_keys_free_fn = loadShieldedViewingKeysFreeCallback 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 @@ -2585,14 +2686,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Parent linkage uses the same lookup key as /// `persistAccount(walletId:spec:)` so the row reliably maps to /// the right `PersistentAccount`. + /// Returns `true` iff the addresses were durably staged (or the account is a + /// non-security-critical type whose transient misses stay tolerant). Returns + /// `false` ONLY when an `IdentityInvitation` (type tag 5) account is missing — + /// that account is registered at wallet-setup and its funding-index pool is the + /// only durable record of the one-time voucher key's derivation index, so a + /// miss is a genuine anomaly. Signaling it drives `store() -> Err`, which makes + /// the pre-broadcast gate abort (preventing voucher-key reuse). For every other + /// account type the tolerant skip is kept: a transient miss during ordinary + /// address sync (e.g. a same-round, not-yet-committed first registration) must + /// NOT roll back and wedge the whole persistence round. func persistAccountAddresses( walletId: Data, accountKey: AccountLookupKey, entries: [CoreAddressEntrySnapshot] - ) { + ) -> Bool { onQueue { guard let account = fetchAccount(walletId: walletId, key: accountKey) else { - return + return accountKey.typeTag != Self.identityInvitationTypeTag } // DIP-17 PlatformPayment pool addresses land in @@ -2605,7 +2716,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: walletId, entries: entries ) - return + return true } for entry in entries { @@ -2662,9 +2773,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } if !self.inChangeset { try? backgroundContext.save() } + return true } // onQueue } + /// The `AccountTypeTagFFI::IdentityInvitation` discriminant. The invitation + /// funding pool is the only durable record of the one-time voucher key's + /// derivation index, so its persistence is treated as a hard gate (see + /// `persistAccountAddresses`), unlike every other account type. + private static let identityInvitationTypeTag: UInt32 = 5 + /// Upsert PlatformPayment entries into `PersistentPlatformAddress`. /// Called only when the address-emit target account is a DIP-17 /// PlatformPayment account (type tag 14). The Rust side emits the @@ -6111,6 +6229,7 @@ private func persistAccountAddressPoolsCallback( return 0 } + var allOk = true for i in 0..?, + 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..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..63554ff4f0a --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AI_QA/QA004_invitation_reclaim.md @@ -0,0 +1,33 @@ +# 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; two default-amount creates need budget ≥ 0.07 DASH to run every step). +- At least one existing Platform identity on the current network (the top-up target). Its credit balance is read from SwiftData `ZPERSISTENTIDENTITY.ZBALANCE`. +- DashPay tab selected (`DashPay` tab-bar item), a DashPay profile present (`dashpay.identityPicker`). + +## Steps + +1. **Create a reclaimable invitation.** Open create-invitation: DashPay tab → paperplane (`dashpay.openSentInvitations`) → the **+** button (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet`. Leave the amount at its default (`0.03` DASH) and submit. Wait for the InstantSend lock to confirm, then screenshot `AI_QA/output/QA004_created.png`. Read `ZPERSISTENTINVITATION` via `sqlite3` and record the new row's `ZOUTPOINTHEX`, `ZAMOUNTDUFFS` (`3000000`), and `ZSTATUSRAW` (`0` = Created). +2. **Open Sent Invitations.** Tap `dashpay.openSentInvitations` (paperplane). `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_sent_list.json` and confirm `dashpay.invitations.list` shows the new row with amount `0.03000000 DASH` and a `Created` badge. +3. **Reclaim → top-up (existing identity).** Swipe the `Created` row left to reveal `dashpay.invitations.reclaim`; tap it. In the sheet, confirm the body copy reads "…as identity credits — not spendable Dash." Leave the target on **Existing identity** (`dashpay.invite.reclaim.target`), pick the target via `dashpay.invite.reclaim.identityPicker`, and tap `dashpay.invite.reclaim.submit`. Wait for the Platform state transition to confirm. Screenshot `AI_QA/output/QA004_reclaim_topup.png`. +4. **Verify the top-up.** Re-read SwiftData: the target identity's `ZBALANCE` rose by ~the voucher value (in credits; 1 duff ≈ 1000 credits), and the reclaimed row's `ZSTATUSRAW` is now `2` (Reclaimed). Cross-check the outpoint reads **consumed** on-chain (platform-explorer). +5. **Reclaim → register (new identity).** Repeat steps 1–3 with a **second** invitation, but in the sheet switch the target to **New identity** (right segment of `dashpay.invite.reclaim.target`) — the identity picker is replaced by "A brand-new identity funded by this voucher." Submit. After it confirms, confirm a new funded identity appears in the Identities tab and the row's `ZSTATUSRAW` is `2` (Reclaimed). Screenshot `AI_QA/output/QA004_reclaim_register.png`. +6. **Already-consumed handling.** Take a row whose voucher is already consumed on-chain and reclaim it again (tap `dashpay.invitations.reclaim` → submit; `ios-simulator__ui_describe_all` → `AI_QA/output/QA004_already_consumed.json`). Two single-device variants — pick by which classifier arm you want to exercise (force-quit the app, edit via `sqlite3` while terminated, relaunch): + - **Interrupted-attempt ambiguity** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=1` on a reclaimed row): the retry hits the wallet's LOCAL "…is not tracked" resume guard (this wallet consumed the lock itself, so the row is locally `Consumed`); with the marker set it surfaces an **explicitly ambiguous error** ("…may already have been consumed by that attempt — check the balance of the identity you targeted then") and deliberately does NOT flip the row: the marker proves only that a consume attempt started, never that it landed, so no inferred `Reclaimed` is written. + - **Marker-unset reset** (`ZSTATUSRAW=0, ZRECLAIMINFLIGHT=0`): the same local "not tracked" failure with no marker deliberately classifies as an **error** — the row stays `Created` and a raw message is shown; the marker this attempt set for itself is **cleared again** (`ZRECLAIMINFLIGHT` back to `0`), so an identical retry repeats the error rather than misreading its own stale marker as a completed self-reclaim. This is the false-positive guard (an unrelated local failure must never flip the row), not a bug. + - The **neutral "This invitation was already claimed."** (→ `ZSTATUSRAW=1`) path requires the consume to actually reach Platform and be rejected with 10504, i.e. a voucher claimed by a *different* wallet while this one still tracks the lock — the two-device DP-19 race, not reproducible by local sqlite edits. +7. **Minimum-amount guard.** Open `CreateInvitationSheet` (paperplane → **+** `dashpay.invitations.create`), set the amount below the floor (e.g. `0.001`), and confirm the create button is disabled with the sub-minimum hint. Screenshot `AI_QA/output/QA004_min_guard.png`. + +## Expected Results + +- **Step 1** create succeeds at the default `0.03` DASH; the persisted row is `Created` (`ZSTATUSRAW=0`) with `ZAMOUNTDUFFS=3000000` and a 36-byte `ZRAWOUTPOINT`. The record is persisted **immediately after the funding broadcast** — before the InstantSend proof wait and the fallible key-export/URI-encode — so an interrupted proof wait or a ChainLock-funded (non-InstantSend) voucher is still recorded and reclaimable. +- **Step 3–4 (top-up)** the sheet states value returns as credits, never L1 Dash; on success the target identity's credit balance rises by ~the voucher value and the row badge flips to **Reclaimed** (`ZSTATUSRAW=2`). No L1 Dash is returned (the on-chain amount was an `OP_RETURN` burn at create time). +- **Step 5 (register)** a brand-new funded identity lands in Identities and the row flips to **Reclaimed** (`ZSTATUSRAW=2`); no contact request is sent (a reclaim carries no DashPay enc/dec pair). +- **Step 6 (already-consumed)** the terminal decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` seam, split by the persisted `reclaimInFlight` marker (set — with a **required, successful** save — only immediately before the on-chain consume; a failed marker save aborts the reclaim before anything irreversible). A Platform-rejected consume (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, Display "…already completely used"): marker **unset** ⇒ provably the invitee claimed first ⇒ **neutral** "This invitation was already claimed." (claimant **not** named) ⇒ row → **Claimed** (`ZSTATUSRAW=1`); marker **set** ⇒ **explicitly ambiguous** — the consumer could be our own interrupted attempt OR a racing claim, so the row resolves to the conservative terminal **Claimed** (`ZSTATUSRAW=1`, marker cleared) with the ambiguity message ("…or possibly by your own earlier interrupted reclaim…"); `Reclaimed` (`ZSTATUSRAW=2`) is written **only** by a consume this attempt observed succeeding. A retry that instead hits the wallet's LOCAL "…is not tracked" resume guard: marker set ⇒ explicit ambiguity **error**, row unchanged (no on-chain proof of consumption at all); marker unset ⇒ plain **error** and the row stays `Created` — an unrelated local failure never flips the row. No funds are lost. +- **Step 7 (min guard)** creating below the minimum is blocked in-UI with "Minimum 0.003 DASH — a smaller voucher can't fund identity registration. Maximum 0.05 DASH."; the Rust layer independently rejects a sub-minimum amount (`MIN_INVITATION_DUFFS`), so the floor holds even if the UI guard is bypassed. + +Fail the QA if: a reclaim returns L1 Dash instead of credits; a failed or non-consumed reclaim flips the row status (a non-consumed error must leave it `Created`); the already-consumed path names the claimant or shows a raw error instead of the neutral message; or an invitation below `0.003` DASH can be created. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/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/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index b10a4039865..eb621986d0e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -435,6 +435,15 @@ struct IdentitiesContentView: View { ) -> [R] { locks.filter { lock in guard lock.statusRaw >= 1 && lock.statusRaw <= 3 else { return false } + // Invitation vouchers (fundingTypeRaw 3, IdentityInvitation) are + // bearer locks whose key is shared in the invitation link — they + // are reclaimed via the Invitations screen, never resumed as a + // local registration (which would consume the voucher into an + // unrelated identity and kill the invitee's claim). Their nominal + // identity slot is 0, so without this exclusion an unclaimed + // voucher surfaces here whenever slot 0 is free. The Rust funding + // resolver refuses them too; this keeps the row from rendering. + guard lock.fundingTypeRaw != 3 else { return false } let slot = UInt32(bitPattern: lock.identityIndexRaw) return !usedSlots.contains( UsedSlot(walletId: lock.walletId, slot: slot) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/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 adcfad7c274..1e459b4af9f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -23,6 +23,20 @@ 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? + + /// Whether an invitation claim is currently in flight. Set by + /// `ClaimInvitationSheet` around its claim task; observed by + /// `DashPayTabView` so a second invite link arriving mid-claim DEFERS + /// (stays in `pendingInviteURL`) instead of re-presenting the sheet — + /// replacing the sheet would orphan the running claim task and let a + /// second claim race the first for the same unused identity index. + @Published var invitationClaimInFlight = false } @main @@ -131,6 +145,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/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 48b67e9fc5e..d4e88070472 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -43,6 +43,11 @@ protocol AssetLockResumeRow { var walletId: Data { get } var statusRaw: Int { get } var identityIndexRaw: Int32 { get } + /// Funding-type discriminant (mirrors the Rust `AssetLockFundingType`). + /// Carried through the row contract so the resumable-registrations + /// anti-join can exclude `IdentityInvitation` (3) vouchers — a shared + /// bearer lock that generic resume must never consume. + var fundingTypeRaw: Int { get } } extension PersistentAssetLock: AssetLockResumeRow {} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift new file mode 100644 index 00000000000..6953c89e694 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift @@ -0,0 +1,289 @@ +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 (sender, if the +/// link carries one) is shown before they commit — the amount isn't in the link +/// and is only known once the funding tx is fetched during the claim. Claiming +/// derives the invitee's own +/// identity keys — including a DashPay Encryption/Decryption pair so the new +/// identity can send the contact request back — exactly like a normal +/// registration, but funds the identity from the voucher instead of a wallet +/// 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 + @EnvironmentObject private var appUIState: AppUIState + @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. Carries the + /// inviter's DPNS username (`du`) rather than an identity id — the legacy link + /// has no id, so it is resolved via DPNS when the request is actually sent. + private struct ContactPrompt: Identifiable { + let id = UUID() + let newIdentityId: Identifier + let 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) { + // Gated while a claim is in flight: dismissing mid-claim + // would leave the unstructured task to finish (and show its + // contact prompt) behind a gone sheet, and a re-open could + // start an overlapping claim racing the same unused + // identity index. Mirrors ReclaimInvitationSheet. + Button("Cancel") { dismiss() } + .disabled(isClaiming) + } + ToolbarItem(placement: .confirmationAction) { + if isClaiming { + ProgressView() + } else { + Button("Claim") { claim() } + .disabled(!canClaim) + .accessibilityIdentifier("dashpay.invite.claim.submit") + } + } + } + .onChange(of: uri) { _, _ in refreshPreview() } + .onAppear { refreshPreview() } + // Swipe-to-dismiss is gated for the same reason as Cancel above. + .interactiveDismissDisabled(isClaiming) + .alert( + contactPrompt.map { "Add \($0.username)?" } ?? "", + isPresented: Binding( + 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 } + // The legacy link carries only the funding txid — the amount, proof type, + // and (for a chainlock invite) the instant lock are resolved at claim time + // by fetching the tx on-chain. So the only pre-claim gate is a + // structurally valid link; instant-vs-chainlock and the exact amount are + // no longer known here, and there is no expiry to check. + return preview.structurallyValid + } + + // MARK: - Sections + + @ViewBuilder private var inputSection: some View { + Section { + TextField("dashpay://invite?…", text: $uri, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .lineLimit(2...4) + .disabled(isClaiming) + .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 { + // The amount isn't in the link — it's read from the funding tx + // during the claim — so show a placeholder until then. + LabeledContent("Amount", value: "—") + if p.hasInviter, let name = p.inviterUsername { + LabeledContent("From", value: name) + } + } + } + } + + // 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 } + // Freeze the URI alongside the already-frozen `preview` so the claim + // submits exactly what was previewed (the field is also disabled while + // claiming, so this is belt-and-suspenders coherence). + let submittedURI = trimmedURI + isClaiming = true + // Published so DashPayTabView defers a second invite link instead of + // re-presenting (and thereby recreating) this sheet mid-claim. + appUIState.invitationClaimInFlight = true + errorMessage = nil + Task { @MainActor in + defer { + isClaiming = false + appUIState.invitationClaimInFlight = 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: submittedURI, + 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 + // (its username); otherwise the claim is done. The inviter's + // identity id is resolved from the username via DPNS when the + // request is sent, not from the link. + if preview.hasInviter, let username = preview.inviterUsername { + contactPrompt = ContactPrompt( + newIdentityId: newIdentityId, + 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 { + // The legacy link carries only the inviter's username, not their + // identity id — resolve it via DPNS before sending the request + // (mirrors Android's identityRepository.getUser(invite.user)). + guard let inviterId = try await wallet.resolveDpnsName(prompt.username) else { + errorMessage = "Identity claimed, but \(prompt.username) couldn't be found to add." + return + } + let signer = KeychainSigner(modelContainer: modelContext.container) + _ = try await wallet.sendContactRequest( + senderIdentityId: prompt.newIdentityId, + recipientIdentityId: 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 + } +} 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..d3cdeaf6ed5 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/CreateInvitationSheet.swift @@ -0,0 +1,272 @@ +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.05 DASH). Mirrored here so the + /// UI rejects an over-cap amount before the FFI does. + private static let maxInvitationDuffs: UInt64 = 5_000_000 + /// Rust-enforced floor (`MIN_INVITATION_DUFFS`, 0.003 DASH). A smaller voucher + /// can't fund identity registration (which needs ~0.00228 DASH) plus the + /// asset-lock overhead, so it could be neither claimed nor reclaimed. Rust is + /// 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.03 + /// DASH — the normal onboarding tier that funds a new identity *and* a normal + /// DPNS name (matches the legacy wallet's onboarding fee); a smaller voucher + /// registers an identity with no name and little usable balance. + @State private var amountDashText: String = "0.03" + /// Opt into the contact-bootstrap: the link carries the inviter so the invitee + /// can send a contact request back. Requires a registered username. + @State private var sendRequestBack = true + + @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.03", text: $amountDashText) + .keyboardType(.decimalPad) + .accessibilityIdentifier("dashpay.invite.create.amount") + Text("DASH") + .foregroundColor(.secondary) + } + Text("Funds a one-time voucher your friend uses to register their identity and a username. Between 0.003 and 0.05 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.05 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, expiring** pasteboard: the link embeds + /// a non-expiring bearer WIF, so it must neither mirror to other devices + /// via Universal Clipboard nor linger on the device-wide pasteboard where + /// a later-granted app could read it. 60s matches the app's existing + /// copied-WIF handling (StorageRecordDetailViews). + private func copyLink(_ uri: String) { + UIPasteboard.general.setItems( + [[UTType.plainText.identifier: uri]], + options: [ + .localOnly: true, + .expirationDate: Date().addingTimeInterval(60), + ] + ) + 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/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 934050587a0..cc730e5ce76 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,38 @@ 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 appUIState.pendingInviteURL != nil else { return } + // NEVER replace the sheet mid-claim: re-seeding recreates the view, + // orphaning the in-flight claim task, and a second claim would race + // the first for the same unused identity index. Leave the URL pending; + // the `.onChange(of: invitationClaimInFlight)` below re-consumes it + // once the claim reaches a terminal state. + guard !appUIState.invitationClaimInFlight else { return } + guard let urlString = appUIState.pendingInviteURL else { return } + // Clear the bearer URL immediately so it can't linger in @Published; the + // nil-write re-fires this via .onChange, where the guard above no-ops. + appUIState.pendingInviteURL = nil + guard let walletId = claimWalletId else { return } + // A fresh ClaimInvite (new id) presents the sheet — and RE-presents it + // if one is already open IDLE, so a second invite link re-seeds it with + // the new URI instead of being dropped (mid-claim it defers above). + claimInvite = ClaimInvite(walletId: walletId, initialURI: urlString) + } + var body: some View { NavigationStack { content @@ -148,6 +194,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 +217,30 @@ struct DashPayTabView: View { .accessibilityIdentifier("dashpay.openIgnored") } } + ToolbarItem(placement: .navigationBarLeading) { + // Gate on a loaded wallet, not the active identity: + // reclaiming needs no identity, so deleting the last identity + // must not hide the reclaim UI while funded (reclaimable) + // invitations remain. The list itself spans all wallets and + // reclaims each by its own walletId. + if claimWalletId != nil { + NavigationLink { + InvitationsView( + network: network, + identity: activeIdentity + ) + // Re-inject explicitly: toolbar-hosted destinations + // don't reliably inherit environment objects (see + // IgnoredContactsView above), and InvitationsView + // requires the manager for its loaded-wallet filter. + .environmentObject(walletManager) + } label: { + Image(systemName: "paperplane") + } + .accessibilityLabel("Sent invitations") + .accessibilityIdentifier("dashpay.openSentInvitations") + } + } } .sheet(isPresented: $showAddViaQR) { if let identity = activeIdentity { @@ -166,6 +248,36 @@ struct DashPayTabView: View { .environmentObject(walletManager) } } + .sheet(item: $claimInvite) { invite in + ClaimInvitationSheet( + walletId: invite.walletId, + network: network, + initialURI: invite.initialURI + ) + .environmentObject(walletManager) + .environmentObject(appUIState) + } + .onChange(of: appUIState.invitationClaimInFlight) { _, inFlight in + // A link that arrived mid-claim was deferred (left in + // pendingInviteURL); consume it now that the claim ended. + if !inFlight { + consumePendingInviteURL() + } + } + .onChange(of: appUIState.pendingInviteURL) { _, _ in + // Warm path: the app is already running, so the tab observes + // 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..51e27eff9df --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/InvitationsView.swift @@ -0,0 +1,183 @@ +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 network: Network + /// The active identity, if any. Reclaim needs no identity (it uses each + /// invitation's own `walletId`); `identity` is required solely to *create* a + /// new invitation, so it is optional — a wallet whose last identity was deleted + /// can still reclaim its funded invitations, only the "+" create is hidden. + let identity: PersistentIdentity? + + // ALL sent invitations, newest first, then filtered below to wallets the + // ACTIVE manager has loaded. Not scoped to one wallet: the paperplane is + // reached from a single-wallet context, but a device may have several + // wallets loaded and each invitation is reclaimed via its OWN `walletId` + // (below), so scoping the list to one wallet would strand the others' + // funded, reclaimable invitations with no way to reach them. + @Query(sort: [SortDescriptor(\PersistentInvitation.createdAtSecs, order: .reverse)]) + private var allInvitations: [PersistentInvitation] + + // Reclaim resolves each row's wallet through the active network's manager, + // so rows whose wallet is NOT loaded there (another network's wallet — the + // row carries no network discriminator — or a deleted wallet) would only + // render a dead "No wallet loaded" reclaim. Hide them instead. + private var invitations: [PersistentInvitation] { + allInvitations.filter { walletManager.wallet(for: $0.walletId) != nil } + } + + @EnvironmentObject private var walletManager: PlatformWalletManager + + @State private var reclaimTarget: PersistentInvitation? + @State private var showCreateInvitation = false + + init(network: Network, identity: PersistentIdentity?) { + self.network = network + self.identity = identity + } + + var body: some View { + List { + if invitations.isEmpty { + ContentUnavailableView( + "No invitations yet", + systemImage: "gift", + description: Text("Tap + to invite a friend.") + ) + } 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") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + // Creating an invitation needs an inviter identity; reclaiming does + // not. Hide "+" when there's no active identity so the reclaim list + // stays usable. + if identity != nil { + Button { + showCreateInvitation = true + } label: { + Image(systemName: "plus.circle") + } + .accessibilityLabel("Create invitation") + .accessibilityIdentifier("dashpay.invitations.create") + } + } + } + .sheet(item: $reclaimTarget) { invitation in + ReclaimInvitationSheet( + invitation: invitation, + // Reclaim targets the invitation's OWN wallet, so a multi-wallet + // list reclaims each row against the correct wallet. + walletId: invitation.walletId, + network: network + ) + } + .sheet(isPresented: $showCreateInvitation) { + if let identity { + CreateInvitationSheet(identity: identity) + } + } + } + + @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..9e7763148d6 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -0,0 +1,470 @@ +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. +/// +/// Reclaimed is asserted ONLY on a successful consume observed by this attempt. +/// The persisted `reclaimInFlight` marker proves just that a local attempt saved +/// it before starting a consume — it is not tied to a submitted transition or +/// target, so it can never upgrade an "already consumed" rejection to Reclaimed +/// (the invitee may have claimed between the crash and the retry). Marker-set +/// failures therefore resolve to a conservative terminal Claimed with an +/// explicitly ambiguous message, or to an explanatory error — never Reclaimed. +struct ReclaimInvitationSheet: View { + let invitation: PersistentInvitation + let walletId: Data + 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") + } + } + } + } + // Swipe-dismissal must be gated like the Cancel button: dismissing + // mid-consume leaves the unstructured task mutating the row after the + // sheet is gone, and re-opening the still-Created row would permit a + // second overlapping consume attempt against one irreversible voucher. + .interactiveDismissDisabled(isReclaiming) + } + + // MARK: - Sections + + @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 + // Whether a *previous* reclaim attempt was already in flight when this + // one started. Captured before the marker is set below so the catch can + // read it (a `do`-local binding wouldn't be visible there). + var hadPriorReclaimInFlight = false + Task { @MainActor in + defer { isReclaiming = false } + do { + guard let wallet = walletManager.wallet(for: walletId) else { + errorMessage = "No wallet loaded." + return + } + let (txid, vout) = try outPointParts() + + // Persist the in-flight marker ONLY immediately before the on-chain + // consume — never before pre-broadcast local work (e.g. register's + // key pre-persist). The marker does NOT attribute a later "already + // consumed" rejection (the invitee can race our crash-interrupted + // consume); it only downgrades the classification from "provably a + // foreign claim" to "explicitly ambiguous". Setting it earlier + // would let a purely local failure leave the marker set, degrading + // a subsequent genuine foreign claim into the ambiguous message. + // `hadPriorReclaimInFlight` captures the PERSISTED prior value first. + // The save must SUCCEED before the consume may run: an unpersisted + // marker followed by a consume + crash would strand the row (a local + // "is not tracked" retry classifies as an error, and a Platform + // "already consumed" as a foreign claim). On a failed save the + // in-memory flag is rolled back so an unrelated later save can't + // leak a marker that never reached disk, and the throw aborts the + // reclaim before anything irreversible. + // `@MainActor` because a nested func is nonisolated by default in + // Swift 6 and this touches main-actor `invitation`/`modelContext`. + @MainActor func markInFlight() throws { + hadPriorReclaimInFlight = invitation.reclaimInFlight + invitation.reclaimInFlight = true + do { + try modelContext.save() + } catch { + invitation.reclaimInFlight = hadPriorReclaimInFlight + throw error + } + } + + switch target { + case .topUp: + guard let identityId = selectedIdentityId else { + errorMessage = "Pick an identity to top up." + return + } + try markInFlight() + _ = try await wallet.resumeTopUpWithAssetLock( + identityId: identityId, + outPointTxid: txid, + outPointVout: vout, + // Reclaim IS the explicitly authorized voucher-consume + // path; generic resume/top-up flows are refused + // invitation locks by the Rust funding resolver. + consumeInvitationVoucher: true + ) + case .register: + let signer = KeychainSigner(modelContainer: modelContext.container) + let identityIndex = nextUnusedIdentityIndex() + // Pre-broadcast local work — BEFORE the marker is set, so a + // failure here leaves no in-flight marker. + let keys = try wallet.prePersistIdentityKeysForRegistration( + identityIndex: identityIndex, + keyCount: Self.authKeyCount, + network: network + ) + try markInFlight() + _ = try await wallet.resumeIdentityWithAssetLock( + outPointTxid: txid, + outPointVout: vout, + identityIndex: identityIndex, + identityPubkeys: keys, + signer: signer, + // Reclaim IS the explicitly authorized voucher-consume + // path (see topUp arm above). + consumeInvitationVoucher: true + ) + } + + // SwiftData is the UI source: flip the local row to Reclaimed. + invitation.statusRaw = 2 + invitation.reclaimInFlight = false + invitation.updatedAt = Date() + try? modelContext.save() + dismiss() + } catch { + switch Self.classifyReclaimFailure( + error: error, + hadPriorReclaimInFlight: hadPriorReclaimInFlight + ) { + case .claimed: + // Someone else claimed the voucher first. Reflect the terminal + // state with a neutral message (the claimant is intentionally + // not named). + invitation.statusRaw = 1 + invitation.reclaimInFlight = false + invitation.updatedAt = Date() + try? modelContext.save() + infoMessage = "This invitation was already claimed." + case .consumedAmbiguous: + // The voucher is provably consumed (Platform's deterministic + // rejection), but with our own earlier attempt in flight the + // consumer could be EITHER that attempt or a racing claim — + // the marker is not evidence tied to a submitted transition, + // so never upgrade to Reclaimed. Terminal-Claimed is the + // conservative resolution; the message states the ambiguity. + invitation.statusRaw = 1 + invitation.reclaimInFlight = false + invitation.updatedAt = Date() + try? modelContext.save() + infoMessage = + "This invitation was already consumed — by the invitee's " + + "claim, or possibly by your own earlier interrupted " + + "reclaim. If that reclaim went through, the credits were " + + "delivered to the target you selected then." + case .untrackedAfterOwnAttempt: + // The wallet no longer tracks the voucher lock and our own + // attempt was in flight — consistent with that attempt's + // consume having landed, but there is no on-chain proof the + // voucher was consumed at all, so neither the status nor the + // marker moves. Explicitly ambiguous by design. + errorMessage = + "This voucher is no longer tracked by the wallet after an " + + "earlier interrupted reclaim attempt. It may already have " + + "been consumed by that attempt — check the balance of the " + + "identity you targeted then before retrying." + case .error: + if Self.shouldClearInFlightMarker( + error: error, + hadPriorReclaimInFlight: hadPriorReclaimInFlight + ) { + // This attempt set the marker itself and then failed the + // LOCAL pre-broadcast resume guard — the consume never + // started, so the marker is demonstrably stale. Clear it, + // or an identical retry would capture it as "prior in + // flight" and misreport the same local failure as a + // completed self-reclaim. + invitation.reclaimInFlight = false + try? modelContext.save() + } + // Otherwise leave the marker as-is: with a prior in-flight + // (or any error that may have reached the network) a later + // retry that hits "already consumed" must still classify as + // ambiguous rather than a provable foreign claim. + 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 + } + + /// The terminal state a reclaim attempt resolves to. + /// + /// There is deliberately NO `.reclaimed` recovery outcome: the persisted + /// `reclaimInFlight` marker proves only that a local attempt saved it + /// before starting a consume. It is not tied to a submitted transition or + /// target, so it cannot attribute an "already consumed" rejection — the + /// invitee may have claimed the voucher between our crash and the retry. + /// Reclaimed is asserted only by the success path's own observed consume. + enum ReclaimOutcome: Equatable { + /// The voucher was consumed and no local attempt was in flight — a + /// foreign claim, unambiguously. + case claimed + /// The voucher is provably consumed (deterministic Platform + /// rejection), but our own in-flight attempt makes the consumer + /// ambiguous: it could be that attempt or a racing foreign claim. + case consumedAmbiguous + /// The wallet no longer tracks the voucher lock and our own attempt + /// was in flight — consistent with that attempt's consume having + /// landed, but with no on-chain proof of consumption at all. Leave + /// status and marker untouched; surface the ambiguity. + case untrackedAfterOwnAttempt + /// An uncertain, unrelated failure — leave state as-is. + case error + } + + /// Pure decision for the reclaim `catch`: an "already consumed" rejection is + /// split by whether *our own* reclaim was already in flight when this + /// attempt started (persisted `reclaimInFlight` marker) — into a provable + /// foreign claim vs an explicitly ambiguous consumption. Kept + /// side-effect-free and `nonisolated` so it is the unit-tested seam for all + /// outcomes; the view maps the outcome to `statusRaw`/message/save. + nonisolated static func classifyReclaimFailure( + error: Error, + hadPriorReclaimInFlight: Bool + ) -> ReclaimOutcome { + if isAlreadyConsumed(error) { + // Platform deterministically rejected the consume as already-spent. + // With no local attempt in flight that is a foreign claim; with one + // in flight, attribution is unknowable from the marker alone. + return hadPriorReclaimInFlight ? .consumedAmbiguous : .claimed + } + // A retry after our own crash-interrupted consume can also fail + // LOCALLY ("…is not tracked") — before Platform, so `isAlreadyConsumed` + // never sees it. That is consistent with our consume having landed, but + // is not proof (local tracking state is not an on-chain observation), + // so it gets its own explicitly ambiguous outcome instead of a + // Reclaimed recovery. A first attempt (`hadPriorReclaimInFlight == + // false`) hitting "not tracked" still resolves to `.error`. + if hadPriorReclaimInFlight && isLockNoLongerTracked(error) { + return .untrackedAfterOwnAttempt + } + return .error + } + + /// Whether the `.error` outcome should also CLEAR the persisted + /// `reclaimInFlight` marker. True only when this attempt set the marker + /// itself (`hadPriorReclaimInFlight == false`) and then failed the LOCAL + /// pre-broadcast "is not tracked" resume guard — proof the consume never + /// started, so the freshly-set marker is stale. Without clearing it, an + /// identical retry captures the marker as a prior in-flight and degrades + /// the same purely-local failure into the ambiguous + /// `.untrackedAfterOwnAttempt` outcome (two-attempt false ambiguity). + /// Every other error keeps the marker: a failure that may have reached + /// the network must keep a later "already consumed" classified as + /// ambiguous rather than a provable foreign claim. + nonisolated static func shouldClearInFlightMarker( + error: Error, + hadPriorReclaimInFlight: Bool + ) -> Bool { + !hadPriorReclaimInFlight && isLockNoLongerTracked(error) + } + + /// Whether an error is the wallet's LOCAL "asset lock is no longer tracked" + /// resume-guard failure (`rs-platform-wallet` `resume_asset_lock`, Display + /// "Asset lock … is not tracked"). Distinct from the network's already-consumed + /// rejection; used only for our-own-reclaim crash recovery above. + nonisolated static func isLockNoLongerTracked(_ error: Error) -> Bool { + isLockNoLongerTracked(message: error.localizedDescription) + } + + nonisolated static func isLockNoLongerTracked(message: String) -> Bool { + message.lowercased().contains("is not tracked") + } + + /// Whether an error is the deterministic "asset lock outpoint already + /// 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 5fa597d3237..d60303feca4 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) } @@ -328,6 +335,9 @@ struct StorageExplorerView: View { filteredCount(PersistentAssetLock.self) { walletsOnNetwork.contains($0.walletId) } + filteredCount(PersistentInvitation.self) { + walletsOnNetwork.contains($0.walletId) + } filteredCount(PersistentMasternode.self) { walletsOnNetwork.contains($0.walletId) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 785e8db462c..9dc4c5fdf96 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 517459daf8d..7291bf11f23 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -359,6 +359,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/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index d24354fb320..64e3e00d14e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -38,6 +38,10 @@ final class CreateIdentityResumableTests: XCTestCase { let walletId: Data let statusRaw: Int let identityIndexRaw: Int32 + /// Defaults to 0 (IdentityRegistration) so the pre-existing cases + /// exercise the non-invitation path unchanged; the invitation + /// exclusion tests pass 3 (IdentityInvitation) explicitly. + var fundingTypeRaw: Int = 0 } private let walletA = Data(repeating: 0xA1, count: 8) @@ -105,6 +109,52 @@ final class CreateIdentityResumableTests: XCTestCase { XCTAssertEqual(result, []) } + // MARK: - invitation-voucher exclusion + + /// An `IdentityInvitation` (fundingTypeRaw 3) lock is a shared bearer + /// voucher — it must NEVER surface as a resumable registration, even at + /// a fully actionable status with its nominal slot 0 unused (the exact + /// state of every unclaimed voucher). Resuming it would consume the + /// voucher into an unrelated local identity and kill the invitee's claim; + /// vouchers are recovered only via the Invitations screen's reclaim flow. + func testInvitationVouchersAreExcludedFromResumableList() { + for status in 1...3 { + let voucher = FakeAssetLockRow( + walletId: walletA, + statusRaw: status, + identityIndexRaw: 0, + fundingTypeRaw: 3 + ) + let result = IdentitiesContentView.crossWalletResumableLocks( + in: [voucher], + usedSlots: [] + ) + XCTAssertEqual( + result, [], + "unclaimed invitation voucher at status \(status) must not be resumable" + ) + } + } + + /// The exclusion is invitation-specific: the other identity funding + /// types (0 registration / 1 top-up / 2 top-up-not-bound) keep + /// surfacing exactly as before. + func testNonInvitationFundingTypesStillSurface() { + let locks = (0...2).map { fundingType in + FakeAssetLockRow( + walletId: walletA, + statusRaw: 2, + identityIndexRaw: Int32(fundingType), // distinct free slots + fundingTypeRaw: fundingType + ) + } + let result = IdentitiesContentView.crossWalletResumableLocks( + in: locks, + usedSlots: [] + ) + XCTAssertEqual(result, locks) + } + // MARK: - anti-join func testFiltersOutLocksWhoseOwnWalletSlotIsAlreadyUsed() { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift new file mode 100644 index 00000000000..4f354f84640 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift @@ -0,0 +1,209 @@ +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")) + } + + // MARK: - Terminal-outcome decision (classifyReclaimFailure) + + private struct StubError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + private static let alreadyConsumed = StubError( + message: "SDK error: Protocol error: Asset lock transaction " + + "3ff8e26d02e53f97a5f06b12327f40fc10cb859077e2788362c5d93032850ff0 " + + "output 0 already completely used" + ) + + /// Already-consumed + our own reclaim was in flight ⇒ explicitly ambiguous, + /// NEVER `.reclaimed`: the marker only proves a local attempt started a + /// consume, not that it landed — the invitee can claim between our crash + /// and the retry, and a Reclaimed recovery would misattribute that claim. + func test_classify_alreadyConsumed_priorInFlight_isAmbiguous() { + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.alreadyConsumed, hadPriorReclaimInFlight: true), + .consumedAmbiguous + ) + } + + /// Already-consumed + no prior reclaim ⇒ the invitee claimed it first (Claimed). + func test_classify_alreadyConsumed_noPrior_isClaimed() { + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.alreadyConsumed, hadPriorReclaimInFlight: false), + .claimed + ) + } + + /// A non-already-consumed failure is `.error` regardless of the marker — the + /// row is left as-is. This is the safety net behind the S3 marker-placement + /// fix: a pre-broadcast local failure (never already-consumed, and with the + /// marker never set) must never be mistaken for a self-reclaim or a claim. + func test_classify_otherError_isError_regardlessOfMarker() { + let other = StubError(message: "SDK error: Transport error: connection refused") + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: other, hadPriorReclaimInFlight: true), + .error + ) + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: other, hadPriorReclaimInFlight: false), + .error + ) + } + + private static let lockNotTracked = StubError( + message: "PlatformWalletError: Asset lock " + + "3ff8e26d02e53f97a5f06b12327f40fc10cb859077e2788362c5d93032850ff0:0 is not tracked" + ) + + /// A retry after our own crash-interrupted consume can fail LOCALLY + /// ("…is not tracked"). With the marker set that is consistent with our + /// consume having landed, but it is NOT on-chain proof — so it resolves to + /// the explicitly ambiguous `.untrackedAfterOwnAttempt`, never `.reclaimed`. + func test_classify_lockNotTracked_priorInFlight_isUntrackedAmbiguous() { + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.lockNotTracked, hadPriorReclaimInFlight: true), + .untrackedAfterOwnAttempt + ) + } + + /// The same local error WITHOUT a prior in-flight marker is `.error` — a first + /// attempt that hits "not tracked" is a genuine anomaly, not a self-reclaim. + func test_classify_lockNotTracked_noPrior_isError() { + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.lockNotTracked, hadPriorReclaimInFlight: false), + .error + ) + } + + // MARK: - Stale-marker clearing (shouldClearInFlightMarker) + + /// A first attempt that set the marker itself and then failed the LOCAL + /// pre-broadcast "not tracked" guard must clear the marker: the consume + /// never started, so the marker is demonstrably stale. + func test_shouldClear_lockNotTracked_noPrior_isTrue() { + XCTAssertTrue( + ReclaimInvitationSheet.shouldClearInFlightMarker( + error: Self.lockNotTracked, hadPriorReclaimInFlight: false) + ) + } + + /// With a PRIOR in-flight marker the same error is the ambiguous + /// crash-recovery signal (classified `.untrackedAfterOwnAttempt` above) + /// — never a clear. + func test_shouldClear_lockNotTracked_prior_isFalse() { + XCTAssertFalse( + ReclaimInvitationSheet.shouldClearInFlightMarker( + error: Self.lockNotTracked, hadPriorReclaimInFlight: true) + ) + } + + /// Errors that may have reached the network keep the marker regardless — + /// a later "already consumed" must stay classified as ambiguous rather + /// than a provable foreign claim. + func test_shouldClear_networkishErrors_isFalse() { + let transport = StubError(message: "SDK error: Transport error: connection refused") + XCTAssertFalse( + ReclaimInvitationSheet.shouldClearInFlightMarker( + error: transport, hadPriorReclaimInFlight: false) + ) + XCTAssertFalse( + ReclaimInvitationSheet.shouldClearInFlightMarker( + error: Self.alreadyConsumed, hadPriorReclaimInFlight: false) + ) + } + + /// The two-attempt regression the clearing exists for: attempt 1 sets the + /// marker and fails locally ("not tracked", no prior) → `.error` + clear; + /// an identical retry therefore STILL sees no prior marker and stays + /// `.error` — a purely-local failure can never escalate past `.error`. + /// (Without the clear, the retry would capture `hadPriorReclaimInFlight == + /// true` and degrade into the ambiguous outcome for no reason.) + func test_twoAttempt_localFailure_neverBecomesReclaimed() { + // Attempt 1: marker freshly set by this attempt (prior == false). + var persistedMarker = false // durable value BEFORE the attempt + let attempt1Prior = persistedMarker + persistedMarker = true // markInFlight() + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.lockNotTracked, hadPriorReclaimInFlight: attempt1Prior), + .error + ) + if ReclaimInvitationSheet.shouldClearInFlightMarker( + error: Self.lockNotTracked, hadPriorReclaimInFlight: attempt1Prior + ) { + persistedMarker = false // the fix under test + } + + // Attempt 2 (identical retry): captures the durable marker as prior. + let attempt2Prior = persistedMarker + XCTAssertEqual( + ReclaimInvitationSheet.classifyReclaimFailure( + error: Self.lockNotTracked, hadPriorReclaimInFlight: attempt2Prior), + .error, + "a repeated purely-local failure must stay an error, not become Reclaimed" + ) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 91bb95cf9c0..6ec689a4af4 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -292,6 +292,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 → paperplane (`dashpay.openSentInvitations`) → **+** (`dashpay.invitations.create`) on the Sent Invitations screen → `CreateInvitationSheet` → amount entry + **"send a contact request back to me"** checkbox → `createInvitation` → `platform_wallet_create_invitation`. Builds an **InstantSend** asset-lock voucher at the DIP-13 invitation funding path (`3'`), amount Rust-capped at `MAX_INVITATION_DUFFS`, and returns a **legacy-compatible** `dashpay://invite?du=…&assetlocktx=…&pk=…&islock=…` link (field-level cross-claimable with the dash-wallet iOS/Android apps) rendered as a QR + share sheet. The link carries the funding **txid** + the voucher **WIF** (`pk`) + the **islock** — not the embedded proof (the invitee refetches the tx at claim). Builds an **L1 asset lock** → needs the Core SPV client running + **testnet funds** (fund via Wallet → Receive → "request from testnet"). `pk` is a one-time voucher **private key** — a bearer credential; the UI must not log it and should flag the pasteboard sensitive. The funded record is persisted **immediately after the funding broadcast** — before the proof wait and the fallible key-export/URI-encode steps — so an interrupted proof wait or a ChainLock-funded (non-InstantSend) voucher is still recorded and reclaimable. | +| DP-13 | Claim invitation (DIP-13) | Platform | Common | 🔌 | | Paste/scan or deep-link a `dashpay://invite` link → `ClaimInvitationSheet` (`dashpay.invite.claim.*`) → `claimInvitation` → `platform_wallet_claim_invitation`. **Claim-by-fetch:** the link carries only the txid, so the SDK refetches the funding tx by id (bounded retry/backoff for DAPI propagation lag; both byte orders), reconstructs the InstantSend proof (from the link's islock) or a ChainLock proof (islock absent), and selects the voucher's credit output. Registers a **new identity for the invitee funded by the imported voucher** (no L1 Dash on the invitee side). Amount shows "—" in the preview (not on the wire). If the link carries a `du` username, prompt **"Add \?"** → on confirm, resolve the id via DPNS and send the contact request (`DP-01` path). New identity lands in Identities; optional contact in Contacts. `🔌` until the UI lands. | +| DP-14 | Invite → claim two-wallet e2e | Cross | Thorough | 🔌 | multiwallet | The feature's acceptance gate. Wallet A (funded, SPV running) creates an invitation (`DP-12`); wallet B (no funds) claims it (`DP-13`) → B gains a funded identity with no L1 Dash; if the inviter opted into the bootstrap **and** the invitee confirms, the contact establishes on both ends (cf. `DP-11`). Requires testnet funding + both wallets on the same network. `🔌` until the UI lands. | +| DP-15 | Reject malformed / reused / wrong-network invitation | Platform | Uncommon | 🔌 | | Negative paths all fail loudly with a clear message and no side effects: a **malformed** link (wrong scheme/host, missing `pk`/`assetlocktx`, duplicate required param, non-WIF `pk`, over-length URI), a **reused** link (asset lock already consumed → deterministic "already completely used"), and a **wrong-network** WIF (a valid key on the wrong chain, caught at claim before the funding fetch). NOTE: the legacy wire carries **no expiry** field (`expiry_unix` is 0 in the preview), so there is no past-expiry rejection — the honest bound is the amount floor (`MIN_INVITATION_DUFFS`) enforced at create. `🔌` until the UI lands. | +| 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`) → `resumeTopUpWithAssetLock(consumeInvitationVoucher: true)` → `platform_wallet_topup_identity_with_existing_asset_lock_signer` consumes the voucher as an IdentityTopUp via `FromExistingAssetLock { consume_invitation_voucher: true }` (the explicit authorization — generic top-up resume passes `false` and is refused invitation locks). 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` @@ -350,12 +358,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..51fa476c89b --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -0,0 +1,153 @@ +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" + ) + } + + /// A clean upsert reports success — the signal the callback maps to `0` + /// (round commits). Pins the new `Bool` return added so a *skipped* upsert + /// can instead surface as a `store()` failure rather than a silent drop. + func testPersistInvitationsReportsSuccessOnCleanUpsert() throws { + let (handler, _) = try makeHandler() + handler.beginChangeset(walletId: walletId) + let ok = handler.persistInvitations( + walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: []) + _ = handler.endChangeset(walletId: walletId, success: true) + XCTAssertTrue(ok, "a clean upsert must report success") + } + + // MARK: - Voucher-index durability gate scoping (B2) + + /// `AccountTypeTagFFI`: IdentityInvitation = 5, Standard = 0. + private func lookupKey(typeTag: UInt32) -> PlatformWalletPersistenceHandler.AccountLookupKey { + .init( + typeTag: typeTag, + index: 0, + standardTag: 0, + registrationIndex: 0, + keyClass: 0, + userIdentityId: Data(count: 32), + friendIdentityId: Data(count: 32) + ) + } + + private func addressSnapshot() -> PlatformWalletPersistenceHandler.CoreAddressEntrySnapshot { + .init( + address: "yTestAddress0000000000000000000000000", + publicKey: Data(count: 33), + keyType: 0, + poolTypeTag: 0, + addressIndex: 0, + isUsed: false, + balance: 0, + derivationPath: "m/9'/1'/5'/3'/0'" + ) + } + + /// A missing `IdentityInvitation` (tag 5) account is a hard failure: that + /// account's funding pool is the only durable record of the one-time voucher + /// key's derivation index, so a persist that stages nothing MUST signal + /// failure (nonzero) — which drives `store() -> Err` and aborts the + /// pre-broadcast gate, preventing voucher-key reuse. + func testPersistAccountAddressesFailsForMissingInvitationAccount() throws { + let (handler, _) = try makeHandler() + let ok = handler.persistAccountAddresses( + walletId: walletId, accountKey: lookupKey(typeTag: 5), entries: [addressSnapshot()]) + XCTAssertFalse(ok, "a missing IdentityInvitation account must signal failure") + } + + /// A missing NON-invitation account (e.g. Standard, tag 0) stays tolerant. + /// Returning false here would roll back the whole round and could permanently + /// wedge ordinary address sync (a first-registration account staged but not + /// yet committed in the same round) — so the strictness is scoped to tag 5. + func testPersistAccountAddressesTolerantForMissingNonInvitationAccount() throws { + let (handler, _) = try makeHandler() + let ok = handler.persistAccountAddresses( + walletId: walletId, accountKey: lookupKey(typeTag: 0), entries: [addressSnapshot()]) + XCTAssertTrue(ok, "a missing non-invitation account must stay tolerant (no sync wedge)") + } +}