diff --git a/src/server/modules/api/trade/trade.resolver.spec.ts b/src/server/modules/api/trade/trade.resolver.spec.ts index b4ecff51..fe6057df 100644 --- a/src/server/modules/api/trade/trade.resolver.spec.ts +++ b/src/server/modules/api/trade/trade.resolver.spec.ts @@ -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', () => { diff --git a/src/server/modules/api/trade/trade.resolver.ts b/src/server/modules/api/trade/trade.resolver.ts index 255aab89..792113fe 100644 --- a/src/server/modules/api/trade/trade.resolver.ts +++ b/src/server/modules/api/trade/trade.resolver.ts @@ -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 = + []; + + for (let attempt = 1; attempt <= BUY_ROUTE_MAX_ATTEMPTS; attempt++) { + let baseRoute: + | NonNullable< + Awaited>['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[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.