diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 80d3852e4f..bbf632da39 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -271,7 +271,7 @@ interface HopTransactionBuildOptions { walletPassphrase: string; } -interface BuildOptions { +export interface BuildOptions { hop?: boolean; wallet?: Wallet; recipients?: Recipient[]; @@ -361,7 +361,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { static hopTransactionSalt = 'bitgoHopAddressRequestSalt'; protected readonly sendMethodName: 'sendMultiSig' | 'sendMultiSigToken'; - protected readonly _staticsCoin: Readonly; + readonly staticsCoin: Readonly; protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { super(bitgo, staticsCoin); @@ -370,12 +370,12 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { throw new Error('missing required constructor parameter staticsCoin'); } - this._staticsCoin = staticsCoin; + this.staticsCoin = staticsCoin; this.sendMethodName = 'sendMultiSig'; } getNetwork(): EthLikeNetwork | undefined { - return this._staticsCoin?.network as EthLikeNetwork; + return this.staticsCoin?.network as EthLikeNetwork; } valuelessTransferAllowed(): boolean { diff --git a/modules/sdk-coin-eth/src/erc20Token.ts b/modules/sdk-coin-eth/src/erc20Token.ts index 9b80199386..b3d0a089c8 100644 --- a/modules/sdk-coin-eth/src/erc20Token.ts +++ b/modules/sdk-coin-eth/src/erc20Token.ts @@ -11,36 +11,96 @@ import { MPCAlgorithm, NamedCoinConstructor, } from '@bitgo/sdk-core'; -import { EthLikeTokenConfig, Erc20TokenConfig } from '@bitgo/statics'; +import { coins, EthLikeTokenConfig, Erc20TokenConfig, tokens } from '@bitgo/statics'; +import { CoinNames } from '@bitgo/abstract-eth'; import { bip32 } from '@bitgo/utxo-lib'; import * as _ from 'lodash'; -import { CoinNames, EthLikeToken, RecoverOptions, RecoveryInfo, optionalDeps } from '@bitgo/abstract-eth'; -import { Eth } from './eth'; +import { Eth, RecoverOptions, RecoveryInfo, optionalDeps, TransactionPrebuild } from './eth'; +import { TransactionBuilder } from './lib'; export { Erc20TokenConfig }; -export class Erc20Token extends EthLikeToken { +export class Erc20Token extends Eth { public readonly tokenConfig: EthLikeTokenConfig; + protected readonly sendMethodName: 'sendMultiSig' | 'sendMultiSigToken'; static coinNames: CoinNames = { Mainnet: 'eth', Testnet: 'gteth', }; + constructor(bitgo: BitGoBase, tokenConfig: Erc20TokenConfig) { - super(bitgo, tokenConfig, Erc20Token.coinNames); + const staticsCoin = coins.get(Erc20Token.coinNames[tokenConfig.network]); + super(bitgo, staticsCoin); + this.tokenConfig = tokenConfig; + this.sendMethodName = 'sendMultiSigToken'; } - static createTokenConstructor(config: EthLikeTokenConfig): CoinConstructor { - return super.createTokenConstructor(config, Erc20Token.coinNames); + static createTokenConstructor(config: Erc20TokenConfig): CoinConstructor { + return (bitgo: BitGoBase) => new Erc20Token(bitgo, config); } static createTokenConstructors(): NamedCoinConstructor[] { - return super.createTokenConstructors(Erc20Token.coinNames); + const tokensCtors: NamedCoinConstructor[] = []; + for (const token of [...tokens.bitcoin.eth.tokens, ...tokens.testnet.eth.tokens]) { + const tokenConstructor = Erc20Token.createTokenConstructor(token); + tokensCtors.push({ name: token.type, coinConstructor: tokenConstructor }); + tokensCtors.push({ name: token.tokenContractAddress, coinConstructor: tokenConstructor }); + } + return tokensCtors; + } + + get type() { + return this.tokenConfig.type; + } + + get name() { + return this.tokenConfig.name; + } + + get coin() { + return this.tokenConfig.coin; + } + + get network() { + return this.tokenConfig.network; + } + + get tokenContractAddress() { + return this.tokenConfig.tokenContractAddress; } - getFullName(): string { + get decimalPlaces() { + return this.tokenConfig.decimalPlaces; + } + + getChain() { + return this.tokenConfig.type; + } + + getFullName() { return 'ERC20 Token'; } + getBaseFactor() { + return Math.pow(10, this.tokenConfig.decimalPlaces); + } + + /** + * Flag for sending value of 0 + * @returns {boolean} True if okay to send 0 value, false otherwise + */ + valuelessTransferAllowed() { + return false; + } + + /** + * Flag for sending data along with transactions + * @returns {boolean} True if okay to send tx data (ETH), false otherwise + */ + transactionDataAllowed() { + return false; + } + /** @inheritDoc */ supportsTss(): boolean { return true; @@ -51,6 +111,10 @@ export class Erc20Token extends EthLikeToken { return 'ecdsa'; } + protected getTransactionBuilder(): TransactionBuilder { + return new TransactionBuilder(coins.get(this.getBaseChain())); + } + /** * Builds a token recovery transaction without BitGo * @param params @@ -233,4 +297,59 @@ export class Erc20Token extends EthLikeToken { return signedTx; } + + getOperation(recipient, expireTime, contractSequenceId) { + return [ + ['string', 'address', 'uint', 'address', 'uint', 'uint'], + [ + 'ERC20', + new optionalDeps.ethUtil.BN(optionalDeps.ethUtil.stripHexPrefix(recipient.address), 16), + recipient.amount, + new optionalDeps.ethUtil.BN(optionalDeps.ethUtil.stripHexPrefix(this.tokenContractAddress), 16), + expireTime, + contractSequenceId, + ], + ]; + } + + getSendMethodArgs(txInfo) { + // Method signature is + // sendMultiSigToken(address toAddress, uint value, address tokenContractAddress, uint expireTime, uint sequenceId, bytes signature) + return [ + { + name: 'toAddress', + type: 'address', + value: txInfo.recipient.address, + }, + { + name: 'value', + type: 'uint', + value: txInfo.recipient.amount, + }, + { + name: 'tokenContractAddress', + type: 'address', + value: this.tokenContractAddress, + }, + { + name: 'expireTime', + type: 'uint', + value: txInfo.expireTime, + }, + { + name: 'sequenceId', + type: 'uint', + value: txInfo.contractSequenceId, + }, + { + name: 'signature', + type: 'bytes', + value: optionalDeps.ethUtil.toBuffer(optionalDeps.ethUtil.addHexPrefix(txInfo.signature)), + }, + ]; + } + + verifyCoin(txPrebuild: TransactionPrebuild): boolean { + return txPrebuild.coin === this.tokenConfig.coin && txPrebuild.token === this.tokenConfig.type; + } } diff --git a/modules/sdk-coin-eth/src/eth.ts b/modules/sdk-coin-eth/src/eth.ts index fcd7956276..083e74c02a 100644 --- a/modules/sdk-coin-eth/src/eth.ts +++ b/modules/sdk-coin-eth/src/eth.ts @@ -20,6 +20,7 @@ import { } from '@bitgo/sdk-core'; import { AbstractEthLikeNewCoins, + BuildOptions, BuildTransactionParams, EIP1559, RecoveryInfo, @@ -37,8 +38,17 @@ import type * as EthTxLib from '@ethereumjs/tx'; import { SignTypedDataVersion, TypedDataUtils, TypedMessage } from '@metamask/eth-sig-util'; import { TransactionBuilder } from './lib/transactionBuilder'; +import { Erc20Token } from './erc20Token'; -export { Recipient, HalfSignedTransaction, FullySignedTransaction, TransactionPrebuild, optionalDeps }; +export { + Recipient, + HalfSignedTransaction, + FullySignedTransaction, + TransactionPrebuild, + optionalDeps, + RecoverOptions, + RecoveryInfo, +}; export class Eth extends AbstractEthLikeNewCoins { protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { @@ -469,6 +479,36 @@ export class Eth extends AbstractEthLikeNewCoins { return { halfSigned: txParams }; } + /** + * Modify prebuild before sending it to the server. Add things like hop transaction params + * @param buildParams The whitelisted parameters for this prebuild + * @param buildParams.hop True if this should prebuild a hop tx, else false + * @param buildParams.recipients The recipients array of this transaction + * @param buildParams.wallet The wallet sending this tx + * @param buildParams.walletPassphrase the passphrase for this wallet + */ + async getExtraPrebuildParams(buildParams: BuildOptions): Promise { + if ( + !_.isUndefined(buildParams.hop) && + buildParams.hop && + !_.isUndefined(buildParams.wallet) && + !_.isUndefined(buildParams.recipients) && + !_.isUndefined(buildParams.walletPassphrase) + ) { + if (this instanceof Erc20Token) { + throw new Error( + `Hop transactions are not enabled for ERC-20 tokens, nor are they necessary. Please remove the 'hop' parameter and try again.` + ); + } + return (await this.createHopTransactionParams({ + wallet: buildParams.wallet, + recipients: buildParams.recipients, + walletPassphrase: buildParams.walletPassphrase, + })) as any; + } + return {}; + } + /** * Create a new transaction builder for the current chain * @return a new transaction builder diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index 24f4cb85bd..84ac693e27 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -11,7 +11,7 @@ import { Wallet, } from '@bitgo/sdk-core'; import { BitGoAPI } from '@bitgo/sdk-api'; -import { Erc20Token, Gteth } from '../../src'; +import { Erc20Token, Teth } from '../../src'; import { EthereumNetwork } from '@bitgo/statics'; import assert from 'assert'; @@ -50,7 +50,7 @@ describe('ETH:', function () { Erc20Token.createTokenConstructors().forEach(({ name, coinConstructor }) => { bitgo.safeRegister(name, coinConstructor); }); - bitgo.safeRegister('gteth', Gteth.createInstance); + bitgo.safeRegister('teth', Teth.createInstance); common.Environments[env].hsmXpub = bitgoXpub; bitgo.initializeTestVars(); }); @@ -61,7 +61,7 @@ describe('ETH:', function () { describe('EIP1559', function () { it('should sign a transaction with EIP1559 fee params', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const userKeychain = { prv: 'xprv9s21ZrQH143K3hekyNj7TciR4XNYe1kMj68W2ipjJGNHETWP7o42AjDnSPgKhdZ4x8NBAvaL72RrXjuXNdmkMqLERZza73oYugGtbLFXG8g', @@ -99,7 +99,7 @@ describe('ETH:', function () { describe('Transaction Verification', function () { it('should verify a normal txPrebuild from the bitgo server that matches the client txParams', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -114,7 +114,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: false, - coin: 'gteth', + coin: 'teth', wallet: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', }; @@ -131,7 +131,8 @@ describe('ETH:', function () { }); it('should verify a batch txPrebuild from the bitgo server that matches the client txParams', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; + console.log('coin is this', coin); const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -151,7 +152,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: true, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', }; @@ -168,7 +169,7 @@ describe('ETH:', function () { }); it('should verify ENS address resolution changing recipient address in client txParams', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -183,7 +184,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: false, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', }; @@ -200,7 +201,7 @@ describe('ETH:', function () { }); it('should verify a hop txPrebuild from the bitgo server that matches the client txParams', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -216,7 +217,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: false, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', hopTransaction: { @@ -246,7 +247,7 @@ describe('ETH:', function () { }); it('should reject when client txParams are missing', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = null; @@ -257,7 +258,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: false, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', }; @@ -270,7 +271,7 @@ describe('ETH:', function () { }); it('should reject txPrebuild that is both batch and hop', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -289,7 +290,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: true, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', hopTransaction: { @@ -315,7 +316,7 @@ describe('ETH:', function () { }); it('should reject a txPrebuild with more than one recipient', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -336,7 +337,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: true, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', }; @@ -349,7 +350,7 @@ describe('ETH:', function () { }); it('should reject a hop txPrebuild that does not send to its hop address', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -365,7 +366,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: false, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', hopTransaction: { @@ -391,7 +392,7 @@ describe('ETH:', function () { }); it('should reject a batch txPrebuild from the bitgo server with the wrong total amount', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -411,7 +412,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: true, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', }; @@ -426,7 +427,7 @@ describe('ETH:', function () { }); it('should reject a batch txPrebuild from the bitgo server that does not send to the batcher contract address', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -444,7 +445,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: true, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', }; @@ -457,7 +458,7 @@ describe('ETH:', function () { }); it('should reject a normal txPrebuild from the bitgo server with the wrong amount', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -472,7 +473,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: false, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', }; @@ -487,7 +488,7 @@ describe('ETH:', function () { }); it('should reject a normal txPrebuild from the bitgo server with the wrong recipient', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -502,7 +503,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: false, - coin: 'gteth', + coin: 'teth', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', }; @@ -532,7 +533,7 @@ describe('ETH:', function () { gasPrice: 20000000000, gasLimit: 500000, isBatch: false, - coin: 'gteth', + coin: 'teth', token: 'test', walletId: 'fakeWalletId', walletContractAddress: 'fakeWalletContractAddress', @@ -550,7 +551,7 @@ describe('ETH:', function () { }); it('should reject a txPrebuild from the bitgo server with the wrong coin', async function () { - const coin = bitgo.coin('tgeth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); const txParams = { @@ -580,13 +581,13 @@ describe('ETH:', function () { describe('Address Verification', function () { it('should verify an address generated using forwarder version 0', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const params = { id: '6127bff4ecd84c0006cd9a0e5ccdc36f', chain: 0, index: 3174, - coin: 'gteth', + coin: 'teth', lastNonce: 0, wallet: '598f606cd8fc24710d2ebadb1d9459bb', baseAddress: '0xdf07117705a9f8dc4c2a78de66b7f1797dba9d4e', @@ -606,14 +607,14 @@ describe('ETH:', function () { }); it('should verify an address generated using forwarder version 1', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const params = { id: '61250217c8c02b000654b15e7af6f618', address: '0xb0b56eeae1b283918caca02a14ada2df17a98e6d', chain: 0, index: 3162, - coin: 'gteth', + coin: 'teth', lastNonce: 0, wallet: '598f606cd8fc24710d2ebadb1d9459bb', baseAddress: '0xdf07117705a9f8dc4c2a78de66b7f1797dba9d4e', @@ -634,14 +635,14 @@ describe('ETH:', function () { }); it('should reject address verification if coinSpecific field is not an object', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const params = { id: '61250217c8c02b000654b15e7af6f618', address: '0xb0b56eeae1b283918caca02a14ada2df17a98e6d', chain: 0, index: 3162, - coin: 'gteth', + coin: 'teth', lastNonce: 0, wallet: '598f606cd8fc24710d2ebadb1d9459bb', baseAddress: '0xdf07117705a9f8dc4c2a78de66b7f1797dba9d4e', @@ -651,14 +652,14 @@ describe('ETH:', function () { }); it('should reject address verification when an actual address is different from expected address', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const params = { id: '61250217c8c02b000654b15e7af6f618', address: '0x28904591f735994f050804fda3b61b813b16e04c', chain: 0, index: 3162, - coin: 'gteth', + coin: 'teth', lastNonce: 0, baseAddress: '0xdf07117705a9f8dc4c2a78de66b7f1797dba9d4e', wallet: '598f606cd8fc24710d2ebadb1d9459bb', @@ -678,14 +679,14 @@ describe('ETH:', function () { }); it('should reject address verification if the derived address is in invalid format', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const params = { id: '61250217c8c02b000654b15e7af6f618', address: '0xe0b56eeae1b283918caca02a14ada2df17a98bvf', chain: 0, index: 3162, - coin: 'gteth', + coin: 'teth', lastNonce: 0, wallet: '598f606cd8fc24710d2ebadb1d9459bb', baseAddress: '0xdf07117705a9f8dc4c2a78de66b7f1797dba9d4e', @@ -705,14 +706,14 @@ describe('ETH:', function () { }); it('should reject address verification if base address is undefined', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const params = { id: '61250217c8c02b000654b15e7af6f618', address: '0xb0b56eeae1b283918caca02a14ada2df17a98e6d', chain: 0, index: 3162, - coin: 'gteth', + coin: 'teth', lastNonce: 0, wallet: '598f606cd8fc24710d2ebadb1d9459bb', coinSpecific: { @@ -731,14 +732,14 @@ describe('ETH:', function () { }); it('should reject address verification if base address is in invalid format', async function () { - const coin = bitgo.coin('gteth') as Gteth; + const coin = bitgo.coin('teth') as Teth; const params = { id: '61250217c8c02b000654b15e7af6f618', address: '0xb0b56eeae1b283918caca02a14ada2df17a98e6d', chain: 0, index: 3162, - coin: 'gteth', + coin: 'teth', lastNonce: 0, wallet: '598f606cd8fc24710d2ebadb1d9459bb', baseAddress: '0xe0b56eeae1b283918caca02a14ada2df17a98bvf',