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
merged 31 commits 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
b930274
wip: onChainSendERC20
erkarl Dec 15, 2020
a582c41
extract getProvider and getSigner from onChainSendERC20
erkarl Dec 15, 2020
22504bd
delete old comments/code
erkarl Dec 15, 2020
c3d9c0b
extract getContract
erkarl Dec 15, 2020
c038c0a
extract getSigner
erkarl Dec 15, 2020
c8beac5
fix units after rebase
erkarl Dec 15, 2020
69cf028
curry all ethprovider exports
erkarl Dec 15, 2020
284b350
export only getEthprovider
erkarl Dec 15, 2020
ed94956
remove curry from getProvider/getSigner
erkarl Dec 16, 2020
241a1e4
getEthBalance
erkarl Dec 16, 2020
5533309
add getEthBalance
erkarl Dec 16, 2020
de38181
getERC20Balance
erkarl Dec 16, 2020
572e466
getERC20BalanceByAddress, getEthBalanceByAddress
erkarl Dec 16, 2020
f827ee6
monitor deposits for all token addresses
erkarl Dec 16, 2020
3a424a2
remove unnecessary defer
erkarl Dec 16, 2020
ec37094
add comment for getBalanceForAddress
erkarl Dec 16, 2020
d54d925
assert ethProvider
erkarl Dec 16, 2020
b10ee70
add comments
erkarl Dec 16, 2020
4c2b311
fix duplicate tx broadcast
erkarl Dec 17, 2020
f93811e
fix ETH signer transfer
erkarl Dec 17, 2020
7273190
only expose onChainTransfer
erkarl Dec 17, 2020
f1f7216
test(jest): fix config for watch mode
erkarl Dec 17, 2020
104339a
ramda as dev dependency
erkarl Dec 17, 2020
e5e9657
fix ConnextClient.withdraw tests
erkarl Dec 17, 2020
5b54165
modify comment
erkarl Dec 17, 2020
e7dc169
enable BTC/USDT pair for simnet
erkarl Dec 17, 2020
59e8e78
remove blank link
erkarl Dec 17, 2020
26de498
remove object-curly-newline rule
erkarl Dec 17, 2020
f1ef669
fix test:jest:watch script
erkarl Dec 17, 2020
0e40c48
enable USDT/DAI pair
erkarl Dec 19, 2020
ccaaa58
add comments to ethprovider
erkarl Dec 22, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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'
erkarl marked this conversation as resolved.
Show resolved Hide resolved
}
},
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) => {
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
Collaborator 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
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