= {
+ notes: 2,
+ mints: 1,
+ settings: 1,
+};
const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
switch (part) {
- case 'notes':
- return JSON.stringify({version: 1, bearers: payload})
+ case 'notes': {
+ const notes = payload as NotesBackupPayload;
+ return JSON.stringify({ version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost });
+ }
case 'mints':
- return JSON.stringify({version: 1, trustedMints: payload})
+ return JSON.stringify({ version: 1, trustedMints: payload });
case 'settings':
- return JSON.stringify({version: 1, settings: payload})
+ return JSON.stringify({ version: 1, settings: payload });
}
-}
+};
// signs one addressable backup event for a single part
export const buildBackupEvent = (
@@ -103,7 +125,7 @@ export const buildBackupEvent =
(
content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
},
secretKey,
- )
+ );
// one event per part present in `parts`, all sharing one timestamp
export const buildBackupEvents = (
@@ -111,33 +133,33 @@ export const buildBackupEvents = (
parts: Partial,
createdAt?: number,
): NostrEvent[] => {
- const at = createdAt ?? Math.floor(Date.now() / 1000)
- const events: NostrEvent[] = []
+ const at = createdAt ?? Math.floor(Date.now() / 1000);
+ const events: NostrEvent[] = [];
for (const part of BACKUP_PARTS) {
- const payload = parts[part]
- if (payload === undefined) continue
- events.push(buildBackupEvent(secretKey, part, payload, at))
+ const payload = parts[part];
+ if (payload === undefined) continue;
+ events.push(buildBackupEvent(secretKey, part, payload, at));
}
- return events
-}
+ return events;
+};
export type ParsedBackupEvent =
- | {part: 'notes'; bearers: EncryptedBearerRecord[]}
- | {part: 'mints'; trustedMints: TrustedMint[]}
- | {part: 'settings'; settings: WalletSettings}
+ | { part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record }
+ | { part: 'mints'; trustedMints: TrustedMint[] }
+ | { part: 'settings'; settings: WalletSettings };
// Light shape checks so parse returns typed values; the strict bounds
-// (record counts, field lengths, pubkey patterns) are enforced by
-// applyBackup / mergeTrustedMints on the restore path, same as file
+// (record counts, field lengths, counter ranges and caps) are enforced by
+// applyBackup / commitFundsRestore on the restore path, same as file
// backups.
const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
- if (typeof data !== 'object' || data === null) return null
- const envelope = data as Record
- if (envelope.version !== 1) return null
+ if (typeof data !== 'object' || data === null) return null;
+ const envelope = data as Record;
+ if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null;
switch (dTag) {
case 'notes': {
- if (!Array.isArray(envelope.bearers)) return null
- const bearers = envelope.bearers as unknown[]
+ if (!Array.isArray(envelope.bearers)) return null;
+ const bearers = envelope.bearers as unknown[];
if (
!bearers.every(
(r) =>
@@ -146,13 +168,20 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string',
)
) {
- return null
+ return null;
}
- return {part: 'notes', bearers: bearers as EncryptedBearerRecord[]}
+ if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null;
+ const counters = envelope.nextByHost as Record;
+ if (!Object.values(counters).every((v) => typeof v === 'number')) return null;
+ return {
+ part: 'notes',
+ bearers: bearers as EncryptedBearerRecord[],
+ nextByHost: counters as Record,
+ };
}
case 'mints': {
- if (!Array.isArray(envelope.trustedMints)) return null
- const mints = envelope.trustedMints as unknown[]
+ if (!Array.isArray(envelope.trustedMints)) return null;
+ const mints = envelope.trustedMints as unknown[];
if (
!mints.every(
(m) =>
@@ -160,22 +189,22 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
typeof (m as TrustedMint)?.mintPubkey === 'string',
)
) {
- return null
+ return null;
}
- return {part: 'mints', trustedMints: mints as TrustedMint[]}
+ return { part: 'mints', trustedMints: mints as TrustedMint[] };
}
case 'settings': {
if (typeof envelope.settings !== 'object' || envelope.settings === null) {
- return null
+ return null;
}
- const settings = envelope.settings as Record
+ const settings = envelope.settings as Record;
if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') {
- return null
+ return null;
}
- return {part: 'settings', settings: settings as WalletSettings}
+ return { part: 'settings', settings: settings as WalletSettings };
}
}
-}
+};
// Validates and decrypts one backup event. Returns null for anything that
// isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag,
@@ -185,23 +214,23 @@ export const parseBackupEvent = (
secretKey: Uint8Array,
event: NostrEvent,
): ParsedBackupEvent | null => {
- if (event.kind !== BACKUP_EVENT_KIND) return null
- if (event.pubkey !== getPublicKey(secretKey)) return null
- const dTag = dTagOf(event)
- if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null
- if (!verifyEvent(event)) return null
- if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null
- let plaintext: string
+ if (event.kind !== BACKUP_EVENT_KIND) return null;
+ if (event.pubkey !== getPublicKey(secretKey)) return null;
+ const dTag = dTagOf(event);
+ if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null;
+ if (!verifyEvent(event)) return null;
+ if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null;
+ let plaintext: string;
try {
- plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey))
+ plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey));
} catch {
- return null
+ return null;
}
- let data: unknown
+ let data: unknown;
try {
- data = JSON.parse(plaintext)
+ data = JSON.parse(plaintext);
} catch {
- return null
+ return null;
}
- return parsePayload(dTag, data)
-}
+ return parsePayload(dTag, data);
+};
diff --git a/src/lnurlcash/nostr/publisher.ts b/src/lnurlcash/nostr/publisher.ts
index 0fab0e2..63e7842 100644
--- a/src/lnurlcash/nostr/publisher.ts
+++ b/src/lnurlcash/nostr/publisher.ts
@@ -2,74 +2,76 @@
// edits (a mint plus a receive plus a reorder) collapse into one publish
// of the final state. Trailing-edge debounce with in-flight coalescing.
-import type {BackupPartPayload} from './events'
+import type { BackupPartPayload } from './events';
export type BackupPublisher = {
// record that local state changed - the latest snapshot wins, and only
// one publish fires per quiet window no matter how many changes landed
- schedule: (parts: Partial) => void
+ schedule: (parts: Partial) => void;
// publish any pending snapshot immediately (app backgrounding, logout)
- flush: () => Promise
+ flush: () => Promise;
// drop any pending snapshot without publishing
- cancel: () => void
-}
+ cancel: () => void;
+};
export type BackupPublisherOptions = {
- publish: (parts: Partial) => Promise
- delayMs: number
+ publish: (parts: Partial) => Promise;
+ delayMs: number;
// a debounced publish has no caller to throw to - errors surface here;
// the next scheduled change retries
- onError?: (error: unknown) => void
-}
+ onError?: (error: unknown) => void;
+};
export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => {
- let timer: ReturnType | null = null
- let pending: Partial | null = null
- let running: Promise | null = null
+ let timer: ReturnType | null = null;
+ let pending: Partial | null = null;
+ let running: Promise | null = null;
const clearTimer = (): void => {
if (timer !== null) {
- clearTimeout(timer)
- timer = null
+ clearTimeout(timer);
+ timer = null;
}
- }
+ };
const drain = async (): Promise => {
// a change can land mid-publish - keep looping until the newest
// snapshot is the one that's out
while (pending !== null) {
- const snapshot = pending
- pending = null
+ const snapshot = pending;
+ pending = null;
try {
- await options.publish(snapshot)
+ await options.publish(snapshot);
} catch (error) {
options.onError?.(
- error instanceof Error ? error : new Error('Backup publication failed.', {cause: error}),
- )
+ error instanceof Error
+ ? error
+ : new Error('Backup publication failed.', { cause: error }),
+ );
}
}
- }
+ };
const fire = (): Promise => {
- clearTimer()
+ clearTimer();
// an in-flight drain picks up anything pending itself - a second drain
// would double-publish the same snapshot
running ??= drain().finally(() => {
- running = null
- })
- return running
- }
+ running = null;
+ });
+ return running;
+ };
return {
schedule: (parts) => {
- pending = parts
- clearTimer()
- timer = setTimeout(() => void fire(), options.delayMs)
+ pending = parts;
+ clearTimer();
+ timer = setTimeout(() => void fire(), options.delayMs);
},
flush: fire,
cancel: () => {
- clearTimer()
- pending = null
+ clearTimer();
+ pending = null;
},
- }
-}
+ };
+};
diff --git a/src/lnurlcash/nostr/sync.ts b/src/lnurlcash/nostr/sync.ts
index 67515cb..3fac858 100644
--- a/src/lnurlcash/nostr/sync.ts
+++ b/src/lnurlcash/nostr/sync.ts
@@ -2,17 +2,17 @@
//
// v1 is single-device last-writer-wins at the relay (addressable events
// replace). Restore merges locally through storage/backup.ts's applyBackup
-// - the SAME entry point as file restore (union by record id, mints merged
-// unconfirmed, settings fill-only) - so the two restore paths can't drift
-// apart. The note-level dedupe (same note under different record ids,
-// spent-wins) happens after decrypt in bearers.ts's mergeBearers, exactly
-// as with a file backup.
+// - the SAME entry point as file restore (union by record id, counters
+// merged upward-only, mints merged unconfirmed, settings fill-only) - so the
+// two restore paths can't drift apart. The note-level dedupe (same note
+// under different record ids, spent-wins) happens after decrypt in
+// bearers.ts's mergeBearers, exactly as with a file backup.
-import type {RestoreResult} from '../storage/backup'
-import {applyBackup} from '../storage/backup'
-import {linkingPubKeyHex} from '../keys'
+import type { RestoreResult } from '../storage/backup';
+import { applyBackup } from '../storage/backup';
+import { linkingPubKeyHex } from '../keys';
-import type {BackupPart, BackupPartPayload, NostrEvent} from './events'
+import type { BackupPart, BackupPartPayload, NostrEvent } from './events';
import {
BACKUP_EVENT_KIND,
BACKUP_PARTS,
@@ -21,18 +21,18 @@ import {
deriveBackupKey,
dTagOf,
parseBackupEvent,
-} from './events'
-import type {BackupTransport} from './transport'
-import {defaultTransport} from './transport'
+} from './events';
+import type { BackupTransport } from './transport';
+import { defaultTransport } from './transport';
export type PublishBackupOptions = {
- transport?: BackupTransport
- createdAt?: number
-}
+ transport?: BackupTransport;
+ createdAt?: number;
+};
export type PublishBackupResult = {
- published: BackupPart[]
-}
+ published: BackupPart[];
+};
// builds and publishes one event per part present in `parts`
export const publishBackup = async (
@@ -41,26 +41,26 @@ export const publishBackup = async (
relays: string[],
options: PublishBackupOptions = {},
): Promise => {
- const at = options.createdAt ?? Math.floor(Date.now() / 1000)
- const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined)
- if (present.length === 0) return {published: []}
- const transport = options.transport ?? (await defaultTransport())
- const published: BackupPart[] = []
+ const at = options.createdAt ?? Math.floor(Date.now() / 1000);
+ const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined);
+ if (present.length === 0) return { published: [] };
+ const transport = options.transport ?? (await defaultTransport());
+ const published: BackupPart[] = [];
for (const part of present) {
- const payload = parts[part]
- if (payload === undefined) continue
- await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at))
- published.push(part)
+ const payload = parts[part];
+ if (payload === undefined) continue;
+ await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at));
+ published.push(part);
}
- return {published}
-}
+ return { published };
+};
export type FetchBackupOptions = {
// the pubkey alone can fetch the ciphertext, but only the key holder
// reads it - decryption is part of fetching
- secretKey: Uint8Array
- transport?: BackupTransport
-}
+ secretKey: Uint8Array;
+ transport?: BackupTransport;
+};
// fetches the newest valid event per d-tag and decrypts it. Honest relays
// already replace addressable events, but a stale or misbehaving relay may
@@ -71,49 +71,49 @@ export const fetchBackup = async (
relays: string[],
options: FetchBackupOptions,
): Promise> => {
- const transport = options.transport ?? (await defaultTransport())
+ const transport = options.transport ?? (await defaultTransport());
const events = await transport.fetch(relays, {
kinds: [BACKUP_EVENT_KIND],
authors: [pubkey],
- })
- const byTag = new Map()
+ });
+ const byTag = new Map();
for (const event of events) {
- const d = dTagOf(event)
- if (!d) continue
- const candidates = byTag.get(d)
- if (candidates) candidates.push(event)
- else byTag.set(d, [event])
+ const d = dTagOf(event);
+ if (!d) continue;
+ const candidates = byTag.get(d);
+ if (candidates) candidates.push(event);
+ else byTag.set(d, [event]);
}
- const parts: Partial = {}
+ const parts: Partial = {};
for (const candidates of byTag.values()) {
// a hostile relay may serve a tampered "newest" copy - walk
// newest-first and take the first that validates and decrypts
- candidates.sort((a, b) => b.created_at - a.created_at)
+ candidates.sort((a, b) => b.created_at - a.created_at);
for (const event of candidates) {
- const parsed = parseBackupEvent(options.secretKey, event)
- if (!parsed) continue
+ const parsed = parseBackupEvent(options.secretKey, event);
+ if (!parsed) continue;
switch (parsed.part) {
case 'notes':
- parts.notes = parsed.bearers
- break
+ parts.notes = { bearers: parsed.bearers, nextByHost: parsed.nextByHost };
+ break;
case 'mints':
- parts.mints = parsed.trustedMints
- break
+ parts.mints = parsed.trustedMints;
+ break;
case 'settings':
- parts.settings = parsed.settings
- break
+ parts.settings = parsed.settings;
+ break;
}
- break
+ break;
}
}
- return parts
-}
+ return parts;
+};
export type NostrRestoreResult = RestoreResult & {
// which d-tags carried a valid payload at all - lets the caller
// distinguish "nothing backed up yet" from "restored an empty wallet"
- found: BackupPart[]
-}
+ found: BackupPart[];
+};
// The restore path end to end: re-derive the backup key from the linking
// key (the holder already re-entered the seed phrase to get that far),
@@ -124,26 +124,27 @@ export type NostrRestoreResult = RestoreResult & {
export const restoreFromNostr = async (
linkingPrivKey: Uint8Array,
relays: string[],
- options: {transport?: BackupTransport} = {},
+ options: { transport?: BackupTransport } = {},
): Promise => {
- const secretKey = deriveBackupKey(linkingPrivKey)
+ const secretKey = deriveBackupKey(linkingPrivKey);
const parts = await fetchBackup(backupPubkey(secretKey), relays, {
secretKey,
transport: options.transport,
- })
+ });
const result = await applyBackup(
{
type: 'sattle-backup',
- version: 1,
+ version: 2,
createdAt: Date.now(),
- bearers: parts.notes ?? [],
+ bearers: parts.notes?.bearers ?? [],
+ nextByHost: parts.notes?.nextByHost ?? {},
trustedMints: parts.mints,
settings: parts.settings,
},
linkingPubKeyHex(linkingPrivKey),
- )
+ );
return {
...result,
found: BACKUP_PARTS.filter((part) => parts[part] !== undefined),
- }
-}
+ };
+};
diff --git a/src/lnurlcash/nostr/transport.ts b/src/lnurlcash/nostr/transport.ts
index b0df178..07ddc32 100644
--- a/src/lnurlcash/nostr/transport.ts
+++ b/src/lnurlcash/nostr/transport.ts
@@ -3,30 +3,30 @@
// lazily: merely importing the backup module must never open (or even
// reference) a WebSocket.
-import type {Filter as NostrFilter} from 'nostr-tools/filter'
+import type { Filter as NostrFilter } from 'nostr-tools/filter';
-import type {NostrEvent} from './events'
+import type { NostrEvent } from './events';
-export type {NostrFilter}
+export type { NostrFilter };
// publish throws when the event was accepted NOWHERE
export type BackupTransport = {
- publish: (relays: string[], event: NostrEvent) => Promise
- fetch: (relays: string[], filter: NostrFilter) => Promise
-}
+ publish: (relays: string[], event: NostrEvent) => Promise;
+ fetch: (relays: string[], filter: NostrFilter) => Promise;
+};
export const defaultTransport = async (): Promise => {
- const {SimplePool} = await import('nostr-tools/pool')
- const pool = new SimplePool()
+ const { SimplePool } = await import('nostr-tools/pool');
+ const pool = new SimplePool();
return {
publish: async (relays, event) => {
- const results = await Promise.allSettled(pool.publish(relays, event))
+ const results = await Promise.allSettled(pool.publish(relays, event));
// one honest relay keeping the event is enough - addressable events
// are re-publishable, and the next debounced publish retries anyway
if (!results.some((r) => r.status === 'fulfilled')) {
- throw new Error('No relay accepted the backup event.')
+ throw new Error('No relay accepted the backup event.');
}
},
fetch: (relays, filter) => pool.querySync(relays, filter),
- }
-}
+ };
+};
diff --git a/src/lnurlcash/nostrBackup.codec.cases.ts b/src/lnurlcash/nostrBackup.codec.cases.ts
index 8d84386..1dcd3e1 100644
--- a/src/lnurlcash/nostrBackup.codec.cases.ts
+++ b/src/lnurlcash/nostrBackup.codec.cases.ts
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
-import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
-import {bytesToHex} from '@noble/hashes/utils.js'
-import type {NostrEvent} from 'nostr-tools/core'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl} from 'lnurlcash-kit'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { bytesToHex } from '@noble/hashes/utils.js';
+import type { NostrEvent } from 'nostr-tools/core';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl } from 'lnurlcash-kit';
-import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
+import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
-} from './nostrBackup'
-import type {BackupPartPayload, BackupTransport} from './nostrBackup'
+} from './nostrBackup';
+import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
-} from './storage'
-import type {Bearer} from './types'
-import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './storage';
+import type { Bearer } from './types';
+import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
+import { requiredValue, stubLocalStorage } from './test-utils';
-const LINKING_KEY = new Uint8Array(32).fill(7)
-const OTHER_KEY = new Uint8Array(32).fill(9)
-const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
+const LINKING_KEY = new Uint8Array(32).fill(7);
+const OTHER_KEY = new Uint8Array(32).fill(9);
+const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
-const K1_A = 'aa'.repeat(32)
-const K1_B = 'bb'.repeat(32)
-const MINT_PUBKEY = 'ab'.repeat(33)
+const K1_A = 'aa'.repeat(32);
+const K1_B = 'bb'.repeat(32);
+const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
-const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
+const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
-})
+});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
- transport: BackupTransport
- events: NostrEvent[]
+ transport: BackupTransport;
+ events: NostrEvent[];
} => {
- const events: NostrEvent[] = []
+ const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
- events.push(event)
- return Promise.resolve()
+ events.push(event);
+ return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,114 +78,137 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
- }
- return {transport, events}
-}
+ };
+ return { transport, events };
+};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
- content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
+ content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
- stubLocalStorage()
-})
+ stubLocalStorage();
+});
afterEach(() => {
- vi.useRealTimers()
-})
+ vi.useRealTimers();
+});
describe('buildBackupEvent / parseBackupEvent', () => {
- const secretKey = deriveBackupKey(LINKING_KEY)
+ const secretKey = deriveBackupKey(LINKING_KEY);
const records = [
// long unique sentinel id: a short id like 'r1' randomly appears in
// base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext
// assertion below
- {id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
- ]
- const mints = [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true}]
- const settings = {defaultMint: 'mint.example'}
+ { id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) },
+ ];
+ const notes = { bearers: records, nextByHost: { 'mint.example': 3 } };
+ const mints = [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true }];
+ const settings = { defaultMint: 'mint.example' };
it('round-trips all three parts through build and parse', () => {
- const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000)
- expect(events).toHaveLength(3)
+ const events = buildBackupEvents(secretKey, { notes, mints, settings }, 1000);
+ expect(events).toHaveLength(3);
expect(events.map((e) => e.kind)).toEqual([
BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND,
- ])
+ ]);
expect(events.map((e) => e.tags)).toEqual([
[['d', 'notes']],
[['d', 'mints']],
[['d', 'settings']],
- ])
- expect(events.every((e) => e.pubkey === backupPubkey(secretKey))).toBe(true)
+ ]);
+ expect(events.every((e) => e.pubkey === backupPubkey(secretKey))).toBe(true);
expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({
part: 'notes',
bearers: records,
- })
+ nextByHost: { 'mint.example': 3 },
+ });
expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({
part: 'mints',
trustedMints: mints,
- })
+ });
expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({
part: 'settings',
settings,
- })
- })
+ });
+ });
it('leaves no plaintext in the payload', () => {
- const event = buildBackupEvent(secretKey, 'notes', records)
- expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a')
- expect(event.content).not.toContain('ciphertext')
- })
+ const event = buildBackupEvent(secretKey, 'notes', notes);
+ expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a');
+ expect(event.content).not.toContain('ciphertext');
+ });
it('builds events only for the parts present', () => {
- const events = buildBackupEvents(secretKey, {settings}, 1000)
- expect(events).toHaveLength(1)
- expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']])
- })
+ const events = buildBackupEvents(secretKey, { settings }, 1000);
+ expect(events).toHaveLength(1);
+ expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']]);
+ });
it('rejects a payload encrypted for a different key', () => {
- const event = buildBackupEvent(secretKey, 'settings', settings)
- expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull()
- })
+ const event = buildBackupEvent(secretKey, 'settings', settings);
+ expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull();
+ });
it('rejects the wrong kind', () => {
- const event = buildBackupEvent(secretKey, 'settings', settings)
- expect(parseBackupEvent(secretKey, {...event, kind: 30079})).toBeNull()
- })
+ const event = buildBackupEvent(secretKey, 'settings', settings);
+ expect(parseBackupEvent(secretKey, { ...event, kind: 30079 })).toBeNull();
+ });
it('rejects an unknown d-tag', () => {
- const event = buildBackupEvent(secretKey, 'settings', settings)
- expect(parseBackupEvent(secretKey, {...event, tags: [['d', 'secrets']]})).toBeNull()
- })
+ const event = buildBackupEvent(secretKey, 'settings', settings);
+ expect(parseBackupEvent(secretKey, { ...event, tags: [['d', 'secrets']] })).toBeNull();
+ });
it('rejects a modified ciphertext - the signature no longer matches', () => {
- const event = buildBackupEvent(secretKey, 'settings', settings)
- const tampered = {...event, content: tamperContent(event.content)}
- expect(parseBackupEvent(secretKey, tampered)).toBeNull()
- })
+ const event = buildBackupEvent(secretKey, 'settings', settings);
+ const tampered = { ...event, content: tamperContent(event.content) };
+ expect(parseBackupEvent(secretKey, tampered)).toBeNull();
+ });
it('rejects an event signed by a different key', () => {
- const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings)
- expect(parseBackupEvent(secretKey, foreign)).toBeNull()
- })
+ const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings);
+ expect(parseBackupEvent(secretKey, foreign)).toBeNull();
+ });
it('rejects a validly signed event whose payload is not a backup envelope', () => {
// a same-key event of the right kind and d-tag, but its decrypted
// content is not a version-1 envelope
- const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey))
+ const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
const event = finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: 1000,
tags: [['d', 'settings']],
- content: nip44v2.encrypt(JSON.stringify({version: 2, settings: {}}), conversationKey),
+ content: nip44v2.encrypt(JSON.stringify({ version: 2, settings: {} }), conversationKey),
},
secretKey,
- )
- expect(parseBackupEvent(secretKey, event)).toBeNull()
- })
-})
+ );
+ expect(parseBackupEvent(secretKey, event)).toBeNull();
+ });
+
+ it('rejects a legacy version-1 notes envelope and a v2 notes envelope without counters', () => {
+ const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
+ const build = (payload: unknown): NostrEvent =>
+ finalizeEvent(
+ {
+ kind: BACKUP_EVENT_KIND,
+ created_at: 1000,
+ tags: [['d', 'notes']],
+ content: nip44v2.encrypt(JSON.stringify(payload), conversationKey),
+ },
+ secretKey,
+ );
+ // v1 notes carried bare bearer arrays and no counters - pre-funds-document
+ expect(parseBackupEvent(secretKey, build({ version: 1, bearers: records }))).toBeNull();
+ expect(parseBackupEvent(secretKey, build({ version: 2, bearers: records }))).toBeNull();
+ // a v2 envelope whose counters are malformed is rejected, not coerced
+ expect(
+ parseBackupEvent(secretKey, build({ version: 2, bearers: records, nextByHost: 'junk' })),
+ ).toBeNull();
+ });
+});
diff --git a/src/lnurlcash/nostrBackup.keys.cases.ts b/src/lnurlcash/nostrBackup.keys.cases.ts
index 916c819..fd0b28d 100644
--- a/src/lnurlcash/nostrBackup.keys.cases.ts
+++ b/src/lnurlcash/nostrBackup.keys.cases.ts
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
-import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
-import {bytesToHex} from '@noble/hashes/utils.js'
-import type {NostrEvent} from 'nostr-tools/core'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl} from 'lnurlcash-kit'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { bytesToHex } from '@noble/hashes/utils.js';
+import type { NostrEvent } from 'nostr-tools/core';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl } from 'lnurlcash-kit';
-import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
+import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
-} from './nostrBackup'
-import type {BackupPartPayload, BackupTransport} from './nostrBackup'
+} from './nostrBackup';
+import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
-} from './storage'
-import type {Bearer} from './types'
-import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './storage';
+import type { Bearer } from './types';
+import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
+import { requiredValue, stubLocalStorage } from './test-utils';
-const LINKING_KEY = new Uint8Array(32).fill(7)
-const OTHER_KEY = new Uint8Array(32).fill(9)
-const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
+const LINKING_KEY = new Uint8Array(32).fill(7);
+const OTHER_KEY = new Uint8Array(32).fill(9);
+const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
-const K1_A = 'aa'.repeat(32)
-const K1_B = 'bb'.repeat(32)
-const MINT_PUBKEY = 'ab'.repeat(33)
+const K1_A = 'aa'.repeat(32);
+const K1_B = 'bb'.repeat(32);
+const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
-const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
+const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
-})
+});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
- transport: BackupTransport
- events: NostrEvent[]
+ transport: BackupTransport;
+ events: NostrEvent[];
} => {
- const events: NostrEvent[] = []
+ const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
- events.push(event)
- return Promise.resolve()
+ events.push(event);
+ return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,21 +78,21 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
- }
- return {transport, events}
-}
+ };
+ return { transport, events };
+};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
- content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
+ content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
- stubLocalStorage()
-})
+ stubLocalStorage();
+});
afterEach(() => {
- vi.useRealTimers()
-})
+ vi.useRealTimers();
+});
describe('deriveBackupKey', () => {
it('derives a stable key from the linking key', () => {
@@ -101,12 +101,12 @@ describe('deriveBackupKey', () => {
// derive a different pubkey and find nothing to restore
expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe(
'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9',
- )
- })
+ );
+ });
it('derives a different key from a different linking key', () => {
expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe(
bytesToHex(deriveBackupKey(LINKING_KEY)),
- )
- })
-})
+ );
+ });
+});
diff --git a/src/lnurlcash/nostrBackup.publisher.cases.ts b/src/lnurlcash/nostrBackup.publisher.cases.ts
index 88bf238..c90cecb 100644
--- a/src/lnurlcash/nostrBackup.publisher.cases.ts
+++ b/src/lnurlcash/nostrBackup.publisher.cases.ts
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
-import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
-import {bytesToHex} from '@noble/hashes/utils.js'
-import type {NostrEvent} from 'nostr-tools/core'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl} from 'lnurlcash-kit'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { bytesToHex } from '@noble/hashes/utils.js';
+import type { NostrEvent } from 'nostr-tools/core';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl } from 'lnurlcash-kit';
-import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
+import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
-} from './nostrBackup'
-import type {BackupPartPayload, BackupTransport} from './nostrBackup'
+} from './nostrBackup';
+import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
-} from './storage'
-import type {Bearer} from './types'
-import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './storage';
+import type { Bearer } from './types';
+import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
+import { requiredValue, stubLocalStorage } from './test-utils';
-const LINKING_KEY = new Uint8Array(32).fill(7)
-const OTHER_KEY = new Uint8Array(32).fill(9)
-const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
+const LINKING_KEY = new Uint8Array(32).fill(7);
+const OTHER_KEY = new Uint8Array(32).fill(9);
+const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
-const K1_A = 'aa'.repeat(32)
-const K1_B = 'bb'.repeat(32)
-const MINT_PUBKEY = 'ab'.repeat(33)
+const K1_A = 'aa'.repeat(32);
+const K1_B = 'bb'.repeat(32);
+const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
-const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
+const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
-})
+});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
- transport: BackupTransport
- events: NostrEvent[]
+ transport: BackupTransport;
+ events: NostrEvent[];
} => {
- const events: NostrEvent[] = []
+ const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
- events.push(event)
- return Promise.resolve()
+ events.push(event);
+ return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,134 +78,137 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
- }
- return {transport, events}
-}
+ };
+ return { transport, events };
+};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
- content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
+ content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
- stubLocalStorage()
-})
+ stubLocalStorage();
+});
afterEach(() => {
- vi.useRealTimers()
-})
+ vi.useRealTimers();
+});
describe('createBackupPublisher', () => {
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
- vi.useFakeTimers()
- const published: Partial[] = []
+ vi.useFakeTimers();
+ const published: Partial[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
- published.push(p)
- return Promise.resolve()
+ published.push(p);
+ return Promise.resolve();
},
delayMs: 1000,
- })
+ });
- publisher.schedule({settings: {defaultMint: 'a'}})
- publisher.schedule({settings: {defaultMint: 'b'}})
- publisher.schedule({settings: {defaultMint: 'c'}})
- await vi.advanceTimersByTimeAsync(999)
- expect(published).toEqual([])
- await vi.advanceTimersByTimeAsync(1)
- expect(published).toEqual([{settings: {defaultMint: 'c'}}])
- })
+ publisher.schedule({ settings: { defaultMint: 'a' } });
+ publisher.schedule({ settings: { defaultMint: 'b' } });
+ publisher.schedule({ settings: { defaultMint: 'c' } });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(published).toEqual([]);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(published).toEqual([{ settings: { defaultMint: 'c' } }]);
+ });
it('publishes again when a change lands after the quiet window', async () => {
- vi.useFakeTimers()
- const published: Partial[] = []
+ vi.useFakeTimers();
+ const published: Partial[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
- published.push(p)
- return Promise.resolve()
+ published.push(p);
+ return Promise.resolve();
},
delayMs: 1000,
- })
+ });
- publisher.schedule({settings: {defaultMint: 'a'}})
- await vi.advanceTimersByTimeAsync(1000)
- publisher.schedule({settings: {defaultMint: 'b'}})
- await vi.advanceTimersByTimeAsync(1000)
- expect(published).toEqual([{settings: {defaultMint: 'a'}}, {settings: {defaultMint: 'b'}}])
- })
+ publisher.schedule({ settings: { defaultMint: 'a' } });
+ await vi.advanceTimersByTimeAsync(1000);
+ publisher.schedule({ settings: { defaultMint: 'b' } });
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(published).toEqual([
+ { settings: { defaultMint: 'a' } },
+ { settings: { defaultMint: 'b' } },
+ ]);
+ });
it('publishes a snapshot that lands mid-publish instead of losing it', async () => {
- vi.useFakeTimers()
- const published: Partial[] = []
+ vi.useFakeTimers();
+ const published: Partial[] = [];
// the publish callback re-schedules on the publisher being created -
// a holder indirection keeps both const
- const holder: {publisher?: ReturnType} = {}
+ const holder: { publisher?: ReturnType } = {};
const publisher = createBackupPublisher({
publish: (p) => {
- published.push(p)
+ published.push(p);
// a local change lands while the first publish is in flight
if (published.length === 1) {
- holder.publisher?.schedule({settings: {defaultMint: 'mid-flight'}})
+ holder.publisher?.schedule({ settings: { defaultMint: 'mid-flight' } });
}
- return Promise.resolve()
+ return Promise.resolve();
},
delayMs: 1000,
- })
- holder.publisher = publisher
+ });
+ holder.publisher = publisher;
- publisher.schedule({settings: {defaultMint: 'first'}})
- await vi.advanceTimersByTimeAsync(1000)
+ publisher.schedule({ settings: { defaultMint: 'first' } });
+ await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([
- {settings: {defaultMint: 'first'}},
- {settings: {defaultMint: 'mid-flight'}},
- ])
- })
+ { settings: { defaultMint: 'first' } },
+ { settings: { defaultMint: 'mid-flight' } },
+ ]);
+ });
it('flush publishes immediately; cancel drops the pending snapshot', async () => {
- vi.useFakeTimers()
- const published: Partial[] = []
+ vi.useFakeTimers();
+ const published: Partial[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
- published.push(p)
- return Promise.resolve()
+ published.push(p);
+ return Promise.resolve();
},
delayMs: 60_000,
- })
+ });
- publisher.schedule({settings: {defaultMint: 'a'}})
- await publisher.flush()
- expect(published).toEqual([{settings: {defaultMint: 'a'}}])
+ publisher.schedule({ settings: { defaultMint: 'a' } });
+ await publisher.flush();
+ expect(published).toEqual([{ settings: { defaultMint: 'a' } }]);
- publisher.schedule({settings: {defaultMint: 'b'}})
- publisher.cancel()
- await vi.advanceTimersByTimeAsync(60_000)
- expect(published).toHaveLength(1)
- })
+ publisher.schedule({ settings: { defaultMint: 'b' } });
+ publisher.cancel();
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(published).toHaveLength(1);
+ });
it('reports a failed publish via onError and retries on the next change', async () => {
- vi.useFakeTimers()
- const published: Partial[] = []
- const errors: unknown[] = []
- let failing = true
+ vi.useFakeTimers();
+ const published: Partial[] = [];
+ const errors: unknown[] = [];
+ let failing = true;
const publisher = createBackupPublisher({
publish: (p) => {
- if (failing) return Promise.reject(new Error('relay down'))
- published.push(p)
- return Promise.resolve()
+ if (failing) return Promise.reject(new Error('relay down'));
+ published.push(p);
+ return Promise.resolve();
},
delayMs: 1000,
onError: (e) => {
- errors.push(e)
+ errors.push(e);
},
- })
+ });
- publisher.schedule({settings: {defaultMint: 'a'}})
- await vi.advanceTimersByTimeAsync(1000)
- expect(published).toEqual([])
- expect(errors).toHaveLength(1)
+ publisher.schedule({ settings: { defaultMint: 'a' } });
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(published).toEqual([]);
+ expect(errors).toHaveLength(1);
- failing = false
- publisher.schedule({settings: {defaultMint: 'b'}})
- await vi.advanceTimersByTimeAsync(1000)
- expect(published).toEqual([{settings: {defaultMint: 'b'}}])
- })
-})
+ failing = false;
+ publisher.schedule({ settings: { defaultMint: 'b' } });
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(published).toEqual([{ settings: { defaultMint: 'b' } }]);
+ });
+});
diff --git a/src/lnurlcash/nostrBackup.restore.cases.ts b/src/lnurlcash/nostrBackup.restore.cases.ts
index fc394e7..c43d790 100644
--- a/src/lnurlcash/nostrBackup.restore.cases.ts
+++ b/src/lnurlcash/nostrBackup.restore.cases.ts
@@ -2,14 +2,19 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
-import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
-import {bytesToHex} from '@noble/hashes/utils.js'
-import type {NostrEvent} from 'nostr-tools/core'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl} from 'lnurlcash-kit'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { bytesToHex } from '@noble/hashes/utils.js';
+import type { NostrEvent } from 'nostr-tools/core';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl } from 'lnurlcash-kit';
-import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
+import {
+ deriveBearerAesKey,
+ deriveWalletMaterial,
+ linkingPubKeyHex,
+ saveWalletMaterial,
+} from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +26,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
-} from './nostrBackup'
-import type {BackupPartPayload, BackupTransport} from './nostrBackup'
+} from './nostrBackup';
+import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +35,30 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
-} from './storage'
-import type {Bearer} from './types'
-import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
-import {requiredValue, stubLocalStorage} from './test-utils'
+ readNextByHost,
+} from './storage';
+import { readFundsDocument, writeFundsDocument } from './storage/bearers';
+import type { Bearer } from './types';
+import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
+import { requiredValue, stubLocalStorage } from './test-utils';
-const LINKING_KEY = new Uint8Array(32).fill(7)
-const OTHER_KEY = new Uint8Array(32).fill(9)
-const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
+const LINKING_KEY = new Uint8Array(32).fill(7);
+const OTHER_KEY = new Uint8Array(32).fill(9);
+const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
+// full v2 material for the fixed harness linking key (real BIP-32 cash root)
+const MATERIAL = {
+ ...deriveWalletMaterial(
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
+ ),
+ linkingKeyHex: bytesToHex(LINKING_KEY),
+};
-const K1_A = 'aa'.repeat(32)
-const K1_B = 'bb'.repeat(32)
-const MINT_PUBKEY = 'ab'.repeat(33)
+const K1_A = 'aa'.repeat(32);
+const K1_B = 'bb'.repeat(32);
+const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
-const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
+const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +69,20 @@ const bearerFixture = (overrides: Partial = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
-})
+});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
- transport: BackupTransport
- events: NostrEvent[]
+ transport: BackupTransport;
+ events: NostrEvent[];
} => {
- const events: NostrEvent[] = []
+ const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
- events.push(event)
- return Promise.resolve()
+ events.push(event);
+ return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,146 +92,211 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
- }
- return {transport, events}
-}
+ };
+ return { transport, events };
+};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
- content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
+ content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
- stubLocalStorage()
-})
+ stubLocalStorage();
+});
afterEach(() => {
- vi.useRealTimers()
-})
+ vi.useRealTimers();
+});
describe('restoreFromNostr', () => {
it('restores notes, mints and settings onto a fresh device through applyBackup', async () => {
- const aesKey = await deriveBearerAesKey(LINKING_KEY)
- const secretKey = deriveBackupKey(LINKING_KEY)
- const {transport} = createRecordingTransport()
+ const aesKey = await deriveBearerAesKey(LINKING_KEY);
+ const secretKey = deriveBackupKey(LINKING_KEY);
+ const { transport } = createRecordingTransport();
// device A: one note, one trusted mint, one setting - all published
- await saveLinkingKey(LINKING_KEY)
- await persistBearer(aesKey, bearerFixture({id: 'note-a'}))
- await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID})
- persistSettings({defaultMint: 'mint.example'})
+ await saveWalletMaterial(MATERIAL);
+ await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
+ await addTrustedMint('mint.example', MINT_PUBKEY, { ownerId: OWNER_ID });
+ persistSettings({ defaultMint: 'mint.example' });
await publishBackup(
secretKey,
{
- notes: readEncryptedBearers(),
+ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
mints: readTrustedMints(OWNER_ID),
settings: loadSettings(),
},
RELAYS,
- {transport},
- )
+ { transport },
+ );
// device B: the same seed on empty storage
- stubLocalStorage()
- await saveLinkingKey(LINKING_KEY)
- const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
+ stubLocalStorage();
+ await saveWalletMaterial(MATERIAL);
+ const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
- expect(result.found).toEqual(['notes', 'mints', 'settings'])
- expect(result.added).toBe(1)
- expect(result.trustedMintsAdded).toBe(1)
- expect(result.settingsRestored).toBe(true)
+ expect(result.found).toEqual(['notes', 'mints', 'settings']);
+ expect(result.added).toBe(1);
+ expect(result.trustedMintsAdded).toBe(1);
+ expect(result.settingsRestored).toBe(true);
// the linking key is never part of a nostr backup - the seed phrase
// the holder entered is its recovery path
- expect(result.linkingKeyRestored).toBe(false)
+ expect(result.linkingKeyRestored).toBe(false);
// the note decrypts under this device's bearer key - same seed
- expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})])
- expect(loadSettings()).toEqual({defaultMint: 'mint.example'})
+ expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
+ expect(loadSettings()).toEqual({ defaultMint: 'mint.example' });
// a file/backup-sourced mint pin stays unconfirmed until a live
// response corroborates it - nostr restore inherits that rule from
// applyBackup unchanged
- expect(isMintUnconfirmed('mint.example', OWNER_ID)).toBe(true)
- })
+ expect(isMintUnconfirmed('mint.example', OWNER_ID)).toBe(true);
+ });
it('unions records by id and lets a spent copy win after decrypt', async () => {
- const aesKey = await deriveBearerAesKey(LINKING_KEY)
- const secretKey = deriveBackupKey(LINKING_KEY)
- const {transport} = createRecordingTransport()
+ const aesKey = await deriveBearerAesKey(LINKING_KEY);
+ const secretKey = deriveBackupKey(LINKING_KEY);
+ const { transport } = createRecordingTransport();
// device A publishes its store holding the spendable note
- await persistBearer(aesKey, bearerFixture({id: 'rec-a'}))
- await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, {
- transport,
- createdAt: 1000,
- })
+ await persistBearer(aesKey, bearerFixture({ id: 'rec-a' }));
+ await publishBackup(
+ secretKey,
+ { notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
+ RELAYS,
+ {
+ transport,
+ createdAt: 1000,
+ },
+ );
// device B restores, then marks the same note spent under its OWN
// record id, and republishes its full store
- stubLocalStorage()
- await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
- await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000}))
- await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, {
- transport,
- createdAt: 2000,
- })
+ stubLocalStorage();
+ await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
+ await persistBearer(aesKey, bearerFixture({ id: 'rec-b', spent: true, updatedAt: 2000 }));
+ await publishBackup(
+ secretKey,
+ { notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
+ RELAYS,
+ {
+ transport,
+ createdAt: 2000,
+ },
+ );
// device C restores from the final published state
- stubLocalStorage()
- const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
+ stubLocalStorage();
+ const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
// union by record id: both copies landed
- expect(result.added).toBe(2)
+ expect(result.added).toBe(2);
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
- ).toEqual(['rec-a', 'rec-b'])
+ ).toEqual(['rec-a', 'rec-b']);
// after decrypt, the note-level merge (same server + k1) collapses
// them, and the spent copy wins even though its record is the newer
// arrival - a restored backup must never resurrect spendable money
- const merged = mergeBearers([], await loadBearers(aesKey))
- expect(merged).toHaveLength(1)
- expect(requiredValue(merged[0]).id).toBe('rec-b')
- expect(requiredValue(merged[0]).spent).toBe(true)
- })
+ const merged = mergeBearers([], await loadBearers(aesKey));
+ expect(merged).toHaveLength(1);
+ expect(requiredValue(merged[0]).id).toBe('rec-b');
+ expect(requiredValue(merged[0]).spent).toBe(true);
+ });
it('never overwrites local state: records union, settings keep local values', async () => {
- const aesKey = await deriveBearerAesKey(LINKING_KEY)
- const secretKey = deriveBackupKey(LINKING_KEY)
- const {transport} = createRecordingTransport()
+ const aesKey = await deriveBearerAesKey(LINKING_KEY);
+ const secretKey = deriveBackupKey(LINKING_KEY);
+ const { transport } = createRecordingTransport();
- await persistBearer(aesKey, bearerFixture({id: 'remote'}))
- persistSettings({defaultMint: 'remote.example'})
+ await persistBearer(aesKey, bearerFixture({ id: 'remote' }));
+ persistSettings({ defaultMint: 'remote.example' });
await publishBackup(
secretKey,
- {notes: readEncryptedBearers(), settings: loadSettings()},
+ {
+ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
+ settings: loadSettings(),
+ },
RELAYS,
- {transport, createdAt: 1000},
- )
+ { transport, createdAt: 1000 },
+ );
// this device already has its own wallet state
- stubLocalStorage()
+ stubLocalStorage();
await persistBearer(
aesKey,
- bearerFixture({id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}),
- )
- persistSettings({defaultMint: 'local.example'})
+ bearerFixture({ id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }),
+ );
+ persistSettings({ defaultMint: 'local.example' });
- const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
- expect(result.added).toBe(1)
+ const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
+ expect(result.added).toBe(1);
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
- ).toEqual(['local', 'remote'])
- expect(result.settingsRestored).toBe(false)
- expect(loadSettings()).toEqual({defaultMint: 'local.example'})
- })
+ ).toEqual(['local', 'remote']);
+ expect(result.settingsRestored).toBe(false);
+ expect(loadSettings()).toEqual({ defaultMint: 'local.example' });
+ });
it('reports nothing found when the relays hold no backup', async () => {
- const {transport} = createRecordingTransport()
- const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
- expect(result.found).toEqual([])
- expect(result.added).toBe(0)
- })
-})
+ const { transport } = createRecordingTransport();
+ const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
+ expect(result.found).toEqual([]);
+ expect(result.added).toBe(0);
+ });
+
+ it('merges relayed counters upward-only and never carries pending state', async () => {
+ const aesKey = await deriveBearerAesKey(LINKING_KEY);
+ const secretKey = deriveBackupKey(LINKING_KEY);
+ const { transport } = createRecordingTransport();
+
+ // device A: counter at 5 for mint.example, one note, all published
+ await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
+ writeFundsDocument({
+ version: 2,
+ bearers: readEncryptedBearers(),
+ pending: [
+ {
+ id: 'device-local-pending',
+ kind: 'cash-allocation',
+ phase: 'reserved',
+ iv: '00',
+ ciphertext: '00',
+ },
+ ],
+ nextByHost: { 'mint.example': 5 },
+ revision: 3,
+ });
+ await publishBackup(
+ secretKey,
+ { notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
+ RELAYS,
+ { transport, createdAt: 1000 },
+ );
+
+ // device B: same seed, a HIGHER local counter for one host and a lower
+ // incoming one for the other; the pending record must not cross over
+ stubLocalStorage();
+ writeFundsDocument({
+ version: 2,
+ bearers: [],
+ pending: [],
+ nextByHost: { 'mint.example': 2, 'local-only.example': 7 },
+ revision: 1,
+ });
+
+ const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
+
+ expect(result.found).toEqual(['notes']);
+ expect(result.added).toBe(1);
+ expect(readFundsDocument().nextByHost).toEqual({
+ 'mint.example': 5,
+ 'local-only.example': 7,
+ });
+ expect(readFundsDocument().pending).toEqual([]);
+ expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
+ });
+});
diff --git a/src/lnurlcash/nostrBackup.test.ts b/src/lnurlcash/nostrBackup.test.ts
index 735fdfc..3103803 100644
--- a/src/lnurlcash/nostrBackup.test.ts
+++ b/src/lnurlcash/nostrBackup.test.ts
@@ -1,5 +1,5 @@
-import './nostrBackup.keys.cases'
-import './nostrBackup.codec.cases'
-import './nostrBackup.transport.cases'
-import './nostrBackup.restore.cases'
-import './nostrBackup.publisher.cases'
+import './nostrBackup.keys.cases';
+import './nostrBackup.codec.cases';
+import './nostrBackup.transport.cases';
+import './nostrBackup.restore.cases';
+import './nostrBackup.publisher.cases';
diff --git a/src/lnurlcash/nostrBackup.transport.cases.ts b/src/lnurlcash/nostrBackup.transport.cases.ts
index ab3873f..39b25cb 100644
--- a/src/lnurlcash/nostrBackup.transport.cases.ts
+++ b/src/lnurlcash/nostrBackup.transport.cases.ts
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
-import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
-import {bytesToHex} from '@noble/hashes/utils.js'
-import type {NostrEvent} from 'nostr-tools/core'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl} from 'lnurlcash-kit'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { bytesToHex } from '@noble/hashes/utils.js';
+import type { NostrEvent } from 'nostr-tools/core';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl } from 'lnurlcash-kit';
-import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
+import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
-} from './nostrBackup'
-import type {BackupPartPayload, BackupTransport} from './nostrBackup'
+} from './nostrBackup';
+import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
-} from './storage'
-import type {Bearer} from './types'
-import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './storage';
+import type { Bearer } from './types';
+import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
+import { requiredValue, stubLocalStorage } from './test-utils';
-const LINKING_KEY = new Uint8Array(32).fill(7)
-const OTHER_KEY = new Uint8Array(32).fill(9)
-const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
+const LINKING_KEY = new Uint8Array(32).fill(7);
+const OTHER_KEY = new Uint8Array(32).fill(9);
+const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
-const K1_A = 'aa'.repeat(32)
-const K1_B = 'bb'.repeat(32)
-const MINT_PUBKEY = 'ab'.repeat(33)
+const K1_A = 'aa'.repeat(32);
+const K1_B = 'bb'.repeat(32);
+const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
-const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
+const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
-})
+});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
- transport: BackupTransport
- events: NostrEvent[]
+ transport: BackupTransport;
+ events: NostrEvent[];
} => {
- const events: NostrEvent[] = []
+ const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
- events.push(event)
- return Promise.resolve()
+ events.push(event);
+ return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,83 +78,86 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
- }
- return {transport, events}
-}
+ };
+ return { transport, events };
+};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
- content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
+ content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
- stubLocalStorage()
-})
+ stubLocalStorage();
+});
afterEach(() => {
- vi.useRealTimers()
-})
+ vi.useRealTimers();
+});
describe('publishBackup / fetchBackup', () => {
- const secretKey = deriveBackupKey(LINKING_KEY)
+ const secretKey = deriveBackupKey(LINKING_KEY);
it('publishes every part and fetches them back decrypted', async () => {
- const {transport} = createRecordingTransport()
+ const { transport } = createRecordingTransport();
const parts = {
- notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}],
- mints: [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}],
- settings: {defaultMint: 'mint.example'},
- }
- const published = await publishBackup(secretKey, parts, RELAYS, {transport})
- expect(published.published).toEqual(['notes', 'mints', 'settings'])
+ notes: {
+ bearers: [{ id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }],
+ nextByHost: { 'mint.example': 2 },
+ },
+ mints: [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false }],
+ settings: { defaultMint: 'mint.example' },
+ };
+ const published = await publishBackup(secretKey, parts, RELAYS, { transport });
+ expect(published.published).toEqual(['notes', 'mints', 'settings']);
const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport,
- })
- expect(fetched).toEqual(parts)
- })
+ });
+ expect(fetched).toEqual(parts);
+ });
it('publishes nothing when no parts are given', async () => {
- const {transport, events} = createRecordingTransport()
- const result = await publishBackup(secretKey, {}, RELAYS, {transport})
- expect(result.published).toEqual([])
- expect(events).toEqual([])
- })
+ const { transport, events } = createRecordingTransport();
+ const result = await publishBackup(secretKey, {}, RELAYS, { transport });
+ expect(result.published).toEqual([]);
+ expect(events).toEqual([]);
+ });
it('picks the newest event per d-tag when a relay serves stale copies', async () => {
- const {transport} = createRecordingTransport()
- await publishBackup(secretKey, {settings: {defaultMint: 'old.example'}}, RELAYS, {
+ const { transport } = createRecordingTransport();
+ await publishBackup(secretKey, { settings: { defaultMint: 'old.example' } }, RELAYS, {
transport,
createdAt: 1000,
- })
- await publishBackup(secretKey, {settings: {defaultMint: 'new.example'}}, RELAYS, {
+ });
+ await publishBackup(secretKey, { settings: { defaultMint: 'new.example' } }, RELAYS, {
transport,
createdAt: 2000,
- })
+ });
// the recording transport serves BOTH - the newer must win
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport,
- })
- expect(parts.settings).toEqual({defaultMint: 'new.example'})
- })
+ });
+ expect(parts.settings).toEqual({ defaultMint: 'new.example' });
+ });
it('falls back to an older valid copy when the newest event is tampered', async () => {
- const {transport, events} = createRecordingTransport()
- await publishBackup(secretKey, {settings: {defaultMint: 'mint.example'}}, RELAYS, {
+ const { transport, events } = createRecordingTransport();
+ await publishBackup(secretKey, { settings: { defaultMint: 'mint.example' } }, RELAYS, {
transport,
createdAt: 1000,
- })
- const latest = requiredValue(events[0])
+ });
+ const latest = requiredValue(events[0]);
events.push({
...latest,
content: tamperContent(latest.content),
created_at: 3000,
- })
+ });
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport,
- })
- expect(parts.settings).toEqual({defaultMint: 'mint.example'})
- })
-})
+ });
+ expect(parts.settings).toEqual({ defaultMint: 'mint.example' });
+ });
+});
diff --git a/src/lnurlcash/nostrBackup.ts b/src/lnurlcash/nostrBackup.ts
index 91a783f..e9c09a4 100644
--- a/src/lnurlcash/nostrBackup.ts
+++ b/src/lnurlcash/nostrBackup.ts
@@ -25,18 +25,18 @@ export {
buildBackupEvent,
buildBackupEvents,
parseBackupEvent,
-} from './nostr/events'
-export type {NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent} from './nostr/events'
+} from './nostr/events';
+export type { NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent } from './nostr/events';
-export type {BackupTransport, NostrFilter} from './nostr/transport'
+export type { BackupTransport, NostrFilter } from './nostr/transport';
-export {publishBackup, fetchBackup, restoreFromNostr} from './nostr/sync'
+export { publishBackup, fetchBackup, restoreFromNostr } from './nostr/sync';
export type {
PublishBackupOptions,
PublishBackupResult,
FetchBackupOptions,
NostrRestoreResult,
-} from './nostr/sync'
+} from './nostr/sync';
-export {createBackupPublisher} from './nostr/publisher'
-export type {BackupPublisher, BackupPublisherOptions} from './nostr/publisher'
+export { createBackupPublisher } from './nostr/publisher';
+export type { BackupPublisher, BackupPublisherOptions } from './nostr/publisher';
diff --git a/src/lnurlcash/nwc.connection.cases.ts b/src/lnurlcash/nwc.connection.cases.ts
index 7eee998..7a9b470 100644
--- a/src/lnurlcash/nwc.connection.cases.ts
+++ b/src/lnurlcash/nwc.connection.cases.ts
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,90 +55,90 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
-} from './nwc.testProtocol'
-import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
+} from './nwc.testProtocol';
+import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('connection strings', () => {
it('round-trips build -> parse, including several relays', () => {
const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [
'wss://relay-a.example',
'wss://relay-b.example/path?q=1',
- ])
+ ]);
expect(uri).toBe(
`nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`,
- )
+ );
expect(parseConnectionString(uri)).toEqual({
walletServicePubkey: 'ab'.repeat(32),
clientSecret: 'cd'.repeat(32),
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'],
- })
- })
+ });
+ });
it('rejects strings that are not connection strings', () => {
- expect(parseConnectionString('not a uri')).toBeNull()
- expect(parseConnectionString('https://example.com')).toBeNull()
+ expect(parseConnectionString('not a uri')).toBeNull();
+ expect(parseConnectionString('https://example.com')).toBeNull();
// missing secret
expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`),
- ).toBeNull()
+ ).toBeNull();
// missing relay
expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`),
- ).toBeNull()
+ ).toBeNull();
// a non-hex pubkey
expect(
parseConnectionString(
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32),
),
- ).toBeNull()
- })
+ ).toBeNull();
+ });
it('createConnection returns a string that parses back to the same connection', () => {
const connection = createConnection(LINKING_KEY, {
relays: RELAYS,
- budget: {maxMsat: 100_000, periodMs: 86_400_000},
+ budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET,
- })
- const parsed = parseConnectionString(connection.connectionString)
+ });
+ const parsed = parseConnectionString(connection.connectionString);
expect(parsed).toEqual({
walletServicePubkey: connection.walletServicePubkey,
clientSecret: '11'.repeat(32),
relays: RELAYS,
- })
+ });
// the record persisted WITHOUT the client secret - it is handed out
// once, in the connection string, and never stored
- const records = readNwcConnections(OWNER_ID)
- expect(records).toHaveLength(1)
- expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY)
- expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32))
- })
-})
+ const records = readNwcConnections(OWNER_ID);
+ expect(records).toHaveLength(1);
+ expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY);
+ expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32));
+ });
+});
describe('deriveNwcWalletKey', () => {
it('is pinned: derivation changes would silently orphan every connection', () => {
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d',
- )
+ );
expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
- )
- })
+ );
+ });
it('derives a distinct key per client and per linking key', () => {
- const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))
+ const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY));
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe(
base,
- )
- expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base)
- })
+ );
+ expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base);
+ });
it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
const first = createConnection(LINKING_KEY, {
relays: RELAYS,
- budget: {maxMsat: 100_000, periodMs: 86_400_000},
+ budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET,
- })
+ });
expect(first.walletServicePubkey).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
- )
- })
-})
+ );
+ });
+});
diff --git a/src/lnurlcash/nwc.fencing.cases.ts b/src/lnurlcash/nwc.fencing.cases.ts
index 1f28488..e0263bc 100644
--- a/src/lnurlcash/nwc.fencing.cases.ts
+++ b/src/lnurlcash/nwc.fencing.cases.ts
@@ -4,29 +4,29 @@
// no longer possible. What must not happen is any stale-owner WRITE:
// no bearer changeset, no budget debit, no success response.
-import {describe, expect, it} from 'vitest'
-import {bytesToHex} from '@noble/hashes/utils.js'
+import { describe, expect, it } from 'vitest';
+import { bytesToHex } from '@noble/hashes/utils.js';
-import {readNwcConnections} from './nwc'
+import { readNwcConnections } from './nwc';
import {
methodRequest,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
waitFor,
-} from './nwc.testProtocol'
-import {makeBearer, mint, readResponse, startTestService} from './nwc.testService'
+} from './nwc.testProtocol';
+import { makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: post-boundary stale-owner fencing', () => {
it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => {
// Given a running service whose next mint call coincides with a second
// tab installing its own wallet (the first fetch is the melt itself:
// the exact-match carve performs no mint call of its own)
- const m = await mint()
- let swapped = false
+ const m = await mint();
+ let swapped = false;
const swappingFetch: typeof fetch = (input, init) => {
if (!swapped) {
- swapped = true
+ swapped = true;
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
@@ -35,32 +35,32 @@ describe('service: post-boundary stale-owner fencing', () => {
ownerId: OTHER_OWNER_ID,
version: 1,
}),
- )
+ );
}
- return fetch(input, init)
- }
- const {relay, walletServicePubkey, state, stop} = await startTestService({
- kit: {fetch: swappingFetch},
- })
- state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)]
+ return fetch(input, init);
+ };
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
+ kit: { fetch: swappingFetch },
+ });
+ state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
+ });
// When the pay flows past the irreversible boundary and reaches the
// first owner-bound persistence (the conservative budget debit)
- relay.emit(request)
- await waitFor(() => state.errors.length > 0)
+ relay.emit(request);
+ await waitFor(() => state.errors.length > 0);
// Then the melt genuinely happened (we are past the boundary) ...
- expect(swapped).toBe(true)
- expect(m.state.noteState('d7'.repeat(32))).toBe('burned')
+ expect(swapped).toBe(true);
+ expect(m.state.noteState('d7'.repeat(32))).toBe('burned');
// ... but nothing under the stale owner moved: no budget debit, no
// bearer changeset, no success response (the failure surfaced through
// onError instead)
- expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0)
- expect(state.changesets).toEqual([])
- expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
- await stop()
- })
-})
+ expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0);
+ expect(state.changesets).toEqual([]);
+ expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
+ await stop();
+ });
+});
diff --git a/src/lnurlcash/nwc.invoice-a.cases.ts b/src/lnurlcash/nwc.invoice-a.cases.ts
index 109e548..be6e8cd 100644
--- a/src/lnurlcash/nwc.invoice-a.cases.ts
+++ b/src/lnurlcash/nwc.invoice-a.cases.ts
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, hashK1, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, hashK1, noteK1 } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredString, requiredValue, stubLocalStorage} from './test-utils'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,21 +55,36 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
-} from './nwc.testProtocol'
-import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
+} from './nwc.testProtocol';
+import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
+import { invoiceRetireAfter } from './nwc/invoices';
+
+export const SIGNED_BOLT11_FIXTURE =
+ 'lnbc250n1p4f4d2ysp5s7pqe2590jtlgpqyzw82m4xtn5wpkehf8jt4fcj754p4s2nf3r3qpp5d2zdf3satrdk5qllx00hwah5tlg69h90w5s78flfg8ffq4fagalsdqhd3h82unvvdshx6pqd45kuaqxqyjw5qcqpjrzjqwda3lfx6jhqd33847rqkfclpnyfv9f74pavem0mtvhdyzlam4lnkr83hqqq55cqqyqqqqqqqqqqzsqq9q9qxpqysgql3u9h88pw0pnfqdqh5hdgstgc8gazln7xeleuu7elmw7ea6q488js0mqwchtcq7rphv9uc3q5yqllx6ksdw8yjfq5e9jcgarlqzrk6gq5ksjdz';
+
describe('service: make_invoice / lookup_invoice', () => {
- it('issues an invoice, settles it in the background, and reports the preimage', async () => {
- const m = await mint({testHooks: true})
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ it('derives a safe retirement time from a signed BOLT11 invoice', () => {
+ expect(invoiceRetireAfter(SIGNED_BOLT11_FIXTURE)).toBeGreaterThan(0);
+ });
+
+ it('rejects a retirement time outside the safe integer range', () => {
+ const timestamp = 'q'.repeat(7);
+ const expiryTag = `xqv${'l'.repeat(12)}`;
+ expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull();
+ });
+
+ it('stages an output, settles it in the background, and reports the preimage', async () => {
+ const m = await mint({ testHooks: true });
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
- })
+ });
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
description: 'nwc test',
expiry: 3600,
- })
- expect(made.error).toBeNull()
+ });
+ expect(made.error).toBeNull();
expect(made.result).toMatchObject({
type: 'incoming',
state: 'pending',
@@ -77,253 +92,131 @@ describe('service: make_invoice / lookup_invoice', () => {
description: 'nwc test',
created_at: nowSeconds(),
expires_at: nowSeconds() + 3600,
- })
- const invoice = requiredValue(made.result).invoice
+ });
+ const invoice = requiredValue(made.result).invoice;
if (typeof invoice !== 'string') {
- throw new TypeError('make_invoice did not return an invoice')
+ throw new TypeError('make_invoice did not return an invoice');
}
- const paymentHash = made.result?.payment_hash
+ const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
- throw new TypeError('make_invoice did not return a payment hash')
+ throw new TypeError('make_invoice did not return a payment hash');
}
- expect(invoice).toMatch(/^lnbc/)
- expect(paymentHash).toMatch(/^[0-9a-f]{64}$/)
-
+ expect(invoice).toMatch(/^lnbc/);
+ expect(paymentHash).toMatch(/^[0-9a-f]{64}$/);
+ const staged = requiredValue(state.bearers.find((bearer) => bearer.id.startsWith('staged-')));
+ expect(staged.verified).toBe(false);
+ expect(staged.callback).toBe('');
// before settlement the lookup reports the pending invoice
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
- })
- expect(pending.error).toBeNull()
- expect(pending.result?.state).toBe('pending')
- expect(pending.result?.preimage).toBeUndefined()
+ });
+ expect(pending.error).toBeNull();
+ expect(pending.result?.state).toBe('pending');
+ expect(pending.result?.preimage).toBeUndefined();
// the "payer" pays the invoice; the background claim settles and
// mints the note
- const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
- expect(settleRes.ok).toBe(true)
- await waitFor(() => state.changesets.some((c) => c.add.length > 0))
+ const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
+ expect(settleRes.ok).toBe(true);
+ await waitFor(() => state.bearers.some((bearer) => bearer.verified));
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
- })
- expect(settled.error).toBeNull()
- expect(settled.result?.state).toBe('settled')
- expect(settled.result?.settled_at).toBe(nowSeconds())
- const preimage = requiredString(settled.result?.preimage)
- expect(preimage).toMatch(/^[0-9a-f]{64}$/)
+ });
+ expect(settled.error).toBeNull();
+ expect(settled.result?.state).toBe('settled');
+ expect(settled.result?.settled_at).toBe(nowSeconds());
+ const preimage = requiredString(settled.result?.preimage);
+ expect(preimage).toMatch(/^[0-9a-f]{64}$/);
- // the minted note was claimed AND rotated before settlement was
- // recorded: the preimage the client just learned is a burned secret,
- // and the wallet's fresh note is the only live one
- expect(m.state.noteState(preimage)).toBe('burned')
- const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
- expect(minted.amount).toBe(21_000)
- expect(minted.verified).toBe(true)
- expect(noteK1(minted.url)).not.toBe(preimage)
- expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding')
- await stop()
- })
-
- it('settles against a NAMED mint: note at the wallet secret, preimage a plain receipt', async () => {
- const m = await mint({commentAllowed: 64, testHooks: true})
- const {relay, walletServicePubkey, state, stop} = await startTestService({
- defaultMint: `mint@127.0.0.1:${m.port}`,
- })
-
- const made = await call(relay, walletServicePubkey, 'make_invoice', {
- amount: 21_000,
- })
- expect(made.error).toBeNull()
- const paymentHash = made.result?.payment_hash
- if (typeof paymentHash !== 'string') {
- throw new TypeError('make_invoice did not return a payment hash')
- }
-
- const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
- expect(settleRes.ok).toBe(true)
- await waitFor(() => state.changesets.some((c) => c.add.length > 0))
-
- const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
- payment_hash: paymentHash,
- })
- expect(settled.error).toBeNull()
- expect(settled.result?.state).toBe('settled')
- // a named mint's verify preimage is an ordinary payment receipt -
- // it keys nothing at the mint - so it is always safe to hand out
- const preimage = requiredString(settled.result?.preimage)
- expect(preimage).toMatch(/^[0-9a-f]{64}$/)
- expect(m.state.noteState(preimage)).toBeNull()
-
- // the note was claimed at the wallet's own secret - unrotated,
- // because nothing exposed it
- const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
- expect(minted.amount).toBe(21_000)
- expect(minted.verified).toBe(true)
- const mintedK1 = requiredValue(noteK1(minted.url))
- expect(m.state.noteState(mintedK1)).toBe('outstanding')
- // the quote was bound to that secret's hash
- expect([...m.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(mintedK1))
- await stop()
- })
-
- it('rescues a NAMED mint that credited the preimage, and burns it before handing it out', async () => {
- // the mintToHashIgnoresH adversary: advertises output naming, binds
- // nothing, credits the payment hash - and (non-compliantly) still
- // serves verify, so the preimage path can recover the note
- const m = await mint({
- commentAllowed: 64,
- mintToHashIgnoresH: true,
- verifyOnUnnamedMint: true,
- testHooks: true,
- })
- const {relay, walletServicePubkey, state, stop} = await startTestService({
- defaultMint: `mint@127.0.0.1:${m.port}`,
- claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
- })
-
- const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
- const paymentHash = made.result?.payment_hash
- if (typeof paymentHash !== 'string') {
- throw new TypeError('make_invoice did not return a payment hash')
- }
- const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
- expect(settleRes.ok).toBe(true)
- await waitFor(() => state.changesets.some((c) => c.add.length > 0))
-
- const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
- payment_hash: paymentHash,
- })
- expect(settled.result?.state).toBe('settled')
- // the rescue claimed through the preimage path and rotated off it, so
- // the receipt handed to the client is a burned secret
- const preimage = requiredString(settled.result?.preimage)
- expect(m.state.noteState(preimage)).toBe('burned')
- const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
- const mintedK1 = requiredValue(noteK1(minted.url))
- expect(mintedK1).not.toBe(preimage)
- expect(m.state.noteState(mintedK1)).toBe('outstanding')
- await stop()
- })
-
- it('withholds the preimage when the rescue claim could not rotate', async () => {
- // same lying mint, but the rotate's request never lands (the mint
- // dies between the claim and the rotate): the preimage may still be
- // the note's secret, so it stays a secret even though the invoice
- // reads settled
- const m = await mint({
- commentAllowed: 64,
- mintToHashIgnoresH: true,
- verifyOnUnnamedMint: true,
- testHooks: true,
- })
- const rotateFailingFetch: typeof fetch = (input, init) => {
- const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
- if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset'))
- return fetch(input, init)
- }
- const {relay, walletServicePubkey, state, stop} = await startTestService({
- defaultMint: `mint@127.0.0.1:${m.port}`,
- claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
- kit: {fetch: rotateFailingFetch},
- })
-
- const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
- const paymentHash = made.result?.payment_hash
- if (typeof paymentHash !== 'string') {
- throw new TypeError('make_invoice did not return a payment hash')
- }
- const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
- expect(settleRes.ok).toBe(true)
- await waitFor(() => state.changesets.some((c) => c.add.length > 0))
-
- const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
- payment_hash: paymentHash,
- })
- expect(settled.result?.state).toBe('settled')
- // settled, but NO receipt: an unrotated preimage may still be the note
- expect(settled.result?.preimage).toBeUndefined()
- // the note is tracked either way (it IS money)
- expect(state.bearers.some((b) => b.id.startsWith('added-'))).toBe(true)
- await stop()
- })
+ // The staged record was updated in place before settlement became
+ // visible. The payment preimage is only a receipt and keys no note.
+ expect(m.state.noteState(preimage)).toBeNull();
+ const minted = requiredValue(state.bearers.find((b) => b.id === staged.id));
+ expect(minted.amount).toBe(21_000);
+ expect(minted.verified).toBe(true);
+ expect(noteK1(minted.url)).not.toBe(preimage);
+ expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding');
+ await stop();
+ });
it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
- const m = await mint({testHooks: true})
- const commit = deferred()
- let commitStarted = false
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint({ testHooks: true });
+ const commit = deferred();
+ let commitStarted = false;
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
- commitStarted = true
- return commit.promise
+ commitStarted = true;
+ return commit.promise;
},
- })
+ });
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
- })
- const paymentHash = made.result?.payment_hash
+ });
+ const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
- throw new TypeError('make_invoice did not return a payment hash')
+ throw new TypeError('make_invoice did not return a payment hash');
}
- const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
- expect(settleResponse.ok).toBe(true)
- await waitFor(() => commitStarted)
+ const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
+ expect(settleResponse.ok).toBe(true);
+ await waitFor(() => commitStarted);
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
- })
- expect(pending.result?.state).toBe('pending')
- expect(pending.result?.settled_at).toBeUndefined()
- expect(pending.result?.preimage).toBeUndefined()
+ });
+ expect(pending.result?.state).toBe('pending');
+ expect(pending.result?.settled_at).toBeUndefined();
+ expect(pending.result?.preimage).toBeUndefined();
- commit.resolve()
- await waitFor(() => state.changesets.length === 1)
+ commit.resolve();
+ await waitFor(() => state.changesets.length === 1);
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
- })
- expect(settled.result?.state).toBe('settled')
- expect(settled.result?.settled_at).toBe(nowSeconds())
- expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/)
- await stop()
- })
+ });
+ expect(settled.result?.state).toBe('settled');
+ expect(settled.result?.settled_at).toBe(nowSeconds());
+ expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/);
+ await stop();
+ });
it('keeps repeated stops pending until an already-started invoice settlement commits', async () => {
- const m = await mint({testHooks: true})
- const commit = deferred()
- let commitStarted = false
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint({ testHooks: true });
+ const commit = deferred();
+ let commitStarted = false;
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
- commitStarted = true
- return commit.promise
+ commitStarted = true;
+ return commit.promise;
},
- })
+ });
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
- })
- const paymentHash = made.result?.payment_hash
+ });
+ const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
- throw new TypeError('make_invoice did not return a payment hash')
+ throw new TypeError('make_invoice did not return a payment hash');
}
- const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
- expect(settleResponse.ok).toBe(true)
- await waitFor(() => commitStarted)
+ const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
+ expect(settleResponse.ok).toBe(true);
+ await waitFor(() => commitStarted);
- let stopped = false
+ let stopped = false;
const firstStop = stop().then(() => {
- stopped = true
- return state.changesets.length
- })
- const repeatedStop = stop()
- for (let turn = 0; turn < 10; turn += 1) await Promise.resolve()
+ stopped = true;
+ return state.changesets.length;
+ });
+ const repeatedStop = stop();
+ for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
- expect(stopped).toBe(false)
- expect(state.changesets).toHaveLength(0)
+ expect(stopped).toBe(false);
+ expect(state.changesets).toHaveLength(0);
- commit.resolve()
- const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop])
- expect(changesetsAtStop).toBe(1)
- expect(state.changesets).toHaveLength(1)
- })
-})
+ commit.resolve();
+ const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]);
+ expect(changesetsAtStop).toBe(1);
+ expect(state.changesets).toHaveLength(1);
+ });
+});
diff --git a/src/lnurlcash/nwc.invoice-b.cases.ts b/src/lnurlcash/nwc.invoice-b.cases.ts
index 115e22a..f1936b5 100644
--- a/src/lnurlcash/nwc.invoice-b.cases.ts
+++ b/src/lnurlcash/nwc.invoice-b.cases.ts
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,187 +55,273 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
-} from './nwc.testProtocol'
-import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
+} from './nwc.testProtocol';
+import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
+import { SIGNED_BOLT11_FIXTURE } from './nwc.invoice-a.cases';
describe('service: make_invoice / lookup_invoice (continued)', () => {
it('drains a rejected invoice settlement and reports it before stop resolves', async () => {
- const m = await mint({testHooks: true})
- const commit = deferred()
- const commitError = new Error('invoice commit rejected during stop')
- let commitStarted = false
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint({ testHooks: true });
+ const commit = deferred();
+ const commitError = new Error('invoice commit rejected during stop');
+ let commitStarted = false;
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
- commitStarted = true
- return commit.promise
+ commitStarted = true;
+ return commit.promise;
},
- })
+ });
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
- })
- const paymentHash = made.result?.payment_hash
+ });
+ const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
- throw new TypeError('make_invoice did not return a payment hash')
+ throw new TypeError('make_invoice did not return a payment hash');
}
- const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
- expect(settleResponse.ok).toBe(true)
- await waitFor(() => commitStarted)
+ const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
+ expect(settleResponse.ok).toBe(true);
+ await waitFor(() => commitStarted);
- let stopped = false
+ let stopped = false;
const stopping = stop().then(() => {
- stopped = true
- return state.errors.length
- })
- for (let turn = 0; turn < 10; turn += 1) await Promise.resolve()
- expect(stopped).toBe(false)
+ stopped = true;
+ return state.errors.length;
+ });
+ for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
+ expect(stopped).toBe(false);
- commit.reject(commitError)
- expect(await stopping).toBe(1)
- expect(state.errors).toEqual([commitError])
- expect(state.changesets).toHaveLength(0)
- await stop()
- })
+ commit.reject(commitError);
+ expect(await stopping).toBe(1);
+ expect(state.errors).toEqual([commitError]);
+ expect(state.changesets).toHaveLength(0);
+ await stop();
+ });
it('does not block stop on an invoice whose claim is still polling', async () => {
// an unpaid invoice's claim poll can legally run for minutes (the
// client pays whenever it pays) - stop must interrupt the wait, not
// sit on it; a settlement that already REACHED the commit phase is
// still awaited (see the drain tests above)
- const m = await mint({testHooks: true})
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint({ testHooks: true });
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
- claimPoll: {intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000},
- })
+ claimPoll: { intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000 },
+ });
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000,
- })
- expect(made.error).toBeNull()
+ });
+ expect(made.error).toBeNull();
// nobody pays the invoice; the claim keeps polling. stop must resolve
// promptly regardless (an un-interrupted stop would wait out the
// whole 60s claim budget)
- await stop()
- expect(state.changesets).toHaveLength(0)
- expect(state.errors).toHaveLength(0)
- })
+ await stop();
+ expect(state.changesets).toHaveLength(0);
+ expect(state.errors).toHaveLength(0);
+ });
it('does not start invoice settlement after stop begins during preparation', async () => {
- const m = await mint({testHooks: true})
- const prepare = deferred()
- let prepareStarted = false
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint({ testHooks: true });
+ const prepare = deferred();
+ let prepareStarted = false;
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
kit: {
fetch: async (input, init) => {
- prepareStarted = true
- await prepare.promise
- return fetch(input, init)
+ prepareStarted = true;
+ await prepare.promise;
+ return fetch(input, init);
},
},
- })
+ });
const request = methodRequest(walletServicePubkey, 'make_invoice', {
amount: 21_000,
- })
- relay.emit(request)
- await waitFor(() => prepareStarted)
+ });
+ relay.emit(request);
+ await waitFor(() => prepareStarted);
- let stopped = false
+ let stopped = false;
const stopping = stop().then(() => {
- stopped = true
- })
- for (let turn = 0; turn < 10; turn += 1) await Promise.resolve()
- expect(stopped).toBe(false)
+ stopped = true;
+ });
+ for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
+ expect(stopped).toBe(false);
- prepare.resolve()
- await stopping
+ prepare.resolve();
+ await stopping;
- expect(state.changesets).toHaveLength(0)
- expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL')
- })
+ expect(state.changesets).toHaveLength(0);
+ expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
+ });
it('marks a paid invoice failed when its bearer commit rejects', async () => {
- const m = await mint({testHooks: true})
- const commit = deferred()
- const commitError = new Error('invoice bearer commit failed')
- let commitStarted = false
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint({ testHooks: true });
+ const commit = deferred();
+ const commitError = new Error('invoice bearer commit failed');
+ let commitStarted = false;
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
- commitStarted = true
- return commit.promise
+ commitStarted = true;
+ return commit.promise;
},
- })
+ });
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
- })
- const paymentHash = made.result?.payment_hash
+ });
+ const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
- throw new TypeError('make_invoice did not return a payment hash')
+ throw new TypeError('make_invoice did not return a payment hash');
}
- const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
- expect(settleResponse.ok).toBe(true)
- await waitFor(() => commitStarted)
- commit.reject(commitError)
- await waitFor(() => state.errors.length === 1)
+ const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
+ expect(settleResponse.ok).toBe(true);
+ await waitFor(() => commitStarted);
+ commit.reject(commitError);
+ await waitFor(() => state.errors.length === 1);
const failed = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
- })
- expect(failed.result?.state).toBe('failed')
- expect(failed.result?.settled_at).toBeUndefined()
- expect(failed.result?.preimage).toBeUndefined()
- expect(state.errors).toEqual([commitError])
- await stop()
- })
+ });
+ expect(failed.result?.state).toBe('failed');
+ expect(failed.result?.settled_at).toBeUndefined();
+ expect(failed.result?.preimage).toBeUndefined();
+ expect(state.errors).toEqual([commitError]);
+ await stop();
+ });
it('finds an invoice by its invoice string too', async () => {
- const m = await mint({testHooks: true})
- const {relay, walletServicePubkey, stop} = await startTestService({
+ const m = await mint({ testHooks: true });
+ const { relay, walletServicePubkey, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
- claimPoll: {intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10},
- })
+ claimPoll: { intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10 },
+ });
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000,
- })
- const invoice = made.result?.invoice
+ });
+ const invoice = made.result?.invoice;
if (typeof invoice !== 'string') {
- throw new TypeError('make_invoice did not return an invoice')
+ throw new TypeError('make_invoice did not return an invoice');
}
const found = await call(relay, walletServicePubkey, 'lookup_invoice', {
invoice: invoice.toUpperCase(),
- })
- expect(found.error).toBeNull()
- expect(found.result?.payment_hash).toBe(made.result?.payment_hash)
- await stop()
- })
+ });
+ expect(found.error).toBeNull();
+ expect(found.result?.payment_hash).toBe(made.result?.payment_hash);
+ await stop();
+ });
it('answers an unknown invoice with NOT_FOUND', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
+ const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: 'ab'.repeat(32),
- })
- expect(response.error?.code).toBe('NOT_FOUND')
- await stop()
- })
+ });
+ expect(response.error?.code).toBe('NOT_FOUND');
+ await stop();
+ });
it('answers make_invoice without a default mint with INTERNAL', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({
+ const { relay, walletServicePubkey, stop } = await startTestService({
defaultMint: null,
- })
+ });
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
- })
- expect(response.error?.code).toBe('INTERNAL')
- await stop()
- })
+ });
+ expect(response.error?.code).toBe('INTERNAL');
+ await stop();
+ });
+
+ it('rejects invoice creation when the durable pending-output cap is reached', async () => {
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
+ defaultMint: 'mint@example.com',
+ });
+ for (let index = 0; index < 20; index += 1) {
+ state.bearers.push({
+ id: `pending-${index}`,
+ url: `https://mint.example/w?k1=${index.toString(16).padStart(64, '0')}`,
+ callback: '',
+ amount: 0,
+ verified: false,
+ pendingMint: {},
+ createdAt: index,
+ updatedAt: index,
+ });
+ }
+
+ const response = await call(relay, walletServicePubkey, 'make_invoice', {
+ amount: 21_000,
+ });
+
+ expect(response.error?.code).toBe('QUOTA_EXCEEDED');
+ expect(state.bearers).toHaveLength(20);
+ await stop();
+ });
+
+ it('removes staged outputs when invoice preparation fails before publication', async () => {
+ const m = await mint({ testHooks: true });
+ let rejectQuotes = true;
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
+ defaultMint: `mint@127.0.0.1:${m.port}`,
+ kit: {
+ fetch: (input, init) => {
+ const url =
+ typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (rejectQuotes && url.includes('comment=')) {
+ return Promise.resolve(new Response('quote failed', { status: 500 }));
+ }
+ return fetch(input, init);
+ },
+ },
+ });
+
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ const failed = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
+ expect(failed.error?.code).toBe('INTERNAL');
+ expect(state.bearers).toHaveLength(0);
+ }
+
+ rejectQuotes = false;
+ const made = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
+ expect(made.error).toBeNull();
+ expect(state.bearers).toHaveLength(1);
+ await stop();
+ });
+
+ it('removes an unpublished stage when retirement persistence fails', async () => {
+ const m = await mint({ testHooks: true });
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
+ defaultMint: `mint@127.0.0.1:${m.port}`,
+ setMintOutputRetirement: () => Promise.reject(new Error('retirement write failed')),
+ kit: {
+ fetch: (input, init) => {
+ const url =
+ typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes('comment=')) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ pr: SIGNED_BOLT11_FIXTURE }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ return fetch(input, init);
+ },
+ },
+ });
+
+ const response = await call(relay, walletServicePubkey, 'make_invoice', { amount: 25_000 });
+
+ expect(response.error?.code).toBe('INTERNAL');
+ expect(state.bearers).toHaveLength(0);
+ await stop();
+ });
it('answers a make_invoice with a bad amount with OTHER', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
+ const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: -5,
- })
- expect(response.error?.code).toBe('OTHER')
- await stop()
- })
-})
+ });
+ expect(response.error?.code).toBe('OTHER');
+ await stop();
+ });
+});
diff --git a/src/lnurlcash/nwc.ownership.cases.ts b/src/lnurlcash/nwc.ownership.cases.ts
index 8b1f154..19f346f 100644
--- a/src/lnurlcash/nwc.ownership.cases.ts
+++ b/src/lnurlcash/nwc.ownership.cases.ts
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,35 +55,46 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
-} from './nwc.testProtocol'
-import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
+} from './nwc.testProtocol';
+import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service ownership', () => {
it('subscribes only current-owner records and leaves foreign budgets untouched', async () => {
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
- budget: {maxMsat: 1000, periodMs: 1000},
+ budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 0,
- })
- const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0)
- storeForeignConnection(foreign.record)
- const relay = createFakeRelay()
+ });
+ const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
+ storeForeignConnection(foreign.record);
+ const relay = createFakeRelay();
const service = await startService(LINKING_KEY, {
assertCurrentOwner: () => undefined,
+ allocateOutputSecrets: () => Promise.resolve([]),
+ mintSignatureKeys: () => [],
+ recoverPendingMints: () => Promise.resolve(),
+ reserveMintOutputSource: () => Promise.resolve(),
+ finalizePaymentReturn: () => Promise.resolve(),
getBearers: () => [],
getDefaultMint: () => null,
+ persistMintOutput: () => Promise.reject(new Error('unused')),
+ discardMintOutput: () => Promise.reject(new Error('unused')),
+ setMintOutputRetirement: () => Promise.reject(new Error('unused')),
+ finalizeMintOutput: () => Promise.reject(new Error('unused')),
+ finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
+ commitCarve: () => Promise.reject(new Error('unused')),
applyChangeset: () => Promise.resolve(),
transport: relay.transport,
nowSeconds,
- })
+ });
- expect(service.connections.map((connection) => connection.record)).toEqual([current.record])
- expect(relay.subscriptionCount()).toBe(1)
- expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0)
- expect(service.connections).not.toContainEqual(foreign)
- await service.stop()
- })
+ expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
+ expect(relay.subscriptionCount()).toBe(1);
+ expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
+ expect(service.connections).not.toContainEqual(foreign);
+ await service.stop();
+ });
it('ignores foreign records handed in through the records snapshot', async () => {
// the injected-records path bypasses storage, so the service's own
@@ -91,69 +102,80 @@ describe('service ownership', () => {
// snapshot from a previous wallet must not be served)
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
- budget: {maxMsat: 1000, periodMs: 1000},
+ budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 0,
- })
- const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0)
- const relay = createFakeRelay()
+ });
+ const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
+ const relay = createFakeRelay();
const service = await startService(
LINKING_KEY,
{
assertCurrentOwner: () => undefined,
+ allocateOutputSecrets: () => Promise.resolve([]),
+ mintSignatureKeys: () => [],
+ recoverPendingMints: () => Promise.resolve(),
+ reserveMintOutputSource: () => Promise.resolve(),
+ finalizePaymentReturn: () => Promise.resolve(),
getBearers: () => [],
getDefaultMint: () => null,
+ persistMintOutput: () => Promise.reject(new Error('unused')),
+ discardMintOutput: () => Promise.reject(new Error('unused')),
+ setMintOutputRetirement: () => Promise.reject(new Error('unused')),
+ finalizeMintOutput: () => Promise.reject(new Error('unused')),
+ finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
+ commitCarve: () => Promise.reject(new Error('unused')),
applyChangeset: () => Promise.resolve(),
transport: relay.transport,
nowSeconds,
},
[current.record, foreign.record],
- )
+ );
- expect(service.connections.map((connection) => connection.record)).toEqual([current.record])
- expect(relay.subscriptionCount()).toBe(1)
+ expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
+ expect(relay.subscriptionCount()).toBe(1);
// the foreign snapshot record must not be persisted for the new owner
- expect(readNwcConnections(OWNER_ID)).toEqual([current.record])
- await service.stop()
- })
-})
+ expect(readNwcConnections(OWNER_ID)).toEqual([current.record]);
+ await service.stop();
+ });
+});
describe('service: info and balance', () => {
it('publishes a kind-13194 info event on startup', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
- const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND))
- expect(info.pubkey).toBe(walletServicePubkey)
- expect(info.content).toContain('pay_invoice')
- expect(info.content).toContain('make_invoice')
- expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04'])
- await stop()
- })
+ const { relay, walletServicePubkey, stop } = await startTestService({});
+ const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND));
+ expect(info.pubkey).toBe(walletServicePubkey);
+ expect(info.content).toContain('pay_invoice');
+ expect(info.content).toContain('make_invoice');
+ expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']);
+ await stop();
+ });
it('answers get_info with the connection identity and method list', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
- const response = await call(relay, walletServicePubkey, 'get_info', {})
- expect(response.error).toBeNull()
- expect(response.result_type).toBe('get_info')
+ const { relay, walletServicePubkey, stop } = await startTestService({});
+ const response = await call(relay, walletServicePubkey, 'get_info', {});
+ expect(response.error).toBeNull();
+ expect(response.result_type).toBe('get_info');
expect(response.result).toMatchObject({
alias: 'sattle',
pubkey: walletServicePubkey,
methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'],
- })
- await stop()
- })
+ });
+ await stop();
+ });
it('answers get_balance with the spendable total only', async () => {
- const m = await mint()
- const {relay, walletServicePubkey, state, stop} = await startTestService({})
+ const m = await mint();
+ const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [
await makeBearer(m, 'aa'.repeat(32), 21_000),
await makeBearer(m, 'bb'.repeat(32), 5_000),
- {...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true},
- ]
- const response = await call(relay, walletServicePubkey, 'get_balance', {})
- expect(response.error).toBeNull()
- expect(response.result).toEqual({balance: 26_000})
- await stop()
- })
-})
+ { ...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true },
+ ];
+ const response = await call(relay, walletServicePubkey, 'get_balance', {});
+ expect(response.error).toBeNull();
+ expect(response.result).toEqual({ balance: 26_000 });
+ await stop();
+ });
+});
diff --git a/src/lnurlcash/nwc.pay-a.cases.ts b/src/lnurlcash/nwc.pay-a.cases.ts
index 9d60619..ba0a098 100644
--- a/src/lnurlcash/nwc.pay-a.cases.ts
+++ b/src/lnurlcash/nwc.pay-a.cases.ts
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredString, requiredValue, stubLocalStorage} from './test-utils'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,159 +55,159 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
-} from './nwc.testProtocol'
-import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
+} from './nwc.testProtocol';
+import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice', () => {
it('applies the settled changeset before publishing success', async () => {
- const m = await mint()
- const {relay, walletServicePubkey, state, stop} = await startTestService({})
- state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)]
+ const m = await mint();
+ const { relay, walletServicePubkey, state, stop } = await startTestService({});
+ state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
+ });
- expect(response.error).toBeNull()
- expect(state.changesets).toHaveLength(1)
- expect(state.bearers[0]?.spent).toBe(true)
- await stop()
- })
+ expect(response.error).toBeNull();
+ expect(state.changesets).toHaveLength(1);
+ expect(state.bearers[0]?.spent).toBe(true);
+ await stop();
+ });
it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => {
- const m = await mint()
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint();
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 50_000,
- })
+ });
const foreign = foreignConnectionFixture(
- {maxMsat: 99_000, periodMs: 86_400_000},
+ { maxMsat: 99_000, periodMs: 86_400_000 },
nowSeconds() * 1000,
- )
- storeForeignConnection(foreign.record)
- state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)]
+ );
+ storeForeignConnection(foreign.record);
+ state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
- expect(response.error).toBeNull()
- expect(requiredString(response.result?.preimage)).toHaveLength(64)
+ });
+ expect(response.error).toBeNull();
+ expect(requiredString(response.result?.preimage)).toHaveLength(64);
// the note is gone (melted) and locked spent via the changeset
- expect(m.state.noteState('dd'.repeat(32))).toBe('burned')
- expect(requiredValue(state.bearers[0]).spent).toBe(true)
+ expect(m.state.noteState('dd'.repeat(32))).toBe('burned');
+ expect(requiredValue(state.bearers[0]).spent).toBe(true);
// the spend was recorded against the budget, persisted
- expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000)
- expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0)
- await stop()
- })
+ expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000);
+ expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
+ await stop();
+ });
it('waits for a deferred bearer commit before publishing payment success', async () => {
- const m = await mint()
- const commit = deferred()
- let commitStarted = false
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint();
+ const commit = deferred();
+ let commitStarted = false;
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
- commitStarted = true
- return commit.promise
+ commitStarted = true;
+ return commit.promise;
},
- })
- state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)]
+ });
+ state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
+ });
- relay.emit(request)
- await waitFor(() => commitStarted)
+ relay.emit(request);
+ await waitFor(() => commitStarted);
// a non-awaiting engine publishes within milliseconds of the commit
// call (one local verify re-read + encrypt); this window is far wider
// than that, so an early response here can only mean a missing barrier
- await new Promise((resolve) => setTimeout(resolve, 250))
- expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
- commit.resolve()
- await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null)
- expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull()
- await stop()
- })
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
+ commit.resolve();
+ await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
+ expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
+ await stop();
+ });
it('reports a rejected bearer commit without publishing payment success', async () => {
- const m = await mint()
- const commit = deferred()
- const commitError = new Error('bearer commit failed')
- let commitStarted = false
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint();
+ const commit = deferred();
+ const commitError = new Error('bearer commit failed');
+ let commitStarted = false;
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
- commitStarted = true
- return commit.promise
+ commitStarted = true;
+ return commit.promise;
},
- })
- state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)]
+ });
+ state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
+ });
- relay.emit(request)
- await waitFor(() => commitStarted)
- commit.reject(commitError)
+ relay.emit(request);
+ await waitFor(() => commitStarted);
+ commit.reject(commitError);
await waitFor(
() =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
- )
+ );
- expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
- expect(state.errors).toEqual([commitError])
+ expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
+ expect(state.errors).toEqual([commitError]);
// the conservative budget debit is persisted separately from bearer
// storage and deliberately NOT rolled back: the budget was debited,
// the bearer was never locked spent
- expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000)
- expect(state.bearers[0]?.spent).toBeUndefined()
- await stop()
- })
+ expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
+ expect(state.bearers[0]?.spent).toBeUndefined();
+ await stop();
+ });
it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => {
- const m = await mint()
- const commit = deferred()
- let commitStarted = false
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint();
+ const commit = deferred();
+ let commitStarted = false;
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
- commitStarted = true
- return commit.promise
+ commitStarted = true;
+ return commit.promise;
},
- })
- state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)]
+ });
+ state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
+ });
- relay.emit(request)
- await waitFor(() => commitStarted)
+ relay.emit(request);
+ await waitFor(() => commitStarted);
// stop closes subscriptions; in-flight handlers still finish (their
// changesets hold money) - a repeated stop interrupts nothing twice
- let stopped = false
+ let stopped = false;
const firstStop = stop().then(() => {
- stopped = true
- })
- const repeatedStop = stop()
- await Promise.resolve()
- expect(stopped).toBe(false)
+ stopped = true;
+ });
+ const repeatedStop = stop();
+ await Promise.resolve();
+ expect(stopped).toBe(false);
// subscriptions close IMMEDIATELY, before the drain completes
- expect(relay.subscriptionCount()).toBe(0)
+ expect(relay.subscriptionCount()).toBe(0);
- const afterStop = methodRequest(walletServicePubkey, 'get_balance', {})
- relay.emitAfterClose(afterStop)
- await new Promise((resolve) => setTimeout(resolve, 25))
- expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull()
+ const afterStop = methodRequest(walletServicePubkey, 'get_balance', {});
+ relay.emitAfterClose(afterStop);
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull();
- commit.resolve()
- await Promise.all([firstStop, repeatedStop])
- expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull()
- expect(state.bearers[0]?.spent).toBe(true)
- })
+ commit.resolve();
+ await Promise.all([firstStop, repeatedStop]);
+ expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
+ expect(state.bearers[0]?.spent).toBe(true);
+ });
it('rejects a stale saved owner before touching the mint', async () => {
- const m = await mint()
- const {relay, walletServicePubkey, state, stop} = await startTestService({})
- state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)]
+ const m = await mint();
+ const { relay, walletServicePubkey, state, stop } = await startTestService({});
+ state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)];
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
@@ -216,20 +216,20 @@ describe('service: pay_invoice', () => {
ownerId: OTHER_OWNER_ID,
version: 1,
}),
- )
+ );
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
+ });
- relay.emit(request)
+ relay.emit(request);
await waitFor(
() =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
- )
+ );
- expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding')
- expect(state.changesets).toEqual([])
- expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL')
- await stop()
- })
-})
+ expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding');
+ expect(state.changesets).toEqual([]);
+ expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
+ await stop();
+ });
+});
diff --git a/src/lnurlcash/nwc.pay-b.cases.ts b/src/lnurlcash/nwc.pay-b.cases.ts
index 30f30cf..b73eff1 100644
--- a/src/lnurlcash/nwc.pay-b.cases.ts
+++ b/src/lnurlcash/nwc.pay-b.cases.ts
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,104 +55,131 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
-} from './nwc.testProtocol'
-import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
+} from './nwc.testProtocol';
+import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice (continued)', () => {
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
- const m = await mint()
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint();
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 20_000,
- })
- state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)]
+ });
+ state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
- expect(response.error?.code).toBe('QUOTA_EXCEEDED')
+ });
+ expect(response.error?.code).toBe('QUOTA_EXCEEDED');
// nothing moved: the note is untouched, no spend recorded
- expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding')
- expect(requiredValue(state.bearers[0]).spent).toBeUndefined()
- expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
- await stop()
- })
+ expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding');
+ expect(requiredValue(state.bearers[0]).spent).toBeUndefined();
+ expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
+ await stop();
+ });
it('resets the allowance once the budget period has rolled over', async () => {
- const m = await mint()
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint();
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 21_000,
periodMs: 60_000,
- })
+ });
// simulate a fully spent budget from a period that ended long ago
- const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0])
+ const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0]);
writeNwcConnections(OWNER_ID, [
- {...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}},
- ])
- state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)]
+ { ...record, spent: { periodStart: Date.now() - 120_000, msat: 21_000 } },
+ ]);
+ state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
- expect(response.error).toBeNull()
- expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000)
- await stop()
- })
+ });
+ expect(response.error).toBeNull();
+ expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
+ await stop();
+ });
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
- const m = await mint()
- const {relay, walletServicePubkey, state, stop} = await startTestService({})
- state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)]
+ const m = await mint();
+ const { relay, walletServicePubkey, state, stop } = await startTestService({});
+ state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
- expect(response.error?.code).toBe('INSUFFICIENT_BALANCE')
- expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding')
- await stop()
- })
+ });
+ expect(response.error?.code).toBe('INSUFFICIENT_BALANCE');
+ expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding');
+ await stop();
+ });
it('rejects a request amount that mismatches the invoice amount', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
+ const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
amount: 5_000,
- })
- expect(response.error?.code).toBe('OTHER')
- expect(response.error?.message).toMatch(/match/i)
- await stop()
- })
+ });
+ expect(response.error?.code).toBe('OTHER');
+ expect(response.error?.message).toMatch(/match/i);
+ await stop();
+ });
it('rejects an amount-less invoice instead of guessing', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
+ const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc1pjqrstuvwxyz',
- })
- expect(response.error?.code).toBe('OTHER')
- expect(response.error?.message).toMatch(/amount/i)
- await stop()
- })
+ });
+ expect(response.error?.code).toBe('OTHER');
+ expect(response.error?.message).toMatch(/amount/i);
+ await stop();
+ });
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
- const m = await mint({meltAlwaysFails: true})
- const {relay, walletServicePubkey, state, stop} = await startTestService({
+ const m = await mint({ meltAlwaysFails: true });
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
// a short verify budget: the failed melt is classified by the poll
// running out, and that wait is the test's own clock
- poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
- })
- state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)]
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ });
+ const original = await makeBearer(m, '01'.repeat(32), 21_000);
+ state.bearers = [original];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
- })
- expect(response.error?.code).toBe('PAYMENT_FAILED')
+ });
+ expect(response.error?.code).toBe('PAYMENT_FAILED');
- // the funds came back, re-secured: the old secret burned, a fresh one
- // tracked unspent via the changeset - and no budget spend recorded
- expect(m.state.noteState('01'.repeat(32))).toBe('burned')
- const returned = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
- expect(returned.spent).toBeUndefined()
- expect(returned.amount).toBe(21_000)
- expect(m.state.noteState(requiredValue(noteK1(returned.url)))).toBe('outstanding')
- expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
- await stop()
- })
-})
+ expect(m.state.noteState('01'.repeat(32))).toBe('burned');
+ expect(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true);
+ expect(
+ state.bearers.find(
+ (bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent,
+ ),
+ ).toBeDefined();
+ expect(
+ state.bearers
+ .filter((bearer) => !bearer.spent)
+ .reduce((sum, bearer) => sum + bearer.amount, 0),
+ ).toBe(21_000);
+ expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
+ await stop();
+ });
+
+ it('conserves balance when a split payment fails and returns its carved funds', async () => {
+ const m = await mint({ meltAlwaysFails: true });
+ const { relay, walletServicePubkey, state, stop } = await startTestService({
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ });
+ state.bearers = [await makeBearer(m, '02'.repeat(32), 30_000)];
+
+ const response = await call(relay, walletServicePubkey, 'pay_invoice', {
+ invoice: 'lnbc210n1pjqrstuvwxyz',
+ });
+
+ expect(response.error?.code).toBe('PAYMENT_FAILED');
+ expect(
+ state.bearers
+ .filter((bearer) => !bearer.spent)
+ .reduce((sum, bearer) => sum + bearer.amount, 0),
+ ).toBe(30_000);
+ expect(state.bearers.find((bearer) => bearer.amount === 21_000 && !bearer.spent)).toBeDefined();
+ await stop();
+ });
+});
diff --git a/src/lnurlcash/nwc.storage.cases.ts b/src/lnurlcash/nwc.storage.cases.ts
index ebe3954..27d138e 100644
--- a/src/lnurlcash/nwc.storage.cases.ts
+++ b/src/lnurlcash/nwc.storage.cases.ts
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,119 +55,119 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
-} from './nwc.testProtocol'
-import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
+} from './nwc.testProtocol';
+import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('storage validation', () => {
it('drops malformed records instead of throwing', () => {
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
- {clientPubkey: 'nope'},
+ { clientPubkey: 'nope' },
{
version: 1,
ownerId: OWNER_ID,
clientPubkey: CLIENT_PUBKEY,
relays: RELAYS,
- budget: {maxMsat: 1000, periodMs: 1000},
- spent: {periodStart: 0, msat: 0},
+ budget: { maxMsat: 1000, periodMs: 1000 },
+ spent: { periodStart: 0, msat: 0 },
createdAt: 0,
},
]),
- )
- expect(readNwcConnections(OWNER_ID)).toHaveLength(1)
- expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY)
- })
+ );
+ expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
+ expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY);
+ });
it('returns nothing for garbage json', () => {
- localStorage.setItem('sattle_nwc_connections', '{{{')
- expect(readNwcConnections(OWNER_ID)).toEqual([])
- })
+ localStorage.setItem('sattle_nwc_connections', '{{{');
+ expect(readNwcConnections(OWNER_ID)).toEqual([]);
+ });
it('returns only strictly parsed records belonging to the requested owner', () => {
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
- budget: {maxMsat: 1000, periodMs: 1000},
+ budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 10,
- }).record
- const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 20).record
- const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]')
- if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records')
+ }).record;
+ const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 20).record;
+ const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
+ if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
...raw,
foreign,
- {...current, ownerId: 'malformed'},
+ { ...current, ownerId: 'malformed' },
{
clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))),
relays: RELAYS,
- budget: {maxMsat: 3000, periodMs: 3000},
- spent: {periodStart: 0, msat: 0},
+ budget: { maxMsat: 3000, periodMs: 3000 },
+ spent: { periodStart: 0, msat: 0 },
createdAt: 30,
},
]),
- )
+ );
- expect(readNwcConnections(OWNER_ID)).toEqual([current])
- expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign])
- })
+ expect(readNwcConnections(OWNER_ID)).toEqual([current]);
+ expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]);
+ });
it('adopts ownerless connections and enabled state only after owner proof', async () => {
- await saveLinkingKey(LINKING_KEY)
- const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}')
+ await saveLinkingKey(LINKING_KEY);
+ const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
if (typeof saved !== 'object' || saved === null) {
- throw new TypeError('Expected a saved linking-key record')
+ throw new TypeError('Expected a saved linking-key record');
}
- Reflect.deleteProperty(saved, 'ownerId')
- Reflect.deleteProperty(saved, 'version')
- localStorage.setItem('sattle_linking_key', JSON.stringify(saved))
+ Reflect.deleteProperty(saved, 'ownerId');
+ Reflect.deleteProperty(saved, 'version');
+ localStorage.setItem('sattle_linking_key', JSON.stringify(saved));
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{
clientPubkey: CLIENT_PUBKEY,
relays: RELAYS,
- budget: {maxMsat: 1000, periodMs: 1000},
- spent: {periodStart: 0, msat: 0},
+ budget: { maxMsat: 1000, periodMs: 1000 },
+ spent: { periodStart: 0, msat: 0 },
createdAt: 0,
},
]),
- )
- localStorage.setItem('sattle_nwc_enabled', 'true')
+ );
+ localStorage.setItem('sattle_nwc_enabled', 'true');
- expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow()
- expect(readNwcConnections(OWNER_ID)).toEqual([])
- expect(readNwcEnabled(OWNER_ID)).toBe(false)
+ expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow();
+ expect(readNwcConnections(OWNER_ID)).toEqual([]);
+ expect(readNwcEnabled(OWNER_ID)).toBe(false);
- ensureSavedKeyOwner(LINKING_KEY)
+ ensureSavedKeyOwner(LINKING_KEY);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 1,
enabled: true,
- })
- expect(readNwcConnections(OWNER_ID)).toHaveLength(1)
- expect(readNwcEnabled(OWNER_ID)).toBe(true)
+ });
+ expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
+ expect(readNwcEnabled(OWNER_ID)).toBe(true);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 0,
enabled: false,
- })
- })
+ });
+ });
it('does not expose one wallet enabled state to another owner', () => {
- writeNwcEnabled(OWNER_ID, true)
+ writeNwcEnabled(OWNER_ID, true);
- expect(readNwcEnabled(OWNER_ID)).toBe(true)
- expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false)
- })
+ expect(readNwcEnabled(OWNER_ID)).toBe(true);
+ expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false);
+ });
it('treats malformed owner-bearing enabled records as disabled', () => {
for (const value of [
- {version: 1, ownerId: 'malformed', enabled: true},
- {version: 2, ownerId: OWNER_ID, enabled: true},
- {version: 1, ownerId: OWNER_ID, enabled: 'true'},
+ { version: 1, ownerId: 'malformed', enabled: true },
+ { version: 2, ownerId: OWNER_ID, enabled: true },
+ { version: 1, ownerId: OWNER_ID, enabled: 'true' },
]) {
- localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value))
- expect(readNwcEnabled(OWNER_ID)).toBe(false)
+ localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value));
+ expect(readNwcEnabled(OWNER_ID)).toBe(false);
}
- })
-})
+ });
+});
diff --git a/src/lnurlcash/nwc.test.ts b/src/lnurlcash/nwc.test.ts
index b150f5e..a07f2c7 100644
--- a/src/lnurlcash/nwc.test.ts
+++ b/src/lnurlcash/nwc.test.ts
@@ -1,9 +1,9 @@
-import './nwc.connection.cases'
-import './nwc.storage.cases'
-import './nwc.ownership.cases'
-import './nwc.validation.cases'
-import './nwc.pay-a.cases'
-import './nwc.pay-b.cases'
-import './nwc.fencing.cases'
-import './nwc.invoice-a.cases'
-import './nwc.invoice-b.cases'
+import './nwc.connection.cases';
+import './nwc.storage.cases';
+import './nwc.ownership.cases';
+import './nwc.validation.cases';
+import './nwc.pay-a.cases';
+import './nwc.pay-b.cases';
+import './nwc.fencing.cases';
+import './nwc.invoice-a.cases';
+import './nwc.invoice-b.cases';
diff --git a/src/lnurlcash/nwc.testProtocol.ts b/src/lnurlcash/nwc.testProtocol.ts
index 96171e9..e3e6957 100644
--- a/src/lnurlcash/nwc.testProtocol.ts
+++ b/src/lnurlcash/nwc.testProtocol.ts
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,29 +29,29 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredValue, stubLocalStorage } from './test-utils';
-export const LINKING_KEY = new Uint8Array(32).fill(7)
-export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
-export const CLIENT_SECRET = hexToBytes('11'.repeat(32))
-export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET)
-export const STRANGER_SECRET = hexToBytes('22'.repeat(32))
-export const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
-export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY)
+export const LINKING_KEY = new Uint8Array(32).fill(7);
+export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
+export const CLIENT_SECRET = hexToBytes('11'.repeat(32));
+export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET);
+export const STRANGER_SECRET = hexToBytes('22'.repeat(32));
+export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
+export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
// never connected - the in-memory relay below stands in
-export const RELAYS = ['wss://relay-a.example']
+export const RELAYS = ['wss://relay-a.example'];
-let NOW = 1_800_000_000
-export const nowSeconds = (): number => NOW
+let NOW = 1_800_000_000;
+export const nowSeconds = (): number => NOW;
-export const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
+export const FAST_POLL = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) =>
connectionInfoOf(OTHER_LINKING_KEY, {
@@ -60,69 +60,69 @@ export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'],
clientPubkey: getPublicKey(STRANGER_SECRET),
relays: RELAYS,
budget,
- spent: {periodStart: now, msat: 0},
+ spent: { periodStart: now, msat: 0 },
createdAt: now,
- })
+ });
export const storeForeignConnection = (record: NwcConnectionRecord): void => {
- const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]')
- if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records')
- localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record]))
-}
+ const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
+ if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
+ localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record]));
+};
// an in-memory relay set: subscriptions register, emit delivers to every
// matching one, publish records
export const createFakeRelay = (): {
- transport: NwcTransport
- published: NostrEvent[]
- emit: (event: NostrEvent) => void
- emitAfterClose: (event: NostrEvent) => void
- subscriptionCount: () => number
+ transport: NwcTransport;
+ published: NostrEvent[];
+ emit: (event: NostrEvent) => void;
+ emitAfterClose: (event: NostrEvent) => void;
+ subscriptionCount: () => number;
} => {
- const published: NostrEvent[] = []
- const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = []
- const allSubs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = []
+ const published: NostrEvent[] = [];
+ const subs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
+ const allSubs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
const transport: NwcTransport = {
publish: (_relays, event) => {
- published.push(event)
- return Promise.resolve()
+ published.push(event);
+ return Promise.resolve();
},
subscribe: (_relays, filter, onEvent) => {
- const sub = {filter, onEvent}
- subs.push(sub)
- allSubs.push(sub)
+ const sub = { filter, onEvent };
+ subs.push(sub);
+ allSubs.push(sub);
return {
close: () => {
- const index = subs.indexOf(sub)
- if (index >= 0) subs.splice(index, 1)
+ const index = subs.indexOf(sub);
+ if (index >= 0) subs.splice(index, 1);
},
- }
+ };
},
- }
+ };
const deliver = (
- targets: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[],
+ targets: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[],
event: NostrEvent,
): void => {
for (const sub of [...targets]) {
- const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind)
- const wanted = sub.filter['#p']
- const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? ''))
- const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since
- if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event)
+ const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind);
+ const wanted = sub.filter['#p'];
+ const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? ''));
+ const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since;
+ if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event);
}
- }
- const emit = (event: NostrEvent): void => deliver(subs, event)
- const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event)
+ };
+ const emit = (event: NostrEvent): void => deliver(subs, event);
+ const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event);
return {
transport,
published,
emit,
emitAfterClose,
subscriptionCount: () => subs.length,
- }
-}
+ };
+};
-export type Encryption = 'nip44_v2' | 'nip04' | 'none'
+export type Encryption = 'nip44_v2' | 'nip04' | 'none';
// a NIP-47 request exactly as a real client would build it, signed by the
// connection's client secret
@@ -132,8 +132,8 @@ export const clientRequest = (
scheme: Encryption = 'nip44_v2',
createdAt: number = NOW,
): NostrEvent => {
- const tags: string[][] = [['p', walletServicePubkey]]
- if (scheme !== 'none') tags.push(['encryption', scheme])
+ const tags: string[][] = [['p', walletServicePubkey]];
+ if (scheme !== 'none') tags.push(['encryption', scheme]);
return finalizeEvent(
{
kind: NWC_REQUEST_KIND,
@@ -148,8 +148,8 @@ export const clientRequest = (
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content),
},
CLIENT_SECRET,
- )
-}
+ );
+};
export const methodRequest = (
walletServicePubkey: string,
@@ -158,31 +158,31 @@ export const methodRequest = (
scheme: Encryption = 'nip44_v2',
createdAt?: number,
): NostrEvent =>
- clientRequest(walletServicePubkey, JSON.stringify({method, params}), scheme, createdAt)
+ clientRequest(walletServicePubkey, JSON.stringify({ method, params }), scheme, createdAt);
// generous: a failed/never-settling melt is only classified after the
// verify-poll budget (seconds) runs out
export const waitFor = async (cond: () => boolean): Promise => {
for (let i = 0; i < 3000 && !cond(); i++) {
- await new Promise((resolve) => setTimeout(resolve, 5))
+ await new Promise((resolve) => setTimeout(resolve, 5));
}
- expect(cond()).toBe(true)
-}
+ expect(cond()).toBe(true);
+};
export const deferred = (): {
- promise: Promise
- resolve: () => void
- reject: (reason: Error) => void
+ promise: Promise;
+ resolve: () => void;
+ reject: (reason: Error) => void;
} => {
- let resolve = (): void => undefined
- let reject = (_reason: Error): void => undefined
+ let resolve = (): void => undefined;
+ let reject = (_reason: Error): void => undefined;
const promise = new Promise((resolvePromise, rejectPromise) => {
- resolve = resolvePromise
- reject = rejectPromise
- })
- return {promise, resolve, reject}
-}
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
export const resetNow = (): void => {
- NOW = 1_800_000_000
-}
+ NOW = 1_800_000_000;
+};
diff --git a/src/lnurlcash/nwc.testService.ts b/src/lnurlcash/nwc.testService.ts
index 4036e0e..22cb07f 100644
--- a/src/lnurlcash/nwc.testService.ts
+++ b/src/lnurlcash/nwc.testService.ts
@@ -6,13 +6,14 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { sha256 } from '@noble/hashes/sha2.js';
+import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, noteK1, serverOf } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,14 +30,14 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredValue, stubLocalStorage} from './test-utils'
-import {isJsonObject} from './jsonParsing'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredValue, stubLocalStorage } from './test-utils';
+import { isJsonObject } from './jsonParsing';
import {
CLIENT_SECRET,
@@ -49,13 +50,13 @@ import {
nowSeconds,
resetNow,
waitFor,
-} from './nwc.testProtocol'
-import type {Encryption} from './nwc.testProtocol'
+} from './nwc.testProtocol';
+import type { Encryption } from './nwc.testProtocol';
export type NwcResponsePayload = {
- result_type: string
- error: {code: string; message: string} | null
- result: Record | null
-}
+ result_type: string;
+ error: { code: string; message: string } | null;
+ result: Record | null;
+};
const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
isJsonObject(value) &&
@@ -64,7 +65,7 @@ const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
(isJsonObject(value.error) &&
typeof value.error.code === 'string' &&
typeof value.error.message === 'string')) &&
- (value.result === null || isJsonObject(value.result))
+ (value.result === null || isJsonObject(value.result));
export const readResponse = (
published: NostrEvent[],
@@ -73,19 +74,19 @@ export const readResponse = (
): NwcResponsePayload | null => {
const event = published.find(
(e) => e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === requestId),
- )
- if (!event) return null
+ );
+ if (!event) return null;
const plaintext =
scheme === 'nip44_v2'
? nip44v2.decrypt(
event.content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey),
)
- : nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content)
- const parsed: unknown = JSON.parse(plaintext)
- if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.')
- return parsed
-}
+ : nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content);
+ const parsed: unknown = JSON.parse(plaintext);
+ if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.');
+ return parsed;
+};
// drives one full request/response round trip over the fake relay
export const call = async (
@@ -95,30 +96,31 @@ export const call = async (
params: Record,
scheme: Encryption = 'nip44_v2',
): Promise => {
- const request = methodRequest(walletServicePubkey, method, params, scheme)
- relay.emit(request)
- await waitFor(() => readResponse(relay.published, request.id, scheme) !== null)
- return requiredValue(readResponse(relay.published, request.id, scheme))
-}
+ const request = methodRequest(walletServicePubkey, method, params, scheme);
+ relay.emit(request);
+ await waitFor(() => readResponse(relay.published, request.id, scheme) !== null);
+ return requiredValue(readResponse(relay.published, request.id, scheme));
+};
-export type Mint = Awaited>
-const mints: Mint[] = []
+export type Mint = Awaited>;
+const mints: Mint[] = [];
export const mint = async (options: Parameters[0] = {}): Promise => {
- const m = await createMockMint(options)
- mints.push(m)
- return m
-}
+ const m = await createMockMint(options);
+ mints.push(m);
+ return m;
+};
afterEach(async () => {
- await Promise.all(mints.splice(0).map((m) => m.close()))
-})
+ await Promise.all(mints.splice(0).map((m) => m.close()));
+});
-let bearerCounter = 0
+let bearerCounter = 0;
+let allocationCounter = 0;
export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise => {
- m.state.creditNote(k1, amountMsat)
- const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat)
- const info = await fetchNoteInfo(url)
- bearerCounter += 1
+ m.state.creditNote(k1, amountMsat);
+ const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat);
+ const info = await fetchNoteInfo(url);
+ bearerCounter += 1;
return {
id: `bearer-${bearerCounter}`,
url,
@@ -128,80 +130,198 @@ export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promi
mintPubkey: m.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now(),
- }
-}
+ };
+};
// the harness around startService: a fake relay, an in-memory "store"
// applying changesets the way the Pinia layer will, and a created
// connection with a pinned client secret
export const startTestService = async (options: {
- budgetMsat?: number
- periodMs?: number
- defaultMint?: string | null
- linkingKey?: Uint8Array
- poll?: typeof FAST_POLL
- claimPoll?: typeof FAST_POLL
- kit?: NwcServiceDeps['kit']
- commitChangeset?: (changeset: NwcChangeset) => Promise
+ budgetMsat?: number;
+ periodMs?: number;
+ defaultMint?: string | null;
+ linkingKey?: Uint8Array;
+ poll?: typeof FAST_POLL;
+ claimPoll?: typeof FAST_POLL;
+ kit?: NwcServiceDeps['kit'];
+ commitChangeset?: (changeset: NwcChangeset) => Promise;
+ setMintOutputRetirement?: () => Promise;
}): Promise<{
- relay: ReturnType
- walletServicePubkey: string
- state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]}
- stop: () => Promise
+ relay: ReturnType;
+ walletServicePubkey: string;
+ state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] };
+ stop: () => Promise;
}> => {
- const budgetMsat = options.budgetMsat ?? 1_000_000_000
+ const budgetMsat = options.budgetMsat ?? 1_000_000_000;
const connection = createConnection(options.linkingKey ?? LINKING_KEY, {
relays: RELAYS,
- budget: {maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000},
+ budget: { maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000 },
clientSecret: CLIENT_SECRET,
now: nowSeconds() * 1000,
- })
- const relay = createFakeRelay()
- const state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]} = {
+ });
+ const relay = createFakeRelay();
+ const state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] } = {
bearers: [],
changesets: [],
errors: [],
- }
+ };
const service = await startService(options.linkingKey ?? LINKING_KEY, {
assertCurrentOwner: () => undefined,
+ // deterministic stand-in for the wallet's BIP-32 allocation path
+ allocateOutputSecrets: (_server, count) => {
+ const secrets = Array.from({ length: count }, () => {
+ allocationCounter += 1;
+ return bytesToHex(sha256(utf8ToBytes(`nwc-test-allocation-${allocationCounter}`)));
+ });
+ return Promise.resolve(secrets);
+ },
+ // the keys a production caller serves from the trusted-mint registry:
+ // each created mock mint's current key plus any it retired
+ mintSignatureKeys: (server) =>
+ mints.flatMap((m) =>
+ new URL(m.url).host === server ? [m.state.pubkey, ...m.state.previousPubkeys] : [],
+ ),
+ recoverPendingMints: () => Promise.resolve(),
getBearers: () => state.bearers,
getDefaultMint: () => options.defaultMint ?? null,
+ persistMintOutput: (note) => {
+ bearerCounter += 1;
+ const bearer: Bearer = {
+ ...note,
+ id: `staged-${bearerCounter}`,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+ state.bearers.push(bearer);
+ return Promise.resolve(bearer);
+ },
+ discardMintOutput: (staged) => {
+ state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
+ return Promise.resolve();
+ },
+ setMintOutputRetirement: (staged, retireAfter) => {
+ const override = options.setMintOutputRetirement?.();
+ if (override) return override;
+ const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
+ if (index < 0) return Promise.reject(new Error('The staged bearer is missing.'));
+ const updated: Bearer = {
+ ...staged,
+ pendingMint: { ...staged.pendingMint, retireAfter },
+ updatedAt: Date.now(),
+ };
+ state.bearers[index] = updated;
+ return Promise.resolve();
+ },
+ reserveMintOutputSource: (staged, sourceId, recoverySecret) => {
+ const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
+ const source = state.bearers.find((bearer) => bearer.id === sourceId);
+ if (index < 0 || !source) return Promise.reject(new Error('The payment journal is missing.'));
+ source.spent = true;
+ state.bearers[index] = {
+ ...state.bearers[index]!,
+ pendingMint: {
+ ...state.bearers[index]!.pendingMint,
+ sourceBearerId: sourceId,
+ sourceRecoverySecret: recoverySecret,
+ },
+ };
+ return Promise.resolve();
+ },
+ finalizeMintOutput: async (staged, note) => {
+ const changeset: NwcChangeset = { add: [], markSpent: [] };
+ await options.commitChangeset?.(changeset);
+ state.changesets.push(changeset);
+ const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
+ if (index < 0) throw new Error('The staged bearer is missing.');
+ state.bearers[index] = { ...staged, ...note, pendingMint: undefined, updatedAt: Date.now() };
+ },
+ finalizePaymentReturn: async (staged, note) => {
+ const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
+ if (index < 0) throw new Error('The staged bearer is missing.');
+ const sourceId = state.bearers[index]?.pendingMint?.sourceBearerId;
+ if (sourceId) {
+ const source = state.bearers.find((bearer) => bearer.id === sourceId);
+ if (source) source.spent = true;
+ }
+ state.bearers[index] = {
+ ...state.bearers[index]!,
+ ...note,
+ pendingMint: undefined,
+ updatedAt: Date.now(),
+ };
+ },
+ finalizeSpentMintOutput: (staged) => {
+ state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
+ return Promise.resolve();
+ },
+ commitCarve: async (carve) => {
+ // a checkpoint is identified by its output secret: the landed phase's
+ // URL carries a signature the staged phase's did not have yet
+ const sameOutput = (url: string, noteUrl: string): boolean =>
+ serverOf(url) === serverOf(noteUrl) &&
+ noteK1(url) !== null &&
+ noteK1(url) === noteK1(noteUrl);
+ const additions = [carve.note, ...(carve.change ? [carve.change] : [])].filter(
+ (note) => !state.bearers.some((bearer) => sameOutput(bearer.url, note.url)),
+ );
+ const changeset: NwcChangeset = {
+ add: additions,
+ markSpent: carve.consumed.map((bearer) => bearer.id),
+ };
+ await options.commitChangeset?.(changeset);
+ state.changesets.push(changeset);
+ for (const bearer of state.bearers) {
+ if (changeset.markSpent.includes(bearer.id)) bearer.spent = true;
+ }
+ for (const note of additions) {
+ bearerCounter += 1;
+ state.bearers.unshift({
+ ...note,
+ id: `carve-${bearerCounter}`,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ }
+ const committed = state.bearers.find((bearer) => sameOutput(bearer.url, carve.note.url));
+ if (!committed) throw new Error('The carved note was not committed.');
+ return committed;
+ },
applyChangeset: async (changeset: NwcChangeset) => {
- await options.commitChangeset?.(changeset)
- state.changesets.push(changeset)
+ await options.commitChangeset?.(changeset);
+ state.changesets.push(changeset);
for (const id of changeset.markSpent) {
- const bearer = state.bearers.find((b) => b.id === id)
- if (bearer) bearer.spent = true
+ const bearer = state.bearers.find((b) => b.id === id);
+ if (bearer) bearer.spent = true;
}
for (const note of changeset.add) {
- bearerCounter += 1
+ bearerCounter += 1;
state.bearers.push({
...note,
id: `added-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now(),
- })
+ });
}
},
onError: (err) => {
- state.errors.push(err)
+ state.errors.push(err);
},
transport: relay.transport,
kit: options.kit,
poll: options.poll ?? FAST_POLL,
claimPoll: options.claimPoll ?? FAST_POLL,
nowSeconds,
- })
+ });
return {
relay,
walletServicePubkey: connection.walletServicePubkey,
state,
stop: service.stop,
- }
-}
+ };
+};
beforeEach(async () => {
- stubLocalStorage()
- await saveLinkingKey(LINKING_KEY)
- resetNow()
-})
+ stubLocalStorage();
+ await saveLinkingKey(LINKING_KEY);
+ resetNow();
+});
diff --git a/src/lnurlcash/nwc.ts b/src/lnurlcash/nwc.ts
index f0b3e67..7e8d5e1 100644
--- a/src/lnurlcash/nwc.ts
+++ b/src/lnurlcash/nwc.ts
@@ -37,7 +37,7 @@
// per-connection request queues
// Budget/connection persistence lives in storage/nwcConnections.ts.
-export type {NwcBudget, NwcBudgetSpend, NwcConnectionRecord} from './storage/nwcConnections'
+export type { NwcBudget, NwcBudgetSpend, NwcConnectionRecord } from './storage/nwcConnections';
export {
clearNwcStorageForOwner,
clearUnownedNwcStorage,
@@ -46,9 +46,9 @@ export {
readNwcConnections,
removeNwcConnection,
writeNwcConnections,
-} from './storage/nwcConnections'
-export {readNwcEnabled, writeNwcEnabled} from './storage/nwcEnabled'
-export type {NwcLegacyMigrationResult} from './storage/nwcConnections'
+} from './storage/nwcConnections';
+export { readNwcEnabled, writeNwcEnabled } from './storage/nwcEnabled';
+export type { NwcLegacyMigrationResult } from './storage/nwcConnections';
export {
buildConnectionString,
@@ -57,13 +57,13 @@ export {
deriveNwcWalletKey,
nwcWalletPubkey,
parseConnectionString,
-} from './nwc/connection'
+} from './nwc/connection';
export type {
CreateConnectionOptions,
CreatedConnection,
NwcConnectionInfo,
ParsedConnectionString,
-} from './nwc/connection'
+} from './nwc/connection';
export {
NWC_INFO_KIND,
@@ -75,7 +75,7 @@ export {
decryptRequest,
errResult,
okResult,
-} from './nwc/protocol'
+} from './nwc/protocol';
export type {
DecryptedNwcRequest,
NostrEvent,
@@ -84,15 +84,15 @@ export type {
NwcMethod,
NwcRequest,
NwcResponse,
-} from './nwc/protocol'
+} from './nwc/protocol';
-export {defaultNwcTransport} from './nwc/transport'
-export type {NostrFilter, NwcSubscription, NwcTransport} from './nwc/transport'
+export { defaultNwcTransport } from './nwc/transport';
+export type { NostrFilter, NwcSubscription, NwcTransport } from './nwc/transport';
-export {budgetRemainingMsat, recordSpend} from './nwc/budget'
-export type {PendingInvoice, RequestContext} from './nwc/context'
-export {invoiceResult, resolvePaymentHash} from './nwc/invoices'
-export {payChangeset} from './nwc/pay'
+export { budgetRemainingMsat, recordSpend } from './nwc/budget';
+export type { PendingInvoice, RequestContext } from './nwc/context';
+export { invoiceResult, resolvePaymentHash } from './nwc/invoices';
+export { payChangeset } from './nwc/pay';
-export {startService} from './nwc/service'
-export type {NwcChangeset, NwcService, NwcServiceDeps} from './nwc/service'
+export { startService } from './nwc/service';
+export type { NwcChangeset, NwcService, NwcServiceDeps } from './nwc/service';
diff --git a/src/lnurlcash/nwc.validation.cases.ts b/src/lnurlcash/nwc.validation.cases.ts
index 09fdc54..7982df0 100644
--- a/src/lnurlcash/nwc.validation.cases.ts
+++ b/src/lnurlcash/nwc.validation.cases.ts
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
-import {afterEach, beforeEach, describe, expect, it} from 'vitest'
-import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
-import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
-import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
+import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
-} from './nwc'
-import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
-import type {NostrFilter} from './nwc/transport'
-import type {NwcChangeset} from './nwc'
-import type {Bearer} from './types'
-import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
-import {requiredValue, stubLocalStorage} from './test-utils'
+} from './nwc';
+import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
+import type { NostrFilter } from './nwc/transport';
+import type { NwcChangeset } from './nwc';
+import type { Bearer } from './types';
+import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
+import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,45 +55,45 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
-} from './nwc.testProtocol'
-import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
+} from './nwc.testProtocol';
+import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: request validation', () => {
it('answers an unknown method with NOT_IMPLEMENTED', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
- const response = await call(relay, walletServicePubkey, 'get_payments', {})
- expect(response.result_type).toBe('get_payments')
- expect(response.error?.code).toBe('NOT_IMPLEMENTED')
- expect(response.result).toBeNull()
- await stop()
- })
+ const { relay, walletServicePubkey, stop } = await startTestService({});
+ const response = await call(relay, walletServicePubkey, 'get_payments', {});
+ expect(response.result_type).toBe('get_payments');
+ expect(response.error?.code).toBe('NOT_IMPLEMENTED');
+ expect(response.result).toBeNull();
+ await stop();
+ });
it('answers a malformed (non-JSON) request with an error, not a crash', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
- const request = clientRequest(walletServicePubkey, 'this is not json')
- relay.emit(request)
- await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null)
- const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2'))
- expect(response.error?.code).toBe('OTHER')
- await stop()
- })
+ const { relay, walletServicePubkey, stop } = await startTestService({});
+ const request = clientRequest(walletServicePubkey, 'this is not json');
+ relay.emit(request);
+ await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
+ const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2'));
+ expect(response.error?.code).toBe('OTHER');
+ await stop();
+ });
it('answers a JSON request without a method with an error', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
- const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}}))
- relay.emit(request)
- await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null)
+ const { relay, walletServicePubkey, stop } = await startTestService({});
+ const request = clientRequest(walletServicePubkey, JSON.stringify({ params: {} }));
+ relay.emit(request);
+ await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe(
'OTHER',
- )
- await stop()
- })
+ );
+ await stop();
+ });
it('ignores a request signed by a stranger key - silently', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
+ const { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip44v2.encrypt(
- JSON.stringify({method: 'get_balance', params: {}}),
+ JSON.stringify({ method: 'get_balance', params: {} }),
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey),
- )
+ );
const forged = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
@@ -105,20 +105,20 @@ describe('service: request validation', () => {
content,
},
STRANGER_SECRET,
- )
- relay.emit(forged)
- await new Promise((resolve) => setTimeout(resolve, 100))
- expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0)
- await stop()
- })
+ );
+ relay.emit(forged);
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
+ await stop();
+ });
it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
+ const { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip04Encrypt(
CLIENT_SECRET,
walletServicePubkey,
- JSON.stringify({method: 'get_balance', params: {}}),
- )
+ JSON.stringify({ method: 'get_balance', params: {} }),
+ );
const request = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
@@ -130,58 +130,58 @@ describe('service: request validation', () => {
content,
},
CLIENT_SECRET,
- )
- relay.emit(request)
+ );
+ relay.emit(request);
// the error answer goes out in the legacy scheme every client reads
- await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null)
+ await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe(
'UNSUPPORTED_ENCRYPTION',
- )
- await stop()
- })
+ );
+ await stop();
+ });
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
+ const { relay, walletServicePubkey, stop } = await startTestService({});
for (const scheme of ['none', 'nip04'] as const) {
- const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme)
- relay.emit(request)
- await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null)
- const response = requiredValue(readResponse(relay.published, request.id, 'nip04'))
- expect(response.error).toBeNull()
- expect(response.result).toEqual({balance: 0})
+ const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme);
+ relay.emit(request);
+ await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
+ const response = requiredValue(readResponse(relay.published, request.id, 'nip04'));
+ expect(response.error).toBeNull();
+ expect(response.result).toEqual({ balance: 0 });
// the response mirrors the request's scheme
const event = requiredValue(
relay.published.find(
(e) =>
e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === request.id),
),
- )
- expect(event.tags).toContainEqual(['encryption', 'nip04'])
+ );
+ expect(event.tags).toContainEqual(['encryption', 'nip04']);
}
- await stop()
- })
+ await stop();
+ });
it('drops requests older than the replay window unanswered', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
+ const { relay, walletServicePubkey, stop } = await startTestService({});
const stale = methodRequest(
walletServicePubkey,
'get_balance',
{},
'nip44_v2',
nowSeconds() - 1200,
- )
- relay.emit(stale)
- await new Promise((resolve) => setTimeout(resolve, 100))
- expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0)
- await stop()
- })
+ );
+ relay.emit(stale);
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
+ await stop();
+ });
it('picks up no new requests after stop', async () => {
- const {relay, walletServicePubkey, stop} = await startTestService({})
- await stop()
- const request = methodRequest(walletServicePubkey, 'get_balance', {})
- relay.emitAfterClose(request)
- await new Promise((resolve) => setTimeout(resolve, 100))
- expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0)
- })
-})
+ const { relay, walletServicePubkey, stop } = await startTestService({});
+ await stop();
+ const request = methodRequest(walletServicePubkey, 'get_balance', {});
+ relay.emitAfterClose(request);
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
+ });
+});
diff --git a/src/lnurlcash/nwc/budget.ts b/src/lnurlcash/nwc/budget.ts
index 628c970..ef5a91c 100644
--- a/src/lnurlcash/nwc/budget.ts
+++ b/src/lnurlcash/nwc/budget.ts
@@ -5,14 +5,14 @@
// check + record below atomic enough: no two pays of one connection ever
// run this concurrently.
-import type {NwcConnectionRecord} from '../storage/nwcConnections'
-import {persistNwcConnection} from '../storage/nwcConnections'
+import type { NwcConnectionRecord } from '../storage/nwcConnections';
+import { persistNwcConnection } from '../storage/nwcConnections';
export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => {
- const {maxMsat, periodMs} = record.budget
- if (nowMs - record.spent.periodStart >= periodMs) return maxMsat
- return Math.max(0, maxMsat - record.spent.msat)
-}
+ const { maxMsat, periodMs } = record.budget;
+ if (nowMs - record.spent.periodStart >= periodMs) return maxMsat;
+ return Math.max(0, maxMsat - record.spent.msat);
+};
// rolls the period when it expired, then adds the spend; persists (the
// caller's queue serialized this read-modify-write)
@@ -22,14 +22,14 @@ export const recordSpend = (
amountMsat: number,
nowMs: number,
): NwcConnectionRecord => {
- const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
+ const expired = nowMs - record.spent.periodStart >= record.budget.periodMs;
return persistNwcConnection(ownerId, {
...record,
spent: expired
- ? {periodStart: nowMs, msat: amountMsat}
+ ? { periodStart: nowMs, msat: amountMsat }
: {
periodStart: record.spent.periodStart,
msat: record.spent.msat + amountMsat,
},
- })
-}
+ });
+};
diff --git a/src/lnurlcash/nwc/connection.ts b/src/lnurlcash/nwc/connection.ts
index 8407637..f8bd0d7 100644
--- a/src/lnurlcash/nwc/connection.ts
+++ b/src/lnurlcash/nwc/connection.ts
@@ -20,17 +20,17 @@
// The connection string follows NIP-47 exactly:
// nostr+walletconnect://?relay=wss://...&secret=
-import {sha256} from '@noble/hashes/sha2.js'
-import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
-import {getPublicKey} from 'nostr-tools/pure'
+import { sha256 } from '@noble/hashes/sha2.js';
+import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
+import { getPublicKey } from 'nostr-tools/pure';
-import {linkingPubKeyHex} from '../keys'
-import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections'
-import {persistNwcConnection} from '../storage/nwcConnections'
+import { linkingPubKeyHex } from '../keys';
+import type { NwcBudget, NwcConnectionRecord } from '../storage/nwcConnections';
+import { persistNwcConnection } from '../storage/nwcConnections';
-const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1'
+const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1';
-const HEX_64 = /^[0-9a-f]{64}$/i
+const HEX_64 = /^[0-9a-f]{64}$/i;
// Deterministic: sha256(linking key || context || client pubkey). The
// result is a secp256k1 secret key used ONLY as this connection's
@@ -43,16 +43,16 @@ export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: str
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
...hexToBytes(clientPubkey),
]),
- )
+ );
// the x-only nostr pubkey the client addresses its requests to
export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string =>
- getPublicKey(walletSecretKey)
+ getPublicKey(walletSecretKey);
export type NwcConnectionInfo = {
- record: NwcConnectionRecord
- walletServicePubkey: string
-}
+ record: NwcConnectionRecord;
+ walletServicePubkey: string;
+};
// the runtime view of a persisted record: the record plus its re-derived
// wallet-service identity
@@ -62,21 +62,21 @@ export const connectionInfoOf = (
): NwcConnectionInfo => ({
record,
walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)),
-})
+});
export type CreatedConnection = NwcConnectionInfo & {
// shown to the holder exactly once - it carries the client secret,
// which the wallet deliberately does NOT store
- connectionString: string
-}
+ connectionString: string;
+};
export type CreateConnectionOptions = {
- relays: string[]
- budget: NwcBudget
+ relays: string[];
+ budget: NwcBudget;
// test hook: a fixed client secret (32 bytes) instead of a random one
- clientSecret?: Uint8Array
- now?: number
-}
+ clientSecret?: Uint8Array;
+ now?: number;
+};
// Creates and persists a connection. The client secret is random by
// default; the wallet-service key falls out of the derivation above.
@@ -85,21 +85,21 @@ export const createConnection = (
options: CreateConnectionOptions,
): CreatedConnection => {
if (options.relays.length === 0) {
- throw new Error('A connection needs at least one relay.')
+ throw new Error('A connection needs at least one relay.');
}
- const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32))
- const clientPubkey = getPublicKey(clientSecret)
- const ownerId = linkingPubKeyHex(linkingPrivKey)
+ const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32));
+ const clientPubkey = getPublicKey(clientSecret);
+ const ownerId = linkingPubKeyHex(linkingPrivKey);
const record = persistNwcConnection(ownerId, {
version: 1,
ownerId,
clientPubkey,
relays: options.relays,
budget: options.budget,
- spent: {periodStart: options.now ?? Date.now(), msat: 0},
+ spent: { periodStart: options.now ?? Date.now(), msat: 0 },
createdAt: options.now ?? Date.now(),
- })
- const info = connectionInfoOf(linkingPrivKey, record)
+ });
+ const info = connectionInfoOf(linkingPrivKey, record);
return {
...info,
connectionString: buildConnectionString(
@@ -107,43 +107,43 @@ export const createConnection = (
bytesToHex(clientSecret),
record.relays,
),
- }
-}
+ };
+};
export const buildConnectionString = (
walletServicePubkey: string,
clientSecretHex: string,
relays: string[],
): string => {
- const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&')
- return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`
-}
+ const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&');
+ return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`;
+};
export type ParsedConnectionString = {
- walletServicePubkey: string
- clientSecret: string
- relays: string[]
-}
+ walletServicePubkey: string;
+ clientSecret: string;
+ relays: string[];
+};
// parses a NIP-47 connection string; returns null for anything that isn't
// exactly one (a client-side counterpart of buildConnectionString, here so
// the format has a tested inverse)
export const parseConnectionString = (uri: string): ParsedConnectionString | null => {
- let url: URL
+ let url: URL;
try {
- url = new URL(uri.trim())
+ url = new URL(uri.trim());
} catch {
- return null
+ return null;
}
- if (url.protocol !== 'nostr+walletconnect:') return null
+ if (url.protocol !== 'nostr+walletconnect:') return null;
// the host is the wallet-service pubkey; normalize case so a
// hand-typed uppercase string still parses (nostr pubkeys are
// conventionally lowercase hex)
- const walletServicePubkey = url.host.toLowerCase()
- if (!HEX_64.test(walletServicePubkey)) return null
- const secret = url.searchParams.get('secret')
- if (!secret || !HEX_64.test(secret)) return null
- const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay))
- if (relays.length === 0) return null
- return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays}
-}
+ const walletServicePubkey = url.host.toLowerCase();
+ if (!HEX_64.test(walletServicePubkey)) return null;
+ const secret = url.searchParams.get('secret');
+ if (!secret || !HEX_64.test(secret)) return null;
+ const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay));
+ if (relays.length === 0) return null;
+ return { walletServicePubkey, clientSecret: secret.toLowerCase(), relays };
+};
diff --git a/src/lnurlcash/nwc/context.ts b/src/lnurlcash/nwc/context.ts
index 1e5148f..9fc060a 100644
--- a/src/lnurlcash/nwc/context.ts
+++ b/src/lnurlcash/nwc/context.ts
@@ -5,83 +5,119 @@
// no logic, so every other nwc/ module can import from here without
// cycles.
-import type {LnurlcashOptions} from 'lnurlcash-kit'
+import type { LnurlcashOptions } from 'lnurlcash-kit';
-import type {Bearer, NewBearer} from '../types'
-import type {NwcConnectionRecord} from '../storage/nwcConnections'
-import type {PreparedMint} from '../ops'
-import type {PollOptions} from '../ops/shared'
+import type { Bearer, NewBearer } from '../types';
+import type { NwcConnectionRecord } from '../storage/nwcConnections';
+import type { PreparedMint } from '../ops';
+import type { CarveResult } from '../ops';
+import type { OutputSecretAllocator } from '../ops/allocation';
+import type { MintSignatureKeys } from '../ops/shared';
+import type { PollOptions } from '../ops/shared';
-import type {NwcConnectionInfo} from './connection'
-import type {NwcMethod} from './protocol'
-import type {NwcTransport} from './transport'
+import type { NwcConnectionInfo } from './connection';
+import type { NwcMethod } from './protocol';
+import type { NwcTransport } from './transport';
// the state delta the caller's store applies after an op ran: new notes
// to track, existing bearer ids to lock spent. Born-spent notes (a
// freshly carved note that was melted away) are deliberately NOT part of
// it - they were never the wallet's money in a trackable state
export type NwcChangeset = {
- add: NewBearer[]
- markSpent: string[]
-}
+ add: NewBearer[];
+ markSpent: string[];
+};
export type NwcServiceDeps = {
- getBearers: () => Bearer[]
+ getBearers: () => Bearer[];
// the mint make_invoice issues invoices against (the wallet's default
// mint - NIP-47's make_invoice carries no mint choice)
- getDefaultMint: () => string | null
- assertCurrentOwner: () => void
+ getDefaultMint: () => string | null;
+ assertCurrentOwner: () => void;
+ // the wallet's durable allocation path (BIP-32 reservation) for every
+ // note output secret a request stages
+ allocateOutputSecrets: (
+ server: string,
+ count: number,
+ assertOwner: () => void,
+ ) => Promise;
+ // trusted current+previous signing keys a landed carve output verifies
+ // against - the trusted-mint registry, owner-scoped
+ mintSignatureKeys: MintSignatureKeys;
+ recoverPendingMints: (assertOwner: () => void) => Promise;
+ persistMintOutput: (note: NewBearer, assertOwner: () => void) => Promise;
+ discardMintOutput: (staged: Bearer, assertOwner: () => void) => Promise;
+ setMintOutputRetirement: (
+ staged: Bearer,
+ retireAfter: number,
+ assertOwner: () => void,
+ ) => Promise;
+ reserveMintOutputSource: (
+ staged: Bearer,
+ sourceId: string,
+ recoverySecret: string,
+ assertOwner: () => void,
+ ) => Promise;
+ finalizeMintOutput: (staged: Bearer, note: NewBearer, assertOwner: () => void) => Promise;
+ finalizePaymentReturn: (
+ staged: Bearer,
+ note: NewBearer,
+ assertOwner: () => void,
+ ) => Promise;
+ finalizeSpentMintOutput: (staged: Bearer, assertOwner: () => void) => Promise;
+ commitCarve: (carve: CarveResult, assertOwner: () => void) => Promise;
applyChangeset: (
changeset: NwcChangeset,
connection: NwcConnectionInfo,
method: NwcMethod,
assertOwner: () => void,
- ) => Promise
- transport?: NwcTransport
+ ) => Promise;
+ transport?: NwcTransport;
// kit transport overrides (fetch injection, timeouts)
- kit?: LnurlcashOptions
+ kit?: LnurlcashOptions;
// verify-poll budget for pay_invoice outcome classification
- poll?: PollOptions
+ poll?: PollOptions;
// verify-poll budget for the background claim after make_invoice -
// generous by default: the client pays the invoice whenever it pays it
- claimPoll?: PollOptions
+ claimPoll?: PollOptions;
// background failures (a lost claim, a rejected publish) have no caller
// to throw to - they surface here
- onError?: (error: unknown, connection: NwcConnectionInfo) => void
+ onError?: (error: unknown, connection: NwcConnectionInfo) => void;
// test hook: pinned clock for event timestamps and expirations
- nowSeconds?: () => number
-}
+ nowSeconds?: () => number;
+};
export type PendingInvoice = {
- invoice: string
- paymentHash: string
+ invoice: string;
+ paymentHash: string;
// the gross invoiced amount (net + mint fee) - what the payer pays
- amountMsat: number
- description?: string
- createdAt: number // unix seconds
- expiresAt?: number
- prepared: PreparedMint
- state: 'pending' | 'settled' | 'failed'
- preimage?: string
- settledAt?: number
-}
+ amountMsat: number;
+ description?: string;
+ createdAt: number; // unix seconds
+ expiresAt?: number;
+ prepared: PreparedMint;
+ stagedOutput: Bearer;
+ state: 'pending' | 'settled' | 'failed';
+ preimage?: string;
+ settledAt?: number;
+};
// what a method handler sees of its connection's runtime: the deps, the
// (fresh) connection info - budget updates replace the record, so it's a
// getter, not a snapshot - the invoice registry, and the clock
export type RequestContext = {
- deps: NwcServiceDeps
- connection: () => NwcConnectionInfo
- updateRecord: (record: NwcConnectionRecord) => void
- invoices: Map
- nowSeconds: () => number
- assertOwner: () => void
+ deps: NwcServiceDeps;
+ connection: () => NwcConnectionInfo;
+ updateRecord: (record: NwcConnectionRecord) => void;
+ invoices: Map;
+ nowSeconds: () => number;
+ assertOwner: () => void;
// Starts service-owned work only while the service accepts new work.
// Accepted tasks become part of stop's drain before key cleanup.
- startBackground: (work: () => Promise) => boolean
+ startBackground: (work: () => Promise) => boolean;
// fires when the service stops: long OBSERVATION waits (the invoice
// claim poll) must interrupt themselves on it. Work that has already
// reached a fund-critical commit must NOT consult it - stop awaits
// those tasks through its drain
- stopSignal: AbortSignal
-}
+ stopSignal: AbortSignal;
+};
diff --git a/src/lnurlcash/nwc/dispatch.ts b/src/lnurlcash/nwc/dispatch.ts
index 99c06f8..5c6c337 100644
--- a/src/lnurlcash/nwc/dispatch.ts
+++ b/src/lnurlcash/nwc/dispatch.ts
@@ -4,23 +4,25 @@
// results go OUT through deps.applyChangeset - this engine never touches
// wallet state itself.
-import {noteK1, sameInvoice} from 'lnurlcash-kit'
+import { noteK1, sameInvoice } from 'lnurlcash-kit';
-import type {Bearer} from '../types'
-import type {PreparedMint} from '../ops'
-import {prepareMint} from '../ops'
-import {PollAbortedError} from '../ops/shared'
+import type { Bearer } from '../types';
+import type { PreparedMint } from '../ops';
+import { prepareMint } from '../ops';
+import { PollAbortedError } from '../ops/shared';
-import type {PendingInvoice, RequestContext} from './context'
-import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices'
-import {handlePayInvoice} from './pay'
-import type {NwcRequest, NwcResponse} from './protocol'
-import {NWC_METHODS, errResult, okResult} from './protocol'
+import type { PendingInvoice, RequestContext } from './context';
+import { invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim } from './invoices';
+import { handlePayInvoice } from './pay';
+import type { NwcRequest, NwcResponse } from './protocol';
+import { NWC_METHODS, errResult, okResult } from './protocol';
+
+const MAX_PENDING_MINT_OUTPUTS = 20;
// the same eligibility carve applies - the balance answers "what could
// this wallet actually pay with right now"
const spendable = (bearer: Bearer): boolean =>
- !bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url)
+ !bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url);
const handleGetInfo = (ctx: RequestContext): NwcResponse =>
okResult('get_info', {
@@ -33,7 +35,7 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse =>
network: 'mainnet',
methods: [...NWC_METHODS],
notifications: [],
- })
+ });
const handleGetBalance = (ctx: RequestContext): NwcResponse =>
okResult('get_balance', {
@@ -41,25 +43,88 @@ const handleGetBalance = (ctx: RequestContext): NwcResponse =>
.getBearers()
.filter(spendable)
.reduce((sum, b) => sum + b.amount, 0),
- })
+ });
const handleMakeInvoice = async (
ctx: RequestContext,
params: Record,
): Promise => {
- const amountMsat = Number(params.amount)
+ const amountMsat = Number(params.amount);
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
- return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.')
+ return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.');
}
- const mint = ctx.deps.getDefaultMint()
+ const mint = ctx.deps.getDefaultMint();
if (!mint) {
- return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.')
+ return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.');
}
- let prepared: PreparedMint
+ const now = ctx.nowSeconds();
+ if (
+ ctx.deps
+ .getBearers()
+ .some(
+ (bearer) =>
+ !bearer.spent &&
+ bearer.pendingMint?.retireAfter !== undefined &&
+ bearer.pendingMint.retireAfter <= now,
+ )
+ ) {
+ await ctx.deps.recoverPendingMints(ctx.assertOwner);
+ }
+ const pendingOutputs = ctx.deps
+ .getBearers()
+ .filter((bearer) => bearer.pendingMint && !bearer.spent).length;
+ if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) {
+ return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.');
+ }
+ let prepared: PreparedMint;
+ let stagedOutput: Bearer | undefined;
try {
- prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {})
+ prepared = await prepareMint(mint, amountMsat, {
+ ...(ctx.deps.kit ?? {}),
+ assertOwner: ctx.assertOwner,
+ allocateOutputSecrets: (server, count) =>
+ ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
+ persistOutput: async (note) => {
+ stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner);
+ },
+ });
} catch (err) {
- return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err))
+ if (stagedOutput) {
+ try {
+ await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
+ } catch (cleanupError) {
+ return errResult(
+ 'make_invoice',
+ 'INTERNAL',
+ cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
+ );
+ }
+ }
+ return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err));
+ }
+ if (!stagedOutput) {
+ return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.');
+ }
+ const retireAfter = invoiceRetireAfter(prepared.invoice);
+ if (retireAfter !== null) {
+ try {
+ await ctx.deps.setMintOutputRetirement(stagedOutput, retireAfter, ctx.assertOwner);
+ } catch (error) {
+ try {
+ await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
+ } catch (cleanupError) {
+ return errResult(
+ 'make_invoice',
+ 'INTERNAL',
+ cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
+ );
+ }
+ return errResult(
+ 'make_invoice',
+ 'INTERNAL',
+ error instanceof Error ? error.message : String(error),
+ );
+ }
}
const entry: PendingInvoice = {
invoice: prepared.invoice,
@@ -67,69 +132,70 @@ const handleMakeInvoice = async (
amountMsat: prepared.grossMsat,
createdAt: ctx.nowSeconds(),
prepared,
+ stagedOutput,
state: 'pending',
- }
+ };
if (typeof params.description === 'string' && params.description) {
- entry.description = params.description
+ entry.description = params.description;
}
- const expiry = Number(params.expiry)
+ const expiry = Number(params.expiry);
if (Number.isInteger(expiry) && expiry > 0) {
- entry.expiresAt = entry.createdAt + expiry
+ entry.expiresAt = entry.createdAt + expiry;
}
- ctx.invoices.set(entry.paymentHash, entry)
+ ctx.invoices.set(entry.paymentHash, entry);
// phase two runs in the background; the invoice goes out now and
// lookup_invoice reports the settlement the service-owned claim observes
const started = ctx.startBackground(() =>
settleAndClaim(ctx, entry).catch((err) => {
- entry.state = 'failed'
+ entry.state = 'failed';
// an interrupted claim poll is normal service teardown (stop
// aborted it), not a background failure worth surfacing
- if (err instanceof PollAbortedError) return
- ctx.deps.onError?.(err, ctx.connection())
+ if (err instanceof PollAbortedError) return;
+ ctx.deps.onError?.(err, ctx.connection());
}),
- )
+ );
if (!started) {
- entry.state = 'failed'
- return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.')
+ entry.state = 'failed';
+ return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.');
}
- return okResult('make_invoice', invoiceResult(entry))
-}
+ return okResult('make_invoice', invoiceResult(entry));
+};
const handleLookupInvoice = (ctx: RequestContext, params: Record): NwcResponse => {
- const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined
+ const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined;
const hashParam =
- typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined
+ typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined;
if (!invoiceParam && !hashParam) {
- return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.')
+ return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.');
}
- let entry = hashParam ? ctx.invoices.get(hashParam) : undefined
+ let entry = hashParam ? ctx.invoices.get(hashParam) : undefined;
if (!entry && invoiceParam) {
for (const candidate of ctx.invoices.values()) {
if (sameInvoice(candidate.invoice, invoiceParam)) {
- entry = candidate
- break
+ entry = candidate;
+ break;
}
}
}
if (!entry) {
- return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.')
+ return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.');
}
- return okResult('lookup_invoice', invoiceResult(entry))
-}
+ return okResult('lookup_invoice', invoiceResult(entry));
+};
export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise => {
switch (request.method) {
case 'get_info':
- return handleGetInfo(ctx)
+ return handleGetInfo(ctx);
case 'get_balance':
- return handleGetBalance(ctx)
+ return handleGetBalance(ctx);
case 'make_invoice':
- return await handleMakeInvoice(ctx, request.params)
+ return await handleMakeInvoice(ctx, request.params);
case 'pay_invoice':
- return await handlePayInvoice(ctx, request.params)
+ return await handlePayInvoice(ctx, request.params);
case 'lookup_invoice':
- return handleLookupInvoice(ctx, request.params)
+ return handleLookupInvoice(ctx, request.params);
default:
- return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`)
+ return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`);
}
-}
+};
diff --git a/src/lnurlcash/nwc/invoices.ts b/src/lnurlcash/nwc/invoices.ts
index e1a8caa..619a057 100644
--- a/src/lnurlcash/nwc/invoices.ts
+++ b/src/lnurlcash/nwc/invoices.ts
@@ -1,49 +1,77 @@
// The invoice registry the two-phase make_invoice / lookup_invoice pair
-// shares: make_invoice answers with the invoice immediately while this
-// module's settleAndClaim watches settlement in the background, claims
-// the minted note, and only THEN records the settlement the lookup
-// reports.
-//
-// THE PREIMAGE, in lnurlcash terms: on an UNNAMED mint the payment
-// preimage IS the minted note's initial secret. NIP-47 clients expect it
-// as the settlement receipt, so it is handed out - but only after
-// claimFromPreimage has rotated the fresh note, when that secret is
-// burned and worthless. If the rotation failed (claimed.rotated ===
-// false) the preimage is withheld: fund safety over spec comfort. On a
-// NAMED mint the note lives at a wallet-chosen secret from the start and
-// the preimage opens nothing at all, so it is an ordinary payment receipt
-// and always safe to hand out.
+// shares. A minted note always lives at the wallet-chosen secret that was
+// durably staged before quote creation; the payment preimage is only a
+// receipt. Settlement becomes visible only after the staged bearer has
+// been replaced with the confirmed note details.
-import {isPreimage, sameInvoice} from 'lnurlcash-kit'
-import {sha256} from '@noble/hashes/sha2.js'
-import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
+import { isPreimage, sameInvoice } from 'lnurlcash-kit';
+import { sha256 } from '@noble/hashes/sha2.js';
+import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
-import type {NewBearer} from '../types'
-import type {PreparedMint} from '../ops'
-import {claimFromPreimage, claimFromSecret} from '../ops/mint'
-import {pollVerifyUntilSettled} from '../ops/shared'
-import type {PollOptions} from '../ops/shared'
+import type { PreparedMint } from '../ops';
+import { MintedNoteSpentError } from '../ops';
+import { claimFromSecret } from '../ops/mint';
+import { pollVerifyUntilSettled } from '../ops/shared';
+import type { PollOptions } from '../ops/shared';
-import type {PendingInvoice, RequestContext} from './context'
+import type { PendingInvoice, RequestContext } from './context';
export const DEFAULT_CLAIM_POLL: Required> = {
intervalMs: 2000,
intervalCapMs: 10_000,
maxWaitMs: 15 * 60_000,
-}
+};
-// LUD-21 verify URLs end in /verify/ (the protocol's verify
-// convention) - that suffix is the invoice's real payment hash. A service
-// that shapes its verify URL differently gets a wallet-local correlation
-// id instead (sha256 of the invoice): still unique and stable for
-// make_invoice <-> lookup_invoice correlation, just not the on-chain hash.
+const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
+const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x');
+const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600;
+const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60;
+
+// The kit has already validated the invoice before this parser runs. Read
+// only BOLT11's timestamp and optional expiry tag; an unfamiliar shape stays
+// durable forever rather than risking retirement while it could still settle.
+export const invoiceRetireAfter = (invoice: string): number | null => {
+ const normalized = invoice.toLowerCase();
+ const separator = normalized.lastIndexOf('1');
+ if (separator < 1 || normalized.length - separator <= 6) return null;
+ const words = [...normalized.slice(separator + 1, -6)].map((character) =>
+ BECH32_CHARSET.indexOf(character),
+ );
+ if (words.length < 7 || words.some((word) => word < 0)) return null;
+ const timestamp = words.slice(0, 7).reduce((value, word) => value * 32 + word, 0);
+ if (!Number.isSafeInteger(timestamp)) return null;
+ let expiry = BOLT11_DEFAULT_EXPIRY_SECONDS;
+ let offset = 7;
+ // Conformance invoices omit the 65-byte signature; real BOLT11 invoices
+ // include its fixed 104 words after the tagged fields.
+ const taggedEnd = words.length >= 7 + 104 ? words.length - 104 : words.length;
+ while (offset + 3 <= taggedEnd) {
+ const tag = words[offset];
+ const firstLength = words[offset + 1];
+ const secondLength = words[offset + 2];
+ if (tag === undefined || firstLength === undefined || secondLength === undefined) return null;
+ const length = firstLength * 32 + secondLength;
+ const valueStart = offset + 3;
+ const valueEnd = valueStart + length;
+ if (valueEnd > taggedEnd) return null;
+ if (tag === BOLT11_EXPIRY_TAG) {
+ expiry = words.slice(valueStart, valueEnd).reduce((value, word) => value * 32 + word, 0);
+ if (!Number.isSafeInteger(expiry)) return null;
+ }
+ offset = valueEnd;
+ }
+ const retireAfter = timestamp + expiry + SETTLEMENT_GRACE_SECONDS;
+ return Number.isSafeInteger(retireAfter) ? retireAfter : null;
+};
+
+// LUD-21 verify URLs conventionally end in the payment hash. A service
+// that uses another shape gets a stable wallet-local correlation id.
export const resolvePaymentHash = (prepared: PreparedMint): string => {
- const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1]
- if (fromVerify) return fromVerify.toLowerCase()
- return bytesToHex(sha256(utf8ToBytes(prepared.invoice)))
-}
+ const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1];
+ if (fromVerify) return fromVerify.toLowerCase();
+ return bytesToHex(sha256(utf8ToBytes(prepared.invoice)));
+};
-// the NIP-47 transaction object make_invoice and lookup_invoice share
export const invoiceResult = (entry: PendingInvoice): Record => {
const result: Record = {
type: 'incoming',
@@ -53,126 +81,48 @@ export const invoiceResult = (entry: PendingInvoice): Record =>
amount: entry.amountMsat,
created_at: entry.createdAt,
metadata: {},
- }
- if (entry.description) result.description = entry.description
- if (entry.expiresAt) result.expires_at = entry.expiresAt
- if (entry.state === 'settled' && entry.preimage) {
- result.preimage = entry.preimage
- }
- if (entry.settledAt) result.settled_at = entry.settledAt
- return result
-}
+ };
+ if (entry.description) result.description = entry.description;
+ if (entry.expiresAt) result.expires_at = entry.expiresAt;
+ if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage;
+ if (entry.settledAt) result.settled_at = entry.settledAt;
+ return result;
+};
-// the background half of make_invoice: watch the invoice, and once it
-// settles claim the note (rotating it immediately on an unnamed mint) and
-// hand the fresh bearer to the caller. Settlement is recorded LAST - after
-// the claim and bearer commit - so lookup can only reveal durably tracked
-// funds and, on an unnamed mint, an already-burned secret.
-// Throws on any failure; the caller marks the entry failed and reports
-// through deps.onError.
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise => {
- if (entry.prepared.mode === 'named') return settleAndClaimNamed(ctx, entry)
- if (!entry.prepared.verifyUrl) {
- throw new Error(
- 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
- )
- }
- // the observation half is interruptible (the client may never pay, so
- // the poll can legally outlive the service); once settlement is seen,
- // everything below - claim, rotate, bearer commit - is fund-critical and
- // deliberately ignores the stop signal: stop's drain awaits it
- const result = await pollVerifyUntilSettled(
- entry.prepared.verifyUrl,
- {...(ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL), signal: ctx.stopSignal},
- ctx.deps.kit ?? {},
- )
- // 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, entry.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.')
- }
- // claimFromPreimage IS claimMintedNote's claim half (poll above is the
- // other half) - invoked in two steps here because NWC needs the
- // preimage, which claimMintedNote deliberately discards
- const claimed = await claimFromPreimage(entry.prepared, preimage, {
- ...(ctx.deps.kit ?? {}),
- assertOwner: ctx.assertOwner,
- })
- const add: NewBearer[] = [claimed.note]
- if (claimed.possibleCopy) add.push(claimed.possibleCopy)
- await ctx.deps.applyChangeset(
- {add, markSpent: []},
- ctx.connection(),
- 'make_invoice',
- ctx.assertOwner,
- )
- entry.settledAt = ctx.nowSeconds()
- entry.state = 'settled'
- if (claimed.rotated) {
- // see the header: only a rotated note makes the preimage a worthless
- // secret, safe to hand out as the settlement receipt
- entry.preimage = preimage
- }
-}
-
-// the named-mint half of settleAndClaim: the note lands at the wallet's
-// own secret, so its APPEARANCE there is the settlement observation and no
-// rotate follows. When the mint also serves the quote's verify, that
-// answer's preimage is kept as the NIP-47 receipt (an ordinary payment
-// proof on a named mint); a named mint without verify yields a settled
-// lookup with no preimage - correct, just receipt-less.
-const settleAndClaimNamed = async (
- ctx: RequestContext,
- entry: PendingInvoice,
-): Promise => {
- const noteSecret = entry.prepared.noteSecret
- if (!noteSecret) {
- throw new Error('This mint was prepared in named mode without a note secret.')
- }
- const prepared = entry.prepared
- const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL
- let preimage: string | null = null
+ const prepared = entry.prepared;
+ const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL;
+ let preimage: string | null = null;
if (prepared.verifyUrl) {
- // the interruptible observation half: watch settlement on verify,
- // then claim by secret below (fund-critical, uninterruptible)
+ // Observation remains interruptible. Once settlement is observed, the
+ // claim and durable update ignore shutdown while stop drains the task.
const result = await pollVerifyUntilSettled(
prepared.verifyUrl,
- {...claimPoll, signal: ctx.stopSignal},
+ { ...claimPoll, signal: ctx.stopSignal },
ctx.deps.kit ?? {},
- )
+ );
if (!sameInvoice(result.pr, prepared.invoice)) {
- throw new Error("The service's verify response is for a different invoice than requested.")
+ throw new Error("The service's verify response is for a different invoice than requested.");
}
- if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage
+ if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage;
}
- // without a verify URL the claim poll IS the observation half, so it
- // stays interruptible; after a verify-observed settlement the note is
- // already there and this returns on the first check
- const claimed = await claimFromSecret(
- {...prepared, noteSecret},
- prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal},
- {
- ...(ctx.deps.kit ?? {}),
- assertOwner: ctx.assertOwner,
- },
- )
- const add: NewBearer[] = [claimed.note]
- if (claimed.possibleCopy) add.push(claimed.possibleCopy)
- await ctx.deps.applyChangeset(
- {add, markSpent: []},
- ctx.connection(),
- 'make_invoice',
- ctx.assertOwner,
- )
- entry.settledAt = ctx.nowSeconds()
- entry.state = 'settled'
- // the rotate guard still matters here: the rescue path (a mint that
- // took the hash but credited the PREIMAGE) claims through
- // claimFromPreimage, and if its rotate failed the preimage is still a
- // LIVE note secret - withheld exactly like the unnamed path
- if (preimage && claimed.rotated) entry.preimage = preimage
-}
+ let claimed;
+ try {
+ claimed = await claimFromSecret(
+ prepared,
+ prepared.verifyUrl ? claimPoll : { ...claimPoll, signal: ctx.stopSignal },
+ {
+ ...(ctx.deps.kit ?? {}),
+ assertOwner: ctx.assertOwner,
+ },
+ );
+ } catch (error) {
+ if (!(error instanceof MintedNoteSpentError)) throw error;
+ await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner);
+ throw error;
+ }
+ await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner);
+ entry.settledAt = ctx.nowSeconds();
+ entry.state = 'settled';
+ if (preimage) entry.preimage = preimage;
+};
diff --git a/src/lnurlcash/nwc/pay.ts b/src/lnurlcash/nwc/pay.ts
index d05ea4e..e6fb4af 100644
--- a/src/lnurlcash/nwc/pay.ts
+++ b/src/lnurlcash/nwc/pay.ts
@@ -10,16 +10,17 @@ import {
fetchInvoiceVerification,
isBolt11Invoice,
noteK1,
-} from 'lnurlcash-kit'
+ withNewK1,
+} from 'lnurlcash-kit';
-import type {Bearer, NewBearer} from '../types'
-import type {PayResult} from '../ops'
-import {UncertainOutcomeError, payWithBearers} from '../ops'
+import type { Bearer, NewBearer } from '../types';
+import type { PayResult } from '../ops';
+import { UncertainOutcomeError, payWithBearers } from '../ops';
-import {budgetRemainingMsat, recordSpend} from './budget'
-import type {NwcChangeset, RequestContext} from './context'
-import type {NwcResponse} from './protocol'
-import {errResult, okResult} from './protocol'
+import { budgetRemainingMsat, recordSpend } from './budget';
+import type { NwcChangeset, RequestContext } from './context';
+import type { NwcResponse } from './protocol';
+import { errResult, okResult } from './protocol';
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
// semantics: carve inputs lock spent, change is tracked, returned funds
@@ -31,151 +32,198 @@ import {errResult, okResult} from './protocol'
// the next refresh to reconcile, exactly as the UI leaves it - the money
// itself sits in the re-secured note, which IS tracked.
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
- const add: NewBearer[] = []
- const markSpent: string[] = result.carve.consumed.map((b) => b.id)
- if (result.carve.change) add.push(result.carve.change)
+ const add: NewBearer[] = [];
+ const markSpent: string[] = result.carve.consumed.map((b) => b.id);
+ // the staged change's URL may lack the signature the landed result's URL
+ // carries - identify outputs by their secret, not the whole URL
+ const changeK1 = result.carve.change ? noteK1(result.carve.change.url) : null;
+ if (
+ result.carve.change &&
+ !bearers.some((bearer) => changeK1 !== null && noteK1(bearer.url) === changeK1)
+ ) {
+ add.push(result.carve.change);
+ }
+ const carvedK1 = noteK1(result.carve.note.url);
+ const existing = carvedK1 ? bearers.find((bearer) => noteK1(bearer.url) === carvedK1) : undefined;
if (result.outcome === 'failed-funds-returned') {
- add.push(result.rotatedNote ?? result.carve.note)
+ if (existing) {
+ const index = markSpent.indexOf(existing.id);
+ if (index >= 0) markSpent.splice(index, 1);
+ }
} else {
// settled / still-pending / already-spent: the carved note is gone or
// locked. When it was one of the wallet's own bearers (an exact-match
// carve), lock that bearer; a freshly carved note is never added -
// it was born spent
- const carvedK1 = noteK1(result.carve.note.url)
- const existing = carvedK1 ? bearers.find((b) => noteK1(b.url) === carvedK1) : undefined
- if (existing) markSpent.push(existing.id)
+ if (existing) markSpent.push(existing.id);
}
- if (result.rescuedNote) add.push(result.rescuedNote)
- return {add, markSpent}
-}
+ return { add, markSpent };
+};
export const handlePayInvoice = async (
ctx: RequestContext,
params: Record,
): Promise => {
- const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : ''
+ const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '';
if (!isBolt11Invoice(invoice)) {
- return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.')
+ return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.');
}
// the melt must match the invoice's amount exactly, so the amount is
// read from the invoice itself - never trusted from the request
- const amountMsat = decodeBolt11AmountMsat(invoice)
+ const amountMsat = decodeBolt11AmountMsat(invoice);
if (amountMsat === null || amountMsat <= 0) {
return errResult(
'pay_invoice',
'OTHER',
"Could not read this invoice's amount - amount-less invoices are not supported.",
- )
+ );
}
if (params.amount !== undefined && params.amount !== amountMsat) {
- return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.")
+ return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.");
}
if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) {
return errResult(
'pay_invoice',
'QUOTA_EXCEEDED',
"This payment exceeds the connection's budget.",
- )
+ );
}
- const bearers = ctx.deps.getBearers()
- let result: PayResult
+ const bearers = ctx.deps.getBearers();
+ let result: PayResult;
+ let committedCarve: Bearer | undefined;
+ let returnStage: Bearer | undefined;
try {
result = await payWithBearers(bearers, invoice, {
poll: ctx.deps.poll ?? {},
kit: ctx.deps.kit ?? {},
assertOwner: ctx.assertOwner,
- })
+ allocateOutputSecrets: (server, count) =>
+ ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
+ mintSignatureKeys: ctx.deps.mintSignatureKeys,
+ onCarve: async (carve) => {
+ committedCarve = await ctx.deps.commitCarve(carve, ctx.assertOwner);
+ },
+ onReturnReady: async (carve, recoverySecret) => {
+ const source =
+ committedCarve ?? ctx.deps.getBearers().find((bearer) => bearer.url === carve.note.url);
+ if (!source) throw new Error('The carved note was not tracked.');
+ returnStage = await ctx.deps.persistMintOutput(
+ {
+ url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
+ callback: '',
+ amount: 0,
+ verified: false,
+ pendingMint: { retireAfter: ctx.nowSeconds() + 14 * 24 * 60 * 60 },
+ },
+ ctx.assertOwner,
+ );
+ await ctx.deps.reserveMintOutputSource(
+ returnStage,
+ source.id,
+ recoverySecret,
+ ctx.assertOwner,
+ );
+ },
+ });
} catch (err) {
if (err instanceof UncertainOutcomeError) {
// the carve's answer was lost and the probe couldn't tell: the
// possible outputs carry fresh secrets that may be the only money
// left - tracked unverified, never dropped
await ctx.deps.applyChangeset(
- {add: err.possibleOutputs, markSpent: []},
+ { add: err.possibleOutputs, markSpent: [] },
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
- )
+ );
return errResult(
'pay_invoice',
'INTERNAL',
'The payment preparation could not be confirmed; possible new notes were stored unverified.',
- )
+ );
}
- const message = err instanceof Error ? err.message : String(err)
+ const message = err instanceof Error ? err.message : String(err);
return errResult(
'pay_invoice',
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
message,
- )
+ );
+ }
+ if (returnStage) {
+ const returned = result.rotatedNote ?? result.rescuedNote;
+ if (returned) {
+ await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner);
+ } else if (result.outcome !== 'unknown-still-pending') {
+ await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner);
+ }
}
const spendRecorded = (): void => {
// This conservative budget debit is persisted separately from bearer
// storage. A later bearer commit failure does not roll it back.
- const connection = ctx.connection().record
- ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()))
- }
+ const connection = ctx.connection().record;
+ ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()));
+ };
switch (result.outcome) {
case 'settled': {
- spendRecorded()
+ spendRecorded();
await ctx.deps.applyChangeset(
- payChangeset(bearers, result),
+ payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
- )
+ );
// the receipt NIP-47 clients expect: the melt's own payment
// preimage, re-read from the settle proof. A mint that reveals
// none yields an empty preimage rather than a fabricated one.
- let preimage = ''
+ let preimage = '';
if (result.verifyUrl) {
try {
- const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {})
- preimage = proof.preimage ?? ''
+ const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {});
+ preimage = proof.preimage ?? '';
} catch (error) {
// the settle proof was already polled inside payWithBearers;
// a failed re-read must not flip the outcome
- if (!(error instanceof Error)) throw error
+ if (!(error instanceof Error)) throw error;
}
}
- return okResult('pay_invoice', {preimage})
+ return okResult('pay_invoice', { preimage });
}
case 'failed-funds-returned':
await ctx.deps.applyChangeset(
- payChangeset(bearers, result),
+ payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
- )
+ );
return errResult(
'pay_invoice',
'PAYMENT_FAILED',
'The payment failed; the funds are back in the wallet.',
- )
+ );
case 'note-already-spent':
await ctx.deps.applyChangeset(
- payChangeset(bearers, result),
+ payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
- )
+ );
return errResult(
'pay_invoice',
'PAYMENT_FAILED',
'The note backing this payment was already spent; nothing was paid.',
- )
+ );
case 'unknown-still-pending':
- spendRecorded()
+ spendRecorded();
await ctx.deps.applyChangeset(
- payChangeset(bearers, result),
+ payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
- )
+ );
return errResult(
'pay_invoice',
'OTHER',
'The payment is still in flight; the note stays locked until it reconciles.',
- )
+ );
}
-}
+};
diff --git a/src/lnurlcash/nwc/protocol.ts b/src/lnurlcash/nwc/protocol.ts
index a7ccaba..6ae707d 100644
--- a/src/lnurlcash/nwc/protocol.ts
+++ b/src/lnurlcash/nwc/protocol.ts
@@ -13,16 +13,16 @@
// - a kind 13194 replaceable info event advertises the supported methods
// and encryption schemes
-import type {Event as NostrEvent} from 'nostr-tools/core'
-import {finalizeEvent, verifyEvent} from 'nostr-tools/pure'
-import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
-import {v2 as nip44v2} from 'nostr-tools/nip44'
+import type { Event as NostrEvent } from 'nostr-tools/core';
+import { finalizeEvent, verifyEvent } from 'nostr-tools/pure';
+import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
+import { v2 as nip44v2 } from 'nostr-tools/nip44';
-export type {NostrEvent}
+export type { NostrEvent };
-export const NWC_REQUEST_KIND = 23194
-export const NWC_RESPONSE_KIND = 23195
-export const NWC_INFO_KIND = 13194
+export const NWC_REQUEST_KIND = 23194;
+export const NWC_RESPONSE_KIND = 23195;
+export const NWC_INFO_KIND = 13194;
export const NWC_METHODS = [
'get_info',
@@ -30,9 +30,9 @@ export const NWC_METHODS = [
'make_invoice',
'pay_invoice',
'lookup_invoice',
-] as const
+] as const;
-export type NwcMethod = (typeof NWC_METHODS)[number]
+export type NwcMethod = (typeof NWC_METHODS)[number];
export type NwcErrorCode =
| 'RATE_LIMITED'
@@ -45,38 +45,38 @@ export type NwcErrorCode =
| 'UNSUPPORTED_ENCRYPTION'
| 'PAYMENT_FAILED'
| 'NOT_FOUND'
- | 'OTHER'
+ | 'OTHER';
export type NwcRequest = {
- method: string
- params: Record
-}
+ method: string;
+ params: Record;
+};
export type NwcResponse = {
- result_type: string
- error: {code: NwcErrorCode; message: string} | null
- result: unknown
-}
+ result_type: string;
+ error: { code: NwcErrorCode; message: string } | null;
+ result: unknown;
+};
export const okResult = (method: string, result: unknown): NwcResponse => ({
result_type: method,
error: null,
result,
-})
+});
export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({
result_type: method,
- error: {code, message},
+ error: { code, message },
result: null,
-})
+});
// the two encryption schemes this service speaks; the scheme of a request
// decides the scheme of its response (NIP-47: "Encrypted using the scheme
// requested by the client")
-export type NwcEncryption = 'nip44_v2' | 'nip04'
+export type NwcEncryption = 'nip44_v2' | 'nip04';
const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array =>
- nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey)
+ nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey);
export const encryptFor = (
scheme: NwcEncryption,
@@ -86,7 +86,7 @@ export const encryptFor = (
): string =>
scheme === 'nip44_v2'
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
- : nip04Encrypt(walletSecretKey, clientPubkey, plaintext)
+ : nip04Encrypt(walletSecretKey, clientPubkey, plaintext);
const decryptFrom = (
scheme: NwcEncryption,
@@ -96,10 +96,10 @@ const decryptFrom = (
): string =>
scheme === 'nip44_v2'
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
- : nip04Decrypt(walletSecretKey, clientPubkey, content)
+ : nip04Decrypt(walletSecretKey, clientPubkey, content);
const tagValue = (event: NostrEvent, name: string): string | undefined =>
- event.tags.find((t) => t[0] === name)?.[1]
+ event.tags.find((t) => t[0] === name)?.[1];
// The outcome of validating + decrypting a candidate request event:
// - a request to dispatch (encryption scheme carried so the response can
@@ -112,8 +112,8 @@ const tagValue = (event: NostrEvent, name: string): string | undefined =>
// dropped silently, exactly what a relay full of strangers' traffic
// demands
export type DecryptedNwcRequest =
- | {respond: false; request: NwcRequest; encryption: NwcEncryption}
- | {respond: true; response: NwcResponse; encryption: NwcEncryption}
+ | { respond: false; request: NwcRequest; encryption: NwcEncryption }
+ | { respond: true; response: NwcResponse; encryption: NwcEncryption };
export const decryptRequest = (
walletSecretKey: Uint8Array,
@@ -122,24 +122,24 @@ export const decryptRequest = (
event: NostrEvent,
nowSeconds: number = Math.floor(Date.now() / 1000),
): DecryptedNwcRequest | null => {
- if (event.kind !== NWC_REQUEST_KIND) return null
+ if (event.kind !== NWC_REQUEST_KIND) return null;
// only the authorized client may talk to this connection, and the
// request must actually be addressed to it
- if (event.pubkey !== clientPubkey) return null
- if (tagValue(event, 'p') !== walletServicePubkey) return null
- if (!verifyEvent(event)) return null
+ if (event.pubkey !== clientPubkey) return null;
+ if (tagValue(event, 'p') !== walletServicePubkey) return null;
+ if (!verifyEvent(event)) return null;
// an expired request is ignored, never answered (NIP-47)
- const expiration = tagValue(event, 'expiration')
+ const expiration = tagValue(event, 'expiration');
if (expiration !== undefined && Number(expiration) < nowSeconds) {
- return null
+ return null;
}
// encryption negotiation: no tag means legacy NIP-04 (NIP-47)
- const advertised = tagValue(event, 'encryption')
- let encryption: NwcEncryption
+ const advertised = tagValue(event, 'encryption');
+ let encryption: NwcEncryption;
if (advertised === undefined || advertised === 'nip04') {
- encryption = 'nip04'
+ encryption = 'nip04';
} else if (advertised === 'nip44_v2') {
- encryption = 'nip44_v2'
+ encryption = 'nip44_v2';
} else {
// the client asked for a scheme we don't speak - answer in the
// legacy default, the one scheme every NIP-47 client must read
@@ -151,24 +151,24 @@ export const decryptRequest = (
'UNSUPPORTED_ENCRYPTION',
`Unsupported encryption scheme: ${advertised}.`,
),
- }
+ };
}
- let plaintext: string
+ let plaintext: string;
try {
- plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content)
+ plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content);
} catch {
// undecryptable - indistinguishable from relay noise; stay silent
- return null
+ return null;
}
- let data: unknown
+ let data: unknown;
try {
- data = JSON.parse(plaintext)
+ data = JSON.parse(plaintext);
} catch {
return {
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request is not valid JSON.'),
- }
+ };
}
if (
typeof data !== 'object' ||
@@ -180,12 +180,13 @@ export const decryptRequest = (
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request has no method.'),
- }
+ };
}
- const request = data as NwcRequest
- const params = typeof request.params === 'object' && request.params !== null ? request.params : {}
- return {respond: false, request: {method: request.method, params}, encryption}
-}
+ const request = data as NwcRequest;
+ const params =
+ typeof request.params === 'object' && request.params !== null ? request.params : {};
+ return { respond: false, request: { method: request.method, params }, encryption };
+};
// signs the kind-23195 answer to a request, mirroring its encryption
// scheme and referencing it via the 'e' tag
@@ -209,7 +210,7 @@ export const buildResponseEvent = (
content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)),
},
walletSecretKey,
- )
+ );
// the replaceable info event advertising this service's capabilities
export const buildInfoEvent = (
@@ -224,4 +225,4 @@ export const buildInfoEvent = (
content: NWC_METHODS.join(' '),
},
walletSecretKey,
- )
+ );
diff --git a/src/lnurlcash/nwc/service.ts b/src/lnurlcash/nwc/service.ts
index 2a43e72..5e6179b 100644
--- a/src/lnurlcash/nwc/service.ts
+++ b/src/lnurlcash/nwc/service.ts
@@ -14,60 +14,60 @@
// foreground-only design itself is documented in the nwc.ts façade
// header.
-import {linkingPubKeyHex} from '../keys'
-import type {NwcConnectionRecord} from '../storage/nwcConnections'
-import {readNwcConnections} from '../storage/nwcConnections'
-import {assertSavedKeyOwner} from '../storage/currentOwner'
+import { linkingPubKeyHex } from '../keys';
+import type { NwcConnectionRecord } from '../storage/nwcConnections';
+import { readNwcConnections } from '../storage/nwcConnections';
+import { assertSavedKeyOwner } from '../storage/currentOwner';
-import type {NwcConnectionInfo} from './connection'
-import {deriveNwcWalletKey, nwcWalletPubkey} from './connection'
-import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context'
-import {dispatch} from './dispatch'
-import type {NostrEvent, NwcEncryption, NwcRequest, NwcResponse} from './protocol'
-import {NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest} from './protocol'
-import type {NwcSubscription} from './transport'
-import {defaultNwcTransport} from './transport'
+import type { NwcConnectionInfo } from './connection';
+import { deriveNwcWalletKey, nwcWalletPubkey } from './connection';
+import type { NwcServiceDeps, PendingInvoice, RequestContext } from './context';
+import { dispatch } from './dispatch';
+import type { NostrEvent, NwcEncryption, NwcRequest, NwcResponse } from './protocol';
+import { NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest } from './protocol';
+import type { NwcSubscription } from './transport';
+import { defaultNwcTransport } from './transport';
-export type {NwcConnectionInfo}
-export type {NwcChangeset, NwcServiceDeps} from './context'
+export type { NwcConnectionInfo };
+export type { NwcChangeset, NwcServiceDeps } from './context';
// requests older than this are dropped unanswered (see the header)
-const MAX_REQUEST_AGE_SECONDS = 600
+const MAX_REQUEST_AGE_SECONDS = 600;
type ConnectionRuntime = {
- info: NwcConnectionInfo
+ info: NwcConnectionInfo;
// nulled and zeroed only after every tracked handler drains
- walletSecret: Uint8Array | null
+ walletSecret: Uint8Array | null;
// invoices this connection issued, by payment hash - in-memory only:
// pending invoices don't survive a restart (lookup then answers
// NOT_FOUND), same as any foreground-only wallet
- invoices: Map
+ invoices: Map;
// serializes pay_invoice handling per connection - the budget check and
// the spend record can't interleave
- queue: Promise
- sub: NwcSubscription
-}
+ queue: Promise;
+ sub: NwcSubscription;
+};
export type NwcService = {
// a snapshot of the served connections at startup; the persisted
// records (storage/nwcConnections) carry the live budget state
- connections: NwcConnectionInfo[]
+ connections: NwcConnectionInfo[];
// closes every relay subscription. In-flight handlers still finish -
// their changesets hold money - but no new requests are picked up
- stop: () => Promise
-}
+ stop: () => Promise;
+};
export const startService = async (
linkingPrivKey: Uint8Array,
deps: NwcServiceDeps,
records?: NwcConnectionRecord[],
): Promise => {
- const transport = deps.transport ?? (await defaultNwcTransport())
- const ownerId = linkingPubKeyHex(linkingPrivKey)
+ const transport = deps.transport ?? (await defaultNwcTransport());
+ const ownerId = linkingPubKeyHex(linkingPrivKey);
const ownedRecords = (records ?? readNwcConnections(ownerId)).filter(
(record) => record.ownerId === ownerId,
- )
- const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000)
+ );
+ const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
const publishResponse = async (
runtime: ConnectionRuntime,
@@ -86,43 +86,54 @@ export const startService = async (
response,
nowSeconds(),
),
- )
- }
+ );
+ };
- // pay_invoice goes through the connection's queue (budget atomicity);
- // everything else dispatches directly
+ let invoiceQueue: Promise = Promise.resolve();
+
+ // Payments serialize per connection for budget atomicity. Invoice
+ // creation serializes across the service so different clients cannot
+ // race the wallet-wide durable pending-output cap.
const dispatchSerialized = (
runtime: ConnectionRuntime,
ctx: RequestContext,
request: NwcRequest,
): Promise => {
- if (request.method !== 'pay_invoice') return dispatch(ctx, request)
+ if (request.method === 'make_invoice') {
+ const run = invoiceQueue.then(
+ () => dispatch(ctx, request),
+ () => dispatch(ctx, request),
+ );
+ invoiceQueue = run.catch(() => undefined);
+ return run;
+ }
+ if (request.method !== 'pay_invoice') return dispatch(ctx, request);
const run = runtime.queue.then(
() => dispatch(ctx, request),
() => dispatch(ctx, request),
- )
- runtime.queue = run.catch(() => undefined)
- return run
- }
+ );
+ runtime.queue = run.catch(() => undefined);
+ return run;
+ };
const handleEvent = async (
runtime: ConnectionRuntime,
ctx: RequestContext,
event: NostrEvent,
): Promise => {
- const walletSecret = runtime.walletSecret
- if (walletSecret === null) return
- const at = nowSeconds()
+ const walletSecret = runtime.walletSecret;
+ if (walletSecret === null) return;
+ const at = nowSeconds();
// replay safety (see the header): too-old requests are dropped
- if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return
+ if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return;
const decrypted = decryptRequest(
walletSecret,
runtime.info.walletServicePubkey,
runtime.info.record.clientPubkey,
event,
at,
- )
- if (decrypted === null) return
+ );
+ if (decrypted === null) return;
if (decrypted.respond) {
await publishResponse(
runtime,
@@ -130,56 +141,56 @@ export const startService = async (
event.id,
decrypted.encryption,
decrypted.response,
- )
- return
+ );
+ return;
}
- const response = await dispatchSerialized(runtime, ctx, decrypted.request)
- await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response)
- }
+ const response = await dispatchSerialized(runtime, ctx, decrypted.request);
+ await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response);
+ };
- let accepting = true
+ let accepting = true;
// interrupts long observation waits (the invoice claim poll) at stop;
// the drain below still awaits tasks that reached a fund-critical commit
- const stopController = new AbortController()
- const inFlight = new Set>()
+ const stopController = new AbortController();
+ const inFlight = new Set>();
const track = (task: Promise): void => {
- inFlight.add(task)
+ inFlight.add(task);
void task.then(
() => inFlight.delete(task),
() => inFlight.delete(task),
- )
- }
+ );
+ };
const startBackground = (work: () => Promise): boolean => {
- if (!accepting) return false
- track(work())
- return true
- }
+ if (!accepting) return false;
+ track(work());
+ return true;
+ };
let runtimes = ownedRecords.map((record) => {
- const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
+ const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey);
const runtime: ConnectionRuntime = {
- info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)},
+ info: { record, walletServicePubkey: nwcWalletPubkey(walletSecret) },
walletSecret,
invoices: new Map(),
queue: Promise.resolve(),
// replaced below, immediately - the field exists because the
// subscription callback closes over the runtime
- sub: {close: () => undefined},
- }
+ sub: { close: () => undefined },
+ };
const ctx: RequestContext = {
deps,
connection: () => runtime.info,
updateRecord: (updated) => {
- runtime.info = {...runtime.info, record: updated}
+ runtime.info = { ...runtime.info, record: updated };
},
invoices: runtime.invoices,
nowSeconds,
assertOwner: () => {
- deps.assertCurrentOwner()
- assertSavedKeyOwner(ownerId)
+ deps.assertCurrentOwner();
+ assertSavedKeyOwner(ownerId);
},
startBackground,
stopSignal: stopController.signal,
- }
+ };
runtime.sub = transport.subscribe(
record.relays,
{
@@ -188,56 +199,62 @@ export const startService = async (
since: nowSeconds(),
},
(event) => {
- if (!accepting) return
+ if (!accepting) return;
const handler = handleEvent(runtime, ctx, event).catch((err) => {
- deps.onError?.(err, runtime.info)
- })
- track(handler)
+ deps.onError?.(err, runtime.info);
+ });
+ track(handler);
},
- )
- return runtime
- })
+ );
+ return runtime;
+ });
// info events: best-effort - a rejected publish must not sink startup;
// the client learns capabilities from its first error-free exchange too
for (const runtime of runtimes) {
- const walletSecret = runtime.walletSecret
- if (walletSecret === null) continue
+ const walletSecret = runtime.walletSecret;
+ if (walletSecret === null) continue;
try {
await transport.publish(
runtime.info.record.relays,
buildInfoEvent(walletSecret, nowSeconds()),
- )
+ );
} catch (error) {
deps.onError?.(
- error instanceof Error ? error : new Error('NWC info publication failed.', {cause: error}),
+ error instanceof Error
+ ? error
+ : new Error('NWC info publication failed.', { cause: error }),
runtime.info,
- )
+ );
}
}
- const connections = runtimes.map((runtime) => runtime.info)
- let stopPromise: Promise | null = null
+ const connections = runtimes.map((runtime) => runtime.info);
+ let stopPromise: Promise | null = null;
const stop = (): Promise => {
- if (stopPromise !== null) return stopPromise
- accepting = false
- for (const runtime of runtimes) runtime.sub.close()
- stopController.abort()
- stopPromise = Promise.all([...inFlight])
- .then(() => undefined)
+ if (stopPromise !== null) return stopPromise;
+ accepting = false;
+ for (const runtime of runtimes) runtime.sub.close();
+ stopController.abort();
+ stopPromise = Promise.allSettled([...inFlight])
+ .then((results) => {
+ for (const result of results) {
+ if (result.status === 'rejected') throw result.reason;
+ }
+ })
.finally(() => {
for (const runtime of runtimes) {
- runtime.walletSecret?.fill(0)
- runtime.walletSecret = null
- runtime.invoices.clear()
+ runtime.walletSecret?.fill(0);
+ runtime.walletSecret = null;
+ runtime.invoices.clear();
}
- runtimes = []
- })
- return stopPromise
- }
+ runtimes = [];
+ });
+ return stopPromise;
+ };
return {
connections,
stop,
- }
-}
+ };
+};
diff --git a/src/lnurlcash/nwc/transport.ts b/src/lnurlcash/nwc/transport.ts
index 673638c..3c70230 100644
--- a/src/lnurlcash/nwc/transport.ts
+++ b/src/lnurlcash/nwc/transport.ts
@@ -4,36 +4,37 @@
// importing the service module never opens (or even references) a
// WebSocket.
-import type {Filter as NostrFilter} from 'nostr-tools/filter'
+import type { Filter as NostrFilter } from 'nostr-tools/filter';
-import type {NostrEvent} from './protocol'
+import type { NostrEvent } from './protocol';
-export type {NostrFilter}
+export type { NostrFilter };
export type NwcSubscription = {
- close: () => void
-}
+ close: () => void;
+};
export type NwcTransport = {
- publish: (relays: string[], event: NostrEvent) => Promise
+ publish: (relays: string[], event: NostrEvent) => Promise;
subscribe: (
relays: string[],
filter: NostrFilter,
onEvent: (event: NostrEvent) => void,
- ) => NwcSubscription
-}
+ ) => NwcSubscription;
+};
export const defaultNwcTransport = async (): Promise => {
- const {SimplePool} = await import('nostr-tools/pool')
- const pool = new SimplePool()
+ const { SimplePool } = await import('nostr-tools/pool');
+ const pool = new SimplePool();
return {
publish: async (relays, event) => {
- const results = await Promise.allSettled(pool.publish(relays, event))
+ const results = await Promise.allSettled(pool.publish(relays, event));
// one honest relay accepting is enough - same rule as the backup
if (!results.some((r) => r.status === 'fulfilled')) {
- throw new Error('No relay accepted the event.')
+ throw new Error('No relay accepted the event.');
}
},
- subscribe: (relays, filter, onEvent) => pool.subscribeMany(relays, filter, {onevent: onEvent}),
- }
-}
+ subscribe: (relays, filter, onEvent) =>
+ pool.subscribeMany(relays, filter, { onevent: onEvent }),
+ };
+};
diff --git a/src/lnurlcash/ops.carve-crash.cases.ts b/src/lnurlcash/ops.carve-crash.cases.ts
new file mode 100644
index 0000000..26cfa5b
--- /dev/null
+++ b/src/lnurlcash/ops.carve-crash.cases.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it } from 'vitest';
+import { noteK1 } from 'lnurlcash-kit';
+
+import type { CarveResult } from './ops';
+import { ensureExactAmount, UnsupportedMultiBatchMergeError } from './ops';
+import { requiredValue } from './test-utils';
+import { makeBearer, mint, secret } from './ops.testHarness';
+
+const allocatedSecrets = (
+ ...secrets: string[]
+): ((server: string, count: number) => Promise) => {
+ let served = false;
+ return (_server, count) => {
+ if (served) throw new Error('The test expected a single allocation.');
+ served = true;
+ return Promise.resolve(secrets.slice(0, count));
+ };
+};
+
+describe('ensureExactAmount crash safety', () => {
+ it('stages both split secrets before either hash reaches the mint', async () => {
+ const instance = await mint();
+ const sourceSecret = secret('70');
+ const targetSecret = secret('71');
+ const changeSecret = secret('72');
+ const bearer = await makeBearer(instance, sourceSecret, 21_000);
+ let staged: CarveResult | undefined;
+ const observingFetch: typeof fetch = (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes('/w/cb')) {
+ expect(staged).toBeDefined();
+ expect(noteK1(requiredValue(staged).note.url)).toBe(targetSecret);
+ expect(noteK1(requiredValue(requiredValue(staged).change).url)).toBe(changeSecret);
+ expect(instance.state.noteState(sourceSecret)).toBe('outstanding');
+ }
+ return fetch(input, init);
+ };
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ fetch: observingFetch,
+ allocateOutputSecrets: allocatedSecrets(targetSecret, changeSecret),
+ onCarve: (prepared) => {
+ staged = prepared;
+ },
+ });
+
+ expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
+ });
+
+ it('stages the merge secret before its hash reaches the mint', async () => {
+ const instance = await mint();
+ const firstSecret = secret('73');
+ const secondSecret = secret('74');
+ const mergedSecret = secret('75');
+ const first = await makeBearer(instance, firstSecret, 3_000);
+ const second = await makeBearer(instance, secondSecret, 4_000);
+ let staged: CarveResult | undefined;
+ const observingFetch: typeof fetch = (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes('/w/cb')) {
+ expect(staged).toBeDefined();
+ expect(noteK1(requiredValue(staged).note.url)).toBe(mergedSecret);
+ expect(instance.state.noteState(firstSecret)).toBe('outstanding');
+ expect(instance.state.noteState(secondSecret)).toBe('outstanding');
+ }
+ return fetch(input, init);
+ };
+
+ await ensureExactAmount([first, second], 7_000, {
+ fetch: observingFetch,
+ allocateOutputSecrets: allocatedSecrets(mergedSecret),
+ onCarve: (prepared) => {
+ staged = prepared;
+ },
+ });
+
+ expect(staged?.consumed.map((entry) => entry.id)).toEqual([first.id, second.id]);
+ });
+
+ it('does not expose a staged split output to a second ambiguous rotation', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('76'), 21_000);
+ let mutationCount = 0;
+ const dropSecondMutation: typeof fetch = async (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (!url.includes('/w/cb')) return fetch(input, init);
+ mutationCount += 1;
+ const response = await fetch(input, init);
+ if (mutationCount === 2) {
+ await response.arrayBuffer();
+ throw new Error('second mutation response lost');
+ }
+ return response;
+ };
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ fetch: dropSecondMutation,
+ mutationRetries: 0,
+ allocateOutputSecrets: allocatedSecrets(secret('77'), secret('78')),
+ onCarve: () => undefined,
+ });
+
+ expect(mutationCount).toBe(1);
+ const targetSecret = requiredValue(noteK1(result.note.url));
+ const changeSecret = requiredValue(noteK1(requiredValue(result.change).url));
+ expect(instance.state.noteState(targetSecret)).toBe('outstanding');
+ expect(instance.state.noteState(changeSecret)).toBe('outstanding');
+ });
+
+ it('rejects a multi-batch merge before any partial fold can land', async () => {
+ const instance = await mint();
+ const bearers = await Promise.all(
+ Array.from({ length: 21 }, (_, index) =>
+ makeBearer(instance, secret((128 + index).toString(16)), 1_000),
+ ),
+ );
+ let mutationCount = 0;
+ const countingFetch: typeof fetch = (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes('/w/cb')) mutationCount += 1;
+ return fetch(input, init);
+ };
+
+ const failure = ensureExactAmount(bearers, 21_000, {
+ fetch: countingFetch,
+ onCarve: () => undefined,
+ });
+ await expect(failure).rejects.toBeInstanceOf(UnsupportedMultiBatchMergeError);
+ expect(mutationCount).toBe(0);
+ expect(
+ bearers.every(
+ (bearer) => instance.state.noteState(requiredValue(noteK1(bearer.url))) === 'outstanding',
+ ),
+ ).toBe(true);
+ });
+
+ it('checks ownership again immediately before a merge mutation', async () => {
+ const instance = await mint();
+ const firstSecret = secret('79');
+ const secondSecret = secret('7a');
+ const first = await makeBearer(instance, firstSecret, 3_000);
+ const second = await makeBearer(instance, secondSecret, 4_000);
+ let ownerChecks = 0;
+ let mutationCount = 0;
+ const countingFetch: typeof fetch = (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes('/w/cb')) mutationCount += 1;
+ return fetch(input, init);
+ };
+
+ await expect(
+ ensureExactAmount([first, second], 7_000, {
+ fetch: countingFetch,
+ allocateOutputSecrets: allocatedSecrets(secret('7b')),
+ assertOwner: () => {
+ ownerChecks += 1;
+ if (ownerChecks === 3) throw new Error('wallet owner changed');
+ },
+ onCarve: () => undefined,
+ }),
+ ).rejects.toThrow(/owner changed/);
+
+ expect(mutationCount).toBe(0);
+ expect(instance.state.noteState(firstSecret)).toBe('outstanding');
+ expect(instance.state.noteState(secondSecret)).toBe('outstanding');
+ });
+});
diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts
index c9a8327..5d69a86 100644
--- a/src/lnurlcash/ops.carve.cases.ts
+++ b/src/lnurlcash/ops.carve.cases.ts
@@ -1,163 +1,361 @@
-import {describe, expect, it} from 'vitest'
-import {fetchNoteInfo, noteK1} from 'lnurlcash-kit'
+import { describe, expect, it } from 'vitest';
+import { fetchNoteInfo, noteK1, noteSignature } from 'lnurlcash-kit';
-import type {Bearer} from './types'
-import type {CarveResult} from './ops'
-import {UncertainOutcomeError, ensureExactAmount} from './ops'
-import {requiredValue} from './test-utils'
-import {makeBearer, mint, noteUrl, secret} from './ops.testHarness'
+import type { Bearer } from './types';
+import type { CarveResult } from './ops';
+import {
+ CarveCheckpointRequiredError,
+ OutputSecretAllocationRequiredError,
+ UncertainOutcomeError,
+ ensureExactAmount,
+} from './ops';
+import { requiredValue } from './test-utils';
+import {
+ allocateOutputSecrets,
+ makeBearer,
+ mint,
+ noteUrl,
+ secret,
+ signatureKeysFor,
+} from './ops.testHarness';
describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => {
- const instance = await mint()
- const k1 = secret('01')
- const bearer = await makeBearer(instance, 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(instance.state.noteState(k1)).toBe('outstanding')
- })
+ const instance = await mint();
+ const k1 = secret('01');
+ const bearer = await makeBearer(instance, 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(instance.state.noteState(k1)).toBe('outstanding');
+ });
it('split path: carves an exact note off a larger one, with change', async () => {
- const instance = await mint()
- const k1 = secret('02')
- const bearer = await makeBearer(instance, 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((entry) => entry.id)).toEqual([bearer.id])
- expect(instance.state.noteState(k1)).toBe('burned')
- const partK1 = requiredValue(noteK1(result.note.url))
- const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
- expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
- expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
- })
+ const instance = await mint();
+ const k1 = secret('02');
+ const bearer = await makeBearer(instance, k1, 21_000);
+ const result = await ensureExactAmount([bearer], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ mintSignatureKeys: signatureKeysFor(instance),
+ });
+ expect(result.note.amount).toBe(5_000);
+ expect(result.note.verified).toBe(true);
+ expect(result.change?.amount).toBe(16_000);
+ expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
+ expect(instance.state.noteState(k1)).toBe('burned');
+ const partK1 = requiredValue(noteK1(result.note.url));
+ const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
+ expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
+ expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
+ });
it('merge path: combines notes summing exactly to the target', async () => {
- const instance = await mint()
- const first = await makeBearer(instance, secret('03'), 3_000)
- const second = await makeBearer(instance, secret('04'), 4_000)
- const result = await ensureExactAmount([first, second], 7_000)
- expect(result.note.amount).toBe(7_000)
- expect(result.change).toBeUndefined()
- expect(result.consumed).toHaveLength(2)
- expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned')
- expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned')
- const mergedK1 = requiredValue(noteK1(result.note.url))
- expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000)
- })
+ const instance = await mint();
+ const first = await makeBearer(instance, secret('03'), 3_000);
+ const second = await makeBearer(instance, secret('04'), 4_000);
+ const result = await ensureExactAmount([first, second], 7_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ });
+ expect(result.note.amount).toBe(7_000);
+ expect(result.change).toBeUndefined();
+ expect(result.consumed).toHaveLength(2);
+ expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned');
+ expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned');
+ const mergedK1 = requiredValue(noteK1(result.note.url));
+ expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000);
+ });
it('merge+split path: splits the target off several notes in one request', async () => {
- const instance = await mint()
- const first = await makeBearer(instance, secret('05'), 3_000)
- const second = await makeBearer(instance, secret('06'), 4_000)
- const result = await ensureExactAmount([first, second], 5_000)
- expect(result.note.amount).toBe(5_000)
- expect(result.change?.amount).toBe(2_000)
- expect(result.consumed).toHaveLength(2)
- const partK1 = requiredValue(noteK1(result.note.url))
- const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
- expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
- expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000)
- })
+ const instance = await mint();
+ const first = await makeBearer(instance, secret('05'), 3_000);
+ const second = await makeBearer(instance, secret('06'), 4_000);
+ const result = await ensureExactAmount([first, second], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ });
+ expect(result.note.amount).toBe(5_000);
+ expect(result.change?.amount).toBe(2_000);
+ expect(result.consumed).toHaveLength(2);
+ const partK1 = requiredValue(noteK1(result.note.url));
+ const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
+ expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
+ expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000);
+ });
it('excludes spent and unverified notes from selection', async () => {
- const instance = await mint()
- const spentBearer = await makeBearer(instance, secret('07'), 50_000)
+ const instance = await mint();
+ const spentBearer = await makeBearer(instance, secret('07'), 50_000);
const unverified: Bearer = {
...(await makeBearer(instance, secret('08'), 50_000)),
- callback: '',
- }
+ verified: false,
+ };
await expect(
- ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000),
- ).rejects.toThrow(/enough/)
- })
+ ensureExactAmount([{ ...spentBearer, spent: true }, unverified], 5_000),
+ ).rejects.toThrow(/enough/);
+ });
it('refuses an amount no mint can cover', async () => {
- const instance = await mint()
- const bearer = await makeBearer(instance, secret('09'), 5_000)
- await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/)
- })
+ const instance = await mint();
+ const bearer = await makeBearer(instance, 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 instance = await mint({dropAfterMutation: true})
- const k1 = secret('10')
- const bearer = await makeBearer(instance, k1, 21_000)
- const result = await ensureExactAmount([bearer], 5_000)
- const partK1 = requiredValue(noteK1(result.note.url))
- const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
- expect(partK1).not.toBe(k1)
- expect(instance.state.noteState(k1)).toBe('burned')
- expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
- expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
- })
+ const instance = await mint({ dropAfterMutation: true });
+ const k1 = secret('10');
+ const bearer = await makeBearer(instance, k1, 21_000);
+ const result = await ensureExactAmount([bearer], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ });
+ const partK1 = requiredValue(noteK1(result.note.url));
+ const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
+ expect(partK1).not.toBe(k1);
+ expect(instance.state.noteState(k1)).toBe('burned');
+ expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
+ expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
+ });
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
- const instance = await mint({dropAfterMutation: true})
- const k1 = secret('11')
- const bearer = await makeBearer(instance, k1, 21_000)
- 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 instance = await mint();
+ const k1 = secret('11');
+ const bearer = await makeBearer(instance, k1, 21_000);
+ // every mutation answer is lost - the first attempt's AND the forced
+ // byte-identical replay's (the mint executed both; the replay is the
+ // recorded success served again, then dropped by the wrapper) - and
+ // every informational probe is unreachable, so neither the mutation
+ // nor the probe can be confirmed
+ const everythingDyingFetch: typeof fetch = async (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes('/w/cb')) {
+ const response = await fetch(input, init);
+ await response.arrayBuffer();
+ throw new Error('mutation response lost');
+ }
+ return Promise.reject(new Error('probe unreachable'));
+ };
+ const checkpoints: CarveResult[] = [];
const failure = await ensureExactAmount([bearer], 5_000, {
- fetch: probeKillingFetch,
- }).catch((error: unknown) => error)
- expect(failure).toBeInstanceOf(UncertainOutcomeError)
- if (!(failure instanceof UncertainOutcomeError)) throw failure
- expect(failure.possibleOutputs).toHaveLength(2)
- const first = requiredValue(failure.possibleOutputs[0])
- const second = requiredValue(failure.possibleOutputs[1])
- expect(first.amount).toBe(5_000)
- expect(second.amount).toBe(16_000)
- expect(
- (await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(first.url))))).maxWithdrawable,
- ).toBe(5_000)
- expect(
- (await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable,
- ).toBe(16_000)
- })
-
- it('reports a mutation-bearing carve through onCarve before returning', async () => {
- const instance = await mint()
- const bearer = await makeBearer(instance, secret('12'), 21_000)
- const seen: CarveResult[] = []
- const result = await ensureExactAmount([bearer], 5_000, {
+ fetch: everythingDyingFetch,
+ allocateOutputSecrets,
onCarve: (carve) => {
- seen.push(carve)
+ checkpoints.push(carve);
},
- })
- // the hook saw exactly the returned result, once
- expect(seen).toHaveLength(1)
- expect(seen[0]).toBe(result)
- expect(result.consumed).toHaveLength(1)
- })
+ }).catch((error: unknown) => error);
+ expect(failure).toBeInstanceOf(UncertainOutcomeError);
+ if (!(failure instanceof UncertainOutcomeError)) throw failure;
+ expect(failure.possibleOutputs).toEqual([]);
+ expect(checkpoints).toHaveLength(1);
+ expect(checkpoints[0]?.consumed).toEqual([]);
+ expect(checkpoints[0]?.note.amount).toBe(5_000);
+ expect(checkpoints[0]?.change?.amount).toBe(16_000);
+ });
- it('never fires onCarve for an already-exact note - nothing was burned', async () => {
- const instance = await mint()
- const bearer = await makeBearer(instance, secret('13'), 21_000)
- let called = false
- const result = await ensureExactAmount([bearer], 21_000, {
- onCarve: () => {
- called = true
+ it('stages outputs before wire and retires inputs only after the mutation lands', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('12'), 21_000);
+ const seen: CarveResult[] = [];
+ const result = await ensureExactAmount([bearer], 5_000, {
+ allocateOutputSecrets,
+ onCarve: (carve) => {
+ seen.push(carve);
},
- })
- expect(called).toBe(false)
- expect(result.consumed).toEqual([])
- })
+ mintSignatureKeys: signatureKeysFor(instance),
+ });
+ expect(seen).toHaveLength(2);
+ expect(seen[0]?.note.verified).toBe(false);
+ expect(seen[0]?.consumed).toEqual([]);
+ expect(seen[0]?.change).toBeDefined();
+ expect(noteK1(requiredValue(seen[0]).note.url)).toBe(noteK1(result.note.url));
+ expect(noteK1(requiredValue(requiredValue(seen[0]).change).url)).toBe(
+ noteK1(requiredValue(result.change).url),
+ );
+ // the landed checkpoint re-reports the SAME staged output (same secret)
+ // - its URL has since gained the mint's signature
+ expect(noteK1(requiredValue(seen[1]).note.url)).toBe(noteK1(requiredValue(seen[0]).note.url));
+ expect(noteSignature(requiredValue(seen[1]).note.url)).not.toBeNull();
+ expect(seen[1]?.note.verified).toBe(true);
+ expect(seen[1]?.change).toBeUndefined();
+ expect(seen[1]?.consumed).toEqual(result.consumed);
+ expect(result.consumed).toHaveLength(1);
+ });
+
+ it('does not stage the split change again during the landed checkpoint', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('17'), 21_000);
+ const trackedSecrets = new Set();
+ const retiredIds = new Set();
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ allocateOutputSecrets,
+ onCarve: (carve) => {
+ // checkpoints identify an output by its secret: the landed phase's
+ // URL has gained a signature the staged phase could not have had
+ const k1 = noteK1(carve.note.url);
+ if (k1) trackedSecrets.add(k1);
+ const changeK1 = carve.change ? noteK1(carve.change.url) : null;
+ if (changeK1) trackedSecrets.add(changeK1);
+ for (const consumed of carve.consumed) retiredIds.add(consumed.id);
+ },
+ });
+
+ expect(trackedSecrets).toEqual(
+ new Set([
+ requiredValue(noteK1(result.note.url)),
+ requiredValue(noteK1(requiredValue(result.change).url)),
+ ]),
+ );
+ expect(retiredIds).toEqual(new Set([bearer.id]));
+ });
+
+ it('keeps staged outputs recoverable when the landed checkpoint fails', async () => {
+ const instance = await mint();
+ const sourceSecret = secret('15');
+ const bearer = await makeBearer(instance, sourceSecret, 21_000);
+ const seen: CarveResult[] = [];
- it('propagates an onCarve failure - the carve landed, the caller must surface it', async () => {
- const instance = await mint()
- const bearer = await makeBearer(instance, secret('14'), 21_000)
await expect(
ensureExactAmount([bearer], 5_000, {
- onCarve: () => {
- throw new Error('commit failed')
+ allocateOutputSecrets,
+ onCarve: (carve) => {
+ seen.push(carve);
+ if (carve.consumed.length > 0) throw new Error('final commit failed');
},
}),
- ).rejects.toThrow(/commit failed/)
- })
-})
+ ).rejects.toThrow(/final commit failed/);
+
+ expect(seen).toHaveLength(2);
+ expect(seen[0]?.consumed).toEqual([]);
+ expect(seen[0]?.change).toBeDefined();
+ expect(instance.state.noteState(sourceSecret)).toBe('burned');
+ expect(instance.state.noteState(requiredValue(noteK1(requiredValue(seen[0]).note.url)))).toBe(
+ 'outstanding',
+ );
+ expect(
+ instance.state.noteState(
+ requiredValue(noteK1(requiredValue(requiredValue(seen[0]).change).url)),
+ ),
+ ).toBe('outstanding');
+ });
+
+ it('never fires onCarve for an already-exact note - nothing was burned', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('13'), 21_000);
+ let called = false;
+ const result = await ensureExactAmount([bearer], 21_000, {
+ onCarve: () => {
+ called = true;
+ },
+ });
+ expect(called).toBe(false);
+ expect(result.consumed).toEqual([]);
+ });
+
+ it('requires a typed durable checkpoint before a mutating carve', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('16'), 21_000);
+
+ await expect(ensureExactAmount([bearer], 5_000)).rejects.toBeInstanceOf(
+ CarveCheckpointRequiredError,
+ );
+ expect(instance.state.noteState(secret('16'))).toBe('outstanding');
+ });
+
+ it('propagates an onCarve failure before the carve reaches the mint', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('14'), 21_000);
+ await expect(
+ ensureExactAmount([bearer], 5_000, {
+ allocateOutputSecrets,
+ onCarve: () => {
+ throw new Error('commit failed');
+ },
+ }),
+ ).rejects.toThrow(/commit failed/);
+ expect(instance.state.noteState(secret('14'))).toBe('outstanding');
+ });
+
+ it('draws the split outputs from the caller allocation path, exactly two', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('80'), 21_000);
+ const allocations: Array<{ server: string; count: number }> = [];
+ const allocated = [secret('81'), secret('82')];
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets: (server, count) => {
+ allocations.push({ server, count });
+ return Promise.resolve(allocated.slice(0, count));
+ },
+ });
+
+ expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 2 }]);
+ expect(noteK1(result.note.url)).toBe(allocated[0]);
+ expect(noteK1(requiredValue(result.change).url)).toBe(allocated[1]);
+ });
+
+ it('draws the merge output from the caller allocation path, exactly one', async () => {
+ const instance = await mint();
+ const first = await makeBearer(instance, secret('83'), 3_000);
+ const second = await makeBearer(instance, secret('84'), 4_000);
+ const allocations: Array<{ server: string; count: number }> = [];
+ const allocated = [secret('85')];
+
+ const result = await ensureExactAmount([first, second], 7_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets: (server, count) => {
+ allocations.push({ server, count });
+ return Promise.resolve(allocated.slice(0, count));
+ },
+ });
+
+ expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
+ expect(noteK1(result.note.url)).toBe(allocated[0]);
+ });
+
+ it('allocates nothing when one note already holds the exact amount', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('86'), 21_000);
+ let allocations = 0;
+
+ await ensureExactAmount([bearer], 21_000, {
+ allocateOutputSecrets: () => {
+ allocations += 1;
+ return Promise.resolve([]);
+ },
+ });
+
+ expect(allocations).toBe(0);
+ });
+
+ it('refuses a mutating carve without an allocation path, before any staging', async () => {
+ const instance = await mint();
+ const split = await makeBearer(instance, secret('87'), 21_000);
+ const mergeA = await makeBearer(instance, secret('88'), 3_000);
+ const mergeB = await makeBearer(instance, secret('89'), 4_000);
+ let mutations = 0;
+ const countingFetch: typeof fetch = (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes('/w/cb')) mutations += 1;
+ return fetch(input, init);
+ };
+
+ await expect(
+ ensureExactAmount([split], 5_000, { fetch: countingFetch, onCarve: () => undefined }),
+ ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
+ await expect(
+ ensureExactAmount([mergeA, mergeB], 7_000, {
+ fetch: countingFetch,
+ onCarve: () => undefined,
+ }),
+ ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
+
+ expect(mutations).toBe(0);
+ expect(instance.state.noteState(secret('87'))).toBe('outstanding');
+ expect(instance.state.noteState(secret('88'))).toBe('outstanding');
+ expect(instance.state.noteState(secret('89'))).toBe('outstanding');
+ });
+});
diff --git a/src/lnurlcash/ops.mint-named.cases.ts b/src/lnurlcash/ops.mint-named.cases.ts
index 5e2d3de..2cbbf7a 100644
--- a/src/lnurlcash/ops.mint-named.cases.ts
+++ b/src/lnurlcash/ops.mint-named.cases.ts
@@ -1,121 +1,112 @@
-import {describe, expect, it} from 'vitest'
-import {hashK1, noteK1} from 'lnurlcash-kit'
+import { describe, expect, it } from 'vitest';
+import { hashK1, noteK1 } from 'lnurlcash-kit';
-import {claimMintedNote, prepareMint} from './ops'
-import {requiredValue} from './test-utils'
-import type {Mint} from './ops.testHarness'
-import {mint, settleLastInvoice} from './ops.testHarness'
+import { claimMintedNote, OutputSecretAllocationRequiredError, prepareMint } from './ops';
+import { requiredValue } from './test-utils';
+import type { Mint } from './ops.testHarness';
+import {
+ allocateOutputSecrets,
+ mint,
+ persistOutput,
+ secret,
+ settleLastInvoice,
+} from './ops.testHarness';
-const HEX32 = /^[0-9a-f]{64}$/
+const HEX32 = /^[0-9a-f]{64}$/;
-// the invoice whose quote the wallet bound to its own output hash
const boundInvoice = (instance: Mint) =>
- [...instance.state.invoices.values()].find((invoice) => invoice.boundTo)
+ [...instance.state.invoices.values()].find((invoice) => invoice.boundTo);
-describe('mint -> claim, named output (comment/mintToHash)', () => {
+describe('mint -> claim, comment-bound output', () => {
it('names the quote with the hash of a wallet-chosen secret', async () => {
- const instance = await mint({commentAllowed: 64, testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- expect(prepared.mode).toBe('named')
- const noteSecret = requiredValue(prepared.noteSecret)
- expect(noteSecret).toMatch(HEX32)
- // the mint bound the quote to sha256 of the wallet's secret - the kit
- // sends it as both comment and h, the mock read one of them
- expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret))
- })
+ const instance = await mint({ testHooks: true });
+ const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
+ persistOutput,
+ allocateOutputSecrets,
+ });
+ expect(prepared.noteSecret).toMatch(HEX32);
+ expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
+ });
- it('claims a named output at the wallet secret, without a rotate', async () => {
- const instance = await mint({commentAllowed: 64, testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- const noteSecret = requiredValue(prepared.noteSecret)
- const preimage = await settleLastInvoice(instance)
+ it('claims the output at the wallet secret without rotating it', async () => {
+ const instance = await mint({ testHooks: true });
+ const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
+ persistOutput,
+ allocateOutputSecrets,
+ });
+ const preimage = await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
- })
- expect(claimed.rotated).toBe(true)
- expect(claimed.note.verified).toBe(true)
- expect(claimed.note.amount).toBe(21_000)
- expect(claimed.note.callback).toBe(`${instance.url}/w/cb`)
- expect(noteK1(claimed.note.url)).toBe(noteSecret)
- // no rotate happened: the note still stands at the wallet's own
- // secret, and the payment preimage keys nothing at all
- expect(instance.state.noteState(noteSecret)).toBe('outstanding')
- expect(instance.state.noteState(preimage)).toBeNull()
- })
+ });
+ expect(claimed.note.verified).toBe(true);
+ expect(claimed.note.amount).toBe(21_000);
+ expect(claimed.note.callback).toBe(`${instance.url}/w/cb`);
+ expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
+ expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding');
+ expect(instance.state.noteState(preimage)).toBeNull();
+ });
- it('names the output through the mintToHash spelling too', async () => {
- const instance = await mint({mintToHash: true, testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- expect(prepared.mode).toBe('named')
- expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret)))
- const preimage = await settleLastInvoice(instance)
+ it('times out cleanly when the invoice is never paid', async () => {
+ const instance = await mint({ testHooks: true });
+ const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
+ persistOutput,
+ allocateOutputSecrets,
+ });
+ await expect(
+ claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100 }),
+ ).rejects.toThrow(/not confirmed/i);
+ });
+
+ it('uses the required comment even when the optional h extension is advertised', async () => {
+ const instance = await mint({ mintToHash: true, testHooks: true });
+ const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
+ persistOutput,
+ allocateOutputSecrets,
+ });
+ expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
+ await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
- })
- expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
- expect(instance.state.noteState(preimage)).toBeNull()
- })
+ });
+ expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
+ expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32);
+ });
- it('keeps an unnamed mint on the preimage path', async () => {
- const instance = await mint({testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- expect(prepared.mode).toBe('unnamed')
- expect(prepared.noteSecret).toBeUndefined()
- // the quote was NOT bound - nothing named an output
- expect(boundInvoice(instance)).toBeUndefined()
- })
+ it('draws the staged note secret from the caller allocation path', async () => {
+ const instance = await mint({ testHooks: true });
+ const allocations: Array<{ server: string; count: number }> = [];
+ const allocated = [secret('90')];
+
+ const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
+ persistOutput,
+ allocateOutputSecrets: (server, count) => {
+ allocations.push({ server, count });
+ return Promise.resolve(allocated.slice(0, count));
+ },
+ });
+
+ expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
+ expect(prepared.noteSecret).toBe(allocated[0]);
+ expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0])));
+ });
+
+ it('refuses to prepare without an allocation path - nothing staged, no invoice', async () => {
+ const instance = await mint({ testHooks: true });
+ let persisted = 0;
- it('times out cleanly when a named mint invoice is never paid', async () => {
- const instance = await mint({commentAllowed: 64, testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await expect(
- claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
- ).rejects.toThrow(/not confirmed/i)
- })
+ prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
+ persistOutput: () => {
+ persisted += 1;
+ },
+ }),
+ ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
- it('rescues through the preimage when the mint credited it anyway and serves verify', async () => {
- // the mintToHashIgnoresH adversary: claims the capability, binds
- // nothing, credits the payment hash - the preimage is still the money
- const instance = await mint({
- commentAllowed: 64,
- mintToHashIgnoresH: true,
- verifyOnUnnamedMint: true,
- testHooks: true,
- })
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- const preimage = await settleLastInvoice(instance)
- expect(instance.state.noteState(preimage)).toBe('outstanding')
- const claimed = await claimMintedNote(prepared, {
- intervalMs: 10,
- intervalCapMs: 20,
- maxWaitMs: 300,
- })
- expect(claimed.rotated).toBe(true)
- expect(claimed.note.verified).toBe(true)
- // claimed through the preimage path and rotated off it, because that
- // preimage rode the invoice
- expect(instance.state.noteState(preimage)).toBe('burned')
- const k1 = requiredValue(noteK1(claimed.note.url))
- expect(k1).not.toBe(preimage)
- expect(instance.state.noteState(k1)).toBe('outstanding')
- })
-
- it('fails loudly when the mint credits the preimage and serves no verify', async () => {
- const instance = await mint({commentAllowed: 64, mintToHashIgnoresH: true, testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- // verify is withheld on the unnamed fallback - there the preimage IS
- // the note, so a compliant mint never serves it
- expect(prepared.verifyUrl).toBeNull()
- const preimage = await settleLastInvoice(instance)
- await expect(
- claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 200}),
- ).rejects.toThrow(/not confirmed/i)
- // the money exists at the preimage but is unreachable without verify -
- // the wallet must NOT report a success here
- expect(instance.state.noteState(preimage)).toBe('outstanding')
- })
-})
+ expect(persisted).toBe(0);
+ expect(instance.state.invoices.size).toBe(0);
+ });
+});
diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts
index 3989d7c..297352e 100644
--- a/src/lnurlcash/ops.mint-receive.cases.ts
+++ b/src/lnurlcash/ops.mint-receive.cases.ts
@@ -1,4 +1,4 @@
-import {describe, expect, it} from 'vitest'
+import { describe, expect, it } from 'vitest';
import {
NoteSpentError,
PendingNoteError,
@@ -6,118 +6,181 @@ import {
meltNote,
noteK1,
rotateNote,
-} from 'lnurlcash-kit'
+} from 'lnurlcash-kit';
-import {claimMintedNote, prepareMint, receiveBearer} from './ops'
-import {requiredValue} from './test-utils'
-import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness'
-
-describe('mint -> claim -> rotate', () => {
- it('mints a note from a paid invoice and rotates it immediately', async () => {
- const instance = await mint({testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- expect(prepared.invoice).toMatch(/^lnbc/)
- expect(prepared.verifyUrl).toBeTruthy()
- expect(prepared.expectedNoteValueMsat).toBe(21_000)
- const preimage = await settleLastInvoice(instance)
- 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)
- expect(instance.state.noteState(preimage)).toBe('burned')
- const k1 = requiredValue(noteK1(claimed.note.url))
- expect(k1).not.toBe(preimage)
- expect(instance.state.noteState(k1)).toBe('outstanding')
- })
+import {
+ claimMintedNote,
+ OutputSecretAllocationRequiredError,
+ prepareMint,
+ receiveBearer,
+ ReceiveRotationStagingRequiredError,
+} from './ops';
+import { requiredValue } from './test-utils';
+import type { NewBearer } from './types';
+import {
+ allocateOutputSecrets,
+ makeBearer,
+ mint,
+ noteUrl,
+ persistOutput,
+ secret,
+ settleLastInvoice,
+ stageRotation,
+} from './ops.testHarness';
+describe('mint fee', () => {
it('grosses the invoice up for an advertised mint fee', async () => {
- const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000)
- expect(prepared.grossMsat).toBeGreaterThan(100_000)
- await settleLastInvoice(instance)
+ const instance = await mint({ testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000 });
+ const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000, {
+ persistOutput,
+ allocateOutputSecrets,
+ });
+ expect(prepared.grossMsat).toBeGreaterThan(100_000);
+ await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 5_000,
- })
+ });
// the service's maxWithdrawable is authoritative: the fee is withheld
// from the gross, so the note nets between the asked value and the gross
- expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000)
- expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat)
- expect(claimed.rotated).toBe(true)
- })
-
- it('refuses a verify response naming a different invoice', async () => {
- const instance = await mint({testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- await settleLastInvoice(instance)
- // a verify answer that names any invoice but the requested one must
- // stop the claim - the preimage it carries belongs to another payment
- const other = `${prepared.invoice.slice(0, -1)}${prepared.invoice.endsWith('q') ? 'p' : 'q'}`
- await expect(
- claimMintedNote({...prepared, invoice: other}, {
- intervalMs: 10,
- intervalCapMs: 20,
- maxWaitMs: 5_000,
- }),
- ).rejects.toThrow(/different invoice/)
- })
-
- it('times out cleanly when the invoice is never paid', async () => {
- const instance = await mint({testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- await expect(
- claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
- ).rejects.toThrow(/not confirmed/i)
- })
-})
+ expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000);
+ expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat);
+ });
+});
describe('receiveBearer', () => {
+ it('stages the rotation output before the rotate can reach the mint', async () => {
+ const instance = await mint();
+ const senderK1 = secret('94');
+ instance.state.creditNote(senderK1, 21_000);
+ const rotationSecret = secret('95');
+ let staged: NewBearer | undefined;
+ const observingFetch: typeof fetch = (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes('/w/cb')) expect(staged).toBeDefined();
+ return fetch(input, init);
+ };
+
+ const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
+ fetch: observingFetch,
+ allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
+ stageRotation: (note) => {
+ staged = note;
+ },
+ });
+
+ expect(received.stage).toBe('finalize');
+ expect(received.rotated).toBe(true);
+ expect(noteK1(received.note.url)).toBe(rotationSecret);
+ expect(noteK1(requiredValue(staged).url)).toBe(rotationSecret);
+ expect(requiredValue(staged).verified).toBe(false);
+ expect(requiredValue(staged).pendingMint?.sourceRecoverySecret).toBe(senderK1);
+ expect(instance.state.noteState(senderK1)).toBe('burned');
+ expect(instance.state.noteState(rotationSecret)).toBe('outstanding');
+ });
+
+ it('reports discard when the rotate provably never landed', async () => {
+ const instance = await mint();
+ const senderK1 = secret('96');
+ instance.state.creditNote(senderK1, 21_000);
+ const rotationSecret = secret('97');
+ let staged: NewBearer | undefined;
+ const failingFetch: typeof fetch = (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset'));
+ return fetch(input, init);
+ };
+
+ const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
+ fetch: failingFetch,
+ allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
+ stageRotation: (note) => {
+ staged = note;
+ },
+ });
+
+ expect(received.stage).toBe('discard');
+ expect(received.rotated).toBe(false);
+ expect(received.note.verified).toBe(true);
+ expect(received.rotationError).toBeDefined();
+ // nothing landed: both the sender's copy and the would-be output live on
+ expect(instance.state.noteState(senderK1)).toBe('outstanding');
+ expect(instance.state.noteState(rotationSecret)).toBeNull();
+ expect(staged).toBeDefined();
+ });
+
it("verifies an incoming note and rotates it, burning the sender's copy", async () => {
- const instance = await mint()
- const senderK1 = secret('20')
- instance.state.creditNote(senderK1, 21_000)
- const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [])
- expect(received.rotated).toBe(true)
- expect(received.note.amount).toBe(21_000)
- expect(received.note.verified).toBe(true)
- const newK1 = requiredValue(noteK1(received.note.url))
- expect(newK1).not.toBe(senderK1)
- expect(instance.state.noteState(senderK1)).toBe('burned')
- expect(instance.state.noteState(newK1)).toBe('outstanding')
- })
+ const instance = await mint();
+ const senderK1 = secret('20');
+ instance.state.creditNote(senderK1, 21_000);
+ const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
+ allocateOutputSecrets,
+ stageRotation,
+ });
+ expect(received.rotated).toBe(true);
+ expect(received.stage).toBe('finalize');
+ expect(received.note.amount).toBe(21_000);
+ expect(received.note.verified).toBe(true);
+ const newK1 = requiredValue(noteK1(received.note.url));
+ expect(newK1).not.toBe(senderK1);
+ expect(instance.state.noteState(senderK1)).toBe('burned');
+ expect(instance.state.noteState(newK1)).toBe('outstanding');
+ });
it('refuses a note the wallet already holds', async () => {
- const instance = await mint()
- const senderK1 = secret('21')
- const existing = await makeBearer(instance, senderK1, 21_000)
+ const instance = await mint();
+ const senderK1 = secret('21');
+ const existing = await makeBearer(instance, senderK1, 21_000);
await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow(
/already/,
- )
- })
+ );
+ });
it('surfaces a spent note as definitively spent', async () => {
- const instance = await mint()
- const k1 = secret('22')
- const bearer = await makeBearer(instance, k1, 21_000)
- const info = await fetchNoteInfo(bearer.url)
- await rotateNote(info.callback, k1)
+ const instance = await mint();
+ const k1 = secret('22');
+ const bearer = await makeBearer(instance, k1, 21_000);
+ const info = await fetchNoteInfo(bearer.url);
+ await rotateNote(info.callback, k1);
await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf(
NoteSpentError,
- )
- })
+ );
+ });
it('surfaces a note locked mid-melt as pending, not as unverified', async () => {
- const instance = await mint({meltNeverSettles: true})
- const k1 = secret('23')
- const bearer = await makeBearer(instance, k1, 21_000)
- await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz')
- await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf(
- PendingNoteError,
- )
- })
-})
+ const instance = await mint({ meltNeverSettles: true });
+ const k1 = secret('23');
+ const bearer = await makeBearer(instance, k1, 21_000);
+ await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz');
+ await expect(
+ receiveBearer(noteUrl(instance, k1, 21_000), [], {
+ allocateOutputSecrets,
+ stageRotation,
+ }),
+ ).rejects.toBeInstanceOf(PendingNoteError);
+ });
+
+ it('refuses to rotate without a durable staging checkpoint', async () => {
+ const instance = await mint();
+ const senderK1 = secret('9a');
+ instance.state.creditNote(senderK1, 21_000);
+
+ await expect(
+ receiveBearer(noteUrl(instance, senderK1, 21_000), [], { allocateOutputSecrets }),
+ ).rejects.toBeInstanceOf(ReceiveRotationStagingRequiredError);
+ // the sender's copy is untouched - no rotation was attempted
+ expect(instance.state.noteState(senderK1)).toBe('outstanding');
+ });
+
+ it('refuses to rotate without an allocation path', async () => {
+ const instance = await mint();
+ const senderK1 = secret('9b');
+ instance.state.creditNote(senderK1, 21_000);
+
+ await expect(
+ receiveBearer(noteUrl(instance, senderK1, 21_000), [], { stageRotation }),
+ ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
+ expect(instance.state.noteState(senderK1)).toBe('outstanding');
+ });
+});
diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts
index 08b1e7c..ffe27ec 100644
--- a/src/lnurlcash/ops.pay.cases.ts
+++ b/src/lnurlcash/ops.pay.cases.ts
@@ -1,98 +1,217 @@
-import {describe, expect, it} from 'vitest'
-import {noteK1} from 'lnurlcash-kit'
+import { describe, expect, it } from 'vitest';
+import { noteK1 } from 'lnurlcash-kit';
-import {payWithBearers} from './ops'
-import {requiredValue} from './test-utils'
-import {makeBearer, mint, secret} from './ops.testHarness'
+import {
+ OutputSecretAllocationRequiredError,
+ PayReturnCheckpointRequiredError,
+ payWithBearers,
+} from './ops';
+import { requiredValue } from './test-utils';
+import { allocateOutputSecrets, makeBearer, mint, secret } from './ops.testHarness';
describe('payWithBearers', () => {
it('pays a bolt11 invoice by melting an exact note (settled)', async () => {
- const instance = await mint()
- const bearer = await makeBearer(instance, secret('30'), 21_000)
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('30'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
- poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
- })
- expect(result.outcome).toBe('settled')
- expect(instance.state.noteState(secret('30'))).toBe('burned')
- })
+ poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
+ });
+ expect(result.outcome).toBe('settled');
+ expect(instance.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}`, {
+ const payer = await mint();
+ const bearer = await makeBearer(payer, secret('31'), 21_000);
+ const payeeFetch: typeof fetch = (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.startsWith('https://payee.example/.well-known/lnurlp/alice')) {
+ return Promise.resolve(
+ Response.json({
+ tag: 'payRequest',
+ callback: 'https://payee.example/invoice',
+ minSendable: 1_000,
+ maxSendable: 100_000_000,
+ metadata: '[]',
+ }),
+ );
+ }
+ if (url.startsWith('https://payee.example/invoice')) {
+ return Promise.resolve(Response.json({ pr: 'lnbc210n1pjqrstuvwxyz' }));
+ }
+ return fetch(input, init);
+ };
+ const result = await payWithBearers([bearer], 'alice@payee.example', {
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')
- })
+ kit: { fetch: payeeFetch },
+ 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 instance = await mint()
- const bearer = await makeBearer(instance, secret('32'), 50_000)
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('32'), 50_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
- poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
- })
- expect(result.outcome).toBe('settled')
- expect(instance.state.noteState(secret('32'))).toBe('burned')
- expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id])
- expect(result.carve.change?.amount).toBe(29_000)
- const change = requiredValue(result.carve.change)
- expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding')
- })
+ poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
+ allocateOutputSecrets,
+ onCarve: () => undefined,
+ });
+ expect(result.outcome).toBe('settled');
+ expect(instance.state.noteState(secret('32'))).toBe('burned');
+ expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
+ expect(result.carve.change?.amount).toBe(29_000);
+ const change = requiredValue(result.carve.change);
+ expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding');
+ });
it('classifies a failed melt as funds-returned once the note is spendable again', async () => {
- const instance = await mint({meltAlwaysFails: true})
- const bearer = await makeBearer(instance, secret('33'), 21_000)
+ const instance = await mint({ meltAlwaysFails: true });
+ const bearer = await makeBearer(instance, secret('33'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
- poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
- })
- expect(result.outcome).toBe('failed-funds-returned')
- expect(instance.state.noteState(secret('33'))).toBe('burned')
- // the returned funds come back re-secured at the classification
- // rotate's fresh secret - carried as rotatedNote, NOT folded into the
- // carve (which an onCarve checkpoint may already have committed)
- const rotatedNote = requiredValue(result.rotatedNote)
- expect(rotatedNote.verified).toBe(true)
- const returnedK1 = requiredValue(noteK1(rotatedNote.url))
- expect(instance.state.noteState(returnedK1)).toBe('outstanding')
- expect(rotatedNote.amount).toBe(21_000)
- })
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ allocateOutputSecrets,
+ onReturnReady: () => undefined,
+ });
+ expect(result.outcome).toBe('failed-funds-returned');
+ expect(instance.state.noteState(secret('33'))).toBe('burned');
+ const rotatedNote = requiredValue(result.rotatedNote);
+ expect(rotatedNote.verified).toBe(true);
+ expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding');
+ });
+
+ it('waits for durable recovery-secret staging before rotating returned funds', async () => {
+ const instance = await mint({ meltAlwaysFails: true });
+ const bearer = await makeBearer(instance, secret('39'), 21_000);
+ let releaseStage: (() => void) | undefined;
+ const stageGate = new Promise((resolve) => {
+ releaseStage = resolve;
+ });
+ let stageStarted: (() => void) | undefined;
+ const stageEntered = new Promise((resolve) => {
+ stageStarted = resolve;
+ });
+ const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ allocateOutputSecrets,
+ onReturnReady: async () => {
+ stageStarted?.();
+ await stageGate;
+ },
+ });
+
+ await stageEntered;
+ expect(instance.state.noteState(secret('39'))).toBe('outstanding');
+ releaseStage?.();
+ expect((await payment).outcome).toBe('failed-funds-returned');
+ expect(instance.state.noteState(secret('39'))).toBe('burned');
+ });
it('classifies a never-settling melt as unknown-still-pending', async () => {
- const instance = await mint({meltNeverSettles: true})
- const bearer = await makeBearer(instance, secret('34'), 21_000)
+ const instance = await mint({ meltNeverSettles: true });
+ const bearer = await makeBearer(instance, 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(instance.state.noteState(secret('34'))).toBe('pending')
- })
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ allocateOutputSecrets,
+ onReturnReady: () => undefined,
+ });
+ expect(result.outcome).toBe('unknown-still-pending');
+ expect(instance.state.noteState(secret('34'))).toBe('pending');
+ });
it('rejects an amountless or unreadable invoice instead of guessing', async () => {
- const instance = await mint()
- const bearer = await makeBearer(instance, 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)
- })
+ const instance = await mint();
+ const bearer = await makeBearer(instance, 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);
+ });
it('aborts before the melt when the carve commit hook fails', async () => {
- const instance = await mint()
- const bearer = await makeBearer(instance, secret('36'), 21_000)
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('36'), 21_000);
// paying 10_500 off a 21_000 note forces a split, which must commit
// through the hook before the melt may start
await expect(
payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', {
+ allocateOutputSecrets,
onCarve: () => {
- throw new Error('commit failed')
+ throw new Error('commit failed');
},
}),
- ).rejects.toThrow(/commit failed/)
- // the split landed (the input is burned) but the melt never happened:
- // nothing sits pending at the mint
- expect(instance.state.noteState(secret('36'))).toBe('burned')
- expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
- })
-})
+ ).rejects.toThrow(/commit failed/);
+ // The checkpoint failed before either split or melt reached the mint.
+ expect(instance.state.noteState(secret('36'))).toBe('outstanding');
+ expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
+ });
+
+ it('draws the return recovery secret from the caller allocation path', async () => {
+ const instance = await mint({ meltAlwaysFails: true });
+ const bearer = await makeBearer(instance, secret('8a'), 21_000);
+ const allocations: Array<{ server: string; count: number }> = [];
+ const allocated = [secret('8b')];
+ const stagedSecrets: string[] = [];
+
+ const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ allocateOutputSecrets: (server, count) => {
+ allocations.push({ server, count });
+ return Promise.resolve(allocated.slice(0, count));
+ },
+ onReturnReady: (_carve, recoverySecret) => {
+ stagedSecrets.push(recoverySecret);
+ },
+ });
+
+ expect(result.outcome).toBe('failed-funds-returned');
+ expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
+ expect(stagedSecrets).toEqual(allocated);
+ expect(noteK1(requiredValue(result.rotatedNote).url)).toBe(allocated[0]);
+ });
+
+ it('allocates nothing while the melt settles cleanly', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('8c'), 21_000);
+ let allocations = 0;
+
+ const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
+ poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
+ allocateOutputSecrets: () => {
+ allocations += 1;
+ return Promise.resolve([]);
+ },
+ });
+
+ expect(result.outcome).toBe('settled');
+ expect(allocations).toBe(0);
+ });
+
+ it('refuses the return classification without its durable checkpoint', async () => {
+ const instance = await mint({ meltAlwaysFails: true });
+ const bearer = await makeBearer(instance, secret('8d'), 21_000);
+
+ await expect(
+ payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ allocateOutputSecrets,
+ }),
+ ).rejects.toBeInstanceOf(PayReturnCheckpointRequiredError);
+ // the payment failed and the mint restored the note; the refusal came
+ // before the classification rotate, so nothing else moved
+ expect(instance.state.noteState(secret('8d'))).toBe('outstanding');
+ });
+
+ it('refuses the return classification without an allocation path', async () => {
+ const instance = await mint({ meltAlwaysFails: true });
+ const bearer = await makeBearer(instance, secret('8e'), 21_000);
+
+ await expect(
+ payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ onReturnReady: () => undefined,
+ }),
+ ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
+ expect(instance.state.noteState(secret('8e'))).toBe('outstanding');
+ });
+});
diff --git a/src/lnurlcash/ops.retry-refusal.cases.ts b/src/lnurlcash/ops.retry-refusal.cases.ts
index 9cf73c4..525b7e7 100644
--- a/src/lnurlcash/ops.retry-refusal.cases.ts
+++ b/src/lnurlcash/ops.retry-refusal.cases.ts
@@ -1,17 +1,18 @@
-import {describe, expect, it} from 'vitest'
-import {noteK1} from 'lnurlcash-kit'
+import { describe, expect, it } from 'vitest';
+import { noteK1 } from 'lnurlcash-kit';
-import {ensureExactAmount, receiveBearer} from './ops'
-import {claimFromPreimage, prepareMint} from './ops/mint'
-import {requiredValue} from './test-utils'
+import type { CarveResult } from './ops';
+import { ensureExactAmount, receiveBearer } from './ops';
+import { requiredValue } from './test-utils';
import {
+ allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
retryingCbFetch,
secret,
- settleLastInvoice,
-} from './ops.testHarness'
+ stageRotation,
+} from './ops.testHarness';
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
// can execute the first attempt of a mutation and refuse the byte-identical
@@ -21,70 +22,76 @@ import {
// carried secrets are the only money left.
describe('mutation retry refusals', () => {
it('rescues the outputs when the split answer is a retry refusal', async () => {
- const instance = await mint()
- const k1 = secret('60')
- const bearer = await makeBearer(instance, k1, 50_000)
- const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()})
+ const instance = await mint();
+ const k1 = secret('60');
+ const bearer = await makeBearer(instance, k1, 50_000);
+ const result = await ensureExactAmount([bearer], 25_000, {
+ fetch: retryingCbFetch(),
+ allocateOutputSecrets,
+ onCarve: () => undefined,
+ });
// the split landed on the first (unseen) attempt: the input is burned
// and both outputs are live under the secrets the wallet carried
- expect(instance.state.noteState(k1)).toBe('burned')
- expect(result.consumed).toHaveLength(1)
- const partK1 = requiredValue(noteK1(result.note.url))
- expect(partK1).not.toBe(k1)
- expect(result.note.amount).toBe(25_000)
- expect(instance.state.noteState(partK1)).toBe('outstanding')
- const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
- expect(instance.state.noteState(changeK1)).toBe('outstanding')
- })
+ expect(instance.state.noteState(k1)).toBe('burned');
+ expect(result.consumed).toHaveLength(1);
+ const partK1 = requiredValue(noteK1(result.note.url));
+ expect(partK1).not.toBe(k1);
+ expect(result.note.amount).toBe(25_000);
+ expect(instance.state.noteState(partK1)).toBe('outstanding');
+ const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
+ expect(instance.state.noteState(changeK1)).toBe('outstanding');
+ });
it('rescues the combined note when the merge answer is a retry refusal', async () => {
- const instance = await mint()
- const a = await makeBearer(instance, secret('61'), 20_000)
- const b = await makeBearer(instance, secret('62'), 5_000)
- const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()})
- expect(result.consumed).toHaveLength(2)
- expect(instance.state.noteState(secret('61'))).toBe('burned')
- expect(instance.state.noteState(secret('62'))).toBe('burned')
- const mergedK1 = requiredValue(noteK1(result.note.url))
- expect(result.note.amount).toBe(25_000)
- expect(instance.state.noteState(mergedK1)).toBe('outstanding')
- })
-
- it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => {
- const instance = await mint({testHooks: true})
- const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
- const preimage = await settleLastInvoice(instance)
- const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()})
- expect(claimed.rotated).toBe(true)
- expect(claimed.note.verified).toBe(true)
- expect(instance.state.noteState(preimage)).toBe('burned')
- const k1 = requiredValue(noteK1(claimed.note.url))
- expect(k1).not.toBe(preimage)
- expect(instance.state.noteState(k1)).toBe('outstanding')
- })
+ const instance = await mint();
+ const a = await makeBearer(instance, secret('61'), 20_000);
+ const b = await makeBearer(instance, secret('62'), 5_000);
+ const result = await ensureExactAmount([a, b], 25_000, {
+ fetch: retryingCbFetch(),
+ allocateOutputSecrets,
+ onCarve: () => undefined,
+ });
+ expect(result.consumed).toHaveLength(2);
+ expect(instance.state.noteState(secret('61'))).toBe('burned');
+ expect(instance.state.noteState(secret('62'))).toBe('burned');
+ const mergedK1 = requiredValue(noteK1(result.note.url));
+ expect(result.note.amount).toBe(25_000);
+ expect(instance.state.noteState(mergedK1)).toBe('outstanding');
+ });
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
- const instance = await mint()
- const senderK1 = secret('63')
- instance.state.creditNote(senderK1, 21_000)
+ const instance = await mint();
+ const senderK1 = secret('63');
+ instance.state.creditNote(senderK1, 21_000);
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: retryingCbFetch(),
- })
- expect(received.rotated).toBe(true)
- expect(received.note.amount).toBe(21_000)
- expect(instance.state.noteState(senderK1)).toBe('burned')
- const k1 = requiredValue(noteK1(received.note.url))
- expect(k1).not.toBe(senderK1)
- expect(instance.state.noteState(k1)).toBe('outstanding')
- })
+ allocateOutputSecrets,
+ stageRotation,
+ });
+ expect(received.rotated).toBe(true);
+ expect(received.note.amount).toBe(21_000);
+ expect(instance.state.noteState(senderK1)).toBe('burned');
+ const k1 = requiredValue(noteK1(received.note.url));
+ expect(k1).not.toBe(senderK1);
+ expect(instance.state.noteState(k1)).toBe('outstanding');
+ });
it('still fails a genuine refusal - a note spent before the split', async () => {
- const instance = await mint()
- const k1 = secret('64')
- const bearer = await makeBearer(instance, k1, 50_000)
- instance.state.settleMelt(k1) // burned elsewhere, before we ever asked
+ const instance = await mint();
+ const k1 = secret('64');
+ const bearer = await makeBearer(instance, k1, 50_000);
+ instance.state.settleMelt(k1); // burned elsewhere, before we ever asked
+ const checkpoints: CarveResult[] = [];
await expect(
- ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}),
- ).rejects.toThrow(/spent/i)
- })
-})
+ ensureExactAmount([bearer], 25_000, {
+ fetch: retryingCbFetch(),
+ allocateOutputSecrets,
+ onCarve: (carve) => {
+ checkpoints.push(carve);
+ },
+ }),
+ ).rejects.toThrow(/spent/i);
+ expect(checkpoints).toHaveLength(1);
+ expect(checkpoints[0]?.consumed).toEqual([]);
+ });
+});
diff --git a/src/lnurlcash/ops.signature.cases.ts b/src/lnurlcash/ops.signature.cases.ts
new file mode 100644
index 0000000..6c715c9
--- /dev/null
+++ b/src/lnurlcash/ops.signature.cases.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it } from 'vitest';
+import { noteK1, noteSignature } from 'lnurlcash-kit';
+
+import { ensureExactAmount } from './ops';
+import { requiredValue } from './test-utils';
+import {
+ allocateOutputSecrets,
+ makeBearer,
+ mint,
+ noteUrl,
+ secret,
+ signatureKeysFor,
+} from './ops.testHarness';
+import { fetchNoteInfo } from 'lnurlcash-kit';
+
+// Landed split/merge outputs carry the mint's signature in their URL (sig
+// param), and earn verified:true only when that signature checks against a
+// trusted CURRENT or PREVIOUS key for the mint. Invalid, missing, or
+// wrong-key signatures - and mints with no trusted keys at all - keep the
+// staged outputs unverified; the money is tracked either way (the notes
+// exist at the mint), only the offline-verifiable badge is withheld.
+describe('landed mutation signature verification', () => {
+ it('verifies a landed split against the current key and preserves its signature', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('a0'), 21_000);
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ mintSignatureKeys: signatureKeysFor(instance),
+ });
+
+ expect(result.note.verified).toBe(true);
+ const signature = noteSignature(result.note.url);
+ expect(signature).not.toBeNull();
+ expect(noteSignature(requiredValue(result.change).url)).not.toBeNull();
+ // change stays unverified (its true post-fee value is unknown until a
+ // refresh) even though its signature is preserved
+ expect(result.change?.verified).toBe(false);
+ });
+
+ it('verifies a landed merge against the current key and preserves its signature', async () => {
+ const instance = await mint();
+ const first = await makeBearer(instance, secret('a1'), 3_000);
+ const second = await makeBearer(instance, secret('a2'), 4_000);
+
+ const result = await ensureExactAmount([first, second], 7_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ mintSignatureKeys: signatureKeysFor(instance),
+ });
+
+ expect(result.note.verified).toBe(true);
+ expect(noteSignature(result.note.url)).not.toBeNull();
+ });
+
+ it('verifies against the previous key after the mint rotated its signing key', async () => {
+ // the mint signs every output under its RETIRED key while advertising
+ // the current one - exactly the rekey window a holder must tolerate
+ const instance = await mint({
+ previousPrivateKey: '22'.repeat(32),
+ signWithPreviousKey: true,
+ });
+ const bearer = await makeBearer(instance, secret('a3'), 21_000);
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ mintSignatureKeys: signatureKeysFor(instance),
+ });
+
+ expect(instance.state.previousPubkeys).toHaveLength(1);
+ expect(result.note.verified).toBe(true);
+ expect(noteSignature(result.note.url)).not.toBeNull();
+ });
+
+ it('rejects verification when the previous key is not trusted', async () => {
+ const instance = await mint({
+ previousPrivateKey: '22'.repeat(32),
+ signWithPreviousKey: true,
+ });
+ const bearer = await makeBearer(instance, secret('a4'), 21_000);
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ // only the current key is trusted - the signature under the retired
+ // key must not verify
+ mintSignatureKeys: () => [instance.state.pubkey],
+ });
+
+ expect(result.note.verified).toBe(false);
+ // the note and its signature are still tracked, recoverable by refresh
+ expect(noteSignature(result.note.url)).not.toBeNull();
+ const partK1 = requiredValue(noteK1(result.note.url));
+ expect(instance.state.noteState(partK1)).toBe('outstanding');
+ expect(instance.state.noteState(secret('a4'))).toBe('burned');
+ });
+
+ it('keeps a wrong-key signature staged unverified', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('a5'), 21_000);
+ const stranger = await mint({ privateKey: '33'.repeat(32) });
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ // the only trusted key on file belongs to a DIFFERENT mint
+ mintSignatureKeys: () => [stranger.state.pubkey],
+ });
+
+ expect(result.note.verified).toBe(false);
+ expect(noteSignature(result.note.url)).not.toBeNull();
+ expect(instance.state.noteState(secret('a5'))).toBe('burned');
+ expect(instance.state.noteState(requiredValue(noteK1(result.note.url)))).toBe('outstanding');
+ });
+
+ it('keeps a landed split unsigned by the mint staged unverified and consumed', async () => {
+ const instance = await mint({ signatures: false });
+ const k1 = secret('a6');
+ const bearer = await makeBearer(instance, k1, 21_000);
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ mintSignatureKeys: signatureKeysFor(instance),
+ });
+
+ // the mutation landed (the kit refused the missing signature, the
+ // rescue probe proved the burn): the inputs are retired, the staged
+ // outputs are tracked at exactly their staged secrets, unverified
+ expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
+ expect(result.note.verified).toBe(false);
+ expect(noteSignature(result.note.url)).toBeNull();
+ expect(instance.state.noteState(k1)).toBe('burned');
+ const partK1 = requiredValue(noteK1(result.note.url));
+ expect(instance.state.noteState(partK1)).toBe('outstanding');
+ expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
+ });
+
+ it('never verifies a landed mutation without any trusted keys on file', async () => {
+ const instance = await mint();
+ const bearer = await makeBearer(instance, secret('a7'), 21_000);
+
+ const result = await ensureExactAmount([bearer], 5_000, {
+ onCarve: () => undefined,
+ allocateOutputSecrets,
+ // no key source at all: nothing may be trusted blindly
+ });
+
+ expect(result.note.verified).toBe(false);
+ expect(noteSignature(result.note.url)).not.toBeNull();
+ expect(instance.state.noteState(requiredValue(noteK1(result.note.url)))).toBe('outstanding');
+ });
+});
diff --git a/src/lnurlcash/ops.test.ts b/src/lnurlcash/ops.test.ts
index 97ce1bf..07d25b7 100644
--- a/src/lnurlcash/ops.test.ts
+++ b/src/lnurlcash/ops.test.ts
@@ -1,6 +1,8 @@
-import './ops.carve.cases'
-import './ops.mint-named.cases'
-import './ops.mint-receive.cases'
-import './ops.pay.cases'
-import './ops.retry-refusal.cases'
-import './ops.transfer.cases'
+import './ops.carve.cases';
+import './ops.carve-crash.cases';
+import './ops.signature.cases';
+import './ops.mint-named.cases';
+import './ops.mint-receive.cases';
+import './ops.pay.cases';
+import './ops.retry-refusal.cases';
+import './ops.transfer.cases';
diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts
index ee6a1b1..73903ca 100644
--- a/src/lnurlcash/ops.testHarness.ts
+++ b/src/lnurlcash/ops.testHarness.ts
@@ -1,42 +1,69 @@
-import {afterEach, expect} 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 {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit'
+import { afterEach, expect } from 'vitest';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
+import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
+import { sha256 } from '@noble/hashes/sha2.js';
+import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit';
-import type {Bearer} from './types'
-import {requiredValue} from './test-utils'
+import type { Bearer } from './types';
+import { requiredValue } from './test-utils';
-export type Mint = Awaited>
+export type Mint = Awaited>;
-const mints: Mint[] = []
+const mints: Mint[] = [];
export const mint = async (options: Parameters[0] = {}): Promise => {
- const instance = await createMockMint(options)
- mints.push(instance)
- return instance
-}
+ const instance = await createMockMint(options);
+ mints.push(instance);
+ return instance;
+};
afterEach(async () => {
- await Promise.all(mints.splice(0).map((instance) => instance.close()))
-})
+ await Promise.all(mints.splice(0).map((instance) => instance.close()));
+});
export const secret = (seed: string): string =>
- bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)))
+ bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)));
+
+export const persistOutput = async (): Promise => undefined;
+
+// deterministic stand-in for the wallet's BIP-32 allocation path: answers
+// every reservation with fresh unique secrets, never reusing one
+let allocatedSecretCounter = 0;
+export const allocateOutputSecrets = (
+ _server: string,
+ count: number,
+): Promise => {
+ const secrets = Array.from({ length: count }, () => {
+ allocatedSecretCounter += 1;
+ return bytesToHex(sha256(utf8ToBytes(`allocated-${allocatedSecretCounter}`)));
+ });
+ return Promise.resolve(secrets);
+};
+
+// the receive-rotation staging checkpoint - engine tests keep no wallet,
+// so staging is a recorded no-op for them
+export const stageRotation = async (): Promise => undefined;
+
+// the trusted signing keys a production caller would serve from the
+// trusted-mint registry: the mock mint's current key plus any it retired
+export const signatureKeysFor = (instance: Mint) => (): readonly string[] => [
+ instance.state.pubkey,
+ ...instance.state.previousPubkeys,
+];
export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string =>
- buildNoteUrl(`${instance.url}/w`, k1, amountMsat)
+ buildNoteUrl(`${instance.url}/w`, k1, amountMsat);
-let fixtureCounter = 0
+let fixtureCounter = 0;
export const makeBearer = async (
instance: Mint,
k1: string,
amountMsat: number,
): Promise => {
- instance.state.creditNote(k1, amountMsat)
- const url = noteUrl(instance, k1, amountMsat)
- const info = await fetchNoteInfo(url)
- fixtureCounter += 1
+ instance.state.creditNote(k1, amountMsat);
+ const url = noteUrl(instance, k1, amountMsat);
+ const info = await fetchNoteInfo(url);
+ fixtureCounter += 1;
return {
id: `fixture-${fixtureCounter}`,
url,
@@ -46,29 +73,29 @@ export const makeBearer = async (
mintPubkey: instance.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now(),
- }
-}
+ };
+};
export const settleLastInvoice = async (instance: Mint): Promise => {
- const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1))
- const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`)
- if (!response.ok) throw new Error(`settle hook failed: ${response.status}`)
- return requiredValue(instance.state.invoices.get(paymentHash)).preimage
-}
+ const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1));
+ const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`);
+ if (!response.ok) throw new Error(`settle hook failed: ${response.status}`);
+ return requiredValue(instance.state.invoices.get(paymentHash)).preimage;
+};
export const settleWhenRequested = async (instance: Mint): Promise => {
for (let attempt = 0; attempt < 200 && instance.state.invoices.size === 0; attempt++) {
- await new Promise((resolve) => setTimeout(resolve, 10))
+ await new Promise((resolve) => setTimeout(resolve, 10));
}
- return settleLastInvoice(instance)
-}
+ return settleLastInvoice(instance);
+};
export const expectBurned = async (instance: Mint, k1: string): Promise => {
for (let attempt = 0; attempt < 200 && instance.state.noteState(k1) !== 'burned'; attempt++) {
- await new Promise((resolve) => setTimeout(resolve, 10))
+ await new Promise((resolve) => setTimeout(resolve, 10));
}
- expect(instance.state.noteState(k1)).toBe('burned')
-}
+ expect(instance.state.noteState(k1)).toBe('burned');
+};
// a fetch wrapper that answers every /w/cb request with the response to
// its SECOND, byte-identical send - the HTTP-stack GET retry a real client
@@ -78,13 +105,13 @@ export const expectBurned = async (instance: Mint, k1: string): Promise =>
// ever sees
export const retryingCbFetch = (): typeof fetch => {
const impl: typeof fetch = async (input, init) => {
- const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) {
- const first = await fetch(input, init)
- await first.arrayBuffer()
- return fetch(input, init)
+ const first = await fetch(input, init);
+ await first.arrayBuffer();
+ return fetch(input, init);
}
- return fetch(input, init)
- }
- return impl
-}
+ return fetch(input, init);
+ };
+ return impl;
+};
diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts
index 7ef3767..efce371 100644
--- a/src/lnurlcash/ops.transfer.cases.ts
+++ b/src/lnurlcash/ops.transfer.cases.ts
@@ -1,260 +1,336 @@
-import {describe, expect, it} from 'vitest'
-import {createMockMint} from 'lnurlcash-conformance/mock-mint'
-import {hashK1, noteK1} from 'lnurlcash-kit'
+import { describe, expect, it } from 'vitest';
+import { createMockMint } from 'lnurlcash-conformance/mock-mint';
+import { hashK1, noteK1 } from 'lnurlcash-kit';
-import {transferBetweenMints} from './ops'
-import type {CarveResult} from './ops'
-import {requiredValue} from './test-utils'
-import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness'
+import { transferBetweenMints as transferBetweenMintsEngine } from './ops';
+import { OutputSecretAllocationRequiredError, TransferMeltCheckpointRequiredError } from './ops';
+import type { CarveResult, TransferOptions } from './ops';
+import type { Bearer } from './types';
+import { requiredValue } from './test-utils';
+import {
+ allocateOutputSecrets,
+ expectBurned,
+ makeBearer,
+ mint,
+ persistOutput,
+ secret,
+ settleWhenRequested,
+} from './ops.testHarness';
+
+const transferBetweenMints = (
+ bearers: Bearer[],
+ amountMsat: number,
+ targetMint: string,
+ options: Omit = {},
+) =>
+ transferBetweenMintsEngine(bearers, amountMsat, targetMint, {
+ allocateOutputSecrets,
+ onMeltReady: () => undefined,
+ ...options,
+ persistOutput,
+ });
describe('transferBetweenMints', () => {
- const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
+ const fastPoll = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
+
+ it('draws the target and source secrets from the caller allocation path', async () => {
+ const source = await mint();
+ const target = await mint({ testHooks: true });
+ const bearer = await makeBearer(source, secret('91'), 21_000);
+ const targetSecret = secret('92');
+ const sourceRecoverySecret = secret('93');
+ const allocations: Array<{ server: string; count: number }> = [];
+ const meltReadiness: string[] = [];
- it('moves value to another mint: melt at source, claim + rotate at target', async () => {
- const source = await mint()
- const target = await mint({testHooks: true})
- const k1 = secret('40')
- const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
- })
- const preimage = await settleWhenRequested(target)
- const result = await pending
- expect(result.outcome).toBe('settled')
- expect(result.invoice).toMatch(/^lnbc/)
+ allocateOutputSecrets: (server, count) => {
+ allocations.push({ server, count });
+ const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret];
+ return Promise.resolve(secrets.slice(0, count));
+ },
+ onMeltReady: (_carve, recovery) => {
+ meltReadiness.push(recovery);
+ },
+ });
+ await settleWhenRequested(target);
+ const result = await pending;
+
+ expect(result.outcome).toBe('settled');
+ // the staged target note first, then the source recovery secret - an
+ // exact source carve allocates nothing
+ expect(allocations).toEqual([
+ { server: `127.0.0.1:${target.port}`, count: 1 },
+ { server: `127.0.0.1:${source.port}`, count: 1 },
+ ]);
+ expect(meltReadiness).toEqual([sourceRecoverySecret]);
+ expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret);
+ });
+
+ it('refuses the source melt without its durable checkpoint', async () => {
+ const source = await mint();
+ const target = await mint({ testHooks: true });
+ const bearer = await makeBearer(source, secret('98'), 21_000);
+
+ await expect(
+ transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
+ allocateOutputSecrets,
+ persistOutput,
+ }),
+ ).rejects.toBeInstanceOf(TransferMeltCheckpointRequiredError);
+
+ // the target stage happened (invoice exists) but the source note is
+ // untouched and nothing melted
+ expect(source.state.noteState(secret('98'))).toBe('outstanding');
+ });
+
+ it('refuses at target staging without an allocation path', async () => {
+ const source = await mint();
+ const target = await mint({ testHooks: true });
+ const bearer = await makeBearer(source, secret('99'), 21_000);
+
+ await expect(
+ transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
+ onMeltReady: () => undefined,
+ persistOutput,
+ }),
+ ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
+ expect(source.state.noteState(secret('99'))).toBe('outstanding');
+ });
+
+ it('moves value to another mint at a wallet-chosen target secret', async () => {
+ const source = await mint();
+ const target = await mint({ testHooks: true });
+ const k1 = secret('40');
+ const bearer = await makeBearer(source, k1, 21_000);
+ const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
+ poll: fastPoll,
+ });
+ const preimage = await settleWhenRequested(target);
+ const result = await pending;
+ expect(result.outcome).toBe('settled');
+ expect(result.invoice).toMatch(/^lnbc/);
expect(result.quote).toEqual({
requestedMsat: 21_000,
grossMsat: 21_000,
targetMintFeeMsat: 0,
sourceMeltFeeReserveMsat: 0,
- })
- expect(result.sourceServer).not.toBe(result.targetServer)
- await expectBurned(source, k1)
- const claimed = requiredValue(result.mintedAtTarget)
- expect(claimed.rotated).toBe(true)
- expect(claimed.note.amount).toBe(21_000)
- expect(claimed.note.verified).toBe(true)
- expect(target.state.noteState(preimage)).toBe('burned')
- const newK1 = requiredValue(noteK1(claimed.note.url))
- expect(newK1).not.toBe(preimage)
- expect(target.state.noteState(newK1)).toBe('outstanding')
- })
+ });
+ expect(result.sourceServer).not.toBe(result.targetServer);
+ await expectBurned(source, k1);
+ const claimed = requiredValue(result.mintedAtTarget);
+ expect(claimed.note.amount).toBe(21_000);
+ expect(claimed.note.verified).toBe(true);
+ expect(target.state.noteState(preimage)).toBeNull();
+ const newK1 = requiredValue(noteK1(claimed.note.url));
+ expect(newK1).not.toBe(preimage);
+ expect(target.state.noteState(newK1)).toBe('outstanding');
+ });
it('refuses an amount no source mint can cover', async () => {
- const source = await mint()
- const target = await mint()
- const k1 = secret('41')
- const bearer = await makeBearer(source, k1, 5_000)
+ const source = await mint();
+ const target = await mint();
+ const k1 = secret('41');
+ const bearer = await makeBearer(source, k1, 5_000);
+ let persisted = false;
await expect(
- transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`),
- ).rejects.toThrow(/enough/)
- expect(source.state.noteState(k1)).toBe('outstanding')
- })
+ transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, {
+ persistOutput: () => {
+ persisted = true;
+ },
+ }),
+ ).rejects.toThrow(/enough/);
+ expect(persisted).toBe(false);
+ expect(source.state.noteState(k1)).toBe('outstanding');
+ });
it('rejects a transfer onto the mint the notes are already on', async () => {
- const instance = await mint()
- const k1 = secret('42')
- const bearer = await makeBearer(instance, k1, 21_000)
+ const instance = await mint();
+ const k1 = secret('42');
+ const bearer = await makeBearer(instance, k1, 21_000);
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`),
- ).rejects.toThrow(/different target/)
- expect(instance.state.noteState(k1)).toBe('outstanding')
- })
+ ).rejects.toThrow(/different target/);
+ expect(instance.state.noteState(k1)).toBe('outstanding');
+ });
it('moves nothing when the target mint is unreachable', async () => {
- const source = await mint()
- const dead = await createMockMint()
- const deadAddress = `mint@127.0.0.1:${dead.port}`
- await dead.close()
- const k1 = secret('43')
- const bearer = await makeBearer(source, k1, 50_000)
- await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow()
- expect(source.state.noteState(k1)).toBe('outstanding')
- })
+ const source = await mint();
+ const dead = await createMockMint();
+ const deadAddress = `mint@127.0.0.1:${dead.port}`;
+ await dead.close();
+ const k1 = secret('43');
+ const bearer = await makeBearer(source, k1, 50_000);
+ await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow();
+ expect(source.state.noteState(k1)).toBe('outstanding');
+ });
+
+ it('reports a definitively spent source before waiting on the target', async () => {
+ const source = await mint();
+ const target = await mint();
+ const k1 = secret('54');
+ const bearer = await makeBearer(source, k1, 21_000);
+ source.state.settleMelt(k1);
+
+ const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`);
+
+ expect(result.outcome).toBe('note-already-spent');
+ });
it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
- const source = await mint({unconfirmedMutation: true})
- const target = await mint({testHooks: true})
- const k1 = secret('44')
- const bearer = await makeBearer(source, k1, 21_000)
+ const source = await mint({ unconfirmedMutation: true });
+ const target = await mint({ testHooks: true });
+ const k1 = secret('44');
+ const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
- })
- await settleWhenRequested(target)
- const result = await pending
- expect(result.outcome).toBe('settled')
- await expectBurned(source, k1)
- expect(result.mintedAtTarget?.note.amount).toBe(21_000)
- expect(result.mintedAtTarget?.rotated).toBe(true)
- })
-
- it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => {
- const source = await mint()
- const target = await mint({testHooks: true, echoWrongK1: true})
- const k1 = secret('45')
- const bearer = await makeBearer(source, k1, 21_000)
- const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
- poll: fastPoll,
- })
- const preimage = await settleWhenRequested(target)
- const result = await pending
- expect(result.outcome).toBe('settled-claim-failed')
- await expectBurned(source, k1)
- const note = requiredValue(result.claimMaterial?.note)
- expect(noteK1(note.url)).toBe(preimage)
- expect(note.verified).toBe(false)
- expect(note.amount).toBe(21_000)
- expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`)
- })
+ });
+ await settleWhenRequested(target);
+ const result = await pending;
+ expect(result.outcome).toBe('settled');
+ await expectBurned(source, k1);
+ expect(result.mintedAtTarget?.note.amount).toBe(21_000);
+ });
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
- const source = await mint()
- const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000})
- const k1 = secret('46')
- const bearer = await makeBearer(source, k1, 100_000)
+ const source = await mint();
+ const target = await mint({ baseFeeMsat: 1_000, feePpm: 2_000 });
+ const k1 = secret('46');
+ const bearer = await makeBearer(source, k1, 100_000);
await expect(
transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`),
- ).rejects.toThrow(/enough/)
- expect(source.state.noteState(k1)).toBe('outstanding')
- })
+ ).rejects.toThrow(/enough/);
+ expect(source.state.noteState(k1)).toBe('outstanding');
+ });
it('restores the source note, re-secured, when the melt fails', async () => {
- const source = await mint({meltAlwaysFails: true})
- const target = await mint({testHooks: true})
- const k1 = secret('47')
- const bearer = await makeBearer(source, k1, 21_000)
+ const source = await mint({ meltAlwaysFails: true });
+ const target = await mint({ testHooks: true });
+ const k1 = secret('47');
+ const bearer = await makeBearer(source, k1, 21_000);
const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
- poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
- })
- expect(result.outcome).toBe('failed-funds-returned')
- expect(result.mintedAtTarget).toBeUndefined()
- expect(source.state.noteState(k1)).toBe('burned')
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ });
+ expect(result.outcome).toBe('failed-funds-returned');
+ expect(result.mintedAtTarget).toBeUndefined();
+ expect(source.state.noteState(k1)).toBe('burned');
// the returned funds come back re-secured at the source-probe rotate's
// fresh secret - carried as rotatedNote, NOT folded into the carve
// (which an onCarve checkpoint may already have committed)
- const rotatedNote = requiredValue(result.rotatedNote)
- expect(rotatedNote.verified).toBe(true)
- const returnedK1 = requiredValue(noteK1(rotatedNote.url))
- expect(returnedK1).not.toBe(k1)
- expect(source.state.noteState(returnedK1)).toBe('outstanding')
- expect(rotatedNote.amount).toBe(21_000)
- })
+ const rotatedNote = requiredValue(result.rotatedNote);
+ expect(rotatedNote.verified).toBe(true);
+ const returnedK1 = requiredValue(noteK1(rotatedNote.url));
+ expect(returnedK1).not.toBe(k1);
+ expect(source.state.noteState(returnedK1)).toBe('outstanding');
+ expect(rotatedNote.amount).toBe(21_000);
+ });
- it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => {
- const source = await mint()
- const target = await mint({commentAllowed: 64, testHooks: true})
- const k1 = secret('48')
- const bearer = await makeBearer(source, k1, 21_000)
+ it('lands the target note at the staged wallet secret without rotating', async () => {
+ const source = await mint();
+ const target = await mint({ testHooks: true });
+ const k1 = secret('48');
+ const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
- })
- const preimage = await settleWhenRequested(target)
- const result = await pending
- expect(result.outcome).toBe('settled')
- await expectBurned(source, k1)
- const claimed = requiredValue(result.mintedAtTarget)
- expect(claimed.rotated).toBe(true)
- expect(claimed.note.amount).toBe(21_000)
- expect(claimed.note.verified).toBe(true)
- const newK1 = requiredValue(noteK1(claimed.note.url))
+ });
+ const preimage = await settleWhenRequested(target);
+ const result = await pending;
+ expect(result.outcome).toBe('settled');
+ await expectBurned(source, k1);
+ const claimed = requiredValue(result.mintedAtTarget);
+ expect(claimed.note.amount).toBe(21_000);
+ expect(claimed.note.verified).toBe(true);
+ const newK1 = requiredValue(noteK1(claimed.note.url));
// the note stands at the wallet's own secret - the quote was bound to
// its hash, and the payment preimage keys nothing at the target
- expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1))
- expect(target.state.noteState(newK1)).toBe('outstanding')
- expect(target.state.noteState(preimage)).toBeNull()
- })
+ expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1));
+ expect(target.state.noteState(newK1)).toBe('outstanding');
+ expect(target.state.noteState(preimage)).toBeNull();
+ });
it('keeps the named claim material when the target never credits the note', async () => {
- const source = await mint({meltNeverSettles: true})
- const target = await mint({commentAllowed: 64, testHooks: true})
- const k1 = secret('49')
- const bearer = await makeBearer(source, k1, 21_000)
+ const source = await mint({ meltNeverSettles: true });
+ const target = await mint({ testHooks: true });
+ const k1 = secret('49');
+ const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
- poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
- })
+ poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
+ });
// the target invoice exists (the quote is bound) but the source melt
// never lands, so nothing is ever credited at the wallet's secret
while (target.state.invoices.size === 0) {
- await new Promise((resolve) => setTimeout(resolve, 10))
+ await new Promise((resolve) => setTimeout(resolve, 10));
}
- const result = await pending
- expect(result.outcome).toBe('unknown-still-pending')
- expect(result.mintedAtTarget).toBeUndefined()
+ const result = await pending;
+ expect(result.outcome).toBe('unknown-still-pending');
+ expect(result.mintedAtTarget).toBeUndefined();
// the wallet's secret is the way back to the money if the melt's
// payment arrives later - and the note at that secret is tracked
// unverified, so a late settlement is never a lost note
- const noteSecret = requiredValue(result.claimMaterial?.noteSecret)
- expect(noteSecret).toMatch(/^[0-9a-f]{64}$/)
- const material = requiredValue(result.claimMaterial?.note)
- expect(material.verified).toBe(false)
- expect(noteK1(material.url)).toBe(noteSecret)
- })
+ const noteSecret = requiredValue(result.claimMaterial?.noteSecret);
+ expect(noteSecret).toMatch(/^[0-9a-f]{64}$/);
+ const material = requiredValue(result.claimMaterial?.note);
+ expect(material.verified).toBe(false);
+ expect(noteK1(material.url)).toBe(noteSecret);
+ });
- it('rescues a named transfer through the preimage when the target credited it anyway', async () => {
- const source = await mint()
- // the mintToHashIgnoresH adversary: advertises naming, credits the
- // payment hash - and (non-compliantly) still serves verify
- const target = await mint({
- commentAllowed: 64,
- mintToHashIgnoresH: true,
- verifyOnUnnamedMint: true,
- testHooks: true,
- })
- const k1 = secret('50')
- const bearer = await makeBearer(source, k1, 21_000)
- const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
- poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
- })
- const preimage = await settleWhenRequested(target)
- const result = await pending
- expect(result.outcome).toBe('settled')
- await expectBurned(source, k1)
- const claimed = requiredValue(result.mintedAtTarget)
- expect(claimed.rotated).toBe(true)
- // claimed through the preimage path and rotated off it, because that
- // preimage rode the invoice
- expect(target.state.noteState(preimage)).toBe('burned')
- const newK1 = requiredValue(noteK1(claimed.note.url))
- expect(newK1).not.toBe(preimage)
- expect(target.state.noteState(newK1)).toBe('outstanding')
- })
-
- it('fires onCarve after the carve landed and before the melt starts', async () => {
- const source = await mint()
- const target = await mint({testHooks: true})
- const bearer = await makeBearer(source, secret('51'), 21_000)
- const seen: CarveResult[] = []
+ it('fires onCarve before the carve or melt reaches the mint', async () => {
+ const source = await mint();
+ const target = await mint({ testHooks: true });
+ const bearer = await makeBearer(source, secret('51'), 21_000);
+ const seen: CarveResult[] = [];
const pending = transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
onCarve: (carve) => {
- seen.push(carve)
- // at hook time the melt must NOT have started: the carved note is
- // still outstanding (a melt would leave it pending)
- expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding')
+ seen.push(carve);
+ if (carve.consumed.length === 0) {
+ expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull();
+ expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding');
+ } else {
+ expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding');
+ expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned');
+ }
},
- })
- await settleWhenRequested(target)
- const result = await pending
- expect(result.outcome).toBe('settled')
- expect(seen).toHaveLength(1)
- expect(seen[0]?.consumed.map((entry) => entry.id)).toEqual([bearer.id])
- })
+ });
+ await settleWhenRequested(target);
+ const result = await pending;
+ expect(result.outcome).toBe('settled');
+ expect(seen).toHaveLength(2);
+ expect(seen[0]?.consumed).toEqual([]);
+ expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
+ });
it('aborts before the melt when the carve commit hook fails', async () => {
- const source = await mint()
- const target = await mint({testHooks: true})
- const k1 = secret('52')
- const bearer = await makeBearer(source, k1, 21_000)
+ const source = await mint();
+ const target = await mint({ testHooks: true });
+ const k1 = secret('52');
+ const bearer = await makeBearer(source, k1, 21_000);
await expect(
transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
onCarve: () => {
- throw new Error('commit failed')
+ throw new Error('commit failed');
},
}),
- ).rejects.toThrow(/commit failed/)
- // the split landed (the input is burned) but the melt never happened:
- // nothing sits pending at the source, nothing was ever paid
- expect(source.state.noteState(k1)).toBe('burned')
- expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
- })
-})
+ ).rejects.toThrow(/commit failed/);
+ // The checkpoint failed before either split or melt reached the source.
+ expect(source.state.noteState(k1)).toBe('outstanding');
+ expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
+ });
+
+ it('aborts before an exact-note melt when the final checkpoint fails', async () => {
+ const source = await mint();
+ const target = await mint();
+ const k1 = secret('53');
+ const bearer = await makeBearer(source, k1, 21_000);
+
+ await expect(
+ transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
+ onMeltReady: () => {
+ throw new Error('source link commit failed');
+ },
+ }),
+ ).rejects.toThrow(/source link commit failed/);
+
+ expect(source.state.noteState(k1)).toBe('outstanding');
+ });
+});
diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts
index af69955..3d8ed3e 100644
--- a/src/lnurlcash/ops.ts
+++ b/src/lnurlcash/ops.ts
@@ -5,12 +5,9 @@
// 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
-// from an UNNAMED 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 NAMED mint (LUD-25 comment /
-// mintToHash) credits the note to a wallet-chosen secret instead; that
-// secret never rode an invoice, so no rotate follows its claim
+// - rotate every externally received bearer. Minted notes are staged at a
+// wallet-chosen secret before invoice creation and claimed there without
+// rotating; the payment preimage is not bearer material
// - 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
@@ -28,20 +25,36 @@
// source, mint + claim at target)
// ops/shared.ts - bounded 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'
-export {transferBetweenMints} from './ops/transfer'
+export { UncertainOutcomeError } from './ops/shared';
+export type { FundOperationOptions, PollOptions } from './ops/shared';
+export { OutputSecretAllocationRequiredError } from './ops/allocation';
+export type { OutputSecretAllocator } from './ops/allocation';
+export {
+ CarveCheckpointRequiredError,
+ ensureExactAmount,
+ UnsupportedMultiBatchMergeError,
+} from './ops/carve';
+export type { CarveOptions, CarveResult } from './ops/carve';
+export {
+ MintedNoteSpentError,
+ prepareMint,
+ claimMintedNote,
+ recoverStagedMintOutput,
+} from './ops/mint';
+export type { PreparedMint, PrepareMintOptions, ClaimedNote, StagedMintRecovery } from './ops/mint';
+export { receiveBearer, ReceiveRotationStagingRequiredError } from './ops/receiveBearer';
+export type { ReceiveBearerOptions, ReceivedNote } from './ops/receiveBearer';
+export { payWithBearers, PayReturnCheckpointRequiredError } from './ops/pay';
+export type { PayOutcome, PayResult, PayOptions } from './ops/pay';
+export {
+ recoverPendingTransferSource,
+ transferBetweenMints,
+ TransferMeltCheckpointRequiredError,
+} from './ops/transfer';
export type {
TransferClaimMaterial,
TransferOptions,
TransferOutcome,
TransferQuote,
TransferResult,
-} from './ops/transfer'
+} from './ops/transfer';
diff --git a/src/lnurlcash/ops/allocation.ts b/src/lnurlcash/ops/allocation.ts
new file mode 100644
index 0000000..9e4c68c
--- /dev/null
+++ b/src/lnurlcash/ops/allocation.ts
@@ -0,0 +1,43 @@
+// Output-secret allocation: a mint-touching operation never invents the
+// secrets its outputs will live at. It asks the caller's durable allocation
+// path (the wallet's BIP-32 counter reservation, see
+// stores/walletFunds.ts) for exactly the secrets it is about to stage, at
+// the moment the target server is known - before any hash of them can
+// reach a mint. The reservation commit (counter bump plus encrypted
+// journal record) is what makes a crash between staging and wire safe:
+// indices may be burned, never reused, and the staged secrets are
+// recoverable from the funds document.
+//
+// The ops layer stays framework-free: this is just the option type and the
+// point-of-use requirement. Tests inject deterministic allocators;
+// production callers serve reserveCashIndices-backed secrets.
+
+export type OutputSecretAllocator = (server: string, count: number) => Promise;
+
+export class OutputSecretAllocationRequiredError extends Error {
+ override readonly name = 'OutputSecretAllocationRequiredError';
+
+ constructor() {
+ super('This operation requires durably allocated output secrets.');
+ }
+}
+
+// The one place secrets enter an operation: exactly `count` fresh secrets
+// for `server`, or a rejection before anything is staged or sent. A
+// short/long/wrong-shaped answer is a caller bug - fail the operation
+// rather than stage a note at an unmappable secret.
+export const requireOutputSecrets = async (
+ allocate: OutputSecretAllocator | undefined,
+ server: string,
+ count: number,
+): Promise => {
+ if (!allocate) throw new OutputSecretAllocationRequiredError();
+ const secrets = await allocate(server, count);
+ if (
+ secrets.length !== count ||
+ secrets.some((secret) => typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret))
+ ) {
+ throw new Error('The output secret allocation did not match the request.');
+ }
+ return secrets;
+};
diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts
index b8b65c2..67777f7 100644
--- a/src/lnurlcash/ops/carve.ts
+++ b/src/lnurlcash/ops/carve.ts
@@ -2,41 +2,72 @@
// and/or splitting as needed, into a single fresh note worth exactly the
// target - the operation every send and every melt starts from.
//
-// A carve whose mutation landed reports itself through options.onCarve
-// BEFORE returning, so a caller about to do something slow (a settlement
-// wait) can durably commit first - a flow aborted mid-wait must never
-// leave burned inputs looking spendable or strand the fresh outputs.
+// Every output secret is chosen and reported through options.onCarve BEFORE
+// its hash reaches the mint. That first checkpoint stages possible outputs
+// without retiring inputs; a second checkpoint retires the inputs only after
+// the mutation is known to have landed.
import {
AmbiguousMutationError,
- mergeNotes,
+ mergeBatches,
newSecretsOf,
noteK1,
probeBurnedNote,
requireNoteK1,
serverOf,
- settleNote,
splitNote,
withNewK1,
-} from 'lnurlcash-kit'
-import type {Bearer, NewBearer} from '../types'
-import type {FundOperationOptions} from './shared'
-import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared'
+} from 'lnurlcash-kit';
+import type { Bearer, NewBearer } from '../types';
+import type { OutputSecretAllocator } from './allocation';
+import { requireOutputSecrets } from './allocation';
+import { mergeAmbiguitySafe } from './carveRecovery';
+import type { FundOperationOptions } from './shared';
+import {
+ assertFundOwner,
+ landedNoteVerifies,
+ probeMutationOutput,
+ UncertainOutcomeError,
+ withMutationSafety,
+} 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
+// The same shape serves both checkpoint phases: the pre-wire phase carries
+// note/change with an empty consumed list, and the landed phase carries the
+// already-staged note plus the inputs the caller can now retire.
export type CarveResult = {
// the exact-amount note, ready to hand over or melt
- note: NewBearer
+ note: NewBearer;
// the remainder note, when the carve split a larger input
- change?: NewBearer
+ change?: NewBearer;
// the input notes burned server-side by the carve (empty when a single
// note already held exactly the target amount)
- consumed: Bearer[]
+ consumed: Bearer[];
+};
+
+export class UnsupportedMultiBatchMergeError extends Error {
+ override readonly name = 'UnsupportedMultiBatchMergeError';
+
+ constructor() {
+ super('This carve would require multiple merge requests and cannot be performed safely.');
+ }
}
+export class CarveCheckpointRequiredError extends Error {
+ override readonly name = 'CarveCheckpointRequiredError';
+
+ constructor() {
+ super('A mutating carve requires a durable onCarve checkpoint.');
+ }
+}
+
+export type CarveOptions = FundOperationOptions & {
+ // the caller's durable allocation path for the carve's output secrets
+ // (split: target + change; merge: the combined note). Required for any
+ // mutating carve: the secrets come from the wallet's reserved BIP-32
+ // indices so a restart can never reuse or lose them.
+ readonly allocateOutputSecrets?: OutputSecretAllocator;
+};
+
// 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
@@ -46,53 +77,59 @@ export type CarveResult = {
//
// 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)
+// - several notes summing exactly: one merge; folds spanning multiple
+// requests are rejected because partial progress needs a larger journal
// - 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
+// split takes many k1s - no merge round trip first); change stays
+// unverified until refresh because a second settle mutation would create
+// another crash window
export const ensureExactAmount = async (
bearers: Bearer[],
amountMsat: number,
- options: FundOperationOptions = {},
+ options: CarveOptions = {},
): Promise => {
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
- throw new Error('Amount must be a positive whole number of msat.')
+ throw new Error('Amount must be a positive whole number of msat.');
}
+ // the forced mutation policy (signatures required, one byte-identical
+ // replay) applies to every mint call below regardless of caller options
+ const mutationOptions = withMutationSafety(options);
const eligible = bearers.filter(
- (b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
- )
+ (b) => b.verified && !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()
+ const byServer = new Map();
for (const b of eligible) {
- const server = serverOf(b.url)
- byServer.set(server, [...(byServer.get(server) ?? []), b])
+ const server = serverOf(b.url);
+ byServer.set(server, [...(byServer.get(server) ?? []), b]);
}
- let pick: Bearer[] | null = null
+ 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
+ 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.')
+ 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))
+ const base = pick[0];
+ const total = pick.reduce((sum, b) => sum + b.amount, 0);
+ const k1s = pick.map((b) => requireNoteK1(b.url));
- // a carve that burned inputs reports itself the moment its outcome is
- // known, before the caller's flow moves on to anything slow or
- // uncertain - see FundOperationOptions.onCarve
- const checkpoint = async (result: CarveResult): Promise => {
- await options.onCarve?.(result)
- return result
- }
+ const checkpoint = async (result: CarveResult): Promise