From 7e5fb5cdebc8a902ab66ef71a790d41ffd1a2b10 Mon Sep 17 00:00:00 2001 From: Bufo <32884105+bufo24@users.noreply.github.com> Date: Sun, 3 May 2026 02:24:23 +0200 Subject: [PATCH] feat: multi-hop swaps with circular rebalancing (#749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: multihop swaps * fix: fee limit * fix: circular rebalancing * chore: simplify and fix post-review issues - magma: guard allChannels against undefined (??= []) - magma: restore peer filter on btcOpen and btcPending — both were accidentally widened to all channels by the multi-hop refactor - TradingPartners: remove btcChannelPubkeys dead code (filter that used it was removed; useMemo and stale dep array entry remain) - trade: move fullHops array from info to debug log to avoid large object serialisation on every rebalance - trade: fix misleading 'No bitcoin channels found' error when the real condition is insufficient outbound liquidity - test: add debug to mock logger --------- Co-authored-by: Bufo Co-authored-by: Anthony Potdevin <31413433+apotdevin@users.noreply.github.com> --- .../src/views/assets/TradingPartners.tsx | 38 +- .../modules/api/magma/magma.resolver.ts | 16 +- .../modules/api/trade/trade.resolver.spec.ts | 639 +++++++++--------- .../modules/api/trade/trade.resolver.ts | 430 +++++------- src/server/modules/node/lightning.types.ts | 56 ++ src/server/modules/node/litd/litd.service.ts | 11 + src/server/modules/node/lnd/lnd.service.ts | 10 + src/server/modules/node/node.service.ts | 9 + .../modules/node/tapd/tapd-node.service.ts | 4 +- 9 files changed, 582 insertions(+), 631 deletions(-) diff --git a/src/client/src/views/assets/TradingPartners.tsx b/src/client/src/views/assets/TradingPartners.tsx index fb4cddc4..8c3f687c 100644 --- a/src/client/src/views/assets/TradingPartners.tsx +++ b/src/client/src/views/assets/TradingPartners.tsx @@ -126,30 +126,6 @@ export const TradingPartners: FC = () => { }; }, [allAssetChannelsData]); - const btcChannelPubkeys = useMemo(() => { - const assetChannelCountByPubkey = new Map(); - for (const ac of allAssetChannelsData?.taproot_assets - ?.get_asset_channel_balances || []) { - assetChannelCountByPubkey.set( - ac.partner_public_key, - (assetChannelCountByPubkey.get(ac.partner_public_key) || 0) + 1 - ); - } - const totalChannelCountByPubkey = new Map(); - for (const ch of allChannelsData?.getChannels || []) { - totalChannelCountByPubkey.set( - ch.partner_public_key, - (totalChannelCountByPubkey.get(ch.partner_public_key) || 0) + 1 - ); - } - const pubkeys = new Set(); - for (const [pubkey, total] of totalChannelCountByPubkey) { - const assetCount = assetChannelCountByPubkey.get(pubkey) || 0; - if (total > assetCount) pubkeys.add(pubkey); - } - return pubkeys; - }, [allChannelsData, allAssetChannelsData]); - const aliasMap = useMemo(() => { const map = new Map(); for (const ch of allChannelsData?.getChannels || []) { @@ -237,14 +213,12 @@ export const TradingPartners: FC = () => { const tradingPartners = useMemo(() => { if (!assetPeersForSelectedAsset) return []; - return Array.from(assetPeersForSelectedAsset) - .filter(pubkey => btcChannelPubkeys.has(pubkey)) - .map(pubkey => ({ - pubkey, - alias: aliasMap.get(pubkey) || null, - assets: peerAssets.get(pubkey) || [], - })); - }, [assetPeersForSelectedAsset, btcChannelPubkeys, aliasMap, peerAssets]); + return Array.from(assetPeersForSelectedAsset).map(pubkey => ({ + pubkey, + alias: aliasMap.get(pubkey) || null, + assets: peerAssets.get(pubkey) || [], + })); + }, [assetPeersForSelectedAsset, aliasMap, peerAssets]); const selectPartner = ( pubkey: string, diff --git a/src/server/modules/api/magma/magma.resolver.ts b/src/server/modules/api/magma/magma.resolver.ts index d6432189..4f001a13 100644 --- a/src/server/modules/api/magma/magma.resolver.ts +++ b/src/server/modules/api/magma/magma.resolver.ts @@ -979,7 +979,7 @@ export class RailsQueriesResolver { const [ peersResult, - peerChannelsResult, + channelsResult, pendingResult, assetBalancesResult, ordersResult, @@ -987,11 +987,7 @@ export class RailsQueriesResolver { onchainAssetResult, ] = await Promise.all([ toWithError(this.nodeService.getPeers(id)), - toWithError( - this.nodeService.getChannels(id, { - partner_public_key: input.peer_pubkey, - }) - ), + toWithError(this.nodeService.getChannels(id)), toWithError(this.nodeService.getPendingChannels(id)), input.tapd_asset_id || input.tapd_group_key ? toWithError( @@ -1011,8 +1007,9 @@ export class RailsQueriesResolver { ), ]); + const allChannels = channelsResult[0]?.channels ?? []; + const peers = peersResult[0]?.peers || []; - const peerChannels = peerChannelsResult[0]?.channels || []; const pendingChannels = pendingResult[0]?.pending_channels || []; const assetBalances = assetBalancesResult[0] || []; const hasPendingOrder = ordersResult; @@ -1046,7 +1043,10 @@ export class RailsQueriesResolver { (ch: { partner_public_key: string; is_opening: boolean }) => ch.partner_public_key === input.peer_pubkey && ch.is_opening ); - const btcOpen = peerChannels.filter(isBtcChannel); + const btcOpen = allChannels.filter( + (ch: { type?: string; partner_public_key?: string }) => + isBtcChannel(ch) && ch.partner_public_key === input.peer_pubkey + ); const btcPending = peerPending.filter( (ch: { asset?: unknown }) => !ch.asset ); diff --git a/src/server/modules/api/trade/trade.resolver.spec.ts b/src/server/modules/api/trade/trade.resolver.spec.ts index 58e44111..b4ecff51 100644 --- a/src/server/modules/api/trade/trade.resolver.spec.ts +++ b/src/server/modules/api/trade/trade.resolver.spec.ts @@ -25,10 +25,11 @@ interface PrivateMethods { peerPubkey: string, btcChannels: BtcChannel[] ): Promise; - getBtcChannelsWithPeer(id: string, peerPubkey: string): Promise; + getBtcChannels(id: string): Promise; findVirtualScidHint( routes: RouteHop[][], - peerPubkey: string + peerPubkey: string, + expectedChannel?: string ): { channel: string; cltv_delta?: number } | undefined; deriveSatsFromRate( assetAmount: string, @@ -54,11 +55,7 @@ interface PrivateMethods { peerPubkey: string, taChannelScid: string, taChannelPartnerScidAlias: string | undefined, - taChannelCapacity: number, - btcChannel: BtcChannel, - rebalanceSats: number, - currentHeight: number, - identityPubkey: string + rebalanceSats: number ): Promise; } @@ -75,11 +72,18 @@ describe('TradeResolver', () => { createInvoice: jest.fn(), decodePaymentRequest: jest.fn(), payViaRoutes: jest.fn(), + pay: jest.fn(), + getRouteToDestination: jest.fn(), }; const mockTapdNodeService = { getAssetChannelBalances: jest.fn(), }; - const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn() }; + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; let resolver: TradeResolver; let priv: PrivateMethods; @@ -104,11 +108,16 @@ describe('TradeResolver', () => { // with the default channel cltv_delta and a typical forwarding fee. // Tests that need different cltv/fees override this per-test. mockNodeService.decodePaymentRequest.mockResolvedValue({ + cltv_delta: 40, + mtokens: '1200000', + // The route hint channel is the peer's SCID alias for the TA channel + // (what LND embeds in invoices for private channels), not our canonical + // SCID. routes: [ [ { public_key: peerPubkey, - channel: '800000x1x0', + channel: '16000000x0x1', cltv_delta: 40, base_fee_mtokens: '1000', fee_rate: 2500, @@ -117,6 +126,12 @@ describe('TradeResolver', () => { ], }); mockNodeService.payViaRoutes.mockResolvedValue({ is_confirmed: true }); + // Default: optimistic pay() fails so multi-hop tests exercise the explicit + // pathfinding fallback. Tests for the optimistic-success path override this. + mockNodeService.pay.mockRejectedValue(new Error('no route via pay()')); + mockNodeService.getRouteToDestination.mockResolvedValue({ + route: undefined, + }); mockTapdNodeService.getAssetChannelBalances.mockResolvedValue([]); resolver = new TradeResolver( mockTapdNodeService as never, @@ -269,9 +284,9 @@ describe('TradeResolver', () => { }); }); - // ── getBtcChannelsWithPeer ── + // ── getBtcChannels ── - describe('getBtcChannelsWithPeer', () => { + describe('getBtcChannels', () => { it('filters out TA channels (type undefined)', async () => { mockNodeService.getChannels.mockResolvedValue({ channels: [ @@ -292,7 +307,7 @@ describe('TradeResolver', () => { ], }); - const result = await priv.getBtcChannelsWithPeer(userId, peerPubkey); + const result = await priv.getBtcChannels(userId); expect(result).toHaveLength(1); expect(result[0].id).toBe('btc-1'); @@ -301,19 +316,19 @@ describe('TradeResolver', () => { it('returns empty array and logs warning on getChannels error', async () => { mockNodeService.getChannels.mockRejectedValue(new Error('rpc down')); - const result = await priv.getBtcChannelsWithPeer(userId, peerPubkey); + const result = await priv.getBtcChannels(userId); expect(result).toEqual([]); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Failed to fetch channels with peer', - expect.objectContaining({ peerPubkey }) + 'Failed to fetch channels', + expect.any(Object) ); }); - it('returns empty array when peer has no channels', async () => { + it('returns empty array when there are no channels', async () => { mockNodeService.getChannels.mockResolvedValue({ channels: [] }); - const result = await priv.getBtcChannelsWithPeer(userId, peerPubkey); + const result = await priv.getBtcChannels(userId); expect(result).toEqual([]); }); @@ -577,306 +592,6 @@ describe('TradeResolver', () => { }); }); - // ── rebalanceTaChannel ── - - describe('rebalanceTaChannel', () => { - const taChannelScid = '800000x1x0'; - const taChannelCapacity = 1_000_000; - const btcChannel: BtcChannel = { - id: 'btc-1', - capacity: 2_000_000, - local_balance: 500_000, - remote_balance: 300_000, - }; - const rebalanceSats = 1_200; - const currentHeight = 800_000; - - it('builds correct 2-hop route and calls payViaRoutes', async () => { - await priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - undefined, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ); - - expect(mockNodeService.payViaRoutes).toHaveBeenCalledTimes(1); - const call = mockNodeService.payViaRoutes.mock.calls[0]; - const routes: Array<{ - hops: Array<{ - channel: string; - public_key: string; - fee: number; - timeout: number; - }>; - timeout: number; - }> = call[1].routes; - - expect(routes).toHaveLength(1); - const [hop1, hop2] = routes[0].hops; - expect(hop1.channel).toBe('btc-1'); - expect(hop1.public_key).toBe(peerPubkey); - expect(hop1.fee).toBeGreaterThan(0); - expect(hop2.channel).toBe(taChannelScid); - expect(hop2.public_key).toBe(myPubkey); - expect(hop2.fee).toBe(0); - expect(hop1.timeout).toBe(hop2.timeout); - expect(routes[0].timeout).toBeGreaterThan(hop1.timeout); - }); - - it('uses cltv_delta fallback of 40 when route hint has no cltv_delta', async () => { - mockNodeService.decodePaymentRequest.mockResolvedValue({ - routes: [ - [ - { - public_key: peerPubkey, - channel: taChannelScid, - base_fee_mtokens: '0', - fee_rate: 0, - }, - ], - ], - }); - - await priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - undefined, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ); - - const routes = mockNodeService.payViaRoutes.mock.calls[0][1] - .routes as Array<{ - hops: Array<{ timeout: number }>; - timeout: number; - }>; - const hop2Timeout = routes[0].hops[0].timeout; - // hop2Timeout = 800_000 + 24 (DEFAULT_INVOICE_CLTV_DELTA) + 3 = 800_027 - expect(hop2Timeout).toBe(800_027); - // routes[0].timeout = hop2Timeout + 40 (DEFAULT_CHANNEL_CLTV_DELTA) = 800_067 - expect(routes[0].timeout).toBe(800_067); - }); - - it('uses cltv_delta from the TA channel route hint', async () => { - mockNodeService.decodePaymentRequest.mockResolvedValue({ - routes: [ - [ - { - public_key: peerPubkey, - channel: taChannelScid, - cltv_delta: 144, - base_fee_mtokens: '0', - fee_rate: 0, - }, - ], - ], - }); - - await priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - undefined, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ); - - const routes = mockNodeService.payViaRoutes.mock.calls[0][1] - .routes as Array<{ - hops: Array<{ timeout: number }>; - timeout: number; - }>; - const hop2Timeout = routes[0].hops[0].timeout; - expect(hop2Timeout).toBe(800_027); - expect(routes[0].timeout).toBe(800_027 + 144); - }); - - it('throws when no TA route hint is found in the invoice', async () => { - mockNodeService.decodePaymentRequest.mockResolvedValue({ routes: [] }); - - await expect( - priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - undefined, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ) - ).rejects.toThrow('no TA channel route hint'); - }); - - it('selects the route hint matching taChannelPartnerScidAlias', async () => { - const alias = '16000000x0x1'; - mockNodeService.decodePaymentRequest.mockResolvedValue({ - routes: [ - [ - { - public_key: peerPubkey, - channel: 'btc-private-1', - cltv_delta: 80, - base_fee_mtokens: '0', - fee_rate: 0, - }, - ], - [ - { - public_key: peerPubkey, - channel: alias, - cltv_delta: 144, - base_fee_mtokens: '0', - fee_rate: 0, - }, - ], - ], - }); - - await priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - alias, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ); - - const routes = mockNodeService.payViaRoutes.mock.calls[0][1] - .routes as Array<{ - hops: Array<{ channel: string; timeout: number }>; - timeout: number; - }>; - const [, hop2] = routes[0].hops; - expect(hop2.channel).toBe(alias); - expect(routes[0].timeout).toBe(800_027 + 144); - }); - - it('throws when createInvoice fails', async () => { - mockNodeService.createInvoice.mockRejectedValue(new Error('rpc down')); - - await expect( - priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - undefined, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ) - ).rejects.toThrow('could not create self-payment invoice'); - }); - - it('throws when payViaRoutes throws', async () => { - mockNodeService.payViaRoutes.mockImplementation(() => { - throw ['503', 'FAILURE_REASON_INSUFFICIENT_BALANCE', { failures: [] }]; - }); - - await expect( - priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - undefined, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ) - ).rejects.toThrow('Circular rebalance payment failed'); - }); - - it('throws when payViaRoutes resolves is_confirmed: false', async () => { - mockNodeService.payViaRoutes.mockResolvedValue({ is_confirmed: false }); - - await expect( - priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - undefined, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ) - ).rejects.toThrow('did not confirm'); - }); - - it('resolves without error on success', async () => { - await expect( - priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - undefined, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ) - ).resolves.toBeUndefined(); - }); - - it('computes zero fees and correct tokens when route hint has zero fees', async () => { - mockNodeService.decodePaymentRequest.mockResolvedValue({ - routes: [ - [ - { - public_key: peerPubkey, - channel: taChannelScid, - cltv_delta: 40, - base_fee_mtokens: '0', - fee_rate: 0, - }, - ], - ], - }); - - await priv.rebalanceTaChannel( - userId, - peerPubkey, - taChannelScid, - undefined, - taChannelCapacity, - btcChannel, - rebalanceSats, - currentHeight, - myPubkey - ); - - const routes = mockNodeService.payViaRoutes.mock.calls[0][1] - .routes as Array<{ - fee: number; - tokens: number; - }>; - expect(routes[0].fee).toBe(0); - expect(routes[0].tokens).toBe(rebalanceSats); - }); - }); - // ── deriveSatsFromRate ── describe('deriveSatsFromRate', () => { @@ -914,4 +629,294 @@ describe('TradeResolver', () => { expect(sats).toBeGreaterThanOrEqual(10_000); }); }); + + // ── rebalanceTaChannel ── + + describe('rebalanceTaChannel', () => { + const taChannelScid = '800000x1x0'; + const taChannelPartnerScidAlias = '16000000x0x1'; + const rebalanceSats = 1_200; + const currentHeight = 800_000; + + // A synthetic 2-hop route from us → middle → peer that + // getRouteToDestination returns. The peer is the last hop; my code rewrites + // its fee and appends the TA hop. + const baseRoute = { + route: { + fee: 4, + fee_mtokens: '4000', + hops: [ + { + channel: 'btc-out-1', + channel_capacity: 5_000_000, + fee: 2, + fee_mtokens: '2000', + forward: 1_202, + forward_mtokens: '1202000', + public_key: 'mid'.repeat(22), + timeout: 800_073, + }, + { + channel: 'btc-mid-peer', + channel_capacity: 5_000_000, + fee: 2, + fee_mtokens: '2000', + forward: 1_200, + forward_mtokens: '1200000', + public_key: peerPubkey, + timeout: 800_067, + }, + ], + mtokens: '1204000', + safe_fee: 4, + safe_tokens: 1_204, + timeout: 800_073, + tokens: 1_204, + }, + }; + + it('builds a multi-hop circular route and submits via payViaRoutes', async () => { + mockNodeService.getRouteToDestination.mockResolvedValue(baseRoute); + + await priv.rebalanceTaChannel( + userId, + peerPubkey, + taChannelScid, + taChannelPartnerScidAlias, + rebalanceSats + ); + + // Pathfinding request includes peer's TA fee in tokens, plus headroom + // for the TA hop's CLTV delta. + const pathArgs = mockNodeService.getRouteToDestination.mock.calls[0][1]; + expect(pathArgs.destination).toBe(peerPubkey); + // forwardMtokens = 1_200_000; peerFee mtokens = 1000 + (1_200_000 * + // 2500 / 1_000_000) = 4000 → ceil to 4 sats. tokens = 1200 + 4 = 1204. + expect(pathArgs.tokens).toBe(1_204); + // invoiceCltvDelta(40) + taCltvDelta(40) + CLTV_BLOCK_BUFFER(3) = 83 + expect(pathArgs.cltv_delta).toBe(83); + + 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; + tokens: number; + mtokens: string; + hops: Array<{ + channel: string; + public_key: string; + fee: number; + fee_mtokens: string; + forward: number; + forward_mtokens: string; + timeout: number; + }>; + }>; + }; + + const route = submitted.routes[0]; + expect(submitted.id).toBe('aa'.repeat(32)); + expect(route.payment).toBe('bb'.repeat(32)); // invoice.payment + expect(route.total_mtokens).toBe('1200000'); // decoded.mtokens + expect(route.tokens).toBe(1_204); // baseRoute.tokens + expect(route.mtokens).toBe('1204000'); // baseRoute.mtokens + + // 2 LND hops + 1 appended TA hop + expect(route.hops).toHaveLength(3); + + // Peer hop: previously the destination (fee=0), now an intermediate + // forwarder that takes peerFee for forwarding over the TA channel. + // forward is overridden to rebalanceSats — the amount peer forwards to + // us, NOT what they receive — so their earned fee = incoming - outgoing. + // timeout is overridden to finalHopTimeout: hop.timeout encodes the + // OUTGOING locktime, and the peer's outgoing locktime must equal the + // TA hop's incoming locktime, otherwise the peer rejects the HTLC with + // IncorrectCltvExpiry. + const peerHop = route.hops[1]; + expect(peerHop.public_key).toBe(peerPubkey); + expect(peerHop.fee).toBe(4); + expect(peerHop.fee_mtokens).toBe('4000'); + expect(peerHop.forward).toBe(rebalanceSats); + expect(peerHop.forward_mtokens).toBe('1200000'); + expect(peerHop.timeout).toBe(currentHeight + 40 + 3); + + // Appended TA hop: peer → us via the TA channel. Channel is the + // peer's SCID alias (what they recognize), fee=0, timeout uses block + // height + invoice CLTV. + const taHop = route.hops[2]; + expect(taHop.channel).toBe(taChannelPartnerScidAlias); + expect(taHop.public_key).toBe(myPubkey); + expect(taHop.fee).toBe(0); + expect(taHop.fee_mtokens).toBe('0'); + expect(taHop.forward).toBe(rebalanceSats); + expect(taHop.forward_mtokens).toBe('1200000'); + // currentHeight + invoiceCltvDelta(40) + CLTV_BLOCK_BUFFER(3) + expect(taHop.timeout).toBe(currentHeight + 40 + 3); + + // Route fee total grows by peerFeeMtokens; mtokens (amount entering + // route) is unchanged. + expect(route.fee_mtokens).toBe('8000'); // 4000 (base) + 4000 (peer) + expect(route.fee).toBe(8); + }); + + it('throws when TA channel has no partner SCID alias', async () => { + await expect( + priv.rebalanceTaChannel( + userId, + peerPubkey, + taChannelScid, + undefined, + rebalanceSats + ) + ).rejects.toThrow('TA channel has no partner SCID alias'); + }); + + it('throws when no TA route hint is found in the invoice', async () => { + mockNodeService.decodePaymentRequest.mockResolvedValue({ + cltv_delta: 40, + mtokens: '1200000', + routes: [], + }); + + await expect( + priv.rebalanceTaChannel( + userId, + peerPubkey, + taChannelScid, + taChannelPartnerScidAlias, + rebalanceSats + ) + ).rejects.toThrow('no TA channel route hint'); + }); + + it('throws when pathfinding returns no route to peer', async () => { + mockNodeService.getRouteToDestination.mockResolvedValue({ + route: undefined, + }); + + await expect( + priv.rebalanceTaChannel( + userId, + peerPubkey, + taChannelScid, + taChannelPartnerScidAlias, + rebalanceSats + ) + ).rejects.toThrow('no route to peer'); + }); + + it('throws when createInvoice fails', async () => { + mockNodeService.createInvoice.mockRejectedValue(new Error('rpc down')); + + await expect( + priv.rebalanceTaChannel( + userId, + peerPubkey, + taChannelScid, + taChannelPartnerScidAlias, + rebalanceSats + ) + ).rejects.toThrow('could not create self-payment invoice'); + }); + + it('throws when payViaRoutes throws', async () => { + mockNodeService.getRouteToDestination.mockResolvedValue(baseRoute); + mockNodeService.payViaRoutes.mockImplementation(() => { + throw ['503', 'FAILURE_REASON_INSUFFICIENT_BALANCE', { failures: [] }]; + }); + + await expect( + priv.rebalanceTaChannel( + userId, + peerPubkey, + taChannelScid, + taChannelPartnerScidAlias, + rebalanceSats + ) + ).rejects.toThrow('Circular rebalance payment failed'); + }); + + it('throws when payViaRoutes resolves is_confirmed: false', async () => { + mockNodeService.getRouteToDestination.mockResolvedValue(baseRoute); + mockNodeService.payViaRoutes.mockResolvedValue({ is_confirmed: false }); + + await expect( + priv.rebalanceTaChannel( + userId, + peerPubkey, + taChannelScid, + taChannelPartnerScidAlias, + rebalanceSats + ) + ).rejects.toThrow('did not confirm'); + }); + + it('uses partner SCID alias to disambiguate route hints', async () => { + const alias = '16000000x0x1'; + mockNodeService.decodePaymentRequest.mockResolvedValue({ + cltv_delta: 40, + mtokens: '1200000', + routes: [ + [ + { + public_key: peerPubkey, + channel: 'btc-private-1', + cltv_delta: 80, + base_fee_mtokens: '0', + fee_rate: 0, + }, + ], + [ + { + public_key: peerPubkey, + channel: alias, + cltv_delta: 144, + base_fee_mtokens: '0', + fee_rate: 0, + }, + ], + ], + }); + mockNodeService.getRouteToDestination.mockResolvedValue(baseRoute); + + await priv.rebalanceTaChannel( + userId, + peerPubkey, + taChannelScid, + alias, + rebalanceSats + ); + + // Pathfinding requested with the alias-matched hint's cltv_delta(144), + // not the other hint's cltv_delta(80). + const pathArgs = mockNodeService.getRouteToDestination.mock.calls[0][1]; + expect(pathArgs.cltv_delta).toBe(40 + 144 + 3); + + // The appended TA hop uses the partner SCID alias (the peer's local + // identifier for the channel). + const submitted = mockNodeService.payViaRoutes.mock.calls[0][1] as { + routes: Array<{ hops: Array<{ channel: string }> }>; + }; + const taHop = submitted.routes[0].hops[2]; + expect(taHop.channel).toBe(alias); + }); + + it('resolves without error on success', async () => { + mockNodeService.getRouteToDestination.mockResolvedValue(baseRoute); + + await expect( + priv.rebalanceTaChannel( + userId, + peerPubkey, + taChannelScid, + taChannelPartnerScidAlias, + rebalanceSats + ) + ).resolves.toBeUndefined(); + }); + }); }); diff --git a/src/server/modules/api/trade/trade.resolver.ts b/src/server/modules/api/trade/trade.resolver.ts index 01352e9e..255aab89 100644 --- a/src/server/modules/api/trade/trade.resolver.ts +++ b/src/server/modules/api/trade/trade.resolver.ts @@ -57,14 +57,9 @@ function buildTradeMemo( const SATS_RESERVE_BUFFER_PCT = 50; const DEFAULT_CHANNEL_CLTV_DELTA = 40; // LND default for channel forwarding policies -// Deliberately small: this is a self-payment, settled within seconds. Must be -// ≥ FinalCltvRejectDelta (19) so LND accepts the HTLC at the final hop. Must -// match the cltv_delta passed to createInvoice so the route satisfies the -// invoice's min_final_cltv_expiry. -const DEFAULT_INVOICE_CLTV_DELTA = 24; -// Extra blocks added to final CLTV to tolerate a block arriving between -// getHeight and HTLC settlement. +// Extra block headroom on top of computed CLTV deltas to absorb slight +// reorgs / block-tip drift between when we build the route and when it locks in. const CLTV_BLOCK_BUFFER = 3; // The `lightning` package intentionally leaves SIMPLE_TAPROOT_OVERLAY unmapped, @@ -276,149 +271,20 @@ export class TradeResolver { ); } - this.logger.debug('Decoded invoice route hints', { - routes: JSON.stringify(decoded.routes), - peerPubkey: input.peer_pubkey, - cltvDelta: decoded.cltv_delta, - paymentHash: decoded.id, - }); + 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 (!routeHint) { - this.logger.error('No virtual SCID route hint found in invoice', { - routes: JSON.stringify(decoded.routes), - peerPubkey: input.peer_pubkey, - }); + if (!btcChannel) throw new GraphQLError( - 'Invoice has no route hint for the trade peer — was it created via addAssetInvoice?' + `No BTC channel with sufficient outbound liquidity for this trade (need ${decoded.tokens} sats)` ); - } - - this.logger.debug('Found virtual SCID route hint', { - virtualScid: routeHint.channel, - cltvDelta: routeHint.cltv_delta, - }); - - const btcChannels = await this.getBtcChannelsWithPeer( - accountId, - input.peer_pubkey - ); - - if (btcChannels.length === 0) { - throw new GraphQLError( - 'No active BTC channel with trade partner — cannot execute trade' - ); - } - - const btcChannel = [...btcChannels].sort( - (a, b) => b.local_balance - a.local_balance - )[0]; - - if (btcChannel.local_balance < decoded.tokens) { - throw new GraphQLError( - `Insufficient outbound BTC liquidity with trade partner: ` + - `need ${decoded.tokens} sats, have ${btcChannel.local_balance} sats` - ); - } - - const [ - [heightResult, heightError], - [identity, identityError], - [channelInfo, channelInfoError], - ] = await Promise.all([ - toWithError(this.nodeService.getHeight(accountId)), - toWithError(this.nodeService.getIdentity(accountId)), - toWithError(this.nodeService.getChannel(accountId, btcChannel.id)), - ]); - - if (channelInfoError) { - this.logger.warn( - 'Could not fetch channel info for fee estimation; using defaults', - { error: channelInfoError, channelId: btcChannel.id } - ); - } - - 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 peerPolicy = channelInfo?.policies?.find( - (p: { public_key: string }) => p.public_key === input.peer_pubkey - ); - - const currentHeight: number = heightResult.current_block_height; - const invoiceCltvDelta = decoded.cltv_delta ?? DEFAULT_INVOICE_CLTV_DELTA; - const hintCltvDelta = routeHint.cltv_delta ?? 144; - const btcChannelCltvDelta: number = - peerPolicy?.cltv_delta ?? DEFAULT_CHANNEL_CLTV_DELTA; - - // Use the larger of the virtual SCID's cltv_delta and the BTC channel's - // cltv_delta for the hop delta — the peer may enforce its BTC channel - // policy on forwards. - const hop2Timeout = currentHeight + invoiceCltvDelta + CLTV_BLOCK_BUFFER; - const hopCltvDelta = Math.max(hintCltvDelta, btcChannelCltvDelta); - const hop1Timeout = hop2Timeout + hopCltvDelta; - - const forwardMtokens = BigInt(decoded.mtokens); - const baseFee = BigInt(peerPolicy?.base_fee_mtokens ?? '1000'); - const feeRate = BigInt(peerPolicy?.fee_rate ?? 2500); - const hop1FeeMtokens = - baseFee + (forwardMtokens * feeRate) / BigInt(1_000_000); - const hop1Fee = Number((hop1FeeMtokens + BigInt(999)) / BigInt(1000)); - - const totalMtokens = forwardMtokens + hop1FeeMtokens; - - const route = { - fee: hop1Fee, - fee_mtokens: String(hop1FeeMtokens), - hops: [ - { - channel: btcChannel.id, - channel_capacity: btcChannel.capacity, - fee: hop1Fee, - fee_mtokens: String(hop1FeeMtokens), - forward: decoded.tokens, - forward_mtokens: decoded.mtokens, - public_key: input.peer_pubkey, - timeout: hop2Timeout, - }, - { - channel: routeHint.channel, - channel_capacity: btcChannel.capacity, - fee: 0, - fee_mtokens: '0', - forward: decoded.tokens, - forward_mtokens: decoded.mtokens, - public_key: identity.public_key, - timeout: hop2Timeout, - }, - ], - mtokens: String(totalMtokens), - payment: decoded.payment, - timeout: hop1Timeout, - tokens: Number((totalMtokens + BigInt(999)) / BigInt(1000)), - 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: btcChannel.id, - currentHeight, - hintCltvDelta, - btcChannelCltvDelta, - hopCltvDelta, - route, - }); let payResult: | { @@ -430,9 +296,10 @@ export class TradeResolver { | undefined; try { - payResult = await this.nodeService.payViaRoutes(accountId, { - id: decoded.id, - routes: [route], + payResult = await this.nodeService.pay(accountId, { + incoming_peer: input.peer_pubkey, + is_allow_self_payment: true, + request: input.payment_request, }); } catch (err: unknown) { // payViaRoutes throws [code, message, {failures}] on failure. @@ -506,26 +373,11 @@ export class TradeResolver { throw new GraphQLError('Derived sats amount is zero or negative'); } - // Fetch BTC channels once for both the return-hint and the liquidity check. - const btcChannels = await this.getBtcChannelsWithPeer( - accountId, - input.peer_pubkey - ); - if (btcChannels.length === 0) { - throw new GraphQLError( - 'No active BTC channel with trade partner — cannot execute trade' - ); - } - - const maxRemote = btcChannels.reduce( - (max, ch) => (ch.remote_balance > max ? ch.remote_balance : max), - 0 - ); - if (maxRemote < invoiceSats) { - throw new GraphQLError( - `Insufficient inbound BTC liquidity with trade partner: need ${invoiceSats} sats, have ${maxRemote} sats` - ); - } + // Fetch BTC channels once for both the return-hint and the rebalance. + // We don't hard-fail when there's no direct BTC channel or when inbound is + // short: the return leg can route through the wider network. Let the + // underlying sendAssetPayment surface its own failure if no path exists. + const btcChannels = await this.getBtcChannels(accountId); await this.ensureTaChannelSatReserve( accountId, @@ -728,10 +580,6 @@ export class TradeResolver { return; } - // Track consumed BTC balance across iterations so we don't overspend - // a channel that was already partially drained by a prior rebalance. - const btcBalanceConsumed = new Map(); - for (const taChannel of taChannels) { // Buffer only the reserve portion — invoiceSats is a fixed cost. const bufferedReserve = Math.ceil( @@ -752,30 +600,6 @@ export class TradeResolver { const rebalanceSats = minRequiredBalance - taChannel.localBalance; - // Pick the BTC channel with the most remaining local balance. - const btcChannel = [...btcChannels].sort((a, b) => { - const aRemaining = - a.local_balance - (btcBalanceConsumed.get(a.id) ?? 0); - const bRemaining = - b.local_balance - (btcBalanceConsumed.get(b.id) ?? 0); - return bRemaining - aRemaining; - })[0]; - - const consumed = btcBalanceConsumed.get(btcChannel.id) ?? 0; - const availableBalance = btcChannel.local_balance - consumed; - - if (availableBalance < rebalanceSats) { - this.logger.warn( - 'Insufficient remaining BTC balance for rebalance; skipping channel', - { - taChannelScid: taChannel.scid, - rebalanceSats, - availableBalance, - } - ); - continue; - } - this.logger.info( 'TA channel below reserve; initiating circular rebalance', { @@ -793,15 +617,7 @@ export class TradeResolver { peerPubkey, taChannel.scid, taChannel.partnerScidAlias, - taChannel.capacity, - btcChannel, - rebalanceSats, - heightResult.current_block_height, - identity.public_key - ); - btcBalanceConsumed.set( - btcChannel.id, - (btcBalanceConsumed.get(btcChannel.id) ?? 0) + rebalanceSats + rebalanceSats ); } catch (err: unknown) { this.logger.warn( @@ -941,12 +757,16 @@ export class TradeResolver { peerPubkey: string, taChannelScid: string, taChannelPartnerScidAlias: string | undefined, - taChannelCapacity: number, - btcChannel: BtcChannel, - rebalanceSats: number, - currentHeight: number, - identityPubkey: string + rebalanceSats: number ): Promise { + // The appended TA hop must use the peer's SCID alias as the channel ID: + // canonical SCIDs and our local aliases are not in the peer's outgoing + // forwarding table for private TA channels, so they reject the HTLC with + // UnknownNextPeer. + if (!taChannelPartnerScidAlias) { + throw new Error('Rebalance failed: TA channel has no partner SCID alias'); + } + // Create the self-payment invoice with private-channel route hints so // LND embeds the TA channel's SCID alias and the peer's gossiped policy // on it (cltv_delta, fees). The alias is a tapd virtual SCID — looking @@ -955,12 +775,15 @@ export class TradeResolver { const [invoice, invoiceError] = await toWithError( this.nodeService.createInvoice(accountId, { tokens: rebalanceSats, - cltv_delta: DEFAULT_INVOICE_CLTV_DELTA, is_including_private_channels: true, }) ); if (invoiceError || !invoice?.request || !invoice.id || !invoice.payment) { + this.logger.warn(`Failed to make rebalance invoice`, { + invoiceError, + invoice, + }); throw new Error( 'Rebalance failed: could not create self-payment invoice' ); @@ -975,6 +798,9 @@ export class TradeResolver { ); } + // Peer's policy on the TA channel — embedded by LND in the invoice's + // private-channel hints. Tells us the cltv_delta and fees the peer charges + // for forwarding on their outgoing TA channel leg. const taRouteHint = this.findVirtualScidHint( decoded.routes, peerPubkey, @@ -988,76 +814,141 @@ export class TradeResolver { const taCltvDelta: number = taRouteHint.cltv_delta ?? DEFAULT_CHANNEL_CLTV_DELTA; - const taBaseFee = BigInt(taRouteHint.base_fee_mtokens ?? '0'); + const taBaseFeeMtokens = BigInt(taRouteHint.base_fee_mtokens ?? '0'); const taFeeRate = BigInt(taRouteHint.fee_rate ?? 0); - const invoiceCltvDelta = DEFAULT_INVOICE_CLTV_DELTA; - const hop2Timeout = currentHeight + invoiceCltvDelta + CLTV_BLOCK_BUFFER; - const hopCltvDelta = taCltvDelta; - const hop1Timeout = hop2Timeout + hopCltvDelta; - const forwardMtokens = BigInt(rebalanceSats) * BigInt(1000); - // Fee the peer earns forwarding on the TA channel (their outgoing leg). - // This goes on hop 1 (the intermediary); hop 2 (final destination) is fee=0. - const hop1FeeMtokens = - taBaseFee + (forwardMtokens * taFeeRate) / BigInt(1_000_000); - const hop1Fee = Number((hop1FeeMtokens + BigInt(999)) / BigInt(1000)); - const totalMtokens = forwardMtokens + hop1FeeMtokens; + const peerFeeMtokens = + taBaseFeeMtokens + (forwardMtokens * taFeeRate) / BigInt(1_000_000); + const peerFee = Number((peerFeeMtokens + BigInt(999)) / BigInt(1000)); - const rebalanceRoute = { - fee: hop1Fee, - fee_mtokens: String(hop1FeeMtokens), - hops: [ - { - // Hop 1: us → peer via BTC channel. The peer is the intermediary - // and charges a forwarding fee (based on TA channel policy, their - // outgoing leg). - channel: btcChannel.id, - channel_capacity: btcChannel.capacity, - fee: hop1Fee, - fee_mtokens: String(hop1FeeMtokens), - forward: rebalanceSats, - forward_mtokens: String(forwardMtokens), - public_key: peerPubkey, - timeout: hop2Timeout, - }, - { - // Hop 2: peer → us via TA channel (final destination, fee=0). - // Use the alias the peer published in the invoice route hint — the - // canonical SCID and our own alias_scids give UnknownNextPeer for - // private TA channels. - channel: taRouteHint.channel, - channel_capacity: taChannelCapacity, - fee: 0, - fee_mtokens: '0', - forward: rebalanceSats, - forward_mtokens: String(forwardMtokens), - public_key: identityPubkey, - timeout: hop2Timeout, - }, - ], - mtokens: String(totalMtokens), + const [[heightResult, heightError], [identity, identityError]] = + await Promise.all([ + toWithError(this.nodeService.getHeight(accountId)), + toWithError(this.nodeService.getIdentity(accountId)), + ]); + + if (heightError || !heightResult?.current_block_height) { + throw new Error('Rebalance failed: could not get block height'); + } + if (identityError || !identity?.public_key) { + throw new Error('Rebalance failed: could not get node identity'); + } + + const invoiceCltvDelta: number = + decoded.cltv_delta ?? DEFAULT_CHANNEL_CLTV_DELTA; + const finalHopTimeout = + heightResult.current_block_height + invoiceCltvDelta + CLTV_BLOCK_BUFFER; + + // Pathfind to the peer requesting enough CLTV headroom that the hop just + // before the peer can decrement by the TA channel's cltv_delta — the peer + // needs `incoming - outgoing >= taCltvDelta` to forward over the TA hop. + const routeResult = await this.nodeService.getRouteToDestination( + accountId, + { + destination: peerPubkey, + tokens: rebalanceSats + peerFee, + cltv_delta: invoiceCltvDelta + taCltvDelta + CLTV_BLOCK_BUFFER, + } + ); + + if (!routeResult.route) { + throw new Error('Rebalance failed: no route to peer'); + } + + const baseRoute = routeResult.route; + const lastBaseHop = baseRoute.hops[baseRoute.hops.length - 1]; + if (!lastBaseHop || lastBaseHop.public_key !== peerPubkey) { + throw new Error('Rebalance failed: route did not terminate at peer'); + } + + // Peer is no longer the destination — they're an intermediate forwarder + // taking the TA channel fee. They still receive (rebalanceSats + peerFee) + // from the previous hop, but they forward only rebalanceSats onward and + // keep peerFee. Their earned fee = incoming forward - outgoing forward, + // so forward/forward_mtokens must be overridden to the outgoing amount. + // + // hop.timeout encodes the OUTGOING locktime from that hop (i.e. the + // incoming locktime at the next hop). The peer's outgoing locktime must + // equal the TA hop's incoming locktime (= finalHopTimeout); otherwise the + // peer's `incoming - outgoing >= cltv_delta` check fails and the HTLC is + // rejected with IncorrectCltvExpiry. The CLTV gap that satisfies the + // peer's policy comes from the prior hop's locktime, supplied by + // getRouteToDestination via the cltv_delta arg above. + const peerForwardingHop = { + ...lastBaseHop, + fee: peerFee, + fee_mtokens: String(peerFeeMtokens), + forward: rebalanceSats, + forward_mtokens: String(forwardMtokens), + timeout: finalHopTimeout, + }; + + // Appended hop: peer → us via the TA channel. Final destination, so fee=0. + // The peer looks up the channel by the alias they assigned to their side, + // not by the canonical SCID — using taChannelScid here would cause them + // to reject the HTLC with UnknownNextPeer. + const taHop = { + channel: taChannelPartnerScidAlias, + channel_capacity: 100_000, + fee: 0, + fee_mtokens: '0', + forward: rebalanceSats, + forward_mtokens: String(forwardMtokens), + public_key: identity.public_key, + timeout: finalHopTimeout, + }; + + const fullHops = [...baseRoute.hops.slice(0, -1), peerForwardingHop, taHop]; + + // Diagnostic for IncorrectCltvExpiry debugging. The effective CLTV decrement + // the peer applies on the TA channel = `peerIncomingTimeout - peerOutgoingTimeout`, + // which must be >= the peer's enforced cltv_delta on that channel. + // peerIncomingTimeout is the timeout of the hop just before the peer + // (or route.timeout if peer is the first forwarder). + const peerIncomingTimeout = + fullHops.length >= 3 + ? fullHops[fullHops.length - 3].timeout + : baseRoute.timeout; + this.logger.info('Constructed circular rebalance route', { + taCltvDelta, + taRouteHintCltvDelta: taRouteHint.cltv_delta, + peerIncomingTimeout, + peerOutgoingTimeout: peerForwardingHop.timeout, + taHopTimeout: taHop.timeout, + effectiveTaDelta: peerIncomingTimeout - peerForwardingHop.timeout, + }); + this.logger.debug('Full circular rebalance hops', { fullHops }); + + // Total amount entering the route is unchanged: peer used to be the + // destination receiving (rebalanceSats + peerFee); now they receive the + // same amount but forward rebalanceSats and keep peerFee as their fee. + // So mtokens stays the same; fee_mtokens grows by peerFeeMtokens. + const newFeeMtokens = BigInt(baseRoute.fee_mtokens) + peerFeeMtokens; + const fullRoute = { + fee: Number((newFeeMtokens + BigInt(999)) / BigInt(1000)), + fee_mtokens: String(newFeeMtokens), + hops: fullHops, + mtokens: baseRoute.mtokens, payment: invoice.payment, - timeout: hop1Timeout, - tokens: Number((totalMtokens + BigInt(999)) / BigInt(1000)), - total_mtokens: String(forwardMtokens), + timeout: baseRoute.timeout, + tokens: baseRoute.tokens, + total_mtokens: decoded.mtokens, }; this.logger.info('Executing circular rebalance to top up TA channel', { taChannelScid, taChannelPartnerScidAlias, - btcChannelId: btcChannel.id, rebalanceSats, - hop1Timeout, - hop2Timeout, - hopCltvDelta, + peerFee, + hopCount: fullHops.length, }); let rebalResult: PayViaRoutesResult | undefined; try { rebalResult = await this.nodeService.payViaRoutes(accountId, { id: invoice.id, - routes: [rebalanceRoute], + routes: [fullRoute], }); } catch (err: unknown) { const rawErr = err as unknown[]; @@ -1081,21 +972,16 @@ export class TradeResolver { } } - private async getBtcChannelsWithPeer( - id: string, - peerPubkey: string - ): Promise> { + private async getBtcChannels(id: string): Promise> { const [channelsResult, channelsError] = await toWithError( this.nodeService.getChannels(id, { - partner_public_key: peerPubkey, is_active: true, }) ); if (channelsError) { - this.logger.warn('Failed to fetch channels with peer', { + this.logger.warn('Failed to fetch channels', { error: channelsError, - peerPubkey, }); } diff --git a/src/server/modules/node/lightning.types.ts b/src/server/modules/node/lightning.types.ts index bf3a4028..b28d4089 100644 --- a/src/server/modules/node/lightning.types.ts +++ b/src/server/modules/node/lightning.types.ts @@ -80,6 +80,7 @@ export type PayOptions = { outgoing_channel?: string; tokens?: number; is_allow_self_payment?: boolean; + incoming_peer?: string; }; export type CreateInvoiceOptions = { @@ -132,6 +133,57 @@ export type PayViaRoutesOptions = { routes: PayViaRoutesRoute[]; }; +export type GetRouteToDestinationOptions = { + destination: string; + tokens?: number; + mtokens?: string; + cltv_delta?: number; + max_fee?: number; + max_fee_mtokens?: string; + max_timeout_height?: number; + outgoing_channel?: string; + incoming_peer?: string; + payment?: string; + total_mtokens?: string; + is_ignoring_past_failures?: boolean; + ignore?: { + channel?: string; + from_public_key: string; + to_public_key?: string; + }[]; + routes?: { + base_fee_mtokens?: string; + channel?: string; + channel_capacity?: number; + cltv_delta?: number; + fee_rate?: number; + public_key: string; + }[][]; +}; + +export type GetRouteToDestinationResult = { + route?: { + confidence?: number; + fee: number; + fee_mtokens: string; + hops: { + channel: string; + channel_capacity: number; + fee: number; + fee_mtokens: string; + forward: number; + forward_mtokens: string; + public_key: string; + timeout: number; + }[]; + mtokens: string; + safe_fee: number; + safe_tokens: number; + timeout: number; + tokens: number; + }; +}; + export type SendToChainAddressOptions = { address: string; tokens?: number; @@ -237,6 +289,10 @@ export interface LightningProvider { options: PayViaPaymentDetailsOptions ): Promise; payViaRoutes(connection: any, options: PayViaRoutesOptions): Promise; + getRouteToDestination( + connection: any, + options: GetRouteToDestinationOptions + ): Promise; decodePaymentRequest(connection: any, request: string): Promise; getPayments(connection: any, options: GetPaymentsOptions): Promise; diff --git a/src/server/modules/node/litd/litd.service.ts b/src/server/modules/node/litd/litd.service.ts index ec4f2625..1c807de6 100644 --- a/src/server/modules/node/litd/litd.service.ts +++ b/src/server/modules/node/litd/litd.service.ts @@ -15,6 +15,7 @@ import { CreateInvoiceOptions, PayViaPaymentDetailsOptions, PayViaRoutesOptions, + GetRouteToDestinationOptions, SendToChainAddressOptions, CreateChainAddressFormat, UpdateRoutingFeesOptions, @@ -192,6 +193,16 @@ export class LitdService implements LightningProvider, TaprootAssetsProvider { return this.lndService.payViaRoutes(this.getLnd(connection), options); } + async getRouteToDestination( + connection: LitdConnection, + options: GetRouteToDestinationOptions + ) { + return this.lndService.getRouteToDestination( + this.getLnd(connection), + options + ); + } + async decodePaymentRequest(connection: LitdConnection, request: string) { return this.lndService.decodePaymentRequest( this.getLnd(connection), diff --git a/src/server/modules/node/lnd/lnd.service.ts b/src/server/modules/node/lnd/lnd.service.ts index 471c45fd..dc9787ed 100644 --- a/src/server/modules/node/lnd/lnd.service.ts +++ b/src/server/modules/node/lnd/lnd.service.ts @@ -30,6 +30,7 @@ import { pay, payViaPaymentDetails, payViaRoutes, + getRouteToDestination, createInvoice, getChannel, closeChannel, @@ -59,6 +60,8 @@ import { CreateInvoiceOptions, PayViaPaymentDetailsOptions, PayViaRoutesOptions, + GetRouteToDestinationOptions, + GetRouteToDestinationResult, SendToChainAddressOptions, CreateChainAddressFormat, UpdateRoutingFeesOptions, @@ -322,6 +325,13 @@ export class LndService implements LightningProvider { return payViaRoutes({ lnd, ...options } as any); } + async getRouteToDestination( + lnd: AuthenticatedLnd, + options: GetRouteToDestinationOptions + ): Promise { + return to(getRouteToDestination({ lnd, ...options } as any)); + } + subscribeToInvoice(lnd: AuthenticatedLnd, id: string): EventEmitter { return subscribeToInvoice({ lnd, id }); } diff --git a/src/server/modules/node/node.service.ts b/src/server/modules/node/node.service.ts index 52abd21f..82ccd211 100644 --- a/src/server/modules/node/node.service.ts +++ b/src/server/modules/node/node.service.ts @@ -24,6 +24,7 @@ import { PayOptions, PayViaPaymentDetailsOptions, PayViaRoutesOptions, + GetRouteToDestinationOptions, SendToChainAddressOptions, UpdateRoutingFeesOptions, VerifyBackupsOptions, @@ -272,6 +273,14 @@ export class NodeService { return provider.payViaRoutes(account.connection, options); } + async getRouteToDestination( + id: string, + options: GetRouteToDestinationOptions + ) { + const { account, provider } = this.getAccountAndProvider(id); + return provider.getRouteToDestination(account.connection, options); + } + subscribeToInvoice(id: string, invoice: string): EventEmitter { const { account, provider } = this.getAccountAndProvider(id); return provider.subscribeToInvoice(account.connection, invoice); diff --git a/src/server/modules/node/tapd/tapd-node.service.ts b/src/server/modules/node/tapd/tapd-node.service.ts index bc781755..1ba672aa 100644 --- a/src/server/modules/node/tapd/tapd-node.service.ts +++ b/src/server/modules/node/tapd/tapd-node.service.ts @@ -56,7 +56,7 @@ type AssetChannelInfo = { /** Timeout for sendPayment RPC before the stream-level guard kicks in. */ const SEND_PAYMENT_TIMEOUT_SECONDS = 60; -const FEE_LIMIT_MAX = 100; +const FEE_LIMIT = 1_000; // Has to be high in order to find a route, not sure why. @Injectable() export class TapdNodeService { @@ -668,8 +668,8 @@ export class TapdNodeService { // Required: asset sale is a self-payment loop — assets flow out via the // asset channel while sats return via the BTC channel on the same node. allowSelfPayment: true, - feeLimitSat: FEE_LIMIT_MAX, timeoutSeconds: SEND_PAYMENT_TIMEOUT_SECONDS, + feeLimitSat: FEE_LIMIT, }, });