diff --git a/lib/connextclient/ConnextClient.ts b/lib/connextclient/ConnextClient.ts index cccc2babc..aa433cf3b 100644 --- a/lib/connextclient/ConnextClient.ts +++ b/lib/connextclient/ConnextClient.ts @@ -41,8 +41,6 @@ import { Observable, fromEvent, from, combineLatest, defer, timer } from 'rxjs'; import { take, pluck, timeout, filter, mergeMap, catchError, mergeMapTo } from 'rxjs/operators'; import { sha256 } from '@ethersproject/solidity'; -const MAX_AMOUNT = Number.MAX_SAFE_INTEGER; - interface ConnextClient { on(event: 'preimage', listener: (preimageRequest: ProvidePreimageEvent) => void): void; on(event: 'transferReceived', listener: (transferReceivedRequest: TransferReceivedEvent) => void): void; @@ -115,7 +113,17 @@ class ConnextClient extends SwapClient { private seed: string | undefined; /** A map of currencies to promises representing balance requests. */ private getBalancePromises = new Map>(); + /** A map of currencies to promises representing collateral requests. */ + private requestCollateralPromises = new Map>(); private _totalOutboundAmount = new Map(); + private _maxChannelInboundAmount = new Map(); + + /** The minimum incremental quantity that we may use for collateral requests. */ + private static MIN_COLLATERAL_REQUEST_SIZES: { [key: string]: number | undefined } = { + ETH: 0.1 * 10 ** 8, + USDT: 100 * 10 ** 8, + DAI: 100 * 10 ** 8, + }; /** * Creates a connext client. @@ -260,9 +268,34 @@ class ConnextClient extends SwapClient { return this._totalOutboundAmount.get(currency) || 0; } - public maxChannelInboundAmount = (_currency: string): number => { - // assume MAX_AMOUNT since Connext will re-collaterize accordingly - return MAX_AMOUNT; + public checkInboundCapacity = (inboundAmount: number, currency: string) => { + const inboundCapacity = this._maxChannelInboundAmount.get(currency) || 0; + if (inboundCapacity < inboundAmount) { + // we do not have enough inbound capacity to receive the specified inbound amount so we must request collateral + this.logger.debug(`collateral of ${inboundCapacity} for ${currency} is insufficient for order amount ${inboundAmount}`); + + // we want to make a request for the current collateral plus the greater of any + // minimum request size for the currency or the capacity shortage + 5% buffer + const quantityToRequest = inboundCapacity + Math.max( + inboundAmount * 1.05 - inboundCapacity, + ConnextClient.MIN_COLLATERAL_REQUEST_SIZES[currency] ?? 0, + ); + const unitsToRequest = this.unitConverter.amountToUnits({ currency, amount: quantityToRequest }); + + // first check whether we already have a pending collateral request for this currency + // if not start a new request, and when it completes call channelBalance to refresh our inbound capacity + const requestCollateralPromise = this.requestCollateralPromises.get(currency) ?? this.sendRequest('/request-collateral', 'POST', { + assetId: this.tokenAddresses.get(currency), + amount: unitsToRequest.toLocaleString('fullwide', { useGrouping: false }), + }).then(() => { + this.logger.debug(`completed collateral request of ${unitsToRequest} ${currency} units`); + this.requestCollateralPromises.delete(currency); + return this.channelBalance(currency); + }).catch(this.logger.error); + this.requestCollateralPromises.set(currency, requestCollateralPromise); + + throw errors.INSUFFICIENT_COLLATERAL; + } } protected updateCapacity = async () => { @@ -586,14 +619,20 @@ class ConnextClient extends SwapClient { return { balance: 0, pendingOpenBalance: 0, inactiveBalance: 0 }; } - const { freeBalanceOffChain } = await this.getBalance(currency); + const { freeBalanceOffChain, nodeFreeBalanceOffChain } = await this.getBalance(currency); const freeBalanceAmount = this.unitConverter.unitsToAmount({ currency, units: Number(freeBalanceOffChain), }); + const nodeFreeBalanceAmount = this.unitConverter.unitsToAmount({ + currency, + units: Number(nodeFreeBalanceOffChain), + }); this._totalOutboundAmount.set(currency, freeBalanceAmount); + this._maxChannelInboundAmount.set(currency, nodeFreeBalanceAmount); + this.logger.trace(`new inbound capacity (collateral) for ${currency} of ${nodeFreeBalanceAmount}`); return { balance: freeBalanceAmount, inactiveBalance: 0, @@ -605,7 +644,7 @@ class ConnextClient extends SwapClient { await this.channelBalance(currency); // refreshes the max outbound balance return { maxSell: this.maxChannelOutboundAmount(currency), - maxBuy: this.maxChannelInboundAmount(currency), + maxBuy: this._maxChannelInboundAmount.get(currency) ?? 0, }; } @@ -671,29 +710,34 @@ class ConnextClient extends SwapClient { amount: units.toLocaleString('fullwide', { useGrouping: false }), // toLocaleString avoids scientific notation }); const { txhash } = await parseResponseBody(depositResponse); - const channelCollateralized$ = fromEvent(this, 'depositConfirmed').pipe( - filter(hash => hash === txhash), // only proceed if the incoming hash matches our expected txhash - take(1), // complete the stream after 1 matching event - timeout(86400000), // clear up the listener after 1 day - mergeMap(() => { - // use defer to only create the inner observable when the outer one subscribes - return defer(() => { - return from( - this.sendRequest('/request-collateral', 'POST', { - assetId, - }), - ); - }); - }), - ); - channelCollateralized$.subscribe({ - complete: () => { - this.logger.verbose(`collateralized channel for ${currency}`); - }, - error: (err) => { - this.logger.error(`failed requesting collateral for ${currency}`, err); - }, - }); + + const minCollateralRequestSize = ConnextClient.MIN_COLLATERAL_REQUEST_SIZES[currency]; + if (minCollateralRequestSize !== undefined) { + const channelCollateralized$ = fromEvent(this, 'depositConfirmed').pipe( + filter(hash => hash === txhash), // only proceed if the incoming hash matches our expected txhash + take(1), // complete the stream after 1 matching event + timeout(86400000), // clear up the listener after 1 day + mergeMap(() => { + // use defer to only create the inner observable when the outer one subscribes + return defer(() => { + return from( + this.sendRequest('/request-collateral', 'POST', { + assetId, + amount: minCollateralRequestSize.toLocaleString('fullwide', { useGrouping: false }), + }), + ); + }); + }), + ); + channelCollateralized$.subscribe({ + complete: () => { + this.logger.verbose(`collateralized channel for ${currency}`); + }, + error: (err) => { + this.logger.error(`failed requesting collateral for ${currency}`, err); + }, + }); + } return txhash; } diff --git a/lib/connextclient/errors.ts b/lib/connextclient/errors.ts index ad3c152ab..ed22a92a4 100644 --- a/lib/connextclient/errors.ts +++ b/lib/connextclient/errors.ts @@ -17,6 +17,7 @@ const errorCodes = { CURRENCY_MISSING: codesPrefix.concat('.14'), EXPIRY_MISSING: codesPrefix.concat('.15'), MISSING_SEED: codesPrefix.concat('.16'), + INSUFFICIENT_COLLATERAL: codesPrefix.concat('.17'), }; const errors = { @@ -76,6 +77,10 @@ const errors = { message: 'seed is missing', code: errorCodes.MISSING_SEED, }, + INSUFFICIENT_COLLATERAL: { + message: 'channel collateralization in progress, please try again in ~1 minute', + code: errorCodes.INSUFFICIENT_COLLATERAL, + }, }; export { errorCodes }; diff --git a/lib/connextclient/types.ts b/lib/connextclient/types.ts index cd3f85fc6..aa67083d9 100644 --- a/lib/connextclient/types.ts +++ b/lib/connextclient/types.ts @@ -64,6 +64,7 @@ export type ConnextConfigResponse = { */ export type ConnextBalanceResponse = { freeBalanceOffChain: string; + nodeFreeBalanceOffChain: string; freeBalanceOnChain: string; }; diff --git a/lib/lndclient/LndClient.ts b/lib/lndclient/LndClient.ts index 7655560c5..8d13a37e2 100644 --- a/lib/lndclient/LndClient.ts +++ b/lib/lndclient/LndClient.ts @@ -197,8 +197,8 @@ class LndClient extends SwapClient { return this._maxChannelOutboundAmount; } - public maxChannelInboundAmount = () => { - return this._maxChannelInboundAmount; + public checkInboundCapacity = (_inboundAmount: number) => { + return; // we do not currently check inbound capacities for lnd } /** Lnd specific procedure to mark the client as locked. */ diff --git a/lib/orderbook/OrderBook.ts b/lib/orderbook/OrderBook.ts index d30575d48..d80aef529 100644 --- a/lib/orderbook/OrderBook.ts +++ b/lib/orderbook/OrderBook.ts @@ -459,13 +459,20 @@ class OrderBook extends EventEmitter { }; } - const { outboundCurrency, inboundCurrency, outboundAmount } = - Swaps.calculateInboundOutboundAmounts(order.quantity, order.price, order.isBuy, order.pairId); - const outboundSwapClient = this.swaps.swapClientManager.get(outboundCurrency); - const inboundSwapClient = this.swaps.swapClientManager.get(inboundCurrency); + const tp = this.getTradingPair(order.pairId); if (!this.nobalancechecks) { + // for limit orders, we use the price of our order to calculate inbound/outbound amounts + // for market orders, we use the price of the best matching order in the order book + const price = order.price ?? + (order.isBuy ? tp.quoteAsk() : tp.quoteBid()); + + const { outboundCurrency, inboundCurrency, outboundAmount, inboundAmount } = + Swaps.calculateInboundOutboundAmounts(order.quantity, price, order.isBuy, order.pairId); + // check if clients exists + const outboundSwapClient = this.swaps.swapClientManager.get(outboundCurrency); + const inboundSwapClient = this.swaps.swapClientManager.get(inboundCurrency); if (!outboundSwapClient) { throw swapsErrors.SWAP_CLIENT_NOT_FOUND(outboundCurrency); } @@ -478,6 +485,9 @@ class OrderBook extends EventEmitter { if (outboundAmount > totalOutboundAmount) { throw errors.INSUFFICIENT_OUTBOUND_BALANCE(outboundCurrency, outboundAmount, totalOutboundAmount); } + + // check if sufficient inbound channel capacity exists + inboundSwapClient.checkInboundCapacity(inboundAmount, inboundCurrency); } let replacedOrderIdentifier: OrderIdentifier | undefined; @@ -494,7 +504,6 @@ class OrderBook extends EventEmitter { } // perform matching routine. maker orders that are matched will be removed from the order book. - const tp = this.getTradingPair(order.pairId); const matchingResult = tp.match(order); /** Any portion of the placed order that could not be swapped or matched internally. */ diff --git a/lib/orderbook/TradingPair.ts b/lib/orderbook/TradingPair.ts index 8a6e0ef28..169b2adb7 100644 --- a/lib/orderbook/TradingPair.ts +++ b/lib/orderbook/TradingPair.ts @@ -364,6 +364,14 @@ class TradingPair extends EventEmitter { } } + public quoteBid = () => { + return this.queues?.buyQueue.peek()?.price ?? 0; + } + + public quoteAsk = () => { + return this.queues?.sellQueue.peek()?.price ?? Number.POSITIVE_INFINITY; + } + /** * Matches an order against its opposite queue. Matched maker orders are removed immediately. * @returns a [[MatchingResult]] with the matches as well as the remaining, unmatched portion of the order diff --git a/lib/swaps/SwapClient.ts b/lib/swaps/SwapClient.ts index a767e2a95..3ad5cc788 100644 --- a/lib/swaps/SwapClient.ts +++ b/lib/swaps/SwapClient.ts @@ -133,7 +133,11 @@ abstract class SwapClient extends EventEmitter { public abstract totalOutboundAmount(currency?: string): number; public abstract maxChannelOutboundAmount(currency?: string): number; - public abstract maxChannelInboundAmount(currency?: string): number; + /** + * Checks whether there is sufficient inbound capacity to receive the specified amount + * and throws an error if there isn't, otherwise does nothing. + */ + public abstract checkInboundCapacity(inboundAmount: number, currency?: string): void; protected abstract updateCapacity(): Promise; public verifyConnectionWithTimeout = () => { diff --git a/test/jest/Connext.spec.ts b/test/jest/Connext.spec.ts index 1a0fc0a81..cee3da97c 100644 --- a/test/jest/Connext.spec.ts +++ b/test/jest/Connext.spec.ts @@ -1,3 +1,5 @@ + +// tslint:disable: max-line-length import ConnextClient from '../../lib/connextclient/ConnextClient'; import { UnitConverter } from '../../lib/utils/UnitConverter'; import Logger from '../../lib/Logger'; @@ -36,6 +38,7 @@ jest.mock('http', () => { const ETH_ASSET_ID = '0x0000000000000000000000000000000000000000'; const USDT_ASSET_ID = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; +const XUC_ASSET_ID = '0x9999999999999999999999999999999999999999'; describe('ConnextClient', () => { let connext: ConnextClient; @@ -63,6 +66,11 @@ describe('ConnextClient', () => { tokenAddress: USDT_ASSET_ID, swapClient: SwapClientType.Connext, }, + { + id: 'XUC', + tokenAddress: XUC_ASSET_ID, + swapClient: SwapClientType.Connext, + }, ] as CurrencyInstance[]; connext = new ConnextClient({ config, @@ -312,4 +320,76 @@ describe('ConnextClient', () => { expect(result).toEqual({ state: PaymentState.Failed }); }); }); + + describe('checkInboundCapacity', () => { + const quantity = 20000000; + const smallQuantity = 100; + beforeEach(() => { + connext['sendRequest'] = jest.fn().mockResolvedValue(undefined); + connext['_maxChannelInboundAmount'].set('ETH', 0); + }); + + it('requests collateral plus 5% buffer when there is none', async () => { + expect(() => connext.checkInboundCapacity(quantity, 'ETH')).toThrowError('channel collateralization in progress, please try again in ~1 minute'); + + expect(connext['sendRequest']).toHaveBeenCalledTimes(1); + expect(connext['sendRequest']).toHaveBeenCalledWith( + '/request-collateral', + 'POST', + expect.objectContaining({ assetId: ETH_ASSET_ID, amount: (quantity * 1.05 * 10 ** 10).toLocaleString('fullwide', { useGrouping: false }) }), + ); + }); + + it('does not request collateral when there is a pending request', async () => { + connext['requestCollateralPromises'].set('ETH', Promise.resolve()); + expect(() => connext.checkInboundCapacity(quantity, 'ETH')).toThrowError('channel collateralization in progress, please try again in ~1 minute'); + + expect(connext['sendRequest']).toHaveBeenCalledTimes(0); + }); + + it('requests the full collateral amount even when there is some existing collateral', async () => { + const partialCollateral = 5000; + connext['_maxChannelInboundAmount'].set('ETH', partialCollateral); + + expect(() => connext.checkInboundCapacity(quantity, 'ETH')).toThrowError('channel collateralization in progress, please try again in ~1 minute'); + + expect(connext['sendRequest']).toHaveBeenCalledTimes(1); + expect(connext['sendRequest']).toHaveBeenCalledWith( + '/request-collateral', + 'POST', + expect.objectContaining({ assetId: ETH_ASSET_ID, amount: (quantity * 1.05 * 10 ** 10).toLocaleString('fullwide', { useGrouping: false }) }), + ); + }); + + it('requests the hardcoded minimum if the collateral shortage is below it', async () => { + const minCollateralRequestUnits = ConnextClient['MIN_COLLATERAL_REQUEST_SIZES']['ETH']! * 10 ** 10; + + expect(() => connext.checkInboundCapacity(smallQuantity, 'ETH')).toThrowError('channel collateralization in progress, please try again in ~1 minute'); + + expect(connext['sendRequest']).toHaveBeenCalledTimes(1); + expect(connext['sendRequest']).toHaveBeenCalledWith( + '/request-collateral', + 'POST', + expect.objectContaining({ assetId: ETH_ASSET_ID, amount: minCollateralRequestUnits.toLocaleString('fullwide', { useGrouping: false }) }), + ); + }); + + it('requests collateral plus 5% buffer for a small shortage when there is no hardcoded minimum for the currency', async () => { + expect(() => connext.checkInboundCapacity(smallQuantity, 'XUC')).toThrowError('channel collateralization in progress, please try again in ~1 minute'); + + expect(connext['sendRequest']).toHaveBeenCalledTimes(1); + expect(connext['sendRequest']).toHaveBeenCalledWith( + '/request-collateral', + 'POST', + expect.objectContaining({ assetId: XUC_ASSET_ID, amount: (smallQuantity * 1.05 * 10 ** 10).toLocaleString('fullwide', { useGrouping: false }) }), + ); + }); + + it('does not request collateral or throw when there is sufficient collateral', async () => { + connext['_maxChannelInboundAmount'].set('ETH', quantity); + connext.checkInboundCapacity(quantity, 'ETH'); + + expect(connext['sendRequest']).toHaveBeenCalledTimes(0); + }); + }); }); diff --git a/test/jest/LndClient.spec.ts b/test/jest/LndClient.spec.ts index b6605d530..20c9caf24 100644 --- a/test/jest/LndClient.spec.ts +++ b/test/jest/LndClient.spec.ts @@ -269,7 +269,7 @@ describe('LndClient', () => { expect(lnd['listChannels']).toHaveBeenCalledTimes(1); expect(lnd.maxChannelOutboundAmount()).toEqual(98); - expect(lnd.maxChannelInboundAmount()).toEqual(295); + expect(lnd['_maxChannelInboundAmount']).toEqual(295); }); }); });