From f07a900bd079637841792b6daddcfc69efda24c3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:05:47 +0200 Subject: [PATCH 1/2] fix(payout): fail-closed on empty post-broadcast tx ids (Lightning, Cardano, BTC, Monero, Zano) Follow-up to #4181 / closes residual double-payout class from #4194. After the on-chain send is reached, a missing/empty/unparseable transaction identifier is ambiguous (funds may already have moved). Returning a plain error caused handleBroadcastError to roll the order back to PREPARATION_CONFIRMED so the cron could broadcast again. - Lightning: map empty/missing payment_hash after LND send to TxBroadcastError for LN_URL, LND_HUB and LN_NID. Invoice retries fetch a NEW invoice, so payment_hash dedup does not protect a re-broadcast. In-band payment_error stays a plain Error (self-heal). PayoutLightningService fail-closes empty ids for all address types. - Cardano: empty txSubmit result -> TxBroadcastError. - Bitcoin sendMany / Monero / Zano: empty txid/tx_hash -> TxBroadcastError. - Replace the unsafe invoice-dedup unit test; add strategy regressions that prove PAYOUT_DESIGNATED is kept with no rebroadcast. --- .../node/__tests__/bitcoin-client.spec.ts | 17 ++ .../bitcoin/node/bitcoin-based-client.ts | 8 +- .../cardano/__tests__/cardano-client.spec.ts | 30 ++++ .../blockchain/cardano/cardano-client.ts | 8 +- .../monero/__tests__/monero-client.spec.ts | 16 ++ .../blockchain/monero/monero-client.ts | 4 + .../zano/__test__/zano-client.spec.ts | 18 +++ .../blockchain/zano/zano-client.ts | 4 + .../__tests__/lightning.service.spec.ts | 145 ++++++++++++++++++ .../lightning/services/lightning.service.ts | 27 +++- .../payout-lightning.service.spec.ts | 35 ++++- .../services/payout-lightning.service.ts | 15 +- ...esignate-before-broadcast.strategy.spec.ts | 39 +++++ 13 files changed, 351 insertions(+), 15 deletions(-) create mode 100644 src/integration/lightning/services/__tests__/lightning.service.spec.ts diff --git a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts index 113c71cdae..ebcfbfc13e 100644 --- a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts +++ b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts @@ -328,6 +328,23 @@ describe('BitcoinClient', () => { expect(error).toBeInstanceOf(TxBroadcastError); expect((error as TxBroadcastError).message).toBe('Bitcoin RPC send failed: node unreachable'); }); + + it('should wrap an empty/missing txid on a resolved `send` response into a TxBroadcastError (fail-closed)', async () => { + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: { complete: true }, error: null, id: 'test' })); + + let error: unknown; + try { + await client.sendMany(payload, 10); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Bitcoin broadcast returned an empty txid'); + }); }); // --- testMempoolAccept() Tests --- // diff --git a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts index 65def00ea1..9a9f784902 100644 --- a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts +++ b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts @@ -74,10 +74,16 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly // node-side call - there is no separate pre-broadcast step to exclude. Any failure surfacing // from this call (including an HTTP-level timeout) is ambiguous: the node may already have // relayed the tx before the response was lost, so it is treated as at-or-after-send. + // An empty/missing txid on a resolved response is equally ambiguous and must stay fail-closed + // (not return '' which would later roll the payout order back for re-broadcast). try { const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); - return result?.txid ?? ''; + if (!result?.txid) { + throw new TxBroadcastError('Bitcoin broadcast returned an empty txid', { cause: result }); + } + return result.txid; } catch (e) { + if (e instanceof TxBroadcastError) throw e; throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); } } diff --git a/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts b/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts index a59c7ec860..409c7005f6 100644 --- a/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts +++ b/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts @@ -87,5 +87,35 @@ describe('CardanoClient - broadcast boundary', () => { expect(error).not.toBeInstanceOf(TxBroadcastError); expect(client.getBlockFrostAPI).not.toHaveBeenCalled(); }); + + it('treats an empty tx hash from txSubmit as a broadcast-boundary failure (fail-closed)', async () => { + const txSubmit = jest.fn().mockResolvedValue(''); + const client = createClientStub(txSubmit); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Cardano broadcast returned an empty tx hash'); + }); + + it('treats a null/undefined tx hash from txSubmit as a broadcast-boundary failure (fail-closed)', async () => { + const txSubmit = jest.fn().mockResolvedValue(undefined); + const client = createClientStub(txSubmit); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Cardano broadcast returned an empty tx hash'); + }); }); }); diff --git a/src/integration/blockchain/cardano/cardano-client.ts b/src/integration/blockchain/cardano/cardano-client.ts index 2949bb884d..90b7faec9a 100644 --- a/src/integration/blockchain/cardano/cardano-client.ts +++ b/src/integration/blockchain/cardano/cardano-client.ts @@ -158,9 +158,15 @@ export class CardanoClient extends BlockchainClient { const blockFrostApi = this.getBlockFrostAPI(); + // Broadcast boundary: once txSubmit is reached the tx may already be accepted by the network. + // An empty/missing hash on a resolved response is ambiguous and must stay fail-closed (not a + // plain Error that would roll the payout order back for re-broadcast). try { - return await blockFrostApi.txSubmit(signedTransactionHex); + const txHash = await blockFrostApi.txSubmit(signedTransactionHex); + if (!txHash) throw new TxBroadcastError('Cardano broadcast returned an empty tx hash'); + return txHash; } catch (e) { + if (e instanceof TxBroadcastError) throw e; throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); } } diff --git a/src/integration/blockchain/monero/__tests__/monero-client.spec.ts b/src/integration/blockchain/monero/__tests__/monero-client.spec.ts index 76c960ada5..485ccfb493 100644 --- a/src/integration/blockchain/monero/__tests__/monero-client.spec.ts +++ b/src/integration/blockchain/monero/__tests__/monero-client.spec.ts @@ -130,5 +130,21 @@ describe('MoneroClient - broadcast boundary', () => { expect(error).toBeInstanceOf(TxBroadcastError); }); + + it('wraps a result with an empty tx_hash into a TxBroadcastError (fail-closed: transfer may already have been relayed)', async () => { + mockPost.mockResolvedValueOnce({ + result: { amount: 1500000000000, fee: 10000000000, tx_hash: '' }, + }); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Monero broadcast returned an empty tx hash'); + }); }); }); diff --git a/src/integration/blockchain/monero/monero-client.ts b/src/integration/blockchain/monero/monero-client.ts index b83895a989..b158037b47 100644 --- a/src/integration/blockchain/monero/monero-client.ts +++ b/src/integration/blockchain/monero/monero-client.ts @@ -273,6 +273,10 @@ export class MoneroClient extends BlockchainClient implements CoinOnly { if (sendTransferResult.error) throw new TxBroadcastError(sendTransferResult.error.message, { cause: sendTransferResult.error }); if (!sendTransferResult.result) throw new TxBroadcastError('No result after send transfer'); + // Empty tx_hash after a resolved transfer is ambiguous (wallet may already have relayed). + if (!sendTransferResult.result.tx_hash) { + throw new TxBroadcastError('Monero broadcast returned an empty tx hash', { cause: sendTransferResult }); + } return this.convertTransferAuToXmr({ amount: sendTransferResult.result.amount, diff --git a/src/integration/blockchain/zano/__test__/zano-client.spec.ts b/src/integration/blockchain/zano/__test__/zano-client.spec.ts index 35169c70f3..2b8848afcb 100644 --- a/src/integration/blockchain/zano/__test__/zano-client.spec.ts +++ b/src/integration/blockchain/zano/__test__/zano-client.spec.ts @@ -99,6 +99,24 @@ describe('ZanoClient - broadcast boundary', () => { expect((error as TxBroadcastError).message).toBe('gateway timeout'); }); + it('wraps a transfer response with an empty tx_hash into a TxBroadcastError (fail-closed)', async () => { + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.resolve({ result: { tx_details: { tx_hash: '' } } }); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Zano broadcast returned an empty tx hash'); + }); + it('wraps a transfer response missing tx_details into a TxBroadcastError', async () => { mockPost.mockImplementation((_url, params) => { if (params.method === 'getbalance') diff --git a/src/integration/blockchain/zano/zano-client.ts b/src/integration/blockchain/zano/zano-client.ts index 7d5717a55c..365c549afc 100644 --- a/src/integration/blockchain/zano/zano-client.ts +++ b/src/integration/blockchain/zano/zano-client.ts @@ -294,6 +294,10 @@ export class ZanoClient extends BlockchainClient { private createSendTransferResult(payoutAmount: number, response?: any): ZanoSendTransferResultDto { if (!response.result?.tx_details) throw new TxBroadcastError(`Transfer not sent: response was ${JSON.stringify(response)}`); + // Empty tx_hash after a resolved transfer is ambiguous (wallet may already have relayed). + if (!response.result.tx_details.tx_hash) { + throw new TxBroadcastError('Zano broadcast returned an empty tx hash', { cause: response }); + } return { txId: response.result.tx_details.tx_hash, diff --git a/src/integration/lightning/services/__tests__/lightning.service.spec.ts b/src/integration/lightning/services/__tests__/lightning.service.spec.ts new file mode 100644 index 0000000000..69b60af63d --- /dev/null +++ b/src/integration/lightning/services/__tests__/lightning.service.spec.ts @@ -0,0 +1,145 @@ +/** + * Focused unit tests for LightningService#sendTransfer post-send response mapping. + * + * After the LND send call has been reached, a missing/empty/unparseable payment_hash is ambiguous + * (payment may already have succeeded) and must surface as TxBroadcastError. An in-band + * payment_error ("no route") remains a plain Error so invoice payouts can self-heal. + */ + +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { HttpService } from 'src/shared/services/http.service'; +import { LightningClient } from '../../lightning-client'; +import { LightningService } from '../lightning.service'; + +jest.mock('src/config/config', () => { + const mockConfig = { + blockchain: { + lightning: { + certificate: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----', + lnd: { apiUrl: 'https://lnd.test', adminMacaroon: 'mac' }, + }, + }, + }; + return { + Config: mockConfig, + GetConfig: () => mockConfig, + }; +}); + +describe('LightningService#sendTransfer — post-send payment_hash mapping', () => { + let service: LightningService; + let client: LightningClient; + let sendByInvoiceSpy: jest.SpyInstance; + let sendByPublicKeySpy: jest.SpyInstance; + + beforeEach(() => { + const http = mock(); + client = mock(); + service = new LightningService(http); + // Replace the internal client used by sendTransfer with our mock + (service as any).client = client; + + sendByInvoiceSpy = jest.spyOn(client, 'sendPaymentByInvoice'); + sendByPublicKeySpy = jest.spyOn(client, 'sendPaymentByPublicKey'); + jest.spyOn(service, 'getInvoiceByLnurlp').mockResolvedValue('lnbc1invoice'); + jest.spyOn(service, 'getInvoiceByLndhub').mockResolvedValue('lnbc1invoice'); + jest.spyOn(service, 'getPublicKeyOfAddress').mockResolvedValue('03pubkey'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the hex payment hash on a successful invoice payment', async () => { + sendByInvoiceSpy.mockResolvedValue({ payment_hash: 'aGFzaA==', payment_error: '' }); + + await expect(service.sendTransfer('LNURL1dp68gurn8ghj7', 0.001)).resolves.toBe( + Buffer.from('aGFzaA==', 'base64').toString('hex'), + ); + }); + + it('throws a plain Error on in-band payment_error (self-heal: payment was not routed)', async () => { + sendByInvoiceSpy.mockResolvedValue({ payment_hash: '', payment_error: 'FAILURE_REASON_NO_ROUTE' }); + + let error: unknown; + try { + await service.sendTransfer('LNURL1dp68gurn8ghj7', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toContain('FAILURE_REASON_NO_ROUTE'); + }); + + it('wraps a missing payment_hash after send into a TxBroadcastError (fail-closed)', async () => { + sendByInvoiceSpy.mockResolvedValue({ payment_error: '' } as any); + + let error: unknown; + try { + await service.sendTransfer('LNURL1dp68gurn8ghj7', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Lightning broadcast returned an empty payment hash'); + }); + + it('wraps an empty payment_hash after send into a TxBroadcastError (fail-closed)', async () => { + sendByInvoiceSpy.mockResolvedValue({ payment_hash: '', payment_error: '' }); + + let error: unknown; + try { + await service.sendTransfer('LNURL1dp68gurn8ghj7', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Lightning broadcast returned an empty payment hash'); + }); + + it('wraps an unparseable payment_hash (Buffer.from throws) into a TxBroadcastError (fail-closed)', async () => { + sendByInvoiceSpy.mockResolvedValue({ payment_hash: { not: 'a string' } as any, payment_error: '' }); + + let error: unknown; + try { + await service.sendTransfer('LNURL1dp68gurn8ghj7', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + }); + + it('wraps a missing payment_hash on keysend into a TxBroadcastError (fail-closed)', async () => { + sendByPublicKeySpy.mockResolvedValue({ payment_error: '' } as any); + + let error: unknown; + try { + await service.sendTransfer('LNNID03aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Lightning broadcast returned an empty payment hash'); + }); + + it('wraps a missing payment_hash on LND_HUB into a TxBroadcastError (fail-closed)', async () => { + sendByInvoiceSpy.mockResolvedValue({ payment_hash: '', payment_error: '' }); + + let error: unknown; + try { + await service.sendTransfer('LNDHUB1dp68gurn8ghj7', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Lightning broadcast returned an empty payment hash'); + }); +}); diff --git a/src/integration/lightning/services/lightning.service.ts b/src/integration/lightning/services/lightning.service.ts index e2325df31b..da865e3c1d 100644 --- a/src/integration/lightning/services/lightning.service.ts +++ b/src/integration/lightning/services/lightning.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { HttpService } from 'src/shared/services/http.service'; import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; import { Util } from 'src/shared/utils/util'; @@ -141,11 +142,35 @@ export class LightningService { throw new Error(`Unknown address type ${addressType} in send payment`); } + // In-band payment_error: LND reports the payment was not routed - provably no funds moved. + // Keep this a plain Error so invoice payouts can self-heal (retry with a fresh invoice is safe). if (paymentResponse.payment_error) { throw new Error(`Error while sending payment by LNURL ${address}: ${paymentResponse.payment_error}`); } - return Buffer.from(paymentResponse.payment_hash, 'base64').toString('hex'); + // Post-send response mapping: the LND send call has already been reached. A missing, empty or + // unparseable payment_hash is ambiguous (payment may have succeeded) and must NOT surface as a + // plain Error - that would roll the payout order back and re-broadcast. Invoice retries also + // fetch a NEW invoice (different payment_hash), so LND dedup cannot protect a second send. + try { + if (!paymentResponse.payment_hash) { + throw new TxBroadcastError('Lightning broadcast returned an empty payment hash', { + cause: paymentResponse, + }); + } + + const txId = Buffer.from(paymentResponse.payment_hash, 'base64').toString('hex'); + if (!txId) { + throw new TxBroadcastError('Lightning broadcast returned an empty payment hash', { + cause: paymentResponse, + }); + } + + return txId; + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async getTransferCompletionData(txId: string, maxHours = 24): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts index c73ff69172..198aaa4065 100644 --- a/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts +++ b/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts @@ -7,9 +7,10 @@ * LightningService#sendTransfer throws for an in-band LND payment_error ("no route" etc., see * lightning.service.ts) - must propagate unchanged so the order self-heals. * - * One exception: a keysend (LN_NID) payment carries no invoice payment_hash, so LND cannot - * deduplicate a re-broadcast. Every keysend failure is therefore wrapped fail-closed (as a - * PayoutBroadcastException), even a plain in-band error, to prevent a double-pay on retry. + * Empty/missing payment hashes after the send are fail-closed for ALL address types (invoice + * retries fetch a NEW invoice, so LND payment_hash dedup does not protect a re-broadcast). + * Keysend (LN_NID) additionally wraps every non-success outcome fail-closed (no payment_hash + * for LND dedup at all). */ import { mock } from 'jest-mock-extended'; @@ -141,13 +142,35 @@ describe('PayoutLightningService', () => { } expect(error).toBeInstanceOf(PayoutBroadcastException); - expect((error as PayoutBroadcastException).message).toBe('Lightning keysend returned an empty payment hash'); + expect((error as PayoutBroadcastException).message).toBe('Lightning payment returned an empty payment hash'); }); - it('returns an empty payment hash unchanged for a non-keysend (invoice payment_hash dedup covers a retry)', async () => { + it('wraps an empty payment hash for invoice (LN_URL) fail-closed - invoice retries use a new payment_hash so LND dedup does not protect a re-broadcast', async () => { sendTransferSpy.mockResolvedValue(''); - await expect(service.sendPayment('ADDR_01', 0.001)).resolves.toBe(''); + let error: unknown; + try { + await service.sendPayment('LNURL1dp68gurn8ghj7...', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Lightning payment returned an empty payment hash'); + }); + + it('wraps an empty payment hash for LND_HUB fail-closed (same new-invoice-on-retry risk as LN_URL)', async () => { + sendTransferSpy.mockResolvedValue(''); + + let error: unknown; + try { + await service.sendPayment('LNDHUB1dp68gurn8ghj7...', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Lightning payment returned an empty payment hash'); }); }); diff --git a/src/subdomains/supporting/payout/services/payout-lightning.service.ts b/src/subdomains/supporting/payout/services/payout-lightning.service.ts index 4dd8c896bc..d7fba0b440 100644 --- a/src/subdomains/supporting/payout/services/payout-lightning.service.ts +++ b/src/subdomains/supporting/payout/services/payout-lightning.service.ts @@ -29,17 +29,20 @@ export class PayoutLightningService { async sendPayment(address: string, amount: number): Promise { // A keysend (LN_NID) payment carries no invoice payment_hash, so LND cannot deduplicate a - // re-broadcast - a self-healing retry could double-pay. Unlike an invoice payment (LN_URL / - // LND_HUB), we therefore treat every keysend outcome that is not a confirmed send as ambiguous - // (fail-closed): keep the order PAYOUT_DESIGNATED for escalation instead of rolling it back. + // re-broadcast - a self-healing retry could double-pay. Every keysend outcome that is not a + // confirmed send is therefore treated as ambiguous (fail-closed). + // + // Invoice payouts (LN_URL / LND_HUB) still self-heal on in-band payment_error ("no route"), + // because that proves the payment was not routed. Empty/missing payment hashes after the send + // are fail-closed for ALL address types: invoice retries fetch a NEW invoice with a different + // payment_hash, so LND dedup of the original invoice does not protect a second payment. const isKeysend = address.startsWith(LightningAddressType.LN_NID); try { const txId = await this.lightningService.sendTransfer(address, amount); - // An empty payment hash (e.g. a blank base64 hash) is returned without throwing; for a keysend - // it would otherwise roll the order back and re-broadcast, so treat it as a broadcast error. - if (isKeysend && !txId) throw new PayoutBroadcastException('Lightning keysend returned an empty payment hash'); + // Defence in depth: empty id after send is always ambiguous (all address types). + if (!txId) throw new PayoutBroadcastException('Lightning payment returned an empty payment hash'); return txId; } catch (e) { diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts index d5ae58091b..efd54d94fb 100644 --- a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts @@ -301,6 +301,45 @@ describe('Payout designate-before-broadcast', () => { expect(order.payoutTxId).toBeNull(); }); }); + + // Regression for #4194: empty/missing tx ids after the send must stay fail-closed (no rollback, + // no second broadcast). Invoice Lightning retries would otherwise fetch a new invoice; Cardano + // would re-submit a second tx after a successful empty-hash response. + describe('empty post-broadcast tx id — fail-closed regression (#4194)', () => { + beforeAll(() => { + new ConfigService(); + }); + + it('LightningStrategy: empty-payment-hash PayoutBroadcastException leaves PAYOUT_DESIGNATED (no rollback, no rebroadcast)', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setupLightning(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchSpy.mockRejectedValue(new PayoutBroadcastException('Lightning payment returned an empty payment hash')); + + await doPayout([order]); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBeNull(); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('CardanoStrategy: empty-tx-hash PayoutBroadcastException leaves PAYOUT_DESIGNATED (no rollback, no rebroadcast)', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setupCardano(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchSpy.mockRejectedValue(new PayoutBroadcastException('Cardano broadcast returned an empty tx hash')); + + await doPayout([order]); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBeNull(); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + }); }); class EvmStrategyWrapper extends EvmStrategy { From 554cd5b242bdaeb47c5ff4cf7e9c7729b6268783 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:52:04 +0200 Subject: [PATCH 2/2] style(payout): format bitcoin-client empty-txid test with prettier CI format:check failed on the empty-txid regression test added for the fail-closed sendMany guard. --- .../blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts index ebcfbfc13e..28e358c647 100644 --- a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts +++ b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts @@ -333,7 +333,9 @@ describe('BitcoinClient', () => { const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase - mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: { complete: true }, error: null, id: 'test' })); + mockRpcPost.mockImplementationOnce(() => + Promise.resolve({ result: { complete: true }, error: null, id: 'test' }), + ); let error: unknown; try {