fix: update buy execution path to not build a route with too little final CLTV headroom (#764)

* chore: update buy execution path

* chore: attempt to find more routes if one fails

---------

Co-authored-by: Anthony Potdevin <31413433+apotdevin@users.noreply.github.com>
This commit is contained in:
Rajat Khanduri 2026-05-29 08:06:00 +05:30 committed by GitHub
parent 42a4501059
commit 6616f2a19c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 575 additions and 28 deletions

View file

@ -11,6 +11,7 @@ jest.mock('../../security/security.types', () => ({}));
import { TradeResolver } from './trade.resolver';
import { BtcChannel, TaChannel } from './trade.types';
import { TapTransactionType } from '../magma/magma.types';
type RouteHop = {
public_key: string;
@ -630,6 +631,286 @@ describe('TradeResolver', () => {
});
});
// ── executeTrade purchase ──
describe('executeTrade purchase', () => {
const paymentRequest = 'lnbc91614390p...';
const paymentHash = 'de'.repeat(32);
const paymentAddress = '02'.repeat(32);
const virtualScid = '16126231x12208172x12187';
const currentHeight = 950_283;
const purchaseInput = {
transaction_type: TapTransactionType.PURCHASE,
peer_pubkey: peerPubkey,
asset_amount: '7000000',
sats_amount: '9161',
payment_request: paymentRequest,
};
beforeEach(() => {
mockNodeService.getHeight.mockResolvedValue({
current_block_height: currentHeight,
});
mockNodeService.decodePaymentRequest.mockResolvedValue({
id: paymentHash,
payment: paymentAddress,
destination: myPubkey,
cltv_delta: 80,
mtokens: '9161439',
tokens: 9161,
safe_tokens: 9162,
routes: [
[
{ public_key: peerPubkey },
{
public_key: myPubkey,
channel: virtualScid,
cltv_delta: 80,
base_fee_mtokens: '1000',
fee_rate: 1,
},
],
],
});
mockNodeService.getChannels.mockResolvedValue({
channels: [
{
id: 'btc-out-1',
type: 'anchor',
capacity: 5_000_000,
local_balance: 50_000,
remote_balance: 1_000_000,
},
],
});
mockNodeService.getRouteToDestination.mockResolvedValue({
route: {
fee: 2,
fee_mtokens: '2000',
hops: [
{
channel: 'btc-out-1',
channel_capacity: 5_000_000,
fee: 2,
fee_mtokens: '2000',
forward: 9164,
forward_mtokens: '9164448',
public_key: 'ef'.repeat(33),
timeout: currentHeight + 169,
},
{
channel: 'btc-mid-peer',
channel_capacity: 5_000_000,
fee: 0,
fee_mtokens: '0',
forward: 9162,
forward_mtokens: '9162448',
public_key: peerPubkey,
timeout: currentHeight + 166,
},
],
mtokens: '9164448',
safe_fee: 2,
safe_tokens: 9165,
timeout: currentHeight + 169,
tokens: 9164,
},
});
mockNodeService.payViaRoutes.mockResolvedValue({
is_confirmed: true,
secret: 'preimage',
safe_tokens: 9165,
fee: 4,
});
});
it('builds an explicit BTC-to-peer plus virtual-SCID route', async () => {
const result = await resolver.executeTrade(
{ id: userId } as never,
purchaseInput
);
expect(result).toEqual({
success: true,
payment_preimage: 'preimage',
sats_amount: '9165',
fee_sats: '4',
});
expect(mockNodeService.pay).not.toHaveBeenCalled();
expect(mockNodeService.getRouteToDestination).toHaveBeenCalledWith(
userId,
{
destination: peerPubkey,
mtokens: '9162448',
outgoing_channel: 'btc-out-1',
cltv_delta: 163,
ignore: undefined,
}
);
expect(mockNodeService.payViaRoutes).toHaveBeenCalledTimes(1);
const submitted = mockNodeService.payViaRoutes.mock.calls[0][1] as {
id: string;
routes: Array<{
payment: string;
total_mtokens: string;
fee: number;
fee_mtokens: string;
mtokens: string;
hops: Array<{
channel: string;
fee: number;
fee_mtokens: string;
forward: number;
forward_mtokens: string;
public_key: string;
timeout: number;
}>;
}>;
};
expect(submitted.id).toBe(paymentHash);
const route = submitted.routes[0];
expect(route.payment).toBe(paymentAddress);
expect(route.total_mtokens).toBe('9161439');
expect(route.mtokens).toBe('9164448');
expect(route.fee_mtokens).toBe('3009');
expect(route.fee).toBe(4);
expect(route.hops).toHaveLength(3);
const peerHop = route.hops[1];
expect(peerHop.public_key).toBe(peerPubkey);
expect(peerHop.fee).toBe(2);
expect(peerHop.fee_mtokens).toBe('1009');
expect(peerHop.forward).toBe(9161);
expect(peerHop.forward_mtokens).toBe('9161439');
expect(peerHop.timeout).toBe(currentHeight + 83);
const taHop = route.hops[2];
expect(taHop.channel).toBe(virtualScid);
expect(taHop.public_key).toBe(myPubkey);
expect(taHop.fee).toBe(0);
expect(taHop.forward_mtokens).toBe('9161439');
expect(taHop.timeout).toBe(currentHeight + 83);
});
it('retries buy route after a temporary BTC channel failure', async () => {
const midPubkey = 'ef'.repeat(33);
mockNodeService.getRouteToDestination
.mockResolvedValueOnce({
route: {
fee: 2,
fee_mtokens: '2000',
hops: [
{
channel: 'btc-out-1',
channel_capacity: 5_000_000,
fee: 2,
fee_mtokens: '2000',
forward: 9164,
forward_mtokens: '9164448',
public_key: midPubkey,
timeout: currentHeight + 169,
},
{
channel: 'btc-mid-peer',
channel_capacity: 5_000_000,
fee: 0,
fee_mtokens: '0',
forward: 9162,
forward_mtokens: '9162448',
public_key: peerPubkey,
timeout: currentHeight + 166,
},
],
mtokens: '9164448',
safe_fee: 2,
safe_tokens: 9165,
timeout: currentHeight + 169,
tokens: 9164,
},
})
.mockResolvedValueOnce({
route: {
fee: 3,
fee_mtokens: '3000',
hops: [
{
channel: 'btc-out-1',
channel_capacity: 5_000_000,
fee: 3,
fee_mtokens: '3000',
forward: 9165,
forward_mtokens: '9165448',
public_key: 'aa'.repeat(33),
timeout: currentHeight + 169,
},
{
channel: 'btc-alt-peer',
channel_capacity: 5_000_000,
fee: 0,
fee_mtokens: '0',
forward: 9162,
forward_mtokens: '9162448',
public_key: peerPubkey,
timeout: currentHeight + 166,
},
],
mtokens: '9165448',
safe_fee: 3,
safe_tokens: 9166,
timeout: currentHeight + 169,
tokens: 9165,
},
});
mockNodeService.payViaRoutes
.mockRejectedValueOnce([
503,
'TemporaryChannelFailure',
{
failures: [
[
503,
'TemporaryChannelFailure',
{ channel: 'btc-mid-peer', index: 1 },
],
],
},
])
.mockResolvedValueOnce({
is_confirmed: true,
secret: 'retry-preimage',
});
const result = await resolver.executeTrade(
{ id: userId } as never,
purchaseInput
);
expect(result.payment_preimage).toBe('retry-preimage');
expect(mockNodeService.payViaRoutes).toHaveBeenCalledTimes(2);
expect(mockNodeService.getRouteToDestination).toHaveBeenNthCalledWith(
2,
userId,
{
destination: peerPubkey,
mtokens: '9162448',
outgoing_channel: 'btc-out-1',
cltv_delta: 163,
ignore: [
{
channel: 'btc-mid-peer',
from_public_key: midPubkey,
to_public_key: peerPubkey,
},
],
}
);
});
});
// ── rebalanceTaChannel ──
describe('rebalanceTaChannel', () => {

View file

@ -6,6 +6,7 @@ import { GraphQLError } from 'graphql';
import type { PayViaRoutesResult, Route } from 'lightning';
import { TapdNodeService } from '../../node/tapd/tapd-node.service';
import { NodeService } from '../../node/node.service';
import type { GetRouteToDestinationOptions } from '../../node/lightning.types';
import { CurrentUser } from '../../security/security.decorators';
import { UserId } from '../../security/security.types';
import { toWithError } from '../../../utils/async';
@ -62,6 +63,8 @@ const DEFAULT_CHANNEL_CLTV_DELTA = 40; // LND default for channel forwarding pol
// reorgs / block-tip drift between when we build the route and when it locks in.
const CLTV_BLOCK_BUFFER = 3;
const BUY_ROUTE_MAX_ATTEMPTS = 5;
// The `lightning` package intentionally leaves SIMPLE_TAPROOT_OVERLAY unmapped,
// so TA channels have type === undefined while all BTC channel types are strings.
const isTaChannel = (ch: { type?: string }) => !ch.type;
@ -258,7 +261,7 @@ export class TradeResolver {
this.nodeService.decodePaymentRequest(accountId, paymentRequest)
);
if (decodeError || decoded?.tokens == null) {
if (decodeError || decoded?.tokens == null || !decoded.mtokens) {
this.logger.error('Failed to decode asset invoice before buy', {
error: decodeError,
});
@ -271,20 +274,65 @@ export class TradeResolver {
);
}
const btcChannels = await this.getBtcChannels(accountId);
const routeHint = this.findVirtualScidHint(
decoded.routes || [],
input.peer_pubkey
);
// Prefer a direct BTC channel with sufficient outbound (one BTC hop + the
// virtual TA hop). When none exists, fall back to multi-hop pathfinding
// through the wider network, which lets us trade with the peer using only
// the asset channel.
const btcChannel = [...btcChannels]
.filter(c => c.local_balance >= decoded.tokens)
.sort((a, b) => b.local_balance - a.local_balance)[0];
if (!btcChannel)
if (!routeHint) {
this.logger.error('No virtual SCID route hint found in invoice', {
routes: JSON.stringify(decoded.routes),
peerPubkey: input.peer_pubkey,
});
throw new GraphQLError(
`No BTC channel with sufficient outbound liquidity for this trade (need ${decoded.tokens} sats)`
'Invoice has no route hint for the trade peer - was it created via addAssetInvoice?'
);
}
const [
btcChannels,
[heightResult, heightError],
[identity, identityError],
] = await Promise.all([
this.getBtcChannels(accountId),
toWithError(this.nodeService.getHeight(accountId)),
toWithError(this.nodeService.getIdentity(accountId)),
]);
if (heightError || !heightResult?.current_block_height) {
throw new GraphQLError('Failed to get current block height');
}
if (identityError || !identity?.public_key) {
throw new GraphQLError('Failed to get node identity');
}
const invoiceCltvDelta: number =
decoded.cltv_delta ?? DEFAULT_CHANNEL_CLTV_DELTA;
const taCltvDelta: number =
routeHint.cltv_delta ?? DEFAULT_CHANNEL_CLTV_DELTA;
const forwardMtokens = BigInt(decoded.mtokens);
const taBaseFeeMtokens = BigInt(routeHint.base_fee_mtokens ?? '0');
const taFeeRate = BigInt(routeHint.fee_rate ?? 0);
const peerFeeMtokens =
taBaseFeeMtokens + (forwardMtokens * taFeeRate) / BigInt(1_000_000);
const peerFee = Number((peerFeeMtokens + BigInt(999)) / BigInt(1000));
const mtokensToPeer = forwardMtokens + peerFeeMtokens;
const tokensToPeer = Number((mtokensToPeer + BigInt(999)) / BigInt(1000));
// Pathfind to the peer over BTC channels only, then append the virtual TA
// hop from the invoice route hint. Generic pay() can satisfy the last-hop
// constraint while still giving the final hop only the invoice-minimum CLTV,
// which is fragile when block tips differ by one block.
const sortedBtcCandidates = [...btcChannels]
.filter(c => c.local_balance >= tokensToPeer)
.sort((a, b) => b.local_balance - a.local_balance);
if (!sortedBtcCandidates.length) {
throw new GraphQLError(
`No BTC channel with sufficient outbound liquidity for this trade (need ${tokensToPeer} sats before routing fees)`
);
}
let payResult:
| {
@ -295,23 +343,186 @@ export class TradeResolver {
}
| undefined;
try {
payResult = await this.nodeService.pay(accountId, {
incoming_peer: input.peer_pubkey,
is_allow_self_payment: true,
request: input.payment_request,
const ignoredPairs: NonNullable<GetRouteToDestinationOptions['ignore']> =
[];
for (let attempt = 1; attempt <= BUY_ROUTE_MAX_ATTEMPTS; attempt++) {
let baseRoute:
| NonNullable<
Awaited<ReturnType<NodeService['getRouteToDestination']>>['route']
>
| undefined;
let routeError: unknown;
let btcChannelId: string | undefined;
for (const candidate of sortedBtcCandidates) {
const [routeResult, error] = await toWithError(
this.nodeService.getRouteToDestination(accountId, {
destination: input.peer_pubkey,
mtokens: String(mtokensToPeer),
outgoing_channel: candidate.id,
cltv_delta: invoiceCltvDelta + taCltvDelta + CLTV_BLOCK_BUFFER,
ignore: ignoredPairs.length ? ignoredPairs : undefined,
})
);
if (error) {
routeError = error;
this.logger.warn('Failed to find BTC route to trade peer', {
error,
peerPubkey: input.peer_pubkey,
outgoingChannel: candidate.id,
ignoredPairs,
});
continue;
}
if (routeResult?.route) {
if (candidate.local_balance < routeResult.route.safe_tokens) {
routeError = new Error('Route exceeds outbound liquidity');
this.logger.warn('BTC route exceeds outbound liquidity', {
peerPubkey: input.peer_pubkey,
outgoingChannel: candidate.id,
localBalance: candidate.local_balance,
routeTokens: routeResult.route.safe_tokens,
});
continue;
}
baseRoute = routeResult.route;
btcChannelId = candidate.id;
break;
}
}
if (!baseRoute) {
this.logger.error('No BTC route to trade peer for buy trade', {
routeError,
peerPubkey: input.peer_pubkey,
mtokensToPeer: String(mtokensToPeer),
ignoredPairs,
});
throw new GraphQLError(
'No BTC route to trade partner with sufficient outbound liquidity'
);
}
const lastBaseHop = baseRoute.hops[baseRoute.hops.length - 1];
if (!lastBaseHop || lastBaseHop.public_key !== input.peer_pubkey) {
this.logger.error('BTC route did not terminate at trade peer', {
peerPubkey: input.peer_pubkey,
route: baseRoute,
});
throw new GraphQLError('Route to trade peer was invalid');
}
const finalHopTimeout =
heightResult.current_block_height +
invoiceCltvDelta +
CLTV_BLOCK_BUFFER;
const peerForwardingHop = {
...lastBaseHop,
fee: peerFee,
fee_mtokens: String(peerFeeMtokens),
forward: decoded.tokens,
forward_mtokens: decoded.mtokens,
timeout: finalHopTimeout,
};
const taHop = {
channel: routeHint.channel,
channel_capacity: Math.max(
tokensToPeer,
decoded.safe_tokens ?? 0,
100_000
),
fee: 0,
fee_mtokens: '0',
forward: decoded.tokens,
forward_mtokens: decoded.mtokens,
public_key: identity.public_key,
timeout: finalHopTimeout,
};
const fullHops = [
...baseRoute.hops.slice(0, -1),
peerForwardingHop,
taHop,
];
const feeMtokens = BigInt(baseRoute.fee_mtokens) + peerFeeMtokens;
const route = {
fee: Number((feeMtokens + BigInt(999)) / BigInt(1000)),
fee_mtokens: String(feeMtokens),
hops: fullHops,
mtokens: baseRoute.mtokens,
payment: decoded.payment,
timeout: baseRoute.timeout,
tokens: baseRoute.tokens,
total_mtokens: decoded.mtokens,
};
this.logger.info('Executing buy trade via explicit route', {
assetAmount: input.asset_amount,
invoicePrefix: paymentRequest.slice(0, 20),
virtualScid: routeHint.channel,
btcChannelId,
invoiceCltvDelta,
taCltvDelta,
finalHopTimeout,
peerFeeMtokens: String(peerFeeMtokens),
mtokensToPeer: String(mtokensToPeer),
hopCount: fullHops.length,
attempt,
});
} catch (err: unknown) {
// payViaRoutes throws [code, message, {failures}] on failure.
// Log the full failure chain for debugging CLTV/routing issues.
const rawErr = err as unknown[];
const failures = Array.isArray(rawErr) ? rawErr[2] : undefined;
this.logger.error('Failed to pay asset invoice via route', {
error: Array.isArray(rawErr) ? rawErr[1] : String(err),
failures: JSON.stringify(failures),
paymentRequest,
});
throw new GraphQLError('Failed to pay asset invoice with sats');
try {
payResult = await this.nodeService.payViaRoutes(accountId, {
id: decoded.id,
routes: [route],
});
break;
} catch (err: unknown) {
// payViaRoutes throws [code, message, {failures}] on failure.
// Log the full failure chain for debugging CLTV/routing issues.
const rawErr = err as unknown[];
const failures = Array.isArray(rawErr) ? rawErr[2] : undefined;
const error = Array.isArray(rawErr) ? rawErr[1] : String(err);
const lastFailure = this.getLastRouteFailureDetails(err);
const ignorePair = lastFailure
? this.ignorePairForBuyRouteFailure(
fullHops,
identity.public_key,
routeHint.channel,
lastFailure
)
: undefined;
if (ignorePair && attempt < BUY_ROUTE_MAX_ATTEMPTS) {
ignoredPairs.push(ignorePair);
this.logger.warn(
'Buy trade route failed; retrying with failed pair ignored',
{
error,
lastFailure,
ignorePair,
attempt,
}
);
continue;
}
this.logger.error('Failed to pay asset invoice via route', {
error,
failures: JSON.stringify(failures),
paymentRequest,
ignoredPairs,
});
throw new GraphQLError(
error === 'TemporaryChannelFailure'
? 'Temporary channel failure while routing sats to trade partner'
: 'Failed to pay asset invoice with sats'
);
}
}
if (!payResult?.is_confirmed) {
@ -1063,6 +1274,61 @@ export class TradeResolver {
return undefined;
}
private getLastRouteFailureDetails(err: unknown):
| {
channel?: string;
index?: number;
public_key?: string;
}
| undefined {
if (!Array.isArray(err)) return undefined;
const details = err[2] as
| {
failures?: Array<
[
number,
string,
{
channel?: string;
index?: number;
public_key?: string;
},
]
>;
}
| undefined;
const failures = details?.failures;
if (!failures?.length) return undefined;
return failures[failures.length - 1][2];
}
private ignorePairForBuyRouteFailure(
hops: Array<{ channel: string; public_key?: string }>,
sourcePubkey: string,
virtualScid: string,
failure: { channel?: string; index?: number }
): NonNullable<GetRouteToDestinationOptions['ignore']>[number] | undefined {
if (failure.index == null || failure.index < 0) return undefined;
const failedHop = hops[failure.index];
if (!failedHop?.public_key || failedHop.channel === virtualScid) {
return undefined;
}
const fromPublicKey =
failure.index === 0 ? sourcePubkey : hops[failure.index - 1]?.public_key;
if (!fromPublicKey) return undefined;
return {
channel: failure.channel || failedHop.channel,
from_public_key: fromPublicKey,
to_public_key: failedHop.public_key,
};
}
/**
* Validates hex-encoded identifiers at the resolver entry points.
* Rejects malformed pubkeys / asset IDs before they reach tapd.