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): multi currency support #2043

Merged
31 commits merged into from
Dec 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module.exports = {
preset: 'ts-jest',
globals: {
'ts-jest': {
tsConfig: '<rootDir>/tsconfig.json'
tsconfig: '<rootDir>/tsconfig.json'
This conversation was marked as resolved.
Show resolved Hide resolved
}
},
testEnvironment: 'node',
Expand Down
116 changes: 65 additions & 51 deletions lib/connextclient/ConnextClient.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The smallest of nits, I'm seeing mixed casing of Ethprovider and EthProvider. I don't know which one is "correct" but might make sense to stick to one.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Will address this in a follow-up PR.

// 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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading