mirror of
https://github.com/apotdevin/thunderhub.git
synced 2026-08-13 12:33:08 +02:00
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:
parent
bd5dda71d3
commit
7e5fb5cdeb
9 changed files with 582 additions and 631 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue