Skip to content

Commit

Permalink
Merge pull request #2043 from ExchangeUnion/feat/connext-multi-currency
Browse files Browse the repository at this point in the history
feat(connext): multi currency support
  • Loading branch information
erkarl committed Dec 28, 2020
2 parents 8a2e786 + ccaaa58 commit 62aad3d
Show file tree
Hide file tree
Showing 8 changed files with 1,147 additions and 119 deletions.
2 changes: 1 addition & 1 deletion jest.config.js
Expand Up @@ -2,7 +2,7 @@ module.exports = {
preset: 'ts-jest',
globals: {
'ts-jest': {
tsConfig: '<rootDir>/tsconfig.json'
tsconfig: '<rootDir>/tsconfig.json'
}
},
testEnvironment: 'node',
Expand Down
116 changes: 65 additions & 51 deletions lib/connextclient/ConnextClient.ts
Expand Up @@ -21,8 +21,8 @@ import { XudError } from '../types';
import { UnitConverter } from '../utils/UnitConverter';
import { parseResponseBody } from '../utils/utils';
import errors, { errorCodes } from './errors';
import { EthProvider, getEthprovider } from './ethprovider';
import {
ConnextBalanceResponse,
ConnextBlockNumberResponse,
ConnextChannelBalanceResponse,
ConnextChannelDetails,
Expand All @@ -39,7 +39,6 @@ import {
EthproviderGasPriceResponse,
ExpectedIncomingTransfer,
GetBlockByNumberResponse,
OnchainTransferResponse,
ProvidePreimageEvent,
TransferReceivedEvent,
} from './types';
Expand Down Expand Up @@ -146,6 +145,7 @@ class ConnextClient extends SwapClient {
public publicIdentifier: string | undefined;
/** On-chain deposit address */
public signerAddress: string | undefined;
private ethProvider: EthProvider | undefined;
/** The set of hashes for outgoing transfers. */
private outgoingTransferHashes = new Set<string>();
private port: number;
Expand All @@ -162,7 +162,7 @@ class ConnextClient extends SwapClient {
private requestCollateralPromises = new Map<string, Promise<any>>();
private outboundAmounts = new Map<string, number>();
private inboundAmounts = new Map<string, number>();
private _reconcileDepositSubscriber: Subscription | undefined;
private _reconcileDepositSubscriptions: Subscription[] = [];

/** Channel multisig address */
private channelAddress: string | undefined;
Expand Down Expand Up @@ -212,6 +212,7 @@ class ConnextClient extends SwapClient {
// Related issue: https://github.com/ExchangeUnion/xud/issues/1494
public setSeed = (seed: string) => {
this.seed = seed;
this.ethProvider = getEthprovider(this.host, this.port, this.network, CHAIN_IDENTIFIERS[this.network], this.seed);
};

public initConnextClient = async (seedMnemonic: string) => {
Expand Down Expand Up @@ -387,41 +388,48 @@ class ConnextClient extends SwapClient {
};

private reconcileDeposit = () => {
if (this._reconcileDepositSubscriber) {
this._reconcileDepositSubscriber.unsubscribe();
}
const ethBalance$ = interval(30000).pipe(
mergeMap(() => from(this.getBalanceForAddress(this.channelAddress!))),
// only emit new ETH balance events when the balance changes
distinctUntilChanged(),
);
this._reconcileDepositSubscriber = ethBalance$
// when ETH balance changes
.pipe(
mergeMap(() => {
if (this.status === ClientStatus.ConnectionVerified) {
return defer(() => {
// create new commitment transaction
return from(
this.sendRequest('/deposit', 'POST', {
channelAddress: this.channelAddress,
publicIdentifier: this.publicIdentifier,
assetId: '0x0000000000000000000000000000000000000000', // TODO: multi currency support
}),
);
});
}
return throwError('stopping deposit calls because client is no longer connected');
}),
)
.subscribe({
this._reconcileDepositSubscriptions.forEach((subscription) => subscription.unsubscribe());
const getBalance$ = (assetId: string, pollInterval: number) => {
return interval(pollInterval).pipe(
mergeMap(() => from(this.getBalanceForAddress(assetId, this.channelAddress))),
// only emit new balance events when the balance changes
distinctUntilChanged(),
);
};
const reconcileForAsset = (assetId: string, balance$: ReturnType<typeof getBalance$>) => {
return (
balance$
// when balance changes
.pipe(
mergeMap(() => {
if (this.status === ClientStatus.ConnectionVerified) {
// create new commitment transaction
return from(
this.sendRequest('/deposit', 'POST', {
channelAddress: this.channelAddress,
publicIdentifier: this.publicIdentifier,
assetId,
}),
);
}
return throwError('stopping deposit calls because client is no longer connected');
}),
)
);
};
this.tokenAddresses.forEach((assetId) => {
const subscription = reconcileForAsset(assetId, getBalance$(assetId, 30000)).subscribe({
next: () => {
this.logger.trace('deposit successfully reconciled');
this.logger.trace(`deposit successfully reconciled for ${this.getCurrencyByTokenaddress(assetId)}`);
},
error: (e) => {
this.logger.trace(`stopped deposit calls because: ${JSON.stringify(e)}`);
this.logger.trace(
`stopped ${this.getCurrencyByTokenaddress(assetId)} deposit calls because: ${JSON.stringify(e)}`,
);
},
});
this._reconcileDepositSubscriptions.push(subscription);
});
};

public sendSmallestAmount = async () => {
Expand Down Expand Up @@ -774,13 +782,22 @@ class ConnextClient extends SwapClient {
return gweiGasPrice;
};

private getBalanceForAddress = async (assetId: string) => {
const res = await this.sendRequest(`/ethprovider/${CHAIN_IDENTIFIERS[this.network]}`, 'POST', {
method: 'eth_getBalance',
params: [assetId, 'latest'],
});
const getBalanceResponse = await parseResponseBody<ConnextBalanceResponse>(res);
return parseInt(getBalanceResponse.result, 16);
/**
* Returns the on-chain balance for a given assetId and address.
* Address defaults to signer address.
*/
private getBalanceForAddress = async (assetId: string, address?: string) => {
assert(this.ethProvider, 'Cannot get balance without ethProvider');
if (assetId === this.tokenAddresses.get('ETH')) {
const ethBalance$ = address ? this.ethProvider.getEthBalanceByAddress(address) : this.ethProvider.getEthBalance();
return BigInt(await ethBalance$.toPromise());
} else {
const contract = this.ethProvider.getContract(assetId);
const erc20balance$ = address
? this.ethProvider.getERC20BalanceByAddress(address, contract)
: this.ethProvider.getERC20Balance(contract);
return BigInt(await erc20balance$.toPromise());
}
};

public getInfo = async (): Promise<ConnextInfo> => {
Expand Down Expand Up @@ -912,7 +929,7 @@ class ConnextClient extends SwapClient {
const tokenAddress = this.getTokenAddress(currency);
getBalancePromise = Promise.all([
this.sendRequest(`/${this.publicIdentifier}/channels/${this.channelAddress}`, 'GET'),
this.getBalanceForAddress(this.signerAddress!),
this.getBalanceForAddress(tokenAddress),
])
.then(async ([channelDetailsRes, onChainBalance]) => {
const channelDetails = await parseResponseBody<ConnextChannelDetails>(channelDetailsRes);
Expand Down Expand Up @@ -1015,6 +1032,8 @@ class ConnextClient extends SwapClient {

// Withdraw on-chain funds
public withdraw = async ({ all, currency, amount, destination, fee }: WithdrawArguments): Promise<string> => {
assert(this.ethProvider, 'cannot send transaction without ethProvider');

if (fee) {
// TODO: allow overwriting gas price
throw Error('setting fee for Ethereum withdrawals is not supported yet');
Expand Down Expand Up @@ -1043,20 +1062,15 @@ class ConnextClient extends SwapClient {
throw new Error('either all must be true or amount must be non-zero');
}

const res = await this.sendRequest('/onchain-transfer', 'POST', {
assetId: this.getTokenAddress(currency),
amount: unitsStr,
recipient: destination,
});
const { txhash } = await parseResponseBody<OnchainTransferResponse>(res);
return txhash;
const sendTransaction$ = this.ethProvider.onChainTransfer(this.getTokenAddress(currency), destination, unitsStr);
const transaction = await sendTransaction$.toPromise();
this.logger.info(`on-chain transfer sent, transaction hash: ${transaction.hash}`);
return transaction.hash;
};

/** Connext client specific cleanup. */
protected disconnect = async () => {
if (this._reconcileDepositSubscriber) {
this._reconcileDepositSubscriber.unsubscribe();
}
this._reconcileDepositSubscriptions.forEach((subscription) => subscription.unsubscribe());
this.setStatus(ClientStatus.Disconnected);

for (const req of this.pendingRequests) {
Expand Down
119 changes: 119 additions & 0 deletions lib/connextclient/ethprovider.ts
@@ -0,0 +1,119 @@
import { ethers } from 'ethers';
import { curry } from 'ramda';
import { from, Observable } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
// This file will be a separate module with the above dependencies.

// gets the Ethereum provider object to read data from the chain
const getProvider = (host: string, port: number, name: string, chainId: number): ethers.providers.JsonRpcProvider => {
return new ethers.providers.JsonRpcProvider(
{ url: `http://${host}:${port}/ethprovider/${chainId}` },
{
name,
chainId,
},
);
};

// gets the signer object necessary for write access (think unlock wallet)
const getSigner = (provider: ethers.providers.JsonRpcProvider, seed: string): ethers.Wallet => {
return ethers.Wallet.fromMnemonic(seed).connect(provider);
};

// We curry getContract so that we can provide its arguments one at a time.
// This allows us to provide some of the necessary arguments (that we already have) before we export the function.
// Read more: https://ramdajs.com/docs/#curry
const getContract = curry(
(signer: ethers.Wallet, contractAddress: string): ethers.Contract => {
// we use the minimum viable contract ABI for ERC20 tokens
// for full contract ABI we should compile it from the solidity source
const erc20abi = ['function balanceOf(address) view returns (uint)', 'function transfer(address to, uint amount)'];
return new ethers.Contract(contractAddress, erc20abi, signer);
},
);

// Sends on-chain ERC20 transfer
// We also curry this function, just like the previous one.
// All the functions that we export out of the package will be curried
const onChainSendERC20 = curry(
(
signer: ethers.Wallet,
contract: ethers.Contract,
destinationAddress: string,
units: string,
): Observable<ethers.ContractTransaction> => {
// get the gas price
return from(signer.provider.getGasPrice()).pipe(
mergeMap(
(gasPrice) =>
// then send the transaction
from(contract.transfer(destinationAddress, units, { gasPrice })) as Observable<ethers.ContractTransaction>,
),
);
},
);

// Sends on-chain ETH transfer
const onChainSendETH = curry(
(signer: ethers.Wallet, destinationAddress: string, units: string): Observable<ethers.ContractTransaction> => {
return from(signer.provider.getGasPrice()).pipe(
mergeMap((gasPrice) => {
const ether = ethers.utils.formatEther(units);
const value = ethers.utils.parseEther(ether);
return signer.sendTransaction({
to: destinationAddress,
value,
gasPrice,
});
}),
);
},
);

// returns ETH on-chain balance for the address in wei
const getEthBalanceByAddress = curry((provider: ethers.providers.JsonRpcProvider, address: string) =>
from(provider.getBalance(address)),
);

// returns ERC20 on-chain balance for the contract address in the smallest unit
const getERC20Balance = curry(
(address: string, contract: ethers.Contract): Observable<ethers.BigNumber> => {
return from(contract.balanceOf(address)) as Observable<ethers.BigNumber>;
},
);

// This is the main function that has to be called before this package exposes more functions.
// Think of it as a constructor where we create the interal state of the module before
// we export more functionality to the consumer.
const getEthprovider = (host: string, port: number, name: string, chainId: number, seed: string) => {
// create the internal state
const provider = getProvider(host, port, name, chainId);
const signer = getSigner(provider, seed);
// because the functions below are curried we can only provide some of the arguments
const getERC20BalanceWithSigner = getERC20Balance(signer.address);
const getContractWithSigner = getContract(signer);
const onChainSendERC20WithSigner = onChainSendERC20(signer);
const onChainSendETHWithSigner = onChainSendETH(signer);
const getEthBalanceByAddressWithProvider = getEthBalanceByAddress(provider);
const onChainTransfer = (contractAddress: string, destinationAddress: string, units: string) => {
if (contractAddress === ethers.constants.AddressZero) {
return onChainSendETHWithSigner(destinationAddress, units);
} else {
const contract = getContractWithSigner(contractAddress);
return onChainSendERC20WithSigner(contract, destinationAddress, units);
}
};
// expose functionality to the consumer
return {
getEthBalance: () => from(signer.getBalance()),
getEthBalanceByAddress: getEthBalanceByAddressWithProvider,
getContract: getContractWithSigner,
getERC20Balance: getERC20BalanceWithSigner,
getERC20BalanceByAddress: getERC20Balance,
onChainTransfer,
};
};

type EthProvider = ReturnType<typeof getEthprovider>;

export { getEthprovider, EthProvider };
13 changes: 0 additions & 13 deletions lib/connextclient/types.ts
Expand Up @@ -121,15 +121,6 @@ export type ConnextChannelBalanceResponse = {
freeBalanceOnChain: string;
};

/**
* The response for ethprovider eth_getBalance call.
*/
export type ConnextBalanceResponse = {
id: number;
jsonrpc: string;
result: string;
};

export type GetBlockByNumberResponse = {
result: {
difficulty: string;
Expand Down Expand Up @@ -319,7 +310,3 @@ export type TransferReceivedEvent = {
units: bigint;
routingId: string;
};

export type OnchainTransferResponse = {
txhash: string;
};
14 changes: 6 additions & 8 deletions lib/db/seeds/simnet.ts
Expand Up @@ -33,20 +33,18 @@ const currencies = [
decimalPlaces: 18,
tokenAddress: '0x0000000000000000000000000000000000000000',
},
/*
{
id: 'USDT',
swapClient: SwapClientType.Connext,
decimalPlaces: 6,
tokenAddress: '0x6149AA6798a75450EFb0151204513ce197f626Ce',
tokenAddress: '0x5C533069289be37789086DB7A615ca5e963Fe5Bc',
},
{
id: 'DAI',
swapClient: SwapClientType.Connext,
decimalPlaces: 18,
tokenAddress: '0x69C3d485623bA3f382Fc0FB6756c4574d43C1618',
tokenAddress: '0x514a44ABFB7F02256eF658d31425385787498Fcd',
},
*/
/*
{
id: 'XUC',
Expand All @@ -58,15 +56,15 @@ const currencies = [
] as db.CurrencyAttributes[];

const pairs = [
// { baseCurrency: 'BTC', quoteCurrency: 'DAI' },
// { baseCurrency: 'BTC', quoteCurrency: 'USDT' },
{ baseCurrency: 'ETH', quoteCurrency: 'BTC' },
{ baseCurrency: 'LTC', quoteCurrency: 'BTC' },
{ baseCurrency: 'BTC', quoteCurrency: 'USDT' },
{ baseCurrency: 'USDT', quoteCurrency: 'DAI' },
// { baseCurrency: 'BTC', quoteCurrency: 'DAI' },
// { baseCurrency: 'ETH', quoteCurrency: 'DAI' },
// { baseCurrency: 'ETH', quoteCurrency: 'USDT' },
{ baseCurrency: 'LTC', quoteCurrency: 'BTC' },
// { baseCurrency: 'LTC', quoteCurrency: 'DAI' },
// { baseCurrency: 'LTC', quoteCurrency: 'USDT' },
// { baseCurrency: 'USDT', quoteCurrency: 'DAI' },
// { baseCurrency: 'XUC', quoteCurrency: 'BTC' },
// { baseCurrency: 'XUC', quoteCurrency: 'ETH' },
// { baseCurrency: 'XUC', quoteCurrency: 'DAI' },
Expand Down

0 comments on commit 62aad3d

Please sign in to comment.