feat: wallet operations engine and encrypted storage with tests

This commit is contained in:
2026-08-19 21:06:17 +02:00
parent 6f966d5a2d
commit 1c9e7165e4
19 changed files with 2698 additions and 7 deletions
+408
View File
@@ -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<ReturnType<typeof createMockMint>>
const mints: Mint[] = []
const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
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<Bearer> => {
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<string> => {
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)
})
})
+35
View File
@@ -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'
+299
View File
@@ -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<CarveResult> => {
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<string, Bearer[]>()
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}
}
}
+220
View File
@@ -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<PreparedMint> => {
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<ClaimedNote> => {
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
}
+201
View File
@@ -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<PayResult> => {
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}
}
}
}
+81
View File
@@ -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<ClaimedNote> => {
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)
}
}
}
+67
View File
@@ -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<PollOptions> = {
intervalMs: 1000,
intervalCapMs: 5000,
maxWaitMs: 120_000
}
const sleep = (ms: number): Promise<void> =>
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<VerifyResult> => {
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.')
}
+12 -7
View File
@@ -7,7 +7,8 @@ import {
rotateNote, rotateNote,
withNewK1, withNewK1,
NoteSpentError, NoteSpentError,
NoteUnknownError NoteUnknownError,
PendingNoteError
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import type {Bearer, NewBearer} from './types' import type {Bearer, NewBearer} from './types'
@@ -42,12 +43,16 @@ export const receiveNote = async (
mintPubkey: info.mintPubkey mintPubkey: info.mintPubkey
} }
} catch (err) { } catch (err) {
// the service positively told us this k1 is dead - that's worth more // the service positively told us this k1 is dead, unknown, or locked
// than the sender's own claim, so don't paper over it with an // mid-melt (pending) - all definitive states the caller must surface
// unverified fallback the way an unreachable/unknown-shaped error // distinctly, so don't paper over them with an unverified fallback the
// below does. The caller (ReceiveDialog.tsx) surfaces this and never stores // way an unreachable/unknown-shaped error below does. The caller never
// the note. // stores the note in these cases.
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) { if (
err instanceof NoteSpentError ||
err instanceof NoteUnknownError ||
err instanceof PendingNoteError
) {
throw err throw err
} }
// service unreachable (or some other non-definitive failure) - fall // service unreachable (or some other non-definitive failure) - fall
+242
View File
@@ -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> = {}): 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)
})
})
+45
View File
@@ -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'
+94
View File
@@ -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<ActivityEvent[]> => {
const events: ActivityEvent[] = []
for (const record of readEncryptedActivity()) {
try {
const event = await decryptRecord<Omit<ActivityEvent, 'id'>>(
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<void> => {
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)
}
+154
View File
@@ -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<string, unknown>
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
}
}
+117
View File
@@ -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<Bearer[]> => {
const bearers: Bearer[] = []
for (const record of readEncryptedBearers()) {
try {
const bearer = await decryptRecord<Omit<Bearer, 'id'>>(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<void> => {
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<void> => {
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<string, Bearer>()
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)
}
+33
View File
@@ -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<string, unknown>
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)
}
+14
View File
@@ -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 = <T>(
name: string,
fn: () => T | Promise<T>
): Promise<T> => {
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined
if (locks) return locks.request(name, fn)
return Promise.resolve(fn())
}
+28
View File
@@ -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<string, string>()
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
}
+194
View File
@@ -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<string, unknown> = {}) => ({
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'
])
})
})
+433
View File
@@ -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@<server>" 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@<server>" - 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
}
+21
View File
@@ -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