Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,25 @@ 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 --- //
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
8 changes: 7 additions & 1 deletion src/integration/blockchain/cardano/cardano-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
4 changes: 4 additions & 0 deletions src/integration/blockchain/monero/monero-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions src/integration/blockchain/zano/__test__/zano-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 4 additions & 0 deletions src/integration/blockchain/zano/zano-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HttpService>();
client = mock<LightningClient>();
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');
});
});
27 changes: 26 additions & 1 deletion src/integration/lightning/services/lightning.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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]> {
Expand Down
Loading