1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
// Two wakes of the phone's worker, seconds apart, each over its own copy of
// the wallet, both drive one Lightning send (PullCap, 2026-10-24 23:21). The
// second's InitiateLightningPayment is refused — the HTLC is already 'ln-spent'
// to this very payment — and that once marked a PAID send 'revoking'. Now the
// send follows the payment instead, and a send in 'revoking' asks the ASP
// before revoking: paid → settled; still in flight → back to waiting.
// Run: bun test tools/ln-pay-double-wake.test.js
import { test, expect, mock } from 'bun:test';
import { hex } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import * as realLn from '../src/ark/lightning.js';
import * as realProto from '../src/ark/proto.js';
import * as realSend from '../src/ark/send.js';
const PRE = 'ab'.repeat(32);
const HASH = hex.encode(sha256(hex.decode(PRE)));
const asp = { initiate: null, check: null, revoked: 0, checks: 0 };
mock.module('../src/ark/lightning.js', () => ({
...realLn,
initiateLightningPayment: async () => { if (asp.initiate) throw asp.initiate; },
checkLightningPayment: async () => { asp.checks++; if (asp.check instanceof Error) throw asp.check; return asp.check; },
requestLightningPayHtlcRevocation: async () => { asp.revoked++; throw new Error('revocation should not be asked for'); },
}));
mock.module('../src/ark/proto.js', () => ({ ...realProto, decodeVtxo: () => ({ id: 'htlc1', point: { raw: new Uint8Array(36) } }) }));
mock.module('../src/ark/send.js', () => ({ ...realSend, registerVtxoTransactions: async () => {},
buildArkoorSend: () => ({}), genUserNonces: () => ({}), cosignPartBytes: () => new Uint8Array(1) }));
const { ArkManager } = await import('../src/ark/manager.js');
const { GrpcError } = realProto;
function mgrWith(step) {
const action = { id: 'lnpay-1700000000000', type: 'ln-pay', step, paymentHash: HASH, amountSat: 1, htlcExpiry: 1000, htlcBytesList: ['00'], htlcVtxoIds: ['htlc1'], changeBytesList: [], invoice: 'lnbc1' };
const m = Object.create(ArkManager.prototype);
const own = {
arkUrl: 'http://ark.test', chain: { tipHeight: async () => 900 },
state: { actions: [action], vtxos: [{ id: 'htlc1', state: 'pending', amountSat: 1 }], movements: [] },
_save: () => {}, _lnDriving: new Set(), _spentRetried: new Set(),
_key: () => ({ pubkey: new Uint8Array(33) }), _mailboxKey: () => ({ pubkey: new Uint8Array(33) }),
};
for (const [k, v] of Object.entries(own)) Object.defineProperty(m, k, { value: v, writable: true, configurable: true });
return { m, action };
}
test("a second wake's initiate refused as 'ln-spent' follows the payment to done", async () => {
Object.assign(asp, { initiate: new GrpcError(3, 'bad user input: vtxo b47f…:0 is not spendable (state: ln-spent)', 'bark_server.ArkService/InitiateLightningPayment'), check: { status: 'success', preimage: PRE }, revoked: 0 });
const { m, action } = mgrWith('cosigned');
await m._driveLnPay(action);
expect(action.step).toBe('done');
expect(action.preimage).toBe(PRE);
expect(asp.revoked).toBe(0);
expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1);
});
test("a send in 'revoking' that the ASP paid settles, nothing revoked", async () => {
Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0 });
const { m, action } = mgrWith('revoking');
await m._driveLnPay(action);
expect(action.step).toBe('done');
expect(asp.revoked).toBe(0);
});
test("a send in 'revoking' still in flight before its expiry goes back to waiting", async () => {
Object.assign(asp, { initiate: null, check: { status: 'pending' }, revoked: 0 });
const { m, action } = mgrWith('revoking');
await m._driveLnPay(action);
expect(action.step).toBe('initiated');
expect(asp.revoked).toBe(0);
});
test("a send the ASP never saw (not found) is still revoked", async () => {
Object.assign(asp, { initiate: null, check: new GrpcError(5, 'not found: payment attempt not found', 'x'), revoked: 0 });
const { m, action } = mgrWith('revoking');
await m._driveLnPay(action).catch(() => {});
expect(asp.revoked).toBe(1);
});
|