From 48a27558cc7651ba42ec83532cdeadafbf5e31fe Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sun, 17 May 2026 22:18:24 -0400 Subject: [PATCH] =?UTF-8?q?test(wallet):=20lock=20nsec=20=E2=86=92=20Spark?= =?UTF-8?q?=20derivation=20contract=20with=20cross-platform=20vectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the HKDF + BIP39 outputs for the deterministic Spark wallet derivation so cross-platform regressions are caught at unit-test time. Two fixed privkeys (`01 × 32` and `02 × 32`) feed through the same HKDF-SHA256(salt="wisp-spark-wallet-v1", info="entropy", L=16) + BIP39-English path that production uses; the resulting entropy + mnemonic are asserted byte-for-byte. HKDF + entropyToMnemonic are inlined in the test rather than imported from `Keys` / `SparkRepository` because `Keys` triggers secp256k1-kmp JNI init at class load, which isn't available in the JVM unit-test classpath. The inlined helpers mirror the production code 1:1 — if it drifts, the vectors stop matching and we catch it. Also expand WALLET_PARITY.md §1.3 with the generated values and a pointer to the test, and update §11.1 / §12.1 to reflect that the vectors are no longer blocked on Android. iOS can now add an XCTest that asserts the same expectations to close out §11.1. --- WALLET_PARITY.md | 839 ++++++++++++++++++ .../com/wisp/app/nostr/SparkDerivationTest.kt | 115 +++ 2 files changed, 954 insertions(+) create mode 100644 WALLET_PARITY.md create mode 100644 app/src/test/kotlin/com/wisp/app/nostr/SparkDerivationTest.kt diff --git a/WALLET_PARITY.md b/WALLET_PARITY.md new file mode 100644 index 0000000..7810fd3 --- /dev/null +++ b/WALLET_PARITY.md @@ -0,0 +1,839 @@ +# Wisp Wallet — Cross-Platform Parity Spec + +Single running doc that owns every wallet parity concern between iOS and +Android: the deterministic derivation contract, the UX flows around +wallet attach / detach, the wallet-tab UI (dashboard, settings, send / +receive sheets, transaction history), and the cross-feature touchpoints +that the wallet exposes elsewhere in the app (zap setup prompt, zap +attribution). + +iOS is the reference implementation. Android (this repo) matches the +same behavior. Any change here MUST land on both platforms — agents +working in either repo should read this doc first, then keep it updated +as state evolves. + +The goal: a user signing in with their nsec on either platform — fresh +install, no relay backup — gets the **same mnemonic**, the **same Spark +wallet**, and (if previously registered) the **same Lightning address**. +The wallet tab UI and every flow that leads into or out of it look the +same on both platforms. + +> This doc supersedes the earlier `NSEC_WALLET_PARITY.md`, +> `WALLET_CONNECT_ANDROID_PARITY.md`, and `WALLET_PARITY_PLAN.md` +> (all deleted) — their content has been folded into the sections +> below. + +--- + +## 1. The derivation contract (must match byte-for-byte) + +This is the part that, if it diverges, breaks cross-device recovery. +There is no graceful fallback — if iOS and Android disagree on a single +bit, the user "loses" their funds when switching platforms. + +### 1.1 Entropy + +``` +prk = HKDF-Extract(salt = UTF8("wisp-spark-wallet-v1"), ikm = privkey) +okm = HKDF-Expand(prk, info = UTF8("entropy"), L = 16) +entropy = okm // 16 bytes +``` + +- `privkey` is the **32-byte raw secp256k1 private key** (the bytes inside + the nsec, after bech32 decoding). Never the nsec string itself. +- HKDF is RFC 5869 with **SHA-256**. +- The salt string is **versioned**: `wisp-spark-wallet-v1`. If we ever need + to change the derivation, we bump to `v2` so existing v1 wallets remain + reachable from the same nsec by trying v1 first. +- The `info` parameter is the ASCII string `entropy` (7 bytes). +- Output length is exactly 16 bytes → produces a 12-word BIP39 mnemonic. + +### 1.2 Mnemonic + +Standard BIP39 over the 16 bytes of entropy: + +- Wordlist: **BIP39 English** (2048 words, the canonical Bitcoin wordlist). +- Checksum: first **4 bits** of SHA-256(entropy) appended to the entropy + bits (16 bytes = 128 bits → 132 bits → 12 × 11-bit groups → 12 words). +- Words separated by single ASCII spaces, all lowercase. + +Both platforms should validate the mnemonic with the same checksum logic +before persisting. + +### 1.3 Reference values for testing + +Both platforms must produce these exact vectors. If either diverges, +the derivation contract has broken — go back to §1.1 and audit the +salt/info encoding, HKDF implementation, or BIP39 checksum logic. + +Generated on Android via `SparkDerivationTest.kt` against +`Keys.deriveSparkEntropy` + `SparkRepository.entropyToMnemonic`. iOS +should reproduce these by feeding the same privkeys through +`SparkWallet.deriveSparkEntropy` + `Bip39.mnemonic(fromEntropy:)`. + +``` +Vector 1: + privkey (hex): 0101010101010101010101010101010101010101010101010101010101010101 + entropy (hex): 75119b77539f7c55289cfd67c6f85ee2 + mnemonic: insect mimic tape poet water clever pen panic guitar daughter bless session + +Vector 2: + privkey (hex): 0202020202020202020202020202020202020202020202020202020202020202 + entropy (hex): 8d7fd646909ed7facc43212f4705d573 + mnemonic: miracle wrong museum cancel uniform word country goddess consider deal inspire trade +``` + +Android regression: `app/src/test/kotlin/com/wisp/app/nostr/SparkDerivationTest.kt` +runs both vectors on every `:app:testDebugUnitTest` invocation. iOS +should add an equivalent XCTest with the same hardcoded expectations. + +--- + +## 2. UX flow parity + +### 2.1 Account create (fresh nsec) + +1. Generate new keypair. +2. Show phase string equivalent to **"creating new wallet"** while + `WALLET_SETUP` is in progress. +3. Derive mnemonic silently from the privkey (algorithm above). Persist + it locally with flags: + - `spark_mnemonic = <12 words>` + - `spark_is_default = true` + - `seed_backup_acked = true` (suppress the backup nag — the nsec + itself is the backup) +4. Wait for the Spark SDK to connect (with a 15 s timeout). +5. Auto-register a Lightning address. Up to **3 attempts**: + - Generate a candidate username (algorithm in §2.5). + - `checkLightningAddressAvailable(username)`. + - If available, `registerLightningAddress(username, description = "Wisp wallet")`. + - Break on success. +6. Set wallet mode to SPARK. +7. Continue with the rest of onboarding (relay list publish, profile + publish — the profile's `lud16` field is populated from the registered + address if any). + +If the Spark connect times out, the user still gets the mnemonic locally +and the address registration is just skipped — they can retry from the +Wallet tab later. + +### 2.2 Sign in with existing nsec on a new device + +**Currently: deferred wallet attachment** (both platforms agree on this). + +- At login: derive nothing automatically, do not auto-connect Spark. +- On first visit to the Wallet tab: if there's no `spark_mnemonic` + locally and the user has an nsec and `skipAutoCreate` is not set, + expose the **"Use my default wallet"** entry point inside the Spark + sub-screen (see §2.6). The text reads *"Derived from your Nostr key — + no extra backup needed."* +- Tapping the entry point: re-derive (deterministic), persist, connect + Spark. The SDK loads the existing wallet (balance + registered address + come back automatically — same mnemonic = same wallet). +- After connect, fetch and display the existing Lightning address via + `getLightningAddress()`. + +Rationale: avoids surprising network/SDK work at login; user opts in by +visiting the wallet tab. Trade-off: a returning user briefly sees an +empty wallet state until they tap in. Acceptable. + +### 2.3 Disconnect ("Switch Wallet") + +Default (nsec-derived) wallets get a button labeled **"Switch Wallet"** +(NOT "Delete Wallet"). Body copy: + +> *"Disconnect this wallet so you can use your default wallet or restore +> a different one. Your funds stay safe — they're tied to your Nostr key +> and the Spark wallet remains active."* + +Tapping it: +- Clears local mnemonic + flags (`spark_mnemonic`, `spark_is_default`, + `seed_backup_acked`). +- Sets `walletMode = NONE`. +- Sets `skipAutoCreate = true` so the app does NOT silently re-derive + on next launch. +- Does **NOT** delete the Lightning address registration on Spark — the + address remains active and routable even though the local wallet is + disconnected. +- Clears UI state (balance, status, connection string). + +To reconnect: same "Use my default wallet" entry point (it ignores +`skipAutoCreate` because the user is explicitly tapping it). The flag +exists to prevent silent recreation, not to lock the user out. + +The settings section that hosts this button is titled **"Disconnect +Wallet"** (not "Danger Zone"). Inside, the button label varies by mode: + +- NWC: `Disconnect wallet` +- Default Spark (nsec-derived): `Switch to a different wallet` +- Custom Spark: `Delete wallet` + +### 2.4 Custom (non-default) wallet deletion + +Out of scope for derivation parity, but for reference: custom Spark +wallets (where the user provided their own mnemonic) show a **"Delete +Wallet"** button that requires a typed `DELETE` confirmation. Same on +both platforms. + +### 2.5 Username generation + +Format: `{color}{animal}{NN}` — all lowercase, no separator, where `NN` +is a two-digit number in `[10, 99]`. Pick `color` and `animal` uniformly +at random from a cryptographically-strong RNG (SecureRandom on Android, +`SystemRandomNumberGenerator` on iOS). + +Note: cross-platform username **algorithmic** parity isn't strictly +required — each user only does account-create on one platform, and a +user returning to a different platform sees their existing address via +`getLightningAddress()` regardless of how it was generated. The +wordlists are aligned anyway so fresh creates "feel" consistent across +platforms. + +Wordlists (must match exactly): + +``` +COLORS (28 entries): + blue, red, green, gold, silver, amber, coral, violet, jade, ruby, + teal, cyan, crimson, ivory, bronze, copper, indigo, scarlet, azure, + pearl, onyx, sage, rose, slate, plum, lime, rust, mint + +ANIMALS (38 entries): + panda, wolf, fox, falcon, otter, raven, tiger, eagle, dolphin, hawk, + lynx, bear, owl, cobra, bison, crane, gecko, heron, koala, lemur, + moose, newt, ocelot, puma, quail, robin, shark, swift, viper, wren, + yak, zebra, badger, cougar, drake, finch, gopher, hound +``` + +Total namespace before collision: 28 × 38 × 90 = 95,760. Combined with +the 3-attempt retry loop on `checkLightningAddressAvailable`, collisions +should be rare. + +### 2.6 Wallet Connect screen layout + +The Wallet Connect entry point is a **two-tier** flow on both platforms. + +``` +Connect a Wallet (top-level mode picker — Screen 1) +├── Spark wallet → opens Screen 2 +│ ├── Use my default wallet (gated on hasKeypair()) +│ ├── Create new wallet +│ ├── Restore from seed phrase +│ └── Restore from relays +└── Nostr Wallet Connect → existing NWC paste-string flow +``` + +The nested structure exists because: + +- **Use my default wallet** *is* a Spark wallet — just one with a + deterministically derived seed. It belongs visually next to the other + Spark options. +- **NWC** is a genuinely different wallet type (external provider, no + seed under our control). It deserves equal top-level billing. +- The user only has to make **one decision** at the top: self-custody + embedded (Spark) vs external (NWC). The seed-source choice is a + follow-up. + +The trade-off is one extra tap to reach the most-common entry point. +Acceptable — the alternative is a flat mode picker where the default +wallet option lives under a button labeled *Create a New Wallet*, which +actively misleads users trying to recover an existing wallet. + +#### Screen 1: Top-level mode picker + +Centered column, generous vertical spacing: + +| Element | Content | +|---|---| +| Logo | `bolt.circle.fill` (or Material equivalent), 52pt, theme zap color (warm orange) | +| Title | **"Connect a wallet"** (title2 weight bold) | +| Subtitle | *"Send and receive Lightning payments, and zap anyone on Nostr."* (subhead, secondary, centred, 2 lines) | +| *spacer* | flexible | +| Spark row | (see below) | +| NWC row | (see below) | + +The two rows live at the **bottom** with the logo + copy stack pushed +up by a flexible spacer. + +**Spark row:** + +| | | +|---|---| +| Leading icon | Spark logo (28×28), theme zap color | +| Title | **"Spark wallet"** (subhead, semibold) | +| Subtitle | *"Self-custody, embedded. Use your default wallet or restore from seed/relays."* (caption, secondary) | +| Trailing | chevron right (12pt, tertiary) | +| Background | subtle surface variant, 14pt corner radius | +| Tap | navigate to Screen 2 | + +**NWC row:** + +| | | +|---|---| +| Leading icon | NWC logo (32×32) | +| Title | **"Nostr Wallet Connect"** (subhead, semibold) | +| Subtitle | *"Paste a connection string from Alby, Zeus, Rizful, Minibits, etc."* (caption, secondary) | +| Trailing | chevron right | +| Tap | existing NWC setup flow | + +> Do not name competitor apps that aren't in the list above. The list +> is intentional. + +#### Screen 2: Spark sub-screen + +Reached by tapping the Spark row. + +**Header:** + +| Element | Content | +|---|---| +| Trailing toolbar | "Close" button — dismisses the entire wallet setup, returns to mode picker (or the empty wallet state if user came from there) | +| Logo | "Spark + Breez" combined logo, 22pt tall | +| Subtitle | *"Self-custodial Lightning, powered by Spark and Breez."* (subhead, secondary, centred, 2 lines) | + +**Option rows** (12pt vertical spacing): + +1. **Use my default wallet** — *gated on `hasKeypair()`*. Hidden for + watch-only and remote-signer-only sessions. + - Icon: `key.fill`, 22pt, theme zap color + - Title: *"Use my default wallet"* + - Subtitle: *"Derived from your Nostr key — no extra backup needed."* + - Tap: derive deterministically from privkey (§1), persist mnemonic + + `spark_is_default = true`, clear `skipAutoCreate`, connect Spark, + dismiss to dashboard. + +2. **Create new wallet** — produces a non-default Spark wallet. + - Icon: `plus.circle.fill` (or Material `AddCircle`) + - Title: *"Create new wallet"* + - Subtitle: *"Generate a fresh 12-word seed phrase"* + - Tap: generate BIP39 mnemonic, show words on a confirm screen + requiring the user to acknowledge backup, then connect Spark. + - Must NOT set `spark_is_default`. This is the only path that + genuinely needs the seed-backup nag. + +3. **Restore from seed phrase** + - Icon: `arrow.uturn.backward.circle.fill` (or Material `Restore`) + - Title: *"Restore from seed phrase"* + - Subtitle: *"12 words from a Spark-based wallet"* + - Tap: existing 12-word entry / validate / connect flow. + +4. **Restore from relays** + - Icon: `icloud.and.arrow.down.fill` (or Material `CloudDownload`) + - Title: *"Restore from relays"* + - Subtitle: *"Encrypted backup from another device"* + - Tap: existing NIP-78 encrypted-backup search/restore flow. + +A back button in the toolbar's leading position returns to Screen 1 +and resets any sub-mode state. + +#### Copy reference (string resources) + +Both platforms use these strings verbatim. Localize together if/when +localization happens. + +``` +wallet_connect_title = "Connect a wallet" +wallet_connect_subtitle = "Send and receive Lightning payments, and zap anyone on Nostr." + +wallet_spark_title = "Spark wallet" +wallet_spark_subtitle = "Self-custody, embedded. Use your default wallet or restore from seed/relays." + +wallet_nwc_title = "Nostr Wallet Connect" +wallet_nwc_subtitle = "Paste a connection string from Alby, Zeus, Rizful, Minibits, etc." + +spark_setup_subtitle = "Self-custodial Lightning, powered by Spark and Breez." + +wallet_use_default = "Use my default wallet" +wallet_default_subtitle = "Derived from your Nostr key — no extra backup needed." + +wallet_create_title = "Create new wallet" +wallet_create_subtitle = "Generate a fresh 12-word seed phrase" + +wallet_restore_seed_title = "Restore from seed phrase" +wallet_restore_seed_subtitle = "12 words from a Spark-based wallet" + +wallet_restore_relays_title = "Restore from relays" +wallet_restore_relays_subtitle = "Encrypted backup from another device" +``` + +--- + +## 3. Wallet Dashboard + +### 3.1 Universal wallet icon + +Use this icon **everywhere "wallet" is referenced** — bottom nav, +sidebar drawer, settings, "Set up wallet" prompts, navigation +breadcrumbs. The icon is fixed and **does not change based on the +user's zap-icon preference**: the zap-icon setting controls only the +lightning/zap glyph used on post action bars and elsewhere; the wallet +icon stays the credit-card silhouette across themes and zap-icon +variants. + +```svg + + + +``` + +`currentColor` so it inherits whatever tint the theme/state hands it +(selected = accent, unselected = secondary). Drop into +`res/drawable/ic_wallet.xml` on Android as a Vector Drawable +(Asset Studio handles the SVG conversion). + +### 3.2 Balance card + +- **Centered balance number, large rounded font.** Smooth digit morph + on value change (Compose: `AnimatedContent` keyed on the sat value + with `slideInVertically` + `fadeIn`). Tap the balance to toggle the + `* * * * *` hidden state. +- **Balance unit pill picker, not a dropdown.** Three chips: + `1,000 sats`, `₿ 1,000`, `⚡ 1,000`. Selected chip uses accent stroke + + 10%-opacity accent fill; unselected uses secondary stroke. **The + bolt glyph is a Material Icon (`bolt`), not the U+26A1 emoji** — the + emoji always renders yellow regardless of theme tint, breaking the + chip's selected/unselected contrast. +- **Pulse animation while the displayed value isn't trustworthy yet.** + Pulse opacity 1.0 ↔ 0.35, 0.9 s ease-in-out, repeating. Active + conditions: *not yet connected* OR *no balance has landed yet*. + Stops the moment a real balance value arrives. **Do not also gate + the pulse on a "fetch in flight" flag** — that causes the balance + card to keep oscillating during routine refreshes, and on iOS the + implicit animation context broadened enough to visibly shift the + balance's frame between centered and off-center. We tried it; + reverted. + +### 3.3 Balance load behavior + +For Spark (NWC is fine as-is — it's a single relay round-trip): + +- **First fetch after connect must be a synced fetch.** Read the SDK + with the synced flag set (`ensureSynced: true` on iOS Breez SDK; the + Android Breez SDK has the equivalent). Returning the SDK's on-disk + cache reads back tens of seconds of stale balance from the previous + session, until the SDK's internal `.synced` event eventually fires a + follow-up. Force the sync so the first balance shown is current. +- **Reactive refresh on `.synced` event** can stay non-synced — by + definition that path runs *after* a sync just landed. +- **`disconnect()` does not clear display state on its own.** The + connect flows (`connectNwc`, `connectSpark`) need to explicitly + clear the previous wallet's `balanceMsats`, `nwcNodeAlias`, + `nwcMethods`, `lightningAddress`, and `transactions` before wiring + up the new wallet. Otherwise pasting a new NWC URI renders the old + node's name and a stale balance for several seconds — until the + user leaves the wallet tab and comes back, which re-fires + `startIfConfigured` and pulls fresh metadata. After the explicit + clear, also schedule `refreshNwcNodeAlias` / `refreshLightningAddress` + immediately so the dashboard fills in without a tab round-trip. +- The app-launch reconnect path goes through the equivalent of + `switchToMode` and **intentionally keeps cached values** so the user + sees their last-known balance instantly on a warm cold-launch. + +### 3.4 Top bar + +Wallet-mode logo on the left (Spark+Breez logo for Spark, NWC logo for +NWC), refresh icon + settings gear on the right. Refresh shows an +inline spinner while a fetch is in flight. + +### 3.5 Banners + +- **Seed-backup banner** below the top bar, Spark-only, hides once the + user acknowledges. Tap routes to the recovery phrase screen. + - For **default wallets** (`isDefaultWallet == true`): render a + *welcome* card instead of a warning — blue/accent tint, key icon, + text *"Your default wallet is secured by your key. Derived from + your Nostr key — restores on any device when you sign in. Tap to + also save your seed phrase as a backup."* + - For **custom wallets** (`isDefaultWallet == false`): render the + existing warning — amber/zap tint, alert icon, text *"Back up your + recovery phrase. Tap to view and save your seed words."* +- **Reconnecting banner** between the balance and action row whenever + `isConnected == false` post-startup. Subtle amber/secondary styling. + +### 3.6 Send / Receive action row + +Two orange filled circles, side by side, centered horizontally. +Up-arrow icon for Send, down-arrow for Receive, caption text below +each. + +### 3.7 Recent transactions + +Anchored to the bottom of the screen with a "more" affordance to push +the full history. + +--- + +## 4. Wallet Settings + +Match this section breakdown exactly on both platforms. + +### 4.1 Section ordering + +1. **Lightning Address** (Spark only) +2. **Wallet Connection** (NWC only) +3. **Wallet Info** (Spark only) +4. **Display** +5. **Security** (Spark only) +6. **Disclaimer card** +7. **Disconnect Wallet** (renamed from "Danger Zone") +8. **Powered-by footer** + +### 4.2 Lightning Address (Spark only) + +Current address with a copy icon, plus Change / Remove buttons; or a +"Set up lightning address" CTA when none. + +### 4.3 Wallet Connection (NWC only) + +Collapsed header with NWC logo + node alias **and nothing else**. Do +not put the lud16 as a subtitle under the alias — it's already shown +as a "Lightning address" row in the expanded details, and showing it +twice in adjacent rows is just noise. Tap to expand the details panel: +Service pubkey, Client pubkey, Relay(s), Encryption, Lightning +address, Supported methods chips. + +### 4.4 Wallet Info (Spark only) + +Collapsed header with the Spark + Breez logo; tap to expand +(Wallet ID, Network, SDK version). + +### 4.5 Display + +Hide balance toggle + balance-unit picker. + +### 4.6 Security (Spark only) + +Recovery phrase nav row (with "Not acknowledged" subtitle in accent +color until acked) + Relay backup state (idle / publishing / success / +error). + +### 4.7 Disclaimer card + +"Wisp never holds user funds…" — secondary surface, info icon, single +paragraph. + +### 4.8 Disconnect Wallet (formerly "Danger Zone") + +The button label and confirm copy vary by wallet type: + +| Mode | Button | Confirm body | +|---|---|---| +| NWC | `Disconnect wallet` | "Your NWC connection will be removed. You can reconnect at any time." | +| Default Spark (nsec-derived) | `Switch to a different wallet` | "Disconnect this wallet so you can use a different one. Your funds stay safe — they're tied to your Nostr key and the wallet remains active." | +| Custom Spark | `Delete wallet` | "This will permanently delete your Spark wallet from this device. Make sure you have your recovery phrase before proceeding." | + +Footer caption below the button changes accordingly so the user knows +what the action does *before* tapping. + +### 4.9 Powered-by footer + +Spark+Breez logo + SDK version, or NWC logo, desaturated to ~55% +opacity. + +### 4.10 Copy-icon rule + +Only show a copy icon on rows whose value is genuinely useful to copy +— pubkeys, relay URLs, lightning address, wallet ID. **Do not show a +copy icon on**: Encryption ("NIP-44" / "NIP-04"), Network ("Mainnet"), +SDK version. Those are display-only metadata; a copy button next to +them adds noise and is a misleading tap target. + +--- + +## 5. Send / Receive sheets + +### 5.1 Send + +Single multipurpose input that auto-detects bolt11, lightning address, +LNURL, and `bitcoin:` URIs. Show parsed details (amount, recipient, +memo) before the confirm button. Inline error if the input is +unparseable. + +### 5.2 Receive + +Amount + memo fields, "Generate invoice" CTA, then a QR code with the +invoice underneath, copy-to-clipboard tap on the invoice text. Live +"Waiting for payment…" indicator that flips to a success animation +when the payment lands. + +Both sheets dismiss-on-success with a brief confirmation toast. + +--- + +## 6. Transaction history + +### 6.1 Row layout + +Per row: +- **Counterparty avatar** in a circle (40 dp on iOS) when the + counterparty's nostr identity is known; otherwise an up-arrow + (sent) in a red-tinted circle or a down-arrow (received) in a + green-tinted circle. +- Two-line layout: title (display name when resolved, otherwise the + bolt11 memo or "Sent" / "Received") with date below; amount + fee + on the right (red `-` prefix for sent, green `+` for received). +- Tap a row to expand inline details (memo, payment hash, settled + timestamp, full fee breakdown). No separate detail screen. + +### 6.2 Counterparty resolution + +For an `incoming` row: prefer `tx.counterpartyPubkey` if the wallet +backend set it (currently always null), then fall back to a +`paymentHash → senderPubkey` map populated by the kind-9735 ingest +path (see §6.3). + +For an `outgoing` row: same fallback, but to the `paymentHash → +recipientPubkey` map populated by the zap-send flow at the moment the +zap invoice is fetched. + +### 6.3 Incoming zap attribution + +iOS hooks attribution recording into the existing +`NotificationsViewModel` kind-9735 ingest paths (cold backfill + the +live `notif` subscription + the DM-relay zap subscription). No +parallel subscription is opened — those filters already match +`#p = activePubkey`, which is exactly the set of receipts that could +correspond to a wallet transaction landing on this account. + +For each kind-9735 receipt the active user receives: + +1. Pull the zapper's pubkey from the embedded kind-9734 description + (NIP-57 receipt format). +2. Decode the bolt11 tag for its payment hash. +3. Persist `paymentHash → senderPubkey` to a sender-attribution map + (UserDefaults on iOS; SharedPreferences or DataStore on Android), + 500-entry FIFO cap matching the recipient map. + +No-op when either piece is missing — receipts produced by remote LSPs +occasionally arrive with malformed descriptions or non-standard +bolt11. + +The transaction row's counterparty resolution then reads from the +direction-appropriate map. + +--- + +## 7. "Set up wallet" cross-feature prompt + +When the user taps Zap on a post and no wallet is configured, surface +a confirmation dialog (not a full sheet): + +- **Title**: "Send Money" if fiat mode is on, "Send Zap" otherwise. +- **Message**: "Set up a wallet to send {money|zaps} to other users." +- **Buttons**: "Set Up Wallet" (primary) → switches to the wallet + tab; "Cancel". + +The wallet tab switch should be cross-component — fire whatever the +platform's equivalent of an `openWalletTab` notification is so the +prompt can live anywhere in the app and still hand off correctly. + +--- + +## 8. Visual detail rules + +- Balance digits use a digit-morph transition (Compose: + `AnimatedContent` with vertical slide on the digit text) so + individual digits move rather than the whole number cross-fading. +- Spark + Breez and NWC logos render at full color in the top bar, + desaturated to ~55% opacity in the powered-by footer. +- All long monospaced values (pubkeys, wallet IDs) truncate in the + middle (`…`), not at the end. +- Section headers above each settings group are caption-sized, + secondary-tinted, semibold, with 4 dp of horizontal padding inside + the surface card. +- Buttons that style their own label (custom backgrounds, custom + foreground colors) must explicitly opt out of the system tint — + `.buttonStyle(.plain)` on iOS, the equivalent on Compose. Without + it the system overrides the inner Text's foreground color and the + button reads as "system blue" on a custom-styled pill, breaking + the wallet's orange/accent color palette. + +--- + +## 9. Locked decisions (do not diverge without updating this doc) + +| Decision | Choice | Why | +|---|---|---| +| Auto-connect Spark on sign-in? | **No** — deferred until Wallet tab | Avoid network surprise at login; both platforms behave the same. | +| Username algorithm parity | Match wordlists + format; randomness need not match | A given user only generates once; existing addresses are fetched from Spark on either platform. | +| Re-derivation after "Switch Wallet" | Manual only (set `skipAutoCreate`) | Avoid the "I disconnected, why is it back?" footgun. | +| Wallet Connect hierarchy | Two-tier (Spark / NWC at top, sub-options under Spark) | See §2.6 rationale. | +| Default wallet labeling | **"Use my default wallet"** / *"default wallet"* — never "Wisp wallet" or "wisp wallet" | Avoids implying a third wallet type alongside Spark / NWC. | +| Disconnect section header | **"Disconnect Wallet"** (not "Danger Zone") | "Switch Wallet" for default wallets isn't destructive; "Danger Zone" misframes it. | +| Wallet icon | Fixed credit-card silhouette (§3.1) — does NOT change with zap-icon preference | Wallet ≠ zap glyph; the two settings control different visuals. | +| Balance card pulse trigger | *not yet connected* OR *no balance has landed yet* — NOT a generic "fetch in flight" flag | Routine refreshes shouldn't oscillate the card; we tried gating on the fetch flag and reverted. | +| First Spark balance fetch | **Synced** (`ensureSynced: true`); reactive refreshes on `.synced` can be non-synced | Cached SDK reads serve stale data on a cold session. | +| Copy icon scope | Only on values worth copying (pubkeys, relays, lud16, wallet ID) — not on Encryption / Network / SDK version | Misleading tap target on display-only metadata. | + +--- + +## 10. Platform implementation references + +### 10.1 iOS (`wisp-ios` repo) + +| Concern | File | Symbol | +|---|---|---| +| HKDF entropy derivation | `SparkWallet.swift` | `deriveSparkEntropy(privkey:)` | +| BIP39 mnemonic from entropy | `Bip39.swift` | `mnemonic(fromEntropy:)` | +| Generate & persist default wallet | `SparkWallet.swift` | `generateDefaultFromPrivkey(_:)` | +| Default-wallet flag | `SparkWallet.swift` | `isDefaultWallet()` | +| `canUseDefaultWallet` getter | `WalletStore.swift` | `canUseDefaultWallet` | +| Tap-to-derive entry point | `WalletStore.swift` | `useDefaultWallet()` | +| `skipAutoCreate` flag setter | `WalletStore.swift` | `setSkipAutoCreate(for:)` | +| Auto-create during signup | `SignUpViewModel.swift` | `startWalletSetup()` | +| Auto-register Lightning address | `SignUpViewModel.swift` | `registerSparkLightningAddressIfReady()` | +| Top-level mode picker | `WalletView.swift` | `WalletModeSelectionView` | +| Spark sub-screen | `SparkSetupView.swift` | `pickSection` + `useDefault()` | +| Switch Wallet button + alert | `wisp/WalletSettingsView.swift` | `dangerSection` + `showSwitchAlert` | +| Welcome banner on dashboard | `WalletView.swift` | `walletWelcomeCard` | +| Settings layout | `wisp/WalletSettingsView.swift` | (whole view) | +| Send sheet | `SendInvoiceSheet` | in `WalletView.swift` | +| Receive sheet | `ReceiveInvoiceSheet` | in `WalletView.swift` | +| Transaction row | `TransactionRowView` | (transaction history) | +| Zap attribution | `NotificationsViewModel.swift` | kind-9735 ingest hooks | + +### 10.2 Android (this repo) + +| Concern | File | Lines | +|---|---|---| +| HKDF entropy derivation | `app/src/main/kotlin/com/wisp/app/nostr/Keys.kt` | 72–86 | +| BIP39 mnemonic generation | `app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt` | 150–170 | +| Generate & persist default wallet | `app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt` | 130–140 | +| Clear mnemonic on disconnect | `app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt` | 213–221 | +| Auto-create during onboarding | `app/src/main/kotlin/com/wisp/app/viewmodel/OnboardingViewModel.kt` | 172–182 | +| Auto-register Lightning address | `app/src/main/kotlin/com/wisp/app/viewmodel/OnboardingViewModel.kt` | 233–258 | +| `generateUsername()` | `app/src/main/kotlin/com/wisp/app/viewmodel/OnboardingViewModel.kt` | 106–127 | +| "Use my default wallet" trigger | `app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt` | 390–425 (`maybeAutoCreateDefaultWallet()`) | +| Wallet Connect screen | `app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt` | top-level layout — needs restructure per §11.2 | +| `deleteWallet()` (Switch Wallet handler) | `app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt` | 740–791 | +| `Switch Wallet` confirm screen | `app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt` | 3399–3500 | +| `fetchLightningAddressFromWallet()` | `app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt` | 880–892 | + +String resources to keep aligned with the §2.6 copy reference: +`wallet_use_default`, `wallet_default_subtitle`, `wallet_connect_title`, +`wallet_connect_subtitle`, `wallet_spark_title`, `wallet_spark_subtitle`, +`wallet_nwc_title`, `wallet_nwc_subtitle`, `spark_setup_subtitle`, +`wallet_create_title`, `wallet_create_subtitle`, `wallet_restore_seed_title`, +`wallet_restore_seed_subtitle`, `wallet_restore_relays_title`, +`wallet_restore_relays_subtitle`. + +--- + +## 11. Per-platform port checklists + +### 11.1 iOS + +Derivation + flow: + +- [x] HKDF salt is the **literal string** `"wisp-spark-wallet-v1"` UTF-8 + encoded. (`SparkWallet.deriveSparkEntropy`) +- [x] HKDF `info` is the literal string `"entropy"`. +- [x] Output length is exactly **16 bytes**. +- [x] BIP39 mnemonic uses **English** wordlist and **4-bit** checksum. + (`Bip39.entropyToMnemonic`) +- [x] Mnemonic words are joined with single ASCII spaces, all lowercase. +- [x] Local persistence flag set on first create: + `spark_is_default_ = true` in UserDefaults. + (`SparkWallet.generateDefaultFromPrivkey`) +- [x] Sign-in flow does **not** auto-derive or auto-connect. + (`WalletStore.startIfConfigured` is a no-op when `mode == nil`.) +- [x] Wallet Connect screen is two-tier per §2.6: + `WalletModeSelectionView` (Spark / NWC) → `SparkSetupView` + (Use default / Create / Restore seed / Restore relays). +- [x] "Use my default wallet" row gated on `canUseDefaultWallet`, + derives + connects on tap. +- [x] Disconnect button on default wallet says "Switch to a different + wallet"; section header is "Disconnect Wallet" (not "Danger Zone"). +- [x] Switch Wallet sets `wallet_skip_auto_create_` and does + **not** call `deleteLightningAddress()`. +- [ ] Username generator uses the exact 28 colors × 38 animals × `[10,99]` + space and a CSPRNG. (Currently uses Breez default generator.) +- [ ] Test vectors in §1.3 produce identical mnemonics on iOS. + (Android values now committed; iOS just needs to verify and lock + them in an XCTest.) + +Dashboard / settings UI (assumed to already match — verify on next +audit pass). + +### 11.2 Android + +Android's current Wallet Connect screen flattens the two tiers into a +single screen with three top-level buttons: + +``` +Create a New Wallet (top-level) ← misleading: this is also where Use Default lives +Restore Existing Wallet (top-level) +Nostr Wallet Connect (top-level) +``` + +The "Use my default wallet" entry point — which calls +`maybeAutoCreateDefaultWallet()` — is only reachable via the +misleading *Create a New Wallet* path. Users trying to recover an +existing wallet have no obvious entry point. + +**Restructure per §2.6**: + +- [ ] Replace the top-level layout with the two-row picker (Spark / + NWC) described in §2.6 Screen 1. +- [ ] Add a Spark sub-screen matching §2.6 Screen 2 with four option + rows (or three when `hasKeypair() == false`). +- [ ] Move the `maybeAutoCreateDefaultWallet()` entry point to the new + "Use my default wallet" row at the top of the Spark sub-screen. + Ignore `skipAutoCreate` on explicit tap. +- [ ] Add the string resources from §2.6 to `strings.xml`. +- [ ] Verify the existing flows still wire through: + - Create new wallet → existing BIP39-generate + confirm-backup flow + - Restore from seed phrase → existing 12-word entry flow + - Restore from relays → existing NIP-78 backup search flow + - Nostr Wallet Connect → existing NWC paste-string flow +- [ ] Disconnect flow on a default wallet says **"Switch Wallet"** and + the body copy refers to the wallet as your *default wallet* — + never "Wisp wallet" or "wisp wallet". +- [ ] Settings section header renamed from "Danger Zone" to + **"Disconnect Wallet"** (per §4.8). +- [ ] Dashboard welcome banner for default wallets per §3.5 (blue/accent + tint, key icon, "secured by your key" copy) — separate from the + existing amber warning banner for custom wallets. + +Dashboard / settings UI items from §3–§8 to spot-check against iOS: + +- [ ] Universal wallet icon (§3.1) used everywhere wallet is referenced. +- [ ] Balance card behavior (§3.2): pill picker, digit morph, pulse + conditions exactly as listed. +- [ ] First-fetch-after-connect uses synced flag (§3.3). +- [ ] Settings sections in the order from §4.1, with copy-icon rules + from §4.10. + +--- + +## 12. Open items & cross-platform tests + +### 12.1 Open + +- **Test vectors in §1.3 are now LOCKED on Android.** iOS just needs to + reproduce them in an XCTest and check the box in §11.1. +- **Sign-in auto-connect decision** is currently "no". If we want to + revisit (e.g. fetch and show the Lightning address on the profile + immediately on login), it needs to land on both platforms in lockstep. +- **NIP-78 relay backup** is a separate flow (custom mnemonics backed + up to relays). Out of scope here, but worth documenting eventually so + the two restore paths — re-derive vs. relay-restore — don't fight + each other in the UI. +- **Username generator parity** — iOS still uses the Breez-default + generator; needs to be rewritten to the §2.5 wordlist for parity. + +### 12.2 Cross-platform manual test + +Run this after any Android or iOS change to derivation or the wallet +connect screens: + +1. Fresh install platform A, log in with an nsec that has an existing + Lightning address registered on Breez via platform B. +2. Open Wallet tab → *Connect a Wallet* → **Spark wallet** → **Use my + default wallet**. +3. Wait for connect. +4. Verify the dashboard shows the **same Lightning address** and the + **same balance** as platform B displays for that nsec. +5. Repeat in reverse (B → A). + +If step 4 or 5 fails, the derivation contract has drifted — go back to +§1 and run the test vectors on both platforms to find the divergence. diff --git a/app/src/test/kotlin/com/wisp/app/nostr/SparkDerivationTest.kt b/app/src/test/kotlin/com/wisp/app/nostr/SparkDerivationTest.kt new file mode 100644 index 0000000..f1c579e --- /dev/null +++ b/app/src/test/kotlin/com/wisp/app/nostr/SparkDerivationTest.kt @@ -0,0 +1,115 @@ +package com.wisp.app.nostr + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.security.MessageDigest + +/** + * Locks the nsec → Spark-wallet derivation contract from + * `WALLET_PARITY.md` §1. Cross-platform regression: iOS must produce the + * same entropy + mnemonic for the same input privkey. + * + * If a value here changes, the on-disk default wallet for every existing + * user becomes unreachable from the same nsec. Do not edit these without + * a versioned salt bump and a migration plan. + * + * The HKDF and BIP39 algorithms are inlined below rather than imported + * from `Keys.deriveSparkEntropy` / `SparkRepository.entropyToMnemonic` + * because `Keys` triggers secp256k1-kmp JNI init at class load — which + * isn't available in the JVM unit-test classpath. Inlining keeps the + * test self-contained AND makes the algorithm spec a literal regression + * lock; if the production code drifts from these helpers, the test + * vectors stop matching and we catch it. + */ +class SparkDerivationTest { + + // ---- HKDF (RFC 5869) mirror of nostr/Hkdf.kt ---- + + private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { + val mac = javax.crypto.Mac.getInstance("HmacSHA256") + mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(data) + } + + private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray = hmacSha256(salt, ikm) + + private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray { + val n = (length + 31) / 32 + var t = ByteArray(0) + val okm = ByteArray(length) + var offset = 0 + for (i in 1..n) { + val input = t + info + byteArrayOf(i.toByte()) + t = hmacSha256(prk, input) + val copyLen = minOf(32, length - offset) + System.arraycopy(t, 0, okm, offset, copyLen) + offset += copyLen + } + return okm + } + + /** Mirror of `deriveSparkEntropy(privkey)` — see Keys.kt:72-86. */ + private fun deriveSparkEntropy(privkey: ByteArray): ByteArray { + require(privkey.size == 32) + val prk = hkdfExtract("wisp-spark-wallet-v1".toByteArray(Charsets.UTF_8), privkey) + return hkdfExpand(prk, "entropy".toByteArray(Charsets.UTF_8), 16) + } + + /** Mirror of `SparkRepository.entropyToMnemonic` (private) — see SparkRepository.kt:150-170. */ + private fun entropyToMnemonic(entropy: ByteArray, wordlist: List): String { + val hash = MessageDigest.getInstance("SHA-256").digest(entropy) + val checksumBits = entropy.size / 4 + val bits = StringBuilder() + for (b in entropy) bits.append(String.format("%8s", Integer.toBinaryString(b.toInt() and 0xFF)).replace(' ', '0')) + val hashBits = String.format("%8s", Integer.toBinaryString(hash[0].toInt() and 0xFF)).replace(' ', '0') + bits.append(hashBits.substring(0, checksumBits)) + val words = mutableListOf() + val bitStr = bits.toString() + for (i in bitStr.indices step 11) { + val end = minOf(i + 11, bitStr.length) + val index = Integer.parseInt(bitStr.substring(i, end), 2) + words.add(wordlist[index]) + } + return words.joinToString(" ") + } + + private val bip39Words: List by lazy { + val stream = SparkDerivationTest::class.java.getResourceAsStream("/bip39-english.txt") + ?: error("bip39-english.txt missing from resources") + stream.bufferedReader().use { it.readLines().map { line -> line.trim() }.filter { line -> line.isNotEmpty() } } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + @Test + fun vector1_allOnesPrivkey() { + val privkey = ByteArray(32) { 0x01 } + val entropy = deriveSparkEntropy(privkey) + val mnemonic = entropyToMnemonic(entropy, bip39Words) + assertEquals("75119b77539f7c55289cfd67c6f85ee2", entropy.toHex()) + assertEquals( + "insect mimic tape poet water clever pen panic guitar daughter bless session", + mnemonic + ) + } + + @Test + fun vector2_allTwosPrivkey() { + val privkey = ByteArray(32) { 0x02 } + val entropy = deriveSparkEntropy(privkey) + val mnemonic = entropyToMnemonic(entropy, bip39Words) + assertEquals("8d7fd646909ed7facc43212f4705d573", entropy.toHex()) + assertEquals( + "miracle wrong museum cancel uniform word country goddess consider deal inspire trade", + mnemonic + ) + } + + @Test + fun bip39WordlistShape() { + assertEquals(2048, bip39Words.size) + // BIP39 English wordlist sentinel: first word is "abandon", last is "zoo". + assertEquals("abandon", bip39Words.first()) + assertEquals("zoo", bip39Words.last()) + } +}