diff options
Diffstat (limited to 'tools/ln-pay-double-wake.test.js')
| -rw-r--r-- | tools/ln-pay-double-wake.test.js | 111 |
1 files changed, 111 insertions, 0 deletions
diff --git a/tools/ln-pay-double-wake.test.js b/tools/ln-pay-double-wake.test.js new file mode 100644 index 0000000..c29d625 --- /dev/null +++ b/tools/ln-pay-double-wake.test.js @@ -0,0 +1,111 @@ +// 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 (_u, _h, wait) => { asp.checks++; (asp.waits = asp.waits || []).push(!!wait); 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); +}); + +// A zap's wait is made of round trips (398ja, 2026-11-10: NWC zaps "a lot more +// slower"). A send just handed to the ASP used to ask its status at once +// ("pending"), fetch the chain tip to go with that, hold a call until it +// settled, then ask once more for the preimage it had just been given: four +// round trips where the one held call does. +test('a fresh send: initiate, then the one held call settles it', async () => { + Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0, checks: 0, waits: [] }); + const { m, action } = mgrWith('cosigned'); + let tips = 0; + m.chain = { tipHeight: async () => { tips++; return 900; } }; + await m._driveLnPay(action); + expect(action.step).toBe('initiated'); // handed over, nothing asked yet + expect(asp.checks).toBe(0); + expect(tips).toBe(0); + const a = await m.awaitLnPay(action.id, 5000); + expect(a.step).toBe('done'); + expect(a.preimage).toBe(PRE); + expect(asp.waits).toEqual([true]); // the held call alone + expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1); +}); + +test('a held call that comes back failed is checked the ordinary way (and revoked)', async () => { + Object.assign(asp, { initiate: null, check: { status: 'failed' }, revoked: 0, checks: 0, waits: [] }); + const { m, action } = mgrWith('initiated'); + m._revokeLnPay = async (x) => { x.step = 'failed'; }; + const a = await m.awaitLnPay(action.id, 5000); + expect(asp.waits[0]).toBe(true); + expect(a.step).toBe('failed'); +}); + +test('a held call with a preimage that does not match the hash settles nothing', async () => { + Object.assign(asp, { initiate: null, check: { status: 'success', preimage: 'cd'.repeat(32) }, revoked: 0, checks: 0, waits: [] }); + const { m, action } = mgrWith('initiated'); + const a = await m.awaitLnPay(action.id, 5000); + expect(a.step).not.toBe('done'); +}); |
