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",