diff --git a/modules/bitgo/src/v2/coinFactory.ts b/modules/bitgo/src/v2/coinFactory.ts index 5b1a4e2555..56e9d6cf51 100644 --- a/modules/bitgo/src/v2/coinFactory.ts +++ b/modules/bitgo/src/v2/coinFactory.ts @@ -9,7 +9,8 @@ import { Near, TNear, Nep141Token } from '@bitgo/sdk-coin-near'; import { SolToken } from '@bitgo/sdk-coin-sol'; import { TrxToken } from '@bitgo/sdk-coin-trx'; import { CoinFactory, CoinConstructor } from '@bitgo/sdk-core'; -import { EthLikeErc20Token } from '@bitgo/sdk-coin-evm'; +import { EthLikeErc20Token, EthLikeErc721Token } from '@bitgo/sdk-coin-evm'; + import { CoinMap, coins, @@ -38,7 +39,6 @@ import { TaoTokenConfig, PolyxTokenConfig, JettonTokenConfig, - NetworkType, } from '@bitgo/statics'; import { Ada, @@ -567,6 +567,20 @@ export function registerCoinConstructors(coinFactory: CoinFactory, coinMap: Coin coinFactory.register(name, coinConstructor); }); }); + + // Generic ERC721 token registration for coins with SUPPORTS_ERC721 feature + coins + .filter((coin) => coin.features.includes(CoinFeature.SUPPORTS_ERC721) && !coin.isToken) + .forEach((coin) => { + const coinNames = { + Mainnet: `${coin.name}`, + Testnet: `t${coin.name}`, + }; + + EthLikeErc721Token.createTokenConstructors(coinNames).forEach(({ name, coinConstructor }) => { + coinFactory.register(name, coinConstructor); + }); + }); } export function getCoinConstructor(coinName: string): CoinConstructor | undefined { @@ -910,45 +924,31 @@ export function getCoinConstructor(coinName: string): CoinConstructor | undefine } } -export const buildEthLikeChainToTestnetMap = (): { - mainnetToTestnetMap: Record; - testnetToMainnetMap: Record; -} => { - const testnetToMainnetMap: Record = {}; - const mainnetToTestnetMap: Record = {}; - - const enabledEvmCoins = ['ip', 'hypeevm', 'plume']; - - // TODO: remove ip and hypeeevm coins here and remove other evm coins from switch block, once changes are tested (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) - coins.forEach((coin) => { - if (coin.network.type === NetworkType.TESTNET && !coin.isToken && enabledEvmCoins.includes(coin.family)) { - if (coins.get(coin.family)?.features.includes(CoinFeature.SUPPORTS_ERC20)) { - mainnetToTestnetMap[coin.family] = `${coin.name}`; - testnetToMainnetMap[coin.name] = `${coin.family}`; - } - } - }); - - return { mainnetToTestnetMap, testnetToMainnetMap }; -}; - -const { mainnetToTestnetMap, testnetToMainnetMap } = buildEthLikeChainToTestnetMap(); - export function getTokenConstructor(tokenConfig: TokenConfig): CoinConstructor | undefined { - const testnetCoin = mainnetToTestnetMap[tokenConfig.coin]; - if (testnetCoin) { + const coin = coins.get(tokenConfig.coin); + + if ( + 'network' in tokenConfig && + tokenConfig.network === 'Mainnet' && + coin?.features.includes(CoinFeature.SUPPORTS_ERC20) + ) { return EthLikeErc20Token.createTokenConstructor(tokenConfig as EthLikeTokenConfig, { Mainnet: tokenConfig.coin, - Testnet: testnetCoin, + Testnet: `t${tokenConfig.coin}`, }); } - const mainnetCoin = testnetToMainnetMap[tokenConfig.coin]; - if (mainnetCoin) { - return EthLikeErc20Token.createTokenConstructor(tokenConfig as EthLikeTokenConfig, { - Mainnet: mainnetCoin, - Testnet: tokenConfig.coin, + + if ( + 'network' in tokenConfig && + tokenConfig.network === 'Mainnet' && + coin?.features.includes(CoinFeature.SUPPORTS_ERC721) + ) { + return EthLikeErc721Token.createTokenConstructor(tokenConfig as EthLikeTokenConfig, { + Mainnet: tokenConfig.coin, + Testnet: `t${tokenConfig.coin}`, }); } + switch (tokenConfig.coin) { case 'eth': case 'hteth': diff --git a/modules/bitgo/src/v2/coins/index.ts b/modules/bitgo/src/v2/coins/index.ts index 21b00a7075..2b187175cf 100644 --- a/modules/bitgo/src/v2/coins/index.ts +++ b/modules/bitgo/src/v2/coins/index.ts @@ -31,7 +31,7 @@ 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, Erc721Token, Eth, Gteth, Hteth, Teth } from '@bitgo/sdk-coin-eth'; -import { EvmCoin, EthLikeErc20Token } from '@bitgo/sdk-coin-evm'; +import { EvmCoin, EthLikeErc20Token, EthLikeErc721Token } from '@bitgo/sdk-coin-evm'; import { Flr, Tflr, FlrToken } from '@bitgo/sdk-coin-flr'; import { Flrp } from '@bitgo/sdk-coin-flrp'; import { Ethw } from '@bitgo/sdk-coin-ethw'; @@ -109,7 +109,7 @@ export { Erc20Token, Erc721Token, Eth, Gteth, Hteth, Teth }; export { Ethw }; export { EthLikeCoin, TethLikeCoin }; export { Etc, Tetc }; -export { EvmCoin, EthLikeErc20Token }; +export { EvmCoin, EthLikeErc20Token, EthLikeErc721Token }; export { Flr, Tflr, FlrToken }; export { Flrp }; export { Hash, Thash, HashToken }; diff --git a/modules/bitgo/test/browser/browser.spec.ts b/modules/bitgo/test/browser/browser.spec.ts index 68dcc018b2..dc89721eb4 100644 --- a/modules/bitgo/test/browser/browser.spec.ts +++ b/modules/bitgo/test/browser/browser.spec.ts @@ -55,6 +55,7 @@ describe('Coins', () => { CosmosSharedCoin: 1, VetToken: 1, EthLikeErc20Token: 1, + EthLikeErc721Token: 1, HashToken: 1, FlrToken: 1, JettonToken: 1, diff --git a/modules/sdk-coin-evm/src/ethLikeErc721Token.ts b/modules/sdk-coin-evm/src/ethLikeErc721Token.ts new file mode 100644 index 0000000000..d61aecdd94 --- /dev/null +++ b/modules/sdk-coin-evm/src/ethLikeErc721Token.ts @@ -0,0 +1,54 @@ +/** + * @prettier + */ +import { coins, EthLikeTokenConfig } from '@bitgo/statics'; +import { BitGoBase, CoinConstructor, common, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core'; +import { CoinNames, EthLikeToken, recoveryBlockchainExplorerQuery } from '@bitgo/abstract-eth'; +import { TransactionBuilder } from './lib'; +import assert from 'assert'; + +export class EthLikeErc721Token extends EthLikeToken { + public readonly tokenConfig: EthLikeTokenConfig; + private readonly coinNames: CoinNames; + + constructor(bitgo: BitGoBase, tokenConfig: EthLikeTokenConfig, coinNames: CoinNames) { + super(bitgo, tokenConfig, coinNames); + this.coinNames = coinNames; + } + + static createTokenConstructor(config: EthLikeTokenConfig, coinNames: CoinNames): CoinConstructor { + return (bitgo: BitGoBase) => new this(bitgo, config, coinNames); + } + + static createTokenConstructors(coinNames: CoinNames): NamedCoinConstructor[] { + return super.createTokenConstructors(coinNames); + } + + protected getTransactionBuilder(): TransactionBuilder { + return new TransactionBuilder(coins.get(this.getBaseChain())); + } + + getMPCAlgorithm(): MPCAlgorithm { + return 'ecdsa'; + } + + supportsTss(): boolean { + return true; + } + + async recoveryBlockchainExplorerQuery(query: Record): Promise> { + const family = this.getFamily(); + const evmConfig = common.Environments[this.bitgo.getEnv()].evm; + assert( + evmConfig && this.getFamily() in evmConfig, + `env config is missing for ${this.getFamily()} in ${this.bitgo.getEnv()}` + ); + const explorerUrl = evmConfig[family].baseUrl; + const apiToken = evmConfig[family].apiToken; + return await recoveryBlockchainExplorerQuery(query, explorerUrl as string, apiToken); + } + + getFullName(): string { + return 'ERC721 Token'; + } +} diff --git a/modules/sdk-coin-evm/src/index.ts b/modules/sdk-coin-evm/src/index.ts index 46b9b7c4f5..c8e82ebfc5 100644 --- a/modules/sdk-coin-evm/src/index.ts +++ b/modules/sdk-coin-evm/src/index.ts @@ -2,3 +2,4 @@ export * from './evmCoin'; export * from './lib'; export * from './register'; export * from './ethLikeErc20Token'; +export * from './ethLikeErc721Token'; diff --git a/modules/sdk-coin-evm/src/register.ts b/modules/sdk-coin-evm/src/register.ts index 7fde6c3810..e0bdcabbf5 100644 --- a/modules/sdk-coin-evm/src/register.ts +++ b/modules/sdk-coin-evm/src/register.ts @@ -2,6 +2,7 @@ import { BitGoBase } from '@bitgo/sdk-core'; import { CoinFeature, coins, NetworkType } from '@bitgo/statics'; import { EvmCoin } from './evmCoin'; import { EthLikeErc20Token } from './ethLikeErc20Token'; +import { EthLikeErc721Token } from './ethLikeErc721Token'; export const registerAll = (sdk: BitGoBase): void => { coins @@ -35,5 +36,17 @@ export const register = (coinFamily: string, sdk: BitGoBase): void => { sdk.register(name, coinConstructor); }); } + + // Handle SUPPORTS_ERC721 registration + if (coinFeatures.includes(CoinFeature.SUPPORTS_ERC721)) { + const coinNames = { + Mainnet: `${coin.name}`, + Testnet: `t${coin.name}`, + }; + + EthLikeErc721Token.createTokenConstructors(coinNames).forEach(({ name, coinConstructor }) => { + sdk.register(name, coinConstructor); + }); + } }); }; diff --git a/modules/statics/src/account.ts b/modules/statics/src/account.ts index c0a10376fc..e9e56fb374 100644 --- a/modules/statics/src/account.ts +++ b/modules/statics/src/account.ts @@ -90,6 +90,10 @@ export interface Erc20ConstructorOptions extends AccountConstructorOptions { contractAddress: string; } +export interface Erc721ConstructorOptions extends AccountConstructorOptions { + contractAddress: string; +} + export interface NFTCollectionIdConstructorOptions extends AccountConstructorOptions { nftCollectionId: string; } @@ -430,6 +434,12 @@ export class EthLikeERC20Token extends ContractAddressDefinedToken { } } +export class EthLikeERC721Token extends ContractAddressDefinedToken { + constructor(options: Erc721ConstructorOptions) { + super(options); + } +} + /** * The AVAX C Chain network support tokens * AVAX C Chain Tokens are ERC20 coins @@ -895,6 +905,49 @@ export function erc20Token( ); } +/** + * Factory function for erc721 token instances. + * + * @param id uuid v4 + * @param name unique identifier of the token + * @param fullName Complete human-readable name of the token + * @param contractAddress Contract address of this token + * @param network network + * @param features Features of this coin. Defaults to the DEFAULT_FEATURES defined in `AccountCoin` + * @param prefix Optional token prefix + * @param suffix Optional token suffix + * @param primaryKeyCurve The elliptic curve for this chain/token + */ +export function erc721Token( + id: string, + name: string, + fullName: string, + contractAddress: string, + network: AccountNetwork, + features: CoinFeature[] = [...AccountCoin.DEFAULT_FEATURES, CoinFeature.EIP1559], + prefix = '', + suffix: string = name.toUpperCase(), + primaryKeyCurve: KeyCurve = KeyCurve.Secp256k1 +): Readonly { + return Object.freeze( + new EthLikeERC721Token({ + id, + name, + fullName, + network, + contractAddress, + decimalPlaces: 0, // ERC721 tokens are non-divisible + asset: UnderlyingAsset.ERC721, + features, + prefix, + suffix, + primaryKeyCurve, + isToken: true, + baseUnit: BaseUnit.ETH, + }) + ); +} + /** * Factory function for erc20 token instances. * diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index d7e4bcde0a..5ef6a17a1e 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -13,6 +13,7 @@ import { erc20CompatibleAccountCoin, erc20Token, erc721, + erc721Token, fiat, flrErc20, gasTankAccount, @@ -2157,7 +2158,7 @@ export const allCoinsAndTokens = [ Networks.main.hederaEVM, 8, UnderlyingAsset.HBAREVM, - BaseUnit.HBAR, + BaseUnit.ETH, [ ...EVM_FEATURES, CoinFeature.SHARED_EVM_SIGNING, @@ -2168,6 +2169,7 @@ export const allCoinsAndTokens = [ CoinFeature.EVM_NON_BITGO_RECOVERY, CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, CoinFeature.SUPPORTS_ERC20, + CoinFeature.SUPPORTS_ERC721, ] ), account( @@ -2177,7 +2179,7 @@ export const allCoinsAndTokens = [ Networks.test.hederaEVM, 8, UnderlyingAsset.HBAREVM, - BaseUnit.HBAR, + BaseUnit.ETH, [ ...EVM_FEATURES, CoinFeature.SHARED_EVM_SIGNING, @@ -2189,6 +2191,15 @@ export const allCoinsAndTokens = [ CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, ] ), + + erc721Token( + 'a7604e03-7f40-41f0-8efa-2e7673ac2a9f', + 'thbarevmnft', + 'Testnet Hedera EVM NFT', + '0x00000000000000000000000000000000007103a5', + Networks.test.hederaEVM + ), + account( '8f6ed7e4-cce2-4686-bdab-ae8f54e2c05e', 'tfluenteth', diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index 8d547bf29f..23ccd78fea 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -422,6 +422,11 @@ export enum CoinFeature { */ SUPPORTS_ERC20 = 'supports-erc20-token', + /** + * This coin supports erc721 tokens + */ + SUPPORTS_ERC721 = 'supports-erc721-token', + /** * This coin is a Cosmos coin and should use shared Cosmos SDK module */ diff --git a/modules/statics/src/coins.ts b/modules/statics/src/coins.ts index b97b60d69b..d2e704b858 100644 --- a/modules/statics/src/coins.ts +++ b/modules/statics/src/coins.ts @@ -27,6 +27,7 @@ import { xrpToken, adaToken, erc20Token, + erc721Token, } from './account'; import { ofcToken } from './ofc'; import { BaseCoin, CoinFeature } from './base'; @@ -64,6 +65,17 @@ allCoinsAndTokens.forEach((coin) => { } }); +const erc721ChainToNameMap: Record = {}; +allCoinsAndTokens.forEach((coin) => { + if ( + coin.features.includes(CoinFeature.SUPPORTS_ERC721) && + coin.network.type === NetworkType.MAINNET && + !coin.isToken + ) { + erc721ChainToNameMap[coin.family] = coin.name; + } +}); + export function createToken(token: AmsTokenConfig): Readonly | undefined { const initializerMap: Record = { algo: algoToken, @@ -105,6 +117,10 @@ export function createToken(token: AmsTokenConfig): Readonly | undefin initializerMap[key] = erc20Token; }); + Object.keys(erc721ChainToNameMap).forEach((key) => { + initializerMap[key] = erc721Token; + }); + //return the BaseCoin from default coin map if present if (isCoinPresentInCoinMap({ ...token })) { if (coins.has(token.name)) { @@ -148,6 +164,16 @@ export function createToken(token: AmsTokenConfig): Readonly | undefin token.suffix, token.primaryKeyCurve ); + case erc721ChainToNameMap[family]: + return initializer( + ...commonArgs.slice(0, 3), // id, name, fullName + token.contractAddress, // contractAddress + token.network, + token.features, + token.prefix, + token.suffix, + token.primaryKeyCurve + ); case 'arbeth': case 'avaxc': case 'baseeth': diff --git a/modules/statics/src/tokenConfig.ts b/modules/statics/src/tokenConfig.ts index 2c64d6cb11..4d83ceb6c8 100644 --- a/modules/statics/src/tokenConfig.ts +++ b/modules/statics/src/tokenConfig.ts @@ -14,6 +14,7 @@ import { Erc20Coin, Erc721Coin, EthLikeERC20Token, + EthLikeERC721Token, FlrERC20Token, HederaToken, Nep141Token, @@ -148,6 +149,10 @@ export type JettonTokenConfig = BaseNetworkConfig & { contractAddress: string; }; +export type EthLikeERC721TokenConfig = BaseContractAddressConfig & { + network: string; +}; + export type TokenConfig = | Erc20TokenConfig | StellarTokenConfig @@ -172,7 +177,9 @@ export type TokenConfig = | VetNFTCollectionConfig | TaoTokenConfig | PolyxTokenConfig - | JettonTokenConfig; + | JettonTokenConfig + | EthLikeERC721TokenConfig; + export interface TokenNetwork { eth: { tokens: Erc20TokenConfig[]; @@ -1069,6 +1076,15 @@ const getFormattedJettonTokens = (customCoinMap = coins) => return acc; }, []); +type EthLikeTokenMap = { + [K in CoinFamily]: { tokens: EthLikeTokenConfig[] }; +}; + +export enum TokenTypeEnum { + ERC20 = 'erc20', + ERC721 = 'erc721', +} + function getEthLikeTokenConfig(coin: EthLikeERC20Token): EthLikeTokenConfig { return { type: coin.name, @@ -1080,32 +1096,60 @@ function getEthLikeTokenConfig(coin: EthLikeERC20Token): EthLikeTokenConfig { }; } -export const getFormattedEthLikeTokenConfig = (customCoinMap = coins): EthLikeTokenConfig[] => - customCoinMap.reduce((acc: EthLikeTokenConfig[], coin) => { - if (coin instanceof EthLikeERC20Token) { - acc.push(getEthLikeTokenConfig(coin)); +function getEthLikeERC721TokenConfig(coin: EthLikeERC721Token): EthLikeERC721TokenConfig { + return { + type: coin.name, + coin: coin.name.split(':')[0].toLowerCase(), + network: coin.network.type === NetworkType.MAINNET ? 'Mainnet' : 'Testnet', + name: coin.fullName, + tokenContractAddress: coin.contractAddress.toString().toLowerCase(), + decimalPlaces: coin.decimalPlaces, + }; +} + +export const getFormattedEthLikeTokenConfig = ( + customCoinMap = coins, + tokenType: TokenTypeEnum +): EthLikeTokenConfig[] => { + let className: typeof EthLikeERC20Token | typeof EthLikeERC721Token; + if (tokenType === TokenTypeEnum.ERC20) { + className = EthLikeERC20Token; + } else if (tokenType === TokenTypeEnum.ERC721) { + className = EthLikeERC721Token; + } else { + throw new Error(`Unsupported token type: ${tokenType}`); + } + + return customCoinMap.reduce((acc: EthLikeTokenConfig[], coin) => { + if (coin instanceof className) { + if (tokenType === TokenTypeEnum.ERC20) { + acc.push(getEthLikeTokenConfig(coin as EthLikeERC20Token)); + } else if (tokenType === TokenTypeEnum.ERC721) { + acc.push(getEthLikeERC721TokenConfig(coin as EthLikeERC721Token)); + } } return acc; }, []); - -type EthLikeTokenMap = { - [K in CoinFamily]: { tokens: EthLikeTokenConfig[] }; }; /* Get all tokens of a given eth like coin for a given network */ -export const getEthLikeTokens = (network: 'Mainnet' | 'Testnet'): EthLikeTokenMap => { - const networkTokens = getFormattedEthLikeTokenConfig().filter((token) => token.network === network); +export const getEthLikeTokens = (network: 'Mainnet' | 'Testnet', tokenType: TokenTypeEnum): EthLikeTokenMap => { + let feature: CoinFeature; + const networkTokens = getFormattedEthLikeTokenConfig(coins, tokenType).filter((token) => token.network === network); + + if (tokenType === TokenTypeEnum.ERC20) { + feature = CoinFeature.SUPPORTS_ERC20; + } else if (tokenType === TokenTypeEnum.ERC721) { + feature = CoinFeature.SUPPORTS_ERC721; + } + const ethLikeTokenMap = {} as EthLikeTokenMap; // TODO: add IP token here and test changes (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) const enabledChains = ['ip', 'hypeevm', 'plume'] as string[]; coins.forEach((coin) => { // TODO: remove enabled chains once changes are done (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) - if ( - coin instanceof AccountCoin && - coin.features.includes(CoinFeature.SUPPORTS_ERC20) && - enabledChains.includes(coin.family) - ) { + if (coin instanceof AccountCoin && coin.features.includes(feature) && enabledChains.includes(coin.family)) { const coinName = coin.family; const coinNameForNetwork = network === 'Testnet' ? `t${coinName}` : coinName; @@ -1118,11 +1162,28 @@ export const getEthLikeTokens = (network: 'Mainnet' | 'Testnet'): EthLikeTokenMa return ethLikeTokenMap; }; +const mergeEthLikeTokenMap = (...maps: EthLikeTokenMap[]): EthLikeTokenMap => { + const mergedMap: EthLikeTokenMap = {} as EthLikeTokenMap; + + maps.forEach((map) => { + Object.keys(map).forEach((key) => { + if (!mergedMap[key as CoinFamily]) { + mergedMap[key as CoinFamily] = { tokens: [] }; + } + mergedMap[key as CoinFamily].tokens.push(...map[key as CoinFamily].tokens); + }); + }); + return mergedMap; +}; + const getFormattedTokensByNetwork = (network: 'Mainnet' | 'Testnet', coinMap: typeof coins) => { const networkType = network === 'Mainnet' ? NetworkType.MAINNET : NetworkType.TESTNET; - const ethLikeTokenMap = getEthLikeTokens(network); + + const ethLikeTokenMap = getEthLikeTokens(network, TokenTypeEnum.ERC20); + const ethLikeErc721TokenMap = getEthLikeTokens(network, TokenTypeEnum.ERC721); + return { - ...ethLikeTokenMap, + ...mergeEthLikeTokenMap(ethLikeTokenMap, ethLikeErc721TokenMap), eth: { tokens: getFormattedErc20Tokens(coinMap).filter((token) => token.network === network), nfts: getFormattedErc721Tokens(coinMap).filter((token) => token.network === network), @@ -1387,6 +1448,8 @@ export function getFormattedTokenConfigForCoin(coin: Readonly): TokenC return getFlrTokenConfig(coin); } else if (coin instanceof EthLikeERC20Token) { return getEthLikeTokenConfig(coin); + } else if (coin instanceof EthLikeERC721Token) { + return getEthLikeERC721TokenConfig(coin); } return undefined; } diff --git a/modules/statics/test/unit/coins.ts b/modules/statics/test/unit/coins.ts index 9d483b982c..63ef64a866 100644 --- a/modules/statics/test/unit/coins.ts +++ b/modules/statics/test/unit/coins.ts @@ -1299,4 +1299,30 @@ describe('create token map using config details', () => { } } }); + + it('should create ERC721 tokens for all coins supporting ERC721 using createToken', () => { + // Get all ERC721 token configs from allCoinsAndTokens that support ERC721 + const erc721TokenConfigs = allCoinsAndTokens + .filter((coin) => coin.isToken && coins.get(coin.family).features.includes(CoinFeature.SUPPORTS_ERC721)) + .map((coin) => coin); + + for (const tokenConfig of erc721TokenConfigs) { + const token = createToken(tokenConfig); + token?.should.not.be.undefined(); + if (token) { + token.name.should.eql(tokenConfig.name); + token.family.should.eql(tokenConfig.family); + token.decimalPlaces.should.eql(0); // ERC721 tokens are non-divisible + token.asset.should.eql(UnderlyingAsset.ERC721); + token.isToken.should.eql(true); + + // Verify contract address matches for ERC721 tokens + if ('contractAddress' in token && 'contractAddress' in tokenConfig) { + (token as unknown as { contractAddress: string }).contractAddress.should.eql( + (tokenConfig as unknown as { contractAddress: string }).contractAddress + ); + } + } + } + }); }); diff --git a/modules/statics/test/unit/tokenConfigTests.ts b/modules/statics/test/unit/tokenConfigTests.ts index a85a099688..bdcfb410f4 100644 --- a/modules/statics/test/unit/tokenConfigTests.ts +++ b/modules/statics/test/unit/tokenConfigTests.ts @@ -15,6 +15,7 @@ import { getEthLikeTokens, getFormattedTokens, EthLikeTokenConfig, + TokenTypeEnum, } from '../../src/tokenConfig'; import { EthLikeERC20Token } from '../../src/account'; @@ -38,7 +39,7 @@ describe('EthLike Token Config Functions', function () { baseUnit: BaseUnit.ETH, }); - const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockMainnetToken]))[0]; + const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockMainnetToken]), TokenTypeEnum.ERC20)[0]; config.should.not.be.undefined(); config.type.should.equal('ip:testtoken'); @@ -67,7 +68,7 @@ describe('EthLike Token Config Functions', function () { baseUnit: BaseUnit.ETH, }); - const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockTestnetToken]))[0]; + const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockTestnetToken]), TokenTypeEnum.ERC20)[0]; config.should.not.be.undefined(); config.type.should.equal('tip:testtoken'); @@ -95,7 +96,7 @@ describe('EthLike Token Config Functions', function () { baseUnit: BaseUnit.ETH, }); - const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockToken]))[0]; + const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockToken]), TokenTypeEnum.ERC20)[0]; config.tokenContractAddress.should.equal('0xabcdef1234567890abcdef1234567890abcdef12'); config.tokenContractAddress.should.not.match(/[A-F]/); @@ -118,7 +119,7 @@ describe('EthLike Token Config Functions', function () { baseUnit: BaseUnit.ETH, }); - const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockToken]))[0]; + const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockToken]), TokenTypeEnum.ERC20)[0]; config.coin.should.equal('ip'); config.type.should.equal('ip:usdc'); @@ -142,7 +143,7 @@ describe('EthLike Token Config Functions', function () { baseUnit: BaseUnit.ETH, }); - const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockMainnetToken]))[0]; + const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockMainnetToken]), TokenTypeEnum.ERC20)[0]; config.should.not.be.undefined(); config.type.should.equal('hypeevm:testtoken'); @@ -171,7 +172,7 @@ describe('EthLike Token Config Functions', function () { baseUnit: BaseUnit.ETH, }); - const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockTestnetToken]))[0]; + const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockTestnetToken]), TokenTypeEnum.ERC20)[0]; config.should.not.be.undefined(); config.type.should.equal('thypeevm:testtoken'); @@ -216,7 +217,10 @@ describe('EthLike Token Config Functions', function () { baseUnit: BaseUnit.BTC, }); - const result = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockEthLikeToken, mockAccountCoin])); + const result = getFormattedEthLikeTokenConfig( + CoinMap.fromCoins([mockEthLikeToken, mockAccountCoin]), + TokenTypeEnum.ERC20 + ); result.length.should.equal(1); result[0].type.should.equal('ip:token1'); @@ -255,7 +259,7 @@ describe('EthLike Token Config Functions', function () { baseUnit: BaseUnit.ETH, }); - const result = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockToken1, mockToken2])); + const result = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockToken1, mockToken2]), TokenTypeEnum.ERC20); result.length.should.equal(2); result[0].type.should.equal('ip:token1'); @@ -265,7 +269,7 @@ describe('EthLike Token Config Functions', function () { }); it('should use default coins map when no parameter is provided', function () { - const result = getFormattedEthLikeTokenConfig(); + const result = getFormattedEthLikeTokenConfig(undefined, TokenTypeEnum.ERC20); result.should.be.an.Array(); // Check that it filters coins from the default coin map @@ -296,7 +300,7 @@ describe('EthLike Token Config Functions', function () { baseUnit: BaseUnit.ETH, }); - const result = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockToken])); + const result = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockToken]), TokenTypeEnum.ERC20); result[0].should.have.property('type').which.is.a.String(); result[0].should.have.property('coin').which.is.a.String(); @@ -309,7 +313,7 @@ describe('EthLike Token Config Functions', function () { describe('getEthLikeTokens', function () { it('should return a map with tokens for enabled chains', function () { - const result = getEthLikeTokens('Mainnet'); + const result = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); result.should.be.an.Object(); // The function filters by enabledChains which currently includes 'ip' and 'hypeevm' @@ -324,7 +328,7 @@ describe('EthLike Token Config Functions', function () { }); it('should filter mainnet tokens correctly', function () { - const result = getEthLikeTokens('Mainnet'); + const result = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); Object.values(result).forEach((chainData) => { chainData.tokens.forEach((token) => { @@ -334,7 +338,7 @@ describe('EthLike Token Config Functions', function () { }); it('should filter testnet tokens correctly', function () { - const result = getEthLikeTokens('Testnet'); + const result = getEthLikeTokens('Testnet', TokenTypeEnum.ERC20); Object.values(result).forEach((chainData) => { chainData.tokens.forEach((token) => { @@ -344,7 +348,7 @@ describe('EthLike Token Config Functions', function () { }); it('should prepend "t" to coin name for testnet tokens', function () { - const result = getEthLikeTokens('Testnet'); + const result = getEthLikeTokens('Testnet', TokenTypeEnum.ERC20); if (result.ip && result.ip.tokens.length > 0) { result.ip.tokens.forEach((token) => { @@ -359,7 +363,7 @@ describe('EthLike Token Config Functions', function () { }); it('should not prepend "t" to coin name for mainnet tokens', function () { - const result = getEthLikeTokens('Mainnet'); + const result = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); if (result.ip && result.ip.tokens.length > 0) { result.ip.tokens.forEach((token) => { @@ -374,7 +378,7 @@ describe('EthLike Token Config Functions', function () { }); it('should only include tokens from chains with SUPPORTS_ERC20 feature', function () { - const result = getEthLikeTokens('Mainnet'); + const result = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); // Verify that all included chains are AccountCoins with SUPPORTS_ERC20 feature Object.keys(result).forEach((family) => { @@ -387,8 +391,8 @@ describe('EthLike Token Config Functions', function () { }); it('should only include tokens from enabled chains', function () { - const mainnetResult = getEthLikeTokens('Mainnet'); - const testnetResult = getEthLikeTokens('Testnet'); + const mainnetResult = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); + const testnetResult = getEthLikeTokens('Testnet', TokenTypeEnum.ERC20); // Current implementation enables 'ip' and 'hypeevm' chains const enabledChains = ['ip', 'hypeevm', 'plume']; @@ -403,7 +407,7 @@ describe('EthLike Token Config Functions', function () { }); it('should return empty tokens array for chains without tokens', function () { - const result = getEthLikeTokens('Mainnet'); + const result = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); // If a chain is in the result but has no tokens, it should have an empty array Object.values(result).forEach((chainData) => { @@ -412,7 +416,7 @@ describe('EthLike Token Config Functions', function () { }); it('should group tokens by their coin family', function () { - const result = getEthLikeTokens('Mainnet'); + const result = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); if (result.ip && result.ip.tokens.length > 0) { result.ip.tokens.forEach((token) => { @@ -434,7 +438,7 @@ describe('EthLike Token Config Functions', function () { }); it('should return tokens with correct structure', function () { - const mainnetResult = getEthLikeTokens('Mainnet'); + const mainnetResult = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); Object.values(mainnetResult).forEach((chainData) => { chainData.should.have.property('tokens'); @@ -452,8 +456,8 @@ describe('EthLike Token Config Functions', function () { }); it('should handle both Mainnet and Testnet parameters', function () { - const mainnetResult = getEthLikeTokens('Mainnet'); - const testnetResult = getEthLikeTokens('Testnet'); + const mainnetResult = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); + const testnetResult = getEthLikeTokens('Testnet', TokenTypeEnum.ERC20); mainnetResult.should.be.an.Object(); testnetResult.should.be.an.Object(); @@ -473,8 +477,8 @@ describe('EthLike Token Config Functions', function () { }); it('should not mix mainnet and testnet tokens', function () { - const mainnetResult = getEthLikeTokens('Mainnet'); - const testnetResult = getEthLikeTokens('Testnet'); + const mainnetResult = getEthLikeTokens('Mainnet', TokenTypeEnum.ERC20); + const testnetResult = getEthLikeTokens('Testnet', TokenTypeEnum.ERC20); // Get all token types from mainnet const mainnetTokenTypes = new Set(); @@ -502,7 +506,7 @@ describe('EthLike Token Config Functions', function () { const ethLikeTokens = Array.from(coins).filter((coin) => coin instanceof EthLikeERC20Token); if (ethLikeTokens.length > 0) { - const configs = getFormattedEthLikeTokenConfig(coins); + const configs = getFormattedEthLikeTokenConfig(coins, TokenTypeEnum.ERC20); configs.length.should.be.greaterThanOrEqual(0); @@ -532,7 +536,7 @@ describe('EthLike Token Config Functions', function () { } }); - const formattedConfigs = getFormattedEthLikeTokenConfig(coins); + const formattedConfigs = getFormattedEthLikeTokenConfig(coins, TokenTypeEnum.ERC20); formattedConfigs.length.should.equal(ethLikeTokenCount); }); });