mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: wallet operations engine and encrypted storage with tests
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user