Skip to content

Commit

Permalink
feat: reserved capacity checks on PlaceOrder
Browse files Browse the repository at this point in the history
This rejects orders that would put our total reserved balance over our
total capacity for either the outbound or inbound currency. The sum of
the inbound & outbound amounts for a newly placed order are added to
the amounts reserved by open orders, and if either of these amounts
exceed the corresponding capacity then the request to place the order is
rejected.

An exception to this are inbound limits for Connext currencies, since we
have the ability to dynamically request additional inbound collateral
via our "lazy collateral" approach.

It is still possible for market orders to cause our open orders to
exceed our capacity. This is a difficult problem to avoid entirely, as
the price that market orders will execute at is unknown until the
execution is complete. Even if we simulate the matching routine, we
won't know which matches will succeed until we attempt a swap.

Instead, we generously assume that market orders will execute at the
best quoted price for purposes of these capacity checks. For users that
simultaneously place limit orders and market orders for the same
currencies, it should be made clear that market orders may use up their
available balance needed for their limit orders to succeed.

Closes #1947.
  • Loading branch information
sangaman committed Oct 21, 2020
1 parent 77079ee commit 549dd9d
Show file tree
Hide file tree
Showing 16 changed files with 275 additions and 301 deletions.
8 changes: 4 additions & 4 deletions lib/connextclient/ConnextClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,6 @@ class ConnextClient extends SwapClient {
});
}

public totalOutboundAmount = (currency: string): number => {
return this.outboundAmounts.get(currency) || 0;
}

/**
* Checks whether we have a pending collateral request for the currency and,
* if one doesn't exist, starts a new request for the specified amount. Then
Expand All @@ -285,6 +281,10 @@ class ConnextClient extends SwapClient {
this.requestCollateralPromises.set(currency, requestCollateralPromise);
}

/**
* Checks whether there is sufficient inbound capacity to receive the specified amount
* and throws an error if there isn't, otherwise does nothing.
*/
public checkInboundCapacity = (inboundAmount: number, currency: string) => {
const inboundCapacity = this.inboundAmounts.get(currency) || 0;
if (inboundCapacity < inboundAmount) {
Expand Down
3 changes: 2 additions & 1 deletion lib/grpc/getGrpcError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ const getGrpcError = (err: any) => {
case orderErrorCodes.CURRENCY_CANNOT_BE_REMOVED:
case orderErrorCodes.MARKET_ORDERS_NOT_ALLOWED:
case serviceErrorCodes.NOMATCHING_MODE_IS_REQUIRED:
case orderErrorCodes.INSUFFICIENT_OUTBOUND_BALANCE:
case swapErrorCodes.INSUFFICIENT_OUTBOUND_CAPACITY:
case swapErrorCodes.INSUFFICIENT_INBOUND_CAPACITY:
case orderErrorCodes.QUANTITY_ON_HOLD:
case swapErrorCodes.SWAP_CLIENT_NOT_FOUND:
case swapErrorCodes.SWAP_CLIENT_MISCONFIGURED:
Expand Down
14 changes: 3 additions & 11 deletions lib/lndclient/LndClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class LndClient extends SwapClient {
private channelBackupSubscription?: ClientReadableStream<lndrpc.ChanBackupSnapshot>;
private invoiceSubscriptions = new Map<string, ClientReadableStream<lndrpc.Invoice>>();
private initRetryTimeout?: NodeJS.Timeout;
private _totalOutboundAmount = 0;
private totalOutboundAmount = 0;
private totalInboundAmount = 0;
private maxChannelOutboundAmount = 0;
private maxChannelInboundAmount = 0;
Expand Down Expand Up @@ -192,14 +192,6 @@ class LndClient extends SwapClient {
return this.chainIdentifier;
}

public totalOutboundAmount = () => {
return this._totalOutboundAmount;
}

public checkInboundCapacity = (_inboundAmount: number) => {
return; // we do not currently check inbound capacities for lnd
}

public setReservedInboundAmount = (_reservedInboundAmount: number) => {
return; // not currently used for lnd
}
Expand Down Expand Up @@ -742,8 +734,8 @@ class LndClient extends SwapClient {
this.logger.debug(`new channel inbound capacity: ${maxInbound}`);
}

if (this._totalOutboundAmount !== totalOutboundAmount) {
this._totalOutboundAmount = totalOutboundAmount;
if (this.totalOutboundAmount !== totalOutboundAmount) {
this.totalOutboundAmount = totalOutboundAmount;
this.logger.debug(`new channel total outbound capacity: ${totalOutboundAmount}`);
}

Expand Down
28 changes: 4 additions & 24 deletions lib/orderbook/OrderBook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'assert';
import { EventEmitter } from 'events';
import { UnitConverter } from '../utils/UnitConverter';
import uuidv1 from 'uuid/v1';
import { SwapClientType, SwapFailureReason, SwapPhase, SwapRole } from '../constants/enums';
import { Models } from '../db/DB';
Expand All @@ -8,7 +9,6 @@ import Logger from '../Logger';
import { SwapFailedPacket, SwapRequestPacket } from '../p2p/packets';
import Peer from '../p2p/Peer';
import Pool from '../p2p/Pool';
import swapsErrors from '../swaps/errors';
import Swaps from '../swaps/Swaps';
import { SwapDeal, SwapFailure, SwapSuccess } from '../swaps/types';
import { pubKeyToAlias } from '../utils/aliasUtils';
Expand Down Expand Up @@ -121,7 +121,7 @@ class OrderBook extends EventEmitter {

const onOrderRemoved = (order: OwnOrder) => {
const { inboundCurrency, outboundCurrency, inboundAmount, outboundAmount } =
Swaps.calculateInboundOutboundAmounts(order.quantity, order.price, order.isBuy, order.pairId);
UnitConverter.calculateInboundOutboundAmounts(order.quantity, order.price, order.isBuy, order.pairId);
this.swaps.swapClientManager.subtractInboundReservedAmount(inboundCurrency, inboundAmount);
this.swaps.swapClientManager.subtractOutboundReservedAmount(outboundCurrency, outboundAmount);
};
Expand All @@ -130,7 +130,7 @@ class OrderBook extends EventEmitter {

this.on('ownOrder.added', (order) => {
const { inboundCurrency, outboundCurrency, inboundAmount, outboundAmount } =
Swaps.calculateInboundOutboundAmounts(order.quantity, order.price, order.isBuy, order.pairId);
UnitConverter.calculateInboundOutboundAmounts(order.quantity, order.price, order.isBuy, order.pairId);
this.swaps.swapClientManager.addInboundReservedAmount(inboundCurrency, inboundAmount);
this.swaps.swapClientManager.addOutboundReservedAmount(outboundCurrency, outboundAmount);
});
Expand Down Expand Up @@ -484,27 +484,7 @@ class OrderBook extends EventEmitter {
(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);
}
if (!inboundSwapClient) {
throw swapsErrors.SWAP_CLIENT_NOT_FOUND(inboundCurrency);
}

// check if sufficient outbound channel capacity exists
const totalOutboundAmount = outboundSwapClient.totalOutboundAmount(outboundCurrency);
if (outboundAmount > totalOutboundAmount) {
throw errors.INSUFFICIENT_OUTBOUND_BALANCE(outboundCurrency, outboundAmount, totalOutboundAmount);
}

// check if sufficient inbound channel capacity exists
inboundSwapClient.checkInboundCapacity(inboundAmount, inboundCurrency);
await this.swaps.swapClientManager.checkSwapCapacities({ ...order, price });
}

let replacedOrderIdentifier: OrderIdentifier | undefined;
Expand Down
5 changes: 0 additions & 5 deletions lib/orderbook/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const errorCodes = {
LOCAL_ID_DOES_NOT_EXIST: codesPrefix.concat('.9'),
QUANTITY_DOES_NOT_MATCH: codesPrefix.concat('.10'),
CURRENCY_MISSING_ETHEREUM_CONTRACT_ADDRESS: codesPrefix.concat('.11'),
INSUFFICIENT_OUTBOUND_BALANCE: codesPrefix.concat('.12'),
MIN_QUANTITY_VIOLATED: codesPrefix.concat('.13'),
QUANTITY_ON_HOLD: codesPrefix.concat('.15'),
};
Expand Down Expand Up @@ -63,10 +62,6 @@ const errors = {
message: `requestedQuantity: ${requestedQuantity} is higher than order quantity: ${orderQuantity}`,
code: errorCodes.QUANTITY_DOES_NOT_MATCH,
}),
INSUFFICIENT_OUTBOUND_BALANCE: (currency: string, amount: number, availableAmount: number) => ({
message: `${currency} outbound balance of ${availableAmount} is not sufficient for order amount of ${amount}`,
code: errorCodes.INSUFFICIENT_OUTBOUND_BALANCE,
}),
MIN_QUANTITY_VIOLATED: (quantity: number, currency: string) => ({
message: `order does not meet the minimum ${currency} quantity of ${quantity} satoshis`,
code: errorCodes.MIN_QUANTITY_VIOLATED,
Expand Down
6 changes: 0 additions & 6 deletions lib/swaps/SwapClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,6 @@ abstract class SwapClient extends EventEmitter {
*/
public abstract swapCapacities(currency?: string): Promise<SwapCapacities>;

public abstract totalOutboundAmount(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;
public abstract setReservedInboundAmount(reservedInboundAmount: number, currency?: string): void;
protected abstract updateCapacity(): Promise<void>;

Expand Down
49 changes: 47 additions & 2 deletions lib/swaps/SwapClientManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import assert from 'assert';
import { EventEmitter } from 'events';
import Config from '../Config';
import ConnextClient from '../connextclient/ConnextClient';
Expand All @@ -7,12 +8,11 @@ import lndErrors from '../lndclient/errors';
import LndClient from '../lndclient/LndClient';
import { LndInfo } from '../lndclient/types';
import { Loggers } from '../Logger';
import { Currency } from '../orderbook/types';
import { Currency, OwnLimitOrder } from '../orderbook/types';
import Peer from '../p2p/Peer';
import { UnitConverter } from '../utils/UnitConverter';
import errors from './errors';
import SwapClient, { ClientStatus } from './SwapClient';
import assert from 'assert';
import { TradingLimits } from './types';

export function isConnextClient(swapClient: SwapClient): swapClient is ConnextClient {
Expand Down Expand Up @@ -184,6 +184,51 @@ class SwapClientManager extends EventEmitter {
}
}

/**
* Checks whether a given order with would exceed our inbound or outbound swap
* capacities when taking into consideration reserved amounts for standing
* orders, throws an exception if so and returns otherwise.
*/
public checkSwapCapacities = async ({ quantity, price, isBuy, pairId }: OwnLimitOrder) => {
const { outboundCurrency, inboundCurrency, outboundAmount, inboundAmount } =
UnitConverter.calculateInboundOutboundAmounts(quantity, price, isBuy, pairId);

// check if clients exists
const outboundSwapClient = this.get(outboundCurrency);
const inboundSwapClient = this.get(inboundCurrency);
if (!outboundSwapClient) {
throw errors.SWAP_CLIENT_NOT_FOUND(outboundCurrency);
}
if (!inboundSwapClient) {
throw errors.SWAP_CLIENT_NOT_FOUND(inboundCurrency);
}

const outboundCapacities = await outboundSwapClient.swapCapacities(outboundCurrency);

// check if sufficient outbound channel capacity exists
const reservedOutbound = this.outboundReservedAmounts.get(outboundCurrency) ?? 0;
const availableOutboundCapacity = outboundCapacities.totalOutboundCapacity - reservedOutbound;
if (outboundAmount > availableOutboundCapacity) {
throw errors.INSUFFICIENT_OUTBOUND_CAPACITY(outboundCurrency, outboundAmount, availableOutboundCapacity);
}

// check if sufficient inbound channel capacity exists
if (isConnextClient(inboundSwapClient)) {
// connext has the unique ability to dynamically request additional inbound capacity aka collateral
// we handle it differently and allow "lazy collateralization" if the total inbound capacity would
// be exceeded when including the reserved inbound amounts, we only reject if this order alone would
// exceed our inbound capacity
inboundSwapClient.checkInboundCapacity(inboundAmount, inboundCurrency);
} else {
const inboundCapacities = await inboundSwapClient.swapCapacities(inboundCurrency);
const reservedInbound = this.inboundReservedAmounts.get(inboundCurrency) ?? 0;
const availableInboundCapacity = inboundCapacities.totalInboundCapacity - reservedInbound;
if (inboundAmount > availableInboundCapacity) {
throw errors.INSUFFICIENT_INBOUND_CAPACITY(outboundCurrency, outboundAmount, availableOutboundCapacity);
}
}
}

public getOutboundReservedAmount = (currency: string) => {
return this.outboundReservedAmounts.get(currency);
}
Expand Down
40 changes: 2 additions & 38 deletions lib/swaps/Swaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { PacketType } from '../p2p/packets';
import * as packets from '../p2p/packets/types';
import Peer from '../p2p/Peer';
import Pool from '../p2p/Pool';
import { UnitConverter } from '../utils/UnitConverter';
import { generatePreimageAndHash, setTimeoutPromise } from '../utils/utils';
import errors, { errorCodes } from './errors';
import SwapClient, { PaymentState } from './SwapClient';
Expand Down Expand Up @@ -48,17 +49,6 @@ class Swaps extends EventEmitter {
private timeouts = new Map<string, number>();
private usedHashes = new Set<string>();
private repository: SwapRepository;
/** Number of smallest units per currency. */
// TODO: Use UnitConverter class instead
private static readonly UNITS_PER_CURRENCY: { [key: string]: number } = {
BTC: 1,
LTC: 1,
ETH: 10 ** 10,
USDT: 10 ** -2,
WETH: 10 ** 10,
DAI: 10 ** 10,
XUC: 10 ** 10,
};
/** The maximum time in milliseconds we will wait for a swap to be accepted before failing it. */
private static readonly SWAP_ACCEPT_TIMEOUT = 10000;
/** The maximum time in milliseconds we will wait for a swap to be completed before failing it. */
Expand Down Expand Up @@ -141,7 +131,7 @@ class Swaps extends EventEmitter {
*/
private static calculateMakerTakerAmounts = (quantity: number, price: number, isBuy: boolean, pairId: string) => {
const { inboundCurrency, inboundAmount, inboundUnits, outboundCurrency, outboundAmount, outboundUnits } =
Swaps.calculateInboundOutboundAmounts(quantity, price, isBuy, pairId);
UnitConverter.calculateInboundOutboundAmounts(quantity, price, isBuy, pairId);
return {
makerCurrency: inboundCurrency,
makerAmount: inboundAmount,
Expand All @@ -152,32 +142,6 @@ class Swaps extends EventEmitter {
};
}

/**
* Calculates the incoming and outgoing currencies and amounts of subunits/satoshis for an order if it is swapped.
* @param quantity The quantity of the order
* @param price The price of the order
* @param isBuy Whether the order is a buy
* @returns An object with the calculated incoming and outgoing values. The quote currency
* amount is returned as zero if the price is 0 or infinity, indicating a market order.
*/
public static calculateInboundOutboundAmounts = (quantity: number, price: number, isBuy: boolean, pairId: string) => {
const [baseCurrency, quoteCurrency] = pairId.split('/');
const baseCurrencyAmount = quantity;
const quoteCurrencyAmount = price > 0 && price < Number.POSITIVE_INFINITY ?
Math.round(quantity * price) :
0; // if price is zero or infinity, this is a market order and we can't know the quote currency amount
const baseCurrencyUnits = Math.floor(baseCurrencyAmount * Swaps.UNITS_PER_CURRENCY[baseCurrency]);
const quoteCurrencyUnits = Math.floor(quoteCurrencyAmount * Swaps.UNITS_PER_CURRENCY[quoteCurrency]);

const inboundCurrency = isBuy ? baseCurrency : quoteCurrency;
const inboundAmount = isBuy ? baseCurrencyAmount : quoteCurrencyAmount;
const inboundUnits = isBuy ? baseCurrencyUnits : quoteCurrencyUnits;
const outboundCurrency = isBuy ? quoteCurrency : baseCurrency;
const outboundAmount = isBuy ? quoteCurrencyAmount : baseCurrencyAmount;
const outboundUnits = isBuy ? quoteCurrencyUnits : baseCurrencyUnits;
return { inboundCurrency, inboundAmount, inboundUnits, outboundCurrency, outboundAmount, outboundUnits };
}

public init = async () => {
// update pool with lnd pubkeys
this.swapClientManager.getLndClientsMap().forEach(({ pubKey, chain, currency, uris }) => {
Expand Down
10 changes: 10 additions & 0 deletions lib/swaps/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const errorCodes = {
PAYMENT_PENDING: codesPrefix.concat('.10'),
REMOTE_IDENTIFIER_MISSING: codesPrefix.concat('.11'),
INSUFFICIENT_BALANCE: codesPrefix.concat('.12'),
INSUFFICIENT_OUTBOUND_CAPACITY: codesPrefix.concat('.13'),
INSUFFICIENT_INBOUND_CAPACITY: codesPrefix.concat('.14'),
};

const errors = {
Expand Down Expand Up @@ -70,6 +72,14 @@ const errors = {
message: 'swap failed due to insufficient channel balance',
code: errorCodes.INSUFFICIENT_BALANCE,
},
INSUFFICIENT_OUTBOUND_CAPACITY: (currency: string, amount: number, capacity: number) => ({
message: `${currency} outbound capacity of ${capacity} is not sufficient for order amount of ${amount}`,
code: errorCodes.INSUFFICIENT_OUTBOUND_CAPACITY,
}),
INSUFFICIENT_INBOUND_CAPACITY: (currency: string, amount: number, capacity: number) => ({
message: `${currency} inbound capacity of ${capacity} is not sufficient for order amount of ${amount}`,
code: errorCodes.INSUFFICIENT_INBOUND_CAPACITY,
}),
};

export { errorCodes };
Expand Down
47 changes: 38 additions & 9 deletions lib/utils/UnitConverter.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
const UNITS_PER_CURRENCY: { [key: string]: number } = {
BTC: 1,
LTC: 1,
ETH: 10 ** 10,
USDT: 10 ** -2,
WETH: 10 ** 10,
DAI: 10 ** 10,
XUC: 10 ** 10,
};

class UnitConverter {
/** Number of smallest units per currency. */
private UNITS_PER_CURRENCY: { [key: string]: number } = {
BTC: 1,
LTC: 1,
ETH: 10 ** 10,
USDT: 10 ** -2,
WETH: 10 ** 10,
DAI: 10 ** 10,
XUC: 10 ** 10,
};
private UNITS_PER_CURRENCY: { [key: string]: number } = UNITS_PER_CURRENCY;

/**
* Calculates the incoming and outgoing currencies and amounts of subunits/satoshis for an order if it is swapped.
* @param quantity The quantity of the order
* @param price The price of the order
* @param isBuy Whether the order is a buy
* @returns An object with the calculated incoming and outgoing values. The quote currency
* amount is returned as zero if the price is 0 or infinity, indicating a market order.
*/
public static calculateInboundOutboundAmounts = (quantity: number, price: number, isBuy: boolean, pairId: string) => {
const [baseCurrency, quoteCurrency] = pairId.split('/');
const baseCurrencyAmount = quantity;
const quoteCurrencyAmount = price > 0 && price < Number.POSITIVE_INFINITY ?
Math.round(quantity * price) :
0; // if price is zero or infinity, this is a market order and we can't know the quote currency amount
const baseCurrencyUnits = Math.floor(baseCurrencyAmount * UNITS_PER_CURRENCY[baseCurrency]);
const quoteCurrencyUnits = Math.floor(quoteCurrencyAmount * UNITS_PER_CURRENCY[quoteCurrency]);

const inboundCurrency = isBuy ? baseCurrency : quoteCurrency;
const inboundAmount = isBuy ? baseCurrencyAmount : quoteCurrencyAmount;
const inboundUnits = isBuy ? baseCurrencyUnits : quoteCurrencyUnits;
const outboundCurrency = isBuy ? quoteCurrency : baseCurrency;
const outboundAmount = isBuy ? quoteCurrencyAmount : baseCurrencyAmount;
const outboundUnits = isBuy ? quoteCurrencyUnits : baseCurrencyUnits;
return { inboundCurrency, inboundAmount, inboundUnits, outboundCurrency, outboundAmount, outboundUnits };
}

public init = () => {
// TODO: Populate the mapping from the database (Currency.decimalPlaces).
// this.UNITS_PER_CURRENCY = await fetchUnitsPerCurrencyFromDatabase();
Expand Down
Loading

0 comments on commit 549dd9d

Please sign in to comment.