From 3f67e7b3110923169f5f3d5329603d8ba2ab8803 Mon Sep 17 00:00:00 2001 From: areebahmeddd Date: Sun, 26 Jul 2026 03:57:44 +0530 Subject: [PATCH 1/2] feat(wallet): rebuild Cashu ecash end to end, with Lightning and recovery --- App.tsx | 261 +- WALLET-KT.md | 786 ++++ package-lock.json | 12 - package.json | 4 +- src/__mocks__/react-native-mmkv.js | 18 +- src/core/payments/__tests__/cashu.test.ts | Bin 6635 -> 9814 bytes src/core/payments/__tests__/nutzap.test.ts | 207 + .../payments/__tests__/wallet-seed.test.ts | 197 + src/core/payments/cashu.ts | 650 ++- src/core/payments/nutzap.ts | 392 +- src/core/payments/wallet-seed.ts | 160 + src/features/chat/message-thread.tsx | 1216 +++--- src/features/discovery/peer-list.tsx | 287 +- .../settings/sections/privacy-screen.tsx | 27 +- .../settings/sections/security-screen.tsx | 96 +- .../settings/sections/storage-screen.tsx | 130 +- .../settings/sections/terms-screen.tsx | 11 +- src/features/wallet/wallet-screen.tsx | 3679 ++++++++++++----- src/services/ecash-transfer.ts | 234 ++ src/services/wallet-service.ts | 2223 ++++++++++ src/store/__tests__/wallet-store.test.ts | 409 +- src/store/settings-store.ts | 24 +- src/store/wallet-store.ts | 896 +++- src/utils/__tests__/panic-wipe.test.ts | 34 +- src/utils/panic-wipe.ts | 36 +- 25 files changed, 9555 insertions(+), 2434 deletions(-) create mode 100644 WALLET-KT.md create mode 100644 src/core/payments/__tests__/nutzap.test.ts create mode 100644 src/core/payments/__tests__/wallet-seed.test.ts create mode 100644 src/core/payments/wallet-seed.ts create mode 100644 src/services/ecash-transfer.ts create mode 100644 src/services/wallet-service.ts diff --git a/App.tsx b/App.tsx index b5c89a3..b3d156a 100644 --- a/App.tsx +++ b/App.tsx @@ -8,7 +8,6 @@ import { } from "@expo-google-fonts/jetbrains-mono"; import { Feather } from "@expo/vector-icons"; import { bytesToHex } from "@noble/hashes/utils.js"; -import { BlurTargetView, BlurView } from "expo-blur"; import { StatusBar } from "expo-status-bar"; import React, { useCallback, @@ -20,6 +19,7 @@ import React, { import { AppState, BackHandler, + DeviceEventEmitter, Linking, Pressable, StyleSheet, @@ -78,6 +78,13 @@ import { setNotificationsActiveChannel, setNotificationsAppActive, } from "./src/services/notification-service"; +import { applyPresence } from "./src/services/presence"; +import { + initWalletService, + publishOwnNutzapInfo, + reconcile, + startNutzapWatcher, +} from "./src/services/wallet-service"; import { useActivityStore } from "./src/store/activity-store"; import { showAlert } from "./src/store/alert-store"; import { subscribeInboundMessages, useChatStore } from "./src/store/chat-store"; @@ -86,8 +93,8 @@ import { useMeshBanners, useMeshStateStore, } from "./src/store/mesh-state-store"; -import { useSettingsStore } from "./src/store/settings-store"; import { useTransferStore } from "./src/store/transfer-store"; +import { useWalletStore } from "./src/store/wallet-store"; import Avatar from "./src/ui/components/avatar"; import CustomAlert from "./src/ui/components/custom-alert"; import MeshStatusBar from "./src/ui/components/mesh-status-bar"; @@ -100,10 +107,14 @@ import { useResolvedTheme, useThemeColors, } from "./src/ui/theme"; -import { ensureBlePermissions } from "./src/utils/ble-permissions"; +import { + ensureBlePermissions, + hasBlePermissions, +} from "./src/utils/ble-permissions"; import { parseAirhopLink } from "./src/utils/deep-link"; import { mentionsNickname } from "./src/utils/mentions"; import { messagePreviewText } from "./src/utils/message-preview"; +import { showBlockedAlert } from "./src/utils/permissions"; import { sumUnread } from "./src/utils/unread"; import { peerIDToUsername } from "./src/utils/username"; @@ -129,6 +140,11 @@ interface MessageTarget { // Placeholder peer ID shown before identity is loaded from secure storage. const FALLBACK_PEER_ID = "0000000000000000"; +// Live NIP-61 subscription, kept at module scope so a re-onboard (panic wipe +// then fresh identity) replaces it instead of leaving the old identity's +// subscription running against the new wallet. +let stopNutzapWatcher: (() => void) | null = null; + // Request the BLE runtime permissions the OS requires, THEN start the mesh. // Without the grant, native startScanning/startAdvertising throw and are // swallowed: a silent, total discovery failure. On denial we surface a @@ -143,18 +159,64 @@ async function startMeshWithPermissions( // than spinning "Scanning…" forever with no route to a fix. useMeshStateStore.getState().setPermissionGranted(perm.granted); if (!perm.granted) { - showAlert( - "Bluetooth permission needed", - perm.blockedForever - ? "Airhop can't find nearby devices without Bluetooth (and Nearby devices/Location) permission. Enable it in Settings → Apps → Airhop → Permissions." - : "Airhop needs Bluetooth and Location permission to discover nearby devices over the mesh. Without it, only internet (Nostr) messaging will work.", - ); + if (perm.blockedForever) { + // The OS will not prompt again, so the only way out is Settings. Same + // deep-linked dialog the camera and photo flows use, rather than a + // dead-end box reciting a path to tap through. + showBlockedAlert({ + label: "Bluetooth access", + purpose: "discover nearby devices over the mesh", + }); + } else { + showAlert( + "Bluetooth permission needed", + "Airhop needs Bluetooth and Location permission to discover nearby devices over the mesh. Without it, only internet (Nostr) messaging will work.", + ); + } } // Apply the persisted Tor preference BEFORE the mesh starts, so the very first // relay pool is built on the Tor socket (never leaking the clear net for a Tor // user). No-op when Tor is off or unavailable. primeTorRoutingOnStartup(); initMeshService(identity, nickname); + + // Open the encrypted ecash store and settle anything left in flight. Proofs + // live in an AES-256 MMKV file whose key is in the Keychain/Keystore, so this + // is async and must happen before the Wallet tab can spend. Failure leaves + // the wallet locked rather than silently falling back to plaintext storage. + // + // `reconcile` then finishes the work a previous session could not: Lightning + // deposits whose invoice was paid after the app was closed, and reserved + // sends whose recipient has since redeemed them. + void (async () => { + const unlocked = await initWalletService(); + if (!unlocked) return; + await reconcile().catch(() => { + // Offline, or the mint is down. Retried on the next launch. + }); + const client = getMeshService()?.getNostrClient(); + const privKey = getMeshService()?.getNostrPrivKey(); + const pubKey = getMeshService()?.getNostrPubKeyHex(); + if (!client || !privKey || !pubKey) return; + // Tell the network how to pay us (NIP-61 kind 10019), then watch for + // incoming nutzaps. Both are no-ops without a mint configured. + await publishOwnNutzapInfo({ + client, + privKey, + relays: client.activeRelays, + }); + stopNutzapWatcher?.(); + stopNutzapWatcher = startNutzapWatcher({ + myPubkey: pubKey, + client, + onRedeemed: (amount, unit, from) => { + showAlert( + `+${amount.toLocaleString()} ${unit}`, + `Nutzap received from ${from.slice(0, 12)}… and redeemed into your wallet.`, + ); + }, + }); + })(); // The mesh always starts Online (advertising + scanning), so keep the chosen // presence in step, in case a prior session left it Away/Invisible. useMeshStateStore.getState().setPresenceStatus("online"); @@ -207,7 +269,7 @@ export default function App(): React.JSX.Element { // sub-screen (About, Version, ...) pops ProfileScreen back to its root, the // same way tapping Chats returns to the conversation list. const [profileResetSignal, setProfileResetSignal] = useState(0); - // Which way the last tab change moved through TABS, so the content + // Which way the last tab change moved through the tabs, so the content // transition slides the same direction the tab bar (or swipe) implied. const [tabDirection, setTabDirection] = useState<"forward" | "backward">( "forward", @@ -216,10 +278,6 @@ export default function App(): React.JSX.Element { const [chatView, setChatView] = useState({ kind: "list" }); const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); - // The content region behind the tab bar. On Android the frosted glass blurs a - // snapshot of this target (the new expo-blur API); on iOS it is a plain View - // and the native backdrop blur ignores it. - const blurTargetRef = useRef(null); // Which message a search result should scroll a thread to on open. const [messageTarget, setMessageTarget] = useState( null, @@ -232,6 +290,11 @@ export default function App(): React.JSX.Element { // Counter-based trigger: incrementing (with an action) tells WalletScreen // to open the matching modal, same pattern as newChanCounter/meshAddCounter. const [walletAction, setWalletAction] = useState(null); + // Whether there is any ecash at all to spend. Selected as a boolean so the + // header only re-renders when the answer flips, not on every proof change. + const hasSpendableEcash = useWalletStore((s) => + Object.values(s.proofs).some((list) => list.length > 0), + ); const [walletActionTrigger, setWalletActionTrigger] = useState(0); // Notification center (bell) visibility, and the count of unseen activity // that badges the bell. Subscribing to entries keeps the badge live. @@ -247,13 +310,6 @@ export default function App(): React.JSX.Element { setLastThread, } = useChatStore(); const meshBanners = useMeshBanners(); - // Payments is switchable from the profile screen, so the tab bar (and the - // swipe order that follows it) is derived rather than fixed. - const paymentsEnabled = useSettingsStore((s) => s.paymentsEnabled); - const tabs = useMemo( - () => ALL_TABS.filter((t) => t.id !== "wallet" || paymentsEnabled), - [paymentsEnabled], - ); // On mount: check for an existing persisted identity. If found, skip // onboarding and start the BLE mesh service immediately. @@ -263,10 +319,26 @@ export default function App(): React.JSX.Element { if (existing) { setGeneratedPeerID(existing.peerID); setOnboardingStep(null); - void startMeshWithPermissions( - existing, - peerIDToUsername(existing.peerID), - ); + // Android can destroy the Activity while the foreground service keeps + // the process (and the JS runtime, and the mesh) alive. Reopening then + // remounts this component with everything already set up, and tearing + // that down just to rebuild it is what made a reopen feel like a hang: + // a full stop() says goodbye to every peer, drops the relay pool, and + // bounces the foreground service, all to arrive back where we started. + // + // So a cold start is exactly: no mesh at all, or one belonging to a + // different identity (a wipe re-onboarded as someone else). An + // existing mesh is left alone whatever state it is in - including + // stopped, because the only things that stop it are the user choosing + // Away and the notification's "Stop mesh". Restarting it here would + // undo a decision they just made, from an event they didn't trigger. + const existingMesh = getMeshService(); + if (existingMesh?.peerID !== existing.peerID) { + void startMeshWithPermissions( + existing, + peerIDToUsername(existing.peerID), + ); + } // Restore the last open thread after an OS-kill-and-reopen. The // channel name is persisted by setLastThread and cleared by closeThread. const { lastThread } = useChatStore.getState(); @@ -355,23 +427,50 @@ export default function App(): React.JSX.Element { // not already looking at the app. useEffect(() => { setNotificationsAppActive(AppState.currentState === "active"); - // Re-read location permission whenever we come to the foreground: the user - // may have toggled it in system Settings while we were backgrounded, and the - // Mesh banner must reflect that. Bluetooth adapter changes already arrive as - // native events, so they need no polling here. - const syncLocation = (): void => { + // Re-read the permissions whenever we come to the foreground: the user may + // have changed either in system Settings while we were backgrounded, and + // coming back is the only signal we get. Bluetooth adapter changes already + // arrive as native events, so they need no polling here. + // + // Both are checks, never requests: prompting someone who just walked back + // into the app would be ambushing them. + const syncPermissions = (): void => { void hasLocationPermission().then((granted) => useMeshStateStore.getState().setLocationGranted(granted), ); + void hasBlePermissions().then((granted) => { + const state = useMeshStateStore.getState(); + if (granted === state.permissionGranted) return; + state.setPermissionGranted(granted); + // Granted while we were away: the radios never started (or were denied + // mid-session), so the mesh is sitting there doing nothing behind a + // banner the user has already acted on. Bring it up now instead of + // making them restart the app to be believed. + if (granted) getMeshService()?.retryRadios(); + }); }; - syncLocation(); + syncPermissions(); const sub = AppState.addEventListener("change", (next) => { setNotificationsAppActive(next === "active"); - if (next === "active") syncLocation(); + if (next === "active") syncPermissions(); }); return () => sub.remove(); }, []); + // "Stop mesh" on the Android background notification. The native service + // hands it here rather than tearing things down itself, so stopping from the + // notification and stopping from the Status picker are the same action: the + // radios come down, the gateway switches off, and presence lands on Away - so + // reopening the app shows "Mesh paused · You're away" with a way back, not a + // dead mesh wearing a green dot. + useEffect(() => { + const sub = DeviceEventEmitter.addListener( + "AirhopBLE.meshStopRequested", + () => applyPresence("away", username), + ); + return () => sub.remove(); + }, [username]); + // One-time setup, deferred until past onboarding so the OS permission prompt // lands on the mesh screen in context (alongside the Bluetooth/Location // prompt) rather than on the welcome screen. Wires the inbound observer @@ -560,8 +659,8 @@ export default function App(): React.JSX.Element { // direction always matches TABS order instead of only working for taps. const navigateToTab = useCallback( (nextTab: MainTab, resetChatView = true): void => { - const nextIndex = tabs.findIndex((t) => t.id === nextTab); - const currentIndex = tabs.findIndex((t) => t.id === tab); + const nextIndex = TABS.findIndex((t) => t.id === nextTab); + const currentIndex = TABS.findIndex((t) => t.id === tab); setTabDirection(nextIndex >= currentIndex ? "forward" : "backward"); setTab(nextTab); if (nextTab === "chats" && resetChatView) { @@ -573,7 +672,7 @@ export default function App(): React.JSX.Element { setProfileResetSignal((n) => n + 1); } }, - [tab, tabs], + [tab], ); function openDMFromMesh(channel: string): void { @@ -612,14 +711,14 @@ export default function App(): React.JSX.Element { Math.abs(event.translationX) > 60 || Math.abs(event.velocityX) > 600; if (!passedThreshold) return; - const currentIndex = tabs.findIndex((t) => t.id === tab); + const currentIndex = TABS.findIndex((t) => t.id === tab); const target = event.translationX < 0 - ? tabs[currentIndex + 1] - : tabs[currentIndex - 1]; + ? TABS[currentIndex + 1] + : TABS[currentIndex - 1]; if (target) runOnJS(navigateToTab)(target.id, true); }), - [tab, tabs, isInThread, navigateToTab], + [tab, isInThread, navigateToTab], ); const tabEntering = @@ -935,10 +1034,20 @@ export default function App(): React.JSX.Element { <> Wallet + {/* Send and Zap both spend, so they are dimmed with + nothing to spend. Letting them open a sheet that can + only end in "not enough balance" is the classic way + to waste somebody's time. Receive and Add mint stay + live, since those are how a new wallet gets started. */} triggerWalletAction("send")} accessibilityRole="button" + accessibilityState={{ disabled: !hasSpendableEcash }} accessibilityLabel="Send ecash token" > triggerWalletAction("zap")} accessibilityRole="button" + accessibilityState={{ disabled: !hasSpendableEcash }} accessibilityLabel="Zap a Nostr contact" > + applyPresence("online", username)} + /> )} - {/* Content: swipe left/right to step through TABS, matching the + {/* Content: swipe left/right to step through tabs, matching the tab bar's order. The inner Animated.View is keyed by tab so only a genuine tab change slides. Switching Channels/Direct or opening a thread within the same tab does not. */} - + { - // Stop BLE mesh immediately so old keys are flushed from memory. - getMeshService()?.stop(); + // The mesh is already down and its keys released: the + // wipe does that first, before it clears anything. + // All that is left here is putting the shell back to + // a first-run state. setGeneratedPeerID(FALLBACK_PEER_ID); // Reset navigation to the fresh-start landing tab. // Panic wipe is triggered from Profile, so without this @@ -1130,35 +1249,22 @@ export default function App(): React.JSX.Element { /> )} - + - {/* Floating bottom stack: the ongoing-transfer pill and the - frosted-glass tab bar, both hovering over the content that - scrolls beneath. box-none so taps land on content in the gaps - around the pills, not on the transparent container. */} + {/* Floating bottom stack: the ongoing-transfer pill and the tab + bar, both hovering over the content that scrolls beneath. + box-none so taps land on content in the gaps around the pills, + not on the transparent container. */} {!isInThread && ( - {/* Outer wrap carries the shadow + rounding; the BlurView must - clip to the pill (overflow hidden), and a clipped view - can't cast the shadow itself. */} + {/* Outer wrap carries the shadow + rounding; the bar itself + clips its children to the pill (overflow hidden), and a + clipped view can't cast the shadow itself. */} - - {tabs.map(({ id, label, icon }) => { + + {TABS.map(({ id, label, icon }) => { const active = tab === id; return ( ); })} - + )} @@ -1240,7 +1346,7 @@ const HEADER_TITLES: Record = { profile: "You", }; -const ALL_TABS: { id: MainTab; label: string; icon: string }[] = [ +const TABS: { id: MainTab; label: string; icon: string }[] = [ { id: "chats", label: "Chats", icon: "message-square" }, { id: "mesh", label: "Mesh", icon: "radio" }, { id: "wallet", label: "Wallet", icon: "credit-card" }, @@ -1354,6 +1460,9 @@ function createStyles(Colors: ReturnType) { justifyContent: "center", backgroundColor: Colors.surfaceRaised, }, + headerPillDisabled: { + opacity: 0.35, + }, // Same tap target as the + pill but with no filled background, so the bell // sits as a lighter-weight action beside it. headerIconBtn: { @@ -1393,9 +1502,9 @@ function createStyles(Colors: ReturnType) { right: 0, bottom: 0, }, - // Outer wrap: carries the shadow + rounding + margins. Kept separate from the - // BlurView because the blur must clip to the pill (overflow hidden), and a - // clipped view cannot also cast a shadow. + // Outer wrap: carries the shadow + rounding + margins. Kept separate from + // the bar itself because the bar clips its children to the pill (overflow + // hidden), and a clipped view cannot also cast a shadow. tabBarWrap: { marginHorizontal: Spacing.base, marginBottom: Spacing.md, @@ -1406,10 +1515,10 @@ function createStyles(Colors: ReturnType) { shadowRadius: 16, elevation: 10, }, - // The frosted-glass pill itself: the BlurView. A hairline glass-edge border - // and a translucent fill (set inline from the theme) sit over the blur. + // The pill itself: a solid surface with a hairline edge. tabBar: { flexDirection: "row", + backgroundColor: Colors.surface, borderRadius: Radius.full, overflow: "hidden", paddingTop: Spacing.xs, diff --git a/WALLET-KT.md b/WALLET-KT.md new file mode 100644 index 0000000..bcb19bf --- /dev/null +++ b/WALLET-KT.md @@ -0,0 +1,786 @@ +# Airhop Wallet: Knowledge Transfer + +Everything about the payments side of Airhop, from first principles. Written for +someone who has never touched Bitcoin, Lightning, ecash, or "web3" terminology. +No prior knowledge assumed. + +If you only read one thing, read [The 60-second version](#the-60-second-version) +and [How to test it](#how-to-test-it). + +## Contents + +1. [The 60-second version](#the-60-second-version) +2. [Why payments at all](#why-payments-at-all) +3. [The three layers: Bitcoin, Lightning, ecash](#the-three-layers-bitcoin-lightning-ecash) +4. [Glossary](#glossary) +5. [How ecash actually works](#how-ecash-actually-works) +6. [The mint, and why you must choose one](#the-mint-and-why-you-must-choose-one) +7. [Money in and money out](#money-in-and-money-out) +8. [Sending: the lifecycle](#sending-the-lifecycle) +9. [Receiving and claiming](#receiving-and-claiming) +10. [Nutzaps: paying over the internet](#nutzaps-paying-over-the-internet) +11. [Backup and recovery](#backup-and-recovery) +12. [Moving between mints](#moving-between-mints) +13. [Security: what is protected, what leaks](#security-what-is-protected-what-leaks) +14. [The code: file by file](#the-code-file-by-file) +15. [Every user flow, step by step](#every-user-flow-step-by-step) +16. [How to test it](#how-to-test-it) +17. [Limits, and which are permanent](#limits-and-which-are-permanent) +18. [FAQ for developers](#faq-for-developers) + +## The 60-second version + +A **mint** is a server that swaps Lightning bitcoin for **ecash tokens**. A token +is a string of text that is worth money to whoever holds it, like a banknote. + +Airhop stores those tokens encrypted on your phone and can hand them to another +phone **over Bluetooth with no internet at all**. That is the entire point: it +works in a blackout, a protest, a dead zone. + +Blind signatures mean the mint cannot see who you pay. Redeeming happens inside +Airhop, never in a browser or another app. Lightning deposits and withdrawals are +the only parts that need the internet, because Airhop is not a Lightning node. + +The mint is the one thing you have to trust. You pick it, you can run your own, +and you should treat the balance like cash in your pocket rather than a savings +account. + +## Why payments at all + +Airhop exists for situations where normal infrastructure is unavailable: +disasters, blackouts, protests, remote areas. In exactly those situations, card +networks and banking apps are also down. + +Every mainstream payment system needs a live connection to a server at the moment +of payment. Ecash does not. That is why it is here, and it is why the design +prioritises the offline path over everything else. + +## The three layers: Bitcoin, Lightning, ecash + +### Bitcoin + +A public ledger. Roughly ten minutes per block, every transaction visible forever, +a fee per transaction. Excellent as a settlement layer, terrible for buying +coffee. + +**Sats** (satoshis) are just the small unit. 1 bitcoin = 100,000,000 sats. Airhop +denominates everything in sats because they are a sensible size for a +message-sized payment. + +### Lightning + +A network built on top of Bitcoin. Instead of writing every payment to the +ledger, people open payment channels and settle instantly between them. Fast, +cheap, works for tiny amounts. + +A Lightning payment is requested with an **invoice**: a long string starting with +`lnbc...`. Someone generates one saying "pay me 500 sats", you pay it, done in a +second. + +**But Lightning needs the internet.** Both ends must be online at the same moment +and routing happens live. In a blackout, Lightning is dead. Which is a problem, +because that is exactly when Airhop is supposed to work. + +### Cashu (ecash) + +The layer that solves the offline problem, and what Airhop actually uses. + +Cashu is **digital cash**. Not a ledger, not a network. A token is a _string of +text_ worth money to whoever holds it. You can send a string over Bluetooth. You +can read it aloud. You can write it on paper. + +**Handing someone the string is handing them the money.** No internet, no server, +no confirmation, no counterparty being online. + +## Glossary + +Plain-English definitions of every term you will hit in the code or the UI. + +| Term | What it means | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **sat** | Satoshi. 1/100,000,000 of a bitcoin. The unit Airhop counts in. | +| **mint** | The server that issues and redeems ecash and holds the real bitcoin. The one trust point. | +| **proof** | One ecash coin. An amount, a secret, and the mint's signature over that secret. | +| **secret** | A random string only the coin's owner knows. Whoever knows it owns the coin. | +| **token** | One or more proofs packed into a single text string. What actually moves between people. Starts with `cashuB`. | +| **keyset** | The mint's set of signing keys, one per denomination. Airhop caches the public half so it can verify tokens offline. | +| **denomination** | Coins come in powers of two (1, 2, 4, 8, 16...). This is why exact amounts are sometimes impossible. | +| **swap** | Trading proofs at the mint for fresh ones with new secrets. What makes a received coin truly yours. | +| **mint (verb)** | Turning Lightning sats into ecash. "Deposit" in the UI. | +| **melt** | Turning ecash back into a Lightning payment. "Withdraw" in the UI. | +| **blind signature** | The mint signs your coin without seeing the secret, so it cannot later link issuance to spending. | +| **DLEQ** | A small proof attached to a coin letting your device verify the mint signed it, with no network. | +| **P2PK** | "Pay to public key". A coin locked so only one specific key can spend it. Used by nutzaps. | +| **nutzap** | Paying a Nostr identity over the internet with P2PK-locked ecash (NIP-61). | +| **bolt11** | The standard Lightning invoice format. The `lnbc...` string. | +| **NUT** | A Cashu spec document. "Notation, Usage, and Terminology". NUT-04 is deposits, NUT-05 is withdrawals, and so on. Numbered chapters, nothing mystical. | +| **NIP** | The same idea for Nostr. NIP-61 is nutzaps. | +| **BIP-39** | The standard for turning random entropy into 12 memorable words. Your recovery phrase. | +| **bearer instrument** | Something where possession equals ownership. Cash, a cinema ticket, an ecash token. | + +### Terms you will _not_ find here + +Airhop's payments involve **no blockchain transactions, no smart contracts, no +tokens in the crypto-asset sense, no wallet-connect, no gas fees, and no +accounts**. If you came expecting Ethereum-style plumbing, none of it applies. +Cashu is closer to a 1990s digital-cash paper than to anything sold as "web3". + +## How ecash actually works + +### What a coin is + +Three parts: + +``` +proof = { + amount: 8 // always a power of two + secret: "a91f3e..." // random string only you know + C: "02b4c9..." // the mint's signature over that secret +} +``` + +The mint holds the real bitcoin. It has **no idea who owns what**. It only keeps +a list of "secrets that have been spent". Show up with a secret it signed and has +not seen spent, and it pays out. + +**If you lose the secret, the money is unreachable.** Not stolen, not refundable. +The bitcoin sits at the mint forever because nobody can prove they own it. This +is why [backup](#backup-and-recovery) exists. + +### Blind signatures: why the mint cannot spy + +This is the clever part and the whole privacy story. + +When the mint signs your coin, **it never sees the secret**. You blind it first, +mathematically. The intuition: you put a document in an envelope lined with +carbon paper, the mint signs the outside, and the signature comes through onto a +document it never read. + +So later, when someone redeems that coin, the mint verifies its own signature but +**cannot tell which customer it was originally issued to**. The link between "who +deposited" and "who spent" is severed by maths, not by policy. + +That is privacy a bank account can never offer. + +### Why denominations are powers of two + +Coins come in 1, 2, 4, 8, 16, 32, 64... so any amount can be assembled from a +handful of them. 100 = 64 + 32 + 4. + +The consequence you will feel: sometimes you cannot make an exact amount, for the +same reason you cannot make £7 from a £5 note and a £10 note without change. And +offline, **there is no change**, because making change requires the mint. Airhop +detects this, tells you the smallest token it can build, and makes you confirm +before overpaying. + +## The mint, and why you must choose one + +**Airhop is a wallet, not a bank.** It holds your coins; it does not issue them. +Someone has to hold the actual bitcoin, and that someone is the mint. + +Think of a casino cashier's desk. You hand over cash, you get chips. The chips +move around the floor with nobody watching. Anyone can bring chips back to the +desk for cash. + +### The honest trade-off + +**What you get:** payments with no internet, no account, no ID check, no way for +the mint to see who you paid. + +**What you give up:** the mint could vanish with the money. It is a custodian. + +It is a _limited_ custodian, though. It cannot see who you are, who you pay, or +link deposits to spends, so it cannot single you out or freeze one person's +funds. But it is holding the bitcoin. + +### How Airhop limits the damage + +- **No default mint ships.** Nothing is chosen for you. +- **The URL is validated before it is saved**, so a typo or a dead host is + rejected up front rather than failing on first use. +- **Plain `http://` is refused** except on localhost, since that would send proofs + over an unauthenticated channel. +- **Balances are per (mint, unit) and never pooled**, so one mint failing cannot + take the rest. +- **You can run your own.** Nutshell is the reference implementation; a Raspberry + Pi is enough. + +### One behaviour to know about + +If you receive a token from a mint you have never added, **Airhop adds that mint +automatically** so the money is usable. That is the right default (otherwise the +money would be stuck) but it means your mint list can grow with mints you never +chose. Worth surfacing to users eventually. + +## Money in and money out + +These are the only two parts that need the internet, and the only two that +involve another app, because **Airhop is not a Lightning node**. + +### In: minting (NUT-04) + +``` +You tap Deposit, enter 1000 sats + │ + ▼ +Mint returns a bolt11 invoice + │ + ▼ +You pay it from any Lightning wallet ← the only external app involved + │ + ▼ +Mint sees it paid, issues 1000 sats of proofs +``` + +Airhop polls while the sheet is open. Close the app mid-flight and `reconcile()` +picks it up on next launch, so a half-finished deposit is never lost. + +### Out: melting (NUT-05) + +``` +Paste a bolt11 invoice into Withdraw + │ + ▼ +Quote: amount + routing reserve ← shown before anything is spent + │ + ▼ +You confirm; the mint pays the invoice + │ + ▼ +Unused reserve comes back as change proofs +``` + +The **routing reserve** is an upper bound on the Lightning network fee. Whatever +routing does not consume is returned, which is why the UI says "up to". + +## Sending: the lifecycle + +This is the most important part of the design, so it is worth understanding +precisely. + +### What happens when you send 100 sats + +``` +1. QUOTE pick proofs that cover 100, plus the mint's fee + → "you spend 101, they receive 100" + +2. RESERVE proofs MOVE to a reserved bucket + → they leave your spendable balance (no double-spend) + → they are NOT deleted + +3. SERIALISE pack them into one cashuB... string + +4. DELIVER mesh DM / share sheet / clipboard + +5. SETTLE one of four things happens +``` + +### Step 5, in detail + +| Outcome | What happens | +| ----------------------------- | ------------------------------------------------- | +| You tap **"They got it"** | Reservation dropped. Done. | +| You tap **Reclaim** | Proofs return to your balance. | +| You do nothing | Stays under **Pending**. Survives app restart. | +| `reconcile()` sees them spent | Auto-completes, because the money really is gone. | + +### Why this matters + +Before this design, proofs were **deleted** the instant the token was built. +Close the sheet without sharing, crash, or have the Bluetooth message fail to +route, and the money was gone with no trace and no recovery. + +Now nothing is destroyed until delivery is confirmed. **This is the single most +important correctness property in the wallet.** + +### The one caveat, which the UI states before you tap + +Reclaiming races the recipient. If they already have the token string, whoever +reaches the mint first keeps the money, and that could be them. + +### Fees: why "send 100" means they get 100 + +Under NUT-02 the mint charges the **recipient** an input fee when they swap. So +naively sending exactly 100 leaves them with less than 100. + +Airhop selects enough to cover that fee, which is what every production Cashu +wallet does. The generated-token sheet shows both numbers when they differ. + +### Where you can send from + +Three entry points, all running **identical code** via +`services/ecash-transfer.ts`: + +- **Wallet tab** → build a token, then share / copy / pick a nearby peer +- **Mesh tab** → tap a peer → "Send sats" +- **Chat** → inside a DM, attach menu → "Send ecash" + +Before the refactor each screen open-coded its own version and each got it +slightly wrong (one used a hard-coded `"local"` sender id, none warned about +inexact amounts, none attached a message id for delivery tracking). + +## Receiving and claiming + +### What the recipient sees + +The token arrives as a message. Airhop detects it and renders a **payment card** +with the amount, mint and memo, plus a Claim button. The raw string is hidden, +because 400 characters of base64 is not a useful thing to look at. + +### Airhop does NOT open a browser or another wallet + +This is a deliberate difference from bitchat, which only _displays_ Cashu tokens +and hands them off to an external wallet app or the `redeem.cashu.me` website. + +**Airhop implements the wallet itself**, so the token never leaves the device and +no third party ever sees it. + +### Online: swap immediately + +Airhop contacts the mint over HTTPS and performs a **swap**: it trades the +received proofs for brand new ones with brand new secrets only you know. + +This matters for a non-obvious reason. **The sender still has a copy of the token +string.** Until you swap, they could spend it out from under you. After the swap, +their copy is dead. + +> Swapping is what turns "someone gave me a token" into "this is my money." + +### Offline: store and flag honestly + +Two things happen: + +**1. The signature is verified offline.** Using the mint's cached public keys, +Airhop checks the **DLEQ** proof attached to each coin. This confirms the mint +really signed it. A forged token is **rejected outright** and never touches your +balance. + +**2. The money is marked unconfirmed.** Your balance shows it, but with a +separate line: _"X sats not yet confirmed with the mint."_ Refresh when online and +the line clears. + +### The limit you must internalise + +> **A valid signature proves the mint issued that coin. +> It can NEVER prove the coin is unspent.** + +Someone can hand you a genuine, correctly signed token they already spent five +minutes ago. No cryptography detects this offline. Only the mint knows. + +This is not an Airhop weakness. It is a property of bearer instruments: a genuine +banknote can also have been promised to someone else. Airhop's job is to be +honest that it does not know yet, which is exactly what the unconfirmed line does. + +**Practical advice:** for a stranger, redeem before handing over goods. For a +friend, it does not matter. + +## Nutzaps: paying over the internet + +A **nutzap** (NIP-61) pays a Nostr identity directly with ecash. + +The difference from a normal token: the sender **locks the coins to the +recipient's public key** (P2PK). It is a token only they can open, which is what +makes it safe to publish in a public event. + +### Airhop tries three tiers and tells you which happened + +| Tier | When | Property | +| ---------------- | ----------------------------------------------- | ----------------------------------------------------- | +| **Nutzap** | They published NIP-61 info and you share a mint | Locked to them. Nobody else can spend it. | +| **Encrypted DM** | They published nothing | Works, but it is a bearer token once they decrypt it. | +| **Manual token** | No connectivity at all | A string you deliver by hand. Still reclaimable. | + +The old implementation silently downgraded between these and reported success +either way. It now names the fallback and the reason. + +### Receiving + +`startNutzapWatcher` runs in the background, redeems incoming zaps automatically, +and alerts you. Redeemed event ids are remembered so a relay replaying old events +cannot credit you twice. + +### Privacy caveat worth repeating + +**A nutzap is a public event.** The coins are locked so nobody else can spend +them, but relays and observers see that pubkey A paid pubkey B, and the amount. +The encrypted-DM fallback is the opposite: metadata-private, but the token is a +bearer instrument once decrypted. + +## Backup and recovery + +**Off by default.** Turning it on is a commitment: the user has to write twelve +words down and keep them. + +### The problem it solves + +By default every coin's secret is fresh random bytes. The only copy that has ever +existed is on this phone. Lose the phone and the money is unreachable forever. + +### How it works + +With a recovery phrase, secrets stop being random. They are **derived** from one +master seed in a fixed order (**NUT-13**): + +``` +seed + keyset + counter 0 → secret #0 +seed + keyset + counter 1 → secret #1 +seed + keyset + counter 2 → secret #2 +... +``` + +Recovery (**NUT-09**) re-derives them on any device and asks the mint _"did you +sign this one? this one? this one?"_ The mint answers from its own records and +the balance reassembles out of nothing but twelve words. + +### What it does and does not cover + +| | | +| ------------------------------------- | --------------------------------------- | +| Ecash derived from the phrase | ✅ | +| At mints you re-add | ✅ | +| Your Airhop identity, chats, contacts | ❌ no backup exists at all | +| Which mints you used | ❌ shown beside the words to write down | +| Coins received and never swapped | ❌ they carry the _sender's_ secrets | + +That last row is subtle and the UI states it: coins someone gave you are outside +your phrase until a swap re-issues them under it. The card shows exactly how much +falls into that category, and Refresh fixes it. + +### Three honesty properties + +**The keychain is the source of truth.** If the flag says backup is on but the +phrase is gone (keychain reset, device restore that did not carry keychain +items), startup turns the flag off rather than claiming coverage you do not have. + +**"Unconfirmed" is its own state.** See the words but bail before the check and +the card reads **Unconfirmed** in red, not On. A phrase that exists but was never +written down is the worst possible state, because the wallet looks protected and +is not. Tapping "View phrase" from there reopens the write-it-down flow. + +**It is one-way.** There is no "turn backup off", because deleting a phrase your +coins were derived from is indistinguishable from deleting the coins. Only the +panic wipe removes it. + +### Counters: the one thing that can go wrong + +Re-deriving a counter recreates a secret the mint has already signed, and the +swap is rejected as a duplicate. + +Mitigations: + +- The cursor is persisted per keyset and only ever moves forward. +- Restore pushes it past everything the mint has on record. +- Reads and writes are synchronous in one function body, so two concurrent + callers cannot get the same range. + +**A rejected swap leaves the input proofs untouched**, so the failure mode is a +retry, never a loss. + +## Moving between mints + +### The permanent limit + +A token names **exactly one mint**. Ecash from two mints can never be combined +into a single token. + +With 60 sats at mint A and 60 at mint B, you cannot send 100, even though the +total says 120. **This is Cashu's design and is not something an app can work +around.** + +### What Airhop can do + +Move the value. The destination mint issues a Lightning invoice and the source +mint pays it: + +``` +mint B issues an invoice for N + │ + ▼ +mint A pays it over Lightning ← one routing fee + │ + ▼ +mint B issues N sats of proofs to you +``` + +One tap, versus the five-step manual dance (external wallet, two invoices, two +routing fees) it replaces. + +The awkward part is sizing: the fee reserve is only known after quoting, quoting +needs an invoice, and an invoice needs an amount. The implementation quotes, +checks whether the total fits, shrinks and retries at most twice, and marks +abandoned invoices expired so they do not linger as phantom pending deposits. + +## Security: what is protected, what leaks + +### Over Bluetooth (the offline path) + +| | | +| ---------------- | ------------------------------------- | +| Encryption | Noise XX + Double Ratchet, end to end | +| Who can read it | Only the two of you | +| Relaying phones | Carry ciphertext, cannot read it | +| Servers involved | **None** | +| Mint involvement | **None** | + +**This path leaks nothing.** No server sees it, no relay sees it, the mint does +not even know the payment happened. It is the strongest thing in the system. + +**Exception:** a token posted to a **public channel** is in the open. Anyone +reading the channel can redeem it, first come first served. Inherent to bearer +tokens, not a flaw. The UI says so. + +### To the mint + +**Sees:** your IP address, deposit and withdrawal amounts, timing. + +**Cannot see:** who you are, who you paid, who paid you, or any link between the +coins you deposited and the ones you spend. Blind signatures make that +mathematically impossible. + +The IP is the weak point, which is why Tor matters here. + +### Tor + +| Platform | Status | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Android** | Orbot runs as a VPN and covers everything, mint traffic included. Fully protected. | +| **iOS** | Tor only covers the Nostr WebSocket. Mint requests would bypass it, so **Airhop blocks them** and explains why, with an opt-in switch under Settings → Privacy & Security. | + +**Mesh ecash never touches the mint**, so sending and receiving to people near you +always works with Tor on, on both platforms. + +The dangerous version of this bug is dead: previously the request went out anyway +and the mint quietly logged the user's IP next to their coins. + +### To Nostr relays + +Only nutzaps touch relays, and **a nutzap is a public event**. See +[Nutzaps](#nutzaps-paying-over-the-internet). + +### On your phone + +Proofs live in an **AES-256 encrypted MMKV file**, keyed from the iOS Keychain / +Android Keystore. If the keychain cannot be opened the wallet **locks** rather +than falling back to plaintext. Panic wipe deletes the file and destroys the key. + +## The code: file by file + +``` +src/core/payments/ + cashu.ts Pure, offline. Token detection, decoding, DLEQ + verification, fee maths, proof selection, serialisation. + No network, no state. + nutzap.ts NIP-61 event construction and parsing. Pure. + wallet-seed.ts BIP-39 phrase generation, validation, keychain storage. + +src/store/ + wallet-store.ts Encrypted persistence. Proofs by (mint, unit), reserved + bucket, mint registry, transaction history, NUT-13 + counters. No network. + +src/services/ + wallet-service.ts The ONLY place that talks to a mint. Owns every rule that + protects money: reservations, DLEQ enforcement, the Tor + guard, unit isolation, Lightning, restore, consolidate. + ecash-transfer.ts Shared "send to a mesh peer" flow used by all three screens. + +src/features/wallet/ + wallet-screen.tsx Presentation only. Does no proof arithmetic of its own. +``` + +### The layering rule + +**`core/` is pure and offline. `services/` owns the network and the rules. +`features/` is presentation.** + +The wallet screen deliberately does no proof arithmetic, because the same logic +runs from the DM thread and the peer sheet and the three must not drift. + +### Guarantees the service provides + +1. Proofs are never deleted to send, only moved to a reserved bucket. +2. Nothing is credited without either a mint swap or a passing DLEQ check. +3. A mint call is never made silently over the clear net while Tor is on (iOS). +4. Units are never mixed. A (mint, unit) pair is one account. + +## Every user flow, step by step + +### First run + +``` +Wallet tab → empty state → "No mint yet" + → header + → paste mint URL → validated → saved with cached keys + → Lightning → Deposit → amount → invoice → pay externally → balance +``` + +### Paying someone standing next to you, no internet + +``` +Mesh tab → tap peer → Send sats → amount + → (if inexact) confirm the overpayment + → token built, proofs reserved, DM sent over Bluetooth + → Wallet tab → Pending → "They got it" once confirmed +``` + +### Being paid, no internet + +``` +Chat → payment card appears → Claim + → DLEQ verified against cached mint keys + → stored, balance shows "X not yet confirmed" + → later, online → Wallet → Refresh → line clears +``` + +### Setting up backup + +``` +Wallet → Backup → Set up + → warning screen (what it is, what it does not cover) + → 12 words shown in a numbered grid + → "I have written them down" + → asked for two randomly chosen words + → card reads On +``` + +### Recovering on a new phone + +``` +Install → Wallet → add the same mint(s) + → Backup → Restore → paste 12 words + → scans every keyset at every mint + → reports recovered / already-spent / unreachable +``` + +### Cashing out + +``` +Generate an invoice in any Lightning wallet + → Airhop → Lightning → Withdraw → paste + → quote shows amount + routing reserve + → confirm → paid, unused reserve returned as change +``` + +## How to test it + +### Setup + +You need a mint. The **community test mints** (the `testnut.cashu.space` family) +issue free play-money sats, so every flow can be exercised with nothing at risk. +Confirm the URL responds at `/v1/info` before relying on it. + +For real sats, use a publicly run mint. `bitcoinmints.com` tracks who is running +what. + +### Single device, about ten minutes + +| # | Do this | Expect | +| --- | ------------------------------------------------- | -------------------------------------------------------------- | +| 1 | Add a mint | Row shows its name and unit | +| 2 | Deposit 100 sats, pay the invoice | Balance appears, **not** flagged unconfirmed | +| 3 | Send 10, then **close the sheet without sharing** | Appears under **Pending** | +| 4 | Reclaim it | Balance back to 100. _This is the bug that used to eat money._ | +| 5 | Send 10 → Copy → Receive → paste it back | "Already claimed", not a phantom +10 | +| 6 | Send 3 when you only hold powers of two | Inexact warning with the real overpayment | +| 7 | Withdraw to an invoice | Quote breakdown, then change returned | + +### Backup + +| # | Do this | Expect | +| --- | ------------------------------------------------------- | --------------------------------------------------------- | +| 1 | Backup → Set up → complete the word check | Card reads **On** | +| 2 | Repeat but bail at the words | Card reads **Unconfirmed** in red | +| 3 | Tap View phrase from that state | Reopens the write-it-down flow, not read-only | +| 4 | Receive a token from another device | Card shows "not covered yet" | +| 5 | Refresh | Line clears, message names it separately from "confirmed" | +| 6 | **Panic wipe, re-add mint, Restore with the words** | Balance comes back | +| 7 | Try restoring a _different_ phrase over an existing one | Warns before replacing | + +### Two devices (the real test) + +1. Both on the same mint. A sends B 20 sats from the Mesh peer sheet. +2. B: payment card in the DM → Claim. Online → "provably yours". +3. **Airplane mode both first** → "stored, not yet confirmed", B's balance shows + the unconfirmed line. +4. B goes online → Refresh → line clears, swap appears in Activity. +5. **Double-spend check:** A sends B a token, then _before B claims_, A reclaims + and spends it elsewhere. B's claim should fail with "already spent" rather than + crediting. + +### Offline / airplane mode + +Everything except deposit, withdraw, refresh and zap must work with no radios: +send, receive, claim, balance, fee maths, DLEQ verification. **This is the core +promise, so hammer it.** + +### Tor + +- **iOS:** mint buttons grey out, banner explains, Settings has the opt-in. +- **Android + Orbot:** everything keeps working. +- **Both:** mesh ecash unaffected. + +### Multi-mint + +Hold a balance at two mints → the split-balance row appears → Move → one mint +holds it all. + +## Limits, and which are permanent + +### Permanent (protocol or platform) + +| Limit | Why | +| ------------------------------------- | -------------------------------------------------------- | +| One token = one mint | Cashu's design. No merging across mints, ever. | +| DLEQ cannot prove "unspent" | Only the mint knows. No offline cryptography fixes this. | +| A token in a public channel is public | Bearer means bearer. | +| Reclaim races the recipient | Both hold the string; first to the mint wins. | +| iOS Simulator has no Bluetooth | Mesh ecash needs two physical devices. | + +### Fixable, not yet done + +| Gap | Effort | Notes | +| ---------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Identity backup | Medium | The wallet has a phrase; the identity does not. Losing the phone still loses your identity, chats and contacts. | +| Mint HTTP over Tor on iOS | 2-3 days native Swift | Needs a raw-socket module. Blocking is the safe interim. | +| NIP-60 relay sync | ~300 lines + conflict design | Multi-device. Depends on identity backup to be useful at all. | +| QR display / scan for tokens | Small | Currently share/copy only. | + +## FAQ for developers + +**Why cashu-ts rather than rolling our own?** +Blind signatures, DLEQ, and NUT-13 derivation are exactly the kind of +cryptography you do not hand-roll. cashu-ts is the reference TypeScript +implementation and owns proof selection (RGLI), fee arithmetic, blinding, and the +mint HTTP surface. `wallet-service.ts` owns everything above it. + +**Why is the wallet store a separate MMKV file?** +Because it is the only store holding bearer instruments. It gets AES-256 and a +keychain-held key; everything else relies on the OS sandbox. + +**Why `(mint, unit)` composite keys?** +A mint can issue sat, usd and eur from the same host. Those are different +currencies and summing them would be a correctness bug, not a display bug. + +**Why does `creditProofs` not take a `derived` parameter?** +Because it is never a judgement call. A proof is restorable exactly when the +wallet that created it had the seed loaded, so `isBackupActive()` at that moment +is the truth. + +**Why does `wallet.send()`'s `keep` array include untouched originals?** +That is cashu-ts behaviour: `keep = [change, ...unselected]`. The difference +between what we offered and what came back is exactly the set the mint consumed. +This is load-bearing and commented at the call site. + +**Why not `withKeyset()` in restore?** +It carries the seed but not the unit, so it silently builds a `sat` wallet that +then fails to bind a `usd` keyset. `batchRestore` takes a keyset id directly. + +**Why is backup enabled when the words are shown rather than after the check?** +Otherwise a user who bails mid-flow has a phrase in the keychain with backup +marked off, and coins minted afterwards fall outside a phrase that exists. The +Unconfirmed state covers that window honestly instead. + +**Why does `assertMintNetworkAllowed` read the mesh store instead of +`isTorRoutingActive()`?** +`tor-routing` imports the BLE native module at module scope, and +`wallet-service` is reachable from the panic wipe, which must stay loadable +without a native host. The store mirrors the same flag. diff --git a/package-lock.json b/package-lock.json index e6156ae..075e1ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@noble/hashes": "^2.2.0", "expo": "~57.0.6", "expo-audio": "~57.0.2", - "expo-blur": "~57.0.2", "expo-build-properties": "~57.0.5", "expo-camera": "~57.0.3", "expo-clipboard": "~57.0.1", @@ -7593,17 +7592,6 @@ "react-native": "*" } }, - "node_modules/expo-blur": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.2.tgz", - "integrity": "sha512-Aoud8H8lmlNkbRufyvRLefmGFELdBf1n5Te/Xm+Zx8ORINH+aXL+gKb5mbftFSha860+I7pMArz77TBYz8HDVg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-build-properties": { "version": "57.0.6", "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.6.tgz", diff --git a/package.json b/package.json index a7e64ab..f0e2ec1 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ], "author": "Areeb Ahmed ", "license": "MIT", - "homepage": "https://airhop.free", + "homepage": "https://airhop.1mindlabs.org", "repository": { "type": "git", "url": "https://github.com/areebahmeddd/airhop" @@ -42,9 +42,9 @@ "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", + "@scure/bip39": "^2.0.1", "expo": "~57.0.6", "expo-audio": "~57.0.2", - "expo-blur": "~57.0.2", "expo-build-properties": "~57.0.5", "expo-camera": "~57.0.3", "expo-clipboard": "~57.0.1", diff --git a/src/__mocks__/react-native-mmkv.js b/src/__mocks__/react-native-mmkv.js index 09d9a20..86d3cbc 100644 --- a/src/__mocks__/react-native-mmkv.js +++ b/src/__mocks__/react-native-mmkv.js @@ -62,9 +62,25 @@ function createMMKV({ id = "default" } = {}) { return instanceCache.get(id); } +// Mirrors the real module's instance deletion (used by the panic wipe for the +// encrypted wallet store, which cannot be reopened without its key). +function deleteMMKV(id) { + return instanceCache.delete(id); +} + +function existsMMKV(id) { + return instanceCache.has(id); +} + // Allow tests to reset all instance state between runs. function __resetAll() { instanceCache.clear(); } -module.exports = { createMMKV, __resetAll, __mockClearAll: clearAllSpy }; +module.exports = { + createMMKV, + deleteMMKV, + existsMMKV, + __resetAll, + __mockClearAll: clearAllSpy, +}; diff --git a/src/core/payments/__tests__/cashu.test.ts b/src/core/payments/__tests__/cashu.test.ts index 394765bf29012999fd9345eccc3425104e7850c2..8029e4ff3e5142491e88b85df208755b983899f6 100644 GIT binary patch literal 9814 zcmbtaYj5Mm742vLiOvcOgi7_WypFL@;5hqA6Ev^JPK&~E;So8KxTZ*kGql$={NH=- zof&FKNoyr*Bv^@@*L|IP?j4Ok|NKCFF1~)Rv>B@WHm!<$rE(+kB2joVJ{XV18>yGI zh>J=IgK>H)5@l3u(jp(=V_YO@z8HwPQbTzsF-ohSLZ+JR=UCH}5MQ&u8Hyc}9+qJRD zs}*d<3z~u-exKf`!GTu@Bx7Yjl7qO&wGlsj_v3{)6}_b~rJjz*tY-B=uF6b};$nr7 zcHlqW{pb804?R4T$q|9?=BFQI#v*z_`rmD-U{xsD0 z<&_{MRtkQT3paSQl4ay(4n*1)r(cPPhCvg_RM;bsB7~b^KWi0ZovFCCABN8#&f&EA zB1-#@*LKL8spuUZ^~B#qIx>ZOJvr%*N|~G+S()f)fDyg^s8XepCVFwyrjU*pkQTlA1k zxv8#!SgBQUHWiv>86ZefLzY4;_n=GrI8c+Z}Q>}=F~P28{G}@C*FG067==Y{~a78O2<_? zQ&F!I5xs$k*lWmXQWN!bSpdvP|Lpg#2KL|AjBmgG@*U%lZcP)gTM7J(I%`M0Qr;6d zPu*H_7AI=n>-XVNuT^BKT0vlP&Rq-e8D9U%c~dLFuGx>++X-!hN&|i~I3JK!lo>Ds z+2ey@!PTlX_ZD3C6n&|(tPpoal_ib9y!;aqZy8Jn<-@M6*agJap~#G0qTJ;Ml8L3^ zq?IG*P#CtRHCE^@9`!ba`BvVl?^V7q%jl2=3Y1CHDgSW%@-{*N&=MpbXpn+OtIoz2 zFzwh=o@itI5*YLH3OExNbL51c)14hYr(}!x-Z<_xC&K%p7K`Gy{P_c~`}kX~^7qC4 zHP#4Sj#nXv3`Cl^eVkYFkjulXQ6=xZ>yq|%j7sx)F|zpVMo+Ac)(WPR(}FydfWZyy z$l2W=O3=#qu`MZkqm0eLX9|pQpQ;SjC~}JDDq-K2*7)MCN{vxD8N~;7$1VWz3m^f# zy{jZh4RH>b(G+3^))dP;$2YA?393Ud&n$w#gK|Agt-y1b;z-sx*3(Ec$O1Ek0dPsu zBMeS-1}gyu&0QfJZ19OeaR?xGddTsWGs9C(!C)};-au{;0MDm-L7;KCd0l2{424h> zcnK*-+5jM1td!Prv3D3uPP~-fB|3yCy7q!GT*(EYmw$B4fV<(}!I^EyYIUnB577g)k?ZVlfY87qMGq@}}j|~(b@Y~IA zy?VO~IAu1QM%Q-5@wn}f!~iy7bYt2doFp2z7s8sUwNK#mm3j}-LSB&%tvp;jv~<)! z_WD>h8TvH+W1VGD8%u(rEchED(LLEX&=-e&$AI>j{OTG0_E>=AV%cCsB8uRSM4dm8 zIAWoYI|fx+OG}n;NXq)Qnquk=J`N_$F2&rJb3;fAT5EWSr<4fv2)2p>obh}hL-n+T z4O$>hQ~M(XIn&E6%B2BkA5YD82vL5YX1RLU*yn6S!u?uhZWvXCo2Rj(zAbdK+D5gw zbyC0xoTOV0z*Wc_s$Sh0EFv~8YefO1Yt&X=n%9;?wZee<186cV6%uzPC<)x#5{D9S zl`@LG{r=rQso{|$adqqlkNc^vnvMoK9k+^tN)51P8ZQM<5A|_uzz>KT0K;-ZV(gR7 z?*&Lrh8nR)Af#-vcL=h>2axgmXQQRmZ!`6~lWWba1IgB>rx1r7ZAEC7Xwxi~t?mqJ z&>E&$2#o5=_wm!b06nE{3{ohye0sGezJE3fU5KBN1%tyi1RVgvtZX5x*(N2}viXw$ z6za_-4Cs3X^`0CPn(RiO*38Xu!SSh>ro@5f3YuCXUV;I}2EJw~AR1oM7#ZKFYN+cH zdtkW1SLw+f*m?FY!S zTPc_Vt<03Fy4u(jE8^{+IkQK-lVfsoUrad<+cM3cqB~OzLEmZPmSTP4LGw+2dfI6x zo}jDO(l6VHiYatb$^n-TWD`m|MEF``16*ucgwbRmCWpw!wjqJKPe6>r(L@Zt5|gk% z2u=`94!6zn5Vw0;do!RlZ8ou+9ktN`koiAV6|P)E={uZPN{JhFj{@*%vA!6HMyO8h z84A*tV<&yY_TTt!Y-?|3ko8In6ox~4HfpLPUVJZ{D$iH}3mExP=C$Vg@ZdS8=#gF8 zhUL|itRfz5NO%VgP7yU7xJxJ%qyPcL{d^smGBjT(!uH7xhWiyd>hSJ;?ym|y(Z@qx zwY4SP(iCah^;L44Oogsz@C6IEWpM`!6!Rff&xy(lgaBV)bVIALw#q4cUKWM_h<4*?S1u8l6{WfW_%bkrl0$eC<_V)ZJQyA%4)2@{&6|3}?+QmLH z*VcepNgGW;hx?EU^$-XJ!!T9XnXDGneV|*4yQ0n#Jw+7Fhidz(gl}j0KCNoJf?O5Csnsvdu z)x$fEW?jqU))xA{eUVEfLimw&l)Y!I0pum^yE#ALI`d2}2-DCgV;6T}16GYE+9;WT z#GHK&rG%2_JIh?oGBLjJ5st zL`Rv9sQ}S3%B5}_w#J`S66Hh(+t!O*dTgastA*_)GYtE0G|s3R1m21=jc;&SP4-)h zys80_1cY^I88MEZDT#+P4wrSqkV0i~ja%xV6>AUm4DGgd z4){CT09fJz7wJD$7Q?ouk7pyO+2swtnD>gXOWuiL{M3hOx4Hz>T<5y{yIVKc*h4%}o8 zU1D8yav?RmRb&Zpv%7ZqS< zxX$E3bXk1~*u)J6di(Aw8CMrhLZF^k!c-6s5Rdly^yxLw)!Dj=y_a*#Tv;Es5oK-l zaVi79eYu^nc(i=1X_D+-dM@i-a1mV41xM12Rk!ilQX2}zT?n(0A0!ry#{adujvFt6zQ~~9=q_QbdkEAu zqvW_qZ+BhJ1~!kLIz-qM`PKKRY_~el4q`f~k)@l=t0 sMv2?Sp`Eq+B8UJC7r0Y2^WD<>TnTlOd~!G>q!&;2jbrQoB#j- literal 6635 zcmcIoVQ(8Z68-L9F%kM<1<10TroKQvHy>0X?p;wNkwgw>-VEQo;lTl&X&*c-T}7wLuWAbJQd`P&&}lHvlCl{-r7kn} zV!C&5Kp(vJLB4ZU zp@OV^Ul)a{*8FXu)~C(}Wo&2D>l{c+DE=o`y)ImBVWAgQ>lcH6Dmluub%kL#o%N(R z1MXT^0C`Ek{FoNC$um+^3PN6oh^rFg0KZLCT<~6pr1@!1wnmWE`N#2`dMKZNB*vrC zp{`I9jh#Z3Nwaf8W=tnPQo_rKOg5*{rC#Ic!x2YL&8GNouTz+s)2H~4{qMfK4;3mX zG2`1$amguuJ>C4_c04}byWL|fe%5)(K?m2x3s6j*KRDozQ#RE^DyaK8oqvp>)YkBFui6EL zE*!q-w%kfcnf7UA7CITlj87;LNcqgKzYR)g)L#YMLo$j<0xngpC0sH08oAa>b2He0 zqP{7COER#Vj7qgGG{+gYt3{gW3#NOiXnDm{8&*{T&Zy$ZK95{c{ zu}hWv7<8HAmLb760KEq!JG?96mCkcV;H*6B;uM4T1^V?wU6f%>UJCiqXYM?x!}V+G zhdeVlq@Vlfi>fNMmJ(nb=-Pypt3kQUwSKU>gJpP1GTOl9XLY6jsqIBrCA0gm8G3=M ziAGcCEY{Qm6}jn1EQx2&o*64QJc9R-HtDtC?6MOV67Q|>?w#+LPFfux|- zAPbnz>4ZjVG_7>0RY;!CX8)PZ+ORv+;#J*nnGazbxHj+rSLcj89jqKR1^e z1Q+0|1WgO&_0fxs^hKv7(I@mNLiOF3(eF32S?WceVZK^_B)UO|4fICNa9QW52EHfEl7}kY%k&~`kFt&9S>a(efYZ3 zldX21(<}-~>YHi5Vh27{`8~RnV&>-3LE3H8>9j*2ekX20V)s>Nyw(CsGtLZ>&9|q$ z`*A#w$&teytZV@^Jbs;kE&Iv%(u@gn`e8Pk^urXH$@p~Dyy;WIHd}={f(2sDy{)E= zP4gN#$Bii<`M8uJk3AUbf8*^$3?mn*TQ0R8J=uHO$1M^Qy)e3x2D`)|I8Ko~ulvYg zLN(9}wVsf7ki04l7G%bkRl#IcLG;TPm{u@G1({EUP6HNLlj^XZBZq6^@&KJfKw)p; ztX>PJ3xx?Ez?3NsuoHy|LPqJp65P&>)pQLF3M#$86q=$LTtLmJ^m2NqzP3)mgH&wD zh{!-7q=m|rO*Pt-Wu2F@$C0U2g<}VHx&qpIXd=@akHqJP6MFt}JfXu`Q&W$Z9zf99 z{nB|OPafe<`SFsSc+iK^q?gF8jkzX%kje2dATI!d6f%bgh>))IruHAO@MDmf(8~!O zyowJ%p3lXlq{9el7_x9c&yn#97OY*zCbjwX|g_X5T5u(XUse95=Dw9x(ecj z-!dCE3$$3%4=lTtm>*v+_=RzIe%|q8vEnqSD`j%E059=CdpJWCey$;WHJ%F# zmX{c^`B7L5pTwsXbT1^eK>^w32?E}L>(N>87-=x8YlXxv>2qx~9E^p=W5v^W?J)lh#MXaDx|i9GUTh;K87>UJyi0+19XS$v%oPWW4qaPy%a9`5JLa} diff --git a/src/core/payments/__tests__/nutzap.test.ts b/src/core/payments/__tests__/nutzap.test.ts new file mode 100644 index 0000000..15d837c --- /dev/null +++ b/src/core/payments/__tests__/nutzap.test.ts @@ -0,0 +1,207 @@ +/** + * @jest-environment node + */ +// NIP-61 nutzap tests: event shapes and hostile-input parsing. +// +// Nutzap events are public, unauthenticated apart from their signature, and +// carry money. The parsing tests are therefore mostly about what must be +// *rejected*: a nutzap that parses into something plausible but unspendable is +// worse than one that does not parse at all, because the UI would show it as +// incoming value. + +import { generateSecretKey, getPublicKey, type Event } from "nostr-tools"; +import { + KIND_NUTZAP, + KIND_NUTZAP_INFO, + parseNutzap, + parseNutzapInfo, +} from "../nutzap"; + +const MINT = "https://mint.example.com"; +// 33-byte compressed secp256k1 key: what NIP-61 locks proofs to. +const P2PK = "02" + "ab".repeat(32); +// 32-byte x-only Nostr key: valid as an author, invalid as a P2PK lock. +const NOSTR_PUB = "ab".repeat(32); + +function event(overrides: Partial): Event { + return { + id: "f".repeat(64), + pubkey: NOSTR_PUB, + created_at: 1_700_000_000, + kind: KIND_NUTZAP, + tags: [], + content: "", + sig: "0".repeat(128), + ...overrides, + } as Event; +} + +function proofTag(amount: number, secret = "s1"): string[] { + return [ + "proof", + JSON.stringify({ + id: "00ad268c4d1f5826", + amount, + secret, + C: "02" + "cd".repeat(32), + }), + ]; +} + +// ---- kind 10019 ------------------------------------------------------------- + +describe("parseNutzapInfo", () => { + it("reads mints, relays and the P2PK key", () => { + const info = parseNutzapInfo( + event({ + kind: KIND_NUTZAP_INFO, + tags: [ + ["relay", "wss://relay.example"], + ["mint", MINT, "sat"], + ["pubkey", P2PK], + ], + }), + ); + + expect(info).not.toBeNull(); + expect(info?.mintUrls).toEqual([MINT]); + expect(info?.relays).toEqual(["wss://relay.example"]); + expect(info?.p2pkPubkey).toBe(P2PK); + expect(info?.pubkey).toBe(NOSTR_PUB); + }); + + it("rejects an x-only Nostr key in the pubkey tag", () => { + // Locking proofs to a 32-byte key produces ecash nobody can ever unlock, + // including the sender. Falling back to event.pubkey would do exactly that. + const info = parseNutzapInfo( + event({ + kind: KIND_NUTZAP_INFO, + tags: [ + ["mint", MINT], + ["pubkey", NOSTR_PUB], + ], + }), + ); + expect(info).toBeNull(); + }); + + it("rejects an event with no mint, since we cannot know what they accept", () => { + const info = parseNutzapInfo( + event({ kind: KIND_NUTZAP_INFO, tags: [["pubkey", P2PK]] }), + ); + expect(info).toBeNull(); + }); + + it("ignores non-http mint tags and non-ws relay tags", () => { + const info = parseNutzapInfo( + event({ + kind: KIND_NUTZAP_INFO, + tags: [ + ["mint", "javascript:alert(1)"], + ["mint", MINT], + ["relay", "http://not-a-relay"], + ["pubkey", P2PK], + ], + }), + ); + expect(info?.mintUrls).toEqual([MINT]); + expect(info?.relays).toEqual([]); + }); + + it("returns null for the wrong kind", () => { + expect(parseNutzapInfo(event({ kind: 1 }))).toBeNull(); + }); +}); + +// ---- kind 9321 -------------------------------------------------------------- + +describe("parseNutzap", () => { + it("reads proofs from proof tags and the mint from the u tag", () => { + const zap = parseNutzap( + event({ + tags: [proofTag(2), proofTag(8, "s2"), ["u", MINT], ["p", NOSTR_PUB]], + content: "thanks!", + }), + ); + + expect(zap).not.toBeNull(); + expect(zap?.proofs).toHaveLength(2); + expect(zap?.amount).toBe(10); + expect(zap?.mintUrl).toBe(MINT); + expect(zap?.unit).toBe("sat"); + expect(zap?.comment).toBe("thanks!"); + }); + + it("returns null without a mint, since the proofs could not be redeemed", () => { + expect(parseNutzap(event({ tags: [proofTag(1)] }))).toBeNull(); + }); + + it("returns null with no proofs", () => { + expect(parseNutzap(event({ tags: [["u", MINT]] }))).toBeNull(); + }); + + it("drops malformed proof tags rather than half-crediting them", () => { + const zap = parseNutzap( + event({ + tags: [ + ["proof", "not json"], + ["proof", JSON.stringify({ id: "x", amount: 5 })], // missing secret/C + ["proof", JSON.stringify({ ...JSON.parse(proofTag(4)[1]), C: "zz" })], + proofTag(16), + ["u", MINT], + ], + }), + ); + expect(zap?.proofs).toHaveLength(1); + expect(zap?.amount).toBe(16); + }); + + it("rejects a non-positive or non-integer amount", () => { + expect(parseNutzap(event({ tags: [proofTag(0), ["u", MINT]] }))).toBeNull(); + expect( + parseNutzap(event({ tags: [proofTag(-5), ["u", MINT]] })), + ).toBeNull(); + }); + + it("keeps the first u tag only, so a second cannot redirect redemption", () => { + const zap = parseNutzap( + event({ + tags: [proofTag(1), ["u", MINT], ["u", "https://evil.example"]], + }), + ); + expect(zap?.mintUrl).toBe(MINT); + }); + + it("carries the zapped event id when one is tagged", () => { + const target = "a".repeat(64); + const zap = parseNutzap( + event({ tags: [proofTag(1), ["u", MINT], ["e", target]] }), + ); + expect(zap?.targetEventId).toBe(target); + }); + + it("returns null for the wrong kind", () => { + expect( + parseNutzap(event({ kind: 1, tags: [proofTag(1), ["u", MINT]] })), + ).toBeNull(); + }); + + it("caps the comment so a hostile sender cannot flood the UI", () => { + const zap = parseNutzap( + event({ tags: [proofTag(1), ["u", MINT]], content: "x".repeat(1000) }), + ); + expect(zap?.comment?.length).toBe(280); + }); +}); + +// ---- key shape -------------------------------------------------------------- + +describe("key shapes", () => { + it("a Nostr identity key is not a valid P2PK lock key", () => { + // Documents the distinction the parser enforces: nostr-tools' getPublicKey + // returns the 32-byte x-only form, which is never a valid `pubkey` tag. + const nostrPub = getPublicKey(generateSecretKey()); + expect(nostrPub).toHaveLength(64); + expect(/^0[23][0-9a-f]{64}$/.test(nostrPub)).toBe(false); + }); +}); diff --git a/src/core/payments/__tests__/wallet-seed.test.ts b/src/core/payments/__tests__/wallet-seed.test.ts new file mode 100644 index 0000000..852feb3 --- /dev/null +++ b/src/core/payments/__tests__/wallet-seed.test.ts @@ -0,0 +1,197 @@ +/** + * @jest-environment node + */ +// Recovery phrase tests. +// +// This module decides whether a user's money is recoverable, and a phrase that +// is accepted but wrong is worse than one that is rejected: the user walks away +// believing they have a backup. So most of these cover *rejection*, and the one +// property that matters above all is that the same phrase always derives the +// same seed. + +import EncryptedStorage from "react-native-encrypted-storage"; +import { + RECOVERY_WORD_COUNT, + generateRecoveryPhrase, + isValidRecoveryPhrase, + loadStoredPhrase, + normalizeRecoveryPhrase, + pickVerificationPositions, + recoveryPhraseToSeed, + storePhrase, + unknownWordsIn, + verifyPositions, +} from "../wallet-seed"; + +// A known-good BIP-39 test vector, so the tests do not depend on the generator. +const KNOWN = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + +beforeEach(async () => { + await EncryptedStorage.clear(); +}); + +// ---- Generation ------------------------------------------------------------- + +describe("generateRecoveryPhrase", () => { + it("produces twelve valid words", () => { + const phrase = generateRecoveryPhrase(); + expect(phrase.split(" ")).toHaveLength(RECOVERY_WORD_COUNT); + expect(isValidRecoveryPhrase(phrase)).toBe(true); + }); + + it("does not repeat itself", () => { + const phrases = new Set( + Array.from({ length: 5 }, () => generateRecoveryPhrase()), + ); + expect(phrases.size).toBe(5); + }); +}); + +// ---- Normalisation ---------------------------------------------------------- + +describe("normalizeRecoveryPhrase", () => { + it("survives the shapes people actually paste", () => { + // Line breaks from a photo transcription, numbering from a notes app, + // stray capitals, and doubled spaces. + const messy = + " Legal WINNER\nthank\tyear wave sausage\n worth useful legal winner thank yellow "; + expect(normalizeRecoveryPhrase(messy)).toBe(KNOWN); + }); + + it("strips digits and punctuation left over from a numbered list", () => { + expect(normalizeRecoveryPhrase("1. legal 2. winner")).toBe("legal winner"); + }); +}); + +// ---- Validation ------------------------------------------------------------- + +describe("isValidRecoveryPhrase", () => { + it("accepts a real phrase", () => { + expect(isValidRecoveryPhrase(KNOWN)).toBe(true); + }); + + it("rejects the wrong number of words", () => { + expect(isValidRecoveryPhrase("legal winner thank")).toBe(false); + expect(isValidRecoveryPhrase(`${KNOWN} extra`)).toBe(false); + }); + + it("rejects a single mistyped word via the checksum", () => { + // Every word is in the wordlist, but the checksum no longer matches. This + // is the case that would otherwise restore an empty wallet and leave the + // user thinking their money vanished. + const swapped = KNOWN.replace("yellow", "zoo"); + expect(swapped.split(" ")).toHaveLength(RECOVERY_WORD_COUNT); + expect(isValidRecoveryPhrase(swapped)).toBe(false); + }); + + it("rejects words that are not in the list at all", () => { + expect(isValidRecoveryPhrase(KNOWN.replace("legal", "notaword"))).toBe( + false, + ); + }); + + it("rejects empty input", () => { + expect(isValidRecoveryPhrase("")).toBe(false); + expect(isValidRecoveryPhrase(" ")).toBe(false); + }); +}); + +describe("unknownWordsIn", () => { + it("names the words that are not BIP-39, so the UI can point at the typo", () => { + expect(unknownWordsIn("legal notaword winner alsonot")).toEqual([ + "notaword", + "alsonot", + ]); + }); + + it("returns nothing for a valid phrase", () => { + expect(unknownWordsIn(KNOWN)).toEqual([]); + }); +}); + +// ---- Seed derivation -------------------------------------------------------- + +describe("recoveryPhraseToSeed", () => { + it("is deterministic, which is the whole point", () => { + const a = recoveryPhraseToSeed(KNOWN); + const b = recoveryPhraseToSeed(KNOWN); + expect(Array.from(a)).toEqual(Array.from(b)); + expect(a).toHaveLength(64); + }); + + it("ignores formatting differences in the same phrase", () => { + const a = recoveryPhraseToSeed(KNOWN); + const b = recoveryPhraseToSeed(` ${KNOWN.toUpperCase()} `); + expect(Array.from(a)).toEqual(Array.from(b)); + }); + + it("gives different phrases different seeds", () => { + const a = recoveryPhraseToSeed(KNOWN); + const b = recoveryPhraseToSeed(generateRecoveryPhrase()); + expect(Array.from(a)).not.toEqual(Array.from(b)); + }); + + it("refuses to derive from an invalid phrase", () => { + // Deriving anyway would produce secrets no mint has ever signed, and a + // restore that silently finds nothing. + expect(() => recoveryPhraseToSeed("not a real phrase at all")).toThrow(); + }); +}); + +// ---- Storage ---------------------------------------------------------------- + +describe("phrase storage", () => { + it("round-trips through the keychain", async () => { + await storePhrase(KNOWN); + expect(await loadStoredPhrase()).toBe(KNOWN); + }); + + it("returns null when nothing is stored", async () => { + expect(await loadStoredPhrase()).toBeNull(); + }); + + it("refuses to store an invalid phrase", async () => { + await expect(storePhrase("nonsense words here")).rejects.toThrow(); + expect(await loadStoredPhrase()).toBeNull(); + }); + + it("treats a corrupted stored value as absent rather than deriving from it", async () => { + await EncryptedStorage.setItem("airhop.wallet.recovery.v1", "corrupted"); + expect(await loadStoredPhrase()).toBeNull(); + }); +}); + +// ---- Verification step ------------------------------------------------------ + +describe("verification", () => { + it("asks for distinct, in-range, 1-based positions", () => { + for (let i = 0; i < 20; i++) { + const positions = pickVerificationPositions(2); + expect(positions).toHaveLength(2); + expect(new Set(positions).size).toBe(2); + for (const p of positions) { + expect(p).toBeGreaterThanOrEqual(1); + expect(p).toBeLessThanOrEqual(RECOVERY_WORD_COUNT); + } + } + }); + + it("accepts the right words", () => { + // KNOWN: 1=legal 2=winner ... 12=yellow + expect(verifyPositions(KNOWN, { 1: "legal", 12: "yellow" })).toBe(true); + }); + + it("forgives capitals and stray spaces", () => { + expect(verifyPositions(KNOWN, { 1: " LEGAL " })).toBe(true); + }); + + it("rejects a wrong word", () => { + expect(verifyPositions(KNOWN, { 1: "legal", 12: "winner" })).toBe(false); + }); + + it("rejects an out-of-range position instead of passing it", () => { + expect(verifyPositions(KNOWN, { 99: "legal" })).toBe(false); + expect(verifyPositions(KNOWN, { 0: "legal" })).toBe(false); + }); +}); diff --git a/src/core/payments/cashu.ts b/src/core/payments/cashu.ts index 18ffa96..bdcb202 100644 --- a/src/core/payments/cashu.ts +++ b/src/core/payments/cashu.ts @@ -1,23 +1,38 @@ -// Cashu ecash: token detection, embedding, and offline proof validation. +// Cashu ecash: token detection, decoding, offline verification, and selection. // // Cashu tokens are bearer instruments: whoever holds the string owns the value. -// Airhop embeds tokens in message text and detects them on receive. No network -// call is needed to transfer value. Redemption requires internet access to the -// mint (not handled here; that is the user's wallet responsibility). +// Airhop embeds tokens in message text and detects them on receive, so no +// network call is needed to transfer value. Everything in this module is pure +// and offline; anything that talks to a mint lives in +// `src/services/wallet-service.ts`. // -// Supported token formats (per NUT-00): -// cashuA - V3 token (JSON, human-readable) -// cashuB - V4 token (CBOR, compact) -// cashu: - URI form -// cashu:// - URI form (alternative) +// Supported token formats (NUT-00): +// cashuA V3 token (JSON, legacy) +// cashuB V4 token (CBOR, compact) - what we emit +// cashu: URI form +// cashu:// URI form (alternative) // -// DLEQ proof verification (hasValidDleq) runs offline and is performed on every -// received proof to catch malformed/forged tokens before displaying them. +// Detection is deliberately identical to bitchat's +// `MessageFormattingEngine.Patterns.cashu`, down to the character class and the +// 40-character minimum body. Both apps read the same messages off the same +// mesh, so a string that renders as a payment chip on one must render as a +// payment chip on the other; being more permissive here would show a card where +// bitchat shows raw text. +// +// What "verification" means offline +// --------------------------------- +// `verifyTokenOffline` runs NUT-12 DLEQ checks against the mint's cached public +// keys. A passing DLEQ proves the mint really signed this proof, so it catches +// forged and tampered tokens. It cannot prove the proof is *unspent* - only the +// mint knows that, and only over the network. Offline-received proofs are +// therefore stored as unverified and redeemed at the first opportunity. import { getDecodedToken, getEncodedToken, - getTokenMetadata, + KeyChain, + verifyDleqIfPresent, + type KeyChainCache, type Proof, type ProofLike, type Token, @@ -26,282 +41,503 @@ import type { StoredProof } from "../../store/wallet-store"; // ---- Constants -------------------------------------------------------------- -// Maximum token string length before we stop processing (abuse prevention). +// Upper bound on an accepted token string. Real tokens are a few KB. const MAX_TOKEN_LENGTH = 60_000; -// Minimum token prefix we check for. -const TOKEN_PREFIXES = ["cashuA", "cashuB", "cashu://", "cashu:"]; +// Stop scanning message text beyond this. Guards CPU on hostile input. +const MAX_SCAN_LENGTH = MAX_TOKEN_LENGTH * 2; + +// Most chips we will render for one message, matching bitchat's cap. +const MAX_TOKENS_PER_MESSAGE = 3; + +// Sanity cap on any single amount or token total: more sats than will ever +// exist. Anything above this is a malformed or hostile token. +const MAX_AMOUNT = 2_100_000_000_000_000; + +// Cheap pre-check before running the scanner. +const TOKEN_HINTS = ["cashuA", "cashuB", "cashu:"]; + +// Bare-token pattern, byte-for-byte the same as bitchat's. The `cashu:` and +// `cashu://` URI forms are handled implicitly: the match starts at the embedded +// `cashuA`/`cashuB`, which is exactly the bearer string we want. +const TOKEN_PATTERN = /\bcashu[AB][A-Za-z0-9._-]{40,}\b/g; // ---- Types ------------------------------------------------------------------ export interface TokenInfo { - version: "A" | "B" | "unknown"; - amount: number; // total proof amounts in the token's declared unit - unit: string; // "sat" if not declared - mintUrl: string; // mint host (first mint) + version: "A" | "B"; + // Total of the proof amounts, in `unit`. + amount: number; + // "sat" when the token does not declare one (NUT-00 default). + unit: string; + // Full mint URL as declared by the token. + mintUrl: string; + // Mint hostname only, for compact display. + mintHost: string; memo?: string; - // Raw decoded token (to pass to Wallet for redemption) + proofCount: number; + // Whether every proof carries a NUT-12 DLEQ witness. Without one there is + // nothing to verify offline, so the token can only be trusted after a swap. + hasDleq: boolean; + // Decoded token, to hand to a Wallet for redemption. token: Token; } export interface EmbeddedToken { info: TokenInfo; - // The raw token string as it appeared in the message body. + // The bare `cashuA…`/`cashuB…` string as it appeared in the message body. raw: string; - // Byte offset in the message text where the token starts. + // Character offset in the message text where the token starts. offset: number; } +export type DleqResult = + // Every proof carried a DLEQ witness and every one verified against the + // mint's keys. The mint definitely signed this. Still says nothing about + // whether it has already been spent. + | { status: "valid"; checked: number } + // At least one witness failed to verify. The token is forged or corrupted; + // refuse it. + | { status: "invalid"; reason: string } + // No witnesses present, or we hold no keys for this mint's keyset, so there + // was nothing to check. Not a failure, just no offline assurance. + | { status: "unchecked"; reason: string }; + // ---- Detection -------------------------------------------------------------- -// Find all Cashu tokens embedded in message text. Returns one entry per token. -// Safe to call on attacker-controlled content; all paths are bounded. +// Whether a string could contain a Cashu token. Cheap enough to call per +// message render before the full scan. +export function mayContainToken(text: string): boolean { + return TOKEN_HINTS.some((hint) => text.includes(hint)); +} + +// Find Cashu tokens embedded in message text, at most MAX_TOKENS_PER_MESSAGE. +// Safe on attacker-controlled content: bounded scan window, bounded matches, +// and every decode failure drops the candidate rather than throwing. +// +// Tokens are deduplicated by their bare string, so `cashu:cashuA…` and the same +// `cashuA…` written twice in one message yield exactly one card. The previous +// implementation scanned once per URI prefix and deduplicated on the raw slice +// including the prefix, which rendered the same bearer token as two cards. export function findTokensInText(text: string): EmbeddedToken[] { - if (text.length > MAX_TOKEN_LENGTH * 2) { - // Truncate before scanning to prevent ReDoS-style CPU abuse. - text = text.slice(0, MAX_TOKEN_LENGTH * 2); - } + if (!mayContainToken(text)) return []; + const scanned = + text.length > MAX_SCAN_LENGTH ? text.slice(0, MAX_SCAN_LENGTH) : text; const results: EmbeddedToken[] = []; - // Find each prefix occurrence and try to extract a token from that position. - for (const prefix of TOKEN_PREFIXES) { - let searchStart = 0; - while (searchStart < text.length) { - const idx = text.indexOf(prefix, searchStart); - if (idx < 0) break; - searchStart = idx + 1; - - const candidate = extractTokenCandidate(text, idx); - if (!candidate) continue; - - const info = decodeToken(candidate); - if (!info) continue; + const seen = new Set(); - results.push({ info, raw: candidate, offset: idx }); - } + // `lastIndex` is mutated by exec on a /g regex, so use a fresh instance + // rather than the shared literal (which is not re-entrant). + const pattern = new RegExp(TOKEN_PATTERN.source, "g"); + let match: RegExpExecArray | null; + while ((match = pattern.exec(scanned)) !== null) { + const raw = match[0]; + if (raw.length > MAX_TOKEN_LENGTH) continue; + if (seen.has(raw)) continue; + seen.add(raw); + + const info = decodeToken(raw); + if (!info) continue; + + results.push({ info, raw, offset: match.index }); + if (results.length >= MAX_TOKENS_PER_MESSAGE) break; } - // De-duplicate (same raw token found via multiple prefixes). - const seen = new Set(); - return results.filter((r) => { - if (seen.has(r.raw)) return false; - seen.add(r.raw); - return true; - }); + return results; } -// Extract a token-shaped string starting at `offset` within `text`. -// Returns null if the candidate is too short, too long, or has illegal chars. -function extractTokenCandidate(text: string, offset: number): string | null { - // Token body ends at the first whitespace or end of string. - const rest = text.slice(offset); - const endIdx = rest.search(/\s|$/); - const candidate = endIdx >= 0 ? rest.slice(0, endIdx) : rest; - - if (candidate.length < 12 || candidate.length > MAX_TOKEN_LENGTH) return null; - - // Strip URI prefix for the charset check. - let payload = candidate; - const lower = payload.toLowerCase(); - if (lower.startsWith("cashu://")) payload = payload.slice(8); - else if (lower.startsWith("cashu:")) payload = payload.slice(6); - - if (!payload.startsWith("cashuA") && !payload.startsWith("cashuB")) - return null; - - // Base64url charset plus '.' for legacy multi-part tokens. - if (!/^[a-zA-Z0-9\-_+/=.]+$/.test(payload.slice(6))) return null; +// Strip a `cashu:` / `cashu://` URI wrapper and percent-encoding to get the +// bare bearer string. Returns null when the input is not token-shaped. +export function bareToken(raw: string): string | null { + let token = raw.trim(); + const lower = token.toLowerCase(); + if (lower.startsWith("cashu://")) token = token.slice(8); + else if (lower.startsWith("cashu:")) token = token.slice(6); + + if (token.includes("%")) { + try { + token = decodeURIComponent(token); + } catch { + // Malformed percent-encoding: keep the original and let the shape check + // below reject it. + } + } - return candidate; + if (token.length < 12 || token.length > MAX_TOKEN_LENGTH) return null; + if (!token.startsWith("cashuA") && !token.startsWith("cashuB")) return null; + // Same charset as the detection pattern, plus the base64 (non-url) characters + // that older wallets emit, since a directly pasted token is not constrained by + // what survives a message body. + if (!/^[A-Za-z0-9._\-+/=]+$/.test(token.slice(6))) return null; + return token; } // ---- Decode ----------------------------------------------------------------- -// Decode a raw token string into a TokenInfo. Returns null on parse error. -// All failure modes silently return null (never throws to caller). +// Decode a token string into a display/redemption summary, or null if it does +// not cleanly parse into a known version carrying a positive amount. +// +// There is no permissive mode: unlike bitchat, which renders a generic chip for +// a V4 payload its minimal CBOR reader cannot walk, we use a full CBOR decoder, +// so failure to decode here means the token really is malformed. Showing a card +// for something we cannot price would be worse than showing the raw string. export function decodeToken(raw: string): TokenInfo | null { - try { - let tokenStr = raw.trim(); - const lower = tokenStr.toLowerCase(); - if (lower.startsWith("cashu://")) tokenStr = tokenStr.slice(8); - else if (lower.startsWith("cashu:")) tokenStr = tokenStr.slice(6); - - if (tokenStr.length > MAX_TOKEN_LENGTH) return null; + const tokenStr = bareToken(raw); + if (!tokenStr) return null; + try { + // The keyset-id list only matters for rehydrating the short ids that V4 + // tokens use for keysets we already know about. An empty list keeps the + // short id, which is still a valid lookup key everywhere we use it. const token = getDecodedToken(tokenStr, []); - // getTokenMetadata takes the raw token string, not the decoded Token. - const meta = getTokenMetadata(tokenStr); - - const version = tokenStr.startsWith("cashuA") - ? "A" - : tokenStr.startsWith("cashuB") - ? "B" - : "unknown"; - - // Sum proof amounts. Proof.amount is an Amount value object; use toNumber(). - const amount = token.proofs.reduce( - (sum: number, p: Proof) => sum + p.amount.toNumber(), - 0, - ); - const unit = token.unit ?? meta?.unit ?? "sat"; - const mintUrl = token.mint ?? meta?.mint ?? ""; - - return { version, amount, unit, mintUrl, memo: token.memo, token }; + if (!Array.isArray(token.proofs) || token.proofs.length === 0) return null; + + let amount = 0; + for (const proof of token.proofs) { + const value = proof.amount.toNumber(); + if (!Number.isFinite(value) || value <= 0 || value > MAX_AMOUNT) + return null; + amount += value; + if (amount > MAX_AMOUNT) return null; + } + if (amount <= 0) return null; + + const mintUrl = typeof token.mint === "string" ? token.mint : ""; + if (mintUrl.length === 0 || mintUrl.length > 512) return null; + + return { + version: tokenStr.startsWith("cashuA") ? "A" : "B", + amount, + unit: sanitizeUnit(token.unit), + mintUrl, + mintHost: mintHostOf(mintUrl), + memo: sanitizeMemo(token.memo), + proofCount: token.proofs.length, + hasDleq: token.proofs.every((p) => p.dleq !== undefined), + token, + }; } catch { return null; } } -// ---- Encode ----------------------------------------------------------------- +// Mint hostname for display. Attacker-controlled, so it is length-capped and +// lowercased; falls back to a truncated raw string for non-URL mints. +function mintHostOf(mintUrl: string): string { + try { + return new URL(mintUrl).hostname.toLowerCase().slice(0, 48); + } catch { + return mintUrl.slice(0, 48); + } +} -// Encode a Token object back to a cashuA/B string. -export function encodeToken(token: Token): string { - return getEncodedToken(token); +// Units are alphanumeric currency codes ("sat", "usd", "eur"). Reject anything +// else rather than rendering attacker-chosen text next to an amount. +function sanitizeUnit(unit: string | undefined): string { + if (typeof unit !== "string") return "sat"; + if (unit.length === 0 || unit.length > 12) return "sat"; + if (!/^[a-zA-Z0-9]+$/.test(unit)) return "sat"; + return unit.toLowerCase(); } -// Create a token-bearing message body by appending the token string. -// Example output: "here's 500 sats for coffee\ncashuA..." -export function embedTokenInMessage(text: string, token: Token): string { - const tokenStr = encodeToken(token); - return text ? `${text}\n${tokenStr}` : tokenStr; +// Memos are shown verbatim in the payment card, so strip control characters and +// newlines (which would let a sender fake extra UI lines) and cap the length. +function sanitizeMemo(memo: string | undefined): string | undefined { + if (typeof memo !== "string" || memo.length > 512) return undefined; + const cleaned = memo.replace(/[-]/g, " ").trim(); + return cleaned.length > 0 ? cleaned.slice(0, 80) : undefined; } -// ---- Offline proof validation ----------------------------------------------- +// ---- Offline DLEQ verification ---------------------------------------------- -// Validate that a proof carries a valid DLEQ witness for the given mint key. -// This runs offline and does not contact the mint. Returns false for any proof -// that fails validation (reject before displaying as payment). +// Verify every proof in a token against the mint's cached public keys (NUT-12). // -// Note: DLEQ verification requires the mint's keyset key for that amount -// denomination. If the keyset is not cached locally, skip and redeem directly. -export function validateProofDleq( - proof: Proof, - mintPubkeyHex: string, -): boolean { - // Import inline to avoid issues in test environments that mock cashu. +// `keysetCache` is the `keyChain.cache` blob persisted per mint by the wallet +// service. It contains public keys only, so it is safe to keep unencrypted and +// safe to use offline. Without it there is nothing to check against, which is +// reported as "unchecked", not as a pass: the old implementation returned true +// both when a witness was missing and when the check threw, which meant it +// could only ever say yes. +export function verifyTokenOffline( + token: Token, + keysetCache: KeyChainCache | undefined, + unit: string, +): DleqResult { + const withDleq = token.proofs.filter((p) => p.dleq !== undefined); + if (withDleq.length === 0) { + return { status: "unchecked", reason: "token carries no DLEQ witness" }; + } + if (!keysetCache) { + return { + status: "unchecked", + reason: "mint keys not cached on this device", + }; + } + + let keyChain: KeyChain; try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { hasValidDleq } = require("@cashu/cashu-ts") as { - hasValidDleq: (proof: Proof, A: unknown) => boolean; + keyChain = KeyChain.fromCache(token.mint, unit, keysetCache); + } catch { + return { status: "unchecked", reason: "cached mint keys are unreadable" }; + } + + let checked = 0; + for (const proof of token.proofs) { + let keyset; + try { + keyset = keyChain.getKeyset(proof.id); + } catch { + // We know this mint but not this keyset (it rotated, or the token uses a + // short id we cannot resolve). Nothing to check for this proof. + continue; + } + try { + // `verifyDleqIfPresent` returns true for a proof with no witness, which is + // the NUT-12 "MUST verify if present" rule. We only reach it for proofs + // that do carry one, or for ones where skipping is correct. + if (!verifyDleqIfPresent(proof, keyset)) { + return { + status: "invalid", + reason: `proof ${proof.secret.slice(0, 8)}… failed DLEQ verification`, + }; + } + if (proof.dleq !== undefined) checked += 1; + } catch (err) { + // A throw here means the proof's amount matches no key in the keyset, + // i.e. it claims a denomination the mint does not issue. That is a + // forgery, not an inconclusive check. + return { + status: "invalid", + reason: `proof ${proof.secret.slice(0, 8)}… has no matching mint key (${String(err)})`, + }; + } + } + + if (checked === 0) { + return { + status: "unchecked", + reason: "no keys cached for this token's keyset", }; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { hashToCurve } = require("@cashu/cashu-ts") as { - hashToCurve: (secret: Uint8Array) => unknown; + } + if (checked < withDleq.length) { + return { + status: "unchecked", + reason: `verified ${String(checked)} of ${String(withDleq.length)} witnesses`, }; - void hashToCurve; // used internally by hasValidDleq - // The mint key for this proof's amount denomination (provided by caller). - // If no DLEQ witness is present, the proof may still be valid at the mint. - if (!proof.dleq) return true; // no witness to check - // Convert hex mint key to the point representation cashu-ts expects. - // This is a best-effort check; if conversion fails, we return true (skip). - return hasValidDleq(proof, mintPubkeyHex as unknown); - } catch { - return true; // DLEQ check unavailable; defer to mint } + return { status: "valid", checked }; } -// ---- Helpers ---------------------------------------------------------------- +// ---- Proof conversion ------------------------------------------------------- -// Summarize a TokenInfo for display in the chat UI. -export function formatTokenSummary(info: TokenInfo): string { - const amount = `${info.amount} ${info.unit}`; - if (info.memo) return `${amount} - ${info.memo}`; - return amount; +// cashu-ts `Proof` -> persisted `StoredProof`. The `Amount` value object does +// not survive JSON, so it is flattened to a number here and rebuilt on the way +// out. `verified` is set by the caller: only the mint can grant it. +export function toStoredProof( + proof: Proof, + opts?: { verified?: boolean; derived?: boolean; receivedAtMs?: number }, +): StoredProof { + return { + id: proof.id, + amount: proof.amount.toNumber(), + secret: proof.secret, + C: proof.C, + ...(proof.dleq + ? { dleq: proof.dleq as unknown as StoredProof["dleq"] } + : {}), + ...(proof.witness !== undefined + ? { + witness: + typeof proof.witness === "string" + ? proof.witness + : JSON.stringify(proof.witness), + } + : {}), + verified: opts?.verified ?? false, + derived: opts?.derived ?? false, + receivedAtMs: opts?.receivedAtMs ?? Date.now(), + }; } -// Check whether a string looks like it might contain a Cashu token -// (lightweight pre-check before running the full scanner). -export function mayContainToken(text: string): boolean { - return TOKEN_PREFIXES.some((p) => text.includes(p)); +// Persisted proof -> the `ProofLike` shape cashu-ts accepts everywhere (it +// normalises the numeric amount internally via `Amount.from`). +export function toProofLike(proof: StoredProof): ProofLike { + return { + id: proof.id, + amount: proof.amount, + secret: proof.secret, + C: proof.C, + ...(proof.dleq ? { dleq: proof.dleq as Proof["dleq"] } : {}), + ...(proof.witness !== undefined + ? { witness: proof.witness as Proof["witness"] } + : {}), + } as ProofLike; +} + +// ---- Encode ----------------------------------------------------------------- + +// Serialise stored proofs into a `cashuB` token string. Pure serialisation: no +// mint contact, no state change. The caller MUST reserve or remove the selected +// proofs from the store before handing the string out, or the same value can be +// spent twice from this device. +// +// DLEQ witnesses are carried through when present, so the recipient can verify +// the mint's signature offline. That is the whole point of sending them. +export function buildToken( + mintUrl: string, + proofs: StoredProof[], + unit = "sat", + memo?: string, +): string { + const token = { + mint: mintUrl, + proofs: proofs.map(toProofLike), + unit, + ...(memo ? { memo } : {}), + }; + return getEncodedToken(token as unknown as Token); +} + +// ---- Fees ------------------------------------------------------------------- + +// NUT-02 input fee for spending `inputCount` proofs of a keyset charging +// `feePpk` parts-per-thousand. The mint rounds up, so the wallet must too or +// swaps get rejected for underpaying by one sat. +export function inputFeeFor(inputCount: number, feePpk: number): number { + if (feePpk <= 0 || inputCount <= 0) return 0; + return Math.ceil((inputCount * feePpk) / 1000); } -// ---- Offline send helpers --------------------------------------------------- +// Fee the recipient will pay to swap `proofs` at the mint. Used to show "they +// receive N" honestly, and to decide how much to over-send when the sender +// chooses to cover it. +export function feeForProofs( + proofs: StoredProof[], + feePpkByKeysetId: Record | undefined, +): number { + if (!feePpkByKeysetId) return 0; + const ppk = proofs.reduce( + (total, p) => total + (feePpkByKeysetId[p.id] ?? 0), + 0, + ); + return ppk > 0 ? Math.ceil(ppk / 1000) : 0; +} + +// ---- Offline proof selection ------------------------------------------------ + +export interface ProofSelection { + selected: StoredProof[]; + // Face value of `selected`. + total: number; + // What the recipient can actually claim after paying the mint's input fee. + receivable: number; + // Fee the recipient will pay to swap this selection. + fee: number; + // True when `receivable` lands exactly on the requested amount. + exact: boolean; +} -// Select a set of proofs from `proofs` covering `targetAmount`, preferring an -// EXACT match. Returns null if the available proofs are insufficient. +// Choose proofs covering `targetAmount` from `proofs`, preferring an exact +// match on what the *recipient receives* rather than on face value. +// +// This is the offline fallback used when the mint's keysets have never been +// cached on this device. When they have been, the wallet service defers to +// cashu-ts `Wallet.sendOffline`, which runs the same job with the library's +// RGLI selector and the mint's real fee schedule. // -// Cashu denominations are powers of two, so an exact subset usually exists. -// The previous implementation walked largest-first and pushed every proof until -// the running sum crossed the target, which overshoots badly (asking to send -// 10 from a single 64 proof spent all 64, with no change). Here we take a proof -// only when it fits in the remaining need, which yields an exact match whenever -// the denominations allow it. +// Cashu denominations are powers of two, so an exact subset usually exists. We +// take a proof only when it fits inside the remaining need, which finds that +// subset whenever the denominations allow it; walking largest-first and pushing +// until the sum crosses the target overshoots badly (sending 10 from a single +// 64 spends all 64, with no change). // -// `exact` tells the caller whether the selection lands on the target. When it is -// false the wallet cannot send this amount offline without overpaying, and the -// caller MUST either swap at the mint for change or get explicit user consent -// before spending `total`. +// `exact: false` means the wallet cannot make this amount offline without +// overpaying. The caller MUST get explicit consent before spending `total`, +// because offline there is no change: the difference goes to the recipient. export function selectProofsForAmount( proofs: StoredProof[], targetAmount: number, -): { selected: StoredProof[]; total: number; exact: boolean } | null { + feePpkByKeysetId?: Record, +): ProofSelection | null { if (targetAmount <= 0 || proofs.length === 0) return null; - const totalAvailable = proofs.reduce((s, p) => s + p.amount, 0); - if (totalAvailable < targetAmount) return null; - // Largest-first, skipping any proof that would overshoot the remainder. - const sorted = [...proofs].sort((a, b) => b.amount - a.amount); + const describe = (selected: StoredProof[]): ProofSelection => { + const total = selected.reduce((s, p) => s + p.amount, 0); + const fee = feeForProofs(selected, feePpkByKeysetId); + const receivable = total - fee; + return { + selected, + total, + receivable, + fee, + exact: receivable === targetAmount, + }; + }; + + // Prefer spending the proofs the mint has not confirmed for us: they are the + // ones most at risk of turning out to be already spent, and passing them on + // immediately is both safer for us and no worse for the recipient, who will + // swap right away. Within each group, largest first. + const ranked = [...proofs].sort( + (a, b) => + Number(a.verified === true) - Number(b.verified === true) || + b.amount - a.amount, + ); + + // Fees depend on how many proofs we pick, so the target moves as we select. + // Walk greedily against a target that includes the fee accrued so far. const selected: StoredProof[] = []; let sum = 0; - for (const p of sorted) { - if (sum === targetAmount) break; - if (sum + p.amount <= targetAmount) { - selected.push(p); - sum += p.amount; + for (const proof of ranked) { + const feeIfTaken = feeForProofs([...selected, proof], feePpkByKeysetId); + const need = targetAmount + feeIfTaken; + if (sum >= need) break; + if (sum + proof.amount <= need) { + selected.push(proof); + sum += proof.amount; } } - if (sum === targetAmount) return { selected, total: sum, exact: true }; + const exactAttempt = describe(selected); + if (exactAttempt.exact) return exactAttempt; - // No exact subset (odd denominations). Fall back to the smallest selection - // that still covers the target, and flag it so the caller can warn or swap - // rather than silently overpaying. + // No exact subset. Fall back to the smallest selection that still covers the + // target plus its own fee, so the recipient is never short-changed, and flag + // it so the caller can warn instead of silently overpaying. const ascending = [...proofs].sort((a, b) => a.amount - b.amount); const covering: StoredProof[] = []; let coverSum = 0; - for (const p of ascending) { - if (coverSum >= targetAmount) break; - covering.push(p); - coverSum += p.amount; + for (const proof of ascending) { + if (coverSum >= targetAmount + feeForProofs(covering, feePpkByKeysetId)) + break; + covering.push(proof); + coverSum += proof.amount; + } + if (coverSum - feeForProofs(covering, feePpkByKeysetId) < targetAmount) { + // Even everything we hold cannot cover the amount plus its fee. + return null; } - // Drop any now-redundant smallest proofs (the tail can make earlier ones - // unnecessary), keeping the overpayment as small as possible. + + // Drop now-redundant proofs: a later, larger pick can make an earlier small + // one unnecessary. Keeps the overpayment as small as the denominations allow. for (let i = 0; i < covering.length; i++) { - const without = coverSum - covering[i].amount; - if (without >= targetAmount) { - coverSum = without; + const trial = covering.filter((_, idx) => idx !== i); + const trialSum = trial.reduce((s, p) => s + p.amount, 0); + if (trialSum - feeForProofs(trial, feePpkByKeysetId) >= targetAmount) { covering.splice(i, 1); i--; } } - return { selected: covering, total: coverSum, exact: false }; + + return describe(covering); } -// Build a cashuA token string from locally stored proofs without any network -// call. This is pure serialization: pick proofs, encode them, hand off the -// string. The caller must remove the selected proofs from the wallet store to -// prevent double-spending from the same device. -export function buildOfflineToken( - mintUrl: string, - proofs: StoredProof[], - unit: string = "sat", - memo?: string, -): string { - // ProofLike accepts AmountLike (number), so our StoredProof.amount (number) - // maps directly. getEncodedToken internally normalises via Amount.from(). - const cashuProofs = proofs.map((p): ProofLike => ({ - id: p.id, - amount: p.amount, - secret: p.secret, - C: p.C, - ...(p.dleq ? { dleq: p.dleq as Proof["dleq"] } : {}), - })); +// ---- Display ---------------------------------------------------------------- - const token = { - mint: mintUrl, - proofs: cashuProofs, - unit, - ...(memo ? { memo } : {}), - }; - return getEncodedToken(token as unknown as Token); +// "500 sat" / "500 sat - coffee money", for chat search previews and +// accessibility labels. +export function formatTokenSummary(info: TokenInfo): string { + const amount = `${info.amount.toLocaleString()} ${info.unit}`; + return info.memo ? `${amount} - ${info.memo}` : amount; } diff --git a/src/core/payments/nutzap.ts b/src/core/payments/nutzap.ts index c73b895..7864a3b 100644 --- a/src/core/payments/nutzap.ts +++ b/src/core/payments/nutzap.ts @@ -1,186 +1,354 @@ -// NIP-61 Nutzap: online Cashu payments over Nostr. +// NIP-61 Nutzaps: Cashu ecash sent over Nostr, for when the internet is up. // -// Flow (send): -// 1. Fetch recipient's kind 10019 event to learn their mint preferences -// and P2PK locking pubkey. -// 2. Create a P2PK-locked Cashu token at the recipient's preferred mint. -// 3. Publish a kind 9321 event embedding the locked proofs. +// A nutzap is not a request to pay; it *is* the payment. The sender mints +// proofs locked to the recipient's public key (NUT-11 P2PK) and publishes them +// in a public event. Anyone can read the event, but only the holder of the +// matching private key can swap the proofs, so the relay never holds +// spendable value and the recipient does not need to be online to be paid. // -// Flow (receive): -// 1. Subscribe to kind 9321 events tagged to our pubkey. -// 2. Decode the embedded proofs from the event. -// 3. Hand proofs to the Cashu wallet for redemption (internet required). +// Two event kinds, both defined by NIP-61: +// +// kind 10019 "here is how to pay me", replaceable, published by the receiver +// tags: ["relay", ] where to send nutzaps +// ["mint", , …] which mints they will accept +// ["pubkey", <33-byte hex>] the P2PK key to lock to +// +// kind 9321 the nutzap itself, published by the sender +// content: optional comment +// tags: ["proof", ] one tag per locked proof +// ["u", ] which mint issued them +// ["p", ] who they are for +// ["e", , ] optional, what is being zapped +// +// Two rules are easy to get wrong and both lose money: +// - The mint the proofs come from MUST be one the recipient listed in their +// kind 10019. Proofs from an untrusted mint are worthless to them. +// - The `pubkey` tag is a 33-byte compressed secp256k1 key, NOT the Nostr +// pubkey. Nostr keys are 32-byte x-only. Locking to the wrong form makes +// the proofs unspendable by everyone, including the sender. // // References: -// NIP-61: https://github.com/nostr-protocol/nips/blob/master/61.md -// NIP-60: https://github.com/nostr-protocol/nips/blob/master/60.md -// PROTOCOLS.md section 8 for kind numbers. +// NIP-61 https://github.com/nostr-protocol/nips/blob/master/61.md +// NIP-60 https://github.com/nostr-protocol/nips/blob/master/60.md +// PROTOCOLS.md section 8 for the kind numbers Airhop uses. -import type { Proof } from "@cashu/cashu-ts"; +import type { Proof, ProofLike } from "@cashu/cashu-ts"; import { finalizeEvent, type Event } from "nostr-tools"; import type { NostrClient } from "../nostr/nostr-client"; // Event kinds per PROTOCOLS.md section 8. -const KIND_NUTZAP = 9321; -const KIND_WALLET_INFO = 10019; +export const KIND_NUTZAP = 9321; +export const KIND_NUTZAP_INFO = 10019; -// ---- Types ------------------------------------------------------------------ +// Guard rails on relay-supplied content. A nutzap event is public and +// unauthenticated apart from its signature, so every field is treated as +// hostile until it has been parsed. +const MAX_PROOFS_PER_NUTZAP = 64; +const MAX_PROOF_TAG_LENGTH = 4096; +const MAX_COMMENT_LENGTH = 280; +const MAX_MINTS = 16; +const MAX_RELAYS = 16; -export interface WalletInfo { - pubkey: string; // recipient Nostr pubkey (hex) - mintUrls: string[]; // mints the recipient trusts - p2pkPubkey: string; // secp256k1 pubkey for P2PK locking (hex, 33-byte compressed) - relays: string[]; // relays where the recipient receives nutzaps -} +// How far back to look for nutzaps we might have missed while offline. +const LOOKBACK_S = 60 * 60 * 24 * 30; + +// ---- Types ------------------------------------------------------------------ -export interface NutzapContent { - proofs: Proof[]; // P2PK-locked Cashu proofs - mint: string; // which mint the proofs are from - unit: string; // token unit (typically "sat") - comment?: string; // optional visible comment +export interface NutzapInfo { + // Nostr pubkey of the person being paid (hex, x-only). + pubkey: string; + // Mints they will accept proofs from, normalised, in their stated order of + // preference. + mintUrls: string[]; + // 33-byte compressed secp256k1 key to lock proofs to (hex). + p2pkPubkey: string; + // Relays they watch for nutzaps. + relays: string[]; } export interface ReceivedNutzap { eventId: string; senderPubkey: string; - timestamp: number; - content: NutzapContent; + createdAt: number; + mintUrl: string; + unit: string; + proofs: ProofLike[]; + amount: number; + comment?: string; + // The event this nutzap was attached to, when the sender tagged one. + targetEventId?: string; +} + +// ---- Publish our own nutzap info (kind 10019) ------------------------------- + +// Announce where and how we can be paid. Without this event nobody can nutzap +// us at all: a sender has no way to know which mints we trust or which key to +// lock proofs to, and NIP-61 explicitly says not to guess. +// +// This is a replaceable event, so publishing again simply supersedes the last +// one. The P2PK key must stay stable across republishes or proofs locked +// against an older announcement become unspendable. +export async function publishNutzapInfo(params: { + mintUrls: string[]; + p2pkPubkey: string; + relays: string[]; + privKey: Uint8Array; + client: NostrClient; +}): Promise { + const mints = params.mintUrls.slice(0, MAX_MINTS); + if (mints.length === 0) { + throw new Error("nutzap info needs at least one mint"); + } + if (!/^0[23][0-9a-f]{64}$/i.test(params.p2pkPubkey)) { + // A 32-byte x-only Nostr key here is the classic NIP-61 mistake: the mint + // would accept the lock and nobody could ever unlock it. + throw new Error( + "p2pk pubkey must be a 33-byte compressed secp256k1 key (02/03 prefix)", + ); + } + + const event = finalizeEvent( + { + kind: KIND_NUTZAP_INFO, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ...params.relays.slice(0, MAX_RELAYS).map((url) => ["relay", url]), + // The trailing entries are the units we accept from that mint. "sat" + // is the only unit Airhop holds today; listing it explicitly saves a + // sender from guessing. + ...mints.map((url) => ["mint", url, "sat"]), + ["pubkey", params.p2pkPubkey.toLowerCase()], + ], + content: "", + }, + params.privKey, + ); + + await params.client.publish(event); + return event; } -// ---- Fetch recipient wallet info (kind 10019) -------------------------------- +// ---- Fetch a recipient's nutzap info ---------------------------------------- -// Fetch the recipient's NIP-61 wallet info. Returns null if not found or -// malformed (caller should fall back to offline Cashu token transfer). -export async function fetchWalletInfo( +// Look up how to pay someone. Returns null when they have never published a +// kind 10019, which is the normal case for most Nostr users and the signal to +// fall back to an unlocked token in a DM. +export async function fetchNutzapInfo( recipientPubkey: string, client: NostrClient, -): Promise { +): Promise { const events = await client.queryEvents({ - kinds: [KIND_WALLET_INFO], + kinds: [KIND_NUTZAP_INFO], authors: [recipientPubkey], limit: 1, }); - const event = events[0]; if (!event) return null; + return parseNutzapInfo(event); +} + +export function parseNutzapInfo(event: Event): NutzapInfo | null { + if (event.kind !== KIND_NUTZAP_INFO) return null; + + const mintUrls: string[] = []; + const relays: string[] = []; + let p2pkPubkey: string | undefined; + + for (const tag of event.tags) { + const [name, value] = tag; + if (typeof value !== "string" || value.length === 0) continue; + if (name === "mint" && mintUrls.length < MAX_MINTS) { + if (isHttpUrl(value)) mintUrls.push(value); + } else if (name === "relay" && relays.length < MAX_RELAYS) { + if (/^wss?:\/\//i.test(value)) relays.push(value); + } else if (name === "pubkey" && p2pkPubkey === undefined) { + if (/^0[23][0-9a-f]{64}$/i.test(value)) p2pkPubkey = value.toLowerCase(); + } + } + + // Both are load-bearing. Without a mint we do not know what they will accept; + // without a valid P2PK key we cannot lock proofs to them. Falling back to + // `event.pubkey` as the lock key (as the previous implementation did) locks + // proofs to a 32-byte x-only Nostr key, which no mint can unlock. + if (mintUrls.length === 0 || p2pkPubkey === undefined) return null; - return parseWalletInfoEvent(event); + return { pubkey: event.pubkey, mintUrls, p2pkPubkey, relays }; } -// ---- Publish nutzap (kind 9321) --------------------------------------------- +// ---- Publish a nutzap (kind 9321) ------------------------------------------- -// Publish a kind 9321 nutzap event targeting recipientPubkey. -// The proofs must already be P2PK-locked to walletInfo.p2pkPubkey. -// Use a Cashu wallet library to create locked proofs before calling this. -export async function publishNutzap( - proofs: Proof[], - mintUrl: string, - unit: string, - recipientPubkey: string, - senderPrivKey: Uint8Array, - client: NostrClient, - comment?: string, -): Promise { - const nutzapContent: NutzapContent = { - proofs, - mint: mintUrl, - unit, - comment, - }; +// Send P2PK-locked proofs to a recipient. +// +// `proofs` must already be locked to the recipient's `p2pkPubkey` (see +// `lockProofsForNutzap` in wallet-service) and must come from a mint in their +// kind 10019 list. Publishing unlocked proofs here would put spendable bearer +// tokens on a public relay for anyone to grab. +export async function publishNutzap(params: { + proofs: Proof[]; + mintUrl: string; + recipientPubkey: string; + senderPrivKey: Uint8Array; + client: NostrClient; + comment?: string; + targetEventId?: string; +}): Promise { + if (params.proofs.length === 0) throw new Error("nutzap needs proofs"); + if (params.proofs.length > MAX_PROOFS_PER_NUTZAP) { + throw new Error("too many proofs for one nutzap"); + } const event = finalizeEvent( { kind: KIND_NUTZAP, created_at: Math.floor(Date.now() / 1000), tags: [ - ["p", recipientPubkey], - ["u", mintUrl], - ["u", unit], + // One tag per proof, each holding the serialised proof object. This is + // the NIP-61 wire format; putting the whole array in `content` (as the + // previous implementation did) produces an event no other Nostr wallet + // can read. + ...params.proofs.map((proof) => [ + "proof", + JSON.stringify({ + id: proof.id, + amount: proof.amount.toNumber(), + secret: proof.secret, + C: proof.C, + ...(proof.witness !== undefined ? { witness: proof.witness } : {}), + }), + ]), + // "u" is the mint URL. The old code emitted a second "u" tag holding + // the unit, which readers parse as a second mint. + ["u", params.mintUrl], + ["p", params.recipientPubkey], + ...(params.targetEventId ? [["e", params.targetEventId]] : []), ], - content: JSON.stringify(nutzapContent), + content: (params.comment ?? "").slice(0, MAX_COMMENT_LENGTH), }, - senderPrivKey, + params.senderPrivKey, ); - await client.publish(event); + await params.client.publish(event); return event; } // ---- Subscribe to incoming nutzaps ------------------------------------------ -// Subscribe to nutzaps addressed to our pubkey. Returns a closer function. -// The callback receives each new nutzap ready for redemption. +// Watch for nutzaps addressed to us. The callback fires once per event; the +// caller is responsible for redeeming and for ignoring events it has already +// redeemed (wallet-store tracks those ids, since a relay can and will replay). export function subscribeNutzaps( myPubkey: string, client: NostrClient, onNutzap: (zap: ReceivedNutzap) => void, ): () => void { - const filter = { - kinds: [KIND_NUTZAP], - "#p": [myPubkey], - since: Math.floor(Date.now() / 1000) - 86400 * 7, // last 7 days - }; - - const closer = client.subscribe([filter], (event: Event) => { - const parsed = parseNutzapEvent(event); - if (parsed) onNutzap(parsed); - }); - + const closer = client.subscribe( + [ + { + kinds: [KIND_NUTZAP], + "#p": [myPubkey], + since: Math.floor(Date.now() / 1000) - LOOKBACK_S, + }, + ], + (event: Event) => { + const parsed = parseNutzap(event); + if (parsed) onNutzap(parsed); + }, + ); return () => closer.close(); } -// ---- Parse helpers ---------------------------------------------------------- +// ---- Parsing ---------------------------------------------------------------- -function parseWalletInfoEvent(event: Event): WalletInfo | null { - if (event.kind !== KIND_WALLET_INFO) return null; +export function parseNutzap(event: Event): ReceivedNutzap | null { + if (event.kind !== KIND_NUTZAP) return null; + + const proofs: ProofLike[] = []; + let mintUrl: string | undefined; + let targetEventId: string | undefined; - const mintUrls = event.tags - .filter(([t]) => t === "mint") - .map(([, url]) => url) - .filter(Boolean); + for (const tag of event.tags) { + const [name, value] = tag; + if (typeof value !== "string") continue; + if (name === "proof") { + if (proofs.length >= MAX_PROOFS_PER_NUTZAP) continue; + if (value.length > MAX_PROOF_TAG_LENGTH) continue; + const proof = parseProofTag(value); + if (proof) proofs.push(proof); + } else if (name === "u" && mintUrl === undefined) { + if (isHttpUrl(value)) mintUrl = value; + } else if (name === "e" && targetEventId === undefined) { + if (/^[0-9a-f]{64}$/i.test(value)) targetEventId = value.toLowerCase(); + } + } - const relays = event.tags - .filter(([t]) => t === "relay") - .map(([, url]) => url) - .filter(Boolean); + // No mint means we cannot redeem, and no proofs means there is nothing to + // redeem. Either way there is nothing to show the user. + if (proofs.length === 0 || mintUrl === undefined) return null; - // p2pk pubkey: kind 10019 includes a "pubkey" tag with the P2PK lock key - const p2pkTag = event.tags.find(([t]) => t === "pubkey"); - const p2pkPubkey = p2pkTag?.[1] ?? event.pubkey; + const amount = proofs.reduce((total, p) => total + Number(p.amount), 0); + if (!Number.isSafeInteger(amount) || amount <= 0) return null; - if (mintUrls.length === 0) return null; + const comment = event.content.trim().slice(0, MAX_COMMENT_LENGTH); return { - pubkey: event.pubkey, - mintUrls, - p2pkPubkey, - relays, + eventId: event.id, + senderPubkey: event.pubkey, + createdAt: event.created_at, + mintUrl, + // NIP-61 carries no unit tag; sat is the NUT-00 default and the only unit + // Airhop's nutzap info advertises. + unit: "sat", + proofs, + amount, + ...(comment.length > 0 ? { comment } : {}), + ...(targetEventId !== undefined ? { targetEventId } : {}), }; } -function parseNutzapEvent(event: Event): ReceivedNutzap | null { - if (event.kind !== KIND_NUTZAP) return null; - - let content: NutzapContent; +// One `["proof", ""]` tag. Rejects anything that is not a structurally +// complete proof: a half-parsed proof would be shown as incoming money and then +// fail at the mint. +function parseProofTag(raw: string): ProofLike | null { + let parsed: unknown; try { - content = JSON.parse(event.content) as NutzapContent; + parsed = JSON.parse(raw); } catch { return null; } + if (typeof parsed !== "object" || parsed === null) return null; + const p = parsed as Record; - if (!Array.isArray(content.proofs) || content.proofs.length === 0) + const amount = typeof p.amount === "number" ? p.amount : Number(p.amount); + if ( + typeof p.id !== "string" || + typeof p.secret !== "string" || + typeof p.C !== "string" || + !Number.isSafeInteger(amount) || + amount <= 0 + ) { return null; - if (!content.mint) return null; + } + if (!/^[0-9a-f]{2,66}$/i.test(p.id)) return null; + if (!/^0[23][0-9a-f]{64}$/i.test(p.C)) return null; return { - eventId: event.id, - senderPubkey: event.pubkey, - timestamp: event.created_at, - content: { - proofs: content.proofs, - mint: content.mint, - unit: content.unit ?? "sat", - comment: content.comment, - }, - }; + id: p.id, + amount, + secret: p.secret, + C: p.C, + ...(p.witness !== undefined + ? { witness: p.witness as ProofLike["witness"] } + : {}), + ...(p.dleq !== undefined ? { dleq: p.dleq as ProofLike["dleq"] } : {}), + } as ProofLike; +} + +function isHttpUrl(value: string): boolean { + if (value.length > 512) return false; + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } } diff --git a/src/core/payments/wallet-seed.ts b/src/core/payments/wallet-seed.ts new file mode 100644 index 0000000..ec06afd --- /dev/null +++ b/src/core/payments/wallet-seed.ts @@ -0,0 +1,160 @@ +// Wallet recovery phrase: 12 BIP-39 words that can rebuild the ecash balance. +// +// Why this exists +// --------------- +// A Cashu proof is a secret plus the mint's signature on it. By default every +// secret is fresh random bytes, which means the only copy that has ever existed +// is the one on this phone. Lose the phone and the money is unreachable +// forever: the mint still holds the bitcoin, but nobody can prove they own it. +// +// With a recovery phrase, secrets stop being random. They are *derived* from +// one master seed in a fixed order (NUT-13), so secret #0, #1, #2 and so on can +// be regenerated anywhere from the same twelve words. Recovery then works by +// re-deriving them and asking the mint "did you sign this one?" (NUT-09). The +// mint answers from its own records and the balance reassembles. +// +// What it does NOT cover +// ---------------------- +// * The Airhop identity itself. That is a separate key and a separate +// decision; these words restore money only. +// * Which mints you used. Recovery has to ask a specific mint, so the mint +// list is shown alongside the words and has to be kept with them. +// * Chat history, contacts, or transaction memos. +// * Coins received offline and never swapped. Those carry the *sender's* +// secrets, not ours, so no seed of ours can reproduce them. They become +// covered the moment they are swapped at the mint. +// +// The phrase is the money. It lives in the OS keychain next to the identity +// keys and is never written to the proof store, never sent anywhere, and never +// logged. + +import { + generateMnemonic, + mnemonicToSeedSync, + validateMnemonic, +} from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; +import EncryptedStorage from "react-native-encrypted-storage"; + +// Keychain / Keystore entry holding the phrase. +const PHRASE_ITEM = "airhop.wallet.recovery.v1"; + +// 128 bits of entropy is 12 words. Enough that guessing is hopeless, short +// enough that people will actually write it down, and the length every other +// wallet uses so it looks familiar. +const ENTROPY_BITS = 128; + +export const RECOVERY_WORD_COUNT = 12; + +// ---- Phrase handling -------------------------------------------------------- + +// A fresh 12-word phrase. Uses the platform CSPRNG via @scure/bip39. +export function generateRecoveryPhrase(): string { + return generateMnemonic(wordlist, ENTROPY_BITS); +} + +// Lowercase, collapse runs of whitespace and newlines to single spaces, drop +// stray punctuation. People paste these from notes apps, photos and password +// managers, so accept whatever shape it arrives in rather than making them +// fight the input field. +export function normalizeRecoveryPhrase(raw: string): string { + return raw + .toLowerCase() + .replace(/[^a-z\s]/g, " ") + .trim() + .split(/\s+/) + .join(" "); +} + +// Whether a phrase is a real BIP-39 mnemonic. This checks the checksum built +// into the standard, so a typo in any single word is caught here rather than +// silently restoring an empty wallet and leaving the user thinking their money +// is gone. +export function isValidRecoveryPhrase(raw: string): boolean { + const phrase = normalizeRecoveryPhrase(raw); + if (phrase.split(" ").length !== RECOVERY_WORD_COUNT) return false; + try { + return validateMnemonic(phrase, wordlist); + } catch { + return false; + } +} + +// Which words are not in the BIP-39 list, so the UI can point at the typo +// instead of only saying "invalid". +export function unknownWordsIn(raw: string): string[] { + const known = new Set(wordlist); + return normalizeRecoveryPhrase(raw) + .split(" ") + .filter((word) => word.length > 0 && !known.has(word)); +} + +// The 64-byte seed cashu-ts derives proof secrets from. Throws on an invalid +// phrase rather than deriving from garbage, which would produce secrets no +// mint has ever signed and a restore that silently finds nothing. +export function recoveryPhraseToSeed(raw: string): Uint8Array { + const phrase = normalizeRecoveryPhrase(raw); + if (!isValidRecoveryPhrase(phrase)) { + throw new Error("invalid recovery phrase"); + } + return mnemonicToSeedSync(phrase); +} + +// ---- Secure storage --------------------------------------------------------- + +export async function loadStoredPhrase(): Promise { + try { + const stored = await EncryptedStorage.getItem(PHRASE_ITEM); + if (typeof stored !== "string" || stored.length === 0) return null; + return isValidRecoveryPhrase(stored) + ? normalizeRecoveryPhrase(stored) + : null; + } catch { + // Keychain locked or unavailable. Treated as "no phrase", which makes the + // wallet fall back to random secrets rather than failing the operation. + return null; + } +} + +export async function storePhrase(raw: string): Promise { + const phrase = normalizeRecoveryPhrase(raw); + if (!isValidRecoveryPhrase(phrase)) { + throw new Error("refusing to store an invalid recovery phrase"); + } + await EncryptedStorage.setItem(PHRASE_ITEM, phrase); +} + +// There is deliberately no "forget phrase" here. Once coins are derived from a +// phrase, deleting it is the same as deleting the coins, so the only thing that +// removes it is the panic wipe clearing the whole keychain. + +// ---- Verification helper ---------------------------------------------------- + +// Pick `count` distinct word positions to quiz the user on after showing them +// the phrase. Randomised per setup so screenshotting one verification screen +// does not teach anyone how to pass the next one. +// +// Positions are 1-based, because that is how they are labelled on screen. +export function pickVerificationPositions(count = 2): number[] { + const positions = new Set(); + while (positions.size < Math.min(count, RECOVERY_WORD_COUNT)) { + const bytes = crypto.getRandomValues(new Uint8Array(1)); + positions.add((bytes[0] % RECOVERY_WORD_COUNT) + 1); + } + return [...positions].sort((a, b) => a - b); +} + +// Whether the words typed into the verification step match the phrase at those +// positions. Compared after normalising, so capitalisation and stray spaces do +// not fail an otherwise correct answer. +export function verifyPositions( + phrase: string, + answers: Record, +): boolean { + const words = normalizeRecoveryPhrase(phrase).split(" "); + return Object.entries(answers).every(([position, answer]) => { + const index = Number.parseInt(position, 10) - 1; + if (index < 0 || index >= words.length) return false; + return words[index] === normalizeRecoveryPhrase(answer); + }); +} diff --git a/src/features/chat/message-thread.tsx b/src/features/chat/message-thread.tsx index 7dc9739..84e5a22 100644 --- a/src/features/chat/message-thread.tsx +++ b/src/features/chat/message-thread.tsx @@ -26,11 +26,10 @@ import React, { useState, } from "react"; import { + AppState, FlatList, Image, - KeyboardAvoidingView, Modal, - Platform, Pressable, ScrollView, Share, @@ -38,6 +37,8 @@ import { Text, TextInput, View, + type NativeScrollEvent, + type NativeSyntheticEvent, } from "react-native"; import Animated, { Easing, @@ -46,12 +47,15 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { - buildOfflineToken, findTokensInText, mayContainToken, - selectProofsForAmount, type EmbeddedToken, } from "../../core/payments/cashu"; +import { + describeRoute, + reportWalletError, + sendEcashToPeer, +} from "../../services/ecash-transfer"; import { isGeoChannel, isManualGeoChannel, @@ -60,6 +64,7 @@ import { } from "../../services/geohash-channel-service"; import { hasLocationPermission } from "../../services/location-service"; import { getMeshService } from "../../services/mesh-service"; +import { hostOf, receiveToken } from "../../services/wallet-service"; import { useActivityStore } from "../../store/activity-store"; import { showAlert } from "../../store/alert-store"; import { @@ -82,6 +87,7 @@ import { } from "../../store/transfer-store"; import { useWalletStore } from "../../store/wallet-store"; import Avatar from "../../ui/components/avatar"; +import BottomSheet from "../../ui/components/bottom-sheet"; import { FontFamily, FontSize, @@ -90,10 +96,12 @@ import { Spacing, useThemeColors, } from "../../ui/theme"; +import { useKeyboardInset } from "../../ui/use-keyboard"; import { channelInviteLink } from "../../utils/deep-link"; import { resolveDisplayName } from "../../utils/display-name"; import { canSendMedia } from "../../utils/media-policy"; import { activeMentionQuery, applyMention } from "../../utils/mentions"; +import { ensurePermission } from "../../utils/permissions"; import { isNostrId, NOSTR_ID_PREFIX, @@ -187,10 +195,36 @@ interface Props { // window to Undo. Short enough not to read as lag, long enough to react. const UNDO_WINDOW_MS = 3000; +// How close to the end of the thread still counts as "at the bottom", in points. +// Roughly one bubble: near enough that following a new message reads as the list +// staying put, far enough that a deliberate scroll up is never mistaken for it. +const AT_BOTTOM_TOLERANCE = 80; + +// Long enough for a bottom sheet to finish sliding out before the next one +// slides in. Matches BottomSheet's own close duration with a little slack. +const SHEET_HANDOFF_MS = 250; + +// The attachment-review sheet darkens the screen further than the standard +// scrim: the point of that sheet is to look at one photo, and the thread behind +// it competing for attention defeats that. +const COMPOSER_SCRIM = "rgba(0,0,0,0.85)"; + function screenshotNoticeText(nickname: string): string { return `* ${nickname} took a screenshot *`; } +// Whole calendar days between two instants, in local time. +// +// Counting elapsed milliseconds would be wrong here: a message sent at 23:50 and +// read at 00:10 is twenty minutes old but belongs to yesterday, which is the +// only thing a date separator cares about. Both sides are flattened to local +// midnight first, so the result is a day count and never a fraction. +function daysBetween(from: Date, to: Date): number { + const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()); + const b = new Date(to.getFullYear(), to.getMonth(), to.getDate()); + return Math.round((b.getTime() - a.getTime()) / 86_400_000); +} + // Slash commands offered by the "/" quick-picker. Only the ones handleSend // actually acts on appear here, so the list never advertises a command that does // nothing. Both are IRC-style emotes: in a DM they target the peer, in a channel @@ -656,6 +690,8 @@ export default function MessageThread({ }: Props): React.JSX.Element { const Colors = useThemeColors(); const styles = useMemo(() => createStyles(Colors), [Colors]); + // How far the compose bar has to lift to clear the on-screen keyboard. + const keyboardInset = useKeyboardInset(); const { messages, addMessage, addChannel } = useChatStore(); // Live peer count, real data from BLE discovery, not a stub. // Subscribe to the stable peer map and derive the reachable list locally. @@ -873,6 +909,11 @@ export default function MessageThread({ const autoDownloadMedia = useSettingsStore((s) => s.autoDownloadMedia); const [showAttachMenu, setShowAttachMenu] = useState(false); const [showSendEcash, setShowSendEcash] = useState(false); + // Raw string of the token currently being claimed, so its button can show + // progress and a double tap cannot start two swaps for the same proofs. + const [claimingToken, setClaimingToken] = useState(null); + // Tokens already taken into the wallet, so their cards read "Claimed". + const claimedTokens = useWalletStore((s) => s.claimedTokens); const [ecashAmount, setEcashAmount] = useState(""); const [ecashMemo, setEcashMemo] = useState(""); const [showChannelInfo, setShowChannelInfo] = useState(false); @@ -938,12 +979,97 @@ export default function MessageThread({ const msgs = useMemo(() => messages[channel] ?? [], [messages, channel]); const isDM = channel.startsWith("dm:"); - // Send read receipts for this DM whenever it is open and its messages change: - // covers both opening the thread and a new message arriving while it is on - // screen. Best-effort and no-op for channels (no per-recipient receipts). + // Read receipts for this DM. Best-effort, and a no-op for channels (there is + // no per-recipient receipt for a broadcast). + // + // Gated on the app actually being in the foreground. The thread stays mounted + // when you switch away, so without this a message arriving while the app is in + // your pocket would be reported back as "read" - telling the other person you + // saw something you have not seen. A read receipt is a claim about a human, + // not about a process, and it is the one piece of presence people notice being + // wrong. Re-runs when we come back, so opening the app does send the receipts + // that were correctly withheld. + const [appActive, setAppActive] = useState( + () => AppState.currentState === "active", + ); + useEffect(() => { + const sub = AppState.addEventListener("change", (next) => + setAppActive(next === "active"), + ); + return () => sub.remove(); + }, []); + useEffect(() => { + if (isDM && appActive) getMeshService()?.sendReadReceipts(channel.slice(3)); + }, [isDM, channel, msgs, appActive]); + + // Where the reader is in the thread, and whether they have been placed at the + // newest message yet. Refs, not state: these are read inside scroll handlers + // on every frame and must never themselves cause a render. + const atBottomRef = useRef(true); + const hasLandedRef = useRef(false); + // The one piece of it the UI needs, so the jump-to-latest pill can appear. + const [showJumpToLatest, setShowJumpToLatest] = useState(false); + + function handleListScroll(e: NativeSyntheticEvent): void { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; + const distanceFromBottom = + contentSize.height - (contentOffset.y + layoutMeasurement.height); + // A tolerance rather than an exact match: momentum, a keyboard resize or a + // half-pixel layout rounding all leave you a few points short of the true + // end, and none of them mean the reader has scrolled away. + const atBottom = distanceFromBottom <= AT_BOTTOM_TOLERANCE; + atBottomRef.current = atBottom; + setShowJumpToLatest((shown) => (shown === !atBottom ? shown : !atBottom)); + } + + function jumpToLatest(): void { + atBottomRef.current = true; + setShowJumpToLatest(false); + listRef.current?.scrollToEnd({ animated: true }); + } + + // Open a second bottom sheet once the first has finished sliding out. One + // pending handoff at a time, and cancelled when the thread goes away: two of + // these racing would open the wrong sheet, or open one over a thread the user + // has already left. The action sheet's own close is what this is waiting on, + // so the delay tracks that animation. + const handoffTimer = useRef | null>(null); + useEffect( + () => () => { + if (handoffTimer.current) clearTimeout(handoffTimer.current); + }, + [], + ); + function scheduleSheetHandoff(open: () => void): void { + if (handoffTimer.current) clearTimeout(handoffTimer.current); + handoffTimer.current = setTimeout(() => { + handoffTimer.current = null; + open(); + }, SHEET_HANDOFF_MS); + } + + // Opening the keyboard shortens the list without changing its content, so no + // content-size event fires and the newest messages end up hidden behind the + // compose bar. Follow the keyboard down to the latest message: you almost + // always start typing in reply to what you were just reading. + // + // Keyed on the keyboard alone. Adding the message count would re-run this for + // every message that arrives while the keyboard is open, racing the animated + // scroll against the instant one onContentSizeChange already does for new + // content - two scrollers fighting over the same list reads as a stutter. + // + // Only for a reader already at the bottom, which is the case this exists for: + // the list got shorter under them and took the newest messages with it. Someone + // reading further up tapped the composer on purpose and expects to keep looking + // at what they were looking at. useEffect(() => { - if (isDM) getMeshService()?.sendReadReceipts(channel.slice(3)); - }, [isDM, channel, msgs]); + if (keyboardInset <= 0 || !atBottomRef.current) return; + const id = setTimeout( + () => listRef.current?.scrollToEnd({ animated: true }), + 50, + ); + return () => clearTimeout(id); + }, [keyboardInset]); // Scroll to a message and briefly flash it. Shared by search-result jumps // and the pinned-messages sheet so both behave identically. @@ -989,21 +1115,39 @@ export default function MessageThread({ }, []); // Claim an ecash token found inside a received message. - // Proofs are stored offline without a mint call; user can redeem later. - function claimToken(embedded: EmbeddedToken): void { - const { addMint, addProofs } = useWalletStore.getState(); - const stored = embedded.info.token.proofs.map((p) => ({ - id: p.id, - amount: p.amount.toNumber(), - secret: p.secret, - C: p.C, - })); - addMint(embedded.info.mintUrl); - addProofs(embedded.info.mintUrl, stored); - showAlert( - `+${embedded.info.amount.toLocaleString()} ${embedded.info.unit}`, - `Token added to your wallet from ${embedded.info.mintUrl.replace(/https?:\/\//, "")}.`, - ); + // + // This goes through the wallet service rather than writing proofs straight + // into the store, which is the difference between "the card said 500 sats so + // we added 500 sats" and actually checking. The service verifies the mint's + // DLEQ signature offline and refuses a forgery outright, swaps at the mint + // when there is internet (the only thing that proves the token has not + // already been spent elsewhere), and otherwise stores it as unconfirmed so + // the balance stays honest about what it does and does not know. + async function claimToken(embedded: EmbeddedToken): Promise { + if (claimingToken !== null) return; + setClaimingToken(embedded.raw); + try { + const result = await receiveToken(embedded.raw, { + counterparty: dmPeerID ?? channel, + }); + if (result.outcome === "duplicate") { + showAlert( + "Already claimed", + "Every proof in this token is already in your wallet, so nothing was added.", + ); + return; + } + showAlert( + `+${result.amount.toLocaleString()} ${result.unit}`, + result.outcome === "swapped" + ? `Redeemed at ${hostOf(result.mintUrl)}. It is provably yours now: the sender's copy of this token no longer works.` + : `Stored from ${hostOf(result.mintUrl)}, but the mint has not confirmed it is unspent yet${result.dleq === "valid" ? " (its signature does check out, so the token is genuine)" : ""}. Refresh from the Wallet tab once you are online.`, + ); + } catch (err) { + reportWalletError(err); + } finally { + setClaimingToken(null); + } } // Show a brief status hint, then auto-clear after 4 seconds. @@ -1270,70 +1414,36 @@ export default function MessageThread({ } } - // Send a Cashu token to this DM peer, the same offline build-and-deduct flow - // as the Wallet tab's Send, just with the recipient already fixed to - // whoever this thread is with. - function handleSendEcash(): void { + // Send a Cashu token to this DM peer. Identical to the Wallet tab's send and + // the Mesh tab's peer sheet, because all three call the same transfer + // service: proof selection, mint fees, and the reservation that makes an + // undelivered token reclaimable all live there rather than being re-derived + // per screen. + async function handleSendEcash(): Promise { const amount = parseInt(ecashAmount, 10); if (!amount || amount <= 0 || !dmPeerID) return; - const { proofsByMint, unit, removeProofs } = useWalletStore.getState(); - const totalBalance = Object.values(proofsByMint).reduce( - (sum, ps) => sum + ps.reduce((s, p) => s + p.amount, 0), - 0, - ); - if (amount > totalBalance) { - showAlert( - "Insufficient balance", - `You have ${totalBalance.toLocaleString()} sats but tried to send ${amount.toLocaleString()} sats.`, - ); - return; - } - - const mintEntry = Object.entries(proofsByMint) - .map(([url, ps]) => ({ - url, - ps, - balance: ps.reduce((s, p) => s + p.amount, 0), - })) - .find((m) => m.balance >= amount); - if (!mintEntry) { - showAlert( - "Balance split across mints", - "No single mint holds the full amount. Use the Wallet tab to consolidate.", - ); - return; - } - - const selection = selectProofsForAmount(mintEntry.ps, amount); - if (!selection) return; - - const tokenStr = buildOfflineToken( - mintEntry.url, - selection.selected, - unit, - ecashMemo.trim() || undefined, - ); - removeProofs( - mintEntry.url, - selection.selected.map((p) => p.secret), - ); - - addMessage({ - id: `${localPeerID}-${Date.now()}-ecash`, - channel, - senderID: localPeerID, + const result = await sendEcashToPeer({ + peerID: dmPeerID, + amount, + memo: ecashMemo.trim() || undefined, senderNickname: localNickname, - text: tokenStr, - - timestampMs: Date.now(), - isMine: true, }); - getMeshService()?.sendDm(dmPeerID, tokenStr); + if (!result) return; setShowSendEcash(false); setEcashAmount(""); setEcashMemo(""); + // The bubble and its delivery status are already on screen, so the happy + // path needs no modal. Only the routes that are not immediate delivery are + // worth interrupting for, because "queued" looks identical to "sent" in the + // thread and means something quite different. + if (result.route !== "sent") { + showAlert( + `${result.prepared.amount.toLocaleString()} ${result.prepared.unit} on its way`, + `${describeRoute(result.route)} It stays reclaimable from the Wallet tab until you confirm it arrived.`, + ); + } } // Build a local attachment message immediately (instant feedback), then read @@ -1479,14 +1589,12 @@ export default function MessageThread({ } async function handleCameraAttach(): Promise { - const { status } = await ImagePicker.requestCameraPermissionsAsync(); - if (status !== "granted") { - showAlert( - "Permission needed", - "Grant camera access in Settings to take photos.", - ); - return; - } + const granted = await ensurePermission( + () => ImagePicker.getCameraPermissionsAsync(), + () => ImagePicker.requestCameraPermissionsAsync(), + { label: "Camera access", purpose: "take a photo to send" }, + ); + if (!granted) return; const result = await ImagePicker.launchCameraAsync({ mediaTypes: ["images", "videos"], quality: UPLOAD_QUALITY_VALUES[useSettingsStore.getState().uploadQuality], @@ -1506,11 +1614,12 @@ export default function MessageThread({ } async function handleLibraryAttach(): Promise { - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== "granted") { - showAlert("Permission needed", "Grant photo library access in Settings."); - return; - } + const granted = await ensurePermission( + () => ImagePicker.getMediaLibraryPermissionsAsync(), + () => ImagePicker.requestMediaLibraryPermissionsAsync(), + { label: "Photo access", purpose: "pick a photo or video to send" }, + ); + if (!granted) return; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], quality: UPLOAD_QUALITY_VALUES[useSettingsStore.getState().uploadQuality], @@ -1566,14 +1675,12 @@ export default function MessageThread({ } async function startRecording(): Promise { - const { granted } = await AudioModule.requestRecordingPermissionsAsync(); - if (!granted) { - showAlert( - "Permission needed", - "Grant microphone access in Settings to record voice notes.", - ); - return; - } + const granted = await ensurePermission( + () => AudioModule.getRecordingPermissionsAsync(), + () => AudioModule.requestRecordingPermissionsAsync(), + { label: "Microphone access", purpose: "record a voice note" }, + ); + if (!granted) return; await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true, @@ -1833,20 +1940,43 @@ export default function MessageThread({ {token.info.memo ? ( {token.info.memo} ) : null} - {!isMine && ( - claimToken(token)} - accessibilityRole="button" - accessibilityLabel={`Claim ${token.info.amount.toLocaleString()} ${token.info.unit}`} - > - Claim - - )} + {/* Nothing to claim on a token you sent: your copy of those proofs is + already reserved against the pending send. */} + {!isMine && + (isTokenClaimed(token) ? ( + + + Claimed + + ) : ( + void claimToken(token)} + accessibilityRole="button" + accessibilityLabel={`Claim ${token.info.amount.toLocaleString()} ${token.info.unit}`} + > + + {claimingToken === token.raw ? "Claiming…" : "Claim"} + + + ))} ); } + // A token is "claimed" once its proofs have been taken into the wallet. + // Matched on the first proof's secret, which the store records on claim, + // because after an online swap the proofs themselves are replaced and can no + // longer be found in the balance. + function isTokenClaimed(token: EmbeddedToken): boolean { + const first = token.info.token.proofs[0]?.secret; + return first !== undefined && claimedTokens.includes(first); + } + function formatTime(ms: number): string { const d = new Date(ms); return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); @@ -1864,29 +1994,26 @@ export default function MessageThread({ ); } + // Date separator label, following what every messenger settles on: the two + // days people think of by name, then the weekday while "last Tuesday" is + // still a useful handle, then a plain date. + // + // The date always carries the year. Without it a thread from last July and one + // from this July render identically, which is the one case a separator exists + // to prevent. function formatDateSeparator(ms: number): string { const d = new Date(ms); const now = new Date(); - if ( - d.getDate() === now.getDate() && - d.getMonth() === now.getMonth() && - d.getFullYear() === now.getFullYear() - ) { - return "Today"; - } - const yesterday = new Date(now); - yesterday.setDate(now.getDate() - 1); - if ( - d.getDate() === yesterday.getDate() && - d.getMonth() === yesterday.getMonth() && - d.getFullYear() === yesterday.getFullYear() - ) { - return "Yesterday"; - } + const days = daysBetween(d, now); + if (days === 0) return "Today"; + if (days === 1) return "Yesterday"; + // Inside the last week a weekday name still locates the message. Past that + // it stops meaning anything, since "Monday" could be any Monday. + if (days < 7) return d.toLocaleDateString([], { weekday: "long" }); return d.toLocaleDateString([], { - weekday: "long", - month: "long", - day: "numeric", + month: "short", + day: "2-digit", + year: "numeric", }); } @@ -1897,10 +2024,13 @@ export default function MessageThread({ : channel; return ( - + // Padding, not KeyboardAvoidingView: Android is edge-to-edge, so the window + // never shrinks for the IME and KAV's Android path (which waits for that + // resize) left the compose bar buried under the keyboard. Measuring the IME + // and padding by it works identically on both platforms. The inset is the + // keyboard height minus the bottom safe-area, because the thread already + // sits above the nav bar inside the app's root SafeAreaView. + {/* Header */} )} - {/* Messages */} - item.id} - // Keep a long thread cheap to update: render fewer rows per batch and - // keep a smaller window mounted, so a keyboard toggle or a new message - // doesn't churn the whole list. The bubble itself is memoized. - initialNumToRender={20} - maxToRenderPerBatch={12} - windowSize={11} - updateCellsBatchingPeriod={50} - renderItem={({ item, index }) => { - const showAvatar = !item.isMine; - const isFirstFromSender = - index === 0 || (msgs[index - 1]?.senderID ?? "") !== item.senderID; - // Only LOCALLY generated notices render as a system row. - // - // This used to also sniff the text for "took a screenshot", which - // meant any peer could forge a system row just by typing that phrase - // and worse, the branch below substitutes a canned string for - // non-mine messages, so an ordinary sentence like "I took a - // screenshot of the map" had its real content silently replaced. - // A peer's screenshot notice now renders as the normal message it - // actually is; a trustworthy version needs a protocol signal, not a - // substring match on user text. - const isSystemRow = item.isSystem === true; - - if (isSystemRow) { - return ( - - {needsDateSeparator(index) && ( - - - - {formatDateSeparator(item.timestampMs)} + {/* Messages. Wrapped so the jump-to-latest pill can float over the end of + the list rather than taking a row in the column and shoving the + compose bar around as it comes and goes. */} + + item.id} + // Keep a long thread cheap to update: render fewer rows per batch and + // keep a smaller window mounted, so a keyboard toggle or a new message + // doesn't churn the whole list. The bubble itself is memoized. + initialNumToRender={20} + maxToRenderPerBatch={12} + windowSize={11} + updateCellsBatchingPeriod={50} + renderItem={({ item, index }) => { + const showAvatar = !item.isMine; + const isFirstFromSender = + index === 0 || + (msgs[index - 1]?.senderID ?? "") !== item.senderID; + // Only LOCALLY generated notices render as a system row. + // + // This used to also sniff the text for "took a screenshot", which + // meant any peer could forge a system row just by typing that phrase + // and worse, the branch below substitutes a canned string for + // non-mine messages, so an ordinary sentence like "I took a + // screenshot of the map" had its real content silently replaced. + // A peer's screenshot notice now renders as the normal message it + // actually is; a trustworthy version needs a protocol signal, not a + // substring match on user text. + const isSystemRow = item.isSystem === true; + + if (isSystemRow) { + return ( + + {needsDateSeparator(index) && ( + + + + {formatDateSeparator(item.timestampMs)} + + + + )} + + + {item.text} + + + ); + } + + // IRC-style emote (/hug, /slap): a real message wrapped in "* … *", + // rendered centered and italic like an action rather than a bubble. + const isEmoteRow = + item.isSystem !== true && + item.attachment === undefined && + /^\* .+ \*$/.test(item.text); + if (isEmoteRow) { + return ( + + {needsDateSeparator(index) && ( + + + + {formatDateSeparator(item.timestampMs)} + + + + )} + + + {item.text.replace(/^\* /, "").replace(/ \*$/, "")} - - )} - - - {item.text} - - ); - } + ); + } + + // Compute the token list once and suppress raw text when the + // entire message is a Cashu token (no extra prose). + const tokens = mayContainToken(item.text) + ? findTokensInText(item.text) + : []; + const isPureToken = + tokens.length > 0 && tokens[0]!.raw.trim() === item.text.trim(); - // IRC-style emote (/hug, /slap): a real message wrapped in "* … *", - // rendered centered and italic like an action rather than a bubble. - const isEmoteRow = - item.isSystem !== true && - item.attachment === undefined && - /^\* .+ \*$/.test(item.text); - if (isEmoteRow) { return ( {needsDateSeparator(index) && ( @@ -2073,92 +2235,102 @@ export default function MessageThread({ )} - - - {item.text.replace(/^\* /, "").replace(/ \*$/, "")} - - + renderTokenCard(token, item.isMine)} + renderAttachment={(attachment) => + renderAttachmentBubble(attachment, item.id, item.isMine) + } + formatTime={formatTime} + onLongPress={handleLongPressMessage} + onRetry={handleRetryMessage} + onPressSender={isDM ? undefined : handlePressSender} + highlighted={item.id === highlightedMessageId} + /> ); - } - - // Compute the token list once and suppress raw text when the - // entire message is a Cashu token (no extra prose). - const tokens = mayContainToken(item.text) - ? findTokensInText(item.text) - : []; - const isPureToken = - tokens.length > 0 && tokens[0]!.raw.trim() === item.text.trim(); + }} + onScroll={handleListScroll} + // 16ms would fire this every frame; 100 is plenty to know which end of + // the thread someone is reading, and costs a fraction as much. + scrollEventThrottle={100} + onContentSizeChange={() => { + // Suppressed while a search-result message is flashed: a stray + // content-size event (e.g. an image finishing layout) would + // otherwise yank the view back to the bottom mid-flash. + if (highlightedMessageId || msgs.length === 0) return; + + // Opening a thread lands at the newest message, instantly - every + // messenger does this, and animating it would just show the user a + // scroll they didn't ask for. + if (!hasLandedRef.current) { + hasLandedRef.current = true; + listRef.current?.scrollToEnd({ animated: false }); + return; + } - return ( - - {needsDateSeparator(index) && ( - - - - {formatDateSeparator(item.timestampMs)} - - - - )} - renderTokenCard(token, item.isMine)} - renderAttachment={(attachment) => - renderAttachmentBubble(attachment, item.id, item.isMine) - } - formatTime={formatTime} - onLongPress={handleLongPressMessage} - onRetry={handleRetryMessage} - onPressSender={isDM ? undefined : handlePressSender} - highlighted={item.id === highlightedMessageId} - /> - - ); - }} - onContentSizeChange={() => { - // Suppressed while a search-result message is flashed: a stray - // content-size event (e.g. an image finishing layout) would - // otherwise yank the view back to the bottom mid-flash. - if (highlightedMessageId) return; - if (msgs.length > 0) - listRef.current?.scrollToEnd({ animated: false }); - }} - onScrollToIndexFailed={(info) => { - // Bubble heights vary (attachments/tokens/multi-line text), so - // scrollToIndex can fail before layout has measured that far. - // Jump to the estimated offset, then retry once layout catches up. - listRef.current?.scrollToOffset({ - offset: info.averageItemLength * info.index, - animated: false, - }); - setTimeout(() => { - const index = msgs.findIndex((m) => m.id === targetMessageId); - if (index !== -1) { - listRef.current?.scrollToIndex({ - index, - animated: true, - viewPosition: 0.3, - }); + // After that, only follow the thread if the reader is already at the + // bottom. Scrolling up is a deliberate act - reading back, quoting + // something, looking at an old photo - and yanking them to the newest + // message because someone typed, or because an image two screens up + // finished loading, is the single most disruptive thing a chat list + // can do. When they aren't at the bottom the jump-to-latest pill + // appears instead, and the choice stays theirs. + if (atBottomRef.current) { + listRef.current?.scrollToEnd({ animated: true }); } - }, 100); - }} - ListEmptyComponent={ - - No messages yet - - {isDM - ? "Start an encrypted conversation." - : `Say something in ${channel}.`} - - - } - contentContainerStyle={styles.list} - /> + }} + onScrollToIndexFailed={(info) => { + // Bubble heights vary (attachments/tokens/multi-line text), so + // scrollToIndex can fail before layout has measured that far. + // Jump to the estimated offset, then retry once layout catches up. + listRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: false, + }); + setTimeout(() => { + const index = msgs.findIndex((m) => m.id === targetMessageId); + if (index !== -1) { + listRef.current?.scrollToIndex({ + index, + animated: true, + viewPosition: 0.3, + }); + } + }, 100); + }} + ListEmptyComponent={ + + No messages yet + + {isDM + ? "Start an encrypted conversation." + : `Say something in ${channel}.`} + + + } + contentContainerStyle={styles.list} + /> + + {/* Jump to latest: only while the reader is away from the end, so it is + an offer rather than permanent chrome. This is the other half of not + auto-scrolling - without a way back, "we left you where you were" + turns into "we stranded you". */} + {showJumpToLatest && msgs.length > 0 && ( + + + + )} + {/* Delivery hints. "queued" means a DM is held for later retry; "no-reach" means a channel broadcast found no peers and no internet cell, so it @@ -2376,129 +2548,103 @@ export default function MessageThread({ )} {/* Attachment picker */} - setShowAttachMenu(false)} + onClose={() => setShowAttachMenu(false)} + sheetStyle={styles.attachSheet} > - - setShowAttachMenu(false)} - accessible={false} - /> - - - Attach - {ATTACH_OPTIONS.filter((o) => !o.dmOnly || isDM).map( - ({ action, icon, label, desc }, i) => ( - - {i > 0 && } - handleAttachAction(action)} - accessibilityRole="button" - accessibilityLabel={label} - > - - - - - {label} - {desc} - - - - ), - )} - - - - Files send over Bluetooth range only. Text and payments reach - internet contacts; attachments do not. - - - setShowAttachMenu(false)} - accessibilityRole="button" - > - Cancel - - + Attach + {ATTACH_OPTIONS.filter((o) => !o.dmOnly || isDM).map( + ({ action, icon, label, desc }, i) => ( + + {i > 0 && } + handleAttachAction(action)} + accessibilityRole="button" + accessibilityLabel={label} + > + + + + + {label} + {desc} + + + + ), + )} + + + + Files send over Bluetooth range only. Text and payments reach + internet contacts; attachments do not. + - + setShowAttachMenu(false)} + accessibilityRole="button" + > + Cancel + + {/* Send ecash: DM-only attach option, builds an offline Cashu token from the wallet and sends it straight to this peer. */} {isDM && ( - setShowSendEcash(false)} + onClose={() => setShowSendEcash(false)} + sheetStyle={styles.ecashSheet} > - + Send ecash + + Built offline from your wallet and sent as a token to {displayName}. + + + + setShowSendEcash(false)} - /> - - - Send ecash - - Built offline from your wallet and sent as a token to{" "} - {displayName}. - - - - - - Send - - { - setShowSendEcash(false); - setEcashAmount(""); - setEcashMemo(""); - }} - > - Cancel - - - + style={[ + styles.ecashConfirm, + !ecashAmount.trim() && styles.ecashConfirmDisabled, + ]} + onPress={() => void handleSendEcash()} + disabled={!ecashAmount.trim()} + > + Send + + { + setShowSendEcash(false); + setEcashAmount(""); + setEcashMemo(""); + }} + > + Cancel + - + )} {/* Channel info sheet: opens when user taps the header center */} @@ -2536,144 +2682,124 @@ export default function MessageThread({ {/* Attachment composer: review the picked media and add a caption before sending, the way WhatsApp/Signal do. The caption rides the file packet so media + caption land as one message. */} - - - - - {pendingAttachment?.type === "image" ? ( - - ) : ( - - - - {pendingAttachment?.name ?? - (pendingAttachment?.type === "video" - ? "Video" - : "Document")} - - - )} - - - - - - + ) : ( + + + + {pendingAttachment?.name ?? + (pendingAttachment?.type === "video" ? "Video" : "Document")} + + )} + + + + + - + {/* Channel sender profile sheet: tap a message's avatar/name. */} {!isDM && ( - setSenderInfoTarget(null)} + onClose={() => setSenderInfoTarget(null)} + sheetStyle={styles.dmInfoSheet} > {senderInfoTarget && ( - - setSenderInfoTarget(null)} - /> - - - {senderInfoTarget.fromMembers && ( - setSenderInfoTarget(null)} - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - accessibilityRole="button" - accessibilityLabel="Back to members" - > - - - )} - - + {senderInfoTarget.fromMembers && ( + setSenderInfoTarget(null)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + accessibilityRole="button" + accessibilityLabel="Back to members" + > + - - {resolveDisplayName(senderInfoTarget.peerID)} - - {isNostrId(senderInfoTarget.peerID) ? ( - - Nostr public key - - {senderInfoTarget.peerID.slice(NOSTR_ID_PREFIX.length)} - - - ) : ( - - {senderInfoTarget.peerID} + + )} + + + + {resolveDisplayName(senderInfoTarget.peerID)} + + {isNostrId(senderInfoTarget.peerID) ? ( + + Nostr public key + + {senderInfoTarget.peerID.slice(NOSTR_ID_PREFIX.length)} - )} - {onlinePeers.some( - (p) => p.peerID === senderInfoTarget.peerID, - ) && ( - - - In BLE range - - )} - - - - - Message - - + + ) : ( + + {senderInfoTarget.peerID} + + )} + {onlinePeers.some( + (p) => p.peerID === senderInfoTarget.peerID, + ) && ( + + + In BLE range + + )} - + + + + Message + + + )} - + )} {/* Screenshot privacy notice: shown right after a screenshot is taken, @@ -2713,7 +2839,7 @@ export default function MessageThread({ // forward sheet slides up: opening both at once reads as a glitch // rather than a handoff between two bottom sheets. const target = actionSheet; - if (target) setTimeout(() => setForwardSource(target), 250); + if (target) scheduleSheetHandoff(() => setForwardSource(target)); }} onCopy={() => actionSheet && @@ -2723,7 +2849,7 @@ export default function MessageThread({ // Same close-then-open handoff as Forward, so the two sheets don't // fight for the screen. const target = actionSheet; - if (target) setTimeout(() => setInfoMessageId(target.id), 250); + if (target) scheduleSheetHandoff(() => setInfoMessageId(target.id)); }} /> @@ -2744,7 +2870,7 @@ export default function MessageThread({ } }} /> - + ); } @@ -2812,12 +2938,34 @@ function createStyles(Colors: ReturnType) { gap: Spacing.sm, }, // Messages + listWrap: { + flex: 1, + }, list: { flexGrow: 1, paddingHorizontal: Spacing.base, paddingTop: Spacing.base, paddingBottom: Spacing.sm, }, + // Floats at the end of the list, clear of the compose bar below it. + jumpToLatest: { + position: "absolute", + right: Spacing.base, + bottom: Spacing.md, + width: 36, + height: 36, + borderRadius: Radius.full, + backgroundColor: Colors.surface, + borderWidth: StyleSheet.hairlineWidth, + borderColor: Colors.border, + alignItems: "center", + justifyContent: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.12, + shadowRadius: 6, + elevation: 4, + }, dateSeparator: { flexDirection: "row", alignItems: "center", @@ -2833,6 +2981,7 @@ function createStyles(Colors: ReturnType) { fontSize: FontSize.xs, color: Colors.textMuted, letterSpacing: 0.4, + textTransform: "uppercase", }, // System row (e.g. screenshot notices): centered, muted, no bubble. systemRow: { @@ -3008,28 +3157,11 @@ function createStyles(Colors: ReturnType) { borderColor: Colors.danger, }, // Attachment picker sheet - attachOverlay: { - flex: 1, - backgroundColor: Colors.overlay, - justifyContent: "flex-end", - }, attachSheet: { width: "100%", - backgroundColor: Colors.surface, - borderTopLeftRadius: Radius["2xl"], - borderTopRightRadius: Radius["2xl"], paddingHorizontal: Spacing.base, - paddingTop: Spacing.base, paddingBottom: Spacing["2xl"], }, - handle: { - width: 36, - height: 4, - borderRadius: 2, - backgroundColor: Colors.borderStrong, - alignSelf: "center", - marginBottom: Spacing.md, - }, attachSheetTitle: { fontSize: FontSize.md, fontWeight: FontWeight.semibold, @@ -3104,16 +3236,9 @@ function createStyles(Colors: ReturnType) { fontWeight: FontWeight.medium, }, // Send ecash modal (DM-only attach option) - ecashOverlay: { - flex: 1, - backgroundColor: Colors.overlay, - justifyContent: "flex-end", - }, ecashSheet: { - backgroundColor: Colors.surface, - borderTopLeftRadius: Radius["2xl"], - borderTopRightRadius: Radius["2xl"], - padding: Spacing.xl, + paddingHorizontal: Spacing.xl, + paddingBottom: Spacing.xl, gap: Spacing.base, }, ecashTitle: { @@ -3378,16 +3503,8 @@ function createStyles(Colors: ReturnType) { backgroundColor: Colors.textPrimary, }, // DM peer info sheet - dmInfoOverlay: { - flex: 1, - backgroundColor: Colors.overlay, - justifyContent: "flex-end", - }, dmInfoSheet: { width: "100%", - backgroundColor: Colors.surface, - borderTopLeftRadius: Radius["2xl"], - borderTopRightRadius: Radius["2xl"], paddingBottom: Spacing["2xl"], }, dmInfoBack: { @@ -3467,17 +3584,9 @@ function createStyles(Colors: ReturnType) { borderColor: Colors.bg, }, // Attachment composer (caption before send). - composerOverlay: { - flex: 1, - justifyContent: "flex-end", - backgroundColor: "rgba(0,0,0,0.85)", - }, composerSheet: { backgroundColor: Colors.bg, - borderTopLeftRadius: Radius["2xl"], - borderTopRightRadius: Radius["2xl"], paddingHorizontal: Spacing.base, - paddingTop: Spacing.base, paddingBottom: Spacing.xl, gap: Spacing.md, }, @@ -3597,6 +3706,21 @@ function createStyles(Colors: ReturnType) { paddingVertical: Spacing.xs, alignSelf: "flex-start", }, + paymentCardClaimBusy: { + opacity: 0.5, + }, + paymentCardClaimed: { + flexDirection: "row", + alignItems: "center", + gap: Spacing.xs, + alignSelf: "flex-start", + marginTop: Spacing.xs, + }, + paymentCardClaimedText: { + fontSize: FontSize.xs, + color: Colors.online, + fontWeight: FontWeight.semibold, + }, paymentCardClaimText: { fontSize: FontSize.xs, fontWeight: FontWeight.semibold, diff --git a/src/features/discovery/peer-list.tsx b/src/features/discovery/peer-list.tsx index aa53fba..1e46a56 100644 --- a/src/features/discovery/peer-list.tsx +++ b/src/features/discovery/peer-list.tsx @@ -9,25 +9,20 @@ import { Feather } from "@expo/vector-icons"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { FlatList, - Modal, Pressable, StyleSheet, Text, TextInput, View, } from "react-native"; -import { - buildOfflineToken, - selectProofsForAmount, -} from "../../core/payments/cashu"; -import { getMeshService } from "../../services/mesh-service"; +import { describeRoute, sendEcashToPeer } from "../../services/ecash-transfer"; import { showAlert } from "../../store/alert-store"; import { useBlockedStore } from "../../store/blocked-store"; import { useChatStore } from "../../store/chat-store"; import { useContactsStore } from "../../store/contacts-store"; import { usePeerStore, type NearbyPeer } from "../../store/peer-store"; -import { useWalletStore } from "../../store/wallet-store"; import Avatar from "../../ui/components/avatar"; +import BottomSheet from "../../ui/components/bottom-sheet"; import StatusDot from "../../ui/components/status-dot"; import { FontFamily, @@ -130,73 +125,31 @@ export default function PeerList({ setSendSatsAmount(""); } - function handleSendSats(peer: NearbyPeer): void { + // Ecash hand-off to a peer standing next to you. All of the proof handling + // (selection, fees, reserving so an undelivered token can be reclaimed) lives + // in the shared transfer service; this only supplies who and how much. + async function handleSendSats(peer: NearbyPeer): Promise { const amount = parseInt(sendSatsAmount, 10); if (!amount || amount <= 0) return; - const { proofsByMint, unit, removeProofs } = useWalletStore.getState(); - const { addMessage } = useChatStore.getState(); - const service = getMeshService(); - - if (!service) { - showAlert("Mesh offline", "Mesh service is not running."); - return; - } - - const totalBalance = Object.values(proofsByMint).reduce( - (s, ps) => s + ps.reduce((a, p) => a + p.amount, 0), - 0, - ); - if (amount > totalBalance) { - showAlert( - "Insufficient balance", - `You have ${totalBalance.toLocaleString()} sats but tried to send ${amount.toLocaleString()}.`, - ); - return; - } - - const mintEntry = Object.entries(proofsByMint) - .map(([url, ps]) => ({ - url, - ps, - balance: ps.reduce((s, p) => s + p.amount, 0), - })) - .find((m) => m.balance >= amount); - - if (!mintEntry) { - showAlert( - "Balance split across mints", - "No single mint holds the full amount. Use the Wallet tab to consolidate.", - ); - return; - } - - const selection = selectProofsForAmount(mintEntry.ps, amount); - if (!selection) return; - - const tokenStr = buildOfflineToken(mintEntry.url, selection.selected, unit); - removeProofs( - mintEntry.url, - selection.selected.map((p) => p.secret), - ); - - const channel = `dm:${peer.peerID}`; - addChannel(channel); - addMessage({ - id: `wallet-sats-${peer.peerID}-${Date.now()}`, - channel, - senderID: "local", - senderNickname: "You", - text: tokenStr, - timestampMs: Date.now(), - isMine: true, + const result = await sendEcashToPeer({ + peerID: peer.peerID, + amount, }); - service.sendDm(peer.peerID, tokenStr); + if (!result) return; setSendSatsAmount(""); setShowSendSats(false); closeSheet(); - onOpenDM?.(channel); + onOpenDM?.(`dm:${peer.peerID}`); + // The thread we just opened shows the bubble, so only speak up when the + // token did not go straight out. + if (result.route !== "sent") { + showAlert( + `${result.prepared.amount.toLocaleString()} ${result.prepared.unit} on its way`, + `${describeRoute(result.route)} It stays reclaimable from the Wallet tab until you confirm it arrived, so nothing is lost if it never lands.`, + ); + } } function handleQRScanned(peerID: string): void { @@ -281,126 +234,111 @@ export default function PeerList({ )} {/* Peer detail sheet */} - {selectedPeer && ( - - - - {/* Drag handle */} - - - {/* Identity */} - - + {/* Identity */} + + + + {resolveDisplayName(selectedPeer.peerID)} + + {selectedPeer.peerID} + + - - {resolveDisplayName(selectedPeer.peerID)} + + {isOnline(selectedPeer) + ? "In range" + : `Last seen ${formatLastSeen(selectedPeer.lastSeenMs)} ago`} - {selectedPeer.peerID} - - - - {isOnline(selectedPeer) - ? "In range" - : `Last seen ${formatLastSeen(selectedPeer.lastSeenMs)} ago`} - - + - {/* Message + Send sats: a tight pair of actions, not spread + {/* Message + Send sats: a tight pair of actions, not spread apart by the sheet's larger identity/actions rhythm. */} - + + handleSendDM(selectedPeer)} + accessibilityRole="button" + accessibilityLabel="Send a direct message" + > + + Message + + + {!showSendSats ? ( handleSendDM(selectedPeer)} + style={styles.sheetSatsBtn} + onPress={() => setShowSendSats(true)} accessibilityRole="button" - accessibilityLabel="Send a direct message" + accessibilityLabel="Send sats" > - - Message + + Send sats - - {!showSendSats ? ( + ) : ( + + void handleSendSats(selectedPeer)} + /> setShowSendSats(true)} + style={[ + styles.sendSatsConfirm, + !sendSatsAmount.trim() && { opacity: 0.4 }, + ]} + onPress={() => void handleSendSats(selectedPeer)} + disabled={!sendSatsAmount.trim()} accessibilityRole="button" - accessibilityLabel="Send sats" + accessibilityLabel="Confirm send sats" > - Send sats - ) : ( - - handleSendSats(selectedPeer)} - /> - handleSendSats(selectedPeer)} - disabled={!sendSatsAmount.trim()} - accessibilityRole="button" - accessibilityLabel="Confirm send sats" - > - - - { - setShowSendSats(false); - setSendSatsAmount(""); - }} - accessibilityRole="button" - accessibilityLabel="Cancel send sats" - > - - - - )} - + { + setShowSendSats(false); + setSendSatsAmount(""); + }} + accessibilityRole="button" + accessibilityLabel="Cancel send sats" + > + + + + )} - + )} - + {/* QR scanner */} ) { lineHeight: FontSize.sm * 1.6, }, // Peer detail sheet - sheetOverlay: { - flex: 1, - backgroundColor: Colors.overlay, - justifyContent: "flex-end", - }, sheet: { - backgroundColor: Colors.surface, - borderTopLeftRadius: Radius["2xl"], - borderTopRightRadius: Radius["2xl"], - padding: Spacing.xl, + paddingHorizontal: Spacing.xl, + paddingBottom: Spacing.xl, gap: Spacing.xl, }, - handle: { - width: 36, - height: 4, - borderRadius: 2, - backgroundColor: Colors.borderStrong, - alignSelf: "center", - marginBottom: Spacing.xs, - }, sheetIdentity: { alignItems: "center", gap: Spacing.sm, diff --git a/src/features/settings/sections/privacy-screen.tsx b/src/features/settings/sections/privacy-screen.tsx index d351c1e..e5486b9 100644 --- a/src/features/settings/sections/privacy-screen.tsx +++ b/src/features/settings/sections/privacy-screen.tsx @@ -32,14 +32,14 @@ const SECTIONS: LegalSection[] = [ { bullets: [ "**Identity keys.** An Ed25519 signing key and a Noise static key are generated locally on first launch and stored in your device's secure storage (iOS Keychain or Android Keystore). A Nostr key, a separate identity for each location cell you use, and one-time prekeys are all derived from that signing key rather than stored separately. Your public keys are shared with peers you communicate with. **Private keys never leave your device.**", - "**Nickname and preferences.** Your chosen display name and app settings are stored locally.", + "**Display name and preferences.** Your generated display name and app settings are stored locally.", "**Message history.** Conversations are stored locally on your device and are never sent to us. They are protected by the operating system's app sandbox and whole-device encryption, not by a separate app-level cipher, so a person with access to an unlocked device can read them. Delete a conversation at any time, or wipe everything instantly with panic wipe.", "**Private group state.** Group names, member lists, and the current group key are stored locally so you can keep reading the group. They are removed by panic wipe or by removing the app.", "**Bulletin board notices.** Signed public notices, and the deletion markers that retract them, persist until the author's chosen expiry, at most seven days. These are public to the mesh or area they were posted to, not private messages.", "**Media attachments.** Photos, videos, and voice notes you send or receive are written to the app's cache so they stay viewable. They are deleted by panic wipe, by clearing the cache in settings, or by removing the app.", "**Queued outgoing messages.** A private message that has not yet been delivered may remain in an encrypted local queue. It is **dropped after 24 hours** if unacknowledged.", "**Courier envelopes.** If your device acts as a mesh courier for another user, it may hold an opaque end-to-end encrypted envelope for up to 24 hours. **The courier cannot read the contents.**", - "**Cashu tokens.** Ecash tokens are stored locally and transferred directly between devices. No payment backend is involved.", + "**Ecash wallet.** Cashu tokens are bearer instruments, so they are kept in a separate file encrypted with AES-256 under a key held in your device's secure storage. The same file holds the mints you added, their public keys, and your transaction history (amounts, timestamps, and the mint involved). If a recovery phrase is set up, the twelve words live in secure storage alongside your identity keys, never in the wallet file. **No payment backend is involved and none of this is transmitted to us.**", ], }, ], @@ -50,7 +50,7 @@ const SECTIONS: LegalSection[] = [ "When the app is running, nearby mesh devices can receive:", { bullets: [ - "Your chosen nickname and public identity keys.", + "Your display name, which the app generates from your public key, and your public identity keys.", "Messages you send to public channels or directly to another peer.", "Public notices you post to the bulletin board, which stay readable until they expire.", "A batch of single-use public keys, so someone can leave you a protected message while you are offline. These contain no private information.", @@ -90,6 +90,22 @@ const SECTIONS: LegalSection[] = [ }, ], }, + { + heading: "Ecash payments (optional)", + paragraphs: [ + "Payments are off until you add a mint. Sending and receiving ecash over Bluetooth involves no server, no relay, and no mint: the two devices do it themselves, and nothing about the payment leaves them.", + "Talking to a mint is different, and only happens when you deposit, withdraw, refresh, or claim a token while online.", + { + bullets: [ + "**What a mint can see.** Your IP address, the amounts you deposit and withdraw, and when. Mints are third parties whose retention and privacy practices are outside this project's control.", + "**What a mint cannot see.** Who you are, who you paid, or which coins you deposited became which coins you spent. Cashu signs tokens blindly, so that link is severed by the maths rather than by policy.", + "**Tor.** On Android, Orbot covers mint traffic along with everything else. On iOS, Tor only wraps Nostr connections, so **mint requests are blocked while Tor is on** unless you opt in under Privacy & Security. Mesh payments are unaffected either way.", + "**Nutzaps are public.** A NIP-61 nutzap is an unencrypted Nostr event. The ecash is locked to the recipient so nobody else can spend it, but relays and observers can see that one public key paid another, and the amount. The encrypted-message fallback does not have this property.", + "**Recovery phrase.** Optional and off by default. It is stored only in your device's secure storage, is never transmitted, and is never shown to a mint. Anyone who obtains it can spend your balance.", + ], + }, + ], + }, { heading: "Internet gateway (optional)", paragraphs: [ @@ -112,6 +128,7 @@ const SECTIONS: LegalSection[] = [ "**Private groups.** Group messages use ChaCha20-Poly1305 under a shared group key. The member list is signed by the group's creator with Ed25519.", "**Public notices.** Bulletin-board posts are Ed25519-signed so their author cannot be forged. They are deliberately public, not confidential.", "**Nostr events.** secp256k1 Schnorr signatures, with private messages sealed using key agreement, HKDF-SHA256, and XChaCha20-Poly1305.", + "**Ecash.** Cashu blind signatures, which stop a mint linking issuance to redemption, plus DLEQ proofs that let your device verify a token was genuinely signed by its mint with no network connection.", "**Implementation.** All cryptographic operations use the [@noble](https://github.com/paulmillr/noble-curves) library suite, which has been independently audited by Cure53.", ], }, @@ -127,6 +144,7 @@ const SECTIONS: LegalSection[] = [ "**Courier envelopes carried for others:** until handed over, or 24 hours.", "**Public bulletin-board notices:** until the author's chosen expiry, at most seven days.", "**Conversations, groups, contacts, keys, and media:** until you delete them, run a panic wipe, or remove the app.", + "**Wallet transaction history:** the most recent 500 entries, until you run a panic wipe or remove the app.", "**Anything sent to a Nostr relay:** according to that relay operator's own policy, which is outside our control.", ], }, @@ -139,6 +157,7 @@ const SECTIONS: LegalSection[] = [ bullets: [ "**Panic wipe.** Instantly erase all local keys, messages, queued mail, and app data from the Profile screen.", "**Feature controls.** The Nostr bridge, Tor routing, location channels, and the internet gateway can each be disabled in settings. Anything already published to a relay cannot be recalled.", + "**Wallet.** Remove a mint at any time from the Wallet tab. Removing one deletes the coins held there from this device, so withdraw or send them first. A panic wipe destroys the wallet file and its encryption key together.", "**System permissions.** Bluetooth, location, microphone, camera, photo library, and notification access can each be revoked in your device settings at any time. Camera access is used only to scan a contact's QR code.", ], }, @@ -168,7 +187,7 @@ export default function PrivacyScreen({ onBack }: Props): React.JSX.Element { return ( diff --git a/src/features/settings/sections/security-screen.tsx b/src/features/settings/sections/security-screen.tsx index 4855dc1..0e16b81 100644 --- a/src/features/settings/sections/security-screen.tsx +++ b/src/features/settings/sections/security-screen.tsx @@ -5,11 +5,9 @@ import Feather from "@expo/vector-icons/Feather"; import React, { useState } from "react"; import { Linking, - Modal, Platform, Pressable, ScrollView, - StyleSheet, Text, View, } from "react-native"; @@ -17,6 +15,7 @@ import { setTorRouting } from "../../../core/nostr/tor-routing"; import { showAlert } from "../../../store/alert-store"; import { useBlockedStore } from "../../../store/blocked-store"; import { useSettingsStore } from "../../../store/settings-store"; +import BottomSheet from "../../../ui/components/bottom-sheet"; import { useThemeColors } from "../../../ui/theme"; import { resolveDisplayName } from "../../../utils/display-name"; import { @@ -40,6 +39,12 @@ export default function SecurityScreen({ onBack }: Props): React.JSX.Element { const torEnabled = useSettingsStore((s) => s.torEnabled); const gatewayEnabled = useSettingsStore((s) => s.gatewayEnabled); const setGatewayEnabled = useSettingsStore((s) => s.setGatewayEnabled); + const allowMintOverClearnet = useSettingsStore( + (s) => s.allowMintOverClearnet, + ); + const setAllowMintOverClearnet = useSettingsStore( + (s) => s.setAllowMintOverClearnet, + ); const [torStarting, setTorStarting] = useState(false); const [showOrbotModal, setShowOrbotModal] = useState(false); // Subscribe to the array itself (not the isBlocked function, whose identity @@ -132,6 +137,29 @@ export default function SecurityScreen({ onBack }: Props): React.JSX.Element { /> } /> + {/* Only meaningful on iOS. Arti is a per-socket SOCKS shim that we + wire into the Nostr WebSocket, so a Cashu mint request (plain + fetch) would bypass Tor entirely and hand the mint this device's + IP alongside the proofs being swapped. Rather than leak + silently, mint calls are refused while Tor is on unless the user + opts in here. Android needs no such switch: Orbot's VPN captures + every socket, so mint traffic is already covered. */} + {Platform.OS === "ios" && torEnabled && ( + <> + + + } + /> + + )} {/* Orbot modal: bottom sheet shown when enabling Tor on Android */} - setShowOrbotModal(false)} + onClose={() => setShowOrbotModal(false)} + sheetStyle={styles.sheet} > - + + + + Tor on Android + + Airhop routes Tor traffic through Orbot. Install and enable Orbot from + the Play Store, then turn this on. + + + + Get Orbot + setShowOrbotModal(false)} - /> - - - - - - Tor on Android - - Airhop routes Tor traffic through Orbot. Install and enable Orbot - from the Play Store, then turn this on. - - - - Get Orbot - - setShowOrbotModal(false)} - accessibilityRole="button" - accessibilityLabel="Later" - > - Later - - - + accessibilityRole="button" + accessibilityLabel="Later" + > + Later + - + ); } diff --git a/src/features/settings/sections/storage-screen.tsx b/src/features/settings/sections/storage-screen.tsx index b285558..660623b 100644 --- a/src/features/settings/sections/storage-screen.tsx +++ b/src/features/settings/sections/storage-screen.tsx @@ -14,14 +14,7 @@ import Feather from "@expo/vector-icons/Feather"; import React, { useCallback, useMemo, useState } from "react"; -import { - Modal, - Pressable, - ScrollView, - StyleSheet, - Text, - View, -} from "react-native"; +import { Pressable, ScrollView, Text, View } from "react-native"; import { createMMKV } from "react-native-mmkv"; import { clearAttachmentCache, @@ -32,6 +25,8 @@ import { useSettingsStore, type UploadQuality, } from "../../../store/settings-store"; +import { WALLET_STORAGE_ID } from "../../../store/wallet-store"; +import BottomSheet from "../../../ui/components/bottom-sheet"; import { useThemeColors } from "../../../ui/theme"; import { MMKV_STORE_IDS } from "../../../utils/panic-wipe"; import { @@ -64,7 +59,11 @@ const QUALITY_META: Record< const QUALITY_ORDER: UploadQuality[] = ["low", "medium", "high"]; function readStorageStats() { - const messagesBytes = MMKV_STORE_IDS.reduce( + // The wallet store is not in MMKV_STORE_IDS (the panic wipe deletes its file + // rather than clearing it, since it is encrypted), so it is measured + // separately. `byteSize` reads the file length and needs no decryption key, + // which is why opening it without one is fine here. + const messagesBytes = [...MMKV_STORE_IDS, WALLET_STORAGE_ID].reduce( (sum, id) => sum + createMMKV({ id }).byteSize, 0, ); @@ -168,75 +167,62 @@ export default function StorageScreen({ onBack }: Props): React.JSX.Element { {/* Upload quality modal */} - setShowQualityModal(false)} + onClose={() => setShowQualityModal(false)} + sheetStyle={styles.sheet} > - - setShowQualityModal(false)} - /> - - - Upload quality - - Applies to photos sent from your camera or library. - - - {QUALITY_ORDER.map((key) => { - const meta = QUALITY_META[key]; - const selected = key === uploadQuality; - return ( - Upload quality + + Applies to photos sent from your camera or library. + + + {QUALITY_ORDER.map((key) => { + const meta = QUALITY_META[key]; + const selected = key === uploadQuality; + return ( + { + setUploadQuality(key); + setShowQualityModal(false); + }} + accessibilityRole="button" + accessibilityLabel={`Set upload quality to ${meta.label}`} + > + + { - setUploadQuality(key); - setShowQualityModal(false); - }} - accessibilityRole="button" - accessibilityLabel={`Set upload quality to ${meta.label}`} > - - - - - - {meta.label} - - {meta.description} - - - {selected && ( - - )} - - - ); - })} - - + + + + {meta.label} + + {meta.description} + + + {selected && ( + + )} + + + ); + })} - + ); } diff --git a/src/features/settings/sections/terms-screen.tsx b/src/features/settings/sections/terms-screen.tsx index 35a882a..f3881a2 100644 --- a/src/features/settings/sections/terms-screen.tsx +++ b/src/features/settings/sections/terms-screen.tsx @@ -30,9 +30,14 @@ const SECTIONS: LegalSection[] = [ ], }, { - heading: "Offline payments", + heading: "Payments", paragraphs: [ - "Airhop supports transferring Cashu ecash tokens directly between devices over the mesh. **We do not operate any payment infrastructure. We are not a financial institution, payment processor, or money services business.** Token transfers occur between devices without any involvement from this project. We have no ability to reverse, recover, or mediate any transaction.", + "Airhop includes an optional Cashu ecash wallet. **We do not operate any payment infrastructure. We are not a financial institution, payment processor, money services business, or custodian of your funds.** We have no ability to reverse, recover, freeze, or mediate any transaction.", + "**Mints are third parties you choose.** A mint is an independent server that issues and redeems ecash and holds the bitcoin backing it. Airhop ships with no default mint and does not endorse, vet, or monitor any of them. Adding a mint means trusting that operator with whatever balance you keep there. A mint may go offline, refuse service, change its fees, or fail to honour its tokens, and any loss that follows is between you and that operator.", + "**Ecash is a bearer instrument.** Whoever holds a token can spend it. A token sent to the wrong person, posted to a public channel, or read by someone over your shoulder is gone. Transfers over the mesh are final and cannot be reversed by anyone.", + "**Recovery is your responsibility.** The optional recovery phrase is the only way to rebuild a balance on another device. It is stored on your device and nowhere else. We cannot recover it, reset it, or help you if it is lost, and anyone who obtains it can spend your balance.", + "**Lightning deposits and withdrawals** are performed by your chosen mint and the wider Lightning Network, not by us. Routing fees, failed payments, and settlement delays are outside our control.", + "You are responsible for complying with any tax, reporting, or financial regulations that apply to you.", ], }, { @@ -72,7 +77,7 @@ export default function TermsScreen({ onBack }: Props): React.JSX.Element { return ( diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx index 07b5e85..38bde75 100644 --- a/src/features/wallet/wallet-screen.tsx +++ b/src/features/wallet/wallet-screen.tsx @@ -1,13 +1,35 @@ -// Wallet screen: Cashu ecash balance and proof management. -// Shows total balance, per-mint breakdown, and quick send/receive actions. -// All proofs are stored locally in MMKV; no server or account required. +// Wallet screen: Cashu ecash balance, mints, transfers and history. +// +// Every operation goes through `services/wallet-service`, which owns proof +// selection, reservations and mint calls. This file is presentation: it decides +// what to ask, what to show, and how to describe what happened. It deliberately +// does no proof arithmetic of its own, because the same logic also runs from +// the DM thread and the peer sheet and the three must not drift. +// +// Three ideas drive the layout: +// +// * Money that is not fully yours yet is shown as such. Proofs received over +// the mesh are real value, but the mint has not confirmed they are unspent, +// so they get an "unconfirmed" line rather than being folded silently into +// the headline number. +// * A send in flight is a first-class object. Building a token reserves the +// proofs; until the user says it landed, the token stays here to re-share +// or reclaim. Closing a sheet can no longer destroy value. +// * Anything that needs the internet says so before it is tapped, and says +// why when it cannot run (offline, Tor, or a mint that lacks the NUT). -import { Mint, Wallet } from "@cashu/cashu-ts"; import { Feather } from "@expo/vector-icons"; +import * as Clipboard from "expo-clipboard"; import { nip19 } from "nostr-tools"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { - Modal, + ActivityIndicator, Pressable, ScrollView, Share, @@ -17,21 +39,53 @@ import { View, } from "react-native"; import { - buildOfflineToken, - decodeToken, - selectProofsForAmount, -} from "../../core/payments/cashu"; -import { fetchWalletInfo, publishNutzap } from "../../core/payments/nutzap"; + isValidRecoveryPhrase, + pickVerificationPositions, + unknownWordsIn, + verifyPositions, +} from "../../core/payments/wallet-seed"; +import { + deliverTokenToPeer, + describeRoute, +} from "../../services/ecash-transfer"; import { getMeshService } from "../../services/mesh-service"; -import { showAlert } from "../../store/alert-store"; -import { useChatStore } from "../../store/chat-store"; +import { + addMint as addMintService, + claimLightningDeposit, + confirmSend, + consolidateMints, + createLightningDeposit, + enableWalletBackup, + getRecoveryPhrase, + hostOf, + isMintNetworkBlocked, + markBackupVerified, + payLightningInvoice, + prepareSend, + quoteLightningWithdrawal, + quoteSend, + receiveToken, + reclaimSend, + refreshAccount, + restoreFromRecoveryPhrase, + sendNutzap, + WalletError, + type LightningDeposit, + type MeltQuote, + type PreparedSend, + type RestoreResult, +} from "../../services/wallet-service"; +import { showAlert, useAlertStore } from "../../store/alert-store"; import { usePeerStore } from "../../store/peer-store"; import { + isWalletStorageReady, + selectAccounts, useWalletStore, - type MintBalance, - type StoredProof, + type AccountBalance, + type WalletTx, } from "../../store/wallet-store"; import Avatar from "../../ui/components/avatar"; +import BottomSheet from "../../ui/components/bottom-sheet"; import { FontFamily, FontSize, @@ -43,15 +97,16 @@ import { } from "../../ui/theme"; import { peerIDToUsername } from "../../utils/username"; -// The four quick actions, triggered from icon buttons in the App-level -// header (styled to match the Mesh tab's "add contact" pill) rather than -// from a row inside the balance card. +// The four quick actions triggered from the App-level header. export type WalletAction = "receive" | "send" | "zap" | "addMint"; +// How often to poll a pending Lightning deposit while its sheet is open. +const DEPOSIT_POLL_MS = 3000; + +// A peer counts as reachable for a hand-off if it was heard from this recently. +const PEER_ONLINE_WINDOW_MS = 60_000; + interface Props { - // Set together (action + an incrementing counter) when a header icon is - // tapped, so the right modal opens here, the same fire-once-per-increment - // pattern used for ChannelList's join modal / PeerList's scanner. action?: WalletAction | null; actionTrigger?: number; } @@ -62,9 +117,32 @@ export default function WalletScreen({ }: Props): React.JSX.Element { const Colors = useThemeColors(); const styles = useMemo(() => createStyles(Colors), [Colors]); - const { proofsByMint, unit, addMint, addProofs, removeProofs, clearMint } = - useWalletStore(); - // Subscribe to the stable peer map and derive the reachable list locally. + + // Narrow subscriptions: the whole store changes on every history write, and + // this screen re-renders a list of peers on a timer as it is. + const proofs = useWalletStore((s) => s.proofs); + const mints = useWalletStore((s) => s.mints); + const reserved = useWalletStore((s) => s.reserved); + const history = useWalletStore((s) => s.history); + const backupEnabled = useWalletStore((s) => s.backupEnabled); + const backupVerified = useWalletStore((s) => s.backupVerified); + + const accounts = useMemo( + () => selectAccounts({ proofs, mints, reserved, backupEnabled }), + [proofs, mints, reserved, backupEnabled], + ); + + const [locked, setLocked] = useState(() => !isWalletStorageReady()); + useEffect(() => { + // Storage opens asynchronously at app start; re-check until it lands so the + // locked banner clears itself rather than needing a tab switch. + if (!locked) return; + const timer = setInterval(() => { + if (isWalletStorageReady()) setLocked(false); + }, 500); + return () => clearInterval(timer); + }, [locked]); + const peers = usePeerStore((s) => s.peers); const [peerClock, setPeerClock] = useState(() => Date.now()); useEffect(() => { @@ -72,13 +150,43 @@ export default function WalletScreen({ return () => clearInterval(timer); }, []); const onlinePeers = useMemo(() => { - const cutoff = peerClock - 60_000; + const cutoff = peerClock - PEER_ONLINE_WINDOW_MS; return [...peers.values()].filter((peer) => peer.lastSeenMs >= cutoff); }, [peerClock, peers]); + + // ---- Sheet state ---- const [showReceive, setShowReceive] = useState(false); const [showSend, setShowSend] = useState(false); const [showZap, setShowZap] = useState(false); const [showAddMint, setShowAddMint] = useState(false); + const [showDeposit, setShowDeposit] = useState(false); + const [showWithdraw, setShowWithdraw] = useState(false); + const [showPeerPicker, setShowPeerPicker] = useState(false); + const [showConsolidate, setShowConsolidate] = useState(false); + const [showRestore, setShowRestore] = useState(false); + + // Recovery-phrase sheet. One sheet, three steps, because they have to happen + // in order: understand the risk, read the words, prove you wrote them down. + // "view" is the read-only variant shown once backup is already on. + const [backupStep, setBackupStep] = useState< + "warn" | "show" | "verify" | "view" | null + >(null); + const [phrase, setPhrase] = useState(""); + const [verifyPositionList, setVerifyPositionList] = useState([]); + const [verifyAnswers, setVerifyAnswers] = useState>( + {}, + ); + const [verifyError, setVerifyError] = useState(false); + + const [restoreInput, setRestoreInput] = useState(""); + const [restoreResult, setRestoreResult] = useState( + null, + ); + const [restoreProgress, setRestoreProgress] = useState(null); + const [consolidateTarget, setConsolidateTarget] = useState( + null, + ); + const [tokenInput, setTokenInput] = useState(""); const [sendAmount, setSendAmount] = useState(""); const [sendMemo, setSendMemo] = useState(""); @@ -86,18 +194,25 @@ export default function WalletScreen({ const [zapAmount, setZapAmount] = useState(""); const [zapNote, setZapNote] = useState(""); const [mintUrlInput, setMintUrlInput] = useState(""); - // Generated token: shown after offline send completes. - const [generatedToken, setGeneratedToken] = useState<{ - token: string; - amount: number; - mintUrl: string; - } | null>(null); - const [showGenerated, setShowGenerated] = useState(false); - // Peer picker: shown when user taps "Send to peer" on a generated token. - const [showPeerPicker, setShowPeerPicker] = useState(false); - // Zap status. - const [isZapping, setIsZapping] = useState(false); + const [depositAmount, setDepositAmount] = useState(""); + // Which mint the Lightning sheets act on. Both deposit and withdraw work + // against a single mint at a time, since ecash cannot be pooled across them. + const [activeMint, setActiveMint] = useState(null); + const [withdrawInvoice, setWithdrawInvoice] = useState(""); + const [withdrawQuote, setWithdrawQuote] = useState(null); + // The token produced by the most recent send, still reserved and reclaimable. + const [pending, setPending] = useState(null); + const [deposit, setDeposit] = useState(null); + + // One busy flag per long-running action, so a spinner sits on the button that + // caused it instead of blocking the whole screen. + const [busy, setBusy] = useState(null); + const [refreshingMint, setRefreshingMint] = useState(null); + + const networkBlocked = isMintNetworkBlocked(); + + // ---- Header action handoff ---- const prevActionTrigger = useRef(actionTrigger ?? 0); useEffect(() => { if ( @@ -107,10 +222,8 @@ export default function WalletScreen({ return; } prevActionTrigger.current = actionTrigger; - // Opening a sheet in response to a one-shot command from the parent (a - // header button press arriving as an incrementing counter). The guard above - // makes this fire at most once per press, so it cannot cascade. This is an - // imperative event handoff, not derived state. + // Imperative one-shot handoff from a header button press, guarded above so + // it fires at most once per press. // eslint-disable-next-line react-hooks/set-state-in-effect if (action === "receive") setShowReceive(true); else if (action === "send") setShowSend(true); @@ -118,1145 +231,2764 @@ export default function WalletScreen({ else if (action === "addMint") setShowAddMint(true); }, [action, actionTrigger]); - const mintBalances = useMemo(() => { - return Object.entries(proofsByMint).map(([mintUrl, proofs]) => ({ - mintUrl, - unit, - balance: proofs.reduce((sum, p) => sum + p.amount, 0), - proofCount: proofs.length, - })); - }, [proofsByMint, unit]); - - const totalSats = mintBalances.reduce((sum, m) => sum + m.balance, 0); + // ---- Derived balances ---- - function shortenMintUrl(url: string): string { - try { - const u = new URL(url); - return u.hostname; - } catch { - return url.slice(0, 24) + "\u2026"; + // Units are separate currencies and are never summed. Sats lead because it is + // the only unit Airhop mints into; anything else appears as its own row. + const unitTotals = useMemo(() => { + const totals = new Map< + string, + { balance: number; unverified: number; reserved: number } + >(); + for (const account of accounts) { + const current = totals.get(account.unit) ?? { + balance: 0, + unverified: 0, + reserved: 0, + }; + current.balance += account.balance; + current.unverified += account.unverified; + current.reserved += account.reserved; + totals.set(account.unit, current); } - } + return [...totals.entries()] + .map(([unit, v]) => ({ unit, ...v })) + .sort((a, b) => (a.unit === "sat" ? -1 : b.unit === "sat" ? 1 : 0)); + }, [accounts]); - function handleReceive(): void { - const raw = tokenInput.trim(); - if (!raw) return; + const primary = unitTotals.find((u) => u.unit === "sat") ?? + unitTotals[0] ?? { unit: "sat", balance: 0, unverified: 0, reserved: 0 }; - // Decode the token using the cashu core module (offline, no network call). - const info = decodeToken(raw); - if (!info) { - showAlert( - "Invalid token", - "Could not decode this token. Check that it starts with cashuA or cashuB.", - ); - return; - } + // Sends whose proofs are still held: the token exists, delivery is unproven. + const pendingSends = useMemo( + () => + history.filter( + (tx) => + tx.kind === "send" && + tx.status === "pending" && + reserved[tx.id] !== undefined, + ), + [history, reserved], + ); - // Convert cashu-ts Proof objects to our StoredProof schema. - const stored: StoredProof[] = info.token.proofs.map((p) => ({ - id: p.id, - amount: p.amount.toNumber(), - secret: p.secret, - C: p.C, - dleq: p.dleq as StoredProof["dleq"], - })); - - // Register the mint and store proofs (offline, no mint network call). - // Full redemption (proof swap) must happen when the user has internet access. - addMint(info.mintUrl); - addProofs(info.mintUrl, stored); - - setShowReceive(false); - setTokenInput(""); - showAlert( - `+${info.amount.toLocaleString()} ${info.unit}`, - `Proofs stored from ${info.mintUrl.replace(/https?:\/\//, "")}.` + - (info.memo ? `\n\n"${info.memo}"` : "") + - "\n\nRedemption at the mint is required to confirm they are unspent.", - ); - } + const pendingDeposits = useMemo( + () => history.filter((tx) => tx.kind === "mint" && tx.status === "pending"), + [history], + ); - function handleSend(): void { - const amount = parseInt(sendAmount, 10); - if (!amount || amount <= 0) return; + const recent = useMemo(() => history.slice(0, 12), [history]); + + const mintList = useMemo(() => Object.values(mints), [mints]); - if (amount > totalSats) { + // How much of the primary unit the recovery phrase could actually rebuild. + // Coins received from other people carry their secrets, so they sit outside + // the phrase until a swap re-issues them under ours. + const coverage = useMemo(() => { + const forUnit = accounts.filter((a) => a.unit === primary.unit); + const unbacked = forUnit.reduce((sum, a) => sum + a.unbacked, 0); + const total = forUnit.reduce((sum, a) => sum + a.balance, 0); + return { covered: total - unbacked, unbacked }; + }, [accounts, primary.unit]); + + // Mints holding spendable value in the primary unit. Two or more means the + // balance cannot pay any amount larger than the biggest single mint holds. + const splitAccounts = useMemo( + () => accounts.filter((a) => a.unit === primary.unit && a.balance > 0), + [accounts, primary.unit], + ); + + // ---- Error surface ---- + + // One place that turns a WalletError into something a person can act on. The + // service already carries the "why" in `detail`; this only decides the title + // and whether there is a follow-up action worth offering. + const reportError = useCallback((err: unknown, fallbackTitle: string) => { + if (err instanceof WalletError) { + const titles: Record = { + locked: "Wallet locked", + offline: "Mint unreachable", + "tor-blocked": "Blocked while Tor is on", + insufficient: "Not enough balance", + inexact: "Can't send that exact amount", + "no-mint": "No mint", + unsupported: "Mint can't do that", + "mint-error": "Mint refused", + "invalid-token": "Unreadable token", + "forged-token": "Token rejected", + "already-spent": "Already spent", + }; showAlert( - "Insufficient balance", - `You have ${totalSats.toLocaleString()} sats but tried to send ${amount.toLocaleString()} sats.`, + titles[err.code] ?? fallbackTitle, + err.detail ? `${err.message}\n\n${err.detail}` : err.message, ); return; } + showAlert(fallbackTitle, String(err)); + }, []); - // Find the first mint that can cover the full amount. - const mintEntry = Object.entries(proofsByMint) - .map(([url, ps]) => ({ - url, - ps, - balance: ps.reduce((s, p) => s + p.amount, 0), - })) - .find((m) => m.balance >= amount); + // ---- Receive ---- - if (!mintEntry) { - showAlert( - "Balance split across mints", - "No single mint holds the full amount. Consolidate proofs at one mint first.", - ); - return; + async function handleReceive(): Promise { + const raw = tokenInput.trim(); + if (!raw) return; + setBusy("receive"); + try { + const result = await receiveToken(raw); + setShowReceive(false); + setTokenInput(""); + + if (result.outcome === "duplicate") { + showAlert( + "Already in your wallet", + "Every proof in this token is already stored here, so nothing was added. Balances are unchanged.", + ); + return; + } + const where = hostOf(result.mintUrl); + if (result.outcome === "swapped") { + showAlert( + `+${result.amount.toLocaleString()} ${result.unit}`, + `Redeemed at ${where}. These proofs are now yours alone: the sender's copy no longer works.` + + (result.memo ? `\n\n"${result.memo}"` : ""), + ); + } else { + showAlert( + `+${result.amount.toLocaleString()} ${result.unit}`, + `Stored from ${where}, but not yet confirmed with the mint (${result.offlineReason ?? "offline"}).` + + (result.dleq === "valid" + ? " The mint's signature checks out, so the token is genuine." + : " The mint's keys are not cached here, so the signature could not be checked offline.") + + " Until you refresh online, the sender could in principle have spent it elsewhere." + + (result.memo ? `\n\n"${result.memo}"` : ""), + ); + } + } catch (err) { + reportError(err, "Could not receive"); + } finally { + setBusy(null); } + } - const selection = selectProofsForAmount(mintEntry.ps, amount); - if (!selection) return; + // ---- Send ---- - const finalize = (): void => { - // Build the token offline (pure serialization, no network call). - const tokenStr = buildOfflineToken( - mintEntry.url, - selection.selected, - unit, - sendMemo.trim() || undefined, - ); + async function handleSend(): Promise { + const amount = Number.parseInt(sendAmount, 10); + if (!amount || amount <= 0) return; + setBusy("send"); + try { + // Quote first so an inexact amount is explained before anything is + // reserved, rather than after the proofs have already moved. + const quote = await quoteSend({ amount, unit: primary.unit }); + const commit = async (allowInexact: boolean): Promise => { + const prepared = await prepareSend({ + amount, + unit: primary.unit, + memo: sendMemo.trim() || undefined, + allowInexact, + }); + setShowSend(false); + setSendAmount(""); + setSendMemo(""); + setPending(prepared); + }; - // Remove the spent proofs from local storage immediately. - removeProofs( - mintEntry.url, - selection.selected.map((p) => p.secret), - ); + if (!quote.exact) { + showAlert( + "Can't send that exact amount", + `Your proofs can't make exactly ${amount.toLocaleString()} ${quote.unit} offline. The smallest token you can build is ${quote.spend.toLocaleString()} ${quote.unit}, and offline there is no change: the extra ${(quote.spend - amount).toLocaleString()} ${quote.unit} goes to the recipient.\n\nRefreshing at the mint while online would split your proofs into denominations that make this exact.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: `Send ${quote.spend.toLocaleString()}`, + style: "destructive", + onPress: () => void commit(true), + }, + ], + ); + return; + } + await commit(false); + } catch (err) { + reportError(err, "Could not build the token"); + } finally { + setBusy(null); + } + } - setShowSend(false); - setSendAmount(""); - setSendMemo(""); - setGeneratedToken({ - token: tokenStr, - amount: selection.total, - mintUrl: mintEntry.url, - }); - setShowGenerated(true); - }; + // The user confirmed the token reached its destination. Drops the reservation. + function markDelivered(txId: string): void { + confirmSend(txId); + setPending(null); + setShowPeerPicker(false); + } - // No exact denomination match: the token would carry more than the user - // asked for, and offline there is no way to get change back. Never spend - // the difference silently, so make it an explicit decision. - if (!selection.exact) { - showAlert( - "Can't send that exact amount", - `Your proofs can't make exactly ${amount} ${unit} offline. The smallest token you can send is ${selection.total} ${unit}, and the extra ${selection.total - amount} ${unit} goes to the recipient and can't be recovered without swapping at the mint first.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: `Send ${selection.total} ${unit}`, - style: "destructive", - onPress: finalize, + // The transfer never landed. Puts the proofs back into the balance. + function handleReclaim(tx: WalletTx | PreparedSend): void { + // WalletTx keys the transaction as `id`, PreparedSend as `txId`; they are + // the same value, and both carry amount and unit. + const txId = "txId" in tx ? tx.txId : tx.id; + showAlert( + "Reclaim this token?", + `The ${tx.amount.toLocaleString()} ${tx.unit} goes back into your balance. Only do this if the token never reached anyone: if they already have the string, whoever redeems it at the mint first keeps the money, and that could be them.`, + [ + { text: "Keep pending", style: "cancel" }, + { + text: "Reclaim", + style: "destructive", + onPress: () => { + reclaimSend(txId); + setPending(null); }, - ], - ); - return; - } + }, + ], + ); + } - finalize(); + function handleShareToken(token: string): void { + void Share.share({ message: token }); } - async function handleZapConfirm(): Promise { - const npubRaw = zapNpub.trim(); - const amount = parseInt(zapAmount, 10); - if (!npubRaw || !amount || amount <= 0) return; + async function handleCopyToken(token: string): Promise { + await Clipboard.setStringAsync(token); + showAlert( + "Copied", + "The token is on your clipboard. It stays reserved here until you mark it delivered, so you can paste it again if the first attempt fails.", + ); + } + + // Copying a seed phrase is a real risk: clipboards are readable by other apps + // and sync across devices on some setups. But refusing to offer it just + // pushes people to screenshot instead, which is worse and permanent. Offer + // it, and say plainly why it needs cleaning up afterwards. + async function handleCopyPhrase(): Promise { + await Clipboard.setStringAsync(phrase); + showAlert( + "Copied", + "Paste it into a password manager, then clear your clipboard. Other apps can read the clipboard, and on some setups it syncs to your other devices.", + ); + } - if (amount > totalSats) { + // Hands the token the user already built to a nearby peer. Uses the shared + // delivery helper rather than posting the DM here, so the message id, the + // delivery status and the pending transaction line up exactly as they do + // when the send starts from a chat or the Mesh tab. + function handleSendTokenToPeer(peerID: string): void { + if (!pending) return; + if (!getMeshService()) { showAlert( - "Insufficient balance", - `You have ${totalSats.toLocaleString()} sats but tried to zap ${amount.toLocaleString()} sats.`, + "Mesh offline", + "The mesh service is not running, so there is nothing to hand the token to. It stays reserved under Pending.", ); return; } + const route = deliverTokenToPeer({ peerID, prepared: pending }); + const amount = pending.amount; + const unit = pending.unit; + // Handed off, not proven delivered. The transaction stays pending so it can + // still be reclaimed if it never lands. + setShowPeerPicker(false); + setPending(null); + showAlert( + `${amount.toLocaleString()} ${unit} sent to ${peerIDToUsername(peerID)}`, + `${describeRoute(route)} It stays reclaimable under Pending until you confirm they got it, or until the mint tells us the proofs were redeemed.`, + ); + } + + // ---- Zap ---- + + async function handleZap(): Promise { + const npubRaw = zapNpub.trim(); + const amount = Number.parseInt(zapAmount, 10); + if (!npubRaw || !amount || amount <= 0) return; - // Decode npub or accept bare hex. let recipientPubkey: string; try { if (npubRaw.startsWith("npub")) { const decoded = nip19.decode(npubRaw); - if (decoded.type !== "npub") throw new Error("not npub"); + if (decoded.type !== "npub") throw new Error("not an npub"); recipientPubkey = decoded.data; + } else if (/^[0-9a-f]{64}$/i.test(npubRaw)) { + recipientPubkey = npubRaw.toLowerCase(); } else { - recipientPubkey = npubRaw; + throw new Error("bad key"); } } catch { showAlert( "Invalid pubkey", - "Enter a valid npub1\u2026 or 64-char hex pubkey.", + "Enter an npub1… or a 64-character hex Nostr pubkey.", ); return; } - setIsZapping(true); + setBusy("zap"); setShowZap(false); + try { + const service = getMeshService(); + const result = await sendNutzap({ + recipientPubkey, + amount, + comment: zapNote.trim() || undefined, + client: service?.getNostrClient() ?? null, + senderPrivKey: service?.getNostrPrivKey() ?? null, + unit: primary.unit, + }); + setZapNpub(""); + setZapAmount(""); + setZapNote(""); - const service = getMeshService(); - const nostrClient = service?.getNostrClient() ?? null; + if (result.method === "nutzap") { + showAlert( + "Nutzap sent", + `${result.amount.toLocaleString()} ${result.unit} locked to their key and published. Only they can redeem it.`, + ); + } else if (result.method === "dm") { + showAlert( + "Sent as an encrypted token", + `${result.amount.toLocaleString()} ${result.unit} sent in a Nostr DM, because ${result.fallbackReason}.\n\nThis is a bearer token: once they decrypt the message, whoever holds that string can redeem it.`, + ); + } else { + setPending({ + txId: result.txId, + token: result.token ?? "", + amount: result.amount, + spend: result.amount, + fee: 0, + exact: true, + unit: result.unit, + mintUrl: result.mintUrl, + proofs: [], + }); + showAlert( + "Couldn't reach the network", + `${result.fallbackReason}. A token was built instead: share it however you like, or reclaim it under Pending.`, + ); + } + } catch (err) { + reportError(err, "Zap failed"); + } finally { + setBusy(null); + } + } - // Build an offline token to send regardless of Nostr connectivity. - const mintEntry = Object.entries(proofsByMint) - .map(([url, ps]) => ({ - url, - ps, - balance: ps.reduce((s, p) => s + p.amount, 0), - })) - .find((m) => m.balance >= amount); + // ---- Mints ---- - if (!mintEntry) { - setIsZapping(false); + async function handleAddMint(): Promise { + const raw = mintUrlInput.trim(); + if (!raw) return; + setBusy("addMint"); + try { + const { mint, units } = await addMintService(raw); + setShowAddMint(false); + setMintUrlInput(""); showAlert( - "Balance split across mints", - "No single mint holds the full amount.", + mint.name ? `Added ${mint.name}` : "Mint added", + `${hostOf(mint.url)} issues ${units.join(", ")}. Its keys are cached on this device, so tokens from it can now be verified even with no internet.`, ); - return; + } catch (err) { + reportError(err, "Could not add mint"); + } finally { + setBusy(null); } + } - const selection = selectProofsForAmount(mintEntry.ps, amount); - if (!selection) { - setIsZapping(false); - return; + async function handleRefreshMint( + mintUrl: string, + unit: string, + ): Promise { + setRefreshingMint(mintUrl); + try { + const result = await refreshAccount(mintUrl, unit); + const parts: string[] = []; + if (result.swapped > 0) { + parts.push( + `${result.swapped.toLocaleString()} ${unit} confirmed and swapped for fresh proofs.`, + ); + } + if (result.spentRemoved > 0) { + parts.push( + `${result.spentRemoved} proof${result.spentRemoved === 1 ? " was" : "s were"} already spent and ${result.spentRemoved === 1 ? "has" : "have"} been removed.`, + ); + } + // Worth naming separately: this value was never in doubt, it was just + // outside the recovery phrase until the swap re-issued it. + if (result.securedForBackup > 0) { + parts.push( + `${result.securedForBackup.toLocaleString()} ${unit} is now covered by your recovery phrase.`, + ); + } + showAlert( + "Refreshed", + parts.length > 0 + ? parts.join("\n\n") + : "Everything here was already confirmed with the mint.", + ); + } catch (err) { + reportError(err, "Refresh failed"); + } finally { + setRefreshingMint(null); } + } - // Try NIP-61 nutzap (online path) if we have Nostr connectivity. - if (nostrClient) { - try { - const walletInfo = await fetchWalletInfo(recipientPubkey, nostrClient); - - if (walletInfo) { - // Full NIP-61: swap proofs for P2PK-locked ones at recipient's mint. - const targetMintUrl = - walletInfo.mintUrls.find((u) => - Object.keys(proofsByMint).includes(u), - ) ?? walletInfo.mintUrls[0]; - - try { - const cashuMint = new Mint(targetMintUrl); - const cashuWallet = new Wallet(cashuMint, { unit }); - await cashuWallet.loadMint(); - - // Re-select proofs compatible with this mint if needed. - const mintProofs = proofsByMint[targetMintUrl] ?? []; - const mintSelection = selectProofsForAmount(mintProofs, amount); - - if (mintSelection) { - const { keep, send: lockedProofs } = await cashuWallet.send( - amount, - mintSelection.selected, - undefined, - // P2PK output: locked to recipient's declared pubkey. - { - send: { - type: "p2pk", - pubkey: walletInfo.p2pkPubkey, - } as unknown as import("@cashu/cashu-ts").OutputType, - }, - ); - - // Store change proofs. - removeProofs( - targetMintUrl, - mintSelection.selected.map((p) => p.secret), - ); - if (keep.length > 0) { - addProofs( - targetMintUrl, - keep.map((p) => ({ - id: p.id, - amount: p.amount.toNumber(), - secret: p.secret, - C: p.C, - })), - ); - } + function handleRemoveMint(account: AccountBalance): void { + const hasValue = account.balance > 0 || account.reserved > 0; + showAlert( + hasValue ? "Remove mint with a balance?" : "Remove mint", + hasValue + ? `${hostOf(account.mintUrl)} holds ${account.balance.toLocaleString()} ${account.unit} in ${account.proofCount} proof${account.proofCount === 1 ? "" : "s"}. Removing it deletes those proofs from this device permanently and there is no backup. Withdraw or send the balance first.` + : `Remove ${hostOf(account.mintUrl)} from your wallet? Its cached keys go too, so tokens from it can no longer be verified offline.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: hasValue ? "Delete anyway" : "Remove", + style: "destructive", + onPress: () => useWalletStore.getState().removeMint(account.mintUrl), + }, + ], + ); + } - const nostrPrivKey = service!.getNostrPrivKey(); - await publishNutzap( - lockedProofs, - targetMintUrl, - unit, - recipientPubkey, - nostrPrivKey, - nostrClient, - zapNote.trim() || undefined, - ); - - setIsZapping(false); - setZapNpub(""); - setZapAmount(""); - setZapNote(""); - showAlert( - "Nutzap sent", - `${amount.toLocaleString()} sats sent to ${npubRaw.slice(0, 20)}\u2026`, - ); - return; - } - } catch (mintErr) { - // Mint swap failed; fall through to offline token via Nostr DM. - void mintErr; - } - } + // ---- Backup ---- - // Fallback: send unlocked offline token via Nostr gift-wrap DM. - const tokenStr = buildOfflineToken( - mintEntry.url, - selection.selected, - unit, - zapNote.trim() || `${amount} sats`, - ); - removeProofs( - mintEntry.url, - selection.selected.map((p) => p.secret), - ); + // Step 1 of setup. Deliberately starts on a warning rather than on the words: + // showing twelve words with no context invites a screenshot, and a screenshot + // in a photo library is the most common way seed phrases get stolen. + function handleStartBackup(): void { + setBackupStep("warn"); + } - const nostrPrivKey = service!.getNostrPrivKey(); - const { event } = await import("../../core/nostr/gift-wrap").then((m) => - m.wrapDm(tokenStr, nostrPrivKey, recipientPubkey), - ); - await nostrClient.publish(event); + // Step 2. Generates (or re-reads) the phrase and switches new proofs over to + // deterministic secrets straight away, so anything minted from here on is + // covered even if the user abandons the verification step. + async function handleRevealPhrase(): Promise { + setBusy("backup"); + try { + const setup = await enableWalletBackup(); + setPhrase(setup.phrase); + setVerifyPositionList(pickVerificationPositions()); + setVerifyAnswers({}); + setVerifyError(false); + setBackupStep("show"); + } catch (err) { + reportError(err, "Could not set up backup"); + setBackupStep(null); + } finally { + setBusy(null); + } + } + + // Step 3. Two words, chosen at random each time, so passing once does not + // teach anyone how to pass again. + function handleVerifyPhrase(): void { + if (verifyPositions(phrase, verifyAnswers)) { + markBackupVerified(); + closeBackupSheet(); + showAlert( + "Backup on", + `Your balance can now be rebuilt from those twelve words.\n\nAnything you were given by someone else stays outside the phrase until you refresh at the mint, and recovery needs your mint list, so keep it written down beside the words.`, + ); + return; + } + setVerifyError(true); + } - setIsZapping(false); - setZapNpub(""); - setZapAmount(""); - setZapNote(""); + async function handleViewPhrase(): Promise { + setBusy("backup"); + try { + const stored = await getRecoveryPhrase(); + if (stored === null) { showAlert( - "Token sent via Nostr", - `${amount.toLocaleString()} sats sent to ${npubRaw.slice(0, 20)}\u2026 as an encrypted Cashu token.`, + "No phrase stored", + "The recovery phrase could not be read from the device keychain. Unlock the device and try again.", ); return; - } catch (e) { - void e; - // Nostr send failed: fall through to manual token. } + setPhrase(stored); + // Someone who set the phrase up but never confirmed a written copy gets + // the full write-it-down flow again rather than a read-only view, so the + // unconfirmed state has an obvious way out. + if (backupVerified) { + setBackupStep("view"); + } else { + setVerifyPositionList(pickVerificationPositions()); + setVerifyAnswers({}); + setVerifyError(false); + setBackupStep("show"); + } + } finally { + setBusy(null); } + } - // No Nostr connectivity: build token for manual sharing. - const tokenStr = buildOfflineToken( - mintEntry.url, - selection.selected, - unit, - zapNote.trim() || undefined, - ); - removeProofs( - mintEntry.url, - selection.selected.map((p) => p.secret), - ); - setIsZapping(false); - setZapNpub(""); - setZapAmount(""); - setZapNote(""); - setGeneratedToken({ - token: tokenStr, - amount: selection.total, - mintUrl: mintEntry.url, + // Wraps the callback-based alert so the restore flow reads as a straight + // line. Backdrop dismissal counts as cancel, which is why this watches the + // store's visibility rather than relying on a button firing. + function confirmReplacePhrase(): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean): void => { + if (settled) return; + settled = true; + unsubscribe(); + resolve(value); + }; + const unsubscribe = useAlertStore.subscribe((state) => { + if (state.visible) return; + setTimeout(() => finish(false), 0); + }); + showAlert( + "Replace your current phrase?", + "You already have a recovery phrase. Restoring a different one replaces it. Coins already covered by the old phrase stay spendable on this device, but they stop being restorable, so make sure the old words are written down before you continue.", + [ + { text: "Cancel", style: "cancel", onPress: () => finish(false) }, + { + text: "Replace", + style: "destructive", + onPress: () => finish(true), + }, + ], + ); }); - setShowGenerated(true); } - async function handleRedeem(mintUrl: string): Promise { - const mintProofs = proofsByMint[mintUrl]; - if (!mintProofs || mintProofs.length === 0) { - showAlert("Nothing to redeem", "This mint has no proofs stored."); + function closeBackupSheet(): void { + setBackupStep(null); + // The phrase is the money. Do not leave it sitting in component state after + // the sheet closes. + setPhrase(""); + setVerifyAnswers({}); + setVerifyError(false); + } + + async function handleRestore(): Promise { + const input = restoreInput.trim(); + // Restoring replaces the stored phrase. Coins already derived from the old + // one stay spendable here, but they stop being restorable, so this is the + // one place a wrong tap can quietly cost someone their backup. + if (backupEnabled && !(await confirmReplacePhrase())) return; + if (!isValidRecoveryPhrase(input)) { + const unknown = unknownWordsIn(input); + showAlert( + "That phrase is not valid", + unknown.length > 0 + ? `These are not BIP-39 words: ${unknown.slice(0, 4).join(", ")}. Check the spelling.` + : "The phrase has a built-in checksum and this one does not pass. Check for a mistyped, missing or swapped word.", + ); + return; + } + if (mintList.length === 0) { + showAlert( + "Add a mint first", + "Recovery works by asking a mint which coins it signed for you, so it needs to know which mint to ask. Add the mints you were using, then restore.", + ); return; } + setBusy("restore"); + setRestoreResult(null); try { - const cashuMint = new Mint(mintUrl); - const cashuWallet = new Wallet(cashuMint, { unit }); - await cashuWallet.loadMint(); + const result = await restoreFromRecoveryPhrase({ + phrase: input, + mintUrls: mintList.map((m) => m.url), + unit: primary.unit, + onProgress: (progress) => + setRestoreProgress( + `${hostOf(progress.mintUrl)} · keyset ${String(progress.step)} of ${String(progress.total)}`, + ), + }); + setRestoreResult(result); + setRestoreInput(""); + } catch (err) { + reportError(err, "Restore failed"); + } finally { + setBusy(null); + setRestoreProgress(null); + } + } - // Build the full token from existing proofs and redeem (swap) them. - const tokenStr = buildOfflineToken(mintUrl, mintProofs, unit); - const newProofs = await cashuWallet.receive(tokenStr); + // ---- Consolidate ---- - // Replace old proofs with freshly swapped ones. - removeProofs( - mintUrl, - mintProofs.map((p) => p.secret), - ); - addProofs( - mintUrl, - newProofs.map((p) => ({ - id: p.id, - amount: p.amount.toNumber(), - secret: p.secret, - C: p.C, - })), - ); - showAlert( - "Redeemed", - `Proofs refreshed at ${mintUrl.replace(/https?:\/\//, "")}.`, - ); - } catch (err) { + async function handleConsolidate(): Promise { + const target = consolidateTarget; + if (!target) return; + const sources = splitAccounts.filter((a) => a.mintUrl !== target); + if (sources.length === 0) return; + + setBusy("consolidate"); + let moved = 0; + let fees = 0; + const failures: string[] = []; + try { + for (const source of sources) { + try { + const result = await consolidateMints({ + fromMintUrl: source.mintUrl, + toMintUrl: target, + unit: primary.unit, + }); + moved += result.received; + fees += result.fee; + } catch (err) { + failures.push( + `${hostOf(source.mintUrl)}: ${err instanceof WalletError ? err.message : String(err)}`, + ); + } + } + setShowConsolidate(false); showAlert( - "Redemption failed", - `Could not reach the mint. Make sure you have internet access.\n\n${String(err)}`, + moved > 0 ? "Moved" : "Nothing moved", + [ + moved > 0 + ? `${moved.toLocaleString()} ${primary.unit} now sits at ${hostOf(target)}, after ${fees.toLocaleString()} ${primary.unit} in Lightning routing fees.` + : null, + failures.length > 0 ? failures.join("\n") : null, + ] + .filter(Boolean) + .join("\n\n"), ); + } finally { + setBusy(null); } } - function handleAddMintConfirm(): void { - const raw = mintUrlInput.trim(); - if (!raw) return; + // ---- Lightning deposit ---- - // Basic URL validation: must be http(s). Also strip trailing slash so that - // "https://mint.example.com/" and "https://mint.example.com" are the same mint. - let url: string; + async function handleCreateDeposit(): Promise { + const amount = Number.parseInt(depositAmount, 10); + const mintUrl = activeMint ?? mintList[0]?.url; + if (!amount || amount <= 0 || !mintUrl) return; + setBusy("deposit"); try { - const parsed = new URL(raw); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - showAlert( - "Invalid URL", - "Mint URL must start with http:// or https://", - ); - return; - } - // Normalise: remove trailing slash from pathname. - parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; - url = parsed.toString().replace(/\/$/, ""); - } catch { - showAlert("Invalid URL", "Please enter a valid mint URL."); - return; + const created = await createLightningDeposit({ + amount, + mintUrl, + unit: "sat", + description: "Airhop wallet top-up", + }); + setDeposit(created); + setDepositAmount(""); + } catch (err) { + reportError(err, "Could not create the invoice"); + } finally { + setBusy(null); } + } + + // Poll the open deposit until the invoice is paid. Stops as soon as the sheet + // closes; an unclaimed deposit is picked up by `reconcile` on next launch, so + // nothing is lost by giving up here. + useEffect(() => { + if (!deposit || !showDeposit) return; + let cancelled = false; + const timer = setInterval(() => { + void (async () => { + try { + const minted = await claimLightningDeposit( + deposit.mintUrl, + deposit.unit, + deposit.quoteId, + ); + if (cancelled || minted <= 0) return; + setDeposit(null); + setShowDeposit(false); + showAlert( + `+${minted.toLocaleString()} ${deposit.unit}`, + `Invoice paid and ${minted.toLocaleString()} ${deposit.unit} issued by ${hostOf(deposit.mintUrl)}. This balance is confirmed: you can spend it offline right away.`, + ); + } catch { + // Still unpaid, or the mint blinked. Keep polling. + } + })(); + }, DEPOSIT_POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [deposit, showDeposit]); + + // ---- Lightning withdrawal ---- - addMint(url); - setShowAddMint(false); - setMintUrlInput(""); + async function handleQuoteWithdraw(): Promise { + const invoice = withdrawInvoice.trim(); + const mintUrl = activeMint ?? mintList[0]?.url; + if (!invoice || !mintUrl) return; + setBusy("withdrawQuote"); + try { + setWithdrawQuote( + await quoteLightningWithdrawal({ invoice, mintUrl, unit: "sat" }), + ); + } catch (err) { + reportError(err, "Could not price this invoice"); + } finally { + setBusy(null); + } } - function handleSendTokenToPeer(peerID: string): void { - if (!generatedToken) return; - const service = getMeshService(); - if (!service) { - showAlert("Mesh offline", "Mesh service is not running."); - return; + async function handlePayWithdraw(): Promise { + if (!withdrawQuote) return; + setBusy("withdrawPay"); + try { + const result = await payLightningInvoice(withdrawQuote); + setShowWithdraw(false); + setWithdrawQuote(null); + setWithdrawInvoice(""); + showAlert( + "Paid", + `${result.paid.toLocaleString()} sats paid over Lightning. The mint charged ${result.fee.toLocaleString()} sats in routing fees` + + (result.changeReturned > 0 + ? `, and returned ${result.changeReturned.toLocaleString()} sats of the reserve to your balance.` + : "."), + ); + } catch (err) { + reportError(err, "Payment failed"); + } finally { + setBusy(null); } - const localPeerID = service.getPeerID(); - const channel = `dm:${peerID}`; - useChatStore.getState().addChannel(channel); - useChatStore.getState().addMessage({ - // eslint-disable-next-line react-hooks/purity - id: `wallet-${peerID}-${Date.now()}`, - channel, - senderID: localPeerID, - senderNickname: "You", - text: generatedToken.token, - // eslint-disable-next-line react-hooks/purity - timestampMs: Date.now(), - isMine: true, - }); - service.sendDm(peerID, generatedToken.token); - setShowPeerPicker(false); - setShowGenerated(false); - setGeneratedToken(null); - showAlert( - "Token sent", - `${generatedToken.amount} sats sent. Open the Chats tab to see the message.`, - ); } + // ---- Render ---- + return ( - {/* Balance section */} + {locked && ( + + + + Wallet storage is locked. Ecash proofs are kept in an encrypted file + whose key lives in the device keychain, and it could not be opened. + Unlock your device and reopen Airhop. + + + )} + + {networkBlocked && !locked && ( + + + + Tor is on, so mint requests are blocked: they would go out over the + clear net and link your IP to your proofs. Sending and receiving + over the mesh still works. Allow mint traffic under Settings, + Security. + + + )} + + {/* Balance */} - Total balance + Spendable - {totalSats.toLocaleString()} + {primary.balance.toLocaleString()} - sats + {primary.unit} + + {/* Everything that is not plain spendable balance is stated + explicitly rather than folded into the number above. */} + {primary.unverified > 0 && ( + + + + {primary.unverified.toLocaleString()} {primary.unit} not yet + confirmed with the mint + + + )} + {primary.reserved > 0 && ( + + + + {primary.reserved.toLocaleString()} {primary.unit} reserved for + a send in flight + + + )} + + {unitTotals + .filter((u) => u.unit !== primary.unit && u.balance > 0) + .map((u) => ( + + {u.balance.toLocaleString()} {u.unit} at a separate mint account + + ))} + - {mintBalances.length} mint{mintBalances.length !== 1 ? "s" : ""} - {"\u2009\u00b7\u2009"} - {mintBalances.reduce((s, m) => s + m.proofCount, 0)} proofs + {mintList.length} mint{mintList.length === 1 ? "" : "s"} + {" · "} + {accounts.reduce((s, a) => s + a.proofCount, 0)} proofs - {/* Mint balances */} - - {mintBalances.length === 0 ? ( - - No mints added yet. - - Receive a Cashu token to automatically add a mint. - - - ) : ( - mintBalances.map((m) => ( - { - const hasBalance = m.balance > 0; - showAlert( - hasBalance ? "Remove mint (has balance)" : "Remove mint", - hasBalance - ? `${shortenMintUrl(m.mintUrl)} has ${m.balance.toLocaleString()} sats in ${m.proofCount} proof${m.proofCount !== 1 ? "s" : ""}. Removing it deletes those proofs permanently. Transfer or redeem first.` - : `Remove ${shortenMintUrl(m.mintUrl)} from your wallet?`, - [ - { text: "Cancel", style: "cancel" }, - { - text: hasBalance ? "Remove anyway" : "Remove", - style: "destructive", - onPress: () => clearMint(m.mintUrl), - }, - ], - ); - }} - accessibilityRole="button" - accessibilityLabel={`${shortenMintUrl(m.mintUrl)}, ${m.balance.toLocaleString()} sats. Long press to remove.`} - > - - - - - - - {shortenMintUrl(m.mintUrl)} - - - {m.proofCount} proof{m.proofCount !== 1 ? "s" : ""} - - - - - - {m.balance.toLocaleString()} + {/* Pending sends: reserved proofs the user can still recover. */} + {pendingSends.length > 0 && ( + + Pending + {pendingSends.map((tx) => ( + + + + + {tx.amount.toLocaleString()} {tx.unit} + + + {relativeTime(tx.createdAtMs)} - sats - {/* Refresh proofs at mint: confirms they are unspent. */} + + + Built and reserved, delivery unconfirmed. The proofs are held + out of your balance so they cannot be spent twice. + {tx.error ? `\n\n${tx.error}` : ""} + + + void handleCopyToken(tx.token ?? "")} + accessibilityRole="button" + accessibilityLabel="Copy the token again" + > + Copy + + handleShareToken(tx.token ?? "")} + accessibilityRole="button" + accessibilityLabel="Share the token again" + > + Share + + markDelivered(tx.id)} + accessibilityRole="button" + accessibilityLabel="Mark this token as delivered" + > + Delivered + void handleRedeem(m.mintUrl)} + style={[styles.pendingBtn, styles.pendingBtnDanger]} + onPress={() => handleReclaim(tx)} accessibilityRole="button" - accessibilityLabel={`Refresh proofs at ${shortenMintUrl(m.mintUrl)}`} + accessibilityLabel="Reclaim this token into your balance" > - Refresh + Reclaim + + ))} + + )} + + {/* Mint accounts */} + + Mints + {accounts.length === 0 ? ( + + No mint yet + + A mint issues and redeems your ecash. Add one to deposit over + Lightning, or just receive a token and its mint is added for you. + + {/* The header has an Add mint icon, but an empty screen should + offer the next step rather than expect it to be found. */} + setShowAddMint(true)} + accessibilityRole="button" + accessibilityLabel="Add a mint" + > + + Add a mint - )) + + ) : ( + accounts.map((account) => { + const record = mints[account.mintUrl]; + return ( + handleRemoveMint(account)} + accessibilityRole="button" + accessibilityLabel={`${hostOf(account.mintUrl)}, ${account.balance.toLocaleString()} ${account.unit}. Long press to remove.`} + > + + + + + + + {record?.name ?? hostOf(account.mintUrl)} + + + {account.proofCount} proof + {account.proofCount === 1 ? "" : "s"} + {account.unverified > 0 + ? ` · ${account.unverified.toLocaleString()} unconfirmed` + : ""} + + + + + + {account.balance.toLocaleString()} + + {account.unit} + + void handleRefreshMint(account.mintUrl, account.unit) + } + accessibilityRole="button" + accessibilityLabel={`Confirm proofs with ${hostOf(account.mintUrl)}`} + > + {refreshingMint === account.mintUrl ? ( + + ) : ( + Refresh + )} + + + + ); + }) + )} + + {/* Ecash from two mints can never become one token, so a split balance + is a real wall. Moving it is possible over Lightning, and this is + the only place that says so. */} + {splitAccounts.length > 1 && ( + { + setConsolidateTarget(splitAccounts[0].mintUrl); + setShowConsolidate(true); + }} + accessibilityRole="button" + accessibilityLabel="Move all balances to one mint" + > + + + Balance split across {splitAccounts.length} mints. Move it to one + + )} - {/* About: what ecash is, then one row per header action. The four icons - sit in the App-level header with no labels, so this is where they are - named and where each one states whether it needs internet. */} + {/* Lightning: the only way value enters or leaves without a token. */} - {/* Info panel */} - - - - - What is Cashu? - - Cashu is an open ecash protocol for Bitcoin. Tokens are - cryptographic bearer instruments. No accounts, no logins, just - proofs. - - + Lightning + + + Turn Lightning sats into ecash you can spend offline, or cash ecash + back out to any Lightning invoice. Both need internet and a mint. + + + { + setActiveMint(mintList[0]?.url ?? null); + setDeposit(null); + setShowDeposit(true); + }} + accessibilityRole="button" + accessibilityLabel="Deposit sats over Lightning" + > + + Deposit + + { + setActiveMint(splitAccounts[0]?.mintUrl ?? null); + setWithdrawQuote(null); + setShowWithdraw(true); + }} + accessibilityRole="button" + accessibilityLabel="Withdraw to a Lightning invoice" + > + + Withdraw + - - + {pendingDeposits.length > 0 && ( + + {pendingDeposits.length} deposit + {pendingDeposits.length === 1 ? "" : "s"} waiting on payment. + Checked again each time the app opens. + + )} + + + + {/* Backup. Off by default, because turning it on is a commitment: the + user has to write twelve words down and keep them, and a phrase + nobody wrote down is worse than none at all (it implies a safety net + that is not there). */} + + Backup + + - - Send - - Turns an amount into a token and hands it to a nearby peer over - the mesh, or shares it as text. Works offline. + Recovery phrase + + + {backupEnabled + ? backupVerified + ? "On" + : "Unconfirmed" + : "Off"} - - - - - Receive - - Paste a token to store its proofs on this device. Works offline. - Refresh at the mint once you are online to confirm they are - unspent. + + {backupEnabled ? ( + <> + + {coverage.covered.toLocaleString()} {primary.unit} can be + rebuilt on a new device from your twelve words. - - - - - - - Zap - - Sends sats to a Nostr contact by npub. Needs internet, since it - is delivered over relays. + {/* A phrase that exists but was never copied out is the most + dangerous state of all: the card would otherwise read as + protected while the words live only on the phone that is + about to be lost. */} + {!backupVerified && ( + + + + You never confirmed a written copy. Right now the words + exist only on this phone, which is the one thing a backup is + supposed to survive. View the phrase and write it down. + + + )} + {coverage.unbacked > 0 && ( + + + + {coverage.unbacked.toLocaleString()} {primary.unit} is not + covered yet. Coins you were given carry the secrets of + whoever sent them, so they only come under your phrase once + they are swapped. Refresh a mint to secure them. + + + )} + {mintList.length > 0 && ( + + Recovery has to ask a mint which coins it signed, so keep this + list with your words:{"\n"} + {mintList.map((m) => hostOf(m.url)).join("\n")} + + )} + + ) : ( + + Your ecash exists only on this phone. If you lose it, nobody can + recover the money, including you. A recovery phrase is twelve + words that can rebuild your balance anywhere. + + )} + + + { + if (backupEnabled) void handleViewPhrase(); + else void handleStartBackup(); + }} + accessibilityRole="button" + accessibilityLabel={ + backupEnabled + ? "View recovery phrase" + : "Set up recovery phrase" + } + > + + + {backupEnabled ? "View phrase" : "Set up"} - + + { + setRestoreInput(""); + setRestoreResult(null); + setShowRestore(true); + }} + accessibilityRole="button" + accessibilityLabel="Restore a wallet from a recovery phrase" + > + + Restore + - - - - - Add mint - - Saves the mint that issues and redeems your tokens. Saving is - offline. Redeeming and Lightning cash-out need internet. - - + + + + {/* Activity */} + {recent.length > 0 && ( + + Activity + + {recent.map((tx, index) => ( + + {index > 0 && } + + + + {txTitle(tx)} + + {relativeTime(tx.createdAtMs)} + {" · "} + {hostOf(tx.mintUrl)} + {tx.status !== "completed" ? ` · ${tx.status}` : ""} + + + + {isCredit(tx) ? "+" : "−"} + {tx.amount.toLocaleString()} + + + + ))} + )} + + {/* What each header action does, and whether it needs internet. */} + + + {[ + { + icon: "help-circle" as const, + title: "What is Cashu?", + body: "Cashu is ecash for Bitcoin. A token is a string that is worth money to whoever holds it, signed blindly by a mint so the mint cannot tell who spent what. No accounts, no logins.", + }, + { + icon: "arrow-up" as const, + title: "Send", + body: "Turns an amount into a token you can hand to a nearby peer over Bluetooth, or share as text. Works with no internet. The proofs stay reserved until you confirm it landed.", + }, + { + icon: "arrow-down" as const, + title: "Receive", + body: "Paste a token to add it. Online it is swapped at the mint immediately, which makes it provably yours. Offline it is stored and marked unconfirmed until you refresh.", + }, + { + icon: "zap" as const, + title: "Zap", + body: "Pays a Nostr identity. If they publish NIP-61 nutzap info, the ecash is locked to their key so only they can spend it. Otherwise it falls back to an encrypted DM. Needs internet.", + }, + { + icon: "plus" as const, + title: "Add mint", + body: "Saves the mint that issues and redeems your ecash, and caches its public keys so tokens from it can be verified offline. Choose a mint you would trust with the balance you keep there.", + }, + { + icon: "shield" as const, + title: "Recovery phrase", + body: "Off by default. Turn it on and your coins are derived from twelve words instead of random numbers, so a new phone can rebuild the balance by asking your mints which coins they signed. Without it, losing the phone loses the money.", + }, + ].map((row, index) => ( + + {index > 0 && } + + + + {row.title} + {row.body} + + + + ))} + - {/* Receive modal */} - setShowReceive(false)} + onClose={() => setShowReceive(false)} + sheetStyle={styles.modalSheet} > - - setShowReceive(false)} - /> - - - Receive ecash - - Paste a Cashu token to add proofs to your wallet. + Receive ecash + + Paste a Cashu token. Online it is redeemed at the mint straight away; + offline it is stored and confirmed the next time you refresh. + + + void handleReceive()} + onCancel={() => setShowReceive(false)} + /> + + + setShowSend(false)} + sheetStyle={styles.modalSheet} + > + Send ecash + + Built offline from proofs you already hold. Nothing leaves your + balance for good until you confirm the token was delivered. + + + + void handleSend()} + onCancel={() => { + setShowSend(false); + setSendAmount(""); + setSendMemo(""); + }} + /> + + + setShowZap(false)} + sheetStyle={styles.modalSheet} + > + Zap a Nostr identity + + If they publish NIP-61 nutzap info, the ecash is locked to their key + so nobody else can spend it. If not, it goes as an encrypted DM + instead and you will be told which happened. + + + + + void handleZap()} + onCancel={() => { + setShowZap(false); + setZapNpub(""); + setZapAmount(""); + setZapNote(""); + }} + /> + + + setShowAddMint(false)} + sheetStyle={styles.modalSheet} + > + Add mint + + A mint holds the Bitcoin backing your ecash, so pick one you would + trust with the balance you keep there. The URL is checked before it is + saved. Run your own with Nutshell if you would rather not trust + anyone. + + + void handleAddMint()} + onCancel={() => { + setShowAddMint(false); + setMintUrlInput(""); + }} + /> + + + {/* Generated token: the send has reserved proofs but not spent them. */} + setPending(null)} + sheetStyle={styles.modalSheet} + > + + + + + {pending?.amount.toLocaleString()} - - - - Receive - - setShowReceive(false)} - > - Cancel - - + {pending?.unit} + + {pending ? hostOf(pending.mintUrl) : ""} + + {pending && pending.fee > 0 && ( + + {pending.spend.toLocaleString()} {pending.unit} leaves your + balance; the extra {pending.fee.toLocaleString()} covers the mint + fee they would otherwise pay + + )} - - {/* Send modal */} - setShowSend(false)} - > - + + + Whoever holds this string owns the money. The proofs are reserved, not + spent: if it never reaches anyone you can reclaim them under Pending. + + setShowSend(false)} - /> - - - Send ecash + style={styles.generatedActionBtn} + onPress={() => pending && void handleCopyToken(pending.token)} + accessibilityRole="button" + accessibilityLabel="Copy token" + > + + Copy + + pending && handleShareToken(pending.token)} + accessibilityRole="button" + accessibilityLabel="Share token" + > + + Share + + setShowPeerPicker(true)} + accessibilityRole="button" + accessibilityLabel="Send token to a nearby peer" + > + + Send to peer + + + + pending && markDelivered(pending.txId)} + accessibilityRole="button" + accessibilityLabel="Mark delivered and finish" + > + They got it + + setPending(null)} + accessibilityRole="button" + accessibilityLabel="Keep this send pending" + > + Decide later + + + + + {/* Lightning deposit */} + { + setShowDeposit(false); + setDeposit(null); + }} + sheetStyle={styles.modalSheet} + > + Deposit over Lightning + {deposit === null ? ( + <> - Token is built offline from your proofs. No mint connection - needed. Share it in a DM or paste it anywhere. + The mint gives you an invoice. Pay it from any Lightning wallet + and the sats come back as ecash you can spend offline. + ({ + mintUrl: m.url, + sub: m.name ?? hostOf(m.url), + }))} + selected={activeMint} + onSelect={setActiveMint} + /> + void handleCreateDeposit()} + onCancel={() => setShowDeposit(false)} + /> + + ) : ( + <> + + Pay this invoice for {deposit.amount.toLocaleString()}{" "} + {deposit.unit}. The wallet is watching for the payment and will + issue your ecash automatically. + - + void Clipboard.setStringAsync(deposit.invoice)} + accessibilityRole="button" + accessibilityLabel="Copy invoice" + > + + Copy invoice + + + void Share.share({ message: `lightning:${deposit.invoice}` }) + } + accessibilityRole="button" + accessibilityLabel="Open in a Lightning wallet" > - Generate token + + Open in wallet + + + + Waiting for payment… + + { - setShowSend(false); - setSendAmount(""); - setSendMemo(""); - }} + onPress={() => setShowDeposit(false)} + accessibilityRole="button" + accessibilityLabel="Close" > - Cancel + Close + + )} + + + {/* Lightning withdrawal */} + { + setShowWithdraw(false); + setWithdrawQuote(null); + }} + sheetStyle={styles.modalSheet} + > + Withdraw to Lightning + + Paste a bolt11 invoice and the mint pays it from your ecash. You are + quoted the routing reserve first; whatever routing does not use comes + back to your balance. + + { + setWithdrawInvoice(text); + setWithdrawQuote(null); + }} + placeholder="lnbc..." + placeholderTextColor={Colors.textMuted} + multiline + numberOfLines={3} + autoCapitalize="none" + autoCorrect={false} + selectionColor={Colors.accent} + /> + ({ + mintUrl: a.mintUrl, + sub: `${a.balance.toLocaleString()} ${a.unit} available`, + }))} + selected={activeMint} + onSelect={(url) => { + setActiveMint(url); + // A quote is priced against one mint's fee schedule, so switching + // mints invalidates it. + setWithdrawQuote(null); + }} + /> + {withdrawQuote && ( + + + + - - + )} + + void (withdrawQuote ? handlePayWithdraw() : handleQuoteWithdraw()) + } + onCancel={() => { + setShowWithdraw(false); + setWithdrawQuote(null); + setWithdrawInvoice(""); + }} + /> + - {/* Zap modal */} - setShowZap(false)} + {/* Recovery phrase: warn -> show -> verify, or view when already set up */} + - - setShowZap(false)} - /> - - - Nutzap + {backupStep === "warn" && ( + <> + Set up a recovery phrase + + You are about to see twelve words. They are the money. + + {[ + "Anyone who reads them can take your balance. Do not screenshot them and do not store them on this phone.", + "Write them on paper and keep them somewhere safe. Airhop cannot show them to you again if the phone is gone.", + "They rebuild your ecash only. Your identity, chats and contacts are not covered.", + "Recovery has to ask a mint which coins it signed, so write your mint list down beside the words.", + ].map((line) => ( + + + {line} + + ))} + void handleRevealPhrase()} + onCancel={closeBackupSheet} + /> + + )} + + {(backupStep === "show" || backupStep === "view") && ( + <> + + {backupStep === "view" + ? "Your recovery phrase" + : "Write these down"} + + + Twelve words, in this exact order. Anyone who has them has your + balance. + + + {phrase.split(" ").map((word, index) => ( + + {index + 1} + {word} + + ))} + + void handleCopyPhrase()} + accessibilityRole="button" + accessibilityLabel="Copy recovery phrase to the clipboard" + > + + Copy to clipboard + + {backupStep === "show" ? ( + setBackupStep("verify")} + onCancel={closeBackupSheet} + /> + ) : ( + + Done + + )} + + )} + + {backupStep === "verify" && ( + <> + Check your copy + + A phrase nobody wrote down is worse than no phrase, because it + looks like a safety net that is not there. Two words to confirm. + + {verifyPositionList.map((position) => ( + + Word {position} + { + setVerifyAnswers((prev) => ({ ...prev, [position]: text })); + setVerifyError(false); + }} + placeholder="word" + placeholderTextColor={Colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + selectionColor={Colors.accent} + /> + + ))} + {verifyError && ( + + That does not match. Check your written copy. + + )} + (verifyAnswers[p] ?? "").trim().length === 0, + ) || busy !== null + } + onConfirm={handleVerifyPhrase} + onCancel={() => setBackupStep("show")} + /> + + )} + + + {/* Restore from a phrase */} + setShowRestore(false)} + sheetStyle={styles.modalSheet} + scrollable + > + Restore from a phrase + {restoreResult === null ? ( + <> - Send ecash to any Nostr contact via NIP-61. Requires internet - connectivity to publish the zap event. + Enter the twelve words. Airhop re-derives your coins and asks each + mint which of them it signed, so the balance comes back from the + records the mint keeps. - + {mintList.length === 0 + ? "No mints added yet. Recovery has to ask a specific mint, so add the ones you were using first." + : `Will scan: ${mintList.map((m) => hostOf(m.url)).join(", ")}. A mint you have not added is never asked, so its balance stays invisible.`} + + {restoreProgress !== null && ( + + + {restoreProgress} + + )} + void handleRestore()} + onCancel={() => setShowRestore(false)} /> - - - void handleZapConfirm()} - disabled={!zapNpub.trim() || !zapAmount.trim() || isZapping} - > - - {isZapping ? "Sending…" : "Zap"} - - - { - setShowZap(false); - setZapNpub(""); - setZapAmount(""); - setZapNote(""); - }} - > - Cancel - - - - - - - {/* Add mint modal */} - setShowAddMint(false)} - > - - setShowAddMint(false)} - /> - - - Add mint - - Enter any Cashu-compatible mint URL. Try mint.minibits.cash or run - your own with Nutshell. - - - - - Add - - { - setShowAddMint(false); - setMintUrlInput(""); - }} - > - Cancel - - - - - - {/* Generated token modal: shown after offline send completes. */} - setShowGenerated(false)} - > - - setShowGenerated(false)} - /> - - + + ) : ( + <> - + 0 ? "check-circle" : "info"} + size={28} + color={ + restoreResult.proofCount > 0 + ? Colors.online + : Colors.textMuted + } + /> - {generatedToken?.amount.toLocaleString()} + {( + restoreResult.recovered[primary.unit] ?? 0 + ).toLocaleString()} - sats + {primary.unit} - - {generatedToken ? shortenMintUrl(generatedToken.mintUrl) : ""} - - - - This token is live. Send it quickly. The proofs have been removed - from your wallet. + + {restoreResult.proofCount > 0 + ? `Recovered ${String(restoreResult.proofCount)} unspent proof${restoreResult.proofCount === 1 ? "" : "s"} from ${restoreResult.mintsScanned.map(hostOf).join(", ")}.` + : "Nothing was recovered from the mints scanned."} - - { - if (generatedToken) { - void Share.share({ message: generatedToken.token }); - } - }} - accessibilityRole="button" - accessibilityLabel="Share token" - > - - Share - - setShowPeerPicker(true)} - accessibilityRole="button" - accessibilityLabel="Send token to a mesh peer" - > - - Send to peer - - + {restoreResult.alreadySpent > 0 && ( + + {restoreResult.alreadySpent} coin + {restoreResult.alreadySpent === 1 ? " was" : "s were"} found but + already spent, so nothing was credited for them. That is normal: + every coin you have ever spent still appears in the records the + mint keeps. + + )} + {restoreResult.mintsFailed.length > 0 && ( + + Could not reach:{" "} + {restoreResult.mintsFailed + .map((f) => hostOf(f.mintUrl)) + .join(", ")} + . Any balance there is still out there. Try again when you have + a better connection. + + )} { - setShowGenerated(false); - setGeneratedToken(null); + setShowRestore(false); + setRestoreResult(null); }} > Done - - - + + )} + - {/* Peer picker: send the generated token to a nearby mesh peer. */} - setShowPeerPicker(false)} + {/* Consolidate across mints */} + setShowConsolidate(false)} + sheetStyle={styles.modalSheet} + scrollable > - - setShowPeerPicker(false)} - /> - - - Send to peer - - Choose a nearby peer to receive the token via DM. - - {onlinePeers.length === 0 ? ( - - No peers in range. - - Scan the Mesh tab to find nearby peers. - - - ) : ( - onlinePeers.map((peer) => { - const username = peerIDToUsername(peer.peerID); - return ( - handleSendTokenToPeer(peer.peerID)} - accessibilityRole="button" - accessibilityLabel={`Send to ${username}`} - > - - - {username} - - {peer.peerID.slice(0, 8)} - - - - - ); - }) - )} + Move to one mint + + A token can only ever name one mint, so a balance spread across + several cannot pay an amount larger than the biggest one holds. Airhop + can move it: each other mint pays a Lightning invoice issued by the + one you pick. Costs a small routing fee and needs internet. + + {splitAccounts.map((account) => { + const isTarget = account.mintUrl === consolidateTarget; + return ( setShowPeerPicker(false)} + key={account.key} + style={[styles.pickRow, isTarget && styles.pickRowSelected]} + onPress={() => setConsolidateTarget(account.mintUrl)} + accessibilityRole="radio" + accessibilityState={{ selected: isTarget }} + accessibilityLabel={`Move everything to ${hostOf(account.mintUrl)}`} > - Cancel + + + {hostOf(account.mintUrl)} + + {account.balance.toLocaleString()} {account.unit} + {isTarget ? " · destination" : " · will be moved"} + + + ); + })} + void handleConsolidate()} + onCancel={() => setShowConsolidate(false)} + /> + + + {/* Peer picker for a mesh hand-off */} + setShowPeerPicker(false)} + sheetStyle={styles.modalSheet} + > + Send to peer + + The token goes out as an encrypted DM over the mesh. No internet + needed. + + {onlinePeers.length === 0 ? ( + + No peers in range + + Open the Mesh tab to find nearby devices, or share the token + another way. + + ) : ( + onlinePeers.map((peer) => { + const username = peerIDToUsername(peer.peerID); + return ( + handleSendTokenToPeer(peer.peerID)} + accessibilityRole="button" + accessibilityLabel={`Send to ${username}`} + > + + + {username} + + {peer.peerID.slice(0, 8)} + + + + + ); + }) + )} + + setShowPeerPicker(false)} + accessibilityRole="button" + accessibilityLabel="Cancel" + > + Cancel + - + ); } +// ---- Small presentational pieces -------------------------------------------- + +type Styles = ReturnType; + +// The stacked confirm/cancel pair every sheet in the app uses. +function SheetActions({ + styles, + confirmLabel, + confirmDisabled, + onConfirm, + onCancel, +}: { + styles: Styles; + confirmLabel: string; + confirmDisabled: boolean; + onConfirm: () => void; + onCancel: () => void; +}): React.JSX.Element { + return ( + + + {confirmLabel} + + + Cancel + + + ); +} + +// Choose which mint an operation acts on. Renders nothing for a single option, +// because a picker with one row is just noise. +function MintPicker({ + styles, + Colors, + label, + options, + selected, + onSelect, +}: { + styles: Styles; + Colors: ReturnType; + label: string; + options: { mintUrl: string; sub: string }[]; + selected: string | null; + onSelect: (mintUrl: string) => void; +}): React.JSX.Element | null { + if (options.length === 0) return null; + if (options.length === 1) { + return ( + + {label} {hostOf(options[0].mintUrl)} + + ); + } + return ( + <> + {label} + {options.map((option) => { + const active = option.mintUrl === selected; + return ( + onSelect(option.mintUrl)} + accessibilityRole="radio" + accessibilityState={{ selected: active }} + accessibilityLabel={`${label} ${hostOf(option.mintUrl)}`} + > + + + {hostOf(option.mintUrl)} + {option.sub} + + + ); + })} + + ); +} + +function QuoteRow({ + styles, + label, + value, +}: { + styles: Styles; + label: string; + value: string; +}): React.JSX.Element { + return ( + + {label} + {value} + + ); +} + +// ---- Transaction formatting ------------------------------------------------- + +function isCredit(tx: WalletTx): boolean { + return tx.kind === "receive" || tx.kind === "mint" || tx.kind === "nutzap-in"; +} + +function txIcon(tx: WalletTx): React.ComponentProps["name"] { + switch (tx.kind) { + case "receive": + return "arrow-down-left"; + case "send": + return "arrow-up-right"; + case "mint": + return "download"; + case "melt": + return "upload"; + case "nutzap-in": + case "nutzap-out": + return "zap"; + case "swap": + return "refresh-cw"; + } +} + +function txTitle(tx: WalletTx): string { + switch (tx.kind) { + case "receive": + return tx.status === "pending" ? "Received, unconfirmed" : "Received"; + case "send": + return tx.status === "reclaimed" ? "Send reclaimed" : "Sent"; + case "mint": + return "Lightning deposit"; + case "melt": + return "Lightning withdrawal"; + case "nutzap-in": + return "Nutzap received"; + case "nutzap-out": + return "Nutzap sent"; + case "swap": + return tx.status === "failed" + ? "Spent proofs removed" + : "Proofs refreshed"; + } +} + +function relativeTime(ms: number): string { + const delta = Date.now() - ms; + if (delta < 60_000) return "just now"; + const minutes = Math.floor(delta / 60_000); + if (minutes < 60) return `${String(minutes)}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${String(hours)}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${String(days)}d ago`; + return new Date(ms).toLocaleDateString(); +} + function createStyles(Colors: ReturnType) { return StyleSheet.create({ container: { flex: 1, backgroundColor: Colors.bg, }, - content: { - padding: Spacing.base, - gap: Spacing.base, - paddingBottom: TAB_BAR_CLEARANCE, + content: { + padding: Spacing.base, + gap: Spacing.base, + paddingBottom: TAB_BAR_CLEARANCE, + }, + section: { + gap: Spacing.sm, + }, + sectionTitle: { + fontSize: FontSize.xs, + color: Colors.textMuted, + letterSpacing: 0.8, + textTransform: "uppercase", + paddingHorizontal: Spacing.xs, + }, + // Banners + banner: { + flexDirection: "row", + gap: Spacing.md, + alignItems: "flex-start", + borderRadius: Radius.lg, + borderWidth: 1, + padding: Spacing.base, + }, + bannerDanger: { + backgroundColor: Colors.dangerDim, + borderColor: Colors.danger, + }, + bannerWarn: { + backgroundColor: Colors.surfaceRaised, + borderColor: Colors.border, + }, + bannerText: { + flex: 1, + fontSize: FontSize.sm, + color: Colors.textSecondary, + lineHeight: FontSize.sm * 1.5, + }, + // Balance + balanceCard: { + backgroundColor: Colors.surface, + borderRadius: Radius.lg, + borderWidth: 1, + borderColor: Colors.border, + padding: Spacing.lg, + gap: Spacing.sm, + }, + balanceLabel: { + fontSize: FontSize.xs, + color: Colors.textMuted, + letterSpacing: 0.8, + textTransform: "uppercase", + }, + balanceRow: { + flexDirection: "row", + alignItems: "flex-end", + gap: Spacing.sm, + }, + balanceAmount: { + fontSize: FontSize["3xl"], + fontWeight: FontWeight.bold, + color: Colors.textPrimary, + lineHeight: FontSize["3xl"] * 1.1, + }, + balanceUnit: { + fontSize: FontSize.lg, + color: Colors.textMuted, + fontWeight: FontWeight.medium, + marginBottom: 4, + }, + balanceNote: { + flexDirection: "row", + alignItems: "center", + gap: Spacing.xs, + }, + balanceNoteText: { + fontSize: FontSize.sm, + color: Colors.textMuted, + flex: 1, + }, + balanceSubtitle: { + fontSize: FontSize.sm, + color: Colors.textMuted, + marginTop: Spacing.xs, + }, + // Pending sends + pendingCard: { + backgroundColor: Colors.surface, + borderRadius: Radius.lg, + borderWidth: 1, + borderColor: Colors.borderStrong, + padding: Spacing.base, + gap: Spacing.sm, + }, + pendingHeader: { + flexDirection: "row", + alignItems: "center", + gap: Spacing.sm, + }, + pendingAmount: { + flex: 1, + fontSize: FontSize.base, + fontWeight: FontWeight.semibold, + color: Colors.textPrimary, + fontFamily: FontFamily.mono, + }, + pendingTime: { + fontSize: FontSize.xs, + color: Colors.textMuted, + }, + pendingBody: { + fontSize: FontSize.sm, + color: Colors.textMuted, + lineHeight: FontSize.sm * 1.5, + }, + pendingActions: { + flexDirection: "row", + flexWrap: "wrap", + gap: Spacing.sm, + }, + pendingBtn: { + paddingHorizontal: Spacing.md, + paddingVertical: Spacing.xs, + borderRadius: Radius.full, + backgroundColor: Colors.surfaceRaised, + borderWidth: 1, + borderColor: Colors.border, + }, + pendingBtnText: { + fontSize: FontSize.sm, + color: Colors.textSecondary, + fontWeight: FontWeight.medium, + }, + pendingBtnDanger: { + backgroundColor: Colors.dangerDim, + borderColor: Colors.danger, + }, + pendingBtnDangerText: { + fontSize: FontSize.sm, + color: Colors.danger, + fontWeight: FontWeight.medium, + }, + // Empty state + emptyCard: { + backgroundColor: Colors.surface, + borderRadius: Radius.lg, + borderWidth: 1, + borderColor: Colors.border, + padding: Spacing.xl, + alignItems: "center", + gap: Spacing.sm, + }, + emptyTitle: { + fontSize: FontSize.base, + color: Colors.textSecondary, + fontWeight: FontWeight.medium, + }, + emptyBody: { + fontSize: FontSize.sm, + color: Colors.textMuted, + textAlign: "center", + lineHeight: FontSize.sm * 1.6, + }, + emptyCta: { + flexDirection: "row", + alignItems: "center", + gap: Spacing.xs, + marginTop: Spacing.xs, + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.sm, + borderRadius: Radius.full, + borderWidth: 1, + borderColor: Colors.border, + backgroundColor: Colors.surfaceRaised, + }, + emptyCtaText: { + fontSize: FontSize.sm, + fontWeight: FontWeight.semibold, + color: Colors.accent, + }, + // Mint rows + mintRow: { + backgroundColor: Colors.surface, + borderRadius: Radius.lg, + borderWidth: 1, + borderColor: Colors.border, + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.md, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + mintLeft: { + flexDirection: "row", + alignItems: "center", + gap: Spacing.md, + flex: 1, + }, + mintIconCircle: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: Colors.surfaceRaised, + borderWidth: 1, + borderColor: Colors.border, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + mintInfo: { + flex: 1, + gap: 2, + }, + mintName: { + fontSize: FontSize.base, + fontWeight: FontWeight.medium, + color: Colors.textPrimary, + fontFamily: FontFamily.mono, + }, + mintMeta: { + fontSize: FontSize.xs, + color: Colors.textMuted, + }, + mintRight: { + alignItems: "flex-end", + gap: 1, + }, + mintBalance: { + fontSize: FontSize.md, + fontWeight: FontWeight.bold, + color: Colors.textPrimary, + fontFamily: FontFamily.mono, + }, + mintUnit: { + fontSize: FontSize.xs, + color: Colors.textMuted, + }, + smallBtn: { + marginTop: 4, + minWidth: 64, + alignItems: "center", + paddingHorizontal: Spacing.sm, + paddingVertical: 3, + borderRadius: Radius.full, + backgroundColor: Colors.surfaceRaised, + borderWidth: 1, + borderColor: Colors.border, + }, + smallBtnDisabled: { + opacity: 0.4, + }, + smallBtnText: { + fontSize: FontSize.xs, + color: Colors.textSecondary, + fontWeight: FontWeight.medium, + }, + // Inline call to action inside a section (e.g. the split-balance prompt). + inlineAction: { + flexDirection: "row", + alignItems: "center", + gap: Spacing.sm, + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.md, + borderRadius: Radius.lg, + borderWidth: 1, + borderColor: Colors.border, + backgroundColor: Colors.surfaceRaised, + }, + inlineActionText: { + flex: 1, + fontSize: FontSize.sm, + color: Colors.accent, + fontWeight: FontWeight.medium, }, - // Quick actions: bordered pill buttons, matching the profile screen's - // Share ID / Share QR pills (icon + text side by side). Raised fill - // (not plain `surface`) so the pill reads against the white card - // instead of blending into it. - // Balance card - balanceCard: { + // Backup + backupCard: { backgroundColor: Colors.surface, borderRadius: Radius.lg, borderWidth: 1, borderColor: Colors.border, - padding: Spacing.lg, + padding: Spacing.base, gap: Spacing.md, }, - balanceLabel: { - fontSize: FontSize.xs, - color: Colors.textMuted, - letterSpacing: 0.8, - textTransform: "uppercase", - }, - balanceRow: { + backupHeader: { flexDirection: "row", - alignItems: "flex-end", + alignItems: "center", gap: Spacing.sm, }, - balanceAmount: { - fontSize: FontSize["3xl"], - fontWeight: FontWeight.bold, + backupTitle: { + flex: 1, + fontSize: FontSize.base, + fontWeight: FontWeight.semibold, color: Colors.textPrimary, - lineHeight: FontSize["3xl"] * 1.1, }, - balanceUnit: { - fontSize: FontSize.lg, + pill: { + paddingHorizontal: Spacing.sm, + paddingVertical: 2, + borderRadius: Radius.full, + borderWidth: 1, + borderColor: Colors.border, + backgroundColor: Colors.surfaceRaised, + }, + pillOn: { + borderColor: Colors.online, + }, + pillWarn: { + borderColor: Colors.danger, + backgroundColor: Colors.dangerDim, + }, + pillText: { + fontSize: FontSize.xs, color: Colors.textMuted, - fontWeight: FontWeight.medium, - marginBottom: 4, + fontWeight: FontWeight.semibold, }, - balanceSubtitle: { + pillTextOn: { + color: Colors.online, + }, + pillTextWarn: { + color: Colors.danger, + }, + backupBody: { fontSize: FontSize.sm, color: Colors.textMuted, + lineHeight: FontSize.sm * 1.5, }, - // Mints section - section: { + backupWarnRow: { + flexDirection: "row", gap: Spacing.sm, - }, - emptyMints: { - backgroundColor: Colors.surface, + alignItems: "flex-start", + backgroundColor: Colors.surfaceRaised, borderRadius: Radius.lg, + padding: Spacing.md, + }, + backupWarnText: { + flex: 1, + fontSize: FontSize.sm, + color: Colors.textSecondary, + lineHeight: FontSize.sm * 1.5, + }, + backupHint: { + fontSize: FontSize.xs, + color: Colors.textMuted, + lineHeight: FontSize.xs * 1.7, + fontFamily: FontFamily.mono, + }, + backupActions: { + flexDirection: "row", + gap: Spacing.sm, + }, + backupBtn: { + flex: 1, + minHeight: 44, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: Spacing.xs, + borderRadius: Radius.full, + backgroundColor: Colors.surfaceRaised, borderWidth: 1, borderColor: Colors.border, - padding: Spacing.xl, + }, + backupBtnText: { + fontSize: FontSize.sm, + fontWeight: FontWeight.semibold, + color: Colors.accent, + }, + // Recovery phrase sheet + bulletRow: { + flexDirection: "row", + gap: Spacing.md, + alignItems: "flex-start", + }, + bulletDot: { + width: 5, + height: 5, + borderRadius: 2.5, + backgroundColor: Colors.textMuted, + marginTop: 7, + flexShrink: 0, + }, + bulletText: { + flex: 1, + fontSize: FontSize.sm, + color: Colors.textSecondary, + lineHeight: FontSize.sm * 1.5, + }, + // Two columns of six, so the numbering reads down each column the way it + // is written on paper. + phraseGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: Spacing.sm, + }, + phraseCell: { + width: "47%", + flexDirection: "row", alignItems: "center", gap: Spacing.sm, + paddingHorizontal: Spacing.md, + paddingVertical: Spacing.sm, + borderRadius: Radius.lg, + borderWidth: 1, + borderColor: Colors.border, + backgroundColor: Colors.surfaceRaised, }, - emptyMintsText: { - fontSize: FontSize.base, + phraseIndex: { + fontSize: FontSize.xs, + color: Colors.textMuted, + fontFamily: FontFamily.mono, + minWidth: 16, + textAlign: "right", + }, + phraseWord: { + flex: 1, + fontSize: FontSize.sm, + color: Colors.textPrimary, + fontFamily: FontFamily.mono, + fontWeight: FontWeight.medium, + }, + verifyRow: { + gap: Spacing.xs, + }, + verifyLabel: { + fontSize: FontSize.sm, color: Colors.textSecondary, fontWeight: FontWeight.medium, }, - emptyMintsSubtext: { + verifyError: { fontSize: FontSize.sm, + color: Colors.danger, + }, + // Radio-style picker rows (consolidate destination). + pickRow: { + flexDirection: "row", + alignItems: "center", + gap: Spacing.md, + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.md, + borderRadius: Radius.lg, + borderWidth: 1, + borderColor: Colors.border, + backgroundColor: Colors.surfaceRaised, + }, + pickRowSelected: { + borderColor: Colors.accent, + }, + pickInfo: { + flex: 1, + gap: 2, + }, + pickTitle: { + fontSize: FontSize.base, + color: Colors.textPrimary, + fontWeight: FontWeight.medium, + fontFamily: FontFamily.mono, + }, + pickSub: { + fontSize: FontSize.xs, color: Colors.textMuted, - textAlign: "center", - lineHeight: FontSize.sm * 1.6, }, - mintRow: { + // Lightning + lightningCard: { backgroundColor: Colors.surface, borderRadius: Radius.lg, borderWidth: 1, borderColor: Colors.border, - paddingHorizontal: Spacing.base, - paddingVertical: Spacing.md, + padding: Spacing.base, + gap: Spacing.md, + }, + lightningBody: { + fontSize: FontSize.sm, + color: Colors.textMuted, + lineHeight: FontSize.sm * 1.5, + }, + lightningActions: { flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + gap: Spacing.sm, }, - mintLeft: { + lightningBtn: { + flex: 1, + minHeight: 44, flexDirection: "row", alignItems: "center", - gap: Spacing.md, - flex: 1, - }, - mintIconCircle: { - width: 40, - height: 40, - borderRadius: 20, + justifyContent: "center", + gap: Spacing.xs, + borderRadius: Radius.full, backgroundColor: Colors.surfaceRaised, borderWidth: 1, borderColor: Colors.border, + }, + lightningBtnText: { + fontSize: FontSize.sm, + fontWeight: FontWeight.semibold, + color: Colors.accent, + }, + lightningPending: { + fontSize: FontSize.xs, + color: Colors.textMuted, + }, + // History + historyCard: { + backgroundColor: Colors.surface, + borderRadius: Radius.lg, + borderWidth: 1, + borderColor: Colors.border, + paddingHorizontal: Spacing.base, + }, + historyRow: { + flexDirection: "row", alignItems: "center", - justifyContent: "center", + gap: Spacing.md, + paddingVertical: Spacing.md, + }, + historyIcon: { flexShrink: 0, }, - mintInfo: { + historyText: { flex: 1, gap: 2, }, - mintName: { - fontSize: FontSize.base, - fontWeight: FontWeight.medium, + historyTitle: { + fontSize: FontSize.sm, color: Colors.textPrimary, - fontFamily: FontFamily.mono, + fontWeight: FontWeight.medium, }, - mintProofs: { + historySub: { fontSize: FontSize.xs, color: Colors.textMuted, }, - mintRight: { - alignItems: "flex-end", - gap: 1, - }, - mintBalance: { - fontSize: FontSize.md, - fontWeight: FontWeight.bold, - color: Colors.textPrimary, + historyAmount: { + fontSize: FontSize.sm, + color: Colors.textSecondary, fontFamily: FontFamily.mono, + fontWeight: FontWeight.semibold, }, - mintUnit: { - fontSize: FontSize.xs, - color: Colors.textMuted, + historyCredit: { + color: Colors.online, + }, + historyDivider: { + height: StyleSheet.hairlineWidth, + backgroundColor: Colors.border, }, // Info panel infoPanel: { @@ -1271,6 +3003,7 @@ function createStyles(Colors: ReturnType) { flexDirection: "row", gap: Spacing.md, alignItems: "flex-start", + paddingVertical: Spacing.xs, }, infoPanelIcon: { marginTop: 2, @@ -1294,27 +3027,12 @@ function createStyles(Colors: ReturnType) { height: StyleSheet.hairlineWidth, backgroundColor: Colors.border, }, - // Modal - modalOverlay: { - flex: 1, - backgroundColor: Colors.overlay, - justifyContent: "flex-end", - }, + // Sheets modalSheet: { - backgroundColor: Colors.surface, - borderTopLeftRadius: Radius["2xl"], - borderTopRightRadius: Radius["2xl"], - padding: Spacing.xl, + paddingHorizontal: Spacing.xl, + paddingBottom: Spacing.xl, gap: Spacing.md, }, - handle: { - width: 36, - height: 4, - borderRadius: 2, - backgroundColor: Colors.borderStrong, - alignSelf: "center", - marginBottom: Spacing.xs, - }, modalTitle: { fontSize: FontSize.md, fontWeight: FontWeight.semibold, @@ -1323,6 +3041,7 @@ function createStyles(Colors: ReturnType) { modalSubtitle: { fontSize: FontSize.sm, color: Colors.textMuted, + lineHeight: FontSize.sm * 1.5, }, tokenInput: { backgroundColor: Colors.surfaceRaised, @@ -1341,16 +3060,24 @@ function createStyles(Colors: ReturnType) { minHeight: 0, fontFamily: undefined, }, - // Stacked full-width pills, primary action on top, the same pattern as - // every other confirm sheet fixed this session (panic wipe, Tor, etc.). + tokenInputMono: { + fontFamily: FontFamily.mono, + fontSize: FontSize.xs, + letterSpacing: 0.3, + }, + // Stacked, full-width pill actions, same shape and rhythm as every other + // sheet in the app (see `settings/shared` sheetActions): the group owns the + // spacing between its buttons so it does not compound with the sheet's own + // gap, and a lone button carries no stray margin. modalActions: { width: "100%", marginTop: Spacing.xs, + gap: Spacing.sm, }, modalCancel: { width: "100%", minHeight: 50, - marginTop: Spacing.sm, + paddingVertical: Spacing.md, backgroundColor: Colors.surfaceRaised, borderWidth: 1, borderColor: Colors.border, @@ -1360,12 +3087,13 @@ function createStyles(Colors: ReturnType) { }, modalCancelText: { fontSize: FontSize.base, - color: Colors.textSecondary, - fontWeight: FontWeight.medium, + color: Colors.textPrimary, + fontWeight: FontWeight.semibold, }, modalConfirm: { width: "100%", minHeight: 50, + paddingVertical: Spacing.md, backgroundColor: Colors.accent, borderRadius: Radius.full, alignItems: "center", @@ -1379,27 +3107,41 @@ function createStyles(Colors: ReturnType) { color: Colors.textInverse, fontWeight: FontWeight.bold, }, - tokenInputMono: { - fontFamily: FontFamily.mono, - fontSize: FontSize.xs, - letterSpacing: 0.3, - }, - // Redeem button on mint rows. - redeemBtn: { - marginTop: 4, - paddingHorizontal: Spacing.sm, - paddingVertical: 3, - borderRadius: Radius.full, + // Quote breakdown + quoteBox: { backgroundColor: Colors.surfaceRaised, + borderRadius: Radius.lg, borderWidth: 1, borderColor: Colors.border, + padding: Spacing.base, + gap: Spacing.xs, }, - redeemBtnText: { - fontSize: FontSize.xs, - color: Colors.textSecondary, - fontWeight: FontWeight.medium, + quoteRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + quoteLabel: { + fontSize: FontSize.sm, + color: Colors.textMuted, + }, + quoteValue: { + fontSize: FontSize.sm, + color: Colors.textPrimary, + fontFamily: FontFamily.mono, + }, + waitingRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: Spacing.sm, + paddingVertical: Spacing.sm, + }, + waitingText: { + fontSize: FontSize.sm, + color: Colors.textMuted, }, - // Generated token modal. + // Generated token generatedHeader: { alignItems: "center", gap: Spacing.xs, @@ -1424,6 +3166,7 @@ function createStyles(Colors: ReturnType) { generatedMint: { fontSize: FontSize.sm, color: Colors.textMuted, + textAlign: "center", }, generatedHint: { fontSize: FontSize.xs, @@ -1435,11 +3178,11 @@ function createStyles(Colors: ReturnType) { generatedActions: { width: "100%", gap: Spacing.sm, - marginVertical: Spacing.sm, }, generatedActionBtn: { width: "100%", minHeight: 50, + paddingVertical: Spacing.md, flexDirection: "row", alignItems: "center", justifyContent: "center", @@ -1454,7 +3197,7 @@ function createStyles(Colors: ReturnType) { fontWeight: FontWeight.semibold, color: Colors.accent, }, - // Peer picker modal. + // Peer picker peerPickerRow: { flexDirection: "row", alignItems: "center", diff --git a/src/services/ecash-transfer.ts b/src/services/ecash-transfer.ts new file mode 100644 index 0000000..7d6af6a --- /dev/null +++ b/src/services/ecash-transfer.ts @@ -0,0 +1,234 @@ +// Sending ecash to a mesh peer, in one place. +// +// Three screens can hand ecash to a peer: the Wallet tab's peer picker, the DM +// thread's attach menu, and the Mesh tab's peer sheet. They used to each +// open-code the same sequence and each got it slightly wrong: one used a +// hard-coded "local" sender id, none of them warned about inexact amounts, none +// of them attached a message id so the DM could report delivery, and all three +// deleted the proofs before the DM had left the device. +// +// The flow here is the same one the Wallet tab uses for a manual token, with +// the delivery step filled in: +// +// 1. Quote. If the denominations held cannot make the amount exactly, ask +// first, because offline there is no change and the difference is a gift. +// 2. Prepare. Proofs move to the reserved bucket and a pending transaction is +// opened holding the token string. +// 3. Post the token into the DM thread and hand it to the mesh. +// 4. Leave the transaction pending. Delivery over a multi-hop mesh is not +// instant and not guaranteed, so the user keeps the ability to reclaim +// until either they confirm it landed or the mint tells us the proofs were +// redeemed (which `reconcile` checks). + +import { showAlert, useAlertStore } from "../store/alert-store"; +import { useChatStore, type ChatMessage } from "../store/chat-store"; +import { getMeshService, type MeshService } from "./mesh-service"; +import { + failSend, + prepareSend, + quoteSend, + WalletError, + type PreparedSend, +} from "./wallet-service"; + +export interface SendEcashParams { + peerID: string; + amount: number; + memo?: string; + unit?: string; + // Sender display name for the local echo. The DM thread has the user's real + // nickname; the peer sheet and wallet picker only need "You". + senderNickname?: string; +} + +// How the DM actually left the device. `sendDm` already works this out; before +// this was surfaced, every screen said "sent over the mesh" even when there was +// no route and the token had merely been queued, which is the difference +// between "they have it" and "they might get it tomorrow". +export type DeliveryRoute = ReturnType; + +export interface SendEcashResult { + prepared: PreparedSend; + route: DeliveryRoute; +} + +// One sentence describing where the token went, for the confirmation the user +// sees. Deliberately honest about the queued cases: the money is reserved and +// reclaimable either way, but "on its way" and "waiting for a route" are very +// different things to the person who just paid. +export function describeRoute(route: DeliveryRoute): string { + switch (route) { + case "sent": + return "Handed straight to their device over the mesh."; + case "sent-nostr": + return "They were out of Bluetooth range, so it went over the internet instead."; + case "needs-courier": + return "No route to them right now. It will be carried by other devices and delivered when one reaches them."; + case "queued": + return "They are not reachable yet. It is queued and will send as soon as they are."; + } +} + +// Returns the prepared send and its delivery route, or null when the user +// cancelled or the wallet refused. Errors are reported to the user here so the +// call sites do not each need their own copy of the message mapping. +export async function sendEcashToPeer( + params: SendEcashParams, +): Promise { + const amount = Math.floor(params.amount); + if (!Number.isFinite(amount) || amount <= 0) return null; + + const unit = params.unit ?? "sat"; + const service = getMeshService(); + if (!service) { + showAlert( + "Mesh offline", + "The mesh service is not running, so there is no way to hand the token over. Nothing has been deducted.", + ); + return null; + } + + try { + const quote = await quoteSend({ amount, unit }); + if (!quote.exact) { + // Ask before reserving anything. An inexact offline send overpays and + // cannot be undone once the recipient redeems. + const confirmed = await confirm( + "Can't send that exact amount", + `Your proofs can't make exactly ${amount.toLocaleString()} ${unit} offline. The smallest token you can build is ${quote.spend.toLocaleString()} ${unit}, and the extra ${(quote.spend - amount).toLocaleString()} ${unit} goes to them with no way to get it back.\n\nRefreshing at the mint while online splits your proofs into denominations that make this exact.`, + `Send ${quote.spend.toLocaleString()}`, + ); + if (!confirmed) return null; + } + + const prepared = await prepareSend({ + amount, + unit, + memo: params.memo, + counterparty: params.peerID, + allowInexact: true, + }); + + const route = deliverTokenToPeer({ + peerID: params.peerID, + prepared, + senderNickname: params.senderNickname, + }); + return { prepared, route }; + } catch (err) { + reportWalletError(err); + return null; + } +} + +// Post an already-prepared token into a DM thread and hand it to the mesh. +// +// Split out from `sendEcashToPeer` because the Wallet tab's peer picker acts on +// a token that was built earlier (the user chose Share, changed their mind, and +// picked a peer instead). Preparing a second one there would reserve a second +// set of proofs for the same payment. +export function deliverTokenToPeer(params: { + peerID: string; + prepared: PreparedSend; + senderNickname?: string; +}): DeliveryRoute { + const service = getMeshService(); + if (!service) return "queued"; + + const channel = `dm:${params.peerID}`; + const chat = useChatStore.getState(); + chat.addChannel(channel); + + // The transaction id doubles as the message id, so the DM's delivery status + // and the wallet's pending send refer to the same thing and a later + // "delivered" receipt can settle the transaction. + const message: ChatMessage = { + id: params.prepared.txId, + channel, + senderID: service.getPeerID(), + senderNickname: params.senderNickname ?? "You", + text: params.prepared.token, + timestampMs: Date.now(), + isMine: true, + status: "sending", + }; + chat.addMessage(message); + const route = service.sendDm( + params.peerID, + params.prepared.token, + params.prepared.txId, + ); + + // Record the awkward routes on the transaction itself, so the Pending card in + // the Wallet tab explains why a send is still sitting there instead of just + // showing an unexplained pending entry days later. + if (route === "queued" || route === "needs-courier") { + failSend(params.prepared.txId, describeRoute(route)); + } + return route; +} + +// Map a WalletError onto the alert the user sees. Kept here rather than in each +// screen so the wording for "not enough balance" is identical everywhere. +export function reportWalletError(err: unknown): void { + if (err instanceof WalletError) { + const titles: Record = { + locked: "Wallet locked", + offline: "Mint unreachable", + "tor-blocked": "Blocked while Tor is on", + insufficient: "Not enough balance", + inexact: "Can't send that exact amount", + "no-mint": "No mint", + unsupported: "Mint can't do that", + "mint-error": "Mint refused", + "invalid-token": "Unreadable token", + "forged-token": "Token rejected", + "already-spent": "Already spent", + }; + showAlert( + titles[err.code] ?? "Could not send", + err.detail ? `${err.message}\n\n${err.detail}` : err.message, + ); + return; + } + showAlert("Could not send", String(err)); +} + +// The app's alert store is callback-based; this wraps it so the send flow above +// reads as a straight line rather than a nest of continuations. +// +// Tapping the backdrop closes the alert without invoking any button's onPress, +// so a button-only promise would never settle and the send would hang holding +// no proofs but also never returning. Watching `visible` catches that: any +// dismissal that was not an explicit confirm resolves false. +function confirm( + title: string, + message: string, + confirmLabel: string, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean): void => { + if (settled) return; + settled = true; + unsubscribe(); + resolve(value); + }; + // custom-alert calls `hide()` and *then* the button's onPress, both + // synchronously, so this listener fires on a confirm too. Deferring by a + // tick lets the onPress that follows settle the promise first; if none + // does, the dismissal was a backdrop tap and cancel is the right answer. + const unsubscribe = useAlertStore.subscribe((state) => { + if (state.visible) return; + setTimeout(() => finish(false), 0); + }); + showAlert(title, message, [ + { text: "Cancel", style: "cancel", onPress: () => finish(false) }, + { + text: confirmLabel, + style: "destructive", + onPress: () => finish(true), + }, + ]); + }); +} diff --git a/src/services/wallet-service.ts b/src/services/wallet-service.ts new file mode 100644 index 0000000..0320324 --- /dev/null +++ b/src/services/wallet-service.ts @@ -0,0 +1,2223 @@ +// Wallet service: the single place that talks to Cashu mints. +// +// Every screen (Wallet tab, DM thread, peer sheet) goes through here, so the +// rules that protect real money live in one file instead of being re-derived in +// three UIs. Before this existed each screen open-coded its own "pick proofs, +// serialise, delete them from the store" sequence, which meant a crash or a +// dismissed sheet between the delete and the delivery destroyed the value. +// +// Guarantees this module provides +// ------------------------------- +// 1. Proofs are never deleted to send. They are moved into a reserved bucket +// against a transaction id and only dropped once delivery is confirmed, so +// an interrupted send is always recoverable (`reclaimSend`). +// 2. Nothing is credited to the balance without either a mint swap or a +// passing DLEQ check; anything credited offline is marked unverified and +// redeemed first when connectivity returns. +// 3. A mint call is never made silently over the clear net while the user has +// Tor on (iOS), because Arti only wraps WebSockets, not fetch. +// 4. Units are never mixed. A (mint, unit) pair is one account. +// +// Offline is the normal case, not the error case: `getWallet` builds a fully +// functional wallet from the cached keysets, so fee maths, proof selection and +// DLEQ verification all work with the radio off. Only swap, mint and melt +// actually need the network. + +import { + Mint, + Wallet, + isMintOperationError, + type CounterSource, + type GetInfoResponse, + type KeyChainCache, + type MeltQuoteBolt11Response, + type MintQuoteBolt11Response, + type Proof, + type ProofLike, +} from "@cashu/cashu-ts"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { Platform } from "react-native"; +import EncryptedStorage from "react-native-encrypted-storage"; +import type { NostrClient } from "../core/nostr/nostr-client"; +import { + buildToken, + decodeToken, + feeForProofs, + selectProofsForAmount, + toProofLike, + toStoredProof, + verifyTokenOffline, + type TokenInfo, +} from "../core/payments/cashu"; +import { + fetchNutzapInfo, + publishNutzap, + publishNutzapInfo, + subscribeNutzaps, + type NutzapInfo, +} from "../core/payments/nutzap"; +import { + generateRecoveryPhrase, + isValidRecoveryPhrase, + loadStoredPhrase, + normalizeRecoveryPhrase, + recoveryPhraseToSeed, + storePhrase, +} from "../core/payments/wallet-seed"; +import { useMeshStateStore } from "../store/mesh-state-store"; +import { useSettingsStore } from "../store/settings-store"; +import { + accountKey, + bootstrapWalletStorage, + isWalletStorageReady, + normalizeMintUrl, + useWalletStore, + type StoredMint, + type StoredProof, + type WalletTx, +} from "../store/wallet-store"; + +// ---- Errors ----------------------------------------------------------------- + +export type WalletErrorCode = + // The encrypted proof store could not be opened (Keychain/Keystore refused). + | "locked" + // The mint could not be reached. The offline path may still be available. + | "offline" + // Tor is on and this platform would send the mint request in the clear. + | "tor-blocked" + // Not enough spendable balance at any single mint for this amount. + | "insufficient" + // The exact amount cannot be made from the proofs held, offline. + | "inexact" + // No mint is configured, or the named mint is unknown. + | "no-mint" + // The mint does not support a NUT this operation needs. + | "unsupported" + // The mint accepted the request and rejected it on its own terms. + | "mint-error" + // The token string did not decode. + | "invalid-token" + // A DLEQ witness failed: the mint did not sign this. Do not credit it. + | "forged-token" + // The mint says these proofs are already spent. + | "already-spent"; + +export class WalletError extends Error { + readonly code: WalletErrorCode; + readonly detail?: string; + + constructor(code: WalletErrorCode, message: string, detail?: string) { + super(message); + this.name = "WalletError"; + this.code = code; + this.detail = detail; + } +} + +// Turn anything thrown by cashu-ts or fetch into a WalletError, so callers only +// ever branch on our own codes. +function asWalletError(err: unknown, fallback: WalletErrorCode): WalletError { + if (err instanceof WalletError) return err; + if (isMintOperationError(err)) { + return new WalletError("mint-error", err.message, String(err)); + } + const message = err instanceof Error ? err.message : String(err); + // fetch failures in React Native surface as a bare "Network request failed". + if (/network|fetch|timeout|abort/i.test(message)) { + return new WalletError("offline", "Could not reach the mint.", message); + } + return new WalletError(fallback, message, String(err)); +} + +// ---- Network policy --------------------------------------------------------- + +// Refuse a mint call that would leak this device's IP while the user believes +// their traffic is anonymised. +// +// On Android, Orbot runs as a VPN and captures every socket, so a mint request +// really does go through Tor and there is nothing to block. On iOS, Tor is Arti +// behind a SOCKS5 proxy that we only wire into the Nostr WebSocket; plain fetch +// bypasses it entirely. Silently making the request there would tell the mint +// exactly who is swapping which proofs, which is the one thing a Tor user is +// trying to avoid, so it is refused unless they have explicitly allowed it. +// `torActive` is read from the mesh state store rather than calling +// `isTorRoutingActive()` directly: tor-routing pulls in the BLE native module +// at import time, and this module is reachable from the panic wipe, which must +// stay loadable without a native host. The store is the same flag, mirrored by +// tor-routing's single writer. +function assertMintNetworkAllowed(): void { + if (!useMeshStateStore.getState().torActive) return; + if (Platform.OS !== "ios") return; + if (useSettingsStore.getState().allowMintOverClearnet) return; + throw new WalletError( + "tor-blocked", + "Mint requests do not go through Tor on iOS.", + "Arti only wraps Nostr WebSockets, so this request would reach the mint over the clear net and link your IP to these proofs. Allow it under Settings > Security, or turn Tor off first. Sending and receiving ecash over the mesh still works.", + ); +} + +// Whether a mint call would currently be refused, for disabling buttons ahead +// of time rather than failing after a tap. +export function isMintNetworkBlocked(): boolean { + try { + assertMintNetworkAllowed(); + return false; + } catch { + return true; + } +} + +// ---- Recovery phrase (NUT-13 deterministic secrets) ------------------------- + +// The seed derived from the user's recovery phrase, held in memory for the +// process lifetime once loaded. Null means backup is off and proof secrets are +// random, which is the default: nothing is restorable until the user opts in. +// +// This is the *only* thing that decides whether new proofs are recoverable, so +// it is read at wallet construction and every wallet is rebuilt when it flips. +let activeSeed: Uint8Array | null = null; + +function isBackupActive(): boolean { + return activeSeed !== null; +} + +// NUT-13 derives a proof's secret from (seed, keyset id, counter). Reusing a +// counter recreates a secret the mint has already signed, which it rejects as a +// duplicate, so the cursor must be persisted and must only ever move forward. +// +// The store write is synchronous; persisting it to the encrypted file is not. +// A crash in that window can lose a counter bump, and the next swap using that +// counter fails with a duplicate error the user can simply retry. Restore also +// pushes the cursor past everything the mint has ever signed, which repairs it +// permanently. Losing money this way is not possible: a rejected swap leaves +// the input proofs untouched. +const counterSource: CounterSource = { + reserve(keysetId: string, n: number) { + return Promise.resolve( + useWalletStore.getState().reserveCounters(keysetId, n), + ); + }, + advanceToAtLeast(keysetId: string, minNext: number) { + useWalletStore.getState().advanceCounter(keysetId, minNext); + return Promise.resolve(); + }, + snapshot() { + return Promise.resolve({ ...useWalletStore.getState().counters }); + }, +}; + +// ---- Wallet instances ------------------------------------------------------- + +// One Wallet per (mint, unit). Building one is cheap from cache but involves a +// round trip online, so they are reused for the process lifetime. Keysets are +// re-fetched by `refreshMint`, not by rebuilding. +const wallets = new Map(); + +// Wallets capture the seed at construction, so any change to backup state has +// to throw the cache away or half the app would keep minting random secrets. +function invalidateWallets(): void { + wallets.clear(); +} + +// Options every Wallet is built with. With a seed present, outputs are +// deterministic and counter-tracked; without one, cashu-ts falls back to random +// secrets, which is exactly the pre-backup behaviour. +function walletOptions(unit: string): { + unit: string; + bip39seed?: Uint8Array; + secretsPolicy?: "deterministic" | "random"; + counterSource?: CounterSource; +} { + if (activeSeed === null) return { unit, secretsPolicy: "random" }; + return { + unit, + bip39seed: activeSeed, + secretsPolicy: "deterministic", + counterSource, + }; +} + +// How long a cached keyset is trusted before an online operation refreshes it. +// Mints rotate keysets rarely; a day keeps fees and keys current without +// hammering the mint on every send. +const KEYSET_TTL_MS = 24 * 60 * 60 * 1000; + +function storedMint(mintUrl: string): StoredMint | undefined { + return useWalletStore.getState().mints[normalizeMintUrl(mintUrl)]; +} + +// Build a Wallet for this account. +// +// `offline: true` never touches the network: it either returns a wallet built +// from the cached keysets or throws "offline". `offline: false` prefers the +// cache and refreshes from the mint only when the cache is missing or stale, so +// a send does not pay for a round trip it does not need. +async function getWallet( + mintUrl: string, + unit: string, + opts: { offline?: boolean; forceRefresh?: boolean } = {}, +): Promise { + const url = normalizeMintUrl(mintUrl); + const key = accountKey(url, unit); + const cached = wallets.get(key); + if (cached && !opts.forceRefresh) return cached; + + const record = storedMint(url); + const cacheFresh = + record?.keysetCache !== undefined && + record.infoResponse !== undefined && + Date.now() - (record.keysetCacheAtMs ?? 0) < KEYSET_TTL_MS; + + const wallet = new Wallet(new Mint(url), walletOptions(unit)); + + if (!opts.forceRefresh && cacheFresh) { + try { + wallet.loadMintFromCache( + record.infoResponse as GetInfoResponse, + record.keysetCache as KeyChainCache, + ); + wallets.set(key, wallet); + return wallet; + } catch { + // Cache written by an older cashu-ts, or corrupted. Fall through and + // re-fetch rather than failing the operation. + } + } + + if (opts.offline === true) { + // Last resort offline: a stale cache still verifies DLEQ and prices fees + // correctly for keysets that have not rotated, which beats refusing. + if ( + record?.keysetCache !== undefined && + record.infoResponse !== undefined + ) { + try { + wallet.loadMintFromCache( + record.infoResponse as GetInfoResponse, + record.keysetCache as KeyChainCache, + ); + wallets.set(key, wallet); + return wallet; + } catch { + // fall through to the throw below + } + } + throw new WalletError( + "offline", + "This mint's keys are not cached on this device.", + "Open the wallet once while online to fetch them.", + ); + } + + assertMintNetworkAllowed(); + try { + await wallet.loadMint(opts.forceRefresh === true); + } catch (err) { + throw asWalletError(err, "offline"); + } + persistMintSnapshot(url, unit, wallet); + wallets.set(key, wallet); + return wallet; +} + +// Persist everything the wallet learned from the mint, so the next cold start +// is fully functional offline: keys for DLEQ, fees for selection, units and NUT +// support for feature gating. +function persistMintSnapshot( + mintUrl: string, + unit: string, + wallet: Wallet, +): void { + const store = useWalletStore.getState(); + let cache: KeyChainCache | undefined; + let feePpkByKeysetId: Record | undefined; + let units: string[] | undefined; + try { + cache = wallet.keyChain.cache; + feePpkByKeysetId = {}; + units = []; + for (const keyset of cache.keysets) { + feePpkByKeysetId[keyset.id] = keyset.input_fee_ppk ?? 0; + if (!units.includes(keyset.unit)) units.push(keyset.unit); + } + } catch { + // A wallet built from a partial cache may not expose one; keep what we have. + } + + let info: GetInfoResponse | undefined; + let name: string | undefined; + let description: string | undefined; + let supportedNuts: number[] | undefined; + try { + // `.cache` is the raw /v1/info response the MintInfo wrapper was built + // from, which is exactly what `loadMintFromCache` wants back. + info = wallet.getMintInfo().cache; + name = typeof info.name === "string" ? info.name.slice(0, 64) : undefined; + description = + typeof info.description === "string" + ? info.description.slice(0, 200) + : undefined; + supportedNuts = Object.keys(info.nuts ?? {}) + .map((n) => Number.parseInt(n, 10)) + .filter((n) => Number.isFinite(n)); + } catch { + // Mint info is optional for offline operation; keys are what matter. + } + + store.addMint(mintUrl, { + ...(name !== undefined ? { name } : {}), + ...(description !== undefined ? { description } : {}), + ...(units !== undefined && units.length > 0 + ? { units } + : { units: [unit] }), + ...(supportedNuts !== undefined ? { supportedNuts } : {}), + ...(info !== undefined ? { infoResponse: info } : {}), + ...(cache !== undefined + ? { keysetCache: cache, keysetCacheAtMs: Date.now() } + : {}), + ...(feePpkByKeysetId !== undefined ? { feePpkByKeysetId } : {}), + lastSeenMs: Date.now(), + }); +} + +// Forget every cached Wallet. Called after a panic wipe so a fresh identity +// never reuses another identity's loaded keysets. +export function resetWalletService(): void { + wallets.clear(); + // The recovery phrase went with the keychain the wipe just cleared, so the + // in-memory seed has to go too. Leaving it would keep deriving proofs from a + // phrase the user can no longer see or write down. + activeSeed = null; +} + +// ---- Store readiness -------------------------------------------------------- + +// Open the encrypted proof store. Call once at app start, before the wallet tab +// can be reached. Resolves false when the Keychain/Keystore is unavailable, in +// which case the wallet stays locked rather than falling back to plaintext. +export async function initWalletService(): Promise { + try { + await bootstrapWalletStorage(); + } catch { + return false; + } + // Backup state comes from the keychain, not the store, so it has to be read + // before the first mint operation or new proofs would be created with random + // secrets and quietly fall outside the user's recovery phrase. + try { + await loadBackupState(); + } catch { + // A keychain read failure leaves backup off, which is the safe default: + // the wallet still works, it just says nothing is covered. + } + return true; +} + +// ---- Backup lifecycle ------------------------------------------------------- + +// Load the recovery phrase from the keychain, if the user has set one up, and +// switch new proof creation over to deterministic secrets. Called once at +// startup. A missing phrase is the normal case and simply leaves backup off. +async function loadBackupState(): Promise { + const phrase = await loadStoredPhrase(); + if (phrase === null) { + activeSeed = null; + // The keychain is the source of truth. If the flag says backup is on but + // the phrase is gone (a keychain reset, a restore from a device backup that + // did not carry keychain items), say so rather than claiming coverage the + // user does not have. + if (useWalletStore.getState().backupEnabled) { + useWalletStore.getState().setBackupEnabled(false); + } + return; + } + activeSeed = recoveryPhraseToSeed(phrase); + useWalletStore.getState().setBackupEnabled(true); + invalidateWallets(); +} + +export interface BackupSetup { + phrase: string; + // True when a phrase already existed, so this returned the old one rather + // than generating a new one. Generating a second phrase would orphan every + // coin derived from the first. + existed: boolean; +} + +// Turn on backup, generating a phrase if there is not already one. +// +// This is deliberately one-way. There is no "turn backup off", because once +// coins are derived from a phrase, deleting the phrase is indistinguishable +// from deleting the coins. The only thing that removes it is the panic wipe, +// which is destroying everything anyway. +export async function enableWalletBackup(): Promise { + assertUnlocked(); + const existing = await loadStoredPhrase(); + if (existing !== null) { + activeSeed = recoveryPhraseToSeed(existing); + useWalletStore.getState().setBackupEnabled(true); + invalidateWallets(); + return { phrase: existing, existed: true }; + } + + const phrase = generateRecoveryPhrase(); + await storePhrase(phrase); + activeSeed = recoveryPhraseToSeed(phrase); + useWalletStore.getState().setBackupEnabled(true); + // Existing proofs stay random until they are swapped, so every wallet has to + // be rebuilt with the seed before the next mint operation can start covering + // them. + invalidateWallets(); + return { phrase, existed: false }; +} + +// The phrase, for the "view recovery phrase" screen. Null when backup is off. +export function getRecoveryPhrase(): Promise { + return loadStoredPhrase(); +} + +// Record that the user proved they copied the phrase out. Kept in the service +// rather than written from the UI so the flag can never claim more than +// `enableWalletBackup` actually set up. +export function markBackupVerified(): void { + if (activeSeed === null) return; + useWalletStore.getState().setBackupVerified(true); +} + +export interface RestoreProgress { + mintUrl: string; + keysetId: string; + // 1-based index of the keyset being scanned, and how many there are. + step: number; + total: number; +} + +export interface RestoreResult { + // Recovered, confirmed-unspent value per unit. + recovered: Record; + proofCount: number; + // Proofs the mint had signed but already marked spent. Reported so a user who + // recovers "nothing" understands it is because the money was spent, not + // because the restore failed. + alreadySpent: number; + mintsScanned: string[]; + // Mints that could not be reached; their balance may still be out there. + mintsFailed: { mintUrl: string; reason: string }[]; +} + +// How far past the last signature to keep looking before deciding a keyset is +// exhausted. Counters only ever increase by one per output, so a real gap this +// wide does not occur in practice; 200 is comfortable headroom. +const RESTORE_GAP_LIMIT = 200; +const RESTORE_BATCH_SIZE = 100; + +// Rebuild the wallet from a recovery phrase (NUT-09). +// +// For every keyset at every mint, this re-derives the secrets the phrase would +// have produced and asks the mint which of them it signed. The mint answers +// from its own records, so this works on a completely fresh install with an +// empty proof store. +// +// Two things it cannot do, both worth surfacing in the UI: +// * It has to know which mint to ask. A mint the user forgets to add is +// simply never queried, and its balance stays invisible. +// * It only recovers coins whose secrets came from this phrase. Anything +// received and never swapped carried the sender's secrets and is gone. +export async function restoreFromRecoveryPhrase(params: { + phrase: string; + mintUrls: string[]; + unit?: string; + onProgress?: (progress: RestoreProgress) => void; +}): Promise { + assertUnlocked(); + assertMintNetworkAllowed(); + + const phrase = normalizeRecoveryPhrase(params.phrase); + if (!isValidRecoveryPhrase(phrase)) { + throw new WalletError( + "invalid-token", + "That recovery phrase is not valid.", + "Check for a mistyped or missing word. The phrase has a built-in checksum, so a single wrong word makes the whole thing invalid.", + ); + } + if (params.mintUrls.length === 0) { + throw new WalletError( + "no-mint", + "Add at least one mint first.", + "Recovery works by asking a mint which coins it signed for you, so it needs to know which mint to ask.", + ); + } + + const unit = params.unit ?? "sat"; + const seed = recoveryPhraseToSeed(phrase); + + // Switch over before scanning: the restore itself creates no new outputs, but + // everything after it must derive from this phrase or the recovered coins and + // the new ones would need two different backups. + await storePhrase(phrase); + activeSeed = seed; + useWalletStore.getState().setBackupEnabled(true); + // Someone restoring has demonstrably got the phrase in front of them, so + // there is nothing left to prove with a write-it-down check. + useWalletStore.getState().setBackupVerified(true); + invalidateWallets(); + + const store = useWalletStore.getState(); + const recovered: Record = {}; + const mintsScanned: string[] = []; + const mintsFailed: { mintUrl: string; reason: string }[] = []; + let proofCount = 0; + let alreadySpent = 0; + + for (const rawUrl of params.mintUrls) { + const url = normalizeMintUrl(rawUrl); + try { + const wallet = await getWallet(url, unit, { forceRefresh: true }); + const keysets = wallet.keyChain.getKeysets(); + + for (const [index, keyset] of keysets.entries()) { + params.onProgress?.({ + mintUrl: url, + keysetId: keyset.id, + step: index + 1, + total: keysets.length, + }); + + // `batchRestore` takes the keyset id directly, so the main wallet can + // scan every keyset. Going through `withKeyset` would look tidier but + // it builds the new wallet without a unit, defaulting it to sat, which + // then fails to bind any keyset in another currency. + const { proofs, lastCounterWithSignature } = await wallet.batchRestore( + RESTORE_GAP_LIMIT, + RESTORE_BATCH_SIZE, + 0, + keyset.id, + ); + + // Push the cursor past everything the mint has ever signed for this + // keyset. Without this the next swap would re-derive a counter the mint + // already knows and be rejected as a duplicate. + if (typeof lastCounterWithSignature === "number") { + store.advanceCounter(keyset.id, lastCounterWithSignature + 1); + } + if (proofs.length === 0) continue; + + // The mint signed these, but plenty will have been spent since. Only + // the unspent ones are money. + const grouped = await wallet.groupProofsByState(proofs); + alreadySpent += grouped.spent.length; + const live = [...grouped.unspent, ...grouped.pending]; + if (live.length === 0) continue; + + creditProofs(url, unit, live, { verified: true }); + proofCount += live.length; + recovered[unit] = + (recovered[unit] ?? 0) + + live.reduce((sum, p) => sum + p.amount.toNumber(), 0); + } + mintsScanned.push(url); + } catch (err) { + mintsFailed.push({ + mintUrl: url, + reason: asWalletError(err, "mint-error").message, + }); + } + } + + if (proofCount > 0) { + recordTx({ + kind: "receive", + status: "completed", + amount: recovered[unit] ?? 0, + unit, + mintUrl: mintsScanned[0] ?? params.mintUrls[0], + memo: "Restored from recovery phrase", + }); + } + + return { recovered, proofCount, alreadySpent, mintsScanned, mintsFailed }; +} + +function assertUnlocked(): void { + if (!isWalletStorageReady()) { + throw new WalletError( + "locked", + "Wallet storage is locked.", + "Airhop keeps ecash proofs in an encrypted file whose key lives in the device keychain. Unlock the device and reopen the app.", + ); + } +} + +// ---- Mints ------------------------------------------------------------------ + +export interface AddMintResult { + mint: StoredMint; + units: string[]; +} + +// Add a mint after checking it really is one. An unreachable or non-Cashu URL is +// rejected up front rather than being saved and failing on first use: a mint +// row that cannot mint is worse than no row. +export async function addMint(rawUrl: string): Promise { + assertUnlocked(); + const url = normalizeMintUrl(rawUrl); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new WalletError("no-mint", "That is not a valid URL."); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new WalletError("no-mint", "A mint URL must start with https://."); + } + // http is allowed only for loopback (running Nutshell locally). Anywhere else + // it would send proofs over an unauthenticated channel. + const isLoopback = + parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "::1"; + if (parsed.protocol === "http:" && !isLoopback) { + throw new WalletError( + "no-mint", + "Refusing to use a mint over plain http.", + "Anyone on the network path could read or alter your proofs. Use an https:// mint.", + ); + } + + assertMintNetworkAllowed(); + + const wallet = new Wallet(new Mint(url), walletOptions("sat")); + try { + await wallet.loadMint(true); + } catch (err) { + throw asWalletError(err, "no-mint"); + } + persistMintSnapshot(url, "sat", wallet); + wallets.set(accountKey(url, "sat"), wallet); + + const record = storedMint(url); + if (!record) throw new WalletError("no-mint", "Mint could not be saved."); + return { mint: record, units: record.units ?? ["sat"] }; +} + +// ---- Receive ---------------------------------------------------------------- + +export interface ReceiveResult { + amount: number; + unit: string; + mintUrl: string; + memo?: string; + // "swapped" redeemed at the mint; the value is now provably ours + // "stored" kept offline; DLEQ passed but the mint has not confirmed it + // is unspent, so it counts as unverified balance + // "duplicate" every proof was already in the wallet; nothing was credited + outcome: "swapped" | "stored" | "duplicate"; + // Why we did not swap, when outcome is "stored". + offlineReason?: string; + // Result of the offline DLEQ check, for the receipt UI. + dleq: "valid" | "unchecked"; +} + +// Take a token string into the wallet. +// +// The order matters. We decode, then verify what can be verified offline, then +// try to swap at the mint, and only fall back to storing the raw proofs when +// the mint is unreachable. A forged token (DLEQ fails) is refused outright and +// never reaches the store, which is the case the old receive path could not +// catch at all: it credited the balance from any well-formed string. +export async function receiveToken( + raw: string, + opts: { preferOffline?: boolean; counterparty?: string } = {}, +): Promise { + assertUnlocked(); + + const info = decodeToken(raw); + if (!info) { + throw new WalletError( + "invalid-token", + "That is not a readable Cashu token.", + "Tokens start with cashuA or cashuB. Check nothing was cut off when it was copied.", + ); + } + + const store = useWalletStore.getState(); + const url = normalizeMintUrl(info.mintUrl); + const record = store.mints[url]; + + // Offline verification first: it costs nothing and it is the only thing + // standing between a forged token and the balance when there is no network. + const dleq = verifyTokenOffline( + info.token, + record?.keysetCache as KeyChainCache | undefined, + info.unit, + ); + if (dleq.status === "invalid") { + throw new WalletError( + "forged-token", + "This token was not signed by the mint it names.", + dleq.reason, + ); + } + + // Everything is already ours: report it instead of showing a phantom credit. + const existing = new Set( + (store.proofs[accountKey(url, info.unit)] ?? []).map((p) => p.secret), + ); + if (info.token.proofs.every((p) => existing.has(p.secret))) { + return { + amount: info.amount, + unit: info.unit, + mintUrl: url, + memo: info.memo, + outcome: "duplicate", + dleq: dleq.status === "valid" ? "valid" : "unchecked", + }; + } + + const dleqLabel = + dleq.status === "valid" ? ("valid" as const) : ("unchecked" as const); + + // Online path: swap the proofs so they are provably unspent and no longer + // known to the sender. This is what makes a received token safe to hold. + if (opts.preferOffline !== true) { + try { + assertMintNetworkAllowed(); + const wallet = await getWallet(url, info.unit); + // `requireDleq` makes cashu-ts reject a proof whose witness does not + // verify against the freshly loaded keys, closing the window where our + // cached keys were missing and the offline check came back "unchecked". + const fresh = await wallet.receive(info.token, { + requireDleq: info.hasDleq, + }); + creditProofs(url, info.unit, fresh, { verified: true }); + markClaimed(info); + recordTx({ + kind: "receive", + status: "completed", + amount: info.amount, + unit: info.unit, + mintUrl: url, + memo: info.memo, + counterparty: opts.counterparty, + }); + return { + amount: info.amount, + unit: info.unit, + mintUrl: url, + memo: info.memo, + outcome: "swapped", + dleq: dleqLabel, + }; + } catch (err) { + const walletErr = asWalletError(err, "mint-error"); + // A mint that says "already spent" is authoritative: storing the proofs + // would show a balance that can never be redeemed. + if ( + walletErr.code === "mint-error" && + /spent|already|TOKEN_ALREADY/i.test( + walletErr.detail ?? walletErr.message, + ) + ) { + throw new WalletError( + "already-spent", + "These proofs have already been spent.", + "Whoever sent this token redeemed it first, or sent the same token to someone else.", + ); + } + if (walletErr.code !== "offline" && walletErr.code !== "tor-blocked") { + throw walletErr; + } + // Offline or Tor-blocked: fall through and store the raw proofs. + return storeOffline( + url, + info, + walletErr.message, + dleqLabel, + opts.counterparty, + ); + } + } + + return storeOffline( + url, + info, + "receiving offline", + dleqLabel, + opts.counterparty, + ); +} + +// Keep the token's own proofs, unverified. This is the offline mesh case: value +// really has moved, and refusing it would make the app useless in the situation +// it exists for. It is recorded as unverified so the UI can be honest that the +// mint has not confirmed it, and so `refreshAccount` redeems it first. +function storeOffline( + mintUrl: string, + info: TokenInfo, + reason: string, + dleq: "valid" | "unchecked", + counterparty?: string, +): ReceiveResult { + const store = useWalletStore.getState(); + store.addMint(mintUrl, { units: [info.unit] }); + const stored = info.token.proofs.map((p) => + toStoredProof(p, { verified: false }), + ); + const { added } = store.addProofs(mintUrl, info.unit, stored); + markClaimed(info); + if (added === 0) { + return { + amount: info.amount, + unit: info.unit, + mintUrl, + memo: info.memo, + outcome: "duplicate", + dleq, + }; + } + recordTx({ + kind: "receive", + status: "pending", + amount: info.amount, + unit: info.unit, + mintUrl, + memo: info.memo, + counterparty, + }); + return { + amount: info.amount, + unit: info.unit, + mintUrl, + memo: info.memo, + outcome: "stored", + offlineReason: reason, + dleq, + }; +} + +// Credit proofs the mint has just signed for us. +// +// `derived` is not a parameter because it is never a judgement call: a proof is +// restorable exactly when the wallet that created it had the seed loaded. These +// proofs always came out of a wallet built by `getWallet`, so `isBackupActive()` +// at this moment is the truth. +// +// `addProofs` deduplicates by secret, so passing a list that also contains +// proofs we already hold (as `wallet.send` does, since its `keep` array carries +// untouched originals alongside fresh change) leaves those records exactly as +// they were rather than relabelling them. +// Remember that this exact token has been taken in, so a payment card in a chat +// can show "Claimed" rather than a button whose only outcome is an error. Keyed +// on the first proof's secret, which is random and unique to the token. +function markClaimed(info: TokenInfo): void { + const first = info.token.proofs[0]?.secret; + if (first !== undefined) useWalletStore.getState().markTokenClaimed(first); +} + +function creditProofs( + mintUrl: string, + unit: string, + proofs: Proof[], + opts: { verified: boolean }, +): void { + const store = useWalletStore.getState(); + const derived = isBackupActive(); + store.addMint(mintUrl, { units: [unit] }); + store.addProofs( + mintUrl, + unit, + proofs.map((p) => toStoredProof(p, { verified: opts.verified, derived })), + ); +} + +// ---- Send ------------------------------------------------------------------- + +export interface SendQuote { + mintUrl: string; + unit: string; + // What the recipient will be able to claim. + amount: number; + // Face value leaving the wallet (amount + fee when the sender covers it). + spend: number; + // Mint input fee the recipient would otherwise have paid. + fee: number; + // False when the denominations held cannot make this amount exactly. The + // caller must get explicit consent, because offline there is no change: the + // difference is a gift to the recipient. + exact: boolean; + proofs: StoredProof[]; +} + +export interface PreparedSend extends SendQuote { + txId: string; + token: string; +} + +// Price a send without committing to it, so the UI can show "they receive N, +// you spend M" before the user taps. +// +// Fees are the reason these two numbers differ. Under NUT-02 the mint charges +// the *recipient* an input fee when they swap, so sending exactly N leaves them +// with less than N. We select enough to cover the fee, which is what every +// production Cashu wallet does and what makes "send 100" mean "they get 100". +export async function quoteSend(params: { + amount: number; + mintUrl?: string; + unit?: string; +}): Promise { + assertUnlocked(); + const unit = params.unit ?? "sat"; + const amount = Math.floor(params.amount); + if (!Number.isFinite(amount) || amount <= 0) { + throw new WalletError("insufficient", "Enter an amount greater than zero."); + } + + const account = pickAccount(amount, unit, params.mintUrl); + const record = storedMint(account.mintUrl); + + // With the mint's keysets cached, defer to cashu-ts: its RGLI selector and + // fee handling are the reference implementation and get the edge cases + // (rotated keysets, mixed fee schedules) right. + try { + const wallet = await getWallet(account.mintUrl, unit, { offline: true }); + const proofLikes = account.proofs.map(toProofLike); + const result = wallet.sendOffline(amount, proofLikes, { + includeFees: true, + exactMatch: true, + }); + const selected = matchStored(account.proofs, result.send); + const spend = selected.reduce((s, p) => s + p.amount, 0); + return { + mintUrl: account.mintUrl, + unit, + amount, + spend, + fee: spend - amount, + exact: true, + proofs: selected, + }; + } catch (err) { + if (err instanceof WalletError && err.code === "locked") throw err; + // No exact offline match, or no cached keysets. Fall back to our own + // selector, which reports honestly whether it landed on the amount. + const selection = selectProofsForAmount( + account.proofs, + amount, + record?.feePpkByKeysetId, + ); + if (!selection) { + throw new WalletError( + "insufficient", + `Not enough balance at ${hostOf(account.mintUrl)}.`, + ); + } + return { + mintUrl: account.mintUrl, + unit, + amount, + spend: selection.total, + fee: selection.fee, + exact: selection.exact, + proofs: selection.selected, + }; + } +} + +// Commit a quoted send: reserve the proofs, serialise the token, and open a +// pending transaction. Nothing is destroyed. The proofs stay recoverable until +// `confirmSend` is called, and the token string is kept on the transaction so +// it can be re-shared after an app restart. +export async function prepareSend(params: { + amount: number; + mintUrl?: string; + unit?: string; + memo?: string; + counterparty?: string; + // Set when the user has already been told the amount is not exact and chose + // to continue. Without it an inexact quote is refused, so overpaying can + // never happen by accident. + allowInexact?: boolean; +}): Promise { + const quote = await quoteSend(params); + if (!quote.exact && params.allowInexact !== true) { + throw new WalletError( + "inexact", + `Your proofs cannot make exactly ${String(params.amount)} ${quote.unit} offline.`, + `The smallest token you can send is ${String(quote.spend)} ${quote.unit}. Offline there is no change, so the extra ${String(quote.spend - quote.amount)} ${quote.unit} goes to the recipient.`, + ); + } + + const txId = newTxId(); + const token = buildToken( + quote.mintUrl, + quote.proofs, + quote.unit, + params.memo, + ); + + const store = useWalletStore.getState(); + store.reserveProofs(txId, quote.mintUrl, quote.unit, quote.proofs); + store.addTx({ + id: txId, + kind: "send", + status: "pending", + amount: quote.amount, + fee: quote.fee, + unit: quote.unit, + mintUrl: quote.mintUrl, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + memo: params.memo, + counterparty: params.counterparty, + token, + }); + + return { ...quote, txId, token }; +} + +// The recipient has it. Drop the reservation for good. +export function confirmSend(txId: string): void { + const store = useWalletStore.getState(); + store.dropReserved(txId); + store.updateTx(txId, { status: "completed" }); +} + +// The transfer never landed. Put the proofs back. +// +// This is safe precisely because the send was offline: no swap happened, so the +// proofs are still valid at the mint. The risk is the recipient also kept a +// copy of the token, which is why the caller should only offer reclaim when +// delivery demonstrably failed, and why `refreshAccount` re-checks state with +// the mint afterwards. +export function reclaimSend(txId: string): boolean { + const store = useWalletStore.getState(); + const restored = store.releaseReserved(txId); + if (!restored) return false; + store.updateTx(txId, { status: "reclaimed" }); + return true; +} + +// Delivery failed in a way we cannot retry. Keeps the reservation (so the token +// can still be reclaimed or re-shared) and records why. +export function failSend(txId: string, reason: string): void { + useWalletStore.getState().updateTx(txId, { error: reason }); +} + +// Choose the account to spend from: the mint the caller named, or the one that +// can cover the amount on its own. Cashu proofs cannot be combined across +// mints in a single token, so a balance split over two mints genuinely cannot +// pay a sum that neither covers; that is a mint-level fact, not a UI shortcut. +function pickAccount( + amount: number, + unit: string, + preferredMint?: string, +): { mintUrl: string; proofs: StoredProof[] } { + const state = useWalletStore.getState(); + const candidates = Object.entries(state.proofs) + .filter(([key]) => key.endsWith(`|${unit}`)) + .map(([key, proofs]) => ({ + mintUrl: key.slice(0, key.length - unit.length - 1), + proofs, + balance: proofs.reduce((s, p) => s + p.amount, 0), + })) + .sort((a, b) => b.balance - a.balance); + + if (candidates.length === 0) { + throw new WalletError( + "no-mint", + "No ecash yet.", + "Add a mint and deposit over Lightning, or receive a token from someone.", + ); + } + + if (preferredMint) { + const url = normalizeMintUrl(preferredMint); + const hit = candidates.find((c) => c.mintUrl === url); + if (!hit || hit.balance < amount) { + throw new WalletError( + "insufficient", + `Not enough balance at ${hostOf(url)}.`, + ); + } + return hit; + } + + const covering = candidates.find((c) => c.balance >= amount); + if (covering) return covering; + + const total = candidates.reduce((s, c) => s + c.balance, 0); + if (total >= amount) { + throw new WalletError( + "insufficient", + "Your balance is split across mints.", + `No single mint holds ${String(amount)} ${unit}. Ecash from different mints cannot be combined into one token: consolidate at one mint first, or send in separate amounts.`, + ); + } + throw new WalletError( + "insufficient", + `You have ${String(total)} ${unit}, and tried to send ${String(amount)}.`, + ); +} + +// Map cashu-ts proofs back onto the stored records they came from, so the +// reservation removes exactly the rows the library chose. +function matchStored(stored: StoredProof[], chosen: Proof[]): StoredProof[] { + const bySecret = new Map(stored.map((p) => [p.secret, p])); + const out: StoredProof[] = []; + for (const proof of chosen) { + const hit = bySecret.get(proof.secret); + if (hit) out.push(hit); + } + return out; +} + +// ---- Refresh / reconcile ---------------------------------------------------- + +export interface RefreshResult { + // Face value that was swapped for fresh proofs. + swapped: number; + // Number of proofs the mint reported as already spent, now removed. + spentRemoved: number; + stillUnverified: number; + // Of what was swapped, how much was swapped purely to bring it under the + // recovery phrase rather than to confirm it. Reported separately so the UI + // can say "secured for backup" instead of implying it was suspect. + securedForBackup: number; +} + +// Bring an account into a known-good state with the mint. +// +// Two jobs, in this order: +// 1. Ask the mint which proofs are actually unspent (NUT-07). Anything the +// mint calls spent is removed: it is not money, and showing it as balance +// is the single worst thing an offline-first wallet can do. +// 2. Swap the surviving unverified proofs for fresh ones. That both confirms +// them and cuts the sender's copy loose, so they can no longer be +// double-spent by whoever sent them. +export async function refreshAccount( + mintUrl: string, + unit = "sat", +): Promise { + assertUnlocked(); + assertMintNetworkAllowed(); + + const url = normalizeMintUrl(mintUrl); + const store = useWalletStore.getState(); + const key = accountKey(url, unit); + const held = store.proofs[key] ?? []; + if (held.length === 0) { + return { + swapped: 0, + spentRemoved: 0, + stillUnverified: 0, + securedForBackup: 0, + }; + } + + const wallet = await getWallet(url, unit, { forceRefresh: true }); + + // Group by state, then map back to our stored rows by secret: a proof's + // secret is its identity, so it survives the round trip through cashu-ts + // without smuggling extra fields into the library's types. + let unspent: StoredProof[]; + let spent: StoredProof[]; + try { + const bySecret = new Map(held.map((p) => [p.secret, p])); + const grouped = await wallet.groupProofsByState(held.map(toProofLike)); + const pick = (list: ProofLike[]): StoredProof[] => + list + .map((p) => bySecret.get(p.secret)) + .filter((p): p is StoredProof => p !== undefined); + // "pending" means an in-flight melt at the mint, not spent. Keep those. + unspent = [...pick(grouped.unspent), ...pick(grouped.pending)]; + spent = pick(grouped.spent); + } catch (err) { + throw asWalletError(err, "mint-error"); + } + + if (spent.length > 0) { + store.removeProofs( + url, + unit, + spent.map((p) => p.secret), + ); + recordTx({ + kind: "swap", + status: "failed", + amount: spent.reduce((s, p) => s + p.amount, 0), + unit, + mintUrl: url, + error: "Mint reported these proofs as already spent.", + }); + } + + // Two independent reasons to swap, and a proof can need it for either: + // + // not verified someone else gave us these proofs and still holds a copy of + // the token. A state check says they are unspent right now, + // not that they will be in a second. Swapping mints fresh + // secrets only we know, which is what actually makes them + // ours. + // not derived the secret was random, so the recovery phrase cannot + // rebuild it. Swapping re-issues it deterministically and + // brings it under the backup. Only relevant once the user has + // set a phrase up. + const backupOn = isBackupActive(); + const needsSwap = (proof: StoredProof): boolean => + proof.verified !== true || (backupOn && proof.derived !== true); + + const toSwap = unspent.filter(needsSwap); + const securedOnly = toSwap.filter( + (proof) => proof.verified === true && proof.derived !== true, + ); + const securedForBackup = securedOnly.reduce((sum, p) => sum + p.amount, 0); + + // Everything the mint just confirmed is unspent is verified, whether or not + // it also needs re-deriving. + store.markVerified( + url, + unit, + unspent.map((p) => p.secret), + ); + + if (toSwap.length === 0) { + return { + swapped: 0, + spentRemoved: spent.length, + stillUnverified: 0, + securedForBackup: 0, + }; + } + + // Swap in one batch so the fee is charged once. A failure here leaves the + // proofs untouched (the mint either accepts the whole swap or none of it), + // so there is no partial-loss window. + const face = toSwap.reduce((s, p) => s + p.amount, 0); + try { + const fresh = await wallet.receive(buildToken(url, toSwap, unit), { + requireDleq: false, + }); + store.removeProofs( + url, + unit, + toSwap.map((p) => p.secret), + ); + creditProofs(url, unit, fresh, { verified: true }); + const received = fresh.reduce((s, p) => s + p.amount.toNumber(), 0); + recordTx({ + kind: "swap", + status: "completed", + amount: received, + fee: face - received, + unit, + mintUrl: url, + }); + // Close out the receipts that were left open when those proofs arrived + // offline. Without this every mesh-received token would sit in the history + // as "Received, unconfirmed" forever, even after it had been confirmed. + for (const open of useWalletStore.getState().history) { + if ( + open.kind === "receive" && + open.status === "pending" && + open.mintUrl === url && + open.unit === unit + ) { + store.updateTx(open.id, { status: "completed" }); + } + } + return { + swapped: received, + spentRemoved: spent.length, + stillUnverified: 0, + securedForBackup, + }; + } catch (err) { + // The state check already ran and any spent proofs are gone, so the wallet + // is in a better state than before even though the swap failed. Surface the + // error rather than reporting success, but do not undo step 1. + throw asWalletError(err, "mint-error"); + } +} + +// Re-check every pending transaction. Safe to call on app resume and whenever +// connectivity returns; it never spends and never credits without the mint. +export async function reconcile(): Promise { + if (!isWalletStorageReady()) return; + if (isMintNetworkBlocked()) return; + + const state = useWalletStore.getState(); + + // Lightning deposits whose invoice may have been paid while the app was shut. + for (const tx of state.history) { + if (tx.kind !== "mint" || tx.status !== "pending" || !tx.quoteId) continue; + try { + await claimLightningDeposit(tx.mintUrl, tx.unit, tx.quoteId); + } catch { + // Still unpaid, or the mint is unreachable. Left pending for next time. + } + } + + // Reserved sends whose proofs the recipient has now redeemed: the value is + // gone for good, so close them out rather than offering a reclaim that would + // fail at the mint. + for (const [txId, entry] of Object.entries(state.reserved)) { + const tx = state.history.find((t) => t.id === txId); + if (!tx || tx.status !== "pending") continue; + try { + const wallet = await getWallet(tx.mintUrl, tx.unit); + const grouped = await wallet.groupProofsByState( + entry.proofs.map(toProofLike), + ); + if (grouped.spent.length === entry.proofs.length) confirmSend(txId); + } catch { + // Unreachable mint: leave the reservation alone. + } + } +} + +// ---- Lightning: deposit (mint) ---------------------------------------------- + +export interface LightningDeposit { + txId: string; + quoteId: string; + invoice: string; + amount: number; + unit: string; + mintUrl: string; + expiresAtMs?: number; +} + +// Ask the mint for a bolt11 invoice. Paying it converts Lightning sats into +// ecash proofs, which is the only way value enters the wallet without someone +// handing over a token. Without this the wallet can only ever spend what it was +// given, which is why the balance stayed at zero for anyone starting fresh. +export async function createLightningDeposit(params: { + amount: number; + mintUrl: string; + unit?: string; + description?: string; +}): Promise { + assertUnlocked(); + assertMintNetworkAllowed(); + const unit = params.unit ?? "sat"; + const url = normalizeMintUrl(params.mintUrl); + const amount = Math.floor(params.amount); + if (!Number.isFinite(amount) || amount <= 0) { + throw new WalletError("insufficient", "Enter an amount greater than zero."); + } + + const wallet = await getWallet(url, unit); + requireNut(url, 4, "issue ecash against a Lightning invoice"); + + let quote: MintQuoteBolt11Response; + try { + quote = await wallet.createMintQuoteBolt11(amount, params.description); + } catch (err) { + throw asWalletError(err, "mint-error"); + } + + const txId = newTxId(); + useWalletStore.getState().addTx({ + id: txId, + kind: "mint", + status: "pending", + amount, + unit, + mintUrl: url, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + quoteId: quote.quote, + invoice: quote.request, + memo: params.description, + counterparty: hostOf(url), + }); + + return { + txId, + quoteId: quote.quote, + invoice: quote.request, + amount, + unit, + mintUrl: url, + expiresAtMs: + typeof quote.expiry === "number" ? quote.expiry * 1000 : undefined, + }; +} + +// Poll a deposit quote and mint the proofs once the invoice is paid. Throws +// while the invoice is still unpaid, so callers can poll on a timer or leave it +// to `reconcile` on next launch. +export async function claimLightningDeposit( + mintUrl: string, + unit: string, + quoteId: string, +): Promise { + assertUnlocked(); + assertMintNetworkAllowed(); + const url = normalizeMintUrl(mintUrl); + const store = useWalletStore.getState(); + const tx = store.history.find( + (t) => t.quoteId === quoteId && t.kind === "mint", + ); + if (!tx) throw new WalletError("mint-error", "Unknown deposit."); + + const wallet = await getWallet(url, unit); + let quote: MintQuoteBolt11Response; + try { + quote = await wallet.checkMintQuoteBolt11(quoteId); + } catch (err) { + throw asWalletError(err, "mint-error"); + } + + // NUT-04 states: UNPAID -> PAID -> ISSUED. Only PAID can be minted, and only + // once; ISSUED means we already claimed it (a duplicate poll, or a retry that + // actually succeeded), so close the transaction rather than erroring. + if (quote.state === "ISSUED") { + store.updateTx(tx.id, { status: "completed" }); + return 0; + } + if (quote.state !== "PAID") { + const expired = + typeof quote.expiry === "number" && quote.expiry * 1000 < Date.now(); + if (expired) { + store.updateTx(tx.id, { + status: "expired", + error: "The invoice expired before it was paid.", + }); + throw new WalletError("mint-error", "That invoice expired."); + } + throw new WalletError("offline", "The invoice has not been paid yet."); + } + + let proofs: Proof[]; + try { + proofs = await wallet.mintProofsBolt11(tx.amount, quote); + } catch (err) { + const walletErr = asWalletError(err, "mint-error"); + store.updateTx(tx.id, { error: walletErr.message }); + throw walletErr; + } + + creditProofs(url, unit, proofs, { verified: true }); + const minted = proofs.reduce((s, p) => s + p.amount.toNumber(), 0); + store.updateTx(tx.id, { status: "completed", amount: minted }); + return minted; +} + +// ---- Lightning: withdraw (melt) --------------------------------------------- + +export interface MeltQuote { + quoteId: string; + mintUrl: string; + unit: string; + // Amount the invoice pays out. + amount: number; + // Routing reserve the mint holds back; any unused part comes back as change. + feeReserve: number; + // What leaves the wallet in the worst case. + total: number; + invoice: string; + expiresAtMs?: number; + // The mint's own quote object, needed verbatim by `meltProofsBolt11`. Not + // serialisable (it holds Amount value objects), so a quote does not survive a + // restart: the UI must re-quote, which is correct anyway since fee reserves + // and invoice expiry both move. + raw: MeltQuoteBolt11Response; +} + +// Price a Lightning withdrawal without committing. The fee reserve is an upper +// bound: whatever routing does not consume is returned as change proofs, so the +// UI should present it as "up to". +export async function quoteLightningWithdrawal(params: { + invoice: string; + mintUrl: string; + unit?: string; +}): Promise { + assertUnlocked(); + assertMintNetworkAllowed(); + const unit = params.unit ?? "sat"; + const url = normalizeMintUrl(params.mintUrl); + const invoice = params.invoice.trim().replace(/^lightning:/i, ""); + if (!/^ln(bc|tb|bcrt)[0-9a-z]+$/i.test(invoice)) { + throw new WalletError( + "invalid-token", + "That is not a Lightning invoice.", + "Paste a bolt11 invoice starting with lnbc.", + ); + } + + const wallet = await getWallet(url, unit); + requireNut(url, 5, "pay a Lightning invoice"); + + let quote: MeltQuoteBolt11Response; + try { + quote = await wallet.createMeltQuoteBolt11(invoice); + } catch (err) { + throw asWalletError(err, "mint-error"); + } + + const amount = quote.amount.toNumber(); + const feeReserve = quote.fee_reserve.toNumber(); + const total = amount + feeReserve; + + const balance = ( + useWalletStore.getState().proofs[accountKey(url, unit)] ?? [] + ).reduce((s, p) => s + p.amount, 0); + if (balance < total) { + throw new WalletError( + "insufficient", + `This invoice needs ${String(total)} ${unit} including the routing reserve, and you have ${String(balance)}.`, + ); + } + + return { + quoteId: quote.quote, + mintUrl: url, + unit, + amount, + feeReserve, + total, + invoice, + expiresAtMs: + typeof quote.expiry === "number" ? quote.expiry * 1000 : undefined, + raw: quote, + }; +} + +// Execute a quoted withdrawal. +// +// The proofs are reserved before the call and only dropped once the mint +// confirms payment, so a timeout mid-melt leaves them recoverable rather than +// vanished. On an ambiguous failure the reservation is deliberately kept: the +// mint may still settle, and `reconcile` will resolve it from the proof state +// rather than us guessing. +export async function payLightningInvoice(quote: MeltQuote): Promise<{ + paid: number; + fee: number; + changeReturned: number; + preimage?: string; +}> { + assertUnlocked(); + assertMintNetworkAllowed(); + + const store = useWalletStore.getState(); + const key = accountKey(quote.mintUrl, quote.unit); + const available = store.proofs[key] ?? []; + const selection = selectProofsForAmount( + available, + quote.total, + storedMint(quote.mintUrl)?.feePpkByKeysetId, + ); + if (!selection) { + throw new WalletError( + "insufficient", + "Not enough balance for this invoice.", + ); + } + + const txId = newTxId(); + store.reserveProofs(txId, quote.mintUrl, quote.unit, selection.selected); + store.addTx({ + id: txId, + kind: "melt", + status: "pending", + amount: quote.amount, + fee: quote.feeReserve, + unit: quote.unit, + mintUrl: quote.mintUrl, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + quoteId: quote.quoteId, + invoice: quote.invoice, + counterparty: "lightning", + }); + + try { + const wallet = await getWallet(quote.mintUrl, quote.unit); + const result = await wallet.meltProofsBolt11( + quote.raw, + selection.selected.map(toProofLike), + ); + + // Unused routing reserve comes back as change proofs. + const change = result.change; + if (change.length > 0) { + creditProofs(quote.mintUrl, quote.unit, change, { verified: true }); + } + const changeReturned = change.reduce((s, p) => s + p.amount.toNumber(), 0); + const spent = selection.total - changeReturned; + + store.dropReserved(txId); + store.updateTx(txId, { + status: "completed", + fee: spent - quote.amount, + }); + return { + paid: quote.amount, + fee: spent - quote.amount, + changeReturned, + preimage: result.quote.payment_preimage ?? undefined, + }; + } catch (err) { + const walletErr = asWalletError(err, "mint-error"); + // Only put the proofs back when the mint definitively refused. A network + // error means the payment may have gone through; restoring the proofs then + // would show a balance the mint has already spent. + if (walletErr.code === "mint-error") { + store.releaseReserved(txId); + store.updateTx(txId, { status: "failed", error: walletErr.message }); + } else { + store.updateTx(txId, { + error: `${walletErr.message} Payment status unknown; checked again on next refresh.`, + }); + } + throw walletErr; + } +} + +// ---- Consolidate across mints ----------------------------------------------- + +export interface ConsolidateResult { + fromMintUrl: string; + toMintUrl: string; + unit: string; + // What left the source mint, including the Lightning routing fee. + spent: number; + // What arrived at the destination. + received: number; + fee: number; +} + +// Lightning fee reserves are usually well under 1%, but a first guess has to +// leave room or every attempt overshoots. The loop below corrects it. +const CONSOLIDATE_FEE_GUESS = 0.02; +const CONSOLIDATE_MIN_BUFFER = 2; +// Each attempt is a live mint round trip, so this is bounded tightly. Two +// corrections is enough for any realistic fee schedule. +const CONSOLIDATE_MAX_ATTEMPTS = 3; + +// Move value from one mint to another over Lightning. +// +// Ecash from two mints can never be combined into one token, because a token +// names exactly one mint. That part is Cashu's design and is not fixable. What +// *is* fixable is being stuck with a split balance: the source mint pays a +// Lightning invoice that the destination mint issued, and the value lands as +// spendable ecash at the destination. +// +// This is the same pair of operations a user could do by hand with an external +// Lightning wallet, except the mints pay each other directly, so it costs one +// routing fee instead of two and needs no third app. +// +// Sizing is the awkward part: the melt quote's fee reserve is only known after +// asking, and asking requires an invoice, which requires an amount. So this +// quotes, checks whether the total fits, and shrinks the amount if it does not. +export async function consolidateMints(params: { + fromMintUrl: string; + toMintUrl: string; + unit?: string; + // Amount to arrive at the destination. Omit to move as much as will fit. + amount?: number; +}): Promise { + assertUnlocked(); + assertMintNetworkAllowed(); + + const unit = params.unit ?? "sat"; + const from = normalizeMintUrl(params.fromMintUrl); + const to = normalizeMintUrl(params.toMintUrl); + if (from === to) { + throw new WalletError( + "no-mint", + "Pick a different destination mint.", + "The source and destination are the same mint, so there is nothing to move.", + ); + } + + requireNut(from, 5, "pay a Lightning invoice"); + requireNut(to, 4, "issue ecash against a Lightning invoice"); + + const store = useWalletStore.getState(); + const available = store.proofs[accountKey(from, unit)] ?? []; + const sourceBalance = available.reduce((s, p) => s + p.amount, 0); + if (sourceBalance <= 0) { + throw new WalletError( + "insufficient", + `${hostOf(from)} has no ${unit} to move.`, + ); + } + + // First guess: everything, less a buffer for the routing fee. + let target = + params.amount ?? + sourceBalance - + Math.max( + CONSOLIDATE_MIN_BUFFER, + Math.ceil(sourceBalance * CONSOLIDATE_FEE_GUESS), + ); + + for (let attempt = 0; attempt < CONSOLIDATE_MAX_ATTEMPTS; attempt++) { + if (target <= 0) break; + + const deposit = await createLightningDeposit({ + amount: target, + mintUrl: to, + unit, + description: `Consolidate from ${hostOf(from)}`, + }); + + let quote: MeltQuote; + try { + quote = await quoteLightningWithdrawal({ + invoice: deposit.invoice, + mintUrl: from, + unit, + }); + } catch (err) { + // The invoice we just asked for cannot be paid from this mint at this + // size. Abandon it so it does not linger in history as a live deposit. + abandonDeposit(deposit.txId, "Quote failed, consolidation retried"); + const walletErr = asWalletError(err, "mint-error"); + if (walletErr.code !== "insufficient") throw walletErr; + target = Math.floor(target * 0.95); + continue; + } + + // The melt needs the invoice amount, the routing reserve, and the mint's + // own per-proof input fees, all out of the same balance. + const inputFee = feeForProofs( + available, + storedMint(from)?.feePpkByKeysetId, + ); + if (quote.total + inputFee > sourceBalance) { + abandonDeposit(deposit.txId, "Amount did not fit, consolidation retried"); + const overshoot = quote.total + inputFee - sourceBalance; + target -= overshoot; + continue; + } + + // Committed from here. The source pays; the destination issues on claim. + await payLightningInvoice(quote); + const received = await claimLightningDeposit(to, unit, deposit.quoteId); + + return { + fromMintUrl: from, + toMintUrl: to, + unit, + spent: quote.total, + received, + fee: quote.total - received, + }; + } + + throw new WalletError( + "insufficient", + "Could not size this transfer.", + `After Lightning routing fees, ${hostOf(from)} cannot move a useful amount to ${hostOf(to)}. Try moving a specific smaller amount instead.`, + ); +} + +// Close out a deposit quote we asked for and then decided not to use, so the +// Lightning section does not show phantom "waiting on payment" entries. +function abandonDeposit(txId: string, reason: string): void { + useWalletStore.getState().updateTx(txId, { + status: "expired", + error: reason, + }); +} + +// ---- NUT support gating ----------------------------------------------------- + +// Fail before a network round trip when the mint has told us it cannot do this. +function requireNut(mintUrl: string, nut: number, what: string): void { + const nuts = storedMint(mintUrl)?.supportedNuts; + if (!nuts || nuts.length === 0) return; // unknown: let the mint decide + if (nuts.includes(nut)) return; + throw new WalletError( + "unsupported", + `${hostOf(mintUrl)} cannot ${what}.`, + `The mint does not advertise NUT-${String(nut)}.`, + ); +} + +// ---- P2PK identity (NIP-61) ------------------------------------------------- + +// Nutzaps are received by locking proofs to a public key the recipient +// publishes. That key must be a real secp256k1 key we hold the private half of, +// and it must be stable, or previously published kind 10019 events point at a +// key we can no longer spend from. It lives in the Keychain next to the +// identity keys, never in the proof store. +const P2PK_KEY_ITEM = "airhop.wallet.p2pk.v1"; + +async function getNutzapPrivKeyHex(): Promise { + const existing = await EncryptedStorage.getItem(P2PK_KEY_ITEM); + if (typeof existing === "string" && /^[0-9a-f]{64}$/i.test(existing)) { + return existing.toLowerCase(); + } + const fresh = bytesToHex(secp256k1.utils.randomSecretKey()); + await EncryptedStorage.setItem(P2PK_KEY_ITEM, fresh); + return fresh; +} + +// 33-byte compressed public key, hex. This is what goes in the kind 10019 +// `pubkey` tag and what senders lock proofs to. +async function getNutzapPubKeyHex(): Promise { + const priv = await getNutzapPrivKeyHex(); + const pub = bytesToHex(secp256k1.getPublicKey(hexToBytes(priv), true)); + useWalletStore.getState().setNutzapPubkey(pub); + return pub; +} + +// ---- Nutzap redemption ------------------------------------------------------ + +// Redeem P2PK-locked proofs from an incoming NIP-61 nutzap. The proofs are +// locked to our key, so they must be signed before the mint will swap them; +// until that swap they are not spendable by anyone else, which is what makes +// nutzaps safe to leave sitting on a relay. +async function redeemNutzapProofs(params: { + proofs: ProofLike[]; + mintUrl: string; + unit: string; + eventId: string; + senderPubkey: string; + comment?: string; +}): Promise { + assertUnlocked(); + const store = useWalletStore.getState(); + if (store.redeemedNutzaps.includes(params.eventId)) return 0; + + assertMintNetworkAllowed(); + const url = normalizeMintUrl(params.mintUrl); + const wallet = await getWallet(url, params.unit); + const privkey = await getNutzapPrivKeyHex(); + + let fresh: Proof[]; + try { + fresh = await wallet.receive(params.proofs, { privkey }); + } catch (err) { + throw asWalletError(err, "mint-error"); + } + + creditProofs(url, params.unit, fresh, { verified: true }); + const amount = fresh.reduce((s, p) => s + p.amount.toNumber(), 0); + store.markNutzapRedeemed(params.eventId); + recordTx({ + kind: "nutzap-in", + status: "completed", + amount, + unit: params.unit, + mintUrl: url, + memo: params.comment, + counterparty: params.senderPubkey, + }); + return amount; +} + +// Build P2PK-locked proofs for an outgoing nutzap. This requires the mint, since +// locking means minting new outputs with a spending condition; there is no +// offline equivalent. +export async function lockProofsForNutzap(params: { + amount: number; + mintUrl: string; + unit: string; + recipientPubkey: string; +}): Promise<{ locked: Proof[]; txId: string }> { + assertUnlocked(); + assertMintNetworkAllowed(); + const url = normalizeMintUrl(params.mintUrl); + const store = useWalletStore.getState(); + const key = accountKey(url, params.unit); + const available = store.proofs[key] ?? []; + + const wallet = await getWallet(url, params.unit); + const txId = newTxId(); + + let result; + try { + result = await wallet.send( + params.amount, + available.map(toProofLike), + { includeFees: true }, + { send: { type: "p2pk", options: { pubkey: params.recipientPubkey } } }, + ); + } catch (err) { + throw asWalletError(err, "mint-error"); + } + + // Work out which proofs the mint actually consumed. + // + // cashu-ts returns `keep` as [change proofs, ...proofs it never selected], so + // the originals it left alone come back with their own secrets intact. The + // difference between what we offered and what came back is therefore exactly + // the set that was spent. Removing that set and then crediting `keep` is + // safe in either order because `addProofs` deduplicates by secret, so the + // untouched originals are not re-added. + const consumed = new Set(available.map((p) => p.secret)); + for (const kept of result.keep) consumed.delete(kept.secret); + store.removeProofs(url, params.unit, [...consumed]); + creditProofs(url, params.unit, result.keep, { verified: true }); + + const sent = result.send.reduce((s, p) => s + p.amount.toNumber(), 0); + store.addTx({ + id: txId, + kind: "nutzap-out", + status: "pending", + amount: sent, + unit: params.unit, + mintUrl: url, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + counterparty: params.recipientPubkey, + }); + + return { locked: result.send, txId }; +} + +// ---- Nutzap send ------------------------------------------------------------ + +export interface NutzapSendResult { + // "nutzap" full NIP-61: proofs locked to their key, published as kind 9321 + // "dm" they publish no nutzap info, so an unlocked token went out as an + // encrypted DM instead. Works, but the token is a bearer + // instrument the moment they decrypt it. + // "token" nothing could be published; a token string is returned for the + // user to deliver by hand. + method: "nutzap" | "dm" | "token"; + amount: number; + unit: string; + mintUrl: string; + // Present when method is "token": the string the caller must show or share. + token?: string; + txId: string; + // Why we fell back, for an honest message in the UI. + fallbackReason?: string; +} + +// Pay a Nostr identity. +// +// Three tiers, best first. The distinction matters to the user because it +// changes who can spend the money: a nutzap is locked to the recipient, a DM +// token is readable by whoever gets the plaintext, and a manual token is +// whoever the user hands it to. The old implementation collapsed all three into +// one "Zap" button that silently downgraded, and reported success either way. +export async function sendNutzap(params: { + recipientPubkey: string; + amount: number; + comment?: string; + client: NostrClient | null; + senderPrivKey: Uint8Array | null; + unit?: string; +}): Promise { + assertUnlocked(); + const unit = params.unit ?? "sat"; + + // No relays, or Tor is blocking mint calls: nothing can be published, so go + // straight to a manual token, which needs neither. + if (!params.client || !params.senderPrivKey) { + return sendAsManualToken(params, unit, "no relay connection"); + } + + let info: NutzapInfo | null = null; + try { + info = await fetchNutzapInfo(params.recipientPubkey, params.client); + } catch { + info = null; + } + + // Full NIP-61 needs three things at once: their nutzap info, a mint we both + // hold value at, and a reachable mint to do the P2PK lock. Missing any one of + // them means falling back, not failing. + if (info) { + const state = useWalletStore.getState(); + const shared = info.mintUrls + .map(normalizeMintUrl) + .find( + (url) => + (state.proofs[accountKey(url, unit)] ?? []).reduce( + (s, p) => s + p.amount, + 0, + ) >= params.amount, + ); + if (shared) { + try { + const { locked, txId } = await lockProofsForNutzap({ + amount: params.amount, + mintUrl: shared, + unit, + recipientPubkey: info.p2pkPubkey, + }); + await publishNutzap({ + proofs: locked, + mintUrl: shared, + recipientPubkey: params.recipientPubkey, + senderPrivKey: params.senderPrivKey, + client: params.client, + comment: params.comment, + }); + useWalletStore.getState().updateTx(txId, { status: "completed" }); + return { + method: "nutzap", + amount: params.amount, + unit, + mintUrl: shared, + txId, + }; + } catch (err) { + // The proofs were locked to them but the publish failed, or the lock + // itself failed. Either way, fall through rather than losing the value: + // `lockProofsForNutzap` leaves a pending transaction, and reconcile + // will settle it from the proof state. + void err; + } + } + } + + const reason = info + ? "no shared mint with enough balance" + : "recipient has not published nutzap info (NIP-61 kind 10019)"; + + // Second tier: an ordinary offline token, delivered inside an encrypted DM. + // Reserved, not spent, so a publish failure is recoverable. + try { + const prepared = await prepareSend({ + amount: params.amount, + unit, + memo: params.comment, + counterparty: params.recipientPubkey, + }); + const { wrapDm } = await import("../core/nostr/gift-wrap"); + const { event } = await wrapDm( + prepared.token, + params.senderPrivKey, + params.recipientPubkey, + ); + await params.client.publish(event); + confirmSend(prepared.txId); + return { + method: "dm", + amount: prepared.amount, + unit, + mintUrl: prepared.mintUrl, + txId: prepared.txId, + fallbackReason: reason, + }; + } catch (err) { + if (err instanceof WalletError && err.code !== "offline") throw err; + return sendAsManualToken(params, unit, reason); + } +} + +// Last tier: build the token and hand it back. Stays reserved and pending until +// the user confirms they delivered it, so an abandoned share sheet does not +// destroy the value. +async function sendAsManualToken( + params: { amount: number; comment?: string; recipientPubkey: string }, + unit: string, + reason: string, +): Promise { + const prepared = await prepareSend({ + amount: params.amount, + unit, + memo: params.comment, + counterparty: params.recipientPubkey, + }); + return { + method: "token", + amount: prepared.amount, + unit, + mintUrl: prepared.mintUrl, + token: prepared.token, + txId: prepared.txId, + fallbackReason: reason, + }; +} + +// ---- Nutzap receive --------------------------------------------------------- + +// Watch relays for incoming nutzaps and redeem them as they arrive. +// +// Redemption needs the mint, so a zap that lands while offline stays on the +// relay and is picked up by the next subscription: NIP-61 events are public and +// replaceable-by-nobody, so nothing is lost by not acting immediately. Already +// redeemed event ids are remembered in the store, which is what stops a relay +// replay from crediting the same zap twice. +export function startNutzapWatcher(params: { + myPubkey: string; + client: NostrClient; + onRedeemed?: (amount: number, unit: string, from: string) => void; +}): () => void { + return subscribeNutzaps(params.myPubkey, params.client, (zap) => { + void (async () => { + const store = useWalletStore.getState(); + if (store.redeemedNutzaps.includes(zap.eventId)) return; + try { + const amount = await redeemNutzapProofs({ + proofs: zap.proofs, + mintUrl: zap.mintUrl, + unit: zap.unit, + eventId: zap.eventId, + senderPubkey: zap.senderPubkey, + comment: zap.comment, + }); + if (amount > 0) params.onRedeemed?.(amount, zap.unit, zap.senderPubkey); + } catch { + // Mint unreachable, Tor-blocked, or the proofs were already claimed. + // Left unmarked so the next subscription retries. + } + })(); + }); +} + +// Publish (or refresh) our own kind 10019 so other wallets can nutzap us. Safe +// to call on every launch: it is a replaceable event, and it only publishes +// when there is something to say (at least one mint). +export async function publishOwnNutzapInfo(params: { + client: NostrClient; + privKey: Uint8Array; + relays: string[]; +}): Promise { + if (!isWalletStorageReady()) return false; + const mints = Object.keys(useWalletStore.getState().mints); + if (mints.length === 0) return false; + try { + await publishNutzapInfo({ + mintUrls: mints, + p2pkPubkey: await getNutzapPubKeyHex(), + relays: params.relays, + privKey: params.privKey, + client: params.client, + }); + return true; + } catch { + return false; + } +} + +// ---- Helpers ---------------------------------------------------------------- + +function newTxId(): string { + return `${Date.now().toString(36)}-${bytesToHex( + crypto.getRandomValues(new Uint8Array(8)), + )}`; +} + +function recordTx( + tx: Omit, +): string { + const id = newTxId(); + useWalletStore.getState().addTx({ + ...tx, + id, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }); + return id; +} + +export function hostOf(mintUrl: string): string { + try { + return new URL(mintUrl).hostname; + } catch { + return mintUrl.replace(/^https?:\/\//, ""); + } +} diff --git a/src/store/__tests__/wallet-store.test.ts b/src/store/__tests__/wallet-store.test.ts index 583020c..6165372 100644 --- a/src/store/__tests__/wallet-store.test.ts +++ b/src/store/__tests__/wallet-store.test.ts @@ -1,29 +1,43 @@ /** * @jest-environment node */ -// Local Cashu proof wallet-store tests. -// Uses the in-memory MMKV mock: no native or network required. +// Wallet store tests: account keying, proof lifecycle, reservations, history. +// +// These cover the invariants that protect real money rather than the plumbing: +// a proof can never be counted twice, a reserved proof is out of the spendable +// balance but not gone, and units from the same mint never merge. +// +// Uses the in-memory MMKV mock, so no native module and no network. import { - selectMintBalances, + accountKey, + normalizeMintUrl, + parseAccountKey, + selectAccounts, + selectBalanceForUnit, selectSecrets, - selectTotalBalance, + selectUnits, useWalletStore, type StoredProof, + type WalletTx, } from "../wallet-store"; -// Reset store between tests. +const MINT = "https://mint.example.com"; +const OTHER = "https://other.mint"; + beforeEach(() => { useWalletStore.getState().clearAll(); }); // ---- Helpers ---------------------------------------------------------------- +let secretCounter = 0; function makeProof(amount: number, secret?: string): StoredProof { + secretCounter += 1; return { - id: "000f01" + amount.toString(16).padStart(4, "0"), + id: "00ad268c4d1f5826", amount, - secret: secret ?? Math.random().toString(36).slice(2), + secret: secret ?? `secret-${String(secretCounter)}`, C: "02" + "ab".repeat(32), }; } @@ -32,143 +46,376 @@ function state() { return useWalletStore.getState(); } +function tx(overrides: Partial = {}): WalletTx { + return { + id: "tx-1", + kind: "send", + status: "pending", + amount: 100, + unit: "sat", + mintUrl: MINT, + createdAtMs: 1, + updatedAtMs: 1, + ...overrides, + }; +} + +// ---- Account keys ----------------------------------------------------------- + +describe("account keys", () => { + it("normalises trailing slashes and host case to one mint", () => { + expect(normalizeMintUrl("https://Mint.Example.com/")).toBe(MINT); + expect(normalizeMintUrl("https://mint.example.com")).toBe(MINT); + }); + + it("strips query and fragment, which are not part of a mint identity", () => { + expect(normalizeMintUrl("https://mint.example.com/?x=1#y")).toBe(MINT); + }); + + it("round-trips through parseAccountKey", () => { + const key = accountKey(MINT, "usd"); + expect(parseAccountKey(key)).toEqual({ mintUrl: MINT, unit: "usd" }); + }); + + it("keeps a path-bearing mint URL intact", () => { + const key = accountKey("https://host.example/cashu/", "sat"); + expect(parseAccountKey(key).mintUrl).toBe("https://host.example/cashu"); + }); +}); + // ---- addProofs -------------------------------------------------------------- describe("addProofs", () => { - it("adds proofs and reflects in proofsByMint", () => { - state().addProofs("https://mint.example", [makeProof(64), makeProof(128)]); - - expect(state().proofsByMint["https://mint.example"]).toHaveLength(2); + it("adds proofs under the (mint, unit) account", () => { + state().addProofs(MINT, "sat", [makeProof(64), makeProof(128)]); + expect(state().proofs[accountKey(MINT, "sat")]).toHaveLength(2); }); - it("deduplicates by secret", () => { + it("deduplicates by secret so a re-paste cannot inflate the balance", () => { const proof = makeProof(32, "same-secret"); + state().addProofs(MINT, "sat", [proof]); + const result = state().addProofs(MINT, "sat", [proof, makeProof(64)]); - state().addProofs("https://mint.example", [proof]); - state().addProofs("https://mint.example", [proof, makeProof(64)]); + expect(result).toEqual({ added: 1, duplicates: 1 }); + expect(state().proofs[accountKey(MINT, "sat")]).toHaveLength(2); + }); - // Only 2 unique proofs: 'same-secret' (32 sat) + new (64 sat) - expect(state().proofsByMint["https://mint.example"]).toHaveLength(2); + it("refuses a proof that is currently reserved for an in-flight send", () => { + const proof = makeProof(50, "reserved-secret"); + state().addProofs(MINT, "sat", [proof]); + state().reserveProofs("tx-1", MINT, "sat", [proof]); + + // Receiving our own outgoing token back must not credit it twice: the + // reservation still holds those proofs against the pending send. + const result = state().addProofs(MINT, "sat", [proof]); + expect(result).toEqual({ added: 0, duplicates: 1 }); + expect(selectBalanceForUnit(state(), "sat")).toBe(0); }); - it("is a no-op for empty array", () => { - state().addProofs("https://mint.example", []); + it("never merges units from the same mint", () => { + state().addProofs(MINT, "sat", [makeProof(100)]); + state().addProofs(MINT, "usd", [makeProof(5)]); + + expect(selectBalanceForUnit(state(), "sat")).toBe(100); + expect(selectBalanceForUnit(state(), "usd")).toBe(5); + expect(selectUnits(state())).toEqual(["sat", "usd"]); + }); - expect(state().proofsByMint["https://mint.example"]).toBeUndefined(); + it("is a no-op for an empty array", () => { + expect(state().addProofs(MINT, "sat", [])).toEqual({ + added: 0, + duplicates: 0, + }); + expect(state().proofs[accountKey(MINT, "sat")]).toBeUndefined(); }); +}); + +// ---- Verification state ----------------------------------------------------- - it("keeps separate proof lists per mint", () => { - state().addProofs("https://mint-a.example", [makeProof(100)]); - state().addProofs("https://mint-b.example", [ - makeProof(200), - makeProof(50), +describe("verification", () => { + it("reports offline-received proofs as unverified until marked", () => { + state().addProofs(MINT, "sat", [ + makeProof(10, "a"), + { ...makeProof(20, "b"), verified: true }, ]); - expect(state().proofsByMint["https://mint-a.example"]).toHaveLength(1); - expect(state().proofsByMint["https://mint-b.example"]).toHaveLength(2); + const account = selectAccounts(state())[0]; + expect(account.balance).toBe(30); + expect(account.unverified).toBe(10); + + state().markVerified(MINT, "sat", ["a"]); + expect(selectAccounts(state())[0].unverified).toBe(0); }); }); -// ---- removeProofs ----------------------------------------------------------- +// ---- Reservations ----------------------------------------------------------- + +describe("reservations", () => { + it("moves proofs out of the spendable balance without deleting them", () => { + const proof = makeProof(64, "held"); + state().addProofs(MINT, "sat", [proof, makeProof(32)]); + state().reserveProofs("tx-1", MINT, "sat", [proof]); + + expect(selectBalanceForUnit(state(), "sat")).toBe(32); + const account = selectAccounts(state()).find((a) => a.unit === "sat"); + expect(account?.reserved).toBe(64); + expect(state().reserved["tx-1"].proofs).toHaveLength(1); + }); + + it("restores a reservation on release", () => { + const proof = makeProof(64, "held"); + state().addProofs(MINT, "sat", [proof]); + state().reserveProofs("tx-1", MINT, "sat", [proof]); + + const restored = state().releaseReserved("tx-1"); + expect(restored).toHaveLength(1); + expect(selectBalanceForUnit(state(), "sat")).toBe(64); + expect(state().reserved["tx-1"]).toBeUndefined(); + }); + + it("does not double-credit when the reserved proof is already back", () => { + const proof = makeProof(64, "held"); + state().addProofs(MINT, "sat", [proof]); + state().reserveProofs("tx-1", MINT, "sat", [proof]); + // Simulate the same proof arriving again (the recipient bounced the token + // back) before the reservation was released. + useWalletStore.setState({ + proofs: { [accountKey(MINT, "sat")]: [proof] }, + }); + + state().releaseReserved("tx-1"); + expect(selectBalanceForUnit(state(), "sat")).toBe(64); + }); + + it("drops a reservation for good once delivery is confirmed", () => { + const proof = makeProof(64, "held"); + state().addProofs(MINT, "sat", [proof]); + state().reserveProofs("tx-1", MINT, "sat", [proof]); + state().dropReserved("tx-1"); + + expect(state().reserved["tx-1"]).toBeUndefined(); + expect(selectBalanceForUnit(state(), "sat")).toBe(0); + }); + + it("returns null when releasing an unknown reservation", () => { + expect(state().releaseReserved("nope")).toBeNull(); + }); +}); + +// ---- removeProofs / replaceProofs ------------------------------------------- describe("removeProofs", () => { - it("removes proofs by secret", () => { - state().addProofs("https://mint.example", [ + it("removes by secret and leaves the rest", () => { + state().addProofs(MINT, "sat", [ makeProof(64, "keep-me"), makeProof(32, "remove-me"), ]); - state().removeProofs("https://mint.example", ["remove-me"]); + state().removeProofs(MINT, "sat", ["remove-me"]); - const remaining = state().proofsByMint["https://mint.example"]; + const remaining = state().proofs[accountKey(MINT, "sat")]; expect(remaining).toHaveLength(1); expect(remaining[0].secret).toBe("keep-me"); }); it("is a no-op for unknown secrets", () => { - state().addProofs("https://mint.example", [makeProof(64, "existing")]); - state().removeProofs("https://mint.example", ["ghost"]); + state().addProofs(MINT, "sat", [makeProof(64, "existing")]); + state().removeProofs(MINT, "sat", ["ghost"]); + expect(state().proofs[accountKey(MINT, "sat")]).toHaveLength(1); + }); +}); - expect(state().proofsByMint["https://mint.example"]).toHaveLength(1); +describe("replaceProofs", () => { + it("swaps the whole account list", () => { + state().addProofs(MINT, "sat", [makeProof(32), makeProof(64)]); + state().replaceProofs(MINT, "sat", [makeProof(128)]); + + const list = state().proofs[accountKey(MINT, "sat")]; + expect(list).toHaveLength(1); + expect(list[0].amount).toBe(128); }); }); -// ---- replaceProofs ---------------------------------------------------------- +// ---- Mints ------------------------------------------------------------------ -describe("replaceProofs", () => { - it("replaces the full proof list for a mint", () => { - state().addProofs("https://mint.example", [makeProof(32), makeProof(64)]); - state().replaceProofs("https://mint.example", [makeProof(128)]); +describe("mints", () => { + it("keeps addedAtMs stable across patches", () => { + state().addMint(MINT, { name: "First" }); + const first = state().mints[MINT].addedAtMs; + state().addMint(MINT, { name: "Renamed" }); - expect(state().proofsByMint["https://mint.example"]).toHaveLength(1); - expect(state().proofsByMint["https://mint.example"][0].amount).toBe(128); + expect(state().mints[MINT].addedAtMs).toBe(first); + expect(state().mints[MINT].name).toBe("Renamed"); + }); + + it("removing a mint deletes every account it holds", () => { + state().addMint(MINT); + state().addProofs(MINT, "sat", [makeProof(10)]); + state().addProofs(MINT, "usd", [makeProof(5)]); + state().addProofs(OTHER, "sat", [makeProof(20)]); + + state().removeMint(MINT); + + expect(state().proofs[accountKey(MINT, "sat")]).toBeUndefined(); + expect(state().proofs[accountKey(MINT, "usd")]).toBeUndefined(); + expect(state().proofs[accountKey(OTHER, "sat")]).toHaveLength(1); }); }); -// ---- clearMint / clearAll --------------------------------------------------- +// ---- History ---------------------------------------------------------------- -describe("clearMint", () => { - it("removes all proofs for the given mint, keeping others", () => { - state().addProofs("https://a.mint", [makeProof(10)]); - state().addProofs("https://b.mint", [makeProof(20)]); - state().clearMint("https://a.mint"); +describe("history", () => { + it("prepends newest first and updates in place", () => { + state().addTx(tx({ id: "a" })); + state().addTx(tx({ id: "b" })); + expect(state().history.map((t) => t.id)).toEqual(["b", "a"]); - expect(state().proofsByMint["https://a.mint"]).toBeUndefined(); - expect(state().proofsByMint["https://b.mint"]).toHaveLength(1); + state().updateTx("a", { status: "completed" }); + expect(state().history.find((t) => t.id === "a")?.status).toBe("completed"); }); }); -describe("clearAll", () => { - it("empties all proofs across all mints", () => { - state().addProofs("https://a.mint", [makeProof(10), makeProof(20)]); - state().clearAll(); +// ---- Nutzap replay guard ---------------------------------------------------- - expect(Object.keys(state().proofsByMint)).toHaveLength(0); +describe("redeemed nutzaps", () => { + it("records an event id once so a relay replay cannot re-credit it", () => { + state().markNutzapRedeemed("event-1"); + state().markNutzapRedeemed("event-1"); + expect(state().redeemedNutzaps).toEqual(["event-1"]); }); }); // ---- Selectors -------------------------------------------------------------- -describe("selectTotalBalance", () => { - it("sums amounts across all mints", () => { - state().addProofs("https://a.mint", [makeProof(100), makeProof(50)]); - state().addProofs("https://b.mint", [makeProof(200)]); +describe("selectors", () => { + it("sums a unit across mints but never across units", () => { + state().addProofs(MINT, "sat", [makeProof(100), makeProof(50)]); + state().addProofs(OTHER, "sat", [makeProof(200)]); + state().addProofs(OTHER, "usd", [makeProof(7)]); - expect(selectTotalBalance(state())).toBe(350); + expect(selectBalanceForUnit(state(), "sat")).toBe(350); + expect(selectBalanceForUnit(state(), "usd")).toBe(7); }); it("returns 0 for an empty store", () => { - expect(selectTotalBalance(state())).toBe(0); + expect(selectBalanceForUnit(state(), "sat")).toBe(0); + expect(selectAccounts(state())).toEqual([]); }); -}); - -describe("selectMintBalances", () => { - it("returns per-mint balance entries with correct totals", () => { - state().addProofs("https://a.mint", [makeProof(64), makeProof(32)]); - const balances = selectMintBalances(state()); - expect(balances).toHaveLength(1); - expect(balances[0].mintUrl).toBe("https://a.mint"); - expect(balances[0].balance).toBe(96); - expect(balances[0].proofCount).toBe(2); - expect(balances[0].unit).toBe("sat"); + it("lists an added mint with no proofs so it is visible in the UI", () => { + state().addMint(MINT, { units: ["sat"] }); + const accounts = selectAccounts(state()); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + mintUrl: MINT, + unit: "sat", + balance: 0, + }); }); -}); -describe("selectSecrets", () => { - it("returns a Set of all stored secrets for a mint", () => { - state().addProofs("https://mint.example", [ + it("selectSecrets returns the account's secrets", () => { + state().addProofs(MINT, "sat", [ makeProof(10, "alpha"), makeProof(20, "beta"), ]); - - const secrets = selectSecrets(state(), "https://mint.example"); + const secrets = selectSecrets(state(), accountKey(MINT, "sat")); expect(secrets.has("alpha")).toBe(true); - expect(secrets.has("beta")).toBe(true); expect(secrets.has("gamma")).toBe(false); + expect(selectSecrets(state(), accountKey(OTHER, "sat")).size).toBe(0); + }); +}); + +// ---- Backup coverage -------------------------------------------------------- + +describe("backup coverage", () => { + it("reports nothing as unbacked while backup is off", () => { + // With no recovery phrase there is no coverage to be inside or outside of, + // so flagging proofs as "unbacked" would be noise. + state().addProofs(MINT, "sat", [makeProof(10), makeProof(20)]); + expect(selectAccounts(state())[0].unbacked).toBe(0); + }); + + it("counts proofs that were not derived from the phrase", () => { + state().setBackupEnabled(true); + state().addProofs(MINT, "sat", [ + { ...makeProof(10), secret: "ours", derived: true }, + { ...makeProof(20), secret: "theirs", derived: false }, + ]); + + const account = selectAccounts({ + proofs: state().proofs, + reserved: state().reserved, + mints: state().mints, + backupEnabled: true, + })[0]; + expect(account.balance).toBe(30); + expect(account.unbacked).toBe(20); + }); +}); + +// ---- NUT-13 counters -------------------------------------------------------- + +describe("counters", () => { + const KEYSET = "00ad268c4d1f5826"; + + it("hands out a fresh range each time and never repeats", () => { + // Reusing a counter re-derives a secret the mint has already signed, which + // it rejects as a duplicate. Ranges must not overlap. + const first = state().reserveCounters(KEYSET, 3); + const second = state().reserveCounters(KEYSET, 2); + + expect(first).toEqual({ start: 0, count: 3 }); + expect(second).toEqual({ start: 3, count: 2 }); + expect(state().counters[KEYSET]).toBe(5); + }); + + it("treats a zero-size reservation as a read-only peek", () => { + state().reserveCounters(KEYSET, 4); + const peek = state().reserveCounters(KEYSET, 0); + + expect(peek).toEqual({ start: 4, count: 0 }); + expect(state().counters[KEYSET]).toBe(4); + }); + + it("tracks keysets independently", () => { + state().reserveCounters(KEYSET, 5); + expect(state().reserveCounters("other-keyset", 1).start).toBe(0); + }); + + it("advances to a floor but never backwards", () => { + state().reserveCounters(KEYSET, 10); + state().advanceCounter(KEYSET, 50); + expect(state().counters[KEYSET]).toBe(50); + + // A restore that finds an older last-signature must not rewind the cursor + // into counters already used by live proofs. + state().advanceCounter(KEYSET, 20); + expect(state().counters[KEYSET]).toBe(50); }); - it("returns empty Set for unknown mint", () => { - const secrets = selectSecrets(state(), "https://unknown.mint"); - expect(secrets.size).toBe(0); + it("clearAll resets backup state and counters together", () => { + state().setBackupEnabled(true); + state().reserveCounters(KEYSET, 5); + state().clearAll(); + + expect(state().backupEnabled).toBe(false); + expect(state().counters).toEqual({}); + }); +}); + +// ---- clearAll --------------------------------------------------------------- + +describe("clearAll", () => { + it("empties proofs, reservations, mints and history", () => { + state().addMint(MINT); + state().addProofs(MINT, "sat", [makeProof(10)]); + state().addTx(tx()); + state().reserveProofs("tx-2", MINT, "sat", [makeProof(5)]); + + state().clearAll(); + + expect(Object.keys(state().proofs)).toHaveLength(0); + expect(Object.keys(state().reserved)).toHaveLength(0); + expect(Object.keys(state().mints)).toHaveLength(0); + expect(state().history).toHaveLength(0); }); }); diff --git a/src/store/settings-store.ts b/src/store/settings-store.ts index fe21e10..b442aaf 100644 --- a/src/store/settings-store.ts +++ b/src/store/settings-store.ts @@ -24,10 +24,6 @@ interface SettingsState { theme: ThemePreference; autoDownloadMedia: boolean; uploadQuality: UploadQuality; - // Whether the Payments feature is switched on. Off hides the Wallet tab - // from the tab bar; it does not touch the wallet's stored proofs, so - // turning it back on restores the balance untouched. - paymentsEnabled: boolean; // Whether this device acts as an internet gateway: relaying mesh-only peers' // geohash events to Nostr (toGateway carriers) and, in future, rebroadcasting // relay traffic to the mesh. Off by default, matching bitchat; enabling it @@ -38,15 +34,23 @@ interface SettingsState { // Persisted so the choice is applied before the first relay connects at // startup (see tor-routing.ts), never leaking the clear net for a Tor user. torEnabled: boolean; + // Whether Cashu mint HTTP calls may go out over the clear net while Tor is + // on. Tor only covers Nostr WebSockets on iOS (Arti is a per-socket SOCKS + // shim, and mint calls are plain fetch), so with Tor enabled a mint request + // would reveal this device's IP to the mint and link it to the proofs being + // swapped. Off by default: mint calls are refused instead, and the wallet + // stays fully usable offline. Android is unaffected, since Orbot's VPN routes + // every socket, so this flag is only consulted on iOS. + allowMintOverClearnet: boolean; // Monospace typeface for keys/IDs/geohashes/amounts. Live: changing it // recomputes styles immediately via useThemeColors (see ui/theme.ts). monoFont: MonoFont; setTheme: (theme: ThemePreference) => void; setAutoDownloadMedia: (enabled: boolean) => void; setUploadQuality: (quality: UploadQuality) => void; - setPaymentsEnabled: (enabled: boolean) => void; setGatewayEnabled: (enabled: boolean) => void; setTorEnabled: (enabled: boolean) => void; + setAllowMintOverClearnet: (allowed: boolean) => void; setMonoFont: (font: MonoFont) => void; // Restore first-run defaults. Used by the panic wipe. reset: () => void; @@ -58,11 +62,9 @@ const DEFAULTS = { theme: "system", autoDownloadMedia: true, uploadQuality: "high", - // Opt-in, like the gateway and Tor: a new install starts with the wallet off - // so payments never appear until the user deliberately enables them. - paymentsEnabled: false, gatewayEnabled: false, torEnabled: false, + allowMintOverClearnet: false, // The device's own monospace by default, so a new install looks native and // familiar. JetBrains Mono is offered as an opt-in choice under Appearance. monoFont: "system", @@ -92,15 +94,15 @@ export const useSettingsStore = create()( setUploadQuality(quality) { set({ uploadQuality: quality }); }, - setPaymentsEnabled(enabled) { - set({ paymentsEnabled: enabled }); - }, setGatewayEnabled(enabled) { set({ gatewayEnabled: enabled }); }, setTorEnabled(enabled) { set({ torEnabled: enabled }); }, + setAllowMintOverClearnet(allowed) { + set({ allowMintOverClearnet: allowed }); + }, setMonoFont(font) { set({ monoFont: font }); }, diff --git a/src/store/wallet-store.ts b/src/store/wallet-store.ts index 1cb8b22..4184a65 100644 --- a/src/store/wallet-store.ts +++ b/src/store/wallet-store.ts @@ -1,37 +1,74 @@ -// Local Cashu proof storage backed by MMKV. +// Local Cashu wallet state: proofs, mints, in-flight sends, and history. // -// Cashu proofs are bearer tokens representing real value. Per ARCHITECTURE.md -// they live in MMKV (not EncryptedStorage) because MMKV supports an optional -// encryptionKey that wraps the backing file with AES-256. The encryption key -// itself must be stored in Keychain/Keystore via react-native-encrypted-storage -// and passed to createMMKV({ id, encryptionKey }) before first use. Callers -// are responsible for that key provisioning step; this module just defines the -// store schema and operations. +// Cashu proofs are bearer instruments. Whoever holds the `secret`/`C` pair owns +// the value, so this store is the only place in the app that must be encrypted +// at rest: the backing MMKV file is opened with an AES-256 key that lives in the +// iOS Keychain / Android Keystore (see `bootstrapWalletStorage`). Nothing here +// touches the network; every mint call lives in `src/services/wallet-service.ts`. // -// This store is intentionally minimal: it tracks unspent proofs only. The -// NIP-60 history (kind 7375/7376 events) is synced separately via Nostr. -// Redemption (swap at mint) is delegated to @cashu/cashu-ts Wallet. +// Shape +// ----- +// State is keyed by *account*, meaning a (mint URL, unit) pair. A mint can issue +// sat, usd and eur from the same host, and those are different currencies: they +// must never be summed into one balance. `accountKey()` builds the composite key +// and `parseAccountKey()` splits it back (mint URLs cannot contain `|`). // -// For proof serialization: Proof objects (from @cashu/cashu-ts) are stored -// as a JSON array keyed by mint URL. The Amount value object is serialized via -// its toJSON() method (which returns the decimal string). +// Proof lifecycle +// --------------- +// spendable + verified swapped at the mint, or minted by us. Known good. +// spendable + unverified received offline (BLE/QR/paste). Cryptographically +// well-formed, and DLEQ-checked when we hold the mint +// keys, but the mint has NOT confirmed it is unspent. +// DLEQ proves the mint signed it; it can never prove +// the sender did not already spend it elsewhere. These +// count towards the balance but are surfaced +// separately in the UI and are redeemed first. +// reserved set aside for a send that has been serialised into a +// token but not yet confirmed delivered. Excluded from +// the spendable balance so the same proof cannot be +// handed to two people, and recoverable via +// `reclaimSend` if the transfer never lands. +// +// The reserved bucket is what makes a crash mid-send survivable: proofs are +// moved (never deleted) and the exact token string is kept on the transaction, +// so the user can re-share or reclaim it after a restart. +// +// Backup +// ------ +// Off by default. When the user sets up a recovery phrase, proof secrets stop +// being random and are derived from it instead (NUT-13), which is what lets a +// new device rebuild the balance by asking the mint which of those secrets it +// signed (NUT-09). `counters` is the per-keyset derivation cursor that makes +// that ordering reproducible; it must only ever move forward, because reusing a +// counter recreates a secret the mint has already seen. `StoredProof.derived` +// records which proofs are actually covered, since anything received from +// somebody else carries their secrets until it is swapped. +// +// The phrase itself is never stored here. It lives in the keychain alongside +// the identity keys (see `core/payments/wallet-seed.ts`). -import type { Proof } from "@cashu/cashu-ts"; +import EncryptedStorage from "react-native-encrypted-storage"; import { createMMKV } from "react-native-mmkv"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -// ---- Types ------------------------------------------------------------------ +// ---- Constants -------------------------------------------------------------- -// Simplified serializable proof for MMKV storage. -// We strip the Amount value object and store numeric sats directly. -export interface StoredProof { - id: string; // Keyset ID - amount: number; // Denominated in the keyset unit (typically sats) - secret: string; // The blinded secret - C: string; // Unblinded signature from mint - dleq?: SerializedDleq; // Optional offline DLEQ proof -} +// Encrypted store. The plaintext v1 id is migrated in and wiped on first run. +export const WALLET_STORAGE_ID = "wallet-store-v2"; +export const LEGACY_WALLET_STORAGE_ID = "wallet-store"; + +// Keychain/Keystore entry holding the MMKV encryption key. +const ENCRYPTION_KEY_ITEM = "airhop.wallet.mmkvKey.v1"; + +// MMKV caps AES-256 keys at 32 bytes; 24 random bytes in base64 is exactly 32 +// ASCII characters, so this spends the whole budget on entropy. +const ENCRYPTION_KEY_BYTES = 24; + +// Separator between mint URL and unit in an account key. +const ACCOUNT_SEP = "|"; + +// ---- Types ------------------------------------------------------------------ export interface SerializedDleq { e: string; @@ -39,152 +76,795 @@ export interface SerializedDleq { r?: string; } -export interface MintBalance { +// A proof as persisted. `amount` is a plain number (the cashu-ts `Amount` value +// object does not survive JSON), denominated in the account's unit. +export interface StoredProof { + id: string; // Keyset ID + amount: number; + secret: string; + C: string; // Unblinded signature from the mint + dleq?: SerializedDleq; // NUT-12 discrete-log-equality witness, when present + witness?: string; // NUT-11 P2PK / NUT-14 HTLC witness, when present + // False for anything received offline: the mint has not told us this proof is + // unspent. Set true after a successful swap or NUT-07 state check. + verified?: boolean; + // True when this proof's secret was derived from the recovery phrase (NUT-13) + // rather than generated randomly, which is what makes it restorable on + // another device. Proofs received from someone else carry *their* secrets, so + // they are never derived until we swap them at the mint. + derived?: boolean; + // When this proof entered the wallet, for "oldest unverified" prompts. + receivedAtMs?: number; +} + +export type TxKind = + | "send" // outgoing ecash token (BLE, QR, share sheet) + | "receive" // incoming ecash token + | "mint" // Lightning deposit (bolt11 invoice paid to the mint) + | "melt" // Lightning withdrawal (mint pays an invoice out) + | "swap" // refresh/consolidate at the mint, no net value change + | "nutzap-out" // NIP-61 outgoing + | "nutzap-in"; // NIP-61 incoming + +export type TxStatus = + | "pending" // reserved/awaiting the mint or the recipient + | "completed" + | "failed" + | "reclaimed" // a pending send the user pulled back into the balance + | "expired"; // a mint quote that ran out before it was paid + +export interface WalletTx { + id: string; + kind: TxKind; + status: TxStatus; + // Face value moved, always positive. Direction comes from `kind`. + amount: number; + // Mint fee paid on top (NUT-02 input fees, or a melt fee reserve). + fee?: number; + unit: string; mintUrl: string; - unit: string; // "sat" typically - balance: number; // Sum of unspent proof amounts + createdAtMs: number; + updatedAtMs: number; + memo?: string; + // Peer ID, npub, or mint host, depending on `kind`. + counterparty?: string; + // Serialised token for a pending send, so it can be re-shared after a + // restart, and so `reclaimSend` has something to show if reclaim fails. + token?: string; + // Mint/melt quote identifier and the bolt11 invoice it relates to. + quoteId?: string; + invoice?: string; + // Populated on `failed`, shown verbatim in the transaction detail sheet. + error?: string; +} + +// A mint the user has chosen to trust, plus everything we cached from it so the +// wallet stays useful offline (fees, units, and the keys DLEQ verification +// needs). +export interface StoredMint { + url: string; + addedAtMs: number; + name?: string; + description?: string; + // Units the mint advertises keysets for, e.g. ["sat", "usd"]. + units?: string[]; + // NUT numbers the mint supports, used to gate Lightning and P2PK features. + supportedNuts?: number[]; + // `keyChain.cache` from cashu-ts, verbatim. Holds public keys only, so it is + // not secret, but it is what makes offline DLEQ verification possible. + keysetCache?: unknown; + keysetCacheAtMs?: number; + // Raw `/v1/info` response, kept verbatim so `wallet.loadMintFromCache` can be + // handed exactly what it expects on a cold start with no network. + infoResponse?: unknown; + // Per-keyset input fee in parts-per-thousand (NUT-02), for offline fee maths. + feePpkByKeysetId?: Record; + // Last time any mint call succeeded, for the "unreachable" badge. + lastSeenMs?: number; +} + +export interface AccountBalance { + key: string; + mintUrl: string; + unit: string; + // Sum of spendable proofs (verified + unverified). + balance: number; + // Subset of `balance` the mint has not confirmed as unspent. + unverified: number; + // Subset of `balance` that the recovery phrase could not rebuild, because + // those secrets were not derived from it. Zero when backup is off, since + // nothing is covered then and the distinction would be noise. + unbacked: number; + // Sum of proofs held in the reserved bucket, not spendable. + reserved: number; proofCount: number; } -// ---- Conversion helpers ----------------------------------------------------- +interface WalletState { + // Spendable proofs, keyed by `accountKey(mintUrl, unit)`. + proofs: Record; + // Proofs set aside for an in-flight send, keyed by transaction id. + reserved: Record; + // Mints the user trusts, keyed by normalised URL. + mints: Record; + // Newest first, capped at MAX_HISTORY. + history: WalletTx[]; + // P2PK public key we publish in NIP-61 kind 10019, hex, 33-byte compressed. + // The matching private key lives in the Keychain, never here. + nutzapPubkey?: string; + // Nostr event ids of nutzaps already redeemed, so a relay replay cannot + // double-credit the balance. + redeemedNutzaps: string[]; + // First proof secret of every token already taken into the wallet. A secret + // is random and unique to its token, so it identifies one without storing the + // whole string. This is display state, not a spend guard: `addProofs` already + // deduplicates. It exists so a payment card in a chat can read "Claimed" + // instead of offering a button that can only produce a confusing error. + claimedTokens: string[]; + + // Whether a recovery phrase has been set up. Off by default: until the user + // opts in, proof secrets are random and nothing is restorable. The phrase + // itself lives in the keychain, never here. + backupEnabled: boolean; + // Whether the user proved they wrote the phrase down. A phrase that exists + // but was never copied out is the worst state to be in, because the wallet + // looks protected and is not, so the two are tracked separately and the UI + // says which one it is. + backupVerified: boolean; + // Next NUT-13 derivation counter per keyset id. Deriving the same counter + // twice recreates the same secret, which the mint rejects as a duplicate, so + // this only ever moves forward and is persisted before the outputs it covers + // are sent. + counters: Record; + + // ---- Mints ---- + addMint: (mintUrl: string, patch?: Partial) => void; + updateMint: (mintUrl: string, patch: Partial) => void; + removeMint: (mintUrl: string) => void; -export function proofToStored(proof: Proof): StoredProof { - return { - id: proof.id, - amount: proof.amount.toNumber(), - secret: proof.secret, - C: proof.C, - dleq: proof.dleq as SerializedDleq | undefined, - }; + // ---- Proofs ---- + addProofs: ( + mintUrl: string, + unit: string, + proofs: StoredProof[], + ) => { added: number; duplicates: number }; + removeProofs: (mintUrl: string, unit: string, secrets: string[]) => void; + replaceProofs: (mintUrl: string, unit: string, proofs: StoredProof[]) => void; + markVerified: (mintUrl: string, unit: string, secrets: string[]) => void; + + // ---- Reservations ---- + // Move proofs out of the spendable pool and hold them against `txId`. + reserveProofs: ( + txId: string, + mintUrl: string, + unit: string, + proofs: StoredProof[], + ) => void; + // Put a reservation back into the spendable pool (send never landed). + releaseReserved: (txId: string) => StoredProof[] | null; + // Drop a reservation for good (recipient confirmed, or mint says spent). + dropReserved: (txId: string) => void; + + // ---- History ---- + addTx: (tx: WalletTx) => void; + updateTx: (id: string, patch: Partial) => void; + + // ---- Nutzap ---- + setNutzapPubkey: (pubkey: string) => void; + markNutzapRedeemed: (eventId: string) => void; + markTokenClaimed: (firstSecret: string) => void; + + // ---- Backup / NUT-13 counters ---- + setBackupEnabled: (enabled: boolean) => void; + setBackupVerified: (verified: boolean) => void; + // Claim `n` counters for a keyset and move the cursor past them. Synchronous + // read-modify-write with no await in between, so two concurrent callers + // cannot be handed the same range. + reserveCounters: ( + keysetId: string, + n: number, + ) => { start: number; count: number }; + // Move the cursor forward to at least `minNext`. Never moves it back: a lower + // value would re-issue counters that have already produced live proofs. + advanceCounter: (keysetId: string, minNext: number) => void; + + // ---- Wipe ---- + clearAccount: (mintUrl: string, unit: string) => void; + clearAll: () => void; } -// ---- State ------------------------------------------------------------------ +// Keep history bounded: MMKV holds the whole blob in memory on read. +const MAX_HISTORY = 500; -interface WalletState { - // Unspent proofs grouped by mint URL. - proofsByMint: Record; - // Default unit assumed for all proofs (sats per NUT-00 default). +// Ring buffer of redeemed nutzap ids. Well past any relay's replay window. +const MAX_REDEEMED_NUTZAPS = 1000; + +// Ring buffer of claimed-token markers. Purely cosmetic, so an entry falling +// off simply means a very old payment card offers Claim again, which then +// reports "already claimed" as it did before. +const MAX_CLAIMED_TOKENS = 1000; + +// ---- Account keys ----------------------------------------------------------- + +// Normalise a mint URL so `https://m.example.com/` and `https://m.example.com` +// are one mint. Lowercases the host (case-insensitive per RFC 3986) but leaves +// the path alone, since mint paths are case-sensitive. +export function normalizeMintUrl(raw: string): string { + const trimmed = raw.trim(); + try { + const url = new URL(trimmed); + url.hostname = url.hostname.toLowerCase(); + url.hash = ""; + url.search = ""; + const path = url.pathname.replace(/\/+$/, ""); + return `${url.protocol}//${url.host}${path}`; + } catch { + return trimmed.replace(/\/+$/, ""); + } +} + +export function accountKey(mintUrl: string, unit: string): string { + return `${normalizeMintUrl(mintUrl)}${ACCOUNT_SEP}${unit}`; +} + +export function parseAccountKey(key: string): { + mintUrl: string; unit: string; +} { + const idx = key.lastIndexOf(ACCOUNT_SEP); + if (idx < 0) return { mintUrl: key, unit: "sat" }; + return { mintUrl: key.slice(0, idx), unit: key.slice(idx + 1) }; +} - // Register a new mint URL (zero proofs). No-op if already registered. - addMint: (mintUrl: string) => void; - // Add proofs from a received token. - addProofs: (mintUrl: string, proofs: StoredProof[]) => void; - // Remove specific proofs by their secret (called after mint swap/redemption). - removeProofs: (mintUrl: string, secrets: string[]) => void; - // Replace proofs for a mint wholesale (after a swap operation). - replaceProofs: (mintUrl: string, proofs: StoredProof[]) => void; - // Clear all proofs for a mint (panic wipe hook). - clearMint: (mintUrl: string) => void; - // Clear everything (panic wipe). - clearAll: () => void; +// ---- Encrypted storage bootstrap -------------------------------------------- + +// MMKV needs its encryption key at construction time, but reading the Keychain +// is async, so the instance cannot exist at module scope. Every persist call is +// therefore funnelled through `ready`, a promise that resolves once the key has +// been fetched (or created) and the instance opened. zustand/persist accepts an +// async storage adapter, so this is invisible to callers apart from +// `useWalletStore.persist.hasHydrated()`. + +type MMKVLike = ReturnType; + +let instance: MMKVLike | null = null; +let ready: Promise | null = null; + +function randomKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(ENCRYPTION_KEY_BYTES)); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + // btoa is present in Hermes and in Node 16+; base64 of 24 bytes is 32 chars. + return globalThis.btoa(binary); +} + +async function loadOrCreateEncryptionKey(): Promise { + const existing = await EncryptedStorage.getItem(ENCRYPTION_KEY_ITEM); + if (typeof existing === "string" && existing.length > 0) return existing; + const fresh = randomKey(); + await EncryptedStorage.setItem(ENCRYPTION_KEY_ITEM, fresh); + return fresh; +} + +// Copy the v1 plaintext store into the encrypted one, once, then wipe it. +// Anything already written to v2 wins: a partial migration must never clobber +// newer state (that would destroy money). +function migrateLegacyStore(target: MMKVLike): void { + try { + const legacy = createMMKV({ id: LEGACY_WALLET_STORAGE_ID }); + const raw = legacy.getString("wallet-state"); + if (raw && target.getString("wallet-state") === undefined) { + target.set("wallet-state-legacy-v1", raw); + } + legacy.clearAll(); + } catch { + // A missing or unreadable legacy store is the normal case for new installs. + } +} + +// Open (or reuse) the encrypted wallet store. Safe to call repeatedly; the +// first call wins and every later one awaits the same promise. +export function bootstrapWalletStorage(): Promise { + ready ??= (async () => { + let encryptionKey: string | undefined; + try { + encryptionKey = await loadOrCreateEncryptionKey(); + } catch { + // Keychain/Keystore unavailable (locked device, simulator quirk, a build + // without the native module). Falling back to an unencrypted store would + // silently downgrade the security of bearer tokens, so refuse: the store + // stays empty, the UI shows the wallet as locked, and no proof is ever + // written to plaintext disk. + encryptionKey = undefined; + } + if (encryptionKey === undefined) { + throw new Error("wallet-keystore-unavailable"); + } + const mmkv = createMMKV({ + id: WALLET_STORAGE_ID, + encryptionKey, + encryptionType: "AES-256", + }); + migrateLegacyStore(mmkv); + instance = mmkv; + return mmkv; + })(); + return ready; } +// True once the encrypted store is open. The wallet UI gates spending on this +// so a Keychain failure surfaces as "wallet locked" rather than a zero balance. +export function isWalletStorageReady(): boolean { + return instance !== null; +} + +const asyncMMKVStorage = { + async getItem(name: string): Promise { + const mmkv = await bootstrapWalletStorage(); + const current = mmkv.getString(name); + if (current !== undefined) return current; + // First run after the v1 -> v2 migration: adopt the legacy blob. + const legacy = mmkv.getString(`${name}-legacy-v1`); + return legacy ?? null; + }, + async setItem(name: string, value: string): Promise { + const mmkv = await bootstrapWalletStorage(); + mmkv.set(name, value); + }, + async removeItem(name: string): Promise { + const mmkv = await bootstrapWalletStorage(); + mmkv.remove(name); + }, +}; + // ---- Selectors -------------------------------------------------------------- -// Total balance across all mints in sats. -export function selectTotalBalance(state: WalletState): number { - return Object.values(state.proofsByMint).reduce( - (total, proofs) => total + proofs.reduce((sum, p) => sum + p.amount, 0), +// The persisted half of the store. Selectors take this rather than the full +// `WalletState` so a component can hand them the exact slices it subscribed to, +// which is both cheaper and what keeps a useMemo's dependency list honest. +export type WalletData = Pick< + WalletState, + | "proofs" + | "reserved" + | "mints" + | "history" + | "redeemedNutzaps" + | "claimedTokens" + | "backupEnabled" + | "backupVerified" + | "counters" +>; + +function sum(proofs: StoredProof[]): number { + return proofs.reduce((total, p) => total + p.amount, 0); +} + +// Per (mint, unit) balances, including the reserved, unverified and unbacked +// splits. `backupEnabled` is optional so callers that only care about balances +// can skip it; without it, nothing is reported as unbacked, which is correct +// because with backup off nothing is covered in the first place. +export function selectAccounts( + state: Pick & + Partial>, +): AccountBalance[] { + const keys = new Set(Object.keys(state.proofs)); + // Surface mints with no proofs too, so a freshly added mint is visible. + for (const mint of Object.values(state.mints)) { + for (const unit of mint.units ?? ["sat"]) + keys.add(accountKey(mint.url, unit)); + } + for (const res of Object.values(state.reserved)) keys.add(res.account); + + return [...keys] + .map((key) => { + const { mintUrl, unit } = parseAccountKey(key); + const proofs = state.proofs[key] ?? []; + const reserved = Object.values(state.reserved) + .filter((r) => r.account === key) + .flatMap((r) => r.proofs); + return { + key, + mintUrl, + unit, + balance: sum(proofs), + unverified: sum(proofs.filter((p) => p.verified !== true)), + unbacked: + state.backupEnabled === true + ? sum(proofs.filter((p) => p.derived !== true)) + : 0, + reserved: sum(reserved), + proofCount: proofs.length, + }; + }) + .sort((a, b) => b.balance - a.balance || a.key.localeCompare(b.key)); +} + +// Spendable balance for one unit across every mint. Units are never summed +// together: 100 sat and 100 usd are not 200 of anything. +export function selectBalanceForUnit( + state: Pick, + unit: string, +): number { + return Object.entries(state.proofs).reduce( + (total, [key, proofs]) => + parseAccountKey(key).unit === unit ? total + sum(proofs) : total, 0, ); } -// Per-mint balances for display. -export function selectMintBalances(state: WalletState): MintBalance[] { - return Object.entries(state.proofsByMint).map(([mintUrl, proofs]) => ({ - mintUrl, - unit: state.unit, - balance: proofs.reduce((sum, p) => sum + p.amount, 0), - proofCount: proofs.length, - })); +// Every unit the wallet currently holds value in, spendable or reserved. +export function selectUnits( + state: Pick, +): string[] { + const units = new Set(); + for (const [key, proofs] of Object.entries(state.proofs)) { + if (proofs.length > 0) units.add(parseAccountKey(key).unit); + } + for (const res of Object.values(state.reserved)) { + units.add(parseAccountKey(res.account).unit); + } + return [...units].sort(); +} + +// Reserved (in-flight) sends that the user can still reclaim or re-share. +export function selectPendingSends( + state: Pick, +): WalletTx[] { + return state.history.filter( + (tx) => tx.status === "pending" && state.reserved[tx.id] !== undefined, + ); } -// Proof secrets as a Set: used to prevent duplicate deposits. export function selectSecrets( - state: WalletState, - mintUrl: string, + state: Pick, + key: string, ): Set { - return new Set((state.proofsByMint[mintUrl] ?? []).map((p) => p.secret)); + return new Set((state.proofs[key] ?? []).map((p) => p.secret)); } // ---- Store ------------------------------------------------------------------ -const storage = createMMKV({ id: "wallet-store" }); - -const mmkvStorage = { - getItem: (name: string): string | null => storage.getString(name) ?? null, - setItem: (name: string, value: string): void => storage.set(name, value), - removeItem: (name: string): void => { - storage.remove(name); - }, -}; - export const useWalletStore = create()( persist( - (set, _get) => ({ - proofsByMint: {}, - unit: "sat", + (set, get) => ({ + proofs: {}, + reserved: {}, + mints: {}, + history: [], + redeemedNutzaps: [], + claimedTokens: [], + backupEnabled: false, + backupVerified: false, + counters: {}, + + // ---- Mints ---- + + addMint(mintUrl, patch) { + const url = normalizeMintUrl(mintUrl); + set((state) => { + const existing = state.mints[url]; + return { + mints: { + ...state.mints, + [url]: { + ...existing, + ...patch, + url, + addedAtMs: existing?.addedAtMs ?? Date.now(), + }, + }, + }; + }); + }, - addMint(mintUrl: string) { + updateMint(mintUrl, patch) { + const url = normalizeMintUrl(mintUrl); set((state) => { - if (mintUrl in state.proofsByMint) return state; + const existing = state.mints[url]; + if (!existing) return state; return { - proofsByMint: { ...state.proofsByMint, [mintUrl]: [] }, + mints: { ...state.mints, [url]: { ...existing, ...patch } }, }; }); }, - addProofs(mintUrl: string, proofs: StoredProof[]) { - if (proofs.length === 0) return; + removeMint(mintUrl) { + const url = normalizeMintUrl(mintUrl); set((state) => { - const existing = state.proofsByMint[mintUrl] ?? []; - // Reject duplicates by secret to prevent double-counting. - const existingSecrets = new Set(existing.map((p) => p.secret)); - const novel = proofs.filter((p) => !existingSecrets.has(p.secret)); + const mints = { ...state.mints }; + delete mints[url]; + const proofs = { ...state.proofs }; + for (const key of Object.keys(proofs)) { + if (parseAccountKey(key).mintUrl === url) delete proofs[key]; + } + return { mints, proofs }; + }); + }, + + // ---- Proofs ---- + + addProofs(mintUrl, unit, incoming) { + if (incoming.length === 0) return { added: 0, duplicates: 0 }; + const key = accountKey(mintUrl, unit); + let added = 0; + let duplicates = 0; + set((state) => { + const existing = state.proofs[key] ?? []; + // A proof is uniquely identified by its secret. Re-adding one is + // either a duplicate paste or a replayed message; either way it must + // not inflate the balance. + const seen = new Set(existing.map((p) => p.secret)); + // Also guard against a proof that is currently reserved for a send: + // crediting it back while the token is still out there would let the + // balance count the same value twice. + for (const res of Object.values(state.reserved)) { + for (const p of res.proofs) seen.add(p.secret); + } + const novel = incoming.filter((p) => { + if (seen.has(p.secret)) return false; + seen.add(p.secret); + return true; + }); + added = novel.length; + duplicates = incoming.length - novel.length; if (novel.length === 0) return state; return { - proofsByMint: { - ...state.proofsByMint, - [mintUrl]: [...existing, ...novel], + proofs: { ...state.proofs, [key]: [...existing, ...novel] }, + }; + }); + return { added, duplicates }; + }, + + removeProofs(mintUrl, unit, secrets) { + if (secrets.length === 0) return; + const key = accountKey(mintUrl, unit); + const drop = new Set(secrets); + set((state) => { + const existing = state.proofs[key]; + if (!existing) return state; + return { + proofs: { + ...state.proofs, + [key]: existing.filter((p) => !drop.has(p.secret)), }, }; }); }, - removeProofs(mintUrl: string, secrets: string[]) { + replaceProofs(mintUrl, unit, proofs) { + const key = accountKey(mintUrl, unit); + set((state) => ({ proofs: { ...state.proofs, [key]: proofs } })); + }, + + markVerified(mintUrl, unit, secrets) { if (secrets.length === 0) return; - const secretSet = new Set(secrets); + const key = accountKey(mintUrl, unit); + const mark = new Set(secrets); + set((state) => { + const existing = state.proofs[key]; + if (!existing) return state; + return { + proofs: { + ...state.proofs, + [key]: existing.map((p) => + mark.has(p.secret) ? { ...p, verified: true } : p, + ), + }, + }; + }); + }, + + // ---- Reservations ---- + + reserveProofs(txId, mintUrl, unit, proofs) { + const key = accountKey(mintUrl, unit); + const move = new Set(proofs.map((p) => p.secret)); + set((state) => { + const existing = state.proofs[key] ?? []; + return { + proofs: { + ...state.proofs, + [key]: existing.filter((p) => !move.has(p.secret)), + }, + reserved: { + ...state.reserved, + [txId]: { account: key, proofs }, + }, + }; + }); + }, + + releaseReserved(txId) { + const entry = get().reserved[txId]; + if (!entry) return null; set((state) => { - const existing = state.proofsByMint[mintUrl] ?? []; - const remaining = existing.filter((p) => !secretSet.has(p.secret)); + const reserved = { ...state.reserved }; + delete reserved[txId]; + const existing = state.proofs[entry.account] ?? []; + const seen = new Set(existing.map((p) => p.secret)); + const restored = entry.proofs.filter((p) => !seen.has(p.secret)); return { - proofsByMint: { ...state.proofsByMint, [mintUrl]: remaining }, + reserved, + proofs: { + ...state.proofs, + [entry.account]: [...existing, ...restored], + }, }; }); + return entry.proofs; + }, + + dropReserved(txId) { + set((state) => { + if (state.reserved[txId] === undefined) return state; + const reserved = { ...state.reserved }; + delete reserved[txId]; + return { reserved }; + }); + }, + + // ---- History ---- + + addTx(tx) { + set((state) => ({ + history: [tx, ...state.history].slice(0, MAX_HISTORY), + })); + }, + + updateTx(id, patch) { + set((state) => ({ + history: state.history.map((tx) => + tx.id === id ? { ...tx, ...patch, updatedAtMs: Date.now() } : tx, + ), + })); }, - replaceProofs(mintUrl: string, proofs: StoredProof[]) { + // ---- Nutzap ---- + + setNutzapPubkey(pubkey) { + set({ nutzapPubkey: pubkey }); + }, + + setBackupEnabled(enabled) { + // Turning backup off also drops the "written down" claim: there is + // nothing left to have written down. + set( + enabled + ? { backupEnabled: true } + : { + backupEnabled: false, + backupVerified: false, + }, + ); + }, + + setBackupVerified(verified) { + set({ backupVerified: verified }); + }, + + reserveCounters(keysetId, n) { + const start = get().counters[keysetId] ?? 0; + if (n <= 0) return { start, count: 0 }; set((state) => ({ - proofsByMint: { ...state.proofsByMint, [mintUrl]: proofs }, + counters: { ...state.counters, [keysetId]: start + n }, })); + return { start, count: n }; + }, + + advanceCounter(keysetId, minNext) { + set((state) => { + const current = state.counters[keysetId] ?? 0; + if (current >= minNext) return state; + return { counters: { ...state.counters, [keysetId]: minNext } }; + }); }, - clearMint(mintUrl: string) { + markTokenClaimed(firstSecret) { + set((state) => + state.claimedTokens.includes(firstSecret) + ? state + : { + claimedTokens: [firstSecret, ...state.claimedTokens].slice( + 0, + MAX_CLAIMED_TOKENS, + ), + }, + ); + }, + + markNutzapRedeemed(eventId) { + set((state) => + state.redeemedNutzaps.includes(eventId) + ? state + : { + redeemedNutzaps: [eventId, ...state.redeemedNutzaps].slice( + 0, + MAX_REDEEMED_NUTZAPS, + ), + }, + ); + }, + + // ---- Wipe ---- + + clearAccount(mintUrl, unit) { + const key = accountKey(mintUrl, unit); set((state) => { - const next = { ...state.proofsByMint }; - delete next[mintUrl]; - return { proofsByMint: next }; + const proofs = { ...state.proofs }; + delete proofs[key]; + return { proofs }; }); }, clearAll() { - set({ proofsByMint: {} }); + set({ + proofs: {}, + reserved: {}, + mints: {}, + history: [], + redeemedNutzaps: [], + claimedTokens: [], + nutzapPubkey: undefined, + // Backup goes with everything else: the panic wipe clears the + // keychain too, so the phrase that made these coins restorable is + // gone and claiming otherwise would be a lie. + backupEnabled: false, + backupVerified: false, + counters: {}, + }); }, }), { name: "wallet-state", - storage: createJSONStorage(() => mmkvStorage), + storage: createJSONStorage(() => asyncMMKVStorage), + version: 2, + // v1 stored `{ proofsByMint: Record, unit }` with + // a single global unit. Every v1 proof therefore belongs to the account + // (mint, that unit). Nothing was verified against a mint back then, so + // they all migrate in as unverified, which is the honest state. + migrate(persisted, version) { + if (version >= 2) return persisted as WalletState; + const old = persisted as { + proofsByMint?: Record; + unit?: string; + } | null; + const unit = old?.unit ?? "sat"; + const proofs: Record = {}; + const mints: Record = {}; + for (const [mintUrl, list] of Object.entries(old?.proofsByMint ?? {})) { + const url = normalizeMintUrl(mintUrl); + mints[url] = { url, addedAtMs: Date.now(), units: [unit] }; + proofs[accountKey(url, unit)] = list.map((p) => ({ + ...p, + verified: false, + })); + } + return { + ...(persisted as object), + proofs, + mints, + reserved: {}, + history: [], + redeemedNutzaps: [], + claimedTokens: [], + backupEnabled: false, + backupVerified: false, + counters: {}, + } as unknown as WalletState; + }, + // Actions are recreated by the initializer; only data is persisted. + partialize: (state) => + ({ + proofs: state.proofs, + reserved: state.reserved, + mints: state.mints, + history: state.history, + redeemedNutzaps: state.redeemedNutzaps, + nutzapPubkey: state.nutzapPubkey, + backupEnabled: state.backupEnabled, + backupVerified: state.backupVerified, + counters: state.counters, + }) as unknown as WalletState, }, ), ); diff --git a/src/utils/__tests__/panic-wipe.test.ts b/src/utils/__tests__/panic-wipe.test.ts index 4b15ae8..f6fdb79 100644 --- a/src/utils/__tests__/panic-wipe.test.ts +++ b/src/utils/__tests__/panic-wipe.test.ts @@ -5,6 +5,10 @@ // Imports come first in source; Babel hoists jest.mock() calls above them. import { panicWipe as identityPanicWipe } from "../../core/crypto/identity"; import { clearAttachmentCache } from "../../services/file-transfer-service"; +import { + LEGACY_WALLET_STORAGE_ID, + WALLET_STORAGE_ID, +} from "../../store/wallet-store"; import { MMKV_STORE_IDS, panicWipe } from "../panic-wipe"; // identity.panicWipe wipes the Keychain/Keystore; mock it out in tests. @@ -47,18 +51,25 @@ jest.mock("react-native-mmkv", () => { if (!instances.has(id)) instances.set(id, new MockMMKV()); return instances.get(id)!; }, + // The encrypted wallet store is removed with deleteMMKV, not clearAll, so + // the mock has to expose it for the wipe to be assertable. + deleteMMKV: jest.fn(() => true), __mockClearAll: clearAll, }; }); const mockClearKeys = identityPanicWipe as jest.Mock; -const mockClearAll = ( - jest.requireMock("react-native-mmkv") as { __mockClearAll: jest.Mock } -).__mockClearAll; +const mmkvMock = jest.requireMock("react-native-mmkv") as { + __mockClearAll: jest.Mock; + deleteMMKV: jest.Mock; +}; +const mockClearAll = mmkvMock.__mockClearAll; +const deleteMMKV = mmkvMock.deleteMMKV; beforeEach(() => { mockClearKeys.mockClear(); mockClearAll.mockClear(); + deleteMMKV.mockClear(); (clearAttachmentCache as jest.Mock).mockClear(); }); @@ -106,10 +117,9 @@ describe("panicWipe", () => { test("wipes every sensitive persisted store, including the activity feed", () => { // Message previews and sender names live in the activity feed, so it must - // be part of the wipe alongside chats, wallet, contacts, blocks and outbox. + // be part of the wipe alongside chats, contacts, blocks and outbox. for (const id of [ "chat-store", - "wallet-store", "blocked-store", "outbox-store", "contacts-store", @@ -119,4 +129,18 @@ describe("panicWipe", () => { expect(MMKV_STORE_IDS).toContain(id); } }); + + test("deletes the encrypted wallet store rather than clearing it", async () => { + // The wallet file is AES-256 encrypted, so reopening it without the key to + // call clearAll() is unreliable. It is removed with deleteMMKV instead, and + // both the current id and the pre-encryption one are covered so an old + // install's plaintext proofs go too. + await panicWipe(); + expect(deleteMMKV).toHaveBeenCalledWith(WALLET_STORAGE_ID); + expect(deleteMMKV).toHaveBeenCalledWith(LEGACY_WALLET_STORAGE_ID); + // It must never appear in the plain clearAll list, or the wipe would depend + // on being able to decrypt what it is trying to destroy. + expect(MMKV_STORE_IDS).not.toContain(WALLET_STORAGE_ID); + expect(MMKV_STORE_IDS).not.toContain(LEGACY_WALLET_STORAGE_ID); + }); }); diff --git a/src/utils/panic-wipe.ts b/src/utils/panic-wipe.ts index 10c5cbb..b117924 100644 --- a/src/utils/panic-wipe.ts +++ b/src/utils/panic-wipe.ts @@ -13,9 +13,10 @@ // After this call the app is left in an empty, first-run state. // A restart will trigger key regeneration at next launch. -import { createMMKV } from "react-native-mmkv"; +import { createMMKV, deleteMMKV } from "react-native-mmkv"; import { panicWipe as clearKeys } from "../core/crypto/identity"; import { clearAttachmentCache } from "../services/file-transfer-service"; +import { resetWalletService } from "../services/wallet-service"; import { useActivityStore } from "../store/activity-store"; import { useBlockedStore } from "../store/blocked-store"; import { useBoardStore } from "../store/board-store"; @@ -29,12 +30,17 @@ import { usePeerStore } from "../store/peer-store"; import { usePlaceNamesStore } from "../store/place-names-store"; import { useSettingsStore } from "../store/settings-store"; import { useTransferStore } from "../store/transfer-store"; -import { useWalletStore } from "../store/wallet-store"; +import { + LEGACY_WALLET_STORAGE_ID, + WALLET_STORAGE_ID, + useWalletStore, +} from "../store/wallet-store"; // The IDs used by all MMKV storage instances in src/store/ and src/core/. // peer-store is intentionally absent: it uses in-memory Zustand with no MMKV // persistence, so it resets automatically when the process restarts. -// wallet-store holds Cashu bearer tokens and MUST be cleared on panic wipe. +// wallet-store is absent here on purpose: it is encrypted, so it is destroyed +// by WALLET_STORE_IDS below rather than cleared through this list. // blocked-store records who this identity has blocked, which is tied to this // identity's relationships, same as chat data, so it goes too. // If a new persisted store is added, add its MMKV ID here. @@ -48,7 +54,6 @@ import { useWalletStore } from "../store/wallet-store"; // meant to leave a clean first-run state, so it is reset too. export const MMKV_STORE_IDS = [ "chat-store", - "wallet-store", "blocked-store", "outbox-store", "contacts-store", @@ -76,14 +81,31 @@ export const MMKV_STORE_IDS = [ "place-names-store", ] as const; +// The wallet store is handled separately from MMKV_STORE_IDS above: its file is +// AES-256 encrypted, so opening it with `createMMKV({ id })` (no key) to call +// clearAll() is not reliable. `deleteMMKV` removes the instance and its backing +// file outright, which works whatever the encryption state. The key itself is +// already destroyed by clearKeys() wiping the Keychain/Keystore, so even a +// failed delete leaves ciphertext nobody can open. The legacy plaintext id is +// included because installs that predate encryption may still have the file. +const WALLET_STORE_IDS = [WALLET_STORAGE_ID, LEGACY_WALLET_STORAGE_ID] as const; + export async function panicWipe(): Promise { - // 1. Destroy all private keys from the OS secure enclave. + // 1. Destroy all private keys from the OS secure enclave. This also removes + // the wallet store's AES key, making step 2's ciphertext unrecoverable. await clearKeys(); // 2. Clear every MMKV partition. for (const id of MMKV_STORE_IDS) { createMMKV({ id }).clearAll(); } + for (const id of WALLET_STORE_IDS) { + try { + deleteMMKV(id); + } catch { + // Instance never opened on this device, or already gone. + } + } // 3. Reset Zustand in-memory state so stale data does not appear after wipe. // MMKV clearing above only affects persistence; live store state is separate. @@ -102,6 +124,10 @@ export async function panicWipe(): Promise { useSettingsStore.getState().reset(); useBlockedStore.setState({ blockedPeerIDs: [] }); + // Drop the cached Cashu Wallet instances too: they hold the previous + // identity's loaded keysets and a handle on the now-deleted store. + resetWalletService(); + // 4. Delete received media files from disk. Best-effort: a failure here must // not abort the wipe, the keys and stores are already gone. try { From d1b74e59bcf190509b530811d497cff6121e5277 Mon Sep 17 00:00:00 2001 From: areebahmeddd Date: Sun, 26 Jul 2026 04:35:46 +0530 Subject: [PATCH 2/2] feat: prevent double sending of eCash and sats by introducing sending state - Added `sendingEcash` state to `MessageThread` to disable the send button during the sending process. - Updated `handleSendEcash` to check for ongoing sends and prevent multiple submissions. - Similar changes made in `PeerList` for sending sats, ensuring a smooth user experience. - Enhanced wallet service to handle mint request timeouts and improve error handling for concurrent sends. - Updated wallet store to ensure reservations are atomic and prevent double spending of proofs. - Added tests to verify that reservations are respected and prevent concurrent sends from using the same proof. --- App.tsx | 9 +- README.md | 18 +- landing/src/components/Explore.tsx | 2 +- landing/src/pages/FAQPage.tsx | 72 +++--- src/features/chat/message-thread.tsx | 27 ++- src/features/discovery/peer-list.tsx | 23 +- src/features/wallet/wallet-screen.tsx | 151 ++++++++----- src/services/wallet-service.ts | 275 +++++++++++++++++++++-- src/store/__tests__/wallet-store.test.ts | 38 ++++ src/store/wallet-store.ts | 157 +++++++------ src/utils/__tests__/panic-wipe.test.ts | 11 +- src/utils/panic-wipe.ts | 11 +- 12 files changed, 575 insertions(+), 219 deletions(-) diff --git a/App.tsx b/App.tsx index b3d156a..c3d6e15 100644 --- a/App.tsx +++ b/App.tsx @@ -191,9 +191,16 @@ async function startMeshWithPermissions( void (async () => { const unlocked = await initWalletService(); if (!unlocked) return; - await reconcile().catch(() => { + + // Settling leftovers is a background chore, not a prerequisite. It walks + // every pending deposit and reserved send, one mint round trip at a time, + // so on a bad network it can take minutes. Awaiting it here would hold the + // nutzap watcher behind it and quietly drop incoming payments for that + // whole window. Nothing below depends on its result. + void reconcile().catch(() => { // Offline, or the mint is down. Retried on the next launch. }); + const client = getMeshService()?.getNostrClient(); const privKey = getMeshService()?.getNostrPrivKey(); const pubKey = getMeshService()?.getNostrPubKeyHex(); diff --git a/README.md b/README.md index c6a1597..c367ccd 100644 --- a/README.md +++ b/README.md @@ -81,15 +81,15 @@ Built on the foundation of [bitchat](https://bitchat.free), using the same [BLE ## Optional Features -| Category | Feature | Description | -| --------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 💰 **Payments** | Cashu ecash | Send and receive ecash over BLE with no internet. Proofs are stored AES-256 encrypted, verified against the mint's signature (NUT-12 DLEQ) on arrival, and an undelivered send stays reclaimable | -| | Lightning | Deposit sats into ecash and cash out to any bolt11 invoice through your mint (NUT-04 / NUT-05) | -| | Nutzaps | NIP-61 payments locked to a Nostr identity's key, falling back to an encrypted DM when they publish no nutzap info | -| | Wallet recovery | Opt-in 12-word phrase (NUT-13 / NUT-09). Coins are derived from it rather than random, so a new device rebuilds the balance by asking your mints which coins they signed. Off by default | -| 🤖 **AI** | Local assistant | On-device inference answers questions with zero network calls, data never leaves your device | -| 🔗 **Social** | AT Protocol | Opt-in bridge to Bluesky, using your Airhop identity | -| | ActivityPub | Opt-in bridge to Mastodon, using your Airhop identity | +| Category | Feature | Description | +| --------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 💰 **Payments** | Cashu ecash | Pay someone standing next to you over Bluetooth, with no internet on either phone. Coins are stored encrypted on your device, checked against the mint’s signature the moment they arrive (NUT-12), and a payment that never reaches anyone can be taken back. Internet is only needed to top up, cash out, or confirm a received coin is unspent | +| | Lightning | Top up from any Lightning wallet, and cash out to any Lightning invoice, through the mint you choose (NUT-04 / NUT-05) | +| | Nutzaps | Pay a Nostr identity over the internet, locked to their key so only they can spend it (NIP-61). Falls back to an encrypted message if they have not set it up | +| | Wallet recovery | Optional 12-word recovery phrase, off by default. Turn it on and a new phone can rebuild your balance by asking your mints which coins they signed (NUT-13 / NUT-09) | +| 🤖 **AI** | Local assistant | On-device inference answers questions with zero network calls, data never leaves your device | +| 🔗 **Social** | AT Protocol | Opt-in bridge to Bluesky, using your Airhop identity | +| | ActivityPub | Opt-in bridge to Mastodon, using your Airhop identity | ## Stack diff --git a/landing/src/components/Explore.tsx b/landing/src/components/Explore.tsx index d5a8200..18b86a2 100644 --- a/landing/src/components/Explore.tsx +++ b/landing/src/components/Explore.tsx @@ -7,7 +7,7 @@ const sections = [ items: [ { title: "iOS", - desc: "Requires iOS 16 or later. Bluetooth background mode enabled.", + desc: "Requires iOS 16.0 or later. Bluetooth background mode enabled.", href: "https://apps.apple.com/app/airhop/id000000000", }, { diff --git a/landing/src/pages/FAQPage.tsx b/landing/src/pages/FAQPage.tsx index 82110af..23df107 100644 --- a/landing/src/pages/FAQPage.tsx +++ b/landing/src/pages/FAQPage.tsx @@ -127,6 +127,35 @@ const SECTIONS: { }, ], }, + { + heading: "Everyday use", + questions: [ + { + q: "Will it drain my battery?", + a: "It uses more than an app sitting idle and far less than maps or video. Airhop stays on Bluetooth Low Energy, which listens and announces in short bursts and lets the radio sleep in between, and it slows its own announcements down once it can hear other devices. Relaying for other people costs a little more. If you want it to stop entirely, set your status to Away in Profile: that stops scanning and announcing, and nothing runs until you set it back.", + }, + { + q: "Why does a messenger ask for my location?", + a: "Android ties Bluetooth scanning to the location permission at the system level, so any app that looks for nearby devices has to ask for it, whether or not it cares where you are. Airhop does not read your position for the mesh. The one place it genuinely uses location is the optional location channels, which need a rough area to work out which cell you are in, and you can decline that and still use everything else. Nothing about your position is ever sent to us, because there is no server to send it to.", + }, + { + q: "What else does it ask permission for?", + a: "Bluetooth and nearby devices, to find and talk to peers, which is the one it cannot work without. Notifications, so a message can reach you when the app is closed. Camera, only to scan a contact's QR code. Photos, only when you attach or save one. Microphone, only when you record a voice note. Every one of them can be refused or revoked later in your device settings, and the app keeps working with whatever is left.", + }, + { + q: "Does it work in airplane mode?", + a: "Yes, as long as you switch Bluetooth back on, which both iOS and Android let you do without leaving airplane mode. The mesh then works in full: discovery, channels, direct messages, files, and ecash transfers, none of which touch the internet. The internet features stop, so messages to people who are not nearby queue up and send themselves when a route appears, and cashing ecash out to Lightning has to wait until you are back online.", + }, + { + q: "What is the notification that will not swipe away?", + a: "On Android, that notification is what keeps the mesh alive after you leave the app. Without it the system would suspend Airhop within minutes and you would stop receiving anything. It has a Stop mesh button that shuts the radios down cleanly and takes the notification with it; reopening the app then shows the mesh paused, with a Resume button. iPhones have no equivalent notification, because iOS keeps Bluetooth apps alive differently.", + }, + { + q: "Can people tell when I have read their message?", + a: "In a direct message, yes, the same way most messengers work. The receipt is only sent when the app is actually open in front of you, so a message that arrives while Airhop is in your pocket stays unread until you look at it. Public and location channels have no receipts at all.", + }, + ], + }, { heading: "Mesh network", questions: [ @@ -206,35 +235,6 @@ const SECTIONS: { }, ], }, - { - heading: "Everyday use", - questions: [ - { - q: "Will it drain my battery?", - a: "It uses more than an app sitting idle and far less than maps or video. Airhop stays on Bluetooth Low Energy, which listens and announces in short bursts and lets the radio sleep in between, and it slows its own announcements down once it can hear other devices. Relaying for other people costs a little more. If you want it to stop entirely, set your status to Away in Profile: that stops scanning and announcing, and nothing runs until you set it back.", - }, - { - q: "Why does a messenger ask for my location?", - a: "Android ties Bluetooth scanning to the location permission at the system level, so any app that looks for nearby devices has to ask for it, whether or not it cares where you are. Airhop does not read your position for the mesh. The one place it genuinely uses location is the optional location channels, which need a rough area to work out which cell you are in, and you can decline that and still use everything else. Nothing about your position is ever sent to us, because there is no server to send it to.", - }, - { - q: "What else does it ask permission for?", - a: "Bluetooth and nearby devices, to find and talk to peers, which is the one it cannot work without. Notifications, so a message can reach you when the app is closed. Camera, only to scan a contact's QR code. Photos, only when you attach or save one. Microphone, only when you record a voice note. Every one of them can be refused or revoked later in your device settings, and the app keeps working with whatever is left.", - }, - { - q: "Does it work in airplane mode?", - a: "Yes, as long as you switch Bluetooth back on, which both iOS and Android let you do without leaving airplane mode. The mesh then works in full: discovery, channels, direct messages, files, and ecash transfers, none of which touch the internet. The internet features stop, so messages to people who are not nearby queue up and send themselves when a route appears, and cashing ecash out to Lightning has to wait until you are back online.", - }, - { - q: "What is the notification that will not swipe away?", - a: "On Android, that notification is what keeps the mesh alive after you leave the app. Without it the system would suspend Airhop within minutes and you would stop receiving anything. It has a Stop mesh button that shuts the radios down cleanly and takes the notification with it; reopening the app then shows the mesh paused, with a Resume button. iPhones have no equivalent notification, because iOS keeps Bluetooth apps alive differently.", - }, - { - q: "Can people tell when I have read their message?", - a: "In a direct message, yes, the same way most messengers work. The receipt is only sent when the app is actually open in front of you, so a message that arrives while Airhop is in your pocket stays unread until you look at it. Public and location channels have no receipts at all.", - }, - ], - }, { heading: "Privacy & security", questions: [ @@ -517,8 +517,18 @@ const SECTIONS: {
  • - To try it out: the community test mints issue free play-money sats, - so you can walk through every flow with nothing at risk. + To try it out:{" "} + + testnut.cashu.space + {" "} + is the community test mint. It issues free play-money sats, so you can walk through + every flow with nothing at risk. The sats are not real and the mint is wiped + periodically, so never keep anything there you would miss.
  • For real sats: pick a publicly run mint with a track record.{" "} diff --git a/src/features/chat/message-thread.tsx b/src/features/chat/message-thread.tsx index 84e5a22..4233ffb 100644 --- a/src/features/chat/message-thread.tsx +++ b/src/features/chat/message-thread.tsx @@ -909,6 +909,7 @@ export default function MessageThread({ const autoDownloadMedia = useSettingsStore((s) => s.autoDownloadMedia); const [showAttachMenu, setShowAttachMenu] = useState(false); const [showSendEcash, setShowSendEcash] = useState(false); + const [sendingEcash, setSendingEcash] = useState(false); // Raw string of the token currently being claimed, so its button can show // progress and a double tap cannot start two swaps for the same proofs. const [claimingToken, setClaimingToken] = useState(null); @@ -1421,14 +1422,21 @@ export default function MessageThread({ // per screen. async function handleSendEcash(): Promise { const amount = parseInt(ecashAmount, 10); - if (!amount || amount <= 0 || !dmPeerID) return; + if (!amount || amount <= 0 || !dmPeerID || sendingEcash) return; - const result = await sendEcashToPeer({ - peerID: dmPeerID, - amount, - memo: ecashMemo.trim() || undefined, - senderNickname: localNickname, - }); + // Quoting awaits the mint, so a double tap would otherwise start two sends. + setSendingEcash(true); + let result; + try { + result = await sendEcashToPeer({ + peerID: dmPeerID, + amount, + memo: ecashMemo.trim() || undefined, + senderNickname: localNickname, + }); + } finally { + setSendingEcash(false); + } if (!result) return; setShowSendEcash(false); @@ -2626,10 +2634,11 @@ export default function MessageThread({ void handleSendEcash()} - disabled={!ecashAmount.trim()} + disabled={!ecashAmount.trim() || sendingEcash} > Send diff --git a/src/features/discovery/peer-list.tsx b/src/features/discovery/peer-list.tsx index 1e46a56..9176488 100644 --- a/src/features/discovery/peer-list.tsx +++ b/src/features/discovery/peer-list.tsx @@ -63,6 +63,10 @@ export default function PeerList({ const [selectedPeer, setSelectedPeer] = useState(null); const [sendSatsAmount, setSendSatsAmount] = useState(""); const [showSendSats, setShowSendSats] = useState(false); + // Set while a send is in flight. Quoting involves an await, so without this a + // double tap starts two sends; the second now loses the reservation race and + // reports a confusing "those coins were just used" instead of doing nothing. + const [sendingSats, setSendingSats] = useState(false); // Refresh "last seen" every 10 seconds and evict stale peers. useEffect(() => { @@ -130,12 +134,15 @@ export default function PeerList({ // in the shared transfer service; this only supplies who and how much. async function handleSendSats(peer: NearbyPeer): Promise { const amount = parseInt(sendSatsAmount, 10); - if (!amount || amount <= 0) return; + if (!amount || amount <= 0 || sendingSats) return; - const result = await sendEcashToPeer({ - peerID: peer.peerID, - amount, - }); + setSendingSats(true); + let result; + try { + result = await sendEcashToPeer({ peerID: peer.peerID, amount }); + } finally { + setSendingSats(false); + } if (!result) return; setSendSatsAmount(""); @@ -309,10 +316,12 @@ export default function PeerList({ void handleSendSats(selectedPeer)} - disabled={!sendSatsAmount.trim()} + disabled={!sendSatsAmount.trim() || sendingSats} accessibilityRole="button" accessibilityLabel="Confirm send sats" > diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx index 38bde75..a080206 100644 --- a/src/features/wallet/wallet-screen.tsx +++ b/src/features/wallet/wallet-screen.tsx @@ -81,6 +81,7 @@ import { isWalletStorageReady, selectAccounts, useWalletStore, + whenWalletHydrated, type AccountBalance, type WalletTx, } from "../../store/wallet-store"; @@ -134,13 +135,18 @@ export default function WalletScreen({ const [locked, setLocked] = useState(() => !isWalletStorageReady()); useEffect(() => { - // Storage opens asynchronously at app start; re-check until it lands so the - // locked banner clears itself rather than needing a tab switch. + // The encrypted store opens and hydrates asynchronously at app start, so + // the banner clears itself rather than needing a tab switch. Settles once: + // when the keychain is unavailable the wallet stays locked for good, and + // polling for a state that will never change just burns battery. if (!locked) return; - const timer = setInterval(() => { - if (isWalletStorageReady()) setLocked(false); - }, 500); - return () => clearInterval(timer); + let cancelled = false; + void whenWalletHydrated().then(() => { + if (!cancelled) setLocked(!isWalletStorageReady()); + }); + return () => { + cancelled = true; + }; }, [locked]); const peers = usePeerStore((s) => s.peers); @@ -260,13 +266,21 @@ export default function WalletScreen({ unitTotals[0] ?? { unit: "sat", balance: 0, unverified: 0, reserved: 0 }; // Sends whose proofs are still held: the token exists, delivery is unproven. + // Anything still owed to somebody and still holding a token the user can + // hand over. + // + // Two shapes end up here. A normal send has its proofs reserved and can be + // reclaimed. A nutzap whose relay publish failed has no reservation, because + // its proofs are already locked to the recipient's key and are not ours to + // take back, but it still carries a token that needs delivering. Leaving that + // second case out would strand the value with no way to reach it. const pendingSends = useMemo( () => history.filter( (tx) => - tx.kind === "send" && tx.status === "pending" && - reserved[tx.id] !== undefined, + (tx.kind === "send" || tx.kind === "nutzap-out") && + (reserved[tx.id] !== undefined || Boolean(tx.token)), ), [history, reserved], ); @@ -879,7 +893,13 @@ export default function WalletScreen({ useEffect(() => { if (!deposit || !showDeposit) return; let cancelled = false; + // A mint round trip can outlast the poll interval. Without this guard two + // claims race for the same quote, and the loser reports a spurious error on + // a deposit that actually succeeded. + let inFlight = false; const timer = setInterval(() => { + if (inFlight) return; + inFlight = true; void (async () => { try { const minted = await claimLightningDeposit( @@ -896,6 +916,8 @@ export default function WalletScreen({ ); } catch { // Still unpaid, or the mint blinked. Keep polling. + } finally { + inFlight = false; } })(); }, DEPOSIT_POLL_MS); @@ -1032,58 +1054,71 @@ export default function WalletScreen({ {pendingSends.length > 0 && ( Pending - {pendingSends.map((tx) => ( - - - - - {tx.amount.toLocaleString()} {tx.unit} - - - {relativeTime(tx.createdAtMs)} + {pendingSends.map((tx) => { + // Reclaim is only meaningful while the proofs are still ours. A + // nutzap that failed to publish is already locked to the + // recipient's key, so offering to pull it back would be a lie. + const reclaimable = reserved[tx.id] !== undefined; + return ( + + + + + {tx.amount.toLocaleString()} {tx.unit} + + + {relativeTime(tx.createdAtMs)} + + + + {reclaimable + ? "Built and reserved, delivery unconfirmed. The proofs are held out of your balance so they cannot be spent twice." + : "Already locked to the recipient's key, so only they can spend it. It just has not reached them yet. Share the token to finish."} + {tx.error ? `\n\n${tx.error}` : ""} + + void handleCopyToken(tx.token ?? "")} + accessibilityRole="button" + accessibilityLabel="Copy the token again" + > + Copy + + handleShareToken(tx.token ?? "")} + accessibilityRole="button" + accessibilityLabel="Share the token again" + > + Share + + markDelivered(tx.id)} + accessibilityRole="button" + accessibilityLabel="Mark this token as delivered" + > + Delivered + + {reclaimable && ( + handleReclaim(tx)} + accessibilityRole="button" + accessibilityLabel="Reclaim this token into your balance" + > + Reclaim + + )} + - - Built and reserved, delivery unconfirmed. The proofs are held - out of your balance so they cannot be spent twice. - {tx.error ? `\n\n${tx.error}` : ""} - - - void handleCopyToken(tx.token ?? "")} - accessibilityRole="button" - accessibilityLabel="Copy the token again" - > - Copy - - handleShareToken(tx.token ?? "")} - accessibilityRole="button" - accessibilityLabel="Share the token again" - > - Share - - markDelivered(tx.id)} - accessibilityRole="button" - accessibilityLabel="Mark this token as delivered" - > - Delivered - - handleReclaim(tx)} - accessibilityRole="button" - accessibilityLabel="Reclaim this token into your balance" - > - Reclaim - - - - ))} + ); + })} )} diff --git a/src/services/wallet-service.ts b/src/services/wallet-service.ts index 0320324..3a7b93f 100644 --- a/src/services/wallet-service.ts +++ b/src/services/wallet-service.ts @@ -25,8 +25,10 @@ import { Mint, + OutputData, Wallet, isMintOperationError, + setGlobalRequestOptions, type CounterSource, type GetInfoResponse, type KeyChainCache, @@ -34,6 +36,7 @@ import { type MintQuoteBolt11Response, type Proof, type ProofLike, + type SerializedOutputData, } from "@cashu/cashu-ts"; import { secp256k1 } from "@noble/curves/secp256k1.js"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; @@ -73,11 +76,32 @@ import { isWalletStorageReady, normalizeMintUrl, useWalletStore, + whenWalletHydrated, type StoredMint, type StoredProof, type WalletTx, } from "../store/wallet-store"; +// ---- Network limits --------------------------------------------------------- + +// Every mint request is bounded. cashu-ts only builds an AbortController when a +// timeout is given, and React Native's fetch has none of its own, so without +// this a mint that accepts the connection and then never answers hangs the call +// forever. That is not an abstract worry on mobile: captive portals, a dropped +// cell handover and an overloaded mint all produce exactly that shape. +// +// A hang is worse than a failure here, because the UI is built around promises +// settling. A stuck request leaves the confirm button spinning, holds the +// per-mint refresh lock so every other mint is unrefreshable, and stalls the +// startup chain before the nutzap watcher is ever installed. A timeout turns +// all of that into an ordinary error the user can retry. +// +// 20s is generous for a mint round trip on a slow connection while still being +// well inside the patience of somebody staring at a spinner. +const MINT_REQUEST_TIMEOUT_MS = 20_000; + +setGlobalRequestOptions({ requestTimeout: MINT_REQUEST_TIMEOUT_MS }); + // ---- Errors ----------------------------------------------------------------- export type WalletErrorCode = @@ -403,6 +427,12 @@ export async function initWalletService(): Promise { } catch { return false; } + // Opening the file is not the same as having read it. zustand overwrites the + // store with the persisted snapshot when hydration lands, so anything that + // credits or spends before that point is discarded. Wait for it, then check: + // hydration can fail, and a failed read must not look like an empty wallet. + await whenWalletHydrated(); + if (!isWalletStorageReady()) return false; // Backup state comes from the keychain, not the store, so it has to be read // before the first mint operation or new proofs would be created with random // secrets and quietly fall outside the user's recovery phrase. @@ -1058,7 +1088,17 @@ export async function prepareSend(params: { ); const store = useWalletStore.getState(); - store.reserveProofs(txId, quote.mintUrl, quote.unit, quote.proofs); + // The quote was priced before the awaits above, so another send may have + // claimed these coins in the meantime. Reserving is the point at which that + // is settled, and losing the race is a retry, not an error worth alarming + // anybody about. + if (!store.reserveProofs(txId, quote.mintUrl, quote.unit, quote.proofs)) { + throw new WalletError( + "insufficient", + "Those coins were just used by another payment.", + "Nothing was deducted. Try again and the wallet will pick a different set.", + ); + } store.addTx({ id: txId, kind: "send", @@ -1359,6 +1399,19 @@ export async function reconcile(): Promise { } } + // Melts whose response never arrived. The mint may have paid regardless, in + // which case the unused routing reserve is sitting there as change signed + // against blanks only this device can unblind. + for (const tx of state.history) { + if (tx.kind !== "melt" || tx.status !== "pending") continue; + if (!tx.quoteId || tx.meltOutputs === undefined) continue; + try { + await recoverMeltChange(tx); + } catch { + // Still unknown, or the mint is unreachable. The blanks stay put. + } + } + // Reserved sends whose proofs the recipient has now redeemed: the value is // gone for good, so close them out rather than offering a reclaim that would // fail at the mint. @@ -1506,6 +1559,64 @@ export async function claimLightningDeposit( return minted; } +// Settle a melt whose response was lost, using the blank outputs saved before +// the request went out. +// +// The mint is the only authority on whether the invoice was actually paid, so +// this asks rather than guesses: +// +// PAID the payment went through. Rebuild the change from the signatures +// the quote carries and credit it, then close the transaction and +// drop the reservation, because those inputs really are spent. +// UNPAID the melt never happened, so the reserved proofs are still good and +// go back into the balance. +// PENDING the mint is still trying. Leave everything exactly as it is. +async function recoverMeltChange(tx: WalletTx): Promise { + if (!tx.quoteId) return; + const store = useWalletStore.getState(); + const wallet = await getWallet(tx.mintUrl, tx.unit); + const quote = await wallet.checkMeltQuoteBolt11(tx.quoteId); + + if (quote.state === "PENDING") return; + + if (quote.state === "UNPAID") { + store.releaseReserved(tx.id); + store.updateTx(tx.id, { + status: "failed", + error: "The mint did not pay this invoice. Your balance is unchanged.", + meltOutputs: undefined, + }); + return; + } + + // PAID. Rebuild the change, if the mint returned any. + let recovered = 0; + const signatures = quote.change ?? []; + if (signatures.length > 0 && Array.isArray(tx.meltOutputs)) { + try { + const outputs = (tx.meltOutputs as SerializedOutputData[]).map((entry) => + OutputData.deserialize(entry), + ); + const change = wallet.createMeltChangeProofs(outputs, signatures); + if (change.length > 0) { + creditProofs(tx.mintUrl, tx.unit, change, { verified: true }); + recovered = change.reduce((sum, p) => sum + p.amount.toNumber(), 0); + } + } catch { + // Malformed or mismatched blanks. The payment still succeeded, so carry + // on and close the transaction rather than leaving it pending forever. + } + } + + store.dropReserved(tx.id); + store.updateTx(tx.id, { + status: "completed", + error: undefined, + meltOutputs: undefined, + ...(recovered > 0 ? { fee: Math.max(0, (tx.fee ?? 0) - recovered) } : {}), + }); +} + // ---- Lightning: withdraw (melt) --------------------------------------------- export interface MeltQuote { @@ -1618,7 +1729,15 @@ export async function payLightningInvoice(quote: MeltQuote): Promise<{ } const txId = newTxId(); - store.reserveProofs(txId, quote.mintUrl, quote.unit, selection.selected); + if ( + !store.reserveProofs(txId, quote.mintUrl, quote.unit, selection.selected) + ) { + throw new WalletError( + "insufficient", + "Those coins were just used by another payment.", + "Nothing was deducted and the invoice was not paid. Try again.", + ); + } store.addTx({ id: txId, kind: "melt", @@ -1636,10 +1755,23 @@ export async function payLightningInvoice(quote: MeltQuote): Promise<{ try { const wallet = await getWallet(quote.mintUrl, quote.unit); - const result = await wallet.meltProofsBolt11( + + // Split into prepare and complete so the blank change outputs exist before + // the request does. Their blinding factors are the only way to unblind the + // change the mint signs, and they would otherwise live purely in memory: + // lose the response and the unused routing reserve is gone for good. + const preview = await wallet.prepareMelt( + "bolt11", quote.raw, selection.selected.map(toProofLike), ); + store.updateTx(txId, { + meltOutputs: preview.outputData.map((output) => + OutputData.serialize(output), + ), + }); + + const result = await wallet.completeMelt(preview); // Unused routing reserve comes back as change proofs. const change = result.change; @@ -1653,6 +1785,8 @@ export async function payLightningInvoice(quote: MeltQuote): Promise<{ store.updateTx(txId, { status: "completed", fee: spent - quote.amount, + // Change is in hand, so the blanks have served their purpose. + meltOutputs: undefined, }); return { paid: quote.amount, @@ -1667,8 +1801,14 @@ export async function payLightningInvoice(quote: MeltQuote): Promise<{ // would show a balance the mint has already spent. if (walletErr.code === "mint-error") { store.releaseReserved(txId); - store.updateTx(txId, { status: "failed", error: walletErr.message }); + store.updateTx(txId, { + status: "failed", + error: walletErr.message, + meltOutputs: undefined, + }); } else { + // Ambiguous: the mint may have paid. The blanks stay on the transaction + // so `reconcile` can recover the change once the quote's state is known. store.updateTx(txId, { error: `${walletErr.message} Payment status unknown; checked again on next refresh.`, }); @@ -2039,35 +2179,40 @@ export async function sendNutzap(params: { ) >= params.amount, ); if (shared) { + // Locking and publishing fail very differently, so they cannot share a + // catch. A failed lock spends nothing, because the mint's swap is atomic. + // A failed publish means the value is already committed to the + // recipient's key: unspendable by us, and invisible to them until it is + // delivered somehow. Falling through in that case would send a second + // payment and strand the first forever. + let locked: Proof[] | null = null; + let txId = ""; try { - const { locked, txId } = await lockProofsForNutzap({ + const result = await lockProofsForNutzap({ amount: params.amount, mintUrl: shared, unit, recipientPubkey: info.p2pkPubkey, }); - await publishNutzap({ - proofs: locked, + locked = result.locked; + txId = result.txId; + } catch { + // Nothing left the wallet. Safe to try the tiers below. + locked = null; + } + + if (locked !== null) { + return deliverLockedNutzap({ + locked, + txId, mintUrl: shared, + unit, + amount: params.amount, recipientPubkey: params.recipientPubkey, senderPrivKey: params.senderPrivKey, client: params.client, comment: params.comment, }); - useWalletStore.getState().updateTx(txId, { status: "completed" }); - return { - method: "nutzap", - amount: params.amount, - unit, - mintUrl: shared, - txId, - }; - } catch (err) { - // The proofs were locked to them but the publish failed, or the lock - // itself failed. Either way, fall through rather than losing the value: - // `lockProofsForNutzap` leaves a pending transaction, and reconcile - // will settle it from the proof state. - void err; } } } @@ -2107,6 +2252,94 @@ export async function sendNutzap(params: { } } +// Get already-locked proofs to their owner, once the value has been committed. +// +// The proofs are P2PK-locked to the recipient, which changes what is safe: the +// token string is worthless to anybody else, so it can travel over any channel +// without the bearer risk an ordinary token carries. That gives two fallbacks +// after a failed relay publish, neither of which spends anything further. +// +// Re-spending is never an option here. The value has already left the wallet +// and cannot be recovered, so every path below is about delivery, not payment. +async function deliverLockedNutzap(params: { + locked: Proof[]; + txId: string; + mintUrl: string; + unit: string; + amount: number; + recipientPubkey: string; + senderPrivKey: Uint8Array; + client: NostrClient; + comment?: string; +}): Promise { + const store = useWalletStore.getState(); + const base = { + amount: params.amount, + unit: params.unit, + mintUrl: params.mintUrl, + txId: params.txId, + }; + + // Preferred: the NIP-61 event, which is what other wallets watch for. + try { + await publishNutzap({ + proofs: params.locked, + mintUrl: params.mintUrl, + recipientPubkey: params.recipientPubkey, + senderPrivKey: params.senderPrivKey, + client: params.client, + comment: params.comment, + }); + store.updateTx(params.txId, { status: "completed" }); + return { method: "nutzap", ...base }; + } catch { + // Relay refused or unreachable. The money is still theirs; only the + // notification failed. + } + + // Keep the locked token on the transaction before trying anything else, so a + // crash from here on still leaves something the user can hand over by hand. + const token = buildToken( + params.mintUrl, + params.locked.map((p) => toStoredProof(p, { verified: true })), + params.unit, + params.comment, + ); + store.updateTx(params.txId, { token }); + + // Second: an encrypted DM carrying the same locked token. + try { + const { wrapDm } = await import("../core/nostr/gift-wrap"); + const { event } = await wrapDm( + token, + params.senderPrivKey, + params.recipientPubkey, + ); + await params.client.publish(event); + store.updateTx(params.txId, { status: "completed" }); + return { + method: "dm", + ...base, + fallbackReason: + "the nutzap relay publish failed, so the locked token went as an encrypted message instead", + }; + } catch { + // Nothing reached the network at all. + } + + store.updateTx(params.txId, { + error: + "Locked to their key but not yet delivered. Share the token from this transaction to complete it.", + }); + return { + method: "token", + ...base, + token, + fallbackReason: + "the payment is already locked to their key, but nothing could be published. Share the token to finish delivering it", + }; +} + // Last tier: build the token and hand it back. Stays reserved and pending until // the user confirms they delivered it, so an abandoned share sheet does not // destroy the value. diff --git a/src/store/__tests__/wallet-store.test.ts b/src/store/__tests__/wallet-store.test.ts index 6165372..b196194 100644 --- a/src/store/__tests__/wallet-store.test.ts +++ b/src/store/__tests__/wallet-store.test.ts @@ -200,6 +200,44 @@ describe("reservations", () => { it("returns null when releasing an unknown reservation", () => { expect(state().releaseReserved("nope")).toBeNull(); }); + + // The reservation is the only thing standing between two concurrent sends and + // putting one coin into two tokens. Callers select proofs, then await a mint + // round trip, so both arrive here holding the same coins. + it("refuses to reserve a proof another send already took", () => { + const proof = makeProof(64, "contested"); + state().addProofs(MINT, "sat", [proof]); + + expect(state().reserveProofs("tx-a", MINT, "sat", [proof])).toBe(true); + expect(state().reserveProofs("tx-b", MINT, "sat", [proof])).toBe(false); + + expect(state().reserved["tx-b"]).toBeUndefined(); + expect(selectBalanceForUnit(state(), "sat")).toBe(0); + }); + + it("reserves all or nothing when only part of the set is still available", () => { + const kept = makeProof(8, "kept"); + const taken = makeProof(16, "taken"); + state().addProofs(MINT, "sat", [kept, taken]); + state().reserveProofs("tx-a", MINT, "sat", [taken]); + + // A second send that wants both must not quietly reserve just the one it + // can get: the token it builds would be short of the amount promised. + expect(state().reserveProofs("tx-b", MINT, "sat", [kept, taken])).toBe( + false, + ); + expect(state().reserved["tx-b"]).toBeUndefined(); + expect(selectBalanceForUnit(state(), "sat")).toBe(8); + }); + + it("is idempotent, so a retry cannot reserve twice under one id", () => { + const proof = makeProof(32, "once"); + state().addProofs(MINT, "sat", [proof]); + + expect(state().reserveProofs("tx-a", MINT, "sat", [proof])).toBe(true); + expect(state().reserveProofs("tx-a", MINT, "sat", [proof])).toBe(false); + expect(state().reserved["tx-a"].proofs).toHaveLength(1); + }); }); // ---- removeProofs / replaceProofs ------------------------------------------- diff --git a/src/store/wallet-store.ts b/src/store/wallet-store.ts index 4184a65..a8e78d7 100644 --- a/src/store/wallet-store.ts +++ b/src/store/wallet-store.ts @@ -54,9 +54,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; // ---- Constants -------------------------------------------------------------- -// Encrypted store. The plaintext v1 id is migrated in and wiped on first run. -export const WALLET_STORAGE_ID = "wallet-store-v2"; -export const LEGACY_WALLET_STORAGE_ID = "wallet-store"; +export const WALLET_STORAGE_ID = "wallet-store"; // Keychain/Keystore entry holding the MMKV encryption key. const ENCRYPTION_KEY_ITEM = "airhop.wallet.mmkvKey.v1"; @@ -134,6 +132,17 @@ export interface WalletTx { // Mint/melt quote identifier and the bolt11 invoice it relates to. quoteId?: string; invoice?: string; + // Melt only: the blank change outputs (NUT-08), serialised, written before + // the melt request goes out. + // + // A melt sends the invoice amount plus a routing reserve, and whatever + // routing does not use comes back as change the mint signs against these + // blanks. Unblinding them needs the blinding factors, which otherwise live + // only in memory for the duration of the call. If the response never arrives, + // the melt may still have succeeded at the mint and that change becomes + // unrecoverable. Persisting them first lets `reconcile` rebuild it later. + // Cleared once the change has been credited. + meltOutputs?: unknown; // Populated on `failed`, shown verbatim in the transaction detail sheet. error?: string; } @@ -234,12 +243,15 @@ interface WalletState { // ---- Reservations ---- // Move proofs out of the spendable pool and hold them against `txId`. + // Returns false and changes nothing when any of the proofs has already been + // taken by another send, which is the only thing standing between two + // concurrent sends and putting the same coin in two tokens. reserveProofs: ( txId: string, mintUrl: string, unit: string, proofs: StoredProof[], - ) => void; + ) => boolean; // Put a reservation back into the spendable pool (send never landed). releaseReserved: (txId: string) => StoredProof[] | null; // Drop a reservation for good (recipient confirmed, or mint says spent). @@ -346,22 +358,6 @@ async function loadOrCreateEncryptionKey(): Promise { return fresh; } -// Copy the v1 plaintext store into the encrypted one, once, then wipe it. -// Anything already written to v2 wins: a partial migration must never clobber -// newer state (that would destroy money). -function migrateLegacyStore(target: MMKVLike): void { - try { - const legacy = createMMKV({ id: LEGACY_WALLET_STORAGE_ID }); - const raw = legacy.getString("wallet-state"); - if (raw && target.getString("wallet-state") === undefined) { - target.set("wallet-state-legacy-v1", raw); - } - legacy.clearAll(); - } catch { - // A missing or unreadable legacy store is the normal case for new installs. - } -} - // Open (or reuse) the encrypted wallet store. Safe to call repeatedly; the // first call wins and every later one awaits the same promise. export function bootstrapWalletStorage(): Promise { @@ -385,27 +381,66 @@ export function bootstrapWalletStorage(): Promise { encryptionKey, encryptionType: "AES-256", }); - migrateLegacyStore(mmkv); instance = mmkv; return mmkv; })(); return ready; } -// True once the encrypted store is open. The wallet UI gates spending on this -// so a Keychain failure surfaces as "wallet locked" rather than a zero balance. +// Whether zustand has finished replacing the initial empty state with what was +// on disk. Separate from `instance`, and the distinction matters a great deal. +// +// Opening the MMKV file is only step one. zustand's persist middleware then +// reads it asynchronously and *overwrites* the store with the result. Anything +// written in that window is silently discarded when hydration lands. A nutzap +// redeemed one tick too early would be credited and then erased, and a balance +// check would report an empty wallet to somebody who has money. +// +// Left false when hydration fails (an unreadable keychain, a corrupt file), so +// the wallet reports itself locked rather than presenting an empty balance as +// though it were real. +let hydrated = false; +let hydrationSettled = false; +const hydrationWaiters: (() => void)[] = []; + +// How long startup will wait for hydration before giving up on it. Only reached +// if the storage promise never settles at all; a normal failure settles fast. +// Present so a wedged read can never hang app startup behind it. +const HYDRATION_TIMEOUT_MS = 15_000; + +// Called exactly once, from `onRehydrateStorage`, on both the success and the +// failure path. Waiting on zustand's `onFinishHydration` instead would deadlock: +// when hydration rejects, zustand invokes the rehydrate callback but leaves +// `hasHydrated` false and never notifies the finish listeners. +function settleHydration(ok: boolean): void { + if (hydrationSettled) return; + hydrated = ok; + hydrationSettled = true; + for (const waiter of hydrationWaiters.splice(0)) waiter(); +} + +// True once the store is both open and populated from disk. Everything that +// spends, credits, or reports a balance gates on this. export function isWalletStorageReady(): boolean { - return instance !== null; + return instance !== null && hydrated; +} + +// Resolves once hydration has settled, successfully or not. Callers must +// re-check `isWalletStorageReady()` afterwards rather than assuming success. +export function whenWalletHydrated(): Promise { + if (hydrationSettled) return Promise.resolve(); + return new Promise((resolve) => { + hydrationWaiters.push(resolve); + setTimeout(() => { + settleHydration(false); + }, HYDRATION_TIMEOUT_MS); + }); } const asyncMMKVStorage = { async getItem(name: string): Promise { const mmkv = await bootstrapWalletStorage(); - const current = mmkv.getString(name); - if (current !== undefined) return current; - // First run after the v1 -> v2 migration: adopt the legacy blob. - const legacy = mmkv.getString(`${name}-legacy-v1`); - return legacy ?? null; + return mmkv.getString(name) ?? null; }, async setItem(name: string, value: string): Promise { const mmkv = await bootstrapWalletStorage(); @@ -658,13 +693,30 @@ export const useWalletStore = create()( reserveProofs(txId, mintUrl, unit, proofs) { const key = accountKey(mintUrl, unit); - const move = new Set(proofs.map((p) => p.secret)); + const want = new Set(proofs.map((p) => p.secret)); + let reserved = false; + + // Validate and move in one synchronous pass, and refuse if any of the + // requested proofs is no longer spendable. + // + // Callers select proofs, then await a mint round trip, then land here. + // Two sends started close together therefore both pick from the same + // pool and both arrive holding the same coins. Trusting the caller + // would put one proof into two different tokens: both recipients see a + // balance, only the first to reach the mint actually has it, and our + // own accounting reserves the same value twice. set((state) => { + if (state.reserved[txId] !== undefined) return state; const existing = state.proofs[key] ?? []; + const spendable = new Set(existing.map((p) => p.secret)); + for (const secret of want) { + if (!spendable.has(secret)) return state; + } + reserved = true; return { proofs: { ...state.proofs, - [key]: existing.filter((p) => !move.has(p.secret)), + [key]: existing.filter((p) => !want.has(p.secret)), }, reserved: { ...state.reserved, @@ -672,6 +724,8 @@ export const useWalletStore = create()( }, }; }); + + return reserved; }, releaseReserved(txId) { @@ -817,40 +871,13 @@ export const useWalletStore = create()( { name: "wallet-state", storage: createJSONStorage(() => asyncMMKVStorage), - version: 2, - // v1 stored `{ proofsByMint: Record, unit }` with - // a single global unit. Every v1 proof therefore belongs to the account - // (mint, that unit). Nothing was verified against a mint back then, so - // they all migrate in as unverified, which is the honest state. - migrate(persisted, version) { - if (version >= 2) return persisted as WalletState; - const old = persisted as { - proofsByMint?: Record; - unit?: string; - } | null; - const unit = old?.unit ?? "sat"; - const proofs: Record = {}; - const mints: Record = {}; - for (const [mintUrl, list] of Object.entries(old?.proofsByMint ?? {})) { - const url = normalizeMintUrl(mintUrl); - mints[url] = { url, addedAtMs: Date.now(), units: [unit] }; - proofs[accountKey(url, unit)] = list.map((p) => ({ - ...p, - verified: false, - })); - } - return { - ...(persisted as object), - proofs, - mints, - reserved: {}, - history: [], - redeemedNutzaps: [], - claimedTokens: [], - backupEnabled: false, - backupVerified: false, - counters: {}, - } as unknown as WalletState; + version: 1, + // Fires on both the success and the failure path, which is why readiness + // is tracked here rather than through `onFinishHydration`. A failure + // means the on-disk state could not be read, so the wallet presents + // itself as locked instead of as empty. + onRehydrateStorage: () => (_state, error) => { + settleHydration(error === undefined); }, // Actions are recreated by the initializer; only data is persisted. partialize: (state) => diff --git a/src/utils/__tests__/panic-wipe.test.ts b/src/utils/__tests__/panic-wipe.test.ts index f6fdb79..eb0bb77 100644 --- a/src/utils/__tests__/panic-wipe.test.ts +++ b/src/utils/__tests__/panic-wipe.test.ts @@ -5,10 +5,7 @@ // Imports come first in source; Babel hoists jest.mock() calls above them. import { panicWipe as identityPanicWipe } from "../../core/crypto/identity"; import { clearAttachmentCache } from "../../services/file-transfer-service"; -import { - LEGACY_WALLET_STORAGE_ID, - WALLET_STORAGE_ID, -} from "../../store/wallet-store"; +import { WALLET_STORAGE_ID } from "../../store/wallet-store"; import { MMKV_STORE_IDS, panicWipe } from "../panic-wipe"; // identity.panicWipe wipes the Keychain/Keystore; mock it out in tests. @@ -132,15 +129,11 @@ describe("panicWipe", () => { test("deletes the encrypted wallet store rather than clearing it", async () => { // The wallet file is AES-256 encrypted, so reopening it without the key to - // call clearAll() is unreliable. It is removed with deleteMMKV instead, and - // both the current id and the pre-encryption one are covered so an old - // install's plaintext proofs go too. + // call clearAll() is unreliable. It is removed with deleteMMKV instead. await panicWipe(); expect(deleteMMKV).toHaveBeenCalledWith(WALLET_STORAGE_ID); - expect(deleteMMKV).toHaveBeenCalledWith(LEGACY_WALLET_STORAGE_ID); // It must never appear in the plain clearAll list, or the wipe would depend // on being able to decrypt what it is trying to destroy. expect(MMKV_STORE_IDS).not.toContain(WALLET_STORAGE_ID); - expect(MMKV_STORE_IDS).not.toContain(LEGACY_WALLET_STORAGE_ID); }); }); diff --git a/src/utils/panic-wipe.ts b/src/utils/panic-wipe.ts index b117924..9bb5b60 100644 --- a/src/utils/panic-wipe.ts +++ b/src/utils/panic-wipe.ts @@ -30,11 +30,7 @@ import { usePeerStore } from "../store/peer-store"; import { usePlaceNamesStore } from "../store/place-names-store"; import { useSettingsStore } from "../store/settings-store"; import { useTransferStore } from "../store/transfer-store"; -import { - LEGACY_WALLET_STORAGE_ID, - WALLET_STORAGE_ID, - useWalletStore, -} from "../store/wallet-store"; +import { WALLET_STORAGE_ID, useWalletStore } from "../store/wallet-store"; // The IDs used by all MMKV storage instances in src/store/ and src/core/. // peer-store is intentionally absent: it uses in-memory Zustand with no MMKV @@ -86,9 +82,8 @@ export const MMKV_STORE_IDS = [ // clearAll() is not reliable. `deleteMMKV` removes the instance and its backing // file outright, which works whatever the encryption state. The key itself is // already destroyed by clearKeys() wiping the Keychain/Keystore, so even a -// failed delete leaves ciphertext nobody can open. The legacy plaintext id is -// included because installs that predate encryption may still have the file. -const WALLET_STORE_IDS = [WALLET_STORAGE_ID, LEGACY_WALLET_STORAGE_ID] as const; +// failed delete leaves ciphertext nobody can open. +const WALLET_STORE_IDS = [WALLET_STORAGE_ID] as const; export async function panicWipe(): Promise { // 1. Destroy all private keys from the OS secure enclave. This also removes