diff --git a/packages/core/src/engine/activity.ts b/packages/core/src/engine/activity.ts index da1695b0..aac17b9c 100644 --- a/packages/core/src/engine/activity.ts +++ b/packages/core/src/engine/activity.ts @@ -17,6 +17,22 @@ import { export class ActivityStream { #running = false; #cursor = 0; + /** + * Preimages seen on the stream, keyed by payment hash. + * + * The daemon reveals a send's preimage exactly once, on the entry it pushes + * when the swap settles, and omits it from the list snapshot that every + * later refresh reads. Without remembering it here the field is unreachable + * through any public API: the stream event is consumed for its cursor and + * the body discarded, and the refresh that follows overwrites the entry with + * a copy that has none. + * + * That matters because a preimage is proof of payment, and the only way a + * caller can demonstrate to a third party that an invoice was actually + * settled. Holding them in memory is enough: they are re-delivered on the + * includeExisting replay when a stream reopens. + */ + #preimages = new Map(); #backoff = STREAM_BACKOFF_MS; #failures = 0; #lifecycleGeneration = 0; @@ -62,19 +78,40 @@ export class ActivityStream { this.#running = false; this.#lifecycleGeneration += 1; this.#cursor = 0; + this.#preimages.clear(); clearTimeout(this.#retryTimer); clearTimeout(this.#debounce); this.#opts.client.stopActivity(); } + /** + * Returns the preimage seen for a payment hash, or undefined. It is the only + * route to a settled send's proof of payment; see #preimages. + */ + preimageFor(paymentHash: string): string | undefined { + return this.#preimages.get(paymentHash); + } + + /** Every preimage seen so far, for merging into a refreshed snapshot. */ + preimages(): ReadonlyMap { + return this.#preimages; + } + /** Forwarded 'activity' client events; debounced into one onActivity call. */ - noteActivity(entry: Pick): void { + noteActivity(entry: Pick & Partial): void { if (!this.#running) { return; } if (Number.isSafeInteger(entry.cursor) && entry.cursor > this.#cursor) { this.#cursor = entry.cursor; } + + // Capture the proof before the body is dropped. This is the only moment + // it exists anywhere the SDK can see. + const progress = entry.progress; + if (progress?.paymentHash && progress.preimage) { + this.#preimages.set(progress.paymentHash, progress.preimage); + } clearTimeout(this.#debounce); this.#debounce = setTimeout(() => this.#opts.onActivity(), ACTIVITY_DEBOUNCE_MS); } diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index d4d8422b..f5c4c061 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -598,7 +598,16 @@ class WavelengthEngine implements WalletEngine { list(req: ListRequest): Promise { this.#assertNotDisposed(); // Listing reads state, so nothing to refresh. - return this.client.list(req); + return this.client.list(req).then((result) => { + if (!result.activity) return result; + return { + ...result, + activity: { + ...result.activity, + entries: restorePreimages(result.activity.entries, this.#stream.preimages()), + }, + }; + }); } clearLogs(): void { @@ -692,7 +701,10 @@ class WavelengthEngine implements WalletEngine { if (snap.phase === 'stopping' || snap.phase === 'stopped') { return snap.balance; } - const entries: Entry[] = rows.activity?.entries || []; + const entries: Entry[] = restorePreimages( + rows.activity?.entries || [], + this.#stream.preimages(), + ); const nextInfo = stabilize(snap.info, info); const nextBalance = stabilize(snap.balance, balance); const nextActivity = stabilize(snap.activity, entries); @@ -850,3 +862,38 @@ class WavelengthEngine implements WalletEngine { this.#rejectRestore(new Error('the runtime stopped during the restore')); } } + +/** + * Puts back the preimages the list snapshot drops. + * + * The daemon reveals a send's preimage once, on the stream entry it pushes at + * settle, and never again: every list read returns the entry with the field + * empty. So a refresh would otherwise erase proof of payment that the SDK had + * already seen, and a caller who blinked would have no way to get it back. + * + * Entries that already carry a preimage, or for which none was ever seen, are + * returned untouched, so this allocates nothing in the ordinary case and never + * invents a value it was not given. + */ +export function restorePreimages( + entries: readonly Entry[], + preimages: ReadonlyMap, +): Entry[] { + if (preimages.size === 0) { + return entries as Entry[]; + } + + return entries.map((entry) => { + const progress = entry.progress; + if (!progress?.paymentHash || progress.preimage) { + return entry; + } + + const preimage = preimages.get(progress.paymentHash); + if (preimage === undefined) { + return entry; + } + + return { ...entry, progress: { ...progress, preimage } }; + }); +} diff --git a/packages/core/src/engine/preimages.test.ts b/packages/core/src/engine/preimages.test.ts new file mode 100644 index 00000000..f49273b9 --- /dev/null +++ b/packages/core/src/engine/preimages.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { restorePreimages } from './engine.ts'; +import { ActivityStream } from './activity.ts'; +import type { Entry } from '../results.ts'; + +// A send's preimage is proof of payment: it is the only thing a caller can +// show a third party to demonstrate that an invoice actually settled. The +// daemon reveals it exactly once, on the stream entry it pushes when the swap +// completes, and every list read afterwards returns the entry with the field +// empty. +// +// These tests pin the two halves that keep it reachable: the stream remembers +// what it saw, and the refresh puts it back. + +function entry(over: Partial & { progress?: Partial }): Entry { + return { + id: 'e1', + kind: 'send', + status: 'complete', + amountSat: -1000, + cursor: 1, + ...over, + progress: { + phase: 'confirmed', + phaseLabel: 'confirmed', + paymentHash: 'hash-1', + txid: '', + confirmationHeight: 0, + vTXOOutpoint: '', + preimage: '', + ...over.progress, + }, + } as Entry; +} + +describe('restorePreimages', () => { + it('puts back a preimage the list snapshot dropped', () => { + const got = restorePreimages( + [entry({})], + new Map([['hash-1', 'pre-1']]), + ); + + assert.equal(got[0]?.progress?.preimage, 'pre-1'); + }); + + it('leaves an entry that already carries one alone', () => { + const rows = [entry({ progress: { preimage: 'from-stream' } })]; + + const got = restorePreimages(rows, new Map([['hash-1', 'stale']])); + + assert.equal(got[0]?.progress?.preimage, 'from-stream'); + assert.equal(got[0], rows[0], 'an untouched entry should not be copied'); + }); + + it('never invents a preimage it was not given', () => { + const rows = [entry({})]; + + const got = restorePreimages(rows, new Map([['other-hash', 'pre']])); + + assert.equal(got[0]?.progress?.preimage, ''); + assert.equal(got[0], rows[0]); + }); + + it('is a no-op when nothing has been seen', () => { + const rows = [entry({})]; + + assert.equal(restorePreimages(rows, new Map()), rows); + }); + + it('does not mutate the entry it was handed', () => { + const rows = [entry({})]; + + restorePreimages(rows, new Map([['hash-1', 'pre-1']])); + + assert.equal( + rows[0]?.progress?.preimage, + '', + 'the caller’s array must be left as it was', + ); + }); +}); + +describe('ActivityStream preimage capture', () => { + function stream() { + const s = new ActivityStream({ + client: { + startActivity: () => Promise.resolve(), + stopActivity: () => Promise.resolve(), + }, + onActivity: () => {}, + onReconcile: () => {}, + onDead: () => {}, + }); + s.start(); + + return s; + } + + it('remembers a preimage before the entry body is discarded', () => { + const s = stream(); + + s.noteActivity( + entry({ cursor: 2, progress: { preimage: 'pre-1' } }), + ); + + assert.equal(s.preimageFor('hash-1'), 'pre-1'); + s.stop(); + }); + + it('ignores an entry that carries no preimage yet', () => { + const s = stream(); + + s.noteActivity(entry({ cursor: 2 })); + + assert.equal(s.preimageFor('hash-1'), undefined); + s.stop(); + }); + + it('forgets everything on stop, so a new wallet starts clean', () => { + const s = stream(); + s.noteActivity(entry({ cursor: 2, progress: { preimage: 'pre-1' } })); + + s.stop(); + + assert.equal(s.preimageFor('hash-1'), undefined); + }); + + it('drops events once stopped', () => { + const s = stream(); + s.stop(); + + s.noteActivity(entry({ cursor: 2, progress: { preimage: 'pre-1' } })); + + assert.equal(s.preimageFor('hash-1'), undefined); + }); +});