Skip to content
Merged
Show file tree
Hide file tree
Changes from 60 commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
4891bd4
docs(dashpay): DIP-13 invitations spec (reviewed + synced)
shumkov Jul 8, 2026
1d8b490
feat(platform-wallet): DIP-13 invitation link codec
shumkov Jul 8, 2026
c60b3e3
feat(platform-wallet): path-gated invitation voucher-key export
shumkov Jul 8, 2026
9e40ee8
feat(platform-wallet): invitation create + claim flows
shumkov Jul 8, 2026
5039946
fix(platform-wallet): fold invitation Rust-core review findings
shumkov Jul 8, 2026
6a730a9
test(qa): add DIP-13 invitation scenarios to the example-app QA contract
shumkov Jul 8, 2026
f59edf6
feat(platform-wallet): invitation create + claim FFI
shumkov Jul 8, 2026
55c9537
fix(platform-wallet): scrub the exported voucher scalar in create_inv…
shumkov Jul 8, 2026
4d39bdc
feat(swift-sdk): createInvitation / claimInvitation wrappers on Manag…
shumkov Jul 8, 2026
2b2a871
feat(platform-wallet): persist inviter-side invitation records (Rust …
shumkov Jul 8, 2026
8fd0613
feat(swift-sdk): add DashPay create-invitation UI (DIP-13)
shumkov Jul 8, 2026
bd60102
feat(platform-wallet-ffi): add parse-invitation preview FFI + Swift w…
shumkov Jul 8, 2026
5967661
feat(swift-sdk): add DashPay claim-invitation flow (DIP-13)
shumkov Jul 8, 2026
4e0dfbe
feat(swift-sdk): handle dashpay:// invitation deep links
shumkov Jul 8, 2026
01cd566
test(platform-wallet-storage): allow-list invitations.rs read-only re…
shumkov Jul 8, 2026
a50a806
fix(swift-sdk): present invitation claim sheet on cold-launch deep link
shumkov Jul 8, 2026
0b51001
fix(rs-sdk-ffi): enforce fully-hardened invitation export path
shumkov Jul 8, 2026
2d568b7
fix(platform-wallet): fold invitation review findings
shumkov Jul 8, 2026
c2ddfcf
docs(dip15): sync spec §4.1/§6 with as-built code + note scheme limit…
shumkov Jul 8, 2026
a001d26
fix(swift-sdk): re-present invitation claim sheet on a second deep link
shumkov Jul 8, 2026
10fbc57
fix(platform-wallet): persist asset-lock account used-index across re…
shumkov Jul 10, 2026
74675fc
test(platform-wallet): pin asset-lock funding-index persistence
shumkov Jul 10, 2026
55937e1
fix(platform-wallet): gate invitation broadcast on funding-index persist
shumkov Jul 10, 2026
096e52d
feat(platform-wallet-ffi): topup-from-existing-asset-lock FFI (invita…
shumkov Jul 10, 2026
ce6a04d
feat(platform-wallet-ffi): invitation persistence callback (Sent-invi…
shumkov Jul 10, 2026
4e49286
test(platform-wallet-ffi): pin InvitationEntryFFI ABI size (layout gu…
shumkov Jul 10, 2026
9a6188c
docs(dip15): reviewed Sent-invitations persistence + reclaim spec
shumkov Jul 10, 2026
c1f0c45
feat(swift-sdk): Sent-invitations SwiftData bridge + list (DIP-13)
shumkov Jul 10, 2026
0b12d2d
fix(rs-sdk-ffi): revert over-strict hardened invitation export gate
shumkov Jul 10, 2026
3f498ba
fix(platform-wallet): persist invitation record for non-hardened fund…
shumkov Jul 10, 2026
7b371fe
fix(SwiftExampleApp): add PersistentInvitation to Storage Explorer views
shumkov Jul 10, 2026
4e3a9b0
style(platform-wallet-ffi): sort invitation_persistence re-export
shumkov Jul 10, 2026
30ab23f
Merge branch 'v4.1-dev' into feat/dip15-dashpay-invitations
shumkov Jul 10, 2026
d7ebdaa
feat(swift-sdk): reclaim an unclaimed DashPay invitation as credits
shumkov Jul 10, 2026
3029ffe
feat(platform-wallet): enforce a minimum invitation amount
shumkov Jul 10, 2026
261e2f2
fix(swift-sdk): raise invitation default above the claimable floor
shumkov Jul 10, 2026
d14c02e
fix(swift-sdk): narrow already-consumed classifier to canonical Displ…
shumkov Jul 10, 2026
139a478
fix(swift-sdk): disable Cancel during an in-flight invitation reclaim
shumkov Jul 10, 2026
250976a
docs(swift-example-app): add QA004 invitation-reclaim AI-QA playbook
shumkov Jul 10, 2026
fb31b53
fix(swift-example-app): make the reclaim classifier nonisolated
shumkov Jul 11, 2026
a0161d0
refactor(swift-example-app): move Create invitation to the Sent Invit…
shumkov Jul 11, 2026
d8ab2f8
docs(swift-example-app): point QA004 + DP-12 at the new Create-invita…
shumkov Jul 11, 2026
cc19969
docs(dip15): address review nits — fence language + stale reclaim-sco…
shumkov Jul 12, 2026
539d721
fix(swift-example-app): distinguish self-reclaim from foreign claim o…
shumkov Jul 12, 2026
2b47bd5
fix(swift-sdk): log invitation persistence fetch failures instead of …
shumkov Jul 12, 2026
101f941
docs(dip13): reviewed legacy-compat invitation spec (v2)
shumkov Jul 13, 2026
4af1427
feat(platform-wallet): size the invitation cap for onboarding (identi…
shumkov Jul 13, 2026
ccc4801
feat(rs-sdk): add Sdk::get_transaction to fetch + decode a Core tx by id
shumkov Jul 13, 2026
31b6ca3
feat(platform-wallet): legacy-compatible invitation codec + claim-by-…
shumkov Jul 13, 2026
48bbdb6
feat(platform-wallet-ffi): adapt invitation FFI to the legacy codec
shumkov Jul 13, 2026
a26136b
docs(dip13): record legacy-format consequences (inviter-id/expiry off…
shumkov Jul 13, 2026
e1cdbd4
feat(swift-example-app): wire invitation UI to the legacy-compat codec
shumkov Jul 13, 2026
b723662
fix(rs-sdk): get_transaction returns Option; classify not-found vs tr…
shumkov Jul 13, 2026
83f0ee6
fix(platform-wallet): harden invitation codec per adversarial review
shumkov Jul 13, 2026
f7af58d
Merge remote-tracking branch 'origin/v4.1-dev' into feat/dip15-dashpa…
shumkov Jul 13, 2026
57f2a98
style: rustfmt the changeset re-export merge resolution
shumkov Jul 13, 2026
b7d79f7
fix(platform-wallet): record invitation before fallible steps; propag…
shumkov Jul 13, 2026
e51c38f
fix(swift-sdk): persistence callbacks signal failure (invitations; sc…
shumkov Jul 13, 2026
bbc0d27
fix(swift-example-app): keep reclaim UI without an identity; safer re…
shumkov Jul 13, 2026
82532db
docs(dip15): invitation review-fix spec; universal-link note; persist…
shumkov Jul 13, 2026
f0423e0
fix(platform-wallet): record chainlock voucher; retry claim fetch; te…
shumkov Jul 14, 2026
b613d92
fix(swift-example-app): reclaim all wallets' invitations; recover cra…
shumkov Jul 14, 2026
6009566
docs(dip15): update invitation QA/test-plan for the legacy codec + re…
shumkov Jul 14, 2026
2377cf3
Merge remote-tracking branch 'origin/v4.1-dev' into feat/dip15-dashpa…
shumkov Jul 14, 2026
08bf78c
fix(platform-wallet): durable invitation persistence gate; record inv…
shumkov Jul 14, 2026
81b0b72
fix(swift-example-app): scope invitation list to loaded wallets; requ…
shumkov Jul 14, 2026
e67ecfe
docs(dip15): round-3 review-fix spec; QA004/TEST_PLAN accuracy for th…
shumkov Jul 14, 2026
204634c
fix(swift-example-app): re-inject walletManager into the toolbar-push…
shumkov Jul 14, 2026
81a09d8
docs(dip15): record the as-built deltas in the invitations spec
shumkov Jul 14, 2026
ac6adbf
docs(dip15): consolidate the four invitation specs into one canonical…
shumkov Jul 14, 2026
61f066b
refactor(rs-sdk-ffi): source export-gate path constants from key_wall…
shumkov Jul 14, 2026
9913db9
fix(platform-wallet,swift-example-app): round-4 review — retry-loop s…
shumkov Jul 14, 2026
0a0b3e9
build: bump rust-dashcore to ac8828c9; use the new dip9 DIP-15 constants
shumkov Jul 14, 2026
f1af0c9
fix(platform-wallet): round-5 review — fail-closed durability; non-br…
shumkov Jul 14, 2026
ee74cfd
Merge remote-tracking branch 'origin/v4.1-dev' into feat/dip15-dashpa…
shumkov Jul 14, 2026
17578a4
fix(platform-wallet,swift-example-app): round-6 review — serialize fu…
shumkov Jul 14, 2026
ff67585
fix(platform-wallet,swift-example-app): round-7 review — voucher resu…
QuantumExplorer Jul 14, 2026
7c64b1d
fix(swift-sdk): update invitation persistence test for the keyType sn…
shumkov Jul 14, 2026
b3cfa46
Merge remote-tracking branch 'origin/v4.1-dev' into feat/dip15-dashpa…
QuantumExplorer Jul 14, 2026
e1b7449
Merge remote-tracking branch 'origin/feat/dip15-dashpay-invitations' …
QuantumExplorer Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
636 changes: 636 additions & 0 deletions docs/dashpay/DIP15_INVITATIONS_SPEC.md

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions docs/dashpay/INVITATIONS_LEGACY_COMPAT_SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# DIP-13 Invitations — Legacy Wallet Compatibility Spec (v2, review-folded)

Status: **REVIEWED (4 spec agents) → for sync/implementation** · Direction: **full legacy interop, dedicated PR** (do not fold into #4041; #4041 lands as the green baseline). · Author: platform-wallet

> v2 folds the four spec reviews (feasibility / scope / security / interop-correctness). See §11 for the resolution log. The headline correction: **the interop contract is "emit strict/canonical, parse leniently — exactly as tolerantly as the live Android wallet."** "Byte-for-byte parity" was wrong; it's **field-level** parity.

## 1. Problem & goal
PR #4041 shipped a new, self-contained invitation impl (`dashpay://invite?data=<binary envelope>`, custom scheme, reclaim) on the unified Rust/Platform-SDK stack. It is **wire-incompatible** with the two legacy wallets (`dash-wallet` Android / AppsFlyer, `dashwallet-ios` / dead Firebase), which share a **field-name-based** `du`+`assetlocktx`+`pk`(WIF)+`islock` payload. Goal: make new-stack invites **cross-claimable with the live Android wallet** by adopting the legacy payload + transport + onboarding amounts, while **keeping reclaim + seedless + one shared codebase**.

The on-chain primitive and derivation path (`m/9'/coin'/5'/3'/idx'`) are already identical across all three — no consensus change.

## 2. Goals / Non-goals
**Goals:** G1 payload cross-claim parity (field-level); G2 AppsFlyer OneLink transport; G3 onboarding amounts; G4 preserve reclaim + seedless.
**Non-goals:** removing the bearer model (self-custody); changing the on-chain primitive/path. (Our-own-AASA Universal Links, prior issue #4096, is superseded by the AppsFlyer decision.)

## 3. Wire format — the interop contract (FIELD-LEVEL parity; emit strict, parse lenient)

**Emit (canonical, what we produce):**
```text
dashpay://invite
?du=<inviter DPNS username> # required
&assetlocktx=<funding txid, lowercase BIG-ENDIAN display hex>
&pk=<voucher credit-burn key, WIF, COMPRESSED, network-correct>
&islock=<InstantSend lock, lowercase hex> # or omit (see below)
[&display-name=<inviter display name>]
[&avatar-url=<inviter avatar url, single %-encoded>]
```
- Parse **by field name, order-independent** — the two legacy wallets differ in param order *and* in scheme/host (iOS emits `https://invitations.dashpay.io/applink?…`), so **byte equality is not the contract**. Our parser MUST accept **both** the `dashpay://invite` scheme and the `https://invitations.dashpay.io/applink` host.
- **`pk`**: WIF, **compressed** flag set (the credit-output hash uses the *compressed* pubkey — wrong compression ⇒ wrong `hash160` ⇒ claim fails), network byte `0xCC` mainnet / `0xEF` testnet (matches bitcoinj + `dashcore::PrivateKey::from_wif`).
- **`assetlocktx`**: we **emit** lowercase big-endian display hex. On **claim** we parse leniently: try as-given, then **retry byte-reversed** on a fetch miss (mirrors Android's `Sha256Hash.wrap(id).reversedBytes` retry — old iOS links are little-endian and still exist).
- **`islock`**: OPTIONAL. Two absence forms MUST be handled: param missing, **and the literal string `"null"`** (Android emits `"null"` when the lock was a **chainlock**, not instantsend; its own `isValid` passes it). `islock == "null"` ⇒ treat as "no instant lock" and reconstruct a **`ChainAssetLockProof`** at claim (see §4.3), not an instant proof. iOS ignores `islock` on claim entirely.
- **islock version**: the hex is not self-describing — the decoder MUST assume **ISDLOCK** version and fall back to **ISLOCK** (Android does exactly this).
- **Validity (lenient, superset of both wallets):** require `assetlocktx` + `pk` present/non-blank (iOS's minimum). `du` is required to *emit* but treated as optional on *parse* (iOS accepts `du`-less links). Never reject solely on a missing/`"null"` `islock`.

## 4. The four changes

### 4.1 Payload codec — Rust `rs-platform-wallet/src/wallet/identity/crypto/invitation.rs`
Replace the binary envelope with the query form.
- `encode_invitation_uri` → build the §3 link. WIF via `dashcore::PrivateKey::to_wif` with `compressed=true`; assert compression+network in tests. txid via `proof.transaction().txid().to_string()` (lowercase big-endian). islock via consensus-encode of `proof.instant_lock()` → hex (omit if the proof is a ChainLock).
- `parse_invitation_uri` → accept both scheme + https host; parse the six fields by name; WIF network-checked decode; handle `islock` missing/`"null"`; **do not** fail on order or on missing `islock`/`du`.
- `ParsedInvitation` drops the embedded `asset_lock`; gains `funding_txid`, `islock: Option<…>`, and keeps `voucher_key` (decoded from WIF). Amount is **no longer known pre-fetch** — the preview shows amount only after §4.3 fetch (or "—" offline).

### 4.2 Transport — AppsFlyer OneLink (app layer)
- Wrap the inner `dashpay://invite?…` as `af_dp` in a OneLink; inbound conversion listener extracts `af_dp`/`deep_link_value`/`link` → claim flow. Keep the raw custom scheme as a **first-class parallel fallback** (QR / in-person), not an afterthought.
- **External blocker (does not gate G1/G3/G4):** OneLink brand domain (`dashpay.onelink.me`), dev key, template ID — from the Android team's AppsFlyer account; the iOS app must be added to the **same OneLink template**. Build with config placeholders; the custom-scheme path is fully testable without creds.
- **Threat-model note (§7):** AppsFlyer receives the plaintext `pk` server-side.

### 4.3 Claim — fetch tx by txid — Rust claim path
- Add `Sdk::get_transaction(txid) -> Transaction` (~20–40 lines; the gRPC `getTransaction` is already issued in `rs-sdk/src/core/transaction.rs:176` but the tx bytes are discarded — surface them, `Transaction::consensus_decode`).
- Reconstruct the proof mirroring Android `TopUpRepository.obtainAssetLockTransaction`:
1. Fetch tx by `assetlocktx` (with the reversed-retry from §3).
2. **Fail-fast guards:** `hash256(fetched tx) == assetlocktx` (both orders); if an islock is present, `islock.txid == fetched tx.txid`.
3. **Derive `output_index` (do NOT hard-code 0):** reuse the shipped `voucher_credit_script(pk)` = `P2PKH(hash160(compressed pubkey(pk)))` as the **selector** — scan the fetched tx's `credit_outputs` for the output whose `script_pubkey` matches; reject if none matches. (The link carries `pk` but not the index; hard-coding 0 fails any legacy invite whose credit output isn't at index 0.)
4. Build the proof: `islock` present → `InstantAssetLockProof { instant_lock, transaction, output_index }`; `islock == "null"`/absent → **`ChainAssetLockProof { core_chain_locked_height, out_point, transaction }`** (fetch the confirming height; mirrors the chainlock-only legacy path).
5. Pass into the **unchanged** `put_to_platform_and_wait_for_response_with_private_key`.
- Retry/backoff on DAPI propagation lag (islock/chainlock proves finality). Consensus enforces pk↔output, islock↔tx, identity_id↔outpoint — all fail closed; the local guards are for fast-fail + correct index, not theft prevention.

### 4.4 Amounts — onboarding tiers — Rust constants + Swift UI
- **Normal tier (ship in v1): `0.03 DASH`** = identity + a normal DPNS name (Android `DASH_PAY_FEE`). New create **default = 0.03**.
- **Raise/drop `MAX_INVITATION_DUFFS`** — current `0.01` is **below** the normal tier, so create rejects its own default; set MAX ≥ the contested tier or drop the hard cap and gate on wallet balance (Android: `spendableBalance >= amount`).
- **Keep `MIN_INVITATION_DUFFS = 0.003`** (already == Android `DASH_PAY_INVITE_MIN`).
- **Contested tier `0.25 DASH` — DEFERRED** until contested/premium-name registration is actually wired into the new-stack claim flow (scope review F5; don't ship a price tier with no consumer). Add the tier + picker when that lands.

## 4b. Consequences of matching the legacy format (confirmed during impl)
- **No inviter identity-id on the wire.** The legacy link carries only `du` (username) — no identity id. So `InviterInfo = {username, display_name, avatar_url}`, and the **invitee resolves the inviter's identity from `du` via DPNS at contact-bootstrap** (mirrors Android `identityRepository.getUser(invite.user)`). Swift contact-bootstrap must do the DPNS lookup instead of using an embedded id.
- **No expiry on the wire.** Legacy links have no expiry; the pre-network staleness gate is dropped (the real bound is the amount cap + reclaim; expiry was advisory anyway). The inviter-side local reclaim TTL is unchanged.
- **islock ISLOCK-fallback is a documented limitation, not a gap.** We decode the modern deterministic ISDLOCK (what every live wallet emits and what we emit). rust-dashcore's `InstantLock` cannot represent the ancient non-deterministic ISLOCK; such a link fails closed (clean failed claim, `islock.txid != tx.txid` guard also catches it). Acceptable — add a manual ISLOCK parser only if a live producer is ever found.

## 5. Preserved (unchanged): reclaim (outpoint + `funding_index`, orthogonal to link format), seedless key handling, shared Rust core. Regression-test reclaim after the codec change.

## 6. Decisions — resolved
- **D1 contact bootstrap:** KEEP opt-in-both-ends (prior owner decision, safer for a bearer link); accept the minor parity gap vs legacy's auto-one-way. (Revisit if product wants frictionless parity.)
- **D2 AppsFlyer creds:** external blocker for G2 only; G1/G3/G4 proceed.
- **D3 byte order:** emit big-endian; **parse lenient with reversed-retry** (do NOT drop the hack — that was a regression).
- **D4 WIF:** compressed=true, network byte `0xCC`/`0xEF`; cross-wallet WIF byte-equality fixture test.
- **D5 sequencing:** **dedicated PR** stacked on the invitation branch; #4041 lands as baseline.

## 7. Threat model & failure modes
| Risk | Severity | Mitigation |
|---|---|---|
| **AppsFlyer discloses plaintext `pk` server-side** | MED (regression vs #4041) | Bounded by amount cap + fast reclaim (NOT the advisory expiry, which a direct holder ignores). Document AppsFlyer as an untrusted intermediary; ensure the web preview forwards only `display-name`/`avatar-url`, never the `pk`-bearing link. |
| Claim-time tx fetch fails (DAPI lag) | MED | Retry/backoff; islock/chainlock proves finality; clear "still confirming" state. |
| Wrong endianness / can't claim old iOS links | LOW | Reversed-retry on fetch miss (§4.3). |
| WIF compression/network mismatch → silent claim fail | LOW | Compressed+network round-trip test. |
| `islock="null"` chainlock invite unclaimable | MED | ChainAssetLockProof path (§4.3.4). |
| Attacker-crafted link | — | No theft (consensus fails closed); worst case a failed claim (griefing) or a valid self-controlled identity. |
| Reclaim vs new payload | — | Orthogonal; regression-tested. |

## 8. Architecture / ownership
Rust `rs-platform-wallet`: codec (§4.1), claim-by-fetch (§4.3), amount constants (§4.4). `rs-sdk`: `get_transaction` wrapper. FFI: signature updates. Swift/SwiftExampleApp: create tier UI, AppsFlyer wrap/inbound (§4.2), claim wiring. Reclaim: untouched.

## 9. Test plan
- **Rust unit:** WIF **compressed+network** round-trip; `assetlocktx` big-endian emit + reversed-retry parse; `islock` present / absent / `"null"` handling; field-name parse (order-independent, both scheme + https host); `output_index` selection by `pk` match; ISDLOCK→ISLOCK fallback.
- **Interop fixture:** parse a **real captured Android link** (incl. a `"null"`-islock case) and assert **field equality** (NOT byte equality) + a successful claim reconstruction; if obtainable, an old little-endian iOS link.
- **Reclaim regression:** create → reclaim (topup + register) still green post-codec.
- **Funded testnet e2e (two emulators):** new→new claim (fetch path); new→**live Android `dash-wallet`** cross-claim (the real proof, when a build is available); reclaim-vs-claim race unchanged; amount 0.03 funds identity + a normal name end-to-end.
- **CI:** `cargo fmt --all --check` + workspace tests + Swift SDK strict build.

## 10. Rollback: revert the codec/claim/amount commits; tracked invitations (outpoint-keyed SwiftData) unaffected.

## 11. Spec-review resolution log
- **Feasibility:** claim-by-fetch is a ~20–40 line wrapper (gRPC already called); WIF exists & network-aware; amounts trivial; AppsFlyer the only external blocker. → §4.3, §4.1, §4.4, D2/D4.
- **Interop-correctness (highest-impact):** "byte-for-byte" → **field-level**; **keep** endianness reversed-retry; handle **`islock="null"`**; **compressed** WIF; accept both scheme+https host; islock optional; ISDLOCK/ISLOCK. → §3, §4.1, §4.3, D3/D4.
- **Security:** no theft vector (consensus fails closed); **must** derive `output_index` by `pk` match (not index 0); add `txid`/`islock.txid` fail-fast guards; **`islock="null"` needs ChainAssetLockProof**; **AppsFlyer server-side `pk` disclosure** is a real regression → threat model. → §4.3, §7.
- **Scope:** amount fix is a standalone bug (ship regardless); **#4041 stays green baseline**; interop = **dedicated PR**; **defer the 0.25 contested tier**; AppsFlyer re-introduces the 3rd-party-service dependency class → keep custom-scheme fallback first-class. → §2, §4.2, §4.4, D5.
Loading
Loading