mirror of
https://github.com/apotdevin/thunderhub.git
synced 2026-08-13 12:33:08 +02:00
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:
parent
42a4501059
commit
6616f2a19c
2 changed files with 575 additions and 28 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue