Skip to content

Commit

Permalink
feat(connext): request collateral for order amount
Browse files Browse the repository at this point in the history
This adds a check when a new order is receiving tokens via connext that
ensures we have sufficient collateral from the connext node to fulfill
the order. If not, we fail the order request and request the additional
collateral necessary. This involves the following changes:

1. The Connext client now tracks the inbound node collateral for each
currency and refreshes this value every time it queries for channel
balances and every time a collateral request completes.

2. Before placing a new order that is receiving Connext tokens we check
the inbound amount against the available collateral for that currency.
In the case of market orders, we use the best available matching price
in the order book to calculate inbound & outbound amounts.

3. If the collateral is insufficient, the order will be rejected and a
collateral request will be performed for the missing capacity plus a
5% buffer.

4. Any other collateral checks while we're awaiting a response for a
collateral request will not perform an additional collateral request so
as to prevent duplicate calls.

5. Traders may repeat their order request and it will be accepted once
sufficient collateral to complete the trade is acquired.

6. Collateral is only requested upon depositing funds to a Connext
channel according to hardcoded collateral request minimums per currency.

There are also several hardcoded collateral request minimums. If the
capacity shortage is smaller than these minimums, the minimum amount
will be requested instead.

Closes #1845.
  • Loading branch information
sangaman committed Sep 17, 2020
1 parent 15f53bb commit 02dee87
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 38 deletions.
101 changes: 72 additions & 29 deletions lib/connextclient/ConnextClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ import { Observable, fromEvent, from, combineLatest, defer, timer } from 'rxjs';
import { take, pluck, timeout, filter, mergeMap, catchError, mergeMapTo } from 'rxjs/operators';
import { sha256 } from '@ethersproject/solidity';

const MAX_AMOUNT = Number.MAX_SAFE_INTEGER;
const MIN_COLLATERAL_REQUEST_SIZES: { [key: string]: number | undefined } = {
ETH: 0.1 * 10 ** 18,
USDT: 100 * 10 ** 6,
DAI: 100 * 10 ** 18,
};

interface ConnextClient {
on(event: 'preimage', listener: (preimageRequest: ProvidePreimageEvent) => void): void;
Expand Down Expand Up @@ -115,7 +119,10 @@ class ConnextClient extends SwapClient {
private seed: string | undefined;
/** A map of currencies to promises representing balance requests. */
private getBalancePromises = new Map<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>();

/**
* Creates a connext client.
Expand Down Expand Up @@ -260,9 +267,34 @@ class ConnextClient extends SwapClient {
return this._totalOutboundAmount.get(currency) || 0;
}

public maxChannelInboundAmount = (_currency: string): number => {
// assume MAX_AMOUNT since Connext will re-collaterize accordingly
return MAX_AMOUNT;
public checkInboundCapacity = (inboundAmount: number, currency: string) => {
const inboundCapacity = this._maxChannelInboundAmount.get(currency) || 0;
if (inboundCapacity < inboundAmount) {
// we do not have enough inbound capacity to receive the specified inbound amount so we must request collateral
this.logger.debug(`collateral of ${inboundCapacity} for ${currency} is insufficient for order amount ${inboundAmount}`);
const capacityShortage = inboundAmount - inboundCapacity;
const capacityShortageUnits = this.unitConverter.amountToUnits({ currency, amount: capacityShortage });

// we want to request the greater of any minimum request size for the currency or the capacity shortage + 5% buffer
const amount = Math.max(
capacityShortageUnits * 1.05, // 5% buffer
MIN_COLLATERAL_REQUEST_SIZES[currency] ?? 0,
);

// first check whether we already have a pending collateral request for this currency
// if not start a new request, and when it completes call channelBalance to refresh our inbound capacity
const requestCollateralPromise = this.requestCollateralPromises.get(currency) ?? this.sendRequest('/request-collateral', 'POST', {
assetId: this.tokenAddresses.get(currency),
amount: amount.toLocaleString('fullwide', { useGrouping: false }),
}).then(() => {
this.logger.debug(`completed collateral request of ${amount} ${currency} units`);
this.requestCollateralPromises.delete(currency);
return this.channelBalance(currency);
}).catch(this.logger.error);
this.requestCollateralPromises.set(currency, requestCollateralPromise);

throw errors.INSUFFICIENT_COLLATERAL;
}
}

protected updateCapacity = async () => {
Expand Down Expand Up @@ -586,14 +618,20 @@ class ConnextClient extends SwapClient {
return { balance: 0, pendingOpenBalance: 0, inactiveBalance: 0 };
}

const { freeBalanceOffChain } = await this.getBalance(currency);
const { freeBalanceOffChain, nodeFreeBalanceOffChain } = await this.getBalance(currency);

const freeBalanceAmount = this.unitConverter.unitsToAmount({
currency,
units: Number(freeBalanceOffChain),
});
const nodeFreeBalanceAmount = this.unitConverter.unitsToAmount({
currency,
units: Number(nodeFreeBalanceOffChain),
});

this._totalOutboundAmount.set(currency, freeBalanceAmount);
this._maxChannelInboundAmount.set(currency, nodeFreeBalanceAmount);
this.logger.trace(`new inbound capacity (collateral) for ${currency} of ${nodeFreeBalanceAmount}`);
return {
balance: freeBalanceAmount,
inactiveBalance: 0,
Expand All @@ -605,7 +643,7 @@ class ConnextClient extends SwapClient {
await this.channelBalance(currency); // refreshes the max outbound balance
return {
maxSell: this.maxChannelOutboundAmount(currency),
maxBuy: this.maxChannelInboundAmount(currency),
maxBuy: this._maxChannelInboundAmount.get(currency) ?? 0,
};
}

Expand Down Expand Up @@ -671,29 +709,34 @@ class ConnextClient extends SwapClient {
amount: units.toString(),
});
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 minCollateralRequestSize = MIN_COLLATERAL_REQUEST_SIZES[currency];
if (minCollateralRequestSize !== undefined) {
const channelCollateralized$ = fromEvent(this, 'depositConfirmed').pipe(
filter(hash => hash === txhash), // only proceed if the incoming hash matches our expected txhash
take(1), // complete the stream after 1 matching event
timeout(86400000), // clear up the listener after 1 day
mergeMap(() => {
// use defer to only create the inner observable when the outer one subscribes
return defer(() => {
return from(
this.sendRequest('/request-collateral', 'POST', {
assetId,
amount: minCollateralRequestSize.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
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
19 changes: 14 additions & 5 deletions lib/orderbook/OrderBook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,13 +459,20 @@ class OrderBook extends EventEmitter {
};
}

const { outboundCurrency, inboundCurrency, outboundAmount } =
Swaps.calculateInboundOutboundAmounts(order.quantity, order.price, order.isBuy, order.pairId);
const outboundSwapClient = this.swaps.swapClientManager.get(outboundCurrency);
const inboundSwapClient = this.swaps.swapClientManager.get(inboundCurrency);
const tp = this.getTradingPair(order.pairId);

if (!this.nobalancechecks) {
// for limit orders, we use the price of our order to calculate inbound/outbound amounts
// for market orders, we use the price of the best matching order in the order book
const price = order.price ??
(order.isBuy ? tp.quoteAsk() : tp.quoteBid());

const { outboundCurrency, inboundCurrency, outboundAmount, inboundAmount } =
Swaps.calculateInboundOutboundAmounts(order.quantity, price, order.isBuy, order.pairId);

// check if clients exists
const outboundSwapClient = this.swaps.swapClientManager.get(outboundCurrency);
const inboundSwapClient = this.swaps.swapClientManager.get(inboundCurrency);
if (!outboundSwapClient) {
throw swapsErrors.SWAP_CLIENT_NOT_FOUND(outboundCurrency);
}
Expand All @@ -478,6 +485,9 @@ class OrderBook extends EventEmitter {
if (outboundAmount > totalOutboundAmount) {
throw errors.INSUFFICIENT_OUTBOUND_BALANCE(outboundCurrency, outboundAmount, totalOutboundAmount);
}

// check if sufficient inbound channel capacity exists
inboundSwapClient.checkInboundCapacity(inboundAmount, inboundCurrency);
}

let replacedOrderIdentifier: OrderIdentifier | undefined;
Expand All @@ -494,7 +504,6 @@ class OrderBook extends EventEmitter {
}

// perform matching routine. maker orders that are matched will be removed from the order book.
const tp = this.getTradingPair(order.pairId);
const matchingResult = tp.match(order);

/** Any portion of the placed order that could not be swapped or matched internally. */
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
48 changes: 48 additions & 0 deletions test/jest/Connext.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,4 +312,52 @@ describe('ConnextClient', () => {
expect(result).toEqual({ state: PaymentState.Failed });
});
});

describe('checkInboundCapacity', () => {
const units = 10000;
beforeEach(() => {
connext['sendRequest'] = jest.fn().mockResolvedValue(undefined);
});

it('requests collateral when there is none', async () => {
connext['_maxChannelInboundAmount'].set('ETH', 0);
expect(() => connext.checkInboundCapacity(units, 'ETH')).toThrowError('channel collateralization in progress, please try again in ~1 minute');

expect(connext['sendRequest']).toHaveBeenCalledTimes(1);
expect(connext['sendRequest']).toHaveBeenCalledWith(
'/request-collateral',
'POST',
expect.objectContaining({ assetId: ETH_ASSET_ID, amount: units * 1.05 * 10 ** 10 }),
);
});

it('does not request collateral when there is a pending request', async () => {
connext['_maxChannelInboundAmount'].set('ETH', 0);
connext['requestCollateralPromises'].set('ETH', Promise.resolve());
expect(() => connext.checkInboundCapacity(units, 'ETH')).toThrowError('channel collateralization in progress, please try again in ~1 minute');

expect(connext['sendRequest']).toHaveBeenCalledTimes(0);
});

it('requests only the collateral shortage when requesting collateral a second time', async () => {
const partialCollateral = 5000;
connext['_maxChannelInboundAmount'].set('ETH', partialCollateral);

expect(() => connext.checkInboundCapacity(units, 'ETH')).toThrowError('channel collateralization in progress, please try again in ~1 minute');

expect(connext['sendRequest']).toHaveBeenCalledTimes(1);
expect(connext['sendRequest']).toHaveBeenCalledWith(
'/request-collateral',
'POST',
expect.objectContaining({ assetId: ETH_ASSET_ID, amount: (units - partialCollateral) * 1.05 * 10 ** 10 }),
);
});

it('does not request collateral or throw when there is sufficient collateral', async () => {
connext['_maxChannelInboundAmount'].set('ETH', units);
connext.checkInboundCapacity(units, 'ETH');

expect(connext['sendRequest']).toHaveBeenCalledTimes(0);
});
});
});
2 changes: 1 addition & 1 deletion test/jest/LndClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ describe('LndClient', () => {

expect(lnd['listChannels']).toHaveBeenCalledTimes(1);
expect(lnd.maxChannelOutboundAmount()).toEqual(98);
expect(lnd.maxChannelInboundAmount()).toEqual(295);
expect(lnd['_maxChannelInboundAmount']).toEqual(295);
});
});
});

0 comments on commit 02dee87

Please sign in to comment.