diff --git a/modules/bitgo/src/v2/coinFactory.ts b/modules/bitgo/src/v2/coinFactory.ts index 7e1b2c0dcf..c249573309 100644 --- a/modules/bitgo/src/v2/coinFactory.ts +++ b/modules/bitgo/src/v2/coinFactory.ts @@ -75,6 +75,7 @@ import { Eos, EosToken, Erc20Token, + Erc721Token, Etc, Eth, Ethw, @@ -399,6 +400,12 @@ export function registerCoinConstructors(coinFactory: CoinFactory, coinMap: Coin } ); + Erc721Token.createTokenConstructors([...tokens.bitcoin.eth.nfts, ...tokens.testnet.eth.nfts]).forEach( + ({ name, coinConstructor }) => { + coinFactory.register(name, coinConstructor); + } + ); + StellarToken.createTokenConstructors([...tokens.bitcoin.xlm.tokens, ...tokens.testnet.xlm.tokens]).forEach( ({ name, coinConstructor }) => { coinFactory.register(name, coinConstructor); @@ -862,7 +869,11 @@ export function getTokenConstructor(tokenConfig: TokenConfig): CoinConstructor | switch (tokenConfig.coin) { case 'eth': case 'hteth': - return Erc20Token.createTokenConstructor(tokenConfig as Erc20TokenConfig); + if (tokenConfig.type.includes('erc721')) { + return Erc721Token.createTokenConstructor(tokenConfig as EthLikeTokenConfig); + } else { + return Erc20Token.createTokenConstructor(tokenConfig as Erc20TokenConfig); + } case 'xlm': case 'txlm': return StellarToken.createTokenConstructor(tokenConfig as StellarTokenConfig); diff --git a/modules/bitgo/src/v2/coins/index.ts b/modules/bitgo/src/v2/coins/index.ts index 11e072d6c6..8ea3bc6b6d 100644 --- a/modules/bitgo/src/v2/coins/index.ts +++ b/modules/bitgo/src/v2/coins/index.ts @@ -30,7 +30,7 @@ import { Doge, Tdoge } from '@bitgo/sdk-coin-doge'; import { Dot, Tdot } from '@bitgo/sdk-coin-dot'; import { Eos, EosToken, Teos } from '@bitgo/sdk-coin-eos'; import { Etc, Tetc } from '@bitgo/sdk-coin-etc'; -import { Erc20Token, Eth, Gteth, Hteth, Teth } from '@bitgo/sdk-coin-eth'; +import { Erc20Token, Erc721Token, Eth, Gteth, Hteth, Teth } from '@bitgo/sdk-coin-eth'; import { EvmCoin } from '@bitgo/sdk-coin-evm'; import { Flr, Tflr } from '@bitgo/sdk-coin-flr'; import { Ethw } from '@bitgo/sdk-coin-ethw'; @@ -104,7 +104,7 @@ export { Doge, Tdoge }; export { Dot, Tdot }; export { Bcha, Tbcha }; export { Eos, EosToken, Teos }; -export { Erc20Token, Eth, Gteth, Hteth, Teth }; +export { Erc20Token, Erc721Token, Eth, Gteth, Hteth, Teth }; export { Ethw }; export { EthLikeCoin, TethLikeCoin }; export { Etc, Tetc }; diff --git a/modules/bitgo/test/browser/browser.spec.ts b/modules/bitgo/test/browser/browser.spec.ts index f26d9d0d01..75d6fb1409 100644 --- a/modules/bitgo/test/browser/browser.spec.ts +++ b/modules/bitgo/test/browser/browser.spec.ts @@ -15,6 +15,7 @@ describe('Coins', () => { AbstractUtxoCoin: 1, AbstractLightningCoin: 1, Erc20Token: 1, + Erc721Token: 1, EthLikeCoin: 1, TethLikeCoin: 1, OfcToken: 1, diff --git a/modules/bitgo/test/v2/resources/amsTokenConfig.ts b/modules/bitgo/test/v2/resources/amsTokenConfig.ts index 7d13be7e2b..cd123981be 100644 --- a/modules/bitgo/test/v2/resources/amsTokenConfig.ts +++ b/modules/bitgo/test/v2/resources/amsTokenConfig.ts @@ -21,4 +21,25 @@ export const reducedAmsTokenConfig = { contractAddress: '0x89a959b9184b4f8c8633646d5dfd049d2ebc983a', }, ], + 'terc721:unsteth': [ + { + id: '49ff49ea-3355-4717-bbb0-5e8f5cae2201', + fullName: 'Test Lido: stETH Withdrawal NFT', + name: 'terc721:unsteth', + prefix: '', + suffix: '', + baseUnit: 'wei', + kind: 'crypto', + family: 'eth', + isToken: true, + additionalFeatures: [], + excludedFeatures: [], + decimalPlaces: 0, + asset: 'terc721:unsteth', + network: { + name: 'Hoodi', + }, + contractAddress: '0xfe56573178f1bcdf53f01a6e9977670dcbbd9186', + }, + ], }; diff --git a/modules/bitgo/test/v2/unit/ams/ams.ts b/modules/bitgo/test/v2/unit/ams/ams.ts index 1c7472250e..f78e11fce6 100644 --- a/modules/bitgo/test/v2/unit/ams/ams.ts +++ b/modules/bitgo/test/v2/unit/ams/ams.ts @@ -25,11 +25,11 @@ describe('Asset metadata service', () => { }); it('should not fetch coin from custom coin factory when useAms is false', async () => { - const bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: false } as any); - bitgo.initializeTestVars(); - bitgo.initCoinFactory(reducedAmsTokenConfig); + const bitgoNoAms = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: false } as any); + bitgoNoAms.initializeTestVars(); + bitgoNoAms.initCoinFactory(reducedAmsTokenConfig); (() => { - bitgo.coin('hteth:faketoken'); + bitgoNoAms.coin('hteth:faketoken'); }).should.throw( 'Coin or token type hteth:faketoken not supported or not compiled. Please be sure that you are using the latest version of BitGoJS. If using @bitgo/sdk-api, please confirm you have registered hteth:faketoken first.' ); @@ -37,8 +37,7 @@ describe('Asset metadata service', () => { it('should be able to register a token in the coin factory', () => { const tokenName = 'hteth:faketoken'; - const amsToken = reducedAmsTokenConfig[tokenName][0]; - bitgo.registerToken(amsToken); + bitgo.registerToken(tokenName); const coin = bitgo.coin(tokenName); should.exist(coin); coin.type.should.equal(tokenName); @@ -47,6 +46,53 @@ describe('Asset metadata service', () => { coin.tokenContractAddress.should.equal('0x89a959b9184b4f8c8633646d5dfd049d2ebc983a'); }); + describe('ERC721 NFTs', () => { + it('should create a custom coin factory from ams response', async () => { + bitgo.initCoinFactory(reducedAmsTokenConfig); + const coin = bitgo.coin('erc721:unsteth'); + should.exist(coin); + coin.type.should.equal('erc721:unsteth'); + coin.name.should.equal('Lido: stETH Withdrawal NFT'); + coin.decimalPlaces.should.equal(0); + coin.tokenContractAddress.should.equal('0x889edc2edab5f40e902b864ad4d7ade8e412f9b1'); + }); + + it('should be able to register an nft in the coin factory', () => { + const nftName = 'terc721:unsteth'; + bitgo.registerToken(nftName); + const coin = bitgo.coin(nftName); + should.exist(coin); + coin.type.should.equal(nftName); + coin.name.should.equal('Test Lido: stETH Withdrawal NFT'); + coin.decimalPlaces.should.equal(0); + coin.tokenContractAddress.should.equal('0xfe56573178f1bcdf53f01a6e9977670dcbbd9186'); + }); + + it('should fetch all assets from AMS and initialize the coin factory', async () => { + const bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: true } as BitGoOptions); + bitgo.initializeTestVars(); + + // Setup nocks + nock(microservicesUri).get('/api/v1/assets/list/testnet').reply(200, reducedAmsTokenConfig); + + await bitgo.registerAllTokens(); + const coin = bitgo.coin('terc721:unsteth'); + should.exist(coin); + }); + + it('should fetch nft from default coin factory when useAms is false', () => { + const bitgoNoAms = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: false } as BitGoOptions); + bitgoNoAms.initializeTestVars(); + bitgoNoAms.initCoinFactory(reducedAmsTokenConfig); + const coin: any = bitgoNoAms.coin('erc721:unsteth'); + should.exist(coin); + coin.type.should.equal('erc721:unsteth'); + coin.name.should.equal('Lido: stETH Withdrawal NFT'); + coin.decimalPlaces.should.equal(0); + coin.tokenContractAddress.should.equal('0x889edc2edab5f40e902b864ad4d7ade8e412f9b1'); + }); + }); + it('should fetch all assets from AMS and initialize the coin factory', async () => { const bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: true } as BitGoOptions); bitgo.initializeTestVars(); @@ -61,10 +107,10 @@ describe('Asset metadata service', () => { describe('registerToken', () => { it('should throw an error when useAms is false', async () => { - const bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: false } as BitGoOptions); - bitgo.initializeTestVars(); + const bitgoNoAms = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: false } as BitGoOptions); + bitgoNoAms.initializeTestVars(); - await bitgo + await bitgoNoAms .registerToken('hteth:faketoken') .should.be.rejectedWith('registerToken is only supported when useAms is set to true'); }); diff --git a/modules/sdk-coin-eth/src/erc721Token.ts b/modules/sdk-coin-eth/src/erc721Token.ts new file mode 100644 index 0000000000..96141944af --- /dev/null +++ b/modules/sdk-coin-eth/src/erc721Token.ts @@ -0,0 +1,382 @@ +/** + * @prettier + */ +import { + BitGoBase, + CoinConstructor, + Util, + checkKrsProvider, + getIsKrsRecovery, + getIsUnsignedSweep, + MPCAlgorithm, + NamedCoinConstructor, +} from '@bitgo/sdk-core'; +import { BigNumber } from 'bignumber.js'; + +import { coins, EthLikeTokenConfig, tokens } from '@bitgo/statics'; +import { CoinNames } from '@bitgo/abstract-eth'; +import { bip32 } from '@bitgo/secp256k1'; +import * as _ from 'lodash'; + +import { Eth, RecoverOptions, RecoveryInfo, optionalDeps, TransactionPrebuild } from './eth'; +import { TransactionBuilder } from './lib'; + +export { EthLikeTokenConfig }; +export class Erc721Token extends Eth { + public readonly tokenConfig: EthLikeTokenConfig; + protected readonly sendMethodName: 'sendMultiSig' | 'sendMultiSigToken'; + static coinNames: CoinNames = { + Mainnet: 'eth', + Testnet: 'hteth', + }; + + constructor(bitgo: BitGoBase, tokenConfig: EthLikeTokenConfig) { + const staticsCoin = coins.get(Erc721Token.coinNames[tokenConfig.network]); + super(bitgo, staticsCoin); + this.tokenConfig = tokenConfig; + this.sendMethodName = 'sendMultiSigToken'; + } + + static createTokenConstructor(config: EthLikeTokenConfig): CoinConstructor { + return (bitgo: BitGoBase) => new Erc721Token(bitgo, config); + } + + static createTokenConstructors( + tokenConfigs: EthLikeTokenConfig[] = [...tokens.bitcoin.eth.nfts, ...tokens.testnet.eth.nfts] + ): NamedCoinConstructor[] { + const tokensCtors: NamedCoinConstructor[] = []; + for (const token of tokenConfigs) { + const tokenConstructor = Erc721Token.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; + } + + get decimalPlaces() { + return this.tokenConfig.decimalPlaces; + } + + getChain() { + return this.tokenConfig.type; + } + + getFullName() { + return 'ERC721 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; + } + + /** @inheritDoc */ + getMPCAlgorithm(): MPCAlgorithm { + return 'ecdsa'; + } + + protected getTransactionBuilder(): TransactionBuilder { + return new TransactionBuilder(coins.get(this.getBaseChain())); + } + + /** + * Builds a token recovery transaction without BitGo + * @param params + * @param params.userKey {String} [encrypted] xprv + * @param params.backupKey {String} [encrypted] xprv or xpub if the xprv is held by a KRS providers + * @param params.walletPassphrase {String} used to decrypt userKey and backupKey + * @param params.walletContractAddress {String} the ETH address of the wallet contract + * @param params.recoveryDestination {String} target address to send recovered funds to + * @param params.krsProvider {String} necessary if backup key is held by KRS + */ + async recover(params: RecoverOptions): Promise { + if (_.isUndefined(params.userKey)) { + throw new Error('missing userKey'); + } + + if (_.isUndefined(params.backupKey)) { + throw new Error('missing backupKey'); + } + + if (_.isUndefined(params.walletPassphrase) && !params.userKey.startsWith('xpub')) { + throw new Error('missing wallet passphrase'); + } + + if (_.isUndefined(params.walletContractAddress) || !this.isValidAddress(params.walletContractAddress)) { + throw new Error('invalid walletContractAddress'); + } + + if (_.isUndefined(params.recoveryDestination) || !this.isValidAddress(params.recoveryDestination)) { + throw new Error('invalid recoveryDestination'); + } + + const isKrsRecovery = getIsKrsRecovery(params); + const isUnsignedSweep = getIsUnsignedSweep(params); + + if (isKrsRecovery) { + checkKrsProvider(this, params.krsProvider, { checkCoinFamilySupport: false }); + } + + // Clean up whitespace from entered values + const userKey = params.userKey.replace(/\s/g, ''); + const backupKey = params.backupKey.replace(/\s/g, ''); + + // Set new eth tx fees (default to using platform values if none are provided) + const gasPrice = params.eip1559 + ? new optionalDeps.ethUtil.BN(params.eip1559.maxFeePerGas) + : new optionalDeps.ethUtil.BN(this.setGasPrice(params.gasPrice)); + const gasLimit = new optionalDeps.ethUtil.BN(this.setGasLimit(params.gasLimit)); + + // Decrypt private keys from KeyCard values + let userPrv; + if (!userKey.startsWith('xpub') && !userKey.startsWith('xprv')) { + try { + userPrv = this.bitgo.decrypt({ + input: userKey, + password: params.walletPassphrase, + }); + } catch (e) { + throw new Error(`Error decrypting user keychain: ${e.message}`); + } + } + + let backupKeyAddress; + let backupSigningKey; + + if (isKrsRecovery || isUnsignedSweep) { + const backupHDNode = bip32.fromBase58(backupKey); + backupSigningKey = backupHDNode.publicKey; + backupKeyAddress = `0x${optionalDeps.ethUtil.publicToAddress(backupSigningKey, true).toString('hex')}`; + } else { + let backupPrv; + + try { + backupPrv = this.bitgo.decrypt({ + input: backupKey, + password: params.walletPassphrase, + }); + } catch (e) { + throw new Error(`Error decrypting backup keychain: ${e.message}`); + } + + const backupHDNode = bip32.fromBase58(backupPrv); + backupSigningKey = backupHDNode.privateKey; + backupKeyAddress = `0x${optionalDeps.ethUtil.privateToAddress(backupSigningKey).toString('hex')}`; + } + + // Get nonce for backup key (should be 0) + let backupKeyNonce = 0; + + const result = await this.recoveryBlockchainExplorerQuery( + { + chainid: this.getChainId().toString(), + module: 'account', + action: 'txlist', + address: backupKeyAddress, + }, + params.apiKey + ); + const backupKeyTxList = result.result; + if (backupKeyTxList.length > 0) { + // Calculate last nonce used + const outgoingTxs = backupKeyTxList.filter((tx) => tx.from === backupKeyAddress); + backupKeyNonce = outgoingTxs.length; + } + + // get balance of backup key and make sure we can afford gas + const backupKeyBalance = await this.queryAddressBalance(backupKeyAddress, params.apiKey); + + if (backupKeyBalance.lt(gasPrice.mul(gasLimit))) { + throw new Error( + `Backup key address ${backupKeyAddress} has balance ${backupKeyBalance.toString( + 10 + )}. This address must have a balance of at least 0.01 ETH to perform recoveries` + ); + } + + // get token balance of wallet + const txAmount = await this.queryAddressTokenBalance( + this.tokenContractAddress, + params.walletContractAddress, + params.apiKey + ); + if (new BigNumber(txAmount).isLessThanOrEqualTo(0)) { + throw new Error('Wallet does not have enough funds to recover'); + } + + // build recipients object + const recipients = [ + { + address: params.recoveryDestination, + amount: txAmount.toString(10), + }, + ]; + + // Get sequence ID using contract call + const sequenceId = await this.querySequenceId(params.walletContractAddress, params.apiKey); + + let operationHash, signature; + if (!isUnsignedSweep) { + // Get operation hash and sign it + operationHash = this.getOperationSha3ForExecuteAndConfirm(recipients, this.getDefaultExpireTime(), sequenceId); + signature = Util.ethSignMsgHash(operationHash, Util.xprvToEthPrivateKey(userPrv)); + + try { + Util.ecRecoverEthAddress(operationHash, signature); + } catch (e) { + throw new Error('Invalid signature'); + } + } + + const txInfo = { + recipient: recipients[0], + expireTime: this.getDefaultExpireTime(), + contractSequenceId: sequenceId, + signature: signature, + gasLimit: gasLimit.toString(10), + tokenContractAddress: this.tokenContractAddress, + }; + + // calculate send data + const sendMethodArgs = this.getSendMethodArgs(txInfo); + const methodSignature = optionalDeps.ethAbi.methodID(this.sendMethodName, _.map(sendMethodArgs, 'type')); + const encodedArgs = optionalDeps.ethAbi.rawEncode(_.map(sendMethodArgs, 'type'), _.map(sendMethodArgs, 'value')); + const sendData = Buffer.concat([methodSignature, encodedArgs]); + + let tx = Eth.buildTransaction({ + to: params.walletContractAddress, + nonce: backupKeyNonce, + value: 0, + gasPrice: gasPrice, + gasLimit: gasLimit, + data: sendData, + eip1559: params.eip1559, + replayProtectionOptions: params.replayProtectionOptions, + }); + + if (isUnsignedSweep) { + return this.formatForOfflineVault( + txInfo, + tx, + userKey, + backupKey, + gasPrice, + gasLimit, + params.eip1559, + params.replayProtectionOptions, + params.apiKey + ) as any; + } + + if (!isKrsRecovery) { + tx = tx.sign(backupSigningKey); + } + + const signedTx: RecoveryInfo = { + id: optionalDeps.ethUtil.bufferToHex(tx.hash()), + tx: tx.serialize().toString('hex'), + }; + + if (isKrsRecovery) { + signedTx.backupKey = backupKey; + signedTx.coin = 'erc721'; + } + + return signedTx; + } + + getOperation(recipient, expireTime, contractSequenceId) { + return [ + ['string', 'address', 'uint', 'address', 'uint', 'uint'], + [ + 'ERC721', + 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/index.ts b/modules/sdk-coin-eth/src/index.ts index fef394647a..6e77f9893d 100644 --- a/modules/sdk-coin-eth/src/index.ts +++ b/modules/sdk-coin-eth/src/index.ts @@ -1,4 +1,5 @@ export * from './erc20Token'; +export * from './erc721Token'; export * from './eth'; export * from './gteth'; export * from './hteth'; diff --git a/modules/statics/src/tokenConfig.ts b/modules/statics/src/tokenConfig.ts index aef1dab4e1..1cc51ecd11 100644 --- a/modules/statics/src/tokenConfig.ts +++ b/modules/statics/src/tokenConfig.ts @@ -162,6 +162,7 @@ export interface Tokens { bitcoin: { eth: { tokens: Erc20TokenConfig[]; + nfts: EthLikeTokenConfig[]; }; xlm: { tokens: StellarTokenConfig[]; @@ -252,6 +253,7 @@ export interface Tokens { testnet: { eth: { tokens: Erc20TokenConfig[]; + nfts: EthLikeTokenConfig[]; }; xlm: { tokens: StellarTokenConfig[]; @@ -583,6 +585,24 @@ const getFormattedSoneiumTokens = (customCoinMap = coins) => return acc; }, []); +function getErc721TokenConfig(coin: Erc721Coin): EthLikeTokenConfig { + return { + type: coin.name, + coin: coin.network.type === NetworkType.MAINNET ? 'eth' : 'hteth', + network: coin.network.type === NetworkType.MAINNET ? 'Mainnet' : 'Testnet', + name: coin.fullName, + tokenContractAddress: coin.contractAddress.toString().toLowerCase(), + decimalPlaces: coin.decimalPlaces, + }; +} +const getFormattedErc721Tokens = (customCoinMap = coins) => + customCoinMap.reduce((acc: EthLikeTokenConfig[], coin) => { + if (coin instanceof Erc721Coin && coin.family === CoinFamily.ETH) { + acc.push(getErc721TokenConfig(coin)); + } + return acc; + }, []); + function getArbethTokenConfig(coin: ArbethERC20Token): EthLikeTokenConfig { return { type: coin.name, @@ -981,6 +1001,7 @@ export const getFormattedTokens = (coinMap = coins): Tokens => { bitcoin: { eth: { tokens: getFormattedErc20Tokens(coinMap).filter((token) => token.network === 'Mainnet'), + nfts: getFormattedErc721Tokens(coinMap).filter((token) => token.network === 'Mainnet'), }, xlm: { tokens: getFormattedStellarTokens(coinMap).filter((token) => token.network === 'Mainnet'), @@ -1075,6 +1096,7 @@ export const getFormattedTokens = (coinMap = coins): Tokens => { testnet: { eth: { tokens: getFormattedErc20Tokens(coinMap).filter((token) => token.network === 'Testnet'), + nfts: getFormattedErc721Tokens(coinMap).filter((token) => token.network === 'Testnet'), }, xlm: { tokens: getFormattedStellarTokens(coinMap).filter((token) => token.network === 'Testnet'), @@ -1234,6 +1256,8 @@ export function getFormattedTokenConfigForCoin(coin: Readonly): TokenC return getPolygonTokenConfig(coin); } else if ((coin instanceof Erc721Coin || coin instanceof Erc1155Coin) && coin.family === CoinFamily.SONEIUM) { return getSoneiumTokenConfig(coin); + } else if (coin instanceof Erc721Coin && coin.family === CoinFamily.ETH) { + return getErc721TokenConfig(coin); } else if (coin instanceof ArbethERC20Token) { return getArbethTokenConfig(coin); } else if (coin instanceof OpethERC20Token) {