fix: drain accepted NWC work before wallet lock

This commit is contained in:
2026-08-22 16:56:25 +02:00
parent 2e0f0038f7
commit e98373491f
3 changed files with 556 additions and 263 deletions
@@ -0,0 +1,186 @@
// Lock vs NWC drain integration: an accepted pay_invoice that already
// crossed its irreversible melt must reach its truthful durable outcome
// (budget debit, spent bearer, success response) BEFORE wallet.lock()
// invalidates the lifecycle fence and clears runtime keys. Uses the real
// wallet store, the real NWC store, the real service, and the real owner
// fence - only the relay transport is fake and the mint is the local
// conformance mock. Regresses the ordering where lock invalidated the
// captured fence first, leaving a debited budget, a locally unspent burned
// bearer, and no response.
import { createPinia, setActivePinia } from 'pinia';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { NWC_RESPONSE_KIND, createConnection, readNwcConnections } from '@/lnurlcash/nwc';
import type { NostrEvent } from '@/lnurlcash/nwc';
import { isJsonObject } from '@/lnurlcash/jsonParsing';
import { requiredValue, stubLocalStorage } from '@/lnurlcash/test-utils';
import {
CLIENT_SECRET,
RELAYS,
createFakeRelay,
deferred,
methodRequest,
waitFor,
} from '@/lnurlcash/nwc.testProtocol';
import { setNwcTransportForTests, useNwcStore } from './nwc';
import { useWalletStore } from './wallet';
const PASSWORD = 'correct horse battery staple';
// decodes to exactly 21_000 msat, matching the credited note (exact carve)
const INVOICE_21K = 'lnbc210n1pjqrstuvwxyz';
type NwcResponsePayload = {
result_type: string;
error: { code: string; message: string } | null;
result: Record<string, unknown> | null;
};
const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
isJsonObject(value) &&
typeof value.result_type === 'string' &&
(value.error === null ||
(isJsonObject(value.error) &&
typeof value.error.code === 'string' &&
typeof value.error.message === 'string')) &&
(value.result === null || isJsonObject(value.result));
const readResponse = (published: NostrEvent[], requestId: string): NwcResponsePayload | null => {
const event = published.find(
(candidate) =>
candidate.kind === NWC_RESPONSE_KIND &&
candidate.tags.some((tag) => tag[0] === 'e' && tag[1] === requestId),
);
if (!event) return null;
const plaintext = nip44v2.decrypt(
event.content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey),
);
const parsed: unknown = JSON.parse(plaintext);
if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.');
return parsed;
};
type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = [];
beforeEach(() => {
vi.unstubAllGlobals();
vi.stubGlobal('navigator', {});
stubLocalStorage();
setActivePinia(createPinia());
});
afterEach(async () => {
setNwcTransportForTests(null);
await Promise.all(mints.splice(0).map((mint) => mint.close()));
});
describe('wallet lock with an in-flight NWC payment', () => {
it('drains an accepted post-melt pay to its durable outcome before invalidating the lifecycle', async () => {
// Given an encrypted unlocked wallet holding one exact-match note, served
// by a running NWC service over the fake relay
const mint = await createMockMint();
mints.push(mint);
const wallet = useWalletStore();
await wallet.create(PASSWORD);
const k1 = 'e1'.repeat(32);
mint.state.creditNote(k1, 21_000);
const noteUrl = buildNoteUrl(`${mint.url}/w`, k1, 21_000);
const noteInfo = await fetchNoteInfo(noteUrl);
const [bearer] = await wallet.addBearers(
[
{
url: noteUrl,
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: true,
mintPubkey: mint.state.pubkey,
},
],
wallet.captureOwnerFence(),
);
if (!bearer) throw new Error('Expected the added bearer.');
const ownerId = wallet.pubkey;
if (ownerId === null) throw new Error('Expected an unlocked owner.');
const connection = createConnection(wallet.requireLinkingKey(), {
relays: RELAYS,
budget: { maxMsat: 1_000_000_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET,
});
const relay = createFakeRelay();
setNwcTransportForTests(relay.transport);
const nwc = useNwcStore();
await nwc.setEnabled(true);
expect(nwc.running).toBe(true);
// ... and a pay that crosses the irreversible melt, then pauses at the
// durable bearer commit
const releaseCommit = deferred();
let commitReached = false;
const applyChangeset = wallet.applyChangeset;
vi.spyOn(wallet, 'applyChangeset').mockImplementation(async (changeset, ownerFence) => {
commitReached = true;
await releaseCommit.promise;
return applyChangeset(changeset, ownerFence);
});
const request = methodRequest(
connection.walletServicePubkey,
'pay_invoice',
{ invoice: INVOICE_21K },
'nip44_v2',
Math.floor(Date.now() / 1000),
);
relay.emit(request);
await waitFor(() => commitReached);
expect(mint.state.noteState(k1)).toBe('burned');
// When the holder locks the wallet mid-flight
let lockSettled = false;
const locking = wallet.lock().then(() => {
lockSettled = true;
});
await new Promise((resolve) => setTimeout(resolve, 25));
// Then the lock waits for the accepted pay instead of invalidating its
// commit: the lifecycle stays commit-capable while subscriptions are
// already closed, so no new work is accepted once the lock began
expect(lockSettled).toBe(false);
expect(wallet.state).toBe('unlocked');
expect(relay.subscriptionCount()).toBe(0);
const lateRequest = methodRequest(
connection.walletServicePubkey,
'get_balance',
{},
'nip44_v2',
Math.floor(Date.now() / 1000),
);
relay.emitAfterClose(lateRequest);
await new Promise((resolve) => setTimeout(resolve, 25));
expect(readResponse(relay.published, lateRequest.id)).toBeNull();
// When the paused payment resumes
releaseCommit.resolve();
// Then the client receives its deterministic success BEFORE the lock
// completes, and budget and bearer state commit consistently
await waitFor(() => readResponse(relay.published, request.id) !== null);
const response = requiredValue(readResponse(relay.published, request.id));
expect(response.error).toBeNull();
expect(typeof response.result?.preimage).toBe('string');
expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000);
await locking;
expect(wallet.state).toBe('locked');
expect(wallet.bearers).toEqual([]);
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
expect(nwc.running).toBe(false);
// ... and the spent note survives the lock durably
await wallet.unlock(PASSWORD);
expect(wallet.bearers.find((candidate) => candidate.id === bearer.id)?.spent).toBe(true);
await nwc.setEnabled(false);
});
});