feat: multi-hop swaps with circular rebalancing (#749)

* 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 <bufo24@users.noreply.github.com>
Co-authored-by: Anthony Potdevin <31413433+apotdevin@users.noreply.github.com>
This commit is contained in:
Bufo 2026-05-03 02:24:23 +02:00 committed by GitHub
parent bd5dda71d3
commit 7e5fb5cdeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 582 additions and 631 deletions

View file

@ -126,30 +126,6 @@ export const TradingPartners: FC = () => {
};
}, [allAssetChannelsData]);
const btcChannelPubkeys = useMemo(() => {
const assetChannelCountByPubkey = new Map<string, number>();
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<string, number>();
for (const ch of allChannelsData?.getChannels || []) {
totalChannelCountByPubkey.set(
ch.partner_public_key,
(totalChannelCountByPubkey.get(ch.partner_public_key) || 0) + 1
);
}
const pubkeys = new Set<string>();
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<string, string>();
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,

View file

@ -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
);

View file

@ -25,10 +25,11 @@ interface PrivateMethods {
peerPubkey: string,
btcChannels: BtcChannel[]
): Promise<unknown>;
getBtcChannelsWithPeer(id: string, peerPubkey: string): Promise<BtcChannel[]>;
getBtcChannels(id: string): Promise<BtcChannel[]>;
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<void>;
}
@ -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();
});
});
});

View file

@ -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<string, number>();
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<void> {
// 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<Array<BtcChannel>> {
private async getBtcChannels(id: string): Promise<Array<BtcChannel>> {
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,
});
}

View file

@ -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<any>;
payViaRoutes(connection: any, options: PayViaRoutesOptions): Promise<any>;
getRouteToDestination(
connection: any,
options: GetRouteToDestinationOptions
): Promise<GetRouteToDestinationResult>;
decodePaymentRequest(connection: any, request: string): Promise<any>;
getPayments(connection: any, options: GetPaymentsOptions): Promise<any>;

View file

@ -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),

View file

@ -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<GetRouteToDestinationResult> {
return to(getRouteToDestination({ lnd, ...options } as any));
}
subscribeToInvoice(lnd: AuthenticatedLnd, id: string): EventEmitter {
return subscribeToInvoice({ lnd, id });
}

View file

@ -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);

View file

@ -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,
},
});