From 25090c93ffe52c7d48dd579e62c1b891a570dfb4 Mon Sep 17 00:00:00 2001 From: Daniel McNally Date: Thu, 10 Sep 2020 03:14:37 -0400 Subject: [PATCH] feat(connext): request collateral for order amount This adds a check when a new order is receiving tokens via connext that ensures we have sufficient collateral from the connext node to fulfill the order. If not, we fail the order request and request the additional collateral necessary. This involves the following changes: 1. The Connext client now tracks the inbound node collateral for each currency and refreshes this value every time it queries for channel balances and every time a collateral request completes. 2. Before placing a new order that is receiving Connext tokens we check the inbound amount against the available collateral for that currency. In the case of market orders, we use the best available matching price in the order book to calculate inbound & outbound amounts. 3. If the collateral is insufficient, the order will be rejected and a collateral request will be performed for the missing capacity plus a 5% buffer. 4. Any other collateral checks while we're awaiting a response for a collateral request will not perform an additional collateral request so as to prevent duplicate calls. 5. Traders may repeat their order request and it will be accepted once sufficient collateral to complete the trade is acquired. 6. Collateral is only requested upon depositing funds to a Connext channel according to hardcoded collateral request minimums per currency. There are also several hardcoded collateral request minimums. If the capacity shortage is smaller than these minimums, the minimum amount will be requested instead. Closes #1845. --- lib/connextclient/ConnextClient.ts | 101 ++++++++++++++++++++--------- lib/connextclient/errors.ts | 5 ++ lib/connextclient/types.ts | 1 + lib/lndclient/LndClient.ts | 4 +- lib/orderbook/OrderBook.ts | 19 ++++-- lib/orderbook/TradingPair.ts | 8 +++ lib/swaps/SwapClient.ts | 6 +- test/jest/Connext.spec.ts | 48 ++++++++++++++ test/jest/LndClient.spec.ts | 2 +- 9 files changed, 156 insertions(+), 38 deletions(-) diff --git a/lib/connextclient/ConnextClient.ts b/lib/connextclient/ConnextClient.ts index 825275170..799529010 100644 --- a/lib/connextclient/ConnextClient.ts +++ b/lib/connextclient/ConnextClient.ts @@ -41,7 +41,11 @@ 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; +const MIN_COLLATERAL_REQUEST_SIZES: { [key: string]: number | undefined } = { + ETH: 0.1 * 10 ** 18, + USDT: 100 * 10 ** 6, + DAI: 100 * 10 ** 18, +}; interface ConnextClient { on(event: 'preimage', listener: (preimageRequest: ProvidePreimageEvent) => void): void; @@ -115,7 +119,10 @@ 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(); /** * Creates a connext client. @@ -260,9 +267,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}`); + const capacityShortage = inboundAmount - inboundCapacity; + const capacityShortageUnits = this.unitConverter.amountToUnits({ currency, amount: capacityShortage }); + + // we want to request the greater of any minimum request size for the currency or the capacity shortage + 5% buffer + const amount = Math.max( + capacityShortageUnits * 1.05, // 5% buffer + MIN_COLLATERAL_REQUEST_SIZES[currency] ?? 0, + ); + + // 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', { + amount, + assetId: this.tokenAddresses.get(currency), + }).then(() => { + this.logger.debug(`completed collateral request of ${amount} ${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 +618,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 +643,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 +709,34 @@ class ConnextClient extends SwapClient { amount: units.toString(), }); 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 = 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, + }), + ); + }); + }), + ); + 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 6e500f12e..6ffa45d4f 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..c9bb08eca 100644 --- a/test/jest/Connext.spec.ts +++ b/test/jest/Connext.spec.ts @@ -312,4 +312,52 @@ describe('ConnextClient', () => { expect(result).toEqual({ state: PaymentState.Failed }); }); }); + + describe('checkInboundCapacity', () => { + const units = 10000; + beforeEach(() => { + connext['sendRequest'] = jest.fn().mockResolvedValue(undefined); + }); + + it('requests collateral when there is none', async () => { + connext['_maxChannelInboundAmount'].set('ETH', 0); + expect(() => connext.checkInboundCapacity(units, '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: units * 1.05 * 10 ** 10 }), + ); + }); + + it('does not request collateral when there is a pending request', async () => { + connext['_maxChannelInboundAmount'].set('ETH', 0); + connext['requestCollateralPromises'].set('ETH', Promise.resolve()); + expect(() => connext.checkInboundCapacity(units, 'ETH')).toThrowError('channel collateralization in progress, please try again in ~1 minute'); + + expect(connext['sendRequest']).toHaveBeenCalledTimes(0); + }); + + it('requests only the collateral shortage when requesting collateral a second time', async () => { + const partialCollateral = 5000; + connext['_maxChannelInboundAmount'].set('ETH', partialCollateral); + + expect(() => connext.checkInboundCapacity(units, '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: (units - partialCollateral) * 1.05 * 10 ** 10 }), + ); + }); + + it('does not request collateral or throw when there is sufficient collateral', async () => { + connext['_maxChannelInboundAmount'].set('ETH', units); + connext.checkInboundCapacity(units, '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); }); }); });