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, andWALLET_PARITY_PLAN.md(all deleted) — their content has been folded into the sections below.
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.
prk = HKDF-Extract(salt = UTF8("wisp-spark-wallet-v1"), ikm = privkey)
okm = HKDF-Expand(prk, info = UTF8("entropy"), L = 16)
entropy = okm // 16 bytes
privkeyis 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 tov2so existing v1 wallets remain reachable from the same nsec by trying v1 first. - The
infoparameter is the ASCII stringentropy(7 bytes). - Output length is exactly 16 bytes → produces a 12-word BIP39 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.
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.
- Generate new keypair.
- Show phase string equivalent to "creating new wallet" while
WALLET_SETUPis in progress. - Derive mnemonic silently from the privkey (algorithm above). Persist
it locally with flags:
spark_mnemonic = <12 words>spark_is_default = trueseed_backup_acked = true(suppress the backup nag — the nsec itself is the backup)
- Wait for the Spark SDK to connect (with a 15 s timeout).
- 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.
- Set wallet mode to SPARK.
- Continue with the rest of onboarding (relay list publish, profile
publish — the profile's
lud16field 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.
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_mnemoniclocally and the user has an nsec andskipAutoCreateis 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.
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 = trueso 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
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.
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.
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.
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.
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):
-
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, clearskipAutoCreate, connect Spark, dismiss to dashboard.
- Icon:
-
Create new wallet — produces a non-default Spark wallet.
- Icon:
plus.circle.fill(or MaterialAddCircle) - 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.
- Icon:
-
Restore from seed phrase
- Icon:
arrow.uturn.backward.circle.fill(or MaterialRestore) - Title: "Restore from seed phrase"
- Subtitle: "12 words from a Spark-based wallet"
- Tap: existing 12-word entry / validate / connect flow.
- Icon:
-
Restore from relays
- Icon:
icloud.and.arrow.down.fill(or MaterialCloudDownload) - Title: "Restore from relays"
- Subtitle: "Encrypted backup from another device"
- Tap: existing NIP-78 encrypted-backup search/restore flow.
- Icon:
A back button in the toolbar's leading position returns to Screen 1 and resets any sub-mode state.
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"
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 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).
- Centered balance number, large rounded font. Smooth digit morph
on value change (Compose:
AnimatedContentkeyed on the sat value withslideInVertically+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.
- 10%-opacity accent fill; unselected uses secondary stroke. The
bolt glyph is a Material Icon (
- 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.
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: trueon 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.syncedevent eventually fires a follow-up. Force the sync so the first balance shown is current. - Reactive refresh on
.syncedevent 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'sbalanceMsats,nwcNodeAlias,nwcMethods,lightningAddress, andtransactionsbefore 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-firesstartIfConfiguredand pulls fresh metadata. After the explicit clear, also schedulerefreshNwcNodeAlias/refreshLightningAddressimmediately so the dashboard fills in without a tab round-trip.- The app-launch reconnect path goes through the equivalent of
switchToModeand intentionally keeps cached values so the user sees their last-known balance instantly on a warm cold-launch.
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.
- 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."
- For default wallets (
- Reconnecting banner between the balance and action row whenever
isConnected == falsepost-startup. Subtle amber/secondary styling.
Two orange filled circles, side by side, centered horizontally. Up-arrow icon for Send, down-arrow for Receive, caption text below each.
Anchored to the bottom of the screen with a "more" affordance to push the full history.
Match this section breakdown exactly on both platforms.
- Lightning Address (Spark only)
- Wallet Connection (NWC only)
- Wallet Info (Spark only)
- Display
- Security (Spark only)
- Disclaimer card
- Disconnect Wallet (renamed from "Danger Zone")
- Powered-by footer
Current address with a copy icon, plus Change / Remove buttons; or a "Set up lightning address" CTA when none.
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.
Collapsed header with the Spark + Breez logo; tap to expand (Wallet ID, Network, SDK version).
Hide balance toggle + balance-unit picker.
Recovery phrase nav row (with "Not acknowledged" subtitle in accent color until acked) + Relay backup state (idle / publishing / success / error).
"Wisp never holds user funds…" — secondary surface, info icon, single paragraph.
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.
Spark+Breez logo + SDK version, or NWC logo, desaturated to ~55% opacity.
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.
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.
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.
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.
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.
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:
- Pull the zapper's pubkey from the embedded kind-9734 description (NIP-57 receipt format).
- Decode the bolt11 tag for its payment hash.
- Persist
paymentHash → senderPubkeyto 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.
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.
- Balance digits use a digit-morph transition (Compose:
AnimatedContentwith 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.
| 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. |
| 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 |
| 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.
Derivation + flow:
- HKDF salt is the literal string
"wisp-spark-wallet-v1"UTF-8 encoded. (SparkWallet.deriveSparkEntropy) - HKDF
infois the literal string"entropy". - Output length is exactly 16 bytes.
- BIP39 mnemonic uses English wordlist and 4-bit checksum.
(
Bip39.entropyToMnemonic) - Mnemonic words are joined with single ASCII spaces, all lowercase.
- Local persistence flag set on first create:
spark_is_default_<pubkey> = truein UserDefaults. (SparkWallet.generateDefaultFromPrivkey) - Sign-in flow does not auto-derive or auto-connect.
(
WalletStore.startIfConfiguredis a no-op whenmode == nil.) - Wallet Connect screen is two-tier per §2.6:
WalletModeSelectionView(Spark / NWC) →SparkSetupView(Use default / Create / Restore seed / Restore relays). - "Use my default wallet" row gated on
canUseDefaultWallet, derives + connects on tap. - Disconnect button on default wallet says "Switch to a different wallet"; section header is "Disconnect Wallet" (not "Danger Zone").
- Switch Wallet sets
wallet_skip_auto_create_<pubkey>and does not calldeleteLightningAddress(). - 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).
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. IgnoreskipAutoCreateon 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.
- 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.
Run this after any Android or iOS change to derivation or the wallet connect screens:
- Fresh install platform A, log in with an nsec that has an existing Lightning address registered on Breez via platform B.
- Open Wallet tab → Connect a Wallet → Spark wallet → Use my default wallet.
- Wait for connect.
- Verify the dashboard shows the same Lightning address and the same balance as platform B displays for that nsec.
- 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.