From 75078c059bfd752ae47c4530a671bd7a95568975 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 necessary 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 | 105 +++++-- lib/connextclient/errors.ts | 5 + lib/connextclient/types.ts | 1 + lib/grpc/getGrpcError.ts | 1 + lib/lndclient/LndClient.ts | 4 +- lib/orderbook/OrderBook.ts | 20 +- lib/orderbook/TradingPair.ts | 8 + lib/swaps/SwapClient.ts | 6 +- test/jest/Connext.spec.ts | 80 +++++ test/jest/LndClient.spec.ts | 2 +- test/jest/Orderbook.spec.ts | 290 ++++++++++++------ .../jest/__snapshots__/Orderbook.spec.ts.snap | 2 +- 12 files changed, 393 insertions(+), 131 deletions(-) diff --git a/lib/connextclient/ConnextClient.ts b/lib/connextclient/ConnextClient.ts index a85b110e7..e4edb29f8 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,35 @@ 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 minCollateralRequestQuantity = ConnextClient.MIN_COLLATERAL_REQUEST_SIZES[currency]; + if (minCollateralRequestQuantity !== undefined) { + const minCollateralRequestUnits = this.unitConverter.amountToUnits({ currency, amount: minCollateralRequestQuantity }); + 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: (minCollateralRequestUnits.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/grpc/getGrpcError.ts b/lib/grpc/getGrpcError.ts index 5e3152daf..06358e0c8 100644 --- a/lib/grpc/getGrpcError.ts +++ b/lib/grpc/getGrpcError.ts @@ -40,6 +40,7 @@ const getGrpcError = (err: any) => { code = status.ALREADY_EXISTS; break; case connextErrorCodes.INSUFFICIENT_BALANCE: + case connextErrorCodes.INSUFFICIENT_COLLATERAL: case p2pErrorCodes.NOT_CONNECTED: case p2pErrorCodes.NODE_NOT_BANNED: case p2pErrorCodes.NODE_IS_BANNED: diff --git a/lib/lndclient/LndClient.ts b/lib/lndclient/LndClient.ts index 7822153ea..5c280a8d3 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..27e8a799b 100644 --- a/lib/orderbook/OrderBook.ts +++ b/lib/orderbook/OrderBook.ts @@ -459,13 +459,21 @@ 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 === 0 || order.price === Number.POSITIVE_INFINITY) ? + (order.isBuy ? tp.quoteAsk() : tp.quoteBid()) : + order.price; + + 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 +486,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 +505,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 d184bf413..ce0f68de4 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 fac9e2b4d..4f5270abf 100644 --- a/test/jest/LndClient.spec.ts +++ b/test/jest/LndClient.spec.ts @@ -291,7 +291,7 @@ describe('LndClient', () => { expect(lnd['listChannels']).toHaveBeenCalledTimes(1); expect(lnd.maxChannelOutboundAmount()).toEqual(98); - expect(lnd.maxChannelInboundAmount()).toEqual(295); + expect(lnd['_maxChannelInboundAmount']).toEqual(295); }); }); }); diff --git a/test/jest/Orderbook.spec.ts b/test/jest/Orderbook.spec.ts index 51b395615..f7e8312b7 100644 --- a/test/jest/Orderbook.spec.ts +++ b/test/jest/Orderbook.spec.ts @@ -4,7 +4,7 @@ import DB from '../../lib/db/DB'; import Logger from '../../lib/Logger'; import NodeKey from '../../lib/nodekey/NodeKey'; import Orderbook from '../../lib/orderbook/OrderBook'; -import { OwnLimitOrder } from '../../lib/orderbook/types'; +import { OwnLimitOrder, OwnMarketOrder } from '../../lib/orderbook/types'; import Network from '../../lib/p2p/Network'; import Peer from '../../lib/p2p/Peer'; import Pool from '../../lib/p2p/Pool'; @@ -16,6 +16,12 @@ jest.mock('../../lib/db/DB', () => { return jest.fn().mockImplementation(() => { return { models: { + Order: { + create: jest.fn(), + }, + Trade: { + create: jest.fn(), + }, Pair: { findAll: () => { return [ @@ -24,6 +30,11 @@ jest.mock('../../lib/db/DB', () => { baseCurrency: 'LTC', quoteCurrency: 'BTC', }, + { + id: 'BTC/USDT', + baseCurrency: 'BTC', + quoteCurrency: 'USDT', + }, ]; }, @@ -32,9 +43,11 @@ jest.mock('../../lib/db/DB', () => { findAll: () => { const ltc = { id: 'LTC', swapClient: SwapClientType.Lnd }; const btc = { id: 'BTC', swapClient: SwapClientType.Lnd }; + const usdt = { id: 'USDT', swapClient: SwapClientType.Connext }; return [ { ...ltc, toJSON: () => ltc }, { ...btc, toJSON: () => btc }, + { ...usdt, toJSON: () => usdt }, ]; }, }, @@ -100,6 +113,7 @@ logger.trace = jest.fn(); logger.verbose = jest.fn(); logger.debug = jest.fn(); logger.error = jest.fn(); +logger.info = jest.fn(); const loggers = { global: logger, db: logger, @@ -203,109 +217,203 @@ describe('OrderBook', () => { expect(orderbook['isPeerCurrencySupported'](peer, 'LTC')).toStrictEqual(false); }); - test('placeOrder insufficient outbound balance does throw when balancechecks enabled', async () => { - orderbook['nobalancechecks'] = false; - const quantity = 10000; - const price = 0.01; - const order: OwnLimitOrder = { - quantity, - pairId, - price, - localId, - isBuy: false, - }; - Swaps['calculateInboundOutboundAmounts'] = () => { - return { - inboundCurrency: 'BTC', - inboundAmount: quantity * price, - inboundUnits: quantity * price, - outboundCurrency: 'LTC', + describe('placeOrder', () => { + test('insufficient outbound balance throws when balancechecks enabled', async () => { + orderbook['nobalancechecks'] = false; + const quantity = 10000; + const price = 0.01; + const order: OwnLimitOrder = { + quantity, + pairId, + price, + localId, + isBuy: false, + }; + Swaps['calculateInboundOutboundAmounts'] = () => { + return { + inboundCurrency: 'BTC', + inboundAmount: quantity * price, + inboundUnits: quantity * price, + outboundCurrency: 'LTC', + outboundAmount: quantity, + outboundUnits: quantity, + }; + }; + swaps.swapClientManager.get = jest.fn().mockReturnValue({ + totalOutboundAmount: () => 1, + }); + await expect(orderbook.placeLimitOrder({ order })) + .rejects.toMatchSnapshot(); + }); + + test('checks swap client for insufficient inbound balance when balancechecks enabled', async () => { + orderbook['nobalancechecks'] = false; + const quantity = 10000; + const price = 0.01; + const isBuy = false; + const order: OwnLimitOrder = { + quantity, + pairId, + price, + localId, + isBuy, + }; + const inboundCurrency = 'BTC'; + const outboundCurrency = 'LTC'; + const inboundAmount = quantity * price; + Swaps['calculateInboundOutboundAmounts'] = jest.fn().mockReturnValue({ + inboundCurrency, + outboundCurrency, + inboundAmount, + inboundUnits: inboundAmount, outboundAmount: quantity, outboundUnits: quantity, + }); + const inboundSwapClient = { + checkInboundCapacity: jest.fn(), }; - }; - swaps.swapClientManager.get = jest.fn().mockReturnValue({ - totalOutboundAmount: () => 1, + swaps.swapClientManager.get = jest.fn().mockImplementation((currency) => { + if (currency === inboundCurrency) { + return inboundSwapClient; + } else if (currency === outboundCurrency) { + return { totalOutboundAmount: () => quantity }; + } + throw 'unexpected currency'; + }); + + await orderbook.placeLimitOrder({ order }); + expect(Swaps['calculateInboundOutboundAmounts']).toHaveBeenCalledWith(quantity, price, isBuy, pairId); + expect(inboundSwapClient.checkInboundCapacity).toHaveBeenCalledWith(inboundAmount, inboundCurrency); }); - await expect(orderbook.placeLimitOrder({ order })) - .rejects.toMatchSnapshot(); - }); - test('placeLimitOrder adds to order book', async () => { - const quantity = 10000; - const order: OwnLimitOrder = { - quantity, - pairId, - localId, - price: 0.01, - isBuy: false, - }; - await orderbook.placeLimitOrder({ order }); - expect(orderbook.getOwnOrderByLocalId(localId)).toHaveProperty('localId', localId); - }); + test('market order checks swap client for insufficient inbound balance using best quoted price', async () => { + const quantity = 20000000; + const price = 4000; + const usdtPairId = 'BTC/USDT'; + const isBuy = false; + // add order to match with market order + await orderbook.placeLimitOrder({ + order: { + quantity, + price, + pairId: usdtPairId, + localId: 'matchingorder', + isBuy: true, + }, + }); - test('placeLimitOrder immediateOrCancel does not add to order book', async () => { - const quantity = 10000; - const order: OwnLimitOrder = { - quantity, - pairId, - localId, - price: 0.01, - isBuy: false, - }; - await orderbook.placeLimitOrder({ - order, - immediateOrCancel: true, - }); - expect(() => orderbook.getOwnOrderByLocalId(localId)).toThrow(`order with local id ${localId} does not exist`); - }); + orderbook['nobalancechecks'] = false; + const order: OwnMarketOrder = { + quantity, + localId, + isBuy, + pairId: usdtPairId, + }; + const inboundCurrency = 'USDT'; + const outboundCurrency = 'BTC'; + const inboundAmount = quantity * price; + Swaps['calculateInboundOutboundAmounts'] = jest.fn().mockReturnValue({ + inboundCurrency, + outboundCurrency, + inboundAmount, + inboundUnits: inboundAmount * 10 ** 10, + outboundAmount: quantity, + outboundUnits: quantity, + }); + const inboundSwapClient = { + checkInboundCapacity: jest.fn(), + }; + swaps.swapClientManager.get = jest.fn().mockImplementation((currency) => { + if (currency === inboundCurrency) { + return inboundSwapClient; + } else if (currency === outboundCurrency) { + return { totalOutboundAmount: () => quantity }; + } + throw 'unexpected currency'; + }); - test('placeLimitOrder with replaceOrderId replaces an order ', async () => { - pool.broadcastOrderInvalidation = jest.fn(); - pool.broadcastOrder = jest.fn(); - const oldQuantity = 10000; - const oldPrice = 0.01; - const newQuantity = 20000; - const newPrice = 0.02; - const order: OwnLimitOrder = { - pairId, - localId, - quantity: oldQuantity, - price: oldPrice, - isBuy: false, - }; + await orderbook.placeMarketOrder({ order }); - const oldOrder = await orderbook.placeLimitOrder({ - order, + expect(Swaps['calculateInboundOutboundAmounts']).toHaveBeenCalledWith(quantity, price, isBuy, usdtPairId); + expect(inboundSwapClient.checkInboundCapacity).toHaveBeenCalledWith(inboundAmount, inboundCurrency); }); - expect(orderbook.getOwnOrders(pairId).sellArray.length).toEqual(1); - expect(orderbook.getOwnOrders(pairId).sellArray[0].quantity).toEqual(oldQuantity); - expect(orderbook.getOwnOrders(pairId).sellArray[0].price).toEqual(oldPrice); - expect(orderbook.getOwnOrders(pairId).sellArray[0].localId).toEqual(localId); - const newOrder = await orderbook.placeLimitOrder({ - order: { - ...order, - price: newPrice, - quantity: newQuantity, - }, - replaceOrderId: localId, + test('placeLimitOrder adds to order book', async () => { + const quantity = 10000; + const order: OwnLimitOrder = { + quantity, + pairId, + localId, + price: 0.01, + isBuy: false, + }; + await orderbook.placeLimitOrder({ order }); + expect(orderbook.getOwnOrderByLocalId(localId)).toHaveProperty('localId', localId); }); - expect(orderbook.getOwnOrders(pairId).sellArray.length).toEqual(1); - expect(orderbook.getOwnOrders(pairId).sellArray[0].quantity).toEqual(newQuantity); - expect(orderbook.getOwnOrders(pairId).sellArray[0].price).toEqual(newPrice); - expect(orderbook.getOwnOrders(pairId).sellArray[0].localId).toEqual(localId); + test('placeLimitOrder immediateOrCancel does not add to order book', async () => { + const quantity = 10000; + const order: OwnLimitOrder = { + quantity, + pairId, + localId, + price: 0.01, + isBuy: false, + }; + await orderbook.placeLimitOrder({ + order, + immediateOrCancel: true, + }); + expect(() => orderbook.getOwnOrderByLocalId(localId)).toThrow(`order with local id ${localId} does not exist`); + }); - expect(pool.broadcastOrderInvalidation).toHaveBeenCalledTimes(0); - expect(pool.broadcastOrder).toHaveBeenCalledTimes(2); - expect(pool.broadcastOrder).toHaveBeenCalledWith({ - id: newOrder.remainingOrder!.id, - isBuy: false, - pairId: 'LTC/BTC', - price: 0.02, - quantity: 20000, - replaceOrderId: oldOrder.remainingOrder!.id, + test('placeLimitOrder with replaceOrderId replaces an order ', async () => { + pool.broadcastOrderInvalidation = jest.fn(); + pool.broadcastOrder = jest.fn(); + const oldQuantity = 10000; + const oldPrice = 0.01; + const newQuantity = 20000; + const newPrice = 0.02; + const order: OwnLimitOrder = { + pairId, + localId, + quantity: oldQuantity, + price: oldPrice, + isBuy: false, + }; + + const oldOrder = await orderbook.placeLimitOrder({ + order, + }); + expect(orderbook.getOwnOrders(pairId).sellArray.length).toEqual(1); + expect(orderbook.getOwnOrders(pairId).sellArray[0].quantity).toEqual(oldQuantity); + expect(orderbook.getOwnOrders(pairId).sellArray[0].price).toEqual(oldPrice); + expect(orderbook.getOwnOrders(pairId).sellArray[0].localId).toEqual(localId); + + const newOrder = await orderbook.placeLimitOrder({ + order: { + ...order, + price: newPrice, + quantity: newQuantity, + }, + replaceOrderId: localId, + }); + + expect(orderbook.getOwnOrders(pairId).sellArray.length).toEqual(1); + expect(orderbook.getOwnOrders(pairId).sellArray[0].quantity).toEqual(newQuantity); + expect(orderbook.getOwnOrders(pairId).sellArray[0].price).toEqual(newPrice); + expect(orderbook.getOwnOrders(pairId).sellArray[0].localId).toEqual(localId); + + expect(pool.broadcastOrderInvalidation).toHaveBeenCalledTimes(0); + expect(pool.broadcastOrder).toHaveBeenCalledTimes(2); + expect(pool.broadcastOrder).toHaveBeenCalledWith({ + id: newOrder.remainingOrder!.id, + isBuy: false, + pairId: 'LTC/BTC', + price: 0.02, + quantity: 20000, + replaceOrderId: oldOrder.remainingOrder!.id, + }); }); }); diff --git a/test/jest/__snapshots__/Orderbook.spec.ts.snap b/test/jest/__snapshots__/Orderbook.spec.ts.snap index cbc928b71..f344c5d8f 100644 --- a/test/jest/__snapshots__/Orderbook.spec.ts.snap +++ b/test/jest/__snapshots__/Orderbook.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`OrderBook placeOrder insufficient outbound balance does throw when balancechecks enabled 1`] = ` +exports[`OrderBook placeOrder insufficient outbound balance throws when balancechecks enabled 1`] = ` Object { "code": "3.12", "message": "LTC outbound balance of 1 is not sufficient for order amount of 10000",