Skip to content

Commit

Permalink
Merge pull request #1885 from ExchangeUnion/collateral-on-order
Browse files Browse the repository at this point in the history
feat(connext): request collateral for order amount
  • Loading branch information
sangaman authored Sep 24, 2020
2 parents 246136e + 75078c0 commit 0ac95fe
Show file tree
Hide file tree
Showing 12 changed files with 393 additions and 131 deletions.
105 changes: 75 additions & 30 deletions lib/connextclient/ConnextClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, Promise<ConnextBalanceResponse>>();
/** A map of currencies to promises representing collateral requests. */
private requestCollateralPromises = new Map<string, Promise<any>>();
private _totalOutboundAmount = new Map<string, number>();
private _maxChannelInboundAmount = new Map<string, number>();

/** 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.
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
}

Expand Down Expand Up @@ -671,29 +710,35 @@ class ConnextClient extends SwapClient {
amount: units.toLocaleString('fullwide', { useGrouping: false }), // toLocaleString avoids scientific notation
});
const { txhash } = await parseResponseBody<ConnextDepositResponse>(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;
}
Expand Down
5 changes: 5 additions & 0 deletions lib/connextclient/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 };
Expand Down
1 change: 1 addition & 0 deletions lib/connextclient/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export type ConnextConfigResponse = {
*/
export type ConnextBalanceResponse = {
freeBalanceOffChain: string;
nodeFreeBalanceOffChain: string;
freeBalanceOnChain: string;
};

Expand Down
1 change: 1 addition & 0 deletions lib/grpc/getGrpcError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions lib/lndclient/LndClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
20 changes: 15 additions & 5 deletions lib/orderbook/OrderBook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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;
Expand All @@ -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. */
Expand Down
8 changes: 8 additions & 0 deletions lib/orderbook/TradingPair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion lib/swaps/SwapClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

public verifyConnectionWithTimeout = () => {
Expand Down
80 changes: 80 additions & 0 deletions test/jest/Connext.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
});
2 changes: 1 addition & 1 deletion test/jest/LndClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading

0 comments on commit 0ac95fe

Please sign in to comment.