Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion packages/core/src/engine/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
#backoff = STREAM_BACKOFF_MS;
#failures = 0;
#lifecycleGeneration = 0;
Expand Down Expand Up @@ -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<string, string> {
return this.#preimages;
}

/** Forwarded 'activity' client events; debounced into one onActivity call. */
noteActivity(entry: Pick<Entry, 'cursor'>): void {
noteActivity(entry: Pick<Entry, 'cursor'> & Partial<Entry>): 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);
}
Expand Down
51 changes: 49 additions & 2 deletions packages/core/src/engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,16 @@ class WavelengthEngine implements WalletEngine {
list(req: ListRequest): Promise<ListResult> {
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 {
Expand Down Expand Up @@ -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(),
);
Comment thread
jamaljsr marked this conversation as resolved.
const nextInfo = stabilize(snap.info, info);
const nextBalance = stabilize(snap.balance, balance);
const nextActivity = stabilize(snap.activity, entries);
Expand Down Expand Up @@ -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<string, string>,
): 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 } };
});
}
138 changes: 138 additions & 0 deletions packages/core/src/engine/preimages.test.ts
Original file line number Diff line number Diff line change
@@ -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<Entry> & { progress?: Partial<Entry['progress']> }): 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);
});
});
Loading