Merge pull request #550 from dmnyc/feat/wallet-derivation-test-vectors

test(wallet): cross-platform parity spec + Spark derivation test vectors
This commit is contained in:
Barry Deen
2026-05-20 10:28:34 -04:00
committed by GitHub
2 changed files with 954 additions and 0 deletions
+839
View File
@@ -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
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" fill-rule="evenodd" d="M3 7.25C3 5.45507 4.45507 4 6.25 4h11.5C19.5449 4 21 5.45507 21 7.25v9.5C21 18.5449 19.5449 20 17.75 20H6.25C4.45507 20 3 18.5449 3 16.75v-9.5zM4.5 9v7.75c0 .9665.7835 1.75 1.75 1.75h11.5c.9665 0 1.75-.7835 1.75-1.75V9H4.5z"/>
</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` | 7286 |
| BIP39 mnemonic generation | `app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt` | 150170 |
| Generate & persist default wallet | `app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt` | 130140 |
| Clear mnemonic on disconnect | `app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt` | 213221 |
| Auto-create during onboarding | `app/src/main/kotlin/com/wisp/app/viewmodel/OnboardingViewModel.kt` | 172182 |
| Auto-register Lightning address | `app/src/main/kotlin/com/wisp/app/viewmodel/OnboardingViewModel.kt` | 233258 |
| `generateUsername()` | `app/src/main/kotlin/com/wisp/app/viewmodel/OnboardingViewModel.kt` | 106127 |
| "Use my default wallet" trigger | `app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt` | 390425 (`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` | 740791 |
| `Switch Wallet` confirm screen | `app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt` | 33993500 |
| `fetchLightningAddressFromWallet()` | `app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt` | 880892 |
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_<pubkey> = 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_<pubkey>` 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.
@@ -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>): 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<String>()
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<String> 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())
}
}