Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(connext): request collateral for order amount #1885

Merged
merged 1 commit into from
Sep 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) => {
This conversation was marked as resolved.
Show resolved Hide resolved
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);
This conversation was marked as resolved.
Show resolved Hide resolved
}).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: {
This conversation was marked as resolved.
Show resolved Hide resolved
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