From 1c9e7165e4269996891e9112951c3ec75a6e6898 Mon Sep 17 00:00:00 2001 From: protom Date: Wed, 19 Aug 2026 21:06:17 +0200 Subject: [PATCH] feat: wallet operations engine and encrypted storage with tests --- src/lnurlcash/ops.test.ts | 408 +++++++++++++++++++++++++ src/lnurlcash/ops.ts | 35 +++ src/lnurlcash/ops/carve.ts | 299 ++++++++++++++++++ src/lnurlcash/ops/mint.ts | 220 ++++++++++++++ src/lnurlcash/ops/pay.ts | 201 +++++++++++++ src/lnurlcash/ops/receiveBearer.ts | 81 +++++ src/lnurlcash/ops/shared.ts | 67 +++++ src/lnurlcash/receive.ts | 19 +- src/lnurlcash/storage.test.ts | 242 +++++++++++++++ src/lnurlcash/storage.ts | 45 +++ src/lnurlcash/storage/activityLog.ts | 94 ++++++ src/lnurlcash/storage/backup.ts | 154 ++++++++++ src/lnurlcash/storage/bearers.ts | 117 ++++++++ src/lnurlcash/storage/settings.ts | 33 ++ src/lnurlcash/storageLock.ts | 14 + src/lnurlcash/test-utils.ts | 28 ++ src/lnurlcash/trustedMints.test.ts | 194 ++++++++++++ src/lnurlcash/trustedMints.ts | 433 +++++++++++++++++++++++++++ src/lnurlcash/units.ts | 21 ++ 19 files changed, 2698 insertions(+), 7 deletions(-) create mode 100644 src/lnurlcash/ops.test.ts create mode 100644 src/lnurlcash/ops.ts create mode 100644 src/lnurlcash/ops/carve.ts create mode 100644 src/lnurlcash/ops/mint.ts create mode 100644 src/lnurlcash/ops/pay.ts create mode 100644 src/lnurlcash/ops/receiveBearer.ts create mode 100644 src/lnurlcash/ops/shared.ts create mode 100644 src/lnurlcash/storage.test.ts create mode 100644 src/lnurlcash/storage.ts create mode 100644 src/lnurlcash/storage/activityLog.ts create mode 100644 src/lnurlcash/storage/backup.ts create mode 100644 src/lnurlcash/storage/bearers.ts create mode 100644 src/lnurlcash/storage/settings.ts create mode 100644 src/lnurlcash/storageLock.ts create mode 100644 src/lnurlcash/test-utils.ts create mode 100644 src/lnurlcash/trustedMints.test.ts create mode 100644 src/lnurlcash/trustedMints.ts create mode 100644 src/lnurlcash/units.ts diff --git a/src/lnurlcash/ops.test.ts b/src/lnurlcash/ops.test.ts new file mode 100644 index 0000000..bb68b16 --- /dev/null +++ b/src/lnurlcash/ops.test.ts @@ -0,0 +1,408 @@ +// The operations engine against the conformance mock mint - a real HTTP +// server that can be told to misbehave. The happy paths matter, but the +// adversarial modes (dropped mutations, failed melts) are what prove the +// fund-safety invariants: fresh secrets are never lost, and melt outcomes +// are classified by proof, not by hope. + +import {afterEach, describe, expect, it} from 'vitest' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {sha256} from '@noble/hashes/sha2.js' +import { + NoteSpentError, + PendingNoteError, + buildNoteUrl, + fetchNoteInfo, + meltNote, + noteK1, + rotateNote +} from 'lnurlcash-kit' + +import type {Bearer} from './types' +import { + UncertainOutcomeError, + claimMintedNote, + ensureExactAmount, + payWithBearers, + prepareMint, + receiveBearer +} from './ops' + +type Mint = Awaited> + +const mints: Mint[] = [] +const mint = async (options: Parameters[0] = {}): Promise => { + const m = await createMockMint(options) + mints.push(m) + return m +} + +afterEach(async () => { + await Promise.all(mints.splice(0).map(m => m.close())) +}) + +const secret = (seed: string) => + bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed))) +const noteUrl = (m: Mint, k1: string, amountMsat?: number) => + buildNoteUrl(`${m.url}/w`, k1, amountMsat) + +// a verified, ready-to-spend bearer fixture: funded on the mock mint and +// read back through the informational GET, exactly as a real receive would +// learn its callback and authoritative amount +let fixtureCounter = 0 +const makeBearer = async ( + m: Mint, + k1: string, + amountMsat: number +): Promise => { + m.state.creditNote(k1, amountMsat) + const url = noteUrl(m, k1, amountMsat) + const info = await fetchNoteInfo(url) + fixtureCounter += 1 + return { + id: `fixture-${fixtureCounter}`, + url, + callback: info.callback, + amount: info.maxWithdrawable, + verified: true, + mintPubkey: m.state.pubkey, + createdAt: Date.now(), + updatedAt: Date.now() + } +} + +// paying a mint invoice is what brings its note into existence - the mock +// exposes that through its test hook (settle + credit in one step). +// Returns the paid invoice's preimage, which IS the fresh note's secret. +const settleLastInvoice = async (m: Mint): Promise => { + const paymentHash = [...m.state.invoices.keys()].at(-1)! + const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + if (!res.ok) throw new Error(`settle hook failed: ${res.status}`) + return m.state.invoices.get(paymentHash)!.preimage +} + +describe('ensureExactAmount', () => { + it('returns an already-exact note untouched, burning nothing', async () => { + const m = await mint() + const k1 = secret('01') + const bearer = await makeBearer(m, k1, 21_000) + + const result = await ensureExactAmount([bearer], 21_000) + expect(noteK1(result.note.url)).toBe(k1) + expect(result.consumed).toEqual([]) + expect(result.change).toBeUndefined() + expect(m.state.noteState(k1)).toBe('outstanding') + }) + + it('split path: carves an exact note off a larger one, with change', async () => { + const m = await mint() + const k1 = secret('02') + const bearer = await makeBearer(m, k1, 21_000) + + const result = await ensureExactAmount([bearer], 5_000) + expect(result.note.amount).toBe(5_000) + expect(result.note.verified).toBe(true) + expect(result.change?.amount).toBe(16_000) + expect(result.consumed.map(b => b.id)).toEqual([bearer.id]) + + // the input is burned; both outputs are live and worth what the result claims + expect(m.state.noteState(k1)).toBe('burned') + const partK1 = noteK1(result.note.url)! + const changeK1 = noteK1(result.change!.url)! + expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000) + expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(16_000) + }) + + it('merge path: combines notes summing exactly to the target', async () => { + const m = await mint() + const a = await makeBearer(m, secret('03'), 3_000) + const b = await makeBearer(m, secret('04'), 4_000) + + const result = await ensureExactAmount([a, b], 7_000) + expect(result.note.amount).toBe(7_000) + expect(result.change).toBeUndefined() + expect(result.consumed).toHaveLength(2) + + expect(m.state.noteState(noteK1(a.url)!)).toBe('burned') + expect(m.state.noteState(noteK1(b.url)!)).toBe('burned') + const mergedK1 = noteK1(result.note.url)! + expect((await fetchNoteInfo(noteUrl(m, mergedK1))).maxWithdrawable).toBe(7_000) + }) + + it('merge+split path: splits the target off several notes in one request', async () => { + const m = await mint() + const a = await makeBearer(m, secret('05'), 3_000) + const b = await makeBearer(m, secret('06'), 4_000) + + const result = await ensureExactAmount([a, b], 5_000) + expect(result.note.amount).toBe(5_000) + expect(result.change?.amount).toBe(2_000) + expect(result.consumed).toHaveLength(2) + + const partK1 = noteK1(result.note.url)! + const changeK1 = noteK1(result.change!.url)! + expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000) + expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(2_000) + }) + + it('excludes spent and unverified notes from selection', async () => { + const m = await mint() + const spentBearer = await makeBearer(m, secret('07'), 50_000) + const unverified: Bearer = { + ...(await makeBearer(m, secret('08'), 50_000)), + callback: '' + } + await expect( + ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000) + ).rejects.toThrow(/enough/) + }) + + it('refuses an amount no mint can cover', async () => { + const m = await mint() + const bearer = await makeBearer(m, secret('09'), 5_000) + await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/) + }) + + it('rescues the fresh secrets when a split\'s answer is lost (probe: gone)', async () => { + const m = await mint({dropAfterMutation: true}) + const k1 = secret('10') + const bearer = await makeBearer(m, k1, 21_000) + + // the split's response never arrives - but the mutation landed, so the + // probe resolves the ambiguity and the carried secrets are adopted + const result = await ensureExactAmount([bearer], 5_000) + const partK1 = noteK1(result.note.url)! + const changeK1 = noteK1(result.change!.url)! + expect(partK1).not.toBe(k1) + expect(m.state.noteState(k1)).toBe('burned') + expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000) + expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(16_000) + }) + + it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => { + const m = await mint({dropAfterMutation: true}) + const k1 = secret('11') + const bearer = await makeBearer(m, k1, 21_000) + + // mutations go to the mint (and land, dropped); every informational GET + // fails, so the probe cannot resolve the ambiguity either + const probeKillingFetch: typeof fetch = (input, init) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + if (url.includes('/w/cb')) return fetch(input, init) + return Promise.reject(new Error('probe unreachable')) + } + const err = await ensureExactAmount([bearer], 5_000, { + fetch: probeKillingFetch + }).catch((e: unknown) => e) + expect(err).toBeInstanceOf(UncertainOutcomeError) + const outputs = (err as UncertainOutcomeError).possibleOutputs + expect(outputs).toHaveLength(2) + // both possible outputs carry their fresh secrets, at the expected + // amounts - if the split landed, these are the only money left + expect(outputs[0]!.amount).toBe(5_000) + expect(outputs[1]!.amount).toBe(16_000) + expect((await fetchNoteInfo(noteUrl(m, noteK1(outputs[0]!.url)!))).maxWithdrawable).toBe(5_000) + expect((await fetchNoteInfo(noteUrl(m, noteK1(outputs[1]!.url)!))).maxWithdrawable).toBe(16_000) + }) +}) + +describe('mint -> claim -> rotate', () => { + it('mints a note from a paid invoice and rotates it immediately', async () => { + const m = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) + expect(prepared.invoice).toMatch(/^lnbc/) + expect(prepared.verifyUrl).toBeTruthy() + expect(prepared.expectedNoteValueMsat).toBe(21_000) + + const preimage = await settleLastInvoice(m) + + const claimed = await claimMintedNote(prepared, { + intervalMs: 10, + intervalCapMs: 50, + maxWaitMs: 5_000 + }) + expect(claimed.rotated).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(claimed.note.verified).toBe(true) + + // the preimage IS the initial note secret - after the rotate, that + // secret (which the mint necessarily saw) is worthless, and the + // wallet's fresh secret is the only live note + expect(m.state.noteState(preimage)).toBe('burned') + const k1 = noteK1(claimed.note.url)! + expect(k1).not.toBe(preimage) + expect(m.state.noteState(k1)).toBe('outstanding') + }) + + it('grosses the invoice up for an advertised mint fee', async () => { + const m = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 100_000) + expect(prepared.grossMsat).toBeGreaterThan(100_000) + + const preimage = await settleLastInvoice(m) + // the service's fee math is authoritative - the credited note nets + // roughly what was asked for (within fee-rounding slack), never more + // than the gross + const info = await fetchNoteInfo( + buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat) + ) + expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000) + expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat) + + // this mock regenerates the proof's pr from the NET amount rather than + // echoing the stored invoice, so the strict same-invoice guard in + // claimMintedNote correctly refuses to bind it - the guard working as + // designed against a mismatched proof + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500}) + ).rejects.toThrow(/different invoice/) + }) + + it('times out cleanly when the invoice is never paid', async () => { + const m = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}) + ).rejects.toThrow(/not confirmed/i) + }) +}) + +describe('receiveBearer', () => { + it('verifies an incoming note and rotates it, burning the sender\'s copy', async () => { + const m = await mint() + // the "sender" hands over this URL - they know its secret + const senderK1 = secret('20') + m.state.creditNote(senderK1, 21_000) + + const received = await receiveBearer(noteUrl(m, senderK1, 21_000), []) + expect(received.rotated).toBe(true) + expect(received.note.amount).toBe(21_000) + expect(received.note.verified).toBe(true) + + const newK1 = noteK1(received.note.url)! + expect(newK1).not.toBe(senderK1) + expect(m.state.noteState(senderK1)).toBe('burned') + expect(m.state.noteState(newK1)).toBe('outstanding') + }) + + it('refuses a note the wallet already holds', async () => { + const m = await mint() + const senderK1 = secret('21') + const existing = await makeBearer(m, senderK1, 21_000) + await expect( + receiveBearer(noteUrl(m, senderK1, 21_000), [existing]) + ).rejects.toThrow(/already/) + }) + + it('surfaces a spent note as definitively spent', async () => { + const m = await mint() + const k1 = secret('22') + const bearer = await makeBearer(m, k1, 21_000) + // burn it server-side (a rotate by the "other" copy of the wallet) + const info = await fetchNoteInfo(bearer.url) + await rotateNote(info.callback, k1) + + await expect(receiveBearer(noteUrl(m, k1, 21_000), [])).rejects.toBeInstanceOf( + NoteSpentError + ) + }) + + it('surfaces a note locked mid-melt as pending, not as unverified', async () => { + const m = await mint({meltNeverSettles: true}) + const k1 = secret('23') + const bearer = await makeBearer(m, k1, 21_000) + await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz') + + await expect(receiveBearer(noteUrl(m, k1, 21_000), [])).rejects.toBeInstanceOf( + PendingNoteError + ) + }) +}) + +describe('payWithBearers', () => { + it('pays a bolt11 invoice by melting an exact note (settled)', async () => { + const m = await mint() + const bearer = await makeBearer(m, secret('30'), 21_000) + + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} + }) + expect(result.outcome).toBe('settled') + expect(m.state.noteState(secret('30'))).toBe('burned') + }) + + it('pays a Lightning Address by requesting an invoice first', async () => { + const payer = await mint() + const payee = await mint() + const bearer = await makeBearer(payer, secret('31'), 21_000) + + const result = await payWithBearers( + [bearer], + `mint@127.0.0.1:${payee.port}`, + {amountMsat: 21_000, poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}} + ) + expect(result.outcome).toBe('settled') + expect(result.invoice).toMatch(/^lnbc/) + expect(payer.state.noteState(secret('31'))).toBe('burned') + }) + + it('carves the exact amount out of a larger note before melting', async () => { + const m = await mint() + const bearer = await makeBearer(m, secret('32'), 50_000) + + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} + }) + expect(result.outcome).toBe('settled') + // the split happened: input burned, the 21000 sat note melted, and the + // change note is tracked for the wallet to keep + expect(m.state.noteState(secret('32'))).toBe('burned') + expect(result.carve.consumed.map(b => b.id)).toEqual([bearer.id]) + expect(result.carve.change?.amount).toBe(29_000) + expect(m.state.noteState(noteK1(result.carve.change!.url)!)).toBe('outstanding') + }) + + it('classifies a failed melt as funds-returned once the note is spendable again', async () => { + const m = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(m, secret('33'), 21_000) + + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300} + }) + expect(result.outcome).toBe('failed-funds-returned') + // the mint restored the note, and the classification rotate re-secured + // it (the melt had put its k1 on the wire): the old secret is burned, + // the fresh one in the result is outstanding at the full amount + expect(m.state.noteState(secret('33'))).toBe('burned') + const returnedK1 = noteK1(result.carve.note.url)! + expect(m.state.noteState(returnedK1)).toBe('outstanding') + expect(result.carve.note.amount).toBe(21_000) + }) + + it('classifies a never-settling melt as unknown-still-pending', async () => { + const m = await mint({meltNeverSettles: true}) + const bearer = await makeBearer(m, secret('34'), 21_000) + + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300} + }) + expect(result.outcome).toBe('unknown-still-pending') + expect(m.state.noteState(secret('34'))).toBe('pending') + }) + + it('rejects an amountless or unreadable invoice instead of guessing', async () => { + const m = await mint() + const bearer = await makeBearer(m, secret('35'), 21_000) + await expect( + payWithBearers([bearer], 'lnbc1pjqrstuvwxyz') + ).rejects.toThrow(/amount/) + await expect( + payWithBearers([bearer], 'not-an-invoice') + ).rejects.toThrow(/not a valid/i) + }) +}) diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts new file mode 100644 index 0000000..891b357 --- /dev/null +++ b/src/lnurlcash/ops.ts @@ -0,0 +1,35 @@ +// The operations engine: every multi-step wallet flow (carving exact +// amounts, minting, receiving, paying), framework-free. Pinia stores call +// these and apply the returned changesets; UI components never touch +// lnurlcash-kit directly. Every function takes bearers in and returns the +// new/changed notes out - it never mutates wallet state itself. +// +// Fund-critical invariants enforced across the flows (see the project plan): +// - rotate on every receive, and immediately after claiming a fresh mint +// (observer race: anyone who saw the unpaid invoice knows the payment +// hash, and the mint necessarily saw the preimage - the preimage IS the +// note secret) +// - a note's declared amount is a claim; the service's maxWithdrawable is +// authoritative +// - a melt's "OK" only means the payment is in flight; its verify URL (or +// the note becoming spendable again) is the real outcome +// - an ambiguous mutation NEVER loses the fresh secrets it carries: they +// are either rescued into tracked notes, probed, or surfaced to the +// caller unverified for later reconcile +// +// The engine is split by flow; this façade is the single import surface: +// ops/carve.ts - ensureExactAmount (merge/split exact-amount carving) +// ops/mint.ts - prepareMint / claimMintedNote (receive over Lightning) +// ops/receiveBearer.ts - receiveBearer (receive a note, rotate on receive) +// ops/pay.ts - payWithBearers (melt to bolt11 / Lightning Address) +// ops/shared.ts - bounded verify polling, UncertainOutcomeError + +export {UncertainOutcomeError} from './ops/shared' +export type {PollOptions} from './ops/shared' +export {ensureExactAmount} from './ops/carve' +export type {CarveResult} from './ops/carve' +export {prepareMint, claimMintedNote} from './ops/mint' +export type {PreparedMint, ClaimedNote} from './ops/mint' +export {receiveBearer} from './ops/receiveBearer' +export {payWithBearers} from './ops/pay' +export type {PayOutcome, PayResult, PayOptions} from './ops/pay' diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts new file mode 100644 index 0000000..fb4429d --- /dev/null +++ b/src/lnurlcash/ops/carve.ts @@ -0,0 +1,299 @@ +// ensureExactAmount: carve an exact amount out of the held notes, merging +// and/or splitting as needed, into a single fresh note worth exactly the +// target - the operation every send and every melt starts from. + +import { + AmbiguousMutationError, + mergeNotes, + noteK1, + probeBurnedNote, + requireNoteK1, + serverOf, + settleNote, + splitNote, + withNewK1 +} from 'lnurlcash-kit' +import type {LnurlcashOptions} from 'lnurlcash-kit' +import type {Bearer, NewBearer} from '../types' +import {UncertainOutcomeError} from './shared' + +// the changeset stores apply after a mutation: `note`/`change` BEFORE +// `consumed` - the mint call already burned every consumed input +// server-side, so the outputs are the only money left and must be tracked +// first; a crash between the two must strand a duplicate, never a secret +export type CarveResult = { + // the exact-amount note, ready to hand over or melt + note: NewBearer + // the remainder note, when the carve split a larger input + change?: NewBearer + // the input notes burned server-side by the carve (empty when a single + // note already held exactly the target amount) + consumed: Bearer[] +} + +// Selection: only notes that can actually take part - verified (callback +// known), not locally spent, holding a real k1 (device-backed mirrors are +// excluded; the ops engine cannot mutate a secret it doesn't hold). Notes +// are grouped by issuing server (a mutation only ever spans one service), +// picked greedily smallest-first within a group until the target is +// covered, and the group with the least waste wins (ties: fewer notes). +// +// Execution, mirroring lnurl-wallet's SendDialog: +// - one note already exact: returned as-is, nothing burned +// - several notes summing exactly: one merge, then settle (reads the true +// post-fee value back and rotates, since the read put k1 on the wire) +// - total above target (one or many notes): a single split request (LUD-25 +// split takes many k1s - no merge round trip first), then the change is +// settled for its true value; the target part carries the mint's +// signature and needs no settle +export const ensureExactAmount = async ( + bearers: Bearer[], + amountMsat: number, + options: LnurlcashOptions = {} +): Promise => { + if (!Number.isInteger(amountMsat) || amountMsat <= 0) { + throw new Error('Amount must be a positive whole number of msat.') + } + const eligible = bearers.filter( + b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url) + ) + // per-server greedy pick: smallest notes first until the target is + // covered (an exact single-note match short-circuits - no mutation at + // all is always better than carving) + const byServer = new Map() + for (const b of eligible) { + const server = serverOf(b.url) + byServer.set(server, [...(byServer.get(server) ?? []), b]) + } + let pick: Bearer[] | null = null + for (const group of byServer.values()) { + const sorted = [...group].sort((a, b) => a.amount - b.amount) + const exact = sorted.find(b => b.amount === amountMsat) + const candidate = exact ? [exact] : accumulate(sorted, amountMsat) + if (!candidate) continue + if (!pick || better(candidate, pick, amountMsat)) pick = candidate + } + if (!pick) { + throw new Error( + 'No mint holds enough verified, unspent balance to cover that amount.' + ) + } + const base = pick[0] + const total = pick.reduce((sum, b) => sum + b.amount, 0) + const k1s = pick.map(b => requireNoteK1(b.url)) + + if (pick.length === 1 && total === amountMsat) { + // already exact - hand over the note itself, untouched + return { + note: { + url: base.url, + callback: base.callback, + amount: base.amount, + verified: base.verified, + mintPubkey: base.mintPubkey + }, + consumed: [] + } + } + + if (total === amountMsat) { + // merge path: many notes, exact sum - merge into one, then settle it + // (true value + fresh secret; a failed settle leaves an unverified + // note a refresh can repair, not a lost secret) + const merged = await mergeAmbiguitySafe(base, k1s, total, options) + const unverified: NewBearer = { + url: withNewK1(base.url, merged.k1, total, merged.signature), + callback: base.callback, + amount: total, + verified: false, + mintPubkey: base.mintPubkey + } + // a merge whose answer was lost leaves the service in an unknown + // state from here - settling fires another mutation (the rotate + // inside settleNote) at it, whose own ambiguous failure would strand + // the rescued secret. Don't compound: return unverified and let a + // refresh repair. + if (merged.rescued) return {note: unverified, consumed: pick} + try { + const settled = await settleNote( + base.url, + merged.k1, + total, + merged.signature, + options + ) + return { + note: { + url: withNewK1( + base.url, + settled.k1, + settled.amountMsat, + settled.signature + ), + callback: settled.callback, + amount: settled.amountMsat, + verified: true, + mintPubkey: base.mintPubkey + }, + consumed: pick + } + } catch { + return {note: unverified, consumed: pick} + } + } + + // split path: total above target - one split request across all picked + // k1s, carving the target off and leaving the change as a fresh note + let partK1: string + let partSignature: string | undefined + let changeK1: string + let changeSignature: string | undefined + let partVerified = false + // true when the split's answer was lost and the probe proved the burn - + // the carried secrets were rescued, but the service is in an unknown + // state, so the change is NOT settled (that would fire another mutation + // at it, whose own ambiguous failure would strand the rescued secret) + let rescued = false + try { + const parts = await splitNote(base.callback, k1s, amountMsat, options) + partK1 = parts.k1 + partSignature = parts.signature + changeK1 = parts.change + changeSignature = parts.changeSignature + partVerified = true + } catch (err) { + if (!(err instanceof AmbiguousMutationError)) throw err + // the split request may have landed despite the failure - probe one + // input before deciding what the carried secrets are worth + const outcome = await probeBurnedNote(base.url, options) + if (outcome === 'live') throw err // nothing burned - a plain failure + if (outcome === 'unknown') { + // can't tell: surface both possible outputs unverified WITHOUT + // consuming the inputs, and stop here rather than spend from limbo + throw new UncertainOutcomeError( + 'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.', + [ + { + url: withNewK1(base.url, err.newSecrets[0], amountMsat), + callback: base.callback, + amount: amountMsat, + verified: false, + mintPubkey: base.mintPubkey + }, + { + url: withNewK1(base.url, err.newSecrets[1], total - amountMsat), + callback: base.callback, + amount: total - amountMsat, + verified: false, + mintPubkey: base.mintPubkey + } + ] + ) + } + // 'gone': the burn landed - the carried secrets are the only money + partK1 = err.newSecrets[0] + changeK1 = err.newSecrets[1] + rescued = true + } + const note: NewBearer = { + url: withNewK1(base.url, partK1, amountMsat, partSignature), + callback: base.callback, + amount: amountMsat, + verified: partVerified, + mintPubkey: base.mintPubkey + } + // settleNote: the change may be worth less than total - amount if this + // mint charges split fees (LUD-25 deducts them from change, never the + // split-off amount) - it comes back at its true value, or stays + // unverified at the naive pre-fee one for a refresh to repair + let change: NewBearer = { + url: withNewK1(base.url, changeK1, total - amountMsat, changeSignature), + callback: base.callback, + amount: total - amountMsat, + verified: false, + mintPubkey: base.mintPubkey + } + if (!rescued) { + try { + const settled = await settleNote( + base.url, + changeK1, + total - amountMsat, + changeSignature, + options + ) + change = { + url: withNewK1( + base.url, + settled.k1, + settled.amountMsat, + settled.signature + ), + callback: settled.callback, + amount: settled.amountMsat, + verified: true, + mintPubkey: base.mintPubkey + } + } catch { + // settle is best-effort - the unverified change above is still tracked + } + } + return {note, change, consumed: pick} +} + +// smallest-first accumulation until the target is covered; null when the +// whole group can't reach it +const accumulate = (sorted: Bearer[], amountMsat: number): Bearer[] | null => { + const picked: Bearer[] = [] + let total = 0 + for (const b of sorted) { + picked.push(b) + total += b.amount + if (total >= amountMsat) return picked + } + return null +} + +// the better carve plan: less waste first, then fewer notes burned +const better = (a: Bearer[], b: Bearer[], target: number): boolean => { + const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0) + const wasteA = sum(a) - target + const wasteB = sum(b) - target + if (wasteA !== wasteB) return wasteA < wasteB + return a.length < b.length +} + +// merge with the full ambiguity protocol: probe one input, rescue the +// carried secret only once the burn is confirmed, surface it unverified +// when the probe can't tell either +const mergeAmbiguitySafe = async ( + base: Bearer, + k1s: string[], + total: number, + options: LnurlcashOptions +): Promise<{k1: string; signature?: string; rescued: boolean}> => { + try { + const merged = await mergeNotes(base.callback, k1s, options) + return {k1: merged.k1, signature: merged.signature, rescued: false} + } catch (err) { + if (!(err instanceof AmbiguousMutationError)) throw err + const outcome = await probeBurnedNote(base.url, options) + if (outcome === 'live') throw err // nothing burned - a plain failure + if (outcome === 'unknown') { + throw new UncertainOutcomeError( + 'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.', + [ + { + url: withNewK1(base.url, err.newSecrets[0], total), + callback: base.callback, + amount: total, + verified: false, + mintPubkey: base.mintPubkey + } + ] + ) + } + // 'gone': the burn landed - the carried secret is the only money left + return {k1: err.newSecrets[0], rescued: true} + } +} diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts new file mode 100644 index 0000000..7731d1d --- /dev/null +++ b/src/lnurlcash/ops/mint.ts @@ -0,0 +1,220 @@ +// Minting: receiving over Lightning. prepareMint resolves a mint and +// requests the invoice; paying it brings the note into existence; +// claimMintedNote watches the payment and converts the revealed preimage +// into a rotated, wallet-owned bearer note. + +import { + AmbiguousMutationError, + buildNoteUrl, + fetchMintAddress, + fetchNoteInfo, + fetchPayRequest, + grossUpForMintFee, + isPreimage, + lightningAddressUsername, + mintAddressUrl, + probeBurnedNote, + requestInvoice, + resolveMintInput, + rotateNote, + sameInvoice, + serverOf, + withNewK1 +} from 'lnurlcash-kit' +import type {LnurlcashOptions, MintAddressInfo} from 'lnurlcash-kit' +import type {NewBearer} from '../types' +import {ceilMsatToSat} from '../units' +import type {PollOptions} from './shared' +import {pollVerifyUntilSettled} from './shared' + +export type PreparedMint = { + invoice: string + verifyUrl: string | null + // the net note value asked for - the claim cross-checks the service's + // authoritative maxWithdrawable against it + expectedNoteValueMsat: number + // the gross amount actually invoiced (net + mint fee, rounded up to a + // whole sat - sub-sat invoices aren't reliably payable) + grossMsat: number + mintUrl: string + withdrawLink: string + mintPubkey?: string + server: string + username: string | null + nodeInfo: MintAddressInfo | null +} + +// Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover +// its mint address (best-effort LUD-25 experimental endpoint), read its +// payRequest, gross the requested net amount up for the advertised mint +// fee, and request the invoice. Paying the returned invoice is what brings +// the note into existence - see claimMintedNote. +export const prepareMint = async ( + mintInput: string, + amountMsat: number, + options: LnurlcashOptions = {} +): Promise => { + if (!Number.isInteger(amountMsat) || amountMsat <= 0) { + throw new Error('Amount must be a positive whole number of msat.') + } + const url = resolveMintInput(mintInput) + if (!url) throw new Error('Enter a mint LNURL or Lightning Address.') + // best-effort mint-address discovery - derived from `url`'s own + // .well-known/lnurlp/{name} path; when it succeeds, its payLink is the + // authoritative place to fetch the payRequest from + const addressUrl = mintAddressUrl(url) + let nodeInfo: MintAddressInfo | null = null + let payUrl = url + if (addressUrl) { + try { + nodeInfo = await fetchMintAddress(addressUrl, options) + payUrl = nodeInfo.payLink + } catch { + // no mint-address support here - proceed with just the guess + } + } + const info = await fetchPayRequest(payUrl, options) + if (!info.withdrawLink) { + throw new Error( + 'This payRequest does not advertise lnurlcash minting (no withdrawLink).' + ) + } + const grossMsat = ceilMsatToSat( + info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat + ) + if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { + throw new Error('Amount is outside this mint\'s sendable range.') + } + const invoice = await requestInvoice(info.callback, grossMsat, options) + const prepared: PreparedMint = { + invoice: invoice.pr, + verifyUrl: invoice.verify ?? null, + expectedNoteValueMsat: amountMsat, + grossMsat, + mintUrl: payUrl, + withdrawLink: info.withdrawLink, + server: serverOf(payUrl), + username: lightningAddressUsername(payUrl), + nodeInfo + } + if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey + return prepared +} + +export type ClaimedNote = { + note: NewBearer + // false when the rotate after claim failed - the note is tracked either + // way (it IS money), but the preimage was transmitted and the mint + // necessarily knows it, so an unrotated note must be treated as exposed + rotated: boolean + // set when the rotate's answer was lost and the probe couldn't tell: the + // possible rotated copy, to track unverified alongside `note` + possibleCopy?: NewBearer + rotationError?: string +} + +// Polls the mint invoice's LUD-21 verify URL until the payment settles, +// then claims the note: the payment preimage IS the note secret. The claim +// itself (an informational GET) puts that secret on the wire, and the mint +// has known it since it generated the invoice - so the fresh note is +// rotated immediately and unconditionally (observer race: anyone who saw +// the unpaid invoice knows the payment hash), before anything else happens +// with it. +export const claimMintedNote = async ( + prepared: PreparedMint, + poll: PollOptions = {}, + options: LnurlcashOptions = {} +): Promise => { + if (!prepared.verifyUrl) { + throw new Error( + 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.' + ) + } + const verifyUrl = prepared.verifyUrl + const result = await pollVerifyUntilSettled(verifyUrl, poll, options) + // a settled report only means this wallet's invoice was paid if it's for + // the invoice this wallet actually requested + if (!sameInvoice(result.pr, prepared.invoice)) { + throw new Error( + "The service's verify response is for a different invoice than requested." + ) + } + const preimage = result.preimage + if (!preimage || !isPreimage(preimage)) { + throw new Error( + 'The payment settled but the service did not reveal the preimage.' + ) + } + // declare the invoiced amount (a claim - not yet confirmed) so the note + // is self-describing even before the verifying GET below + const declaredUrl = buildNoteUrl( + prepared.withdrawLink, + preimage, + prepared.expectedNoteValueMsat + ) + // the service's maxWithdrawable is authoritative - SERVICE's own fee + // math might not match this wallet's estimate, and the note is worth + // exactly maxWithdrawable regardless + const noteInfo = await fetchNoteInfo(declaredUrl, options) + const mintPubkey = noteInfo.mintPubkey ?? prepared.mintPubkey + const base: NewBearer = { + url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable), + callback: noteInfo.callback, + amount: noteInfo.maxWithdrawable, + verified: true + } + if (mintPubkey) base.mintPubkey = mintPubkey + + let url = base.url + let rotated = true + let possibleCopy: NewBearer | undefined + let rotationError: string | undefined + try { + const rotatedNote = await rotateNote(noteInfo.callback, noteInfo.k1, options) + url = withNewK1( + declaredUrl, + rotatedNote.k1, + noteInfo.maxWithdrawable, + rotatedNote.signature + ) + } catch (err) { + rotated = false + if (err instanceof AmbiguousMutationError) { + // the rotate request may have landed despite the failure - the fresh + // secret it carried is then the only copy of this note + const outcome = await probeBurnedNote(declaredUrl, options) + if (outcome === 'gone') { + // the burn landed - adopt the fresh secret as the note + url = withNewK1( + declaredUrl, + err.newSecrets[0], + noteInfo.maxWithdrawable + ) + rotated = true + } else if (outcome === 'unknown') { + // can't tell: the preimage note is returned either way - the + // possible rotated copy goes alongside it, both refreshable + possibleCopy = { + url: withNewK1( + declaredUrl, + err.newSecrets[0], + noteInfo.maxWithdrawable + ), + callback: noteInfo.callback, + amount: noteInfo.maxWithdrawable, + verified: false + } + if (mintPubkey) possibleCopy.mintPubkey = mintPubkey + rotationError = `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.` + } else { + rotationError = err.message + } + } else { + rotationError = err instanceof Error ? err.message : String(err) + } + } + const claimed: ClaimedNote = {note: {...base, url}, rotated} + if (possibleCopy) claimed.possibleCopy = possibleCopy + if (rotationError) claimed.rotationError = rotationError + return claimed +} diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts new file mode 100644 index 0000000..c5fe5e7 --- /dev/null +++ b/src/lnurlcash/ops/pay.ts @@ -0,0 +1,201 @@ +// Paying over Lightning: melt held notes into a bolt11 invoice or a +// Lightning Address payment. melt demands an exact amount match and takes +// a single k1, so the notes are carved first (see carve.ts's +// ensureExactAmount). + +import { + AmbiguousMutationError, + NoteSpentError, + PendingNoteError, + decodeBolt11AmountMsat, + fetchPayRequest, + isBolt11Invoice, + meltNote, + requestInvoice, + requireNoteK1, + resolveLnurlInput, + rotateNote, + sameInvoice, + withNewK1 +} from 'lnurlcash-kit' +import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit' +import type {Bearer, NewBearer} from '../types' +import type {CarveResult} from './carve' +import {ensureExactAmount} from './carve' +import type {PollOptions} from './shared' +import {pollVerifyUntilSettled} from './shared' + +export type PayOutcome = + | 'settled' + | 'failed-funds-returned' + | 'unknown-still-pending' + // the service reports the carved note as already spent before the melt + // even started - nothing was paid, and the note is definitively gone + | 'note-already-spent' + +export type PayResult = { + outcome: PayOutcome + carve: CarveResult + // the invoice that was (attempted to be) paid + invoice: string + amountMsat: number + verifyUrl: string | null + // a fresh secret rescued from an ambiguous rotate during outcome + // classification - the caller must track it unverified; if the rotate + // landed, this is the only copy of the (returned) funds + rescuedNote?: NewBearer +} + +export type PayOptions = { + // required when `input` is a Lightning Address / LNURL-pay (a bolt11 + // carries its own amount) + amountMsat?: number + // verify-poll budget - tests shrink this + poll?: PollOptions + // kit transport overrides (fetch injection, timeouts) + kit?: LnurlcashOptions +} + +// A melt's resolved promise only means the payment is in flight; the +// outcome is classified by polling the melt's LUD-25 verify URL, then - if +// that budget runs out - by attempting a rotate on the melted note (a +// failed melt is never reported through the callback; it is only +// observable as the note becoming spendable again, which a rotate proves +// by succeeding - and rotates, since the melt put k1 on the wire anyway): +// - settled: the payment went through; the note is gone for good +// - failed-funds-returned: the note was spendable again, nothing was paid +// - unknown-still-pending: neither confirmed; the note stays locked spent +// locally until a refresh reconciles it +export const payWithBearers = async ( + bearers: Bearer[], + input: string, + {amountMsat, poll = {}, kit = {}}: PayOptions = {} +): Promise => { + const options = kit + let invoice: string + let amount: number + const trimmed = input.trim() + if (isBolt11Invoice(trimmed)) { + const decoded = decodeBolt11AmountMsat(trimmed) + if (decoded === null || decoded <= 0) { + throw new Error( + 'Could not read this invoice\'s amount - amount-less invoices are not supported.' + ) + } + invoice = trimmed + amount = decoded + } else { + // a Lightning Address (or LNURL-pay) has no invoice of its own yet - + // resolving it gets a payRequest, and an amount is needed before an + // actual invoice exists + const url = resolveLnurlInput(trimmed) + if (!url) { + throw new Error('Not a valid bolt11 invoice or Lightning Address.') + } + if (amountMsat === undefined || !Number.isInteger(amountMsat) || amountMsat <= 0) { + throw new Error('Enter an amount to pay to this address.') + } + const info = await fetchPayRequest(url, options) + if (amountMsat < info.minSendable || amountMsat > info.maxSendable) { + throw new Error('Amount is outside the payee\'s sendable range.') + } + const result = await requestInvoice(info.callback, amountMsat, options) + invoice = result.pr + amount = amountMsat + } + + const carve = await ensureExactAmount(bearers, amount, options) + const k1 = requireNoteK1(carve.note.url) + let melt: MeltResult + try { + melt = await meltNote(carve.note.callback, k1, invoice, options) + } catch (err) { + // this melt names a single note, so a NoteSpentError here is + // unambiguous - it's already gone, and gets locked spent the same way + // a successful melt would have locked it + if (err instanceof NoteSpentError) { + return {outcome: 'note-already-spent', carve, invoice, amountMsat: amount, verifyUrl: null} + } + throw err + } + + if (!melt.verify) { + // no melt proof to poll - the note locking as spent locally is all the + // confirmation there is + return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl: null} + } + const verifyUrl = melt.verify + try { + const proof = await pollVerifyUntilSettled(verifyUrl, poll, options) + // a settled report is only this payment's proof when it's for the + // invoice this melt actually paid - a mint that mixes up proofs must + // not confirm the wrong payment. The verify URL is already scoped to + // this melt's payment hash, so an exact string match binds it; short + // of that, a proof pr that decodes to a DIFFERENT amount definitely + // belongs to another payment, while an undecodable one says nothing + // either way (a service regenerating synthetic prs in proofs) and is + // tolerated. + const proofAmount = decodeBolt11AmountMsat(proof.pr) + if ( + !sameInvoice(proof.pr, invoice) && + proofAmount !== null && + proofAmount !== amount + ) { + return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} + } + return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} + } catch { + // the verify budget ran out - probe the note itself with a rotate: a + // failed melt is only observable as the note becoming spendable again + try { + const rotated = await rotateNote(carve.note.callback, k1, options) + // the rotate succeeded, so the mint restored the note - and k1 had + // been on the wire since the melt, so the rotation doubles as the + // required re-securing of the returned funds + return { + outcome: 'failed-funds-returned', + carve: { + ...carve, + note: { + ...carve.note, + url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature) + } + }, + invoice, + amountMsat: amount, + verifyUrl + } + } catch (err) { + if (err instanceof PendingNoteError) { + // still locked mid-melt - no outcome either way + return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} + } + if (err instanceof NoteSpentError) { + // burned without a settled proof - the money is gone either way + return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} + } + if (err instanceof AmbiguousMutationError) { + // the rotate's answer was lost. Had the note still been pending, + // the service would have said so cleanly - so the funds ARE back, + // but whether the rotation landed is unknown: the original k1 may + // be live, or the fresh secret may be the only copy. Surface both. + const rescuedNote: NewBearer = { + url: withNewK1(carve.note.url, err.newSecrets[0], amount), + callback: carve.note.callback, + amount, + verified: false + } + if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey + return { + outcome: 'failed-funds-returned', + carve, + invoice, + amountMsat: amount, + verifyUrl, + rescuedNote + } + } + return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} + } + } +} diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts new file mode 100644 index 0000000..c151c61 --- /dev/null +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -0,0 +1,81 @@ +// Receiving a bearer note: wraps receive.ts's receiveNote + +// secureReceivedNote into one flow - resolve whatever came in (note URL, +// bech32, lnurlw://), verify it with the issuing service, then rotate +// immediately, since the previous holder (and anything that logged the URL +// in transit) still knows the old secret. + +import { + AmbiguousMutationError, + NoteSpentError, + NoteUnknownError, + PendingNoteError, + probeBurnedNote, + withNewK1 +} from 'lnurlcash-kit' +import type {LnurlcashOptions} from 'lnurlcash-kit' +import type {Bearer, NewBearer} from '../types' +import {receiveNote, secureReceivedNote} from '../receive' +import type {ClaimedNote} from './mint' + +// NoteSpentError / NoteUnknownError / PendingNoteError from the service are +// definitive and propagate; an unreachable service still yields the note, +// unverified, at the sender's declared amount. +export const receiveBearer = async ( + input: string, + existing: Bearer[], + options: LnurlcashOptions = {} +): Promise => { + const note = await receiveNote(input, existing) + if (!note.verified || !note.callback) { + return {note, rotated: false} + } + try { + const rotatedUrl = await secureReceivedNote(note) + return {note: {...note, url: rotatedUrl}, rotated: true} + } catch (err) { + // a definitive service state (dead/unknown/locked mid-melt) is not a + // rotation failure to warn about - it tells the holder what this note + // actually is, so it propagates distinctly + if ( + err instanceof NoteSpentError || + err instanceof PendingNoteError || + err instanceof NoteUnknownError + ) { + throw err + } + if (err instanceof AmbiguousMutationError) { + const outcome = await probeBurnedNote(note.url, options) + if (outcome === 'gone') { + return { + note: { + ...note, + url: withNewK1(note.url, err.newSecrets[0], note.amount) + }, + rotated: true + } + } + if (outcome === 'unknown') { + const possibleCopy: NewBearer = { + url: withNewK1(note.url, err.newSecrets[0], note.amount), + callback: note.callback, + amount: note.amount, + verified: false + } + if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey + return { + note, + rotated: false, + possibleCopy, + rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.` + } + } + } + // a failed rotate never fails the receive - the note is money as it + // is; the caller warns that it must be treated as exposed + return { + note, + rotated: false, + rotationError: err instanceof Error ? err.message : String(err) + } + } +} diff --git a/src/lnurlcash/ops/shared.ts b/src/lnurlcash/ops/shared.ts new file mode 100644 index 0000000..45ba0e3 --- /dev/null +++ b/src/lnurlcash/ops/shared.ts @@ -0,0 +1,67 @@ +// Shared plumbing for the operations engine: the bounded verify polling +// every flow that waits on a payment uses, and the uncertainty type a lost +// mutation answer surfaces as. + +import {fetchInvoiceVerification} from 'lnurlcash-kit' +import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit' +import type {NewBearer} from '../types' + +// a mutation's answer was lost AND the probe could not tell whether it +// landed - the possible outputs the fresh secrets would control, for the +// caller to track unverified alongside the (kept) inputs. Never dropped: +// if the mutation did land, these are the only money left. +export class UncertainOutcomeError extends Error { + readonly possibleOutputs: NewBearer[] + constructor(message: string, possibleOutputs: NewBearer[]) { + super(message) + this.name = 'UncertainOutcomeError' + this.possibleOutputs = possibleOutputs + } +} + +export type PollOptions = { + // first delay between checks (doubles each round up to intervalCapMs) + intervalMs?: number + intervalCapMs?: number + // total budget before giving up + maxWaitMs?: number +} + +const DEFAULT_POLL: Required = { + intervalMs: 1000, + intervalCapMs: 5000, + maxWaitMs: 120_000 +} + +const sleep = (ms: number): Promise => + new Promise(resolve => setTimeout(resolve, ms)) + +// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with +// backoff, inside a total time budget. A single failed check isn't fatal - +// the next round tries again. Returns the settled VerifyResult; throws on +// budget exhaustion. +export const pollVerifyUntilSettled = async ( + verifyUrl: string, + poll: PollOptions, + options: LnurlcashOptions +): Promise => { + const {intervalMs, intervalCapMs, maxWaitMs} = {...DEFAULT_POLL, ...poll} + const deadline = Date.now() + maxWaitMs + let delay = intervalMs + let lastError: unknown = null + while (Date.now() < deadline) { + try { + const result = await fetchInvoiceVerification(verifyUrl, options) + if (result.settled) return result + lastError = null + } catch (err) { + lastError = err + } + await sleep(Math.min(delay, Math.max(0, deadline - Date.now()))) + delay = Math.min(delay * 2, intervalCapMs) + } + if (lastError instanceof Error) { + throw new Error(`Payment not confirmed: ${lastError.message}`) + } + throw new Error('Payment not confirmed within the time budget.') +} diff --git a/src/lnurlcash/receive.ts b/src/lnurlcash/receive.ts index 8bef556..9efdb95 100644 --- a/src/lnurlcash/receive.ts +++ b/src/lnurlcash/receive.ts @@ -7,7 +7,8 @@ import { rotateNote, withNewK1, NoteSpentError, - NoteUnknownError + NoteUnknownError, + PendingNoteError } from 'lnurlcash-kit' import type {Bearer, NewBearer} from './types' @@ -42,12 +43,16 @@ export const receiveNote = async ( mintPubkey: info.mintPubkey } } catch (err) { - // the service positively told us this k1 is dead - that's worth more - // than the sender's own claim, so don't paper over it with an - // unverified fallback the way an unreachable/unknown-shaped error - // below does. The caller (ReceiveDialog.tsx) surfaces this and never stores - // the note. - if (err instanceof NoteSpentError || err instanceof NoteUnknownError) { + // the service positively told us this k1 is dead, unknown, or locked + // mid-melt (pending) - all definitive states the caller must surface + // distinctly, so don't paper over them with an unverified fallback the + // way an unreachable/unknown-shaped error below does. The caller never + // stores the note in these cases. + if ( + err instanceof NoteSpentError || + err instanceof NoteUnknownError || + err instanceof PendingNoteError + ) { throw err } // service unreachable (or some other non-definitive failure) - fall diff --git a/src/lnurlcash/storage.test.ts b/src/lnurlcash/storage.test.ts new file mode 100644 index 0000000..96fd16e --- /dev/null +++ b/src/lnurlcash/storage.test.ts @@ -0,0 +1,242 @@ +// Storage round-trips and backup merge semantics. Runs in Node against an +// in-memory localStorage stub; WebCrypto (crypto.subtle) is native. + +import {beforeEach, describe, expect, it} from 'vitest' +import {buildNoteUrl} from 'lnurlcash-kit' + +import type {Bearer} from './types' +import {deriveBearerAesKey} from './keys' +import { + applyBackup, + buildBackup, + clearAllBearers, + deleteBearerRecord, + loadActivity, + loadBearers, + mergeBearers, + newBearerId, + persistActivityEvent, + persistBearer, + readEncryptedBearers, + MAX_ACTIVITY_ENTRIES +} from './storage' +import {saveLinkingKey} from './keys' +import {stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) + +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: newBearerId(), + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides +}) + +beforeEach(() => { + stubLocalStorage() +}) + +describe('encrypted bearer records', () => { + it('round-trips a bearer through AES-GCM storage', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const bearer = bearerFixture() + await persistBearer(key, bearer) + + // at rest, nothing plaintext leaks: no k1, no amounts + const raw = localStorage.getItem('sattle_bearers')! + expect(raw).not.toContain(K1_A) + expect(raw).not.toContain('21000') + + const loaded = await loadBearers(key) + expect(loaded).toEqual([bearer]) + }) + + it('skips records written under a different seed without destroying them', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const other = await deriveBearerAesKey(OTHER_KEY) + await persistBearer(key, bearerFixture({id: 'mine'})) + await persistBearer(other, bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})) + + const loaded = await loadBearers(key) + expect(loaded.map(b => b.id)).toEqual(['mine']) + // the foreign ciphertext is still there, untouched + expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['foreign', 'mine']) + }) + + it('overwrites a record when the same id is persisted again', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const bearer = bearerFixture() + await persistBearer(key, bearer) + await persistBearer(key, {...bearer, spent: true, updatedAt: 2000}) + + const loaded = await loadBearers(key) + expect(loaded).toHaveLength(1) + expect(loaded[0]!.spent).toBe(true) + }) + + it('deletes a record by id and clears all', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const a = bearerFixture({id: 'a'}) + const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}) + await persistBearer(key, a) + await persistBearer(key, b) + + await deleteBearerRecord('a') + expect((await loadBearers(key)).map(x => x.id)).toEqual(['b']) + + clearAllBearers() + expect(readEncryptedBearers()).toEqual([]) + }) +}) + +describe('activity log', () => { + it('round-trips events newest-first', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistActivityEvent(key, {id: '1', kind: 'mint', message: 'a', createdAt: 1000}) + await persistActivityEvent(key, {id: '2', kind: 'melt', message: 'b', createdAt: 2000}) + + const loaded = await loadActivity(key) + expect(loaded.map(e => e.id)).toEqual(['2', '1']) + }) + + it('caps the log, rolling the oldest entries off', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + for (let i = 0; i < MAX_ACTIVITY_ENTRIES + 5; i++) { + await persistActivityEvent(key, { + id: `ev-${i}`, + kind: 'receive', + message: `event ${i}`, + createdAt: i + }) + } + const loaded = await loadActivity(key) + expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES) + // the five oldest rolled off; the newest is first + expect(loaded[0]!.id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`) + expect(loaded.at(-1)!.id).toBe('ev-5') + }, 30_000) +}) + +describe('mergeBearers (union by note id, spent-wins)', () => { + it('unions notes with distinct secrets', () => { + const a = bearerFixture({id: 'a'}) + const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}) + const merged = mergeBearers([a], [b]) + expect(merged.map(x => x.id).sort()).toEqual(['a', 'b']) + }) + + it('lets the spent copy of a note win over a still-spendable one', () => { + const spendable = bearerFixture({id: 'old-copy', updatedAt: 3000}) + const spent = bearerFixture({id: 'new-copy', spent: true, updatedAt: 1000}) + // same note (same server + k1), different record ids, and the spent + // copy is even the STALER one - spent still wins, or a restored backup + // would resurrect burned money + const merged = mergeBearers([spendable], [spent]) + expect(merged).toHaveLength(1) + expect(merged[0]!.id).toBe('new-copy') + expect(merged[0]!.spent).toBe(true) + }) + + it('keeps the newer copy when both agree on spent state', () => { + const stale = bearerFixture({id: 'stale', updatedAt: 1000, amount: 1}) + const fresh = bearerFixture({id: 'fresh', updatedAt: 2000, amount: 2}) + const merged = mergeBearers([stale], [fresh]) + expect(merged).toHaveLength(1) + expect(merged[0]!.id).toBe('fresh') + }) + + it('treats the same secret on different servers as different notes', () => { + const here = bearerFixture({id: 'here'}) + const there = bearerFixture({ + id: 'there', + url: buildNoteUrl('https://other.example/w', K1_A, 21_000) + }) + expect(mergeBearers([here], [there])).toHaveLength(2) + }) +}) + +describe('backup', () => { + it('never exports a plaintext linking key', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await saveLinkingKey(LINKING_KEY) // no password: stored plaintext + await persistBearer(key, bearerFixture()) + + const backup = buildBackup() + expect(backup.type).toBe('sattle-backup') + expect(backup.linkingKey).toBeUndefined() + expect(backup.bearers).toHaveLength(1) + }) + + it('exports the linking key when it is itself password-encrypted', async () => { + await saveLinkingKey(LINKING_KEY, 'hunter2') + const backup = buildBackup() + expect(backup.linkingKey?.enc).toBe(true) + }) + + it('merges a backup by record id - union, never overwrite', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'existing'})) + + // a backup holding the same record id plus a new one + const backup = buildBackup() + const incoming = { + ...backup, + bearers: [ + ...backup.bearers, + {id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)} + ] + } + const result = applyBackup(incoming) + expect(result.added).toBe(1) + expect(result.skipped).toBe(1) + expect(readEncryptedBearers().map(r => r.id).sort()).toEqual([ + 'existing', + 'from-backup' + ]) + }) + + it('restores the linking key only onto a device that has none', async () => { + await saveLinkingKey(LINKING_KEY, 'hunter2') + const backup = buildBackup() + + // same device: a key already exists, so the backup's key is skipped + const here = applyBackup(backup) + expect(here.linkingKeySkipped).toBe(true) + expect(here.linkingKeyRestored).toBe(false) + + // fresh device: the key installs + stubLocalStorage() + const fresh = applyBackup(backup) + expect(fresh.linkingKeyRestored).toBe(true) + expect(fresh.linkingKeySkipped).toBe(false) + }) + + it('rejects a file that is not a sattle backup', () => { + expect(() => applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []})).toThrow() + expect(() => applyBackup(null)).toThrow() + expect(() => applyBackup({type: 'sattle-backup', version: 2, bearers: []})).toThrow() + }) + + it('skips malformed records instead of failing the whole restore', () => { + const result = applyBackup({ + type: 'sattle-backup', + version: 1, + createdAt: 1, + bearers: [ + {id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, + {id: 42, iv: null, ciphertext: 'xx'}, + 'garbage' + ] + }) + expect(result.added).toBe(1) + expect(result.skipped).toBe(2) + }) +}) diff --git a/src/lnurlcash/storage.ts b/src/lnurlcash/storage.ts new file mode 100644 index 0000000..5ac33ca --- /dev/null +++ b/src/lnurlcash/storage.ts @@ -0,0 +1,45 @@ +// The storage layer's single import surface: encrypted persistence for +// bearer notes and the activity log (AES-GCM under a key derived from the +// linking key), plaintext registries for settings, and backup files. The +// trusted-mint registry is plaintext too but lives in trustedMints.ts - +// the Pinia mints store and this module's backup both use it. +// +// Split by concern; this façade re-exports everything: +// storage/bearers.ts - encrypted bearer records + mergeBearers +// storage/activityLog.ts - the append-only encrypted activity log +// storage/settings.ts - plaintext wallet settings +// storage/backup.ts - buildBackup / applyBackup + +export type {Bearer, NewBearer} from './types' + +export { + compareBearerOrder, + newBearerId, + readEncryptedBearers, + loadBearers, + persistBearer, + deleteBearerRecord, + clearAllBearers, + mergeBearers +} from './storage/bearers' +export type {EncryptedBearerRecord} from './storage/bearers' + +export { + newActivityId, + readEncryptedActivity, + loadActivity, + persistActivityEvent, + clearAllActivity, + MAX_ACTIVITY_ENTRIES +} from './storage/activityLog' +export type { + ActivityKind, + ActivityEvent, + EncryptedActivityRecord +} from './storage/activityLog' + +export {loadSettings, persistSettings, clearSettings} from './storage/settings' +export type {WalletSettings} from './storage/settings' + +export {buildBackup, applyBackup, MAX_BACKUP_FILE_BYTES} from './storage/backup' +export type {BackupFile, RestoreResult} from './storage/backup' diff --git a/src/lnurlcash/storage/activityLog.ts b/src/lnurlcash/storage/activityLog.ts new file mode 100644 index 0000000..25f139c --- /dev/null +++ b/src/lnurlcash/storage/activityLog.ts @@ -0,0 +1,94 @@ +// The encrypted activity log: one entry per important wallet action, +// AES-GCM under the same bearer key, append-only, capped so a wallet used +// for years doesn't grow localStorage without limit. + +import type {EncryptedRecordParts} from '../keys' +import {encryptRecord, decryptRecord} from '../keys' +import {withStorageLock} from '../storageLock' + +// `message` is the full human-readable sentence rather than structured +// fields the UI reassembles, so the log stays simple to read and to extend +// with new kinds later. +export type ActivityKind = + | 'mint' + | 'split' + | 'combine' + | 'melt' + | 'transfer' + | 'receive' + | 'spent' + | 'deleted' + +export type ActivityEvent = { + id: string + kind: ActivityKind + message: string + createdAt: number +} + +export type EncryptedActivityRecord = {id: string} & EncryptedRecordParts + +const ACTIVITY_STORAGE_KEY = 'sattle_activity' +// bounds how far back the log ever reaches - the oldest entries simply +// roll off once this many are kept +export const MAX_ACTIVITY_ENTRIES = 500 + +export const newActivityId = (): string => + Array.from(crypto.getRandomValues(new Uint8Array(8))) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + +export const readEncryptedActivity = (): EncryptedActivityRecord[] => { + const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY) + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +const writeEncryptedActivity = (records: EncryptedActivityRecord[]): void => { + localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records)) +} + +// same tolerance as loadBearers - an entry that fails to decrypt with this +// key (written by a different seed) is skipped, not destroyed +export const loadActivity = async ( + aesKey: CryptoKey +): Promise => { + const events: ActivityEvent[] = [] + for (const record of readEncryptedActivity()) { + try { + const event = await decryptRecord>( + aesKey, + record + ) + events.push({...event, id: record.id}) + } catch { + // undecryptable with this key - leave it in place + } + } + return events.sort((a, b) => b.createdAt - a.createdAt) +} + +// append-only (the log never edits or removes a single entry, only clears +// outright - see clearAllActivity) - records are stored oldest-first so +// trimming to the cap is just dropping off the front +export const persistActivityEvent = async ( + aesKey: CryptoKey, + event: ActivityEvent +): Promise => { + const {id, ...plain} = event + const parts = await encryptRecord(aesKey, plain) + await withStorageLock(ACTIVITY_STORAGE_KEY, () => { + const records = readEncryptedActivity() + records.push({id, ...parts}) + writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES)) + }) +} + +export const clearAllActivity = (): void => { + localStorage.removeItem(ACTIVITY_STORAGE_KEY) +} diff --git a/src/lnurlcash/storage/backup.ts b/src/lnurlcash/storage/backup.ts new file mode 100644 index 0000000..1e2b394 --- /dev/null +++ b/src/lnurlcash/storage/backup.ts @@ -0,0 +1,154 @@ +// Backup files: everything exactly as it sits in localStorage - bearer +// ciphertexts always, the linking-key record only when it is itself +// password-encrypted. A plaintext linking key never leaves the device in a +// backup; the seed phrase is the recovery path for it instead. Trusted +// mints are plain (not secret - a mintPubkey is public), included as-is. + +import type {StoredSecret} from '../keys' +import { + getSavedLinkingKeyStored, + savedKeyExists, + savedKeyIsEncrypted, + restoreLinkingKeyStored, + isValidStoredSecret +} from '../keys' +import type {TrustedMint} from '../trustedMints' +import {readTrustedMints, mergeTrustedMints} from '../trustedMints' +import type {EncryptedBearerRecord} from './bearers' +import {readEncryptedBearers, writeEncryptedBearers} from './bearers' + +export type BackupFile = { + type: 'sattle-backup' + version: 1 + createdAt: number + linkingKey?: StoredSecret + bearers: EncryptedBearerRecord[] + trustedMints?: TrustedMint[] +} + +export const buildBackup = (): BackupFile => { + const backup: BackupFile = { + type: 'sattle-backup', + version: 1, + createdAt: Date.now(), + bearers: readEncryptedBearers(), + trustedMints: readTrustedMints() + } + const storedKey = getSavedLinkingKeyStored() + if (savedKeyIsEncrypted() && storedKey) { + backup.linkingKey = storedKey + } + return backup +} + +export type RestoreResult = { + added: number + skipped: number + linkingKeyRestored: boolean + // true when the backup carried a linking key but this device already had + // one, so it was deliberately NOT installed (see below) - distinct from + // "no key in this backup at all". The bearer records above still merged + // in regardless, but they were encrypted under the backup's own seed, not + // whatever wallet is active on this device - unless that's the exact same + // seed, they won't decrypt here, and the caller should say so rather than + // let that read as a silent no-op. + linkingKeySkipped: boolean + trustedMintsAdded: number +} + +// restore-time bounds - a crafted or corrupt file must not be able to fill +// localStorage with junk records that never decrypt (quota exhaustion turns +// every later write into a failure, which can strand a just-rotated note), +// nor hang the tab in JSON.parse. A real backup holds a handful of notes, +// each well under a kilobyte encrypted, so these are generous +export const MAX_BACKUP_FILE_BYTES = 10 * 1024 * 1024 +const MAX_BACKUP_RECORDS = 10_000 +const MAX_BACKUP_FIELD_LENGTH = 64 * 1024 + +const isBackupFile = (data: unknown): data is BackupFile => { + if (typeof data !== 'object' || data === null) return false + const backup = data as Record + return ( + backup.type === 'sattle-backup' && + backup.version === 1 && + Array.isArray(backup.bearers) + ) +} + +// merges a backup into localStorage: bearer records are added by id +// (already present ids are left as-is - union, never overwrite), the +// backup's linking key is only installed when this device has none yet - +// never overwriting an existing wallet. That guard is deliberate (a +// stale/wrong backup must never clobber a wallet already holding funds), +// but it means restore order matters: a device that already has ANY wallet +// silently keeps its own key, and this backup's bearers merge into storage +// without ever becoming visible, since they don't decrypt under a +// different key. See linkingKeySkipped above. The note-level dedupe (same +// note arriving under a different record id, spent-wins) happens after +// decrypt, in bearers.ts's mergeBearers. +export const applyBackup = (data: unknown): RestoreResult => { + if (!isBackupFile(data)) { + throw new Error('Not a valid sattle backup file.') + } + const backup = data + const existing = readEncryptedBearers() + const existingIds = new Set(existing.map(r => r.id)) + if (backup.bearers.length > MAX_BACKUP_RECORDS) { + throw new Error( + `Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.` + ) + } + let added = 0 + let skipped = 0 + for (const record of backup.bearers) { + if ( + typeof record?.id !== 'string' || + typeof record?.iv !== 'string' || + typeof record?.ciphertext !== 'string' || + record.id.length > MAX_BACKUP_FIELD_LENGTH || + record.iv.length > MAX_BACKUP_FIELD_LENGTH || + record.ciphertext.length > MAX_BACKUP_FIELD_LENGTH + ) { + skipped++ + continue + } + if (existingIds.has(record.id)) { + skipped++ + continue + } + existing.push({id: record.id, iv: record.iv, ciphertext: record.ciphertext}) + existingIds.add(record.id) + added++ + } + try { + writeEncryptedBearers(existing) + } catch { + throw new Error( + 'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.' + ) + } + + let linkingKeyRestored = false + let linkingKeySkipped = false + // an invalid key record reads as "no key in this backup", never as skipped + if (backup.linkingKey && isValidStoredSecret(backup.linkingKey)) { + if (savedKeyExists()) { + linkingKeySkipped = true + } else { + restoreLinkingKeyStored(backup.linkingKey) + linkingKeyRestored = true + } + } + + const trustedMintsAdded = Array.isArray(backup.trustedMints) + ? mergeTrustedMints(backup.trustedMints) + : 0 + + return { + added, + skipped, + linkingKeyRestored, + linkingKeySkipped, + trustedMintsAdded + } +} diff --git a/src/lnurlcash/storage/bearers.ts b/src/lnurlcash/storage/bearers.ts new file mode 100644 index 0000000..87bad1f --- /dev/null +++ b/src/lnurlcash/storage/bearers.ts @@ -0,0 +1,117 @@ +// Encrypted bearer-note persistence: each note is an AES-GCM ciphertext +// record under a key derived from the linking key (see keys.ts), so a note +// URL - which IS the money - never touches disk in plaintext. + +import type {EncryptedRecordParts} from '../keys' +import {encryptRecord, decryptRecord} from '../keys' +import type {Bearer} from '../types' +import {noteK1, serverOf} from 'lnurlcash-kit' +import {withStorageLock} from '../storageLock' + +// the wallet's default note order (newest first) with manually dragged +// notes taking priority once they have an explicit rank +export const compareBearerOrder = (a: Bearer, b: Bearer): number => + (a.sortIndex ?? -a.createdAt) - (b.sortIndex ?? -b.createdAt) + +export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts + +const BEARERS_STORAGE_KEY = 'sattle_bearers' + +export const newBearerId = (): string => + Array.from(crypto.getRandomValues(new Uint8Array(8))) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + +export const readEncryptedBearers = (): EncryptedBearerRecord[] => { + const raw = localStorage.getItem(BEARERS_STORAGE_KEY) + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +export const writeEncryptedBearers = ( + records: EncryptedBearerRecord[] +): void => { + localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records)) +} + +// decrypts everything currently stored - a record that fails to decrypt +// (e.g. written by a different seed's key) is skipped, not destroyed: it +// stays in localStorage untouched and simply doesn't show up +export const loadBearers = async (aesKey: CryptoKey): Promise => { + const bearers: Bearer[] = [] + for (const record of readEncryptedBearers()) { + try { + const bearer = await decryptRecord>(aesKey, record) + bearers.push({...bearer, id: record.id}) + } catch { + // undecryptable with this key - leave it in place + } + } + return bearers.sort((a, b) => b.createdAt - a.createdAt) +} + +export const persistBearer = async ( + aesKey: CryptoKey, + bearer: Bearer +): Promise => { + const {id, ...plain} = bearer + const parts = await encryptRecord(aesKey, plain) + await withStorageLock(BEARERS_STORAGE_KEY, () => { + const records = readEncryptedBearers().filter(r => r.id !== id) + records.push({id, ...parts}) + writeEncryptedBearers(records) + }) +} + +export const deleteBearerRecord = async (id: string): Promise => { + await withStorageLock(BEARERS_STORAGE_KEY, () => { + writeEncryptedBearers(readEncryptedBearers().filter(r => r.id !== id)) + }) +} + +// wipes every bearer record from this device outright - unlike forgetting +// just the linking key, this is not recoverable by restoring the same seed: +// the ciphertexts themselves are gone, so only a previously downloaded +// backup file can bring them back +export const clearAllBearers = (): void => { + localStorage.removeItem(BEARERS_STORAGE_KEY) +} + +// Merge two decrypted bearer lists into one, keyed by note identity +// (issuing server + k1 secret), falling back to record id for notes whose +// k1 is absent (a paired-device mirror carries none). Union semantics with +// spent-wins: when both lists hold the same note, the copy locked as spent +// always survives over a still-spendable one - a spent note that "comes +// back" after a restore is how double-spends are born. Among copies in the +// same spent state, the newer updatedAt wins. This is the merge a restore +// (backup file now, nostr later) applies after its records decrypt, and it +// is what makes multi-device restores converge instead of duplicate. +export const mergeBearers = ( + current: Bearer[], + incoming: Bearer[] +): Bearer[] => { + const keyOf = (b: Bearer): string => { + const k1 = noteK1(b.url) + return k1 ? `${serverOf(b.url)}#${k1}` : `id#${b.id}` + } + const merged = new Map() + for (const bearer of [...current, ...incoming]) { + const key = keyOf(bearer) + const existing = merged.get(key) + if (!existing) { + merged.set(key, bearer) + continue + } + if (bearer.spent !== existing.spent) { + merged.set(key, bearer.spent ? bearer : existing) + continue + } + merged.set(key, bearer.updatedAt >= existing.updatedAt ? bearer : existing) + } + return [...merged.values()].sort((a, b) => b.createdAt - a.createdAt) +} diff --git a/src/lnurlcash/storage/settings.ts b/src/lnurlcash/storage/settings.ts new file mode 100644 index 0000000..e61a1ad --- /dev/null +++ b/src/lnurlcash/storage/settings.ts @@ -0,0 +1,33 @@ +// Wallet settings - plaintext, nothing secret (a default mint choice, a +// fiat display unit). Flat optional fields rather than a versioned +// envelope: absent keys just mean "never set". + +export type WalletSettings = { + defaultMint?: string +} + +const SETTINGS_STORAGE_KEY = 'sattle_settings' + +export const loadSettings = (): WalletSettings => { + const raw = localStorage.getItem(SETTINGS_STORAGE_KEY) + if (!raw) return {} + try { + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== 'object' || parsed === null) return {} + const s = parsed as Record + return { + defaultMint: + typeof s.defaultMint === 'string' ? s.defaultMint : undefined + } + } catch { + return {} + } +} + +export const persistSettings = (settings: WalletSettings): void => { + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)) +} + +export const clearSettings = (): void => { + localStorage.removeItem(SETTINGS_STORAGE_KEY) +} diff --git a/src/lnurlcash/storageLock.ts b/src/lnurlcash/storageLock.ts new file mode 100644 index 0000000..dc96281 --- /dev/null +++ b/src/lnurlcash/storageLock.ts @@ -0,0 +1,14 @@ +// serializes a read-modify-write cycle across tabs: localStorage access +// itself is per-tab synchronous, so two tabs interleaving read…write can +// lose each other's records (worst case: a stale tab overwrites a freshly +// persisted rotated note after its old k1 was burned). Falls back to +// running unlocked where Web Locks is unavailable (plain-Node tests, very +// old browsers). +export const withStorageLock = ( + name: string, + fn: () => T | Promise +): Promise => { + const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined + if (locks) return locks.request(name, fn) + return Promise.resolve(fn()) +} diff --git a/src/lnurlcash/test-utils.ts b/src/lnurlcash/test-utils.ts new file mode 100644 index 0000000..4e9697f --- /dev/null +++ b/src/lnurlcash/test-utils.ts @@ -0,0 +1,28 @@ +// Shared test scaffolding: an in-memory localStorage (Node has none outside +// a browser) plus the mock-mint helpers every suite uses. + +import {vi} from 'vitest' + +export class LocalStorageStub { + private map = new Map() + getItem = (key: string): string | null => this.map.get(key) ?? null + setItem = (key: string, value: string): void => { + this.map.set(key, String(value)) + } + removeItem = (key: string): void => { + this.map.delete(key) + } + clear = (): void => { + this.map.clear() + } + get length(): number { + return this.map.size + } + key = (index: number): string | null => [...this.map.keys()][index] ?? null +} + +export const stubLocalStorage = (): LocalStorageStub => { + const stub = new LocalStorageStub() + vi.stubGlobal('localStorage', stub) + return stub +} diff --git a/src/lnurlcash/trustedMints.test.ts b/src/lnurlcash/trustedMints.test.ts new file mode 100644 index 0000000..0d5e54f --- /dev/null +++ b/src/lnurlcash/trustedMints.test.ts @@ -0,0 +1,194 @@ +// Trusted-mint registry: key pinning, rekey staging, and backup merge +// rules. Runs against an in-memory localStorage stub. + +import {beforeEach, describe, expect, it} from 'vitest' + +import type {TrustedMint} from './trustedMints' +import { + PUBLIC_MINTS, + addTrustedMint, + clearTrustedMints, + confirmTrustedMintRekey, + dismissTrustedMintRekey, + getTrustedMintPubkey, + grandfatherTrustedMint, + isMintTrusted, + isMintUnconfirmed, + lockTrustedMint, + mergeTrustedMints, + readTrustedMints, + removeTrustedMint +} from './trustedMints' +import {stubLocalStorage} from './test-utils' + +const KEY_A = '02' + 'aa'.repeat(32) +const KEY_B = '03' + 'bb'.repeat(32) +const KEY_C = '02' + 'cc'.repeat(32) +const SERVER = 'mint.example' + +beforeEach(() => { + stubLocalStorage() + clearTrustedMints() +}) + +describe('pinning', () => { + it('locks a mint the first time a bearer is held from it', () => { + expect(lockTrustedMint(SERVER, KEY_A)).toBe('added') + expect(isMintTrusted(SERVER)).toBe(true) + expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + expect(readTrustedMints()[0]!.locked).toBe(true) + // same key again: silent no-op + expect(lockTrustedMint(SERVER, KEY_A)).toBe('unchanged') + }) + + it('rejects a malformed signing key without throwing', () => { + expect(lockTrustedMint(SERVER, 'not-a-key')).toBe('unchanged') + expect(isMintTrusted(SERVER)).toBe(false) + }) +}) + +describe('rekey staging', () => { + it('stages a differing advertised key for review, never auto-applies it', () => { + lockTrustedMint(SERVER, KEY_A) + + expect(lockTrustedMint(SERVER, KEY_B)).toBe('rekey-pending') + const mint = readTrustedMints()[0]! + // the staged candidate is visible, but the ORIGINAL pin is still + // authoritative - this is the entire point of the staging model + expect(mint.pendingMintPubkey).toBe(KEY_B) + expect(mint.mintPubkey).toBe(KEY_A) + expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + + // re-advertising the same pending key doesn't duplicate or escalate + expect(lockTrustedMint(SERVER, KEY_B)).toBe('rekey-pending') + expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A) + + // and a THIRD key replaces the staged candidate, still not the pin + expect(lockTrustedMint(SERVER, KEY_C)).toBe('rekey-pending') + expect(readTrustedMints()[0]!.pendingMintPubkey).toBe(KEY_C) + expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A) + }) + + it('promotes the staged key only on explicit holder confirmation', () => { + lockTrustedMint(SERVER, KEY_A) + lockTrustedMint(SERVER, KEY_B) + + confirmTrustedMintRekey(SERVER) + const mint = readTrustedMints()[0]! + expect(mint.mintPubkey).toBe(KEY_B) + expect(mint.pendingMintPubkey).toBeUndefined() + expect(getTrustedMintPubkey(SERVER)).toBe(KEY_B) + }) + + it('drops the staged key on dismissal, keeping the original pin', () => { + lockTrustedMint(SERVER, KEY_A) + lockTrustedMint(SERVER, KEY_B) + + dismissTrustedMintRekey(SERVER) + const mint = readTrustedMints()[0]! + expect(mint.pendingMintPubkey).toBeUndefined() + expect(mint.mintPubkey).toBe(KEY_A) + }) + + it('stages a rekey even through unlock-time grandfathering', () => { + grandfatherTrustedMint(SERVER, KEY_A) + expect(grandfatherTrustedMint(SERVER, KEY_B)).toBe('rekey-pending') + expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A) + }) +}) + +describe('grandfathering (storage-sourced claims)', () => { + it('adds an unknown server unlocked and unconfirmed', () => { + expect(grandfatherTrustedMint(SERVER, KEY_A)).toBe('added') + const mint = readTrustedMints()[0]! + expect(mint.locked).toBe(false) + expect(mint.unconfirmed).toBe(true) + // unconfirmed pins stay out of offline signature verification + expect(getTrustedMintPubkey(SERVER)).toBeNull() + expect(isMintUnconfirmed(SERVER)).toBe(true) + }) + + it('is corroborated and locked by a live response advertising the same key', () => { + grandfatherTrustedMint(SERVER, KEY_A) + expect(lockTrustedMint(SERVER, KEY_A)).toBe('unchanged') + const mint = readTrustedMints()[0]! + expect(mint.locked).toBe(true) + expect(mint.unconfirmed).toBeUndefined() + expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + }) +}) + +describe('manual add and removal', () => { + it('validates input instead of silently no-oping', () => { + expect(() => addTrustedMint('', KEY_A)).toThrow() + expect(() => addTrustedMint(SERVER, 'junk')).toThrow() + }) + + it('refuses to remove a mint locked by a held bearer', () => { + lockTrustedMint(SERVER, KEY_A) + expect(() => removeTrustedMint(SERVER)).toThrow(/bearer/) + expect(isMintTrusted(SERVER)).toBe(true) + }) + + it('removes an unlocked mint', () => { + addTrustedMint(SERVER, KEY_A) + removeTrustedMint(SERVER) + expect(isMintTrusted(SERVER)).toBe(false) + }) +}) + +describe('backup merge', () => { + const fromFile = (overrides: Record = {}) => ({ + server: 'backup-mint.example', + mintPubkey: KEY_B, + addedAt: 123, + locked: true, // must never survive a merge from a file + pendingMintPubkey: KEY_C, // must never survive either + nodeAlias: 'Backup Mint', + ...overrides + }) + + it('merges unknown servers as unlocked, unconfirmed, and without staged keys', () => { + expect(mergeTrustedMints([fromFile()])).toBe(1) + const mint = readTrustedMints()[0]! + expect(mint.server).toBe('backup-mint.example') + expect(mint.mintPubkey).toBe(KEY_B) + expect(mint.locked).toBe(false) + expect(mint.unconfirmed).toBe(true) + expect(mint.pendingMintPubkey).toBeUndefined() + expect(mint.nodeAlias).toBe('Backup Mint') + }) + + it('never overwrites a server this device already knows', () => { + lockTrustedMint(SERVER, KEY_A) + const added = mergeTrustedMints([fromFile({server: SERVER, mintPubkey: KEY_B})]) + expect(added).toBe(0) + expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + }) + + it('skips malformed entries', () => { + // JSON round-trip: a backup file's entries are runtime data, not + // compile-time TrustedMints - the merge must filter, not trust + const malformed: TrustedMint[] = JSON.parse( + JSON.stringify([ + fromFile({mintPubkey: 'not-hex'}), + fromFile({server: 42}), + null + ]) + ) + expect(mergeTrustedMints(malformed)).toBe(0) + expect(readTrustedMints()).toEqual([]) + }) +}) + +describe('PUBLIC_MINTS', () => { + it('is the curated quick-start list, ported verbatim from lnurl-wallet', () => { + expect(PUBLIC_MINTS).toEqual([ + '@mint.600.wtf', + '@lnurl.21mint.me', + '@mint.forgesworn.dev', + '@lnurl.21linz.at', + '@minty.exe.xyz' + ]) + }) +}) diff --git a/src/lnurlcash/trustedMints.ts b/src/lnurlcash/trustedMints.ts new file mode 100644 index 0000000..f942110 --- /dev/null +++ b/src/lnurlcash/trustedMints.ts @@ -0,0 +1,433 @@ +import type {MintAddressInfo} from 'lnurlcash-kit' + +// allow: SIZE_OK — one indivisible registry: every operation below reads +// and writes the same pinned-key cache through the same persist/notify +// path, and the file is a deliberate verbatim-behavior port of +// lnurl-wallet's trustedMints.ts so the two wallets' pinning semantics +// stay auditable side by side. + +// A mint's signing key (LUD-25 Offline verification's `mintPubkey`) - not a +// secret, just a public identity, so this is plain unencrypted localStorage, +// unlike bearer notes. Framework-free (the Pinia mints store subscribes via +// onTrustedMintsChange) so plain utility code - ops.ts's flows in +// particular - can touch it too, not just UI components. +export type TrustedMint = { + server: string + mintPubkey: string + addedAt: number + // true once a bearer is held from this server - trust then follows + // holding funds there, not a standalone opinion, so it can't be revoked + // by deleting it here (see removeTrustedMint) + locked: boolean + // true when this pin came from a backup file or a stored bearer's cached + // key rather than a live response from the server itself (see + // mergeTrustedMints / grandfatherTrustedMint): excluded from offline + // signature verification until a live response from this server + // advertises the same key (any lockTrustedMint/addTrustedMint match + // clears it) - a crafted backup could otherwise plant a pin for a mint it + // controls and forge "signed" badges on worthless notes + unconfirmed?: boolean + // a DIFFERENT signing key this mint has since advertised (via a note + // refresh, a lookup, etc) - staged for explicit holder review, never + // auto-applied. The pinned mintPubkey above stays authoritative until + // confirmTrustedMintRekey promotes this one; a key that silently rotated + // would defeat the entire pinning model (a compromised mint could sign + // unbacked notes that then show the "signed" badge). + pendingMintPubkey?: string + // best-effort node identity/capacity, cached from the mint-address + // discovery endpoint (see the kit's fetchMintAddress) purely for display - + // absent for a mint that doesn't support it, or one trusted before this + // wallet learned to ask. Never used for anything security-relevant; + // mintPubkey above remains the only thing a note's signature is ever + // checked against. + nodeAlias?: string + nodeColor?: string + nodeCapacityMsat?: number + nodeNumChannels?: number + nodeNumPeers?: number + // the local-part this mint was actually reached at ("mint" out of + // "mint@host" - see the kit's lightningAddressUsername), cached so a + // later quick-select can reconstruct the exact address instead of + // guessing "mint@" for a mint that uses a different one. Absent + // for a mint only ever looked up as a bech32 LNURL, which has no such + // concept. + username?: string +} + +// the subset of TrustedMint that's cacheable display metadata, as opposed +// to the server/mintPubkey/addedAt/locked fields every entry has regardless +export type TrustedMintNodeInfo = { + nodeAlias?: string + nodeColor?: string + nodeCapacityMsat?: number + nodeNumChannels?: number + nodeNumPeers?: number + username?: string +} + +// distills a mint-address lookup (see the kit's fetchMintAddress) down to +// just the cacheable display fields above - shared by every mint discovery +// flow, so all of them cache node info the same way rather than duplicating +// this shape-narrowing themselves. `username` is independent of whether the +// mint-address endpoint itself succeeded - it's cached even when info is +// null, since it comes straight from whichever payRequest URL was actually +// resolved, not from that endpoint's response. +export const mintAddressCacheInfo = ( + info: MintAddressInfo | null, + username: string | null +): TrustedMintNodeInfo | undefined => { + if (!info && !username) return undefined + return { + nodeAlias: info?.nodeAlias, + nodeColor: info?.nodeColor, + nodeCapacityMsat: info?.nodeCapacityMsat, + nodeNumChannels: info?.nodeNumChannels, + nodeNumPeers: info?.nodeNumPeers, + username: username ?? undefined + } +} + +// A small curated list of known public mints, for a one-click quick start - +// unrelated to whether any given entry ends up in the trusted-mints +// registry above (appearing here says nothing about a mint's signing key or +// whether this wallet has ever used it). The bare "@domain" form rather +// than spelling out "mint@domain" - still resolves to the exact same +// address, just how these mints tend to actually display their own. +// Ported verbatim from lnurl-wallet/src/trustedMints.ts. +export const PUBLIC_MINTS = [ + '@mint.600.wtf', + '@lnurl.21mint.me', + '@mint.forgesworn.dev', + '@lnurl.21linz.at', + '@minty.exe.xyz' +] + +const STORAGE_KEY = 'sattle_trusted_mints' + +// 33-byte compressed secp256k1 pubkey, hex +const PUBKEY_PATTERN = /^[0-9a-f]{66}$/ + +const readStored = (): TrustedMint[] => { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + // shape-check every entry - this is the wallet's own persisted state + // (so locked/pendingMintPubkey/unconfirmed are all kept), but a + // tampered or corrupt record must not plant junk entries + return parsed.filter( + (m): m is TrustedMint => + typeof m?.server === 'string' && + typeof m?.mintPubkey === 'string' && + PUBKEY_PATTERN.test(m.mintPubkey.toLowerCase()) && + typeof m?.addedAt === 'number' && + typeof m?.locked === 'boolean' + ) + } catch { + return [] + } +} + +// lazily initialized on first access: importing this module must not touch +// localStorage (plain-Node test environments have none until stubbed) +let cache: TrustedMint[] | null = null +const readCache = (): TrustedMint[] => { + cache ??= readStored() + return cache +} +const listeners = new Set<(mints: TrustedMint[]) => void>() + +// the Pinia mints store subscribes here to mirror the registry into +// reactive state; returns the unsubscribe +export const onTrustedMintsChange = ( + listener: (mints: TrustedMint[]) => void +): (() => void) => { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export const readTrustedMints = (): TrustedMint[] => readCache() + +const persist = (mints: TrustedMint[]): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(mints)) + cache = mints + for (const listener of listeners) listener(mints) +} + +export const isMintTrusted = (server: string): boolean => + readCache().some(m => m.server === server) + +export const getTrustedMintPubkey = (server: string): string | null => + readCache().find(m => m.server === server && !m.unconfirmed)?.mintPubkey ?? null + +// true when a server has a pin that came from a file/storage rather than a +// live response (see TrustedMint.unconfirmed) - callers should treat a +// bearer's own cached mintPubkey for such a server as equally +// uncorroborated +export const isMintUnconfirmed = (server: string): boolean => + readCache().some(m => m.server === server && m.unconfirmed) + +// this mint's self-reported node color, for tinting its notes' background - +// purely cosmetic. Mint-supplied, so it's only ever handed out as a plain +// hex color - anything else (a style sink can take far more than colors) is +// treated as absent +export const getTrustedMintNodeColor = (server: string): string | null => { + const color = readCache().find(m => m.server === server)?.nodeColor + return color && /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : null +} + +// the exact Lightning Address this mint was last reached at (see +// TrustedMint.username), for a quick-select that reconstructs it instead of +// guessing "mint@" - null for a mint with no cached username +// (looked up as a bech32 LNURL, or trusted before this wallet learned to +// remember one) +export const getTrustedMintAddress = (server: string): string | null => { + const username = readCache().find(m => m.server === server)?.username + return username ? `${username}@${server}` : null +} + +// what a lock/add attempt did with the advertised key - 'rekey-pending' is +// the security-relevant one: the mint advertised a DIFFERENT key than the +// pinned one, which was staged for review (pendingMintPubkey) instead of +// silently replacing it. Callers should surface that loudly. +export type TrustKeyResult = 'added' | 'unchanged' | 'rekey-pending' + +// Called whenever this wallet ends up holding (or already holds) a bearer +// from `server` - minting, receiving, splitting, merging all route through +// the wallet store's addBearers/updateBearer, which is where this gets +// called from. Per "a mint you have a bearer from is trusted by default", +// this never asks and can't be refused - it silently trusts (or upgrades an +// already-trusted-but-unlocked entry) and locks it against removal. The one +// thing it never does silently is CHANGE the pinned key: a differing +// advertised key is staged as pendingMintPubkey for the holder to confirm +// or dismiss (see confirmTrustedMintRekey). +export const lockTrustedMint = ( + server: string, + mintPubkey: string +): TrustKeyResult => { + const key = mintPubkey.trim().toLowerCase() + if (!server || !PUBKEY_PATTERN.test(key)) return 'unchanged' + const existing = readCache().find(m => m.server === server) + if (existing) { + if (existing.mintPubkey === key) { + if (existing.locked && !existing.unconfirmed) return 'unchanged' + // a match here is a live response from the server advertising this + // exact key - it corroborates an unconfirmed (file-sourced) pin + persist( + readCache().map(m => + m.server === server ? {...m, locked: true, unconfirmed: undefined} : m + ) + ) + return 'unchanged' + } + if (existing.pendingMintPubkey === key) return 'rekey-pending' + persist( + readCache().map(m => (m.server === server ? {...m, pendingMintPubkey: key} : m)) + ) + return 'rekey-pending' + } + persist([...readCache(), {server, mintPubkey: key, addedAt: Date.now(), locked: true}]) + return 'added' +} + +// unlock-time grandfathering of the mints behind already-stored bearers - +// the key claims come from local storage, not a live response, so an +// unknown server is added unlocked AND unconfirmed (excluded from signature +// verification until corroborated live, see TrustedMint.unconfirmed), and +// an existing entry is never locked or confirmed here. A differing claim +// still stages a rekey review. Live bearer operations go through +// lockTrustedMint instead, which is what corroborates and re-locks. +export const grandfatherTrustedMint = ( + server: string, + mintPubkey: string +): TrustKeyResult => { + const key = mintPubkey.trim().toLowerCase() + if (!server || !PUBKEY_PATTERN.test(key)) return 'unchanged' + const existing = readCache().find(m => m.server === server) + if (existing) { + if (existing.mintPubkey === key) return 'unchanged' + if (existing.pendingMintPubkey === key) return 'rekey-pending' + persist( + readCache().map(m => (m.server === server ? {...m, pendingMintPubkey: key} : m)) + ) + return 'rekey-pending' + } + persist([ + ...readCache(), + {server, mintPubkey: key, addedAt: Date.now(), locked: false, unconfirmed: true} + ]) + return 'added' +} + +// Manual add from the mints settings, or a user-confirmed first encounter - +// unlocked, since no bearer necessarily backs it yet. Validates and throws +// instead of silently no-op'ing, since a human is waiting on the result +// either way. `nodeInfo` is whatever the mint-address lookup (if any) +// turned up alongside this pubkey. Same rule as lockTrustedMint for a +// server already pinned with a DIFFERENT key: staged for review (nodeInfo +// still refreshes - it's display-only), never overwritten in place. +export const addTrustedMint = ( + server: string, + mintPubkey: string, + nodeInfo?: TrustedMintNodeInfo +): TrustKeyResult => { + const trimmedServer = server.trim() + const key = mintPubkey.trim().toLowerCase() + if (!trimmedServer) { + throw new Error('Enter a server.') + } + if (!PUBKEY_PATTERN.test(key)) { + throw new Error( + 'Signing key must be a 33-byte compressed pubkey (66 hex characters).' + ) + } + const existing = readCache().find(m => m.server === trimmedServer) + if (existing) { + if (existing.mintPubkey === key) { + // a match here is a live lookup corroborating the pin - it clears an + // unconfirmed (file-sourced) flag + persist( + readCache().map(m => + m.server === trimmedServer + ? {...m, ...nodeInfo, unconfirmed: undefined} + : m + ) + ) + return 'unchanged' + } + persist( + readCache().map(m => + m.server === trimmedServer + ? {...m, pendingMintPubkey: key, ...nodeInfo} + : m + ) + ) + return 'rekey-pending' + } + persist([ + ...readCache(), + { + server: trimmedServer, + mintPubkey: key, + addedAt: Date.now(), + locked: false, + ...nodeInfo + } + ]) + return 'added' +} + +// the holder confirms a mint's advertised new signing key - the pending key +// becomes the pinned one. Legitimate rotations (a mint moving to a new +// node) go through here; nothing else ever replaces a pin. +export const confirmTrustedMintRekey = (server: string): void => { + const existing = readCache().find(m => m.server === server) + if (!existing?.pendingMintPubkey) return + const pending = existing.pendingMintPubkey + persist( + readCache().map(m => + m.server === server + ? {...m, mintPubkey: pending, pendingMintPubkey: undefined, unconfirmed: undefined} + : m + ) + ) +} + +// the holder rejects the advertised new key - the staged candidate is +// dropped, the original pin stays. Worth doing only when the change is +// unexpected; the old key stays authoritative either way until confirmed. +export const dismissTrustedMintRekey = (server: string): void => { + if (!readCache().some(m => m.server === server)) return + persist( + readCache().map(m => + m.server === server ? {...m, pendingMintPubkey: undefined} : m + ) + ) +} + +// refreshes just the cached display info for a server already in the list - +// never touches mintPubkey/addedAt/locked, and no-ops for a server that +// isn't trusted yet (that's addTrustedMint's job, which takes the same info +// directly alongside the pubkey it's trusting for the first time). Called +// opportunistically whenever a lookup re-discovers a mint address for a +// mint this wallet already trusts, so the cache doesn't just freeze at +// whatever was known the moment trust was first established. +export const cacheTrustedMintNodeInfo = ( + server: string, + nodeInfo: TrustedMintNodeInfo +): void => { + if (!readCache().some(m => m.server === server)) return + persist(readCache().map(m => (m.server === server ? {...m, ...nodeInfo} : m))) +} + +// only succeeds for entries not backed by a held bearer - see +// TrustedMint.locked +export const removeTrustedMint = (server: string): void => { + const entry = readCache().find(m => m.server === server) + if (!entry) return + if (entry.locked) { + throw new Error("Can't remove - you hold a bearer note from this mint.") + } + persist(readCache().filter(m => m.server !== server)) +} + +// wipes the whole registry - part of forgetting a wallet: nothing about a +// wallet's mints (including otherwise-irremovable locked pins) should +// linger on the device after it +export const clearTrustedMints = (): void => { + localStorage.removeItem(STORAGE_KEY) + cache = [] + for (const listener of listeners) listener([]) +} + +// merges a backup's trusted mints in by server - a server already known on +// this device keeps its own current entry rather than being overwritten by +// the backup's (possibly stale) copy. Three fields never come across from a +// file: `locked` (a crafted backup could otherwise plant irremovable junk +// entries - real locks re-establish themselves from held bearers on live +// operations anyway) and `pendingMintPubkey` (a key rotation must be +// re-detected from the mint's own live responses, never staged by a file) - +// and every merged entry is marked `unconfirmed`, keeping it out of offline +// signature verification until a live response from that server advertises +// the same key (a crafted backup could otherwise forge "signed" badges) +export const mergeTrustedMints = (incoming: TrustedMint[]): number => { + const knownServers = new Set(readCache().map(m => m.server)) + const merged = [...readCache()] + let added = 0 + for (const mint of incoming) { + if ( + typeof mint?.server !== 'string' || + typeof mint?.mintPubkey !== 'string' || + typeof mint?.addedAt !== 'number' || + !PUBKEY_PATTERN.test(mint.mintPubkey.toLowerCase()) + ) { + continue + } + if (knownServers.has(mint.server)) continue + merged.push({ + server: mint.server, + mintPubkey: mint.mintPubkey.toLowerCase(), + addedAt: mint.addedAt, + locked: false, + unconfirmed: true, + nodeAlias: typeof mint.nodeAlias === 'string' ? mint.nodeAlias : undefined, + nodeColor: typeof mint.nodeColor === 'string' ? mint.nodeColor : undefined, + nodeCapacityMsat: + typeof mint.nodeCapacityMsat === 'number' + ? mint.nodeCapacityMsat + : undefined, + nodeNumChannels: + typeof mint.nodeNumChannels === 'number' + ? mint.nodeNumChannels + : undefined, + nodeNumPeers: + typeof mint.nodeNumPeers === 'number' ? mint.nodeNumPeers : undefined, + username: typeof mint.username === 'string' ? mint.username : undefined + }) + knownServers.add(mint.server) + added++ + } + if (added > 0) persist(merged) + return added +} diff --git a/src/lnurlcash/units.ts b/src/lnurlcash/units.ts new file mode 100644 index 0000000..b7c2542 --- /dev/null +++ b/src/lnurlcash/units.ts @@ -0,0 +1,21 @@ +// The protocol layer speaks msat everywhere (LUD-25 amounts, kit fee math); +// humans think in sats. These helpers are the only place the two meet. + +export const MSAT_PER_SAT = 1000 + +export const msatToSats = (msat: number): number => msat / MSAT_PER_SAT + +export const satsToMsat = (sats: number): number => + Math.round(sats * MSAT_PER_SAT) + +// rounds up to the next whole sat - for an msat amount about to be +// requested as an invoice, where sub-sat precision (e.g. from a mint fee's +// percentage cut, see grossUpForMintFee) isn't reliably payable +export const ceilMsatToSat = (msat: number): number => + Math.ceil(msat / MSAT_PER_SAT) * MSAT_PER_SAT + +// rounds down to the nearest whole sat - for a fee-adjusted amount shown +// as an upper bound: rounding up there would advertise a note value that +// isn't actually reachable +export const floorMsatToSat = (msat: number): number => + Math.floor(msat / MSAT_PER_SAT) * MSAT_PER_SAT