Skip to content

Commit

Permalink
Merge pull request #359 from BoltzExchange/swap-timeout-fix
Browse files Browse the repository at this point in the history
Swap timeout fix
  • Loading branch information
michael1011 authored Jun 30, 2023
2 parents 7f9628a + 0f71bed commit 8ea31ae
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 35 deletions.
4 changes: 2 additions & 2 deletions lib/service/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,11 @@ export default {
message: 'AMP invoices not supported',
code: concatErrorCode(ErrorCodePrefix.Service, 31),
}),
MIN_CLTV_TOO_BIG: (
MIN_EXPIRY_TOO_BIG: (
swapMaximal: number,
minFinalCltvExpiry: number,
): Error => ({
message: `minimal CLTV expiry ${minFinalCltvExpiry} of invoice plus the minimal offset ${TimeoutDeltaProvider.minCltvOffset} is greater than max swap timeout ${swapMaximal}`,
message: `minimal swap expiry ${minFinalCltvExpiry} plus the minimal offset ${TimeoutDeltaProvider.minCltvOffset} minutes is greater than max swap timeout ${swapMaximal}`,
code: concatErrorCode(ErrorCodePrefix.Service, 32),
}),
};
62 changes: 48 additions & 14 deletions lib/service/TimeoutDeltaProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import { ConfigType } from '../Config';
import { OrderSide } from '../consts/Enums';
import { PairConfig } from '../consts/Types';
import ElementsClient from '../chain/ElementsClient';
import { decodeInvoice, getPairId, splitPairId, stringify } from '../Utils';
import {
getPairId,
stringify,
splitPairId,
decodeInvoice,
getChainCurrency,
getLightningCurrency,
} from '../Utils';

type PairTimeoutBlocksDelta = {
reverse: number;
Expand All @@ -21,7 +28,8 @@ type PairTimeoutBlockDeltas = {
};

class TimeoutDeltaProvider {
public static minCltvOffset = 6;
public static minCltvOffset = 60;
private static routingOffset = 90;

// A map of the symbols of currencies and their block times in minutes
public static blockTimes = new Map<string, number>([
Expand Down Expand Up @@ -107,38 +115,64 @@ class TimeoutDeltaProvider {
isReverse: boolean,
invoice?: string,
): number => {
const timeout = this.timeoutDeltas.get(pairId);
const timeouts = this.timeoutDeltas.get(pairId);

if (!timeout) {
if (!timeouts) {
throw Errors.PAIR_NOT_FOUND(pairId);
}

const { base, quote } = timeout;

if (isReverse) {
return orderSide === OrderSide.BUY ? base.reverse : quote.reverse;
return orderSide === OrderSide.BUY
? timeouts.base.reverse
: timeouts.quote.reverse;
} else {
const deltas = orderSide === OrderSide.BUY ? quote : base;
const { base, quote } = splitPairId(pairId);
const chain = getChainCurrency(base, quote, orderSide, false);
const lightning = getLightningCurrency(base, quote, orderSide, false);

const deltas =
orderSide === OrderSide.BUY ? timeouts.quote : timeouts.base;
return invoice
? this.getTimeoutInvoice(deltas, invoice)
? this.getTimeoutInvoice(chain, lightning, deltas, invoice)
: deltas.swapMinimal;
}
};

private getTimeoutInvoice = (
chainCurrency: string,
lightningCurrency: string,
timeout: PairTimeoutBlocksDelta,
invoice: string,
): number => {
const { minFinalCltvExpiry } = decodeInvoice(invoice);
const { minFinalCltvExpiry, routingInfo } = decodeInvoice(invoice);

let blocks = timeout.swapMinimal;

if (minFinalCltvExpiry) {
const minTimeout =
minFinalCltvExpiry + TimeoutDeltaProvider.minCltvOffset;
const deltas = [minFinalCltvExpiry]
.concat((routingInfo || []).map((info) => info.cltv_expiry_delta))
.filter(
(val): val is Exclude<typeof val, undefined> => val !== undefined,
);

if (deltas.length > 0) {
let maxDelta = Math.max(...deltas);

// Add some buffer to make sure we have enough limit to route to the hop hint
if (routingInfo !== undefined) {
maxDelta += TimeoutDeltaProvider.routingOffset;
}

const finalExpiry = Math.ceil(
maxDelta * TimeoutDeltaProvider.getBlockTime(lightningCurrency),
);

const minTimeout = Math.ceil(
(TimeoutDeltaProvider.minCltvOffset + finalExpiry) /
TimeoutDeltaProvider.getBlockTime(chainCurrency),
);

if (minTimeout > timeout.swapMaximal) {
throw Errors.MIN_CLTV_TOO_BIG(timeout.swapMaximal, minFinalCltvExpiry);
throw Errors.MIN_EXPIRY_TOO_BIG(timeout.swapMaximal, minTimeout);
}

blocks = Math.max(timeout.swapMinimal, minTimeout);
Expand Down
9 changes: 9 additions & 0 deletions lib/swap/LightningNursery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ class LightningNursery extends EventEmitter {
);
};

public static errIsPaymentInTransition = (error: unknown): boolean => {
return (
error !== undefined &&
error !== null &&
(error as any).code === 6 &&
(error as any).details === 'payment is in transition'
);
};

public static errIsCltvLimitExceeded = (error: unknown): boolean => {
if (error === undefined || error === null) {
return false;
Expand Down
13 changes: 13 additions & 0 deletions lib/swap/PaymentHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ class PaymentHandler {
throw 'CLTV limit to small';
}

this.logger.debug(
`Paying invoice of swap ${swap.id} with cltvLimit: ${cltvLimit}`,
);
const payResponse = await Promise.race([
lightningCurrency.lndClient!.sendPayment(
swap.invoice!,
Expand Down Expand Up @@ -180,6 +183,16 @@ class PaymentHandler {
return undefined;
}

if (
LightningNursery.errIsPaymentInTransition(error) ||
LightningNursery.errIsCltvLimitExceeded(error)
) {
return undefined;
}

this.logger.debug(
`Resetting ${lightningCurrency.symbol} lightning mission control`,
);
await lightningCurrency.lndClient!.resetMissionControl();

// If the invoice could not be paid but the Swap has a Channel Creation attached to it, a channel will be opened
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
},
"cross-os": {
"postcompile": {
"linux": "cp -R lib/api/static dist/lib/api/static && rsync -am --include '*/' --include '*.js*' --exclude '*' lib/proto/ dist/lib/proto",
"darwin": "cp -R lib/api/static dist/lib/api/static && cp -R lib/proto/ dist/lib/proto/"
"linux": "cp package.json dist && cp -R lib/api/static dist/lib/api/static && rsync -am --include '*/' --include '*.js*' --exclude '*' lib/proto/ dist/lib/proto",
"darwin": "cp package.json dist && cp -R lib/api/static dist/lib/api/static && cp -R lib/proto/ dist/lib/proto/"
}
},
"license": "AGPL-3.0",
Expand Down
32 changes: 25 additions & 7 deletions test/integration/service/TimeoutDeltaProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('TimeoutDeltaProvider', () => {
const { base, quote } = splitPairId(pair);
const timeoutDelta = {
reverse: 1400,
swapMinimal: 1000,
swapMinimal: 190,
swapMaximal: 2800,
};

Expand Down Expand Up @@ -79,26 +79,44 @@ describe('TimeoutDeltaProvider', () => {
);

// Greater than minimum
for (const amount of [101, 150, 274]) {
for (const cltvDelta of [140, 150, 274]) {
const ltcBlocks = Math.ceil(
cltvDelta / TimeoutDeltaProvider.blockTimes.get(base)!,
);
expect(
deltaProvider.getTimeout(
pair,
OrderSide.BUY,
false,
await createInvoice(amount),
await createInvoice(ltcBlocks),
),
).toEqual(
Math.ceil(
(cltvDelta + TimeoutDeltaProvider.minCltvOffset) /
TimeoutDeltaProvider.blockTimes.get(quote)!,
),
).toEqual(amount + TimeoutDeltaProvider.minCltvOffset);
);
}

// Greater than maximum
const swapMaximal =
timeoutDelta.swapMaximal / TimeoutDeltaProvider.blockTimes.get(quote)!;
const invoiceCltv = swapMaximal - TimeoutDeltaProvider.minCltvOffset + 1;
timeoutDelta.swapMaximal / TimeoutDeltaProvider.blockTimes.get(base)!;
const invoiceCltv =
swapMaximal -
TimeoutDeltaProvider.minCltvOffset /
TimeoutDeltaProvider.blockTimes.get(base)! +
1;
const invoice = await createInvoice(invoiceCltv);

expect(() =>
deltaProvider.getTimeout(pair, OrderSide.BUY, false, invoice),
).toThrow(Errors.MIN_CLTV_TOO_BIG(swapMaximal, invoiceCltv).message);
).toThrow(
Errors.MIN_EXPIRY_TOO_BIG(
timeoutDelta.swapMaximal / TimeoutDeltaProvider.blockTimes.get(quote)!,
timeoutDelta.swapMaximal / TimeoutDeltaProvider.blockTimes.get(quote)! +
1,
).message,
);
});

test('should get timeouts of reverse swaps', () => {
Expand Down
61 changes: 51 additions & 10 deletions test/unit/service/TimeoutDeltaProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { parse } from '@iarna/toml';
import { existsSync, unlinkSync, readFileSync } from 'fs';
import { existsSync, readFileSync, unlinkSync } from 'fs';
import Logger from '../../../lib/Logger';
import Errors from '../../../lib/service/Errors';
import { ConfigType } from '../../../lib/Config';
Expand All @@ -13,13 +13,26 @@ const currencies = [
{
base: 'BTC',
quote: 'BTC',
timeoutDelta: 360,
timeoutDelta: {
reverse: 1440,
swapMinimal: 360,
swapMaximal: 2880,
},
},
{
base: 'LTC',
quote: 'BTC',
timeoutDelta: 20,
},
{
base: 'L-BTC',
quote: 'BTC',
timeoutDelta: {
reverse: 1440,
swapMinimal: 1400,
swapMaximal: 2880,
},
},
] as any as PairConfig[];

describe('TimeoutDeltaProvider', () => {
Expand Down Expand Up @@ -52,17 +65,29 @@ describe('TimeoutDeltaProvider', () => {

beforeAll(() => {
cleanup();
});

test('should init', () => {
deltaProvider.init(currencies);
});

afterAll(() => {
cleanup();
});

test('should init', () => {
const deltas = deltaProvider['timeoutDeltas'];

expect(deltas.size).toEqual(2);
expect(deltas.size).toEqual(currencies.length);
expect(deltas.get('BTC/BTC')).toEqual({
base: createDeltas(36),
quote: createDeltas(36),
base: {
reverse: 144,
swapMinimal: 36,
swapMaximal: 288,
},
quote: {
reverse: 144,
swapMinimal: 36,
swapMaximal: 288,
},
});
expect(deltas.get('LTC/BTC')).toEqual({
base: createDeltas(8),
Expand Down Expand Up @@ -167,7 +192,23 @@ describe('TimeoutDeltaProvider', () => {
expect(TimeoutDeltaProvider.convertBlocks('BTC', 'LTC', 3)).toEqual(12);
});

afterAll(() => {
cleanup();
});
test.each`
desc | chain | lightning | deltas | timeout | invoice
${'routing hints'} | ${'BTC'} | ${'BTC'} | ${'BTC/BTC'} | ${240} | ${'lnbc100u1pjfmfrcpp533fk6s5pjp55cv2zms6x4z0kkwyyrt2252pgxdxpklk6tnlw99yqdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5cdt869pnacqmw60ugma0sdgtsrh3g66mhheh3pwvjxrpn4l364fsrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcllcyzdrt8pcdlgqqqqlgqqqqqeqqjqpnppy43z486f0d5u856svstzwax80a8x53xa8lksrr40fprd8fckkewhkke59q4cex9udhp9u9dgsy8j60g7kytfm9aj904phm8mdsgp3329uv'}
${'minFinalCltvExpiry'} | ${'BTC'} | ${'BTC'} | ${'BTC/BTC'} | ${153} | ${'lnbc4651250n1pjfmvphpp58xdd4f4kycjhvr2cq3g8jljz6phfrehqhm9jxk7gyc84m4sfyjlsdql2djkuepqw3hjqsj5gvsxzerywfjhxuccqzynxqrrsssp55wjzuzjkcqaam5e94wcjzva6hgx69xu30exqxeqwccuzk4um46ys9qyyssq3fwqktxlgn6vunvcgnrqcg04e8yes8fk5658nnnml5zmwajr9p9y8jc60dhmhw269k9wfjdjflkhwe9edygg2ae2u0hz2tynwh4c9lgp7qq23f'}
${'block time conversion routing hints'} | ${'L-BTC'} | ${'BTC'} | ${'L-BTC/BTC'} | ${2400} | ${'lnbc100u1pjfmfrcpp533fk6s5pjp55cv2zms6x4z0kkwyyrt2252pgxdxpklk6tnlw99yqdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5cdt869pnacqmw60ugma0sdgtsrh3g66mhheh3pwvjxrpn4l364fsrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcllcyzdrt8pcdlgqqqqlgqqqqqeqqjqpnppy43z486f0d5u856svstzwax80a8x53xa8lksrr40fprd8fckkewhkke59q4cex9udhp9u9dgsy8j60g7kytfm9aj904phm8mdsgp3329uv'}
${'block time conversion'} | ${'L-BTC'} | ${'BTC'} | ${'L-BTC/BTC'} | ${1530} | ${'lnbc4651250n1pjfmvphpp58xdd4f4kycjhvr2cq3g8jljz6phfrehqhm9jxk7gyc84m4sfyjlsdql2djkuepqw3hjqsj5gvsxzerywfjhxuccqzynxqrrsssp55wjzuzjkcqaam5e94wcjzva6hgx69xu30exqxeqwccuzk4um46ys9qyyssq3fwqktxlgn6vunvcgnrqcg04e8yes8fk5658nnnml5zmwajr9p9y8jc60dhmhw269k9wfjdjflkhwe9edygg2ae2u0hz2tynwh4c9lgp7qq23f'}
`(
'should get timeout for invoice with routing hints for case "$desc"',
({ chain, lightning, deltas, timeout, invoice }) => {
expect(
deltaProvider['getTimeoutInvoice'](
chain,
lightning,
deltaProvider['timeoutDeltas'].get(deltas)!.base,
invoice,
),
).toEqual(timeout);
},
);
});
18 changes: 18 additions & 0 deletions test/unit/swap/LightningNursery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ describe('LightningNursery', () => {
expect(LightningNursery.errIsInvoicePaid(undefined)).toEqual(false);
});

test.each`
expected | error
${true} | ${{ code: 6, details: 'payment is in transition' }}
${false} | ${{ code: 6, details: 'payment is not in transition' }}
${false} | ${{ code: 5, details: 'payment is in transition' }}
${false} | ${{ code: 6 }}
${false} | ${{}}
${false} | ${undefined}
${false} | ${null}
`(
'should detect "payment is in transition" error for $error',
({ error, expected }) => {
expect(LightningNursery.errIsPaymentInTransition(error)).toEqual(
expected,
);
},
);

test('should detect "cltv limit should be greater than" errors', () => {
expect(
LightningNursery.errIsCltvLimitExceeded({
Expand Down

0 comments on commit 8ea31ae

Please sign in to comment.