diff --git a/modules/bitgo/src/v2/coinFactory.ts b/modules/bitgo/src/v2/coinFactory.ts index 0057393436..89c7ea385a 100644 --- a/modules/bitgo/src/v2/coinFactory.ts +++ b/modules/bitgo/src/v2/coinFactory.ts @@ -88,6 +88,7 @@ import { Flrp, FlrToken, HashToken, + MonToken, TethLikeCoin, FiatAED, FiatEur, @@ -557,6 +558,10 @@ export function registerCoinConstructors(coinFactory: CoinFactory, coinMap: Coin coinFactory.register(name, coinConstructor); }); + MonToken.createTokenConstructors().forEach(({ name, coinConstructor }) => { + coinFactory.register(name, coinConstructor); + }); + XdcToken.createTokenConstructors().forEach(({ name, coinConstructor }) => { coinFactory.register(name, coinConstructor); }); @@ -1081,10 +1086,8 @@ export function getTokenConstructor(tokenConfig: TokenConfig): CoinConstructor | case 'tton': return JettonToken.createTokenConstructor(tokenConfig as JettonTokenConfig); case 'mon': - case 'tmon': { - const coinNames = { Mainnet: 'mon', Testnet: 'tmon' }; - return EthLikeErc20Token.createTokenConstructor(tokenConfig as EthLikeTokenConfig, coinNames); - } + case 'tmon': + return MonToken.createTokenConstructor(tokenConfig as EthLikeTokenConfig); case 'xdc': case 'txdc': return XdcToken.createTokenConstructor(tokenConfig as EthLikeTokenConfig); diff --git a/modules/bitgo/src/v2/coins/index.ts b/modules/bitgo/src/v2/coins/index.ts index c51dc4b761..43c9beedf4 100644 --- a/modules/bitgo/src/v2/coins/index.ts +++ b/modules/bitgo/src/v2/coins/index.ts @@ -45,7 +45,7 @@ import { Iota } from '@bitgo/sdk-coin-iota'; import { Islm, Tislm } from '@bitgo/sdk-coin-islm'; import { Lnbtc, Tlnbtc } from '@bitgo/sdk-coin-lnbtc'; import { Ltc, Tltc } from '@bitgo/sdk-coin-ltc'; -import { Mon, Tmon } from '@bitgo/sdk-coin-mon'; +import { Mon, Tmon, MonToken } from '@bitgo/sdk-coin-mon'; import { Oas, Toas } from '@bitgo/sdk-coin-oas'; import { Opeth, Topeth, OpethToken } from '@bitgo/sdk-coin-opeth'; import { Osmo, Tosmo } from '@bitgo/sdk-coin-osmo'; @@ -119,7 +119,7 @@ export { Initia, Tinitia }; export { Iota }; export { Lnbtc, Tlnbtc }; export { Ltc, Tltc }; -export { Mon, Tmon }; +export { Mon, Tmon, MonToken }; export { Oas, Toas }; export { Opeth, Topeth, OpethToken }; export { Osmo, Tosmo }; diff --git a/modules/bitgo/test/browser/browser.spec.ts b/modules/bitgo/test/browser/browser.spec.ts index e9becb2471..b756a2a8e6 100644 --- a/modules/bitgo/test/browser/browser.spec.ts +++ b/modules/bitgo/test/browser/browser.spec.ts @@ -58,6 +58,7 @@ describe('Coins', () => { EthLikeErc721Token: 1, HashToken: 1, FlrToken: 1, + MonToken: 1, XdcToken: 1, JettonToken: 1, }; diff --git a/modules/sdk-coin-mon/src/index.ts b/modules/sdk-coin-mon/src/index.ts index 4b9b2102ad..818a2e1e3d 100644 --- a/modules/sdk-coin-mon/src/index.ts +++ b/modules/sdk-coin-mon/src/index.ts @@ -2,3 +2,4 @@ export * from './lib'; export * from './mon'; export * from './tmon'; export * from './register'; +export * from './monToken'; diff --git a/modules/sdk-coin-mon/src/monToken.ts b/modules/sdk-coin-mon/src/monToken.ts new file mode 100644 index 0000000000..9403d7980f --- /dev/null +++ b/modules/sdk-coin-mon/src/monToken.ts @@ -0,0 +1,58 @@ +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'; + +export { EthLikeTokenConfig }; + +export class MonToken extends EthLikeToken { + public readonly tokenConfig: EthLikeTokenConfig; + static coinNames: CoinNames = { + Mainnet: 'mon', + Testnet: 'tmon', + }; + constructor(bitgo: BitGoBase, tokenConfig: EthLikeTokenConfig) { + super(bitgo, tokenConfig, MonToken.coinNames); + } + static createTokenConstructor(config: EthLikeTokenConfig): CoinConstructor { + return super.createTokenConstructor(config, MonToken.coinNames); + } + + static createTokenConstructors(): NamedCoinConstructor[] { + return super.createTokenConstructors(MonToken.coinNames); + } + + protected getTransactionBuilder(): TransactionBuilder { + return new TransactionBuilder(coins.get(this.getBaseChain())); + } + + /** @inheritDoc **/ + getMPCAlgorithm(): MPCAlgorithm { + return 'ecdsa'; + } + + /** @inheritDoc */ + supportsTss(): boolean { + return true; + } + + /** + * Make a query to Mon explorer for information such as balance, token balance, solidity calls + * @param {Object} query key-value pairs of parameters to append after /api + * @param {string} apiKey optional API key to use instead of the one from the environment + * @returns {Promise} response from Mon explorer + */ + async recoveryBlockchainExplorerQuery( + query: Record, + apiKey?: string + ): Promise> { + const apiToken = apiKey || common.Environments[this.bitgo.getEnv()].monExplorerApiToken; + const explorerUrl = common.Environments[this.bitgo.getEnv()].monExplorerBaseUrl; + return await recoveryBlockchainExplorerQuery(query, explorerUrl as string, apiToken); + } + + getFullName(): string { + return 'Mon Token'; + } +} diff --git a/modules/sdk-coin-mon/src/register.ts b/modules/sdk-coin-mon/src/register.ts index acdc1239f1..6469a2792b 100644 --- a/modules/sdk-coin-mon/src/register.ts +++ b/modules/sdk-coin-mon/src/register.ts @@ -1,8 +1,12 @@ import { BitGoBase } from '@bitgo/sdk-core'; import { Mon } from './mon'; import { Tmon } from './tmon'; +import { MonToken } from './monToken'; export const register = (sdk: BitGoBase): void => { sdk.register('mon', Mon.createInstance); sdk.register('tmon', Tmon.createInstance); + MonToken.createTokenConstructors().forEach(({ name, coinConstructor }) => { + sdk.register(name, coinConstructor); + }); }; diff --git a/modules/sdk-coin-mon/test/unit/monToken.ts b/modules/sdk-coin-mon/test/unit/monToken.ts new file mode 100644 index 0000000000..115663b898 --- /dev/null +++ b/modules/sdk-coin-mon/test/unit/monToken.ts @@ -0,0 +1,107 @@ +import 'should'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; + +import { register, MonToken } from '../../src'; + +describe('Mon Token:', function () { + let bitgo: TestBitGoAPI; + let monTokenCoin; + const tokenName = 'mon:usdc'; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'prod' }); + register(bitgo); + bitgo.initializeTestVars(); + monTokenCoin = bitgo.coin(tokenName); + }); + + it('should return constants', function () { + monTokenCoin.getChain().should.equal('mon:usdc'); + monTokenCoin.getBaseChain().should.equal('mon'); + monTokenCoin.getFullName().should.equal('Mon Token'); + monTokenCoin.getBaseFactor().should.equal(1e6); + monTokenCoin.type.should.equal(tokenName); + monTokenCoin.name.should.equal('Monad USDC'); + monTokenCoin.coin.should.equal('mon'); + monTokenCoin.network.should.equal('Mainnet'); + monTokenCoin.decimalPlaces.should.equal(6); + }); + + describe('Token Registration and TransactionBuilder', function () { + const mainnetTokens = ['mon:usdc', 'mon:wmon']; + + describe('Mainnet tokens', function () { + mainnetTokens.forEach((tokenName) => { + it(`${tokenName} should be registered as MonToken`, function () { + const token = bitgo.coin(tokenName); + token.should.be.instanceOf(MonToken); + }); + + it(`${tokenName} should create TransactionBuilder without error`, function () { + const token = bitgo.coin(tokenName) as MonToken; + // @ts-expect-error - accessing protected method for testing + (() => token.getTransactionBuilder()).should.not.throw(); + }); + + it(`${tokenName} should use Mon-specific TransactionBuilder`, function () { + const token = bitgo.coin(tokenName) as MonToken; + // @ts-expect-error - accessing protected method for testing + const builder = token.getTransactionBuilder(); + builder.should.have.property('_common'); + builder.constructor.name.should.equal('TransactionBuilder'); + }); + + it(`${tokenName} should not throw "Cannot use common sdk module" error`, function () { + const token = bitgo.coin(tokenName) as MonToken; + let errorThrown = false; + let errorMessage = ''; + + try { + // @ts-expect-error - accessing protected method for testing + const builder = token.getTransactionBuilder(); + // Try to use the builder to ensure it's fully functional + // @ts-expect-error - type expects TransactionType enum + builder.type('Send'); + } catch (e) { + errorThrown = true; + errorMessage = (e as Error).message; + } + + errorThrown.should.equal(false); + errorMessage.should.not.match(/Cannot use common sdk module/); + }); + + it(`${tokenName} should build transaction successfully`, async function () { + const token = bitgo.coin(tokenName) as MonToken; + // @ts-expect-error - accessing protected method for testing + const builder = token.getTransactionBuilder(); + + // Set up a basic transfer transaction + // @ts-expect-error - type expects TransactionType enum + builder.type('Send'); + builder.fee({ + fee: '10000000000', + gasLimit: '100000', + }); + builder.counter(1); + builder.contract(token.tokenContractAddress); + + // Verify the builder is correctly configured + builder.should.have.property('_type', 'Send'); + builder.should.have.property('_fee'); + builder.should.have.property('_counter', 1); + }); + }); + }); + + it('should verify all Mon tokens use MonToken class, not EthLikeErc20Token', function () { + mainnetTokens.forEach((tokenName) => { + const token = bitgo.coin(tokenName); + token.should.be.instanceOf(MonToken); + token.constructor.name.should.equal('MonToken'); + token.constructor.name.should.not.equal('EthLikeErc20Token'); + }); + }); + }); +}); diff --git a/modules/statics/src/account.ts b/modules/statics/src/account.ts index 0afdabb27e..adcbb9cf97 100644 --- a/modules/statics/src/account.ts +++ b/modules/statics/src/account.ts @@ -540,6 +540,16 @@ export class XdcERC20Token extends ContractAddressDefinedToken { } } +/** + * The Mon network supports tokens + * Mon Tokens are ERC20 tokens + */ +export class MonERC20Token extends ContractAddressDefinedToken { + constructor(options: Erc20ConstructorOptions) { + super(options); + } +} + /** * The Xrp network supports tokens * Xrp tokens are identified by their issuer address @@ -2956,6 +2966,96 @@ export function txdcErc20( ); } +/** + * Factory function for MonErc20 token instances. + * + * @param id uuid v4 + * @param name unique identifier of the token + * @param fullName Complete human-readable name of the token + * @param decimalPlaces Number of decimal places this token supports (divisibility exponent) + * @param contractAddress Contract address of this token + * @param asset Asset which this coin represents. This is the same for both mainnet and testnet variants of a coin. + * @param prefix? Optional token prefix. Defaults to empty string + * @param suffix? Optional token suffix. Defaults to token name. + * @param network? Optional token network. Defaults to Mon mainnet network. + * @param features? Features of this coin. Defaults to the DEFAULT_FEATURES defined in `AccountCoin` + * @param primaryKeyCurve The elliptic curve for this chain/token + */ +export function monErc20( + id: string, + name: string, + fullName: string, + decimalPlaces: number, + contractAddress: string, + asset: UnderlyingAsset, + features: CoinFeature[] = [...AccountCoin.DEFAULT_FEATURES, CoinFeature.EIP1559], + prefix = '', + suffix: string = name.toUpperCase(), + network: AccountNetwork = Networks.main.mon, + primaryKeyCurve: KeyCurve = KeyCurve.Secp256k1 +) { + return Object.freeze( + new MonERC20Token({ + id, + name, + fullName, + network, + contractAddress, + prefix, + suffix, + features, + decimalPlaces, + asset, + isToken: true, + primaryKeyCurve, + baseUnit: BaseUnit.ETH, + }) + ); +} + +/** + * Factory function for Mon testnet MonErc20 token instances. + * + * @param id uuid v4 + * @param name unique identifier of the token + * @param fullName Complete human-readable name of the token + * @param decimalPlaces Number of decimal places this token supports (divisibility exponent) + * @param contractAddress Contract address of this token + * @param asset Asset which this coin represents. This is the same for both mainnet and testnet variants of a coin. + * @param prefix? Optional token prefix. Defaults to empty string + * @param suffix? Optional token suffix. Defaults to token name. + * @param network? Optional token network. Defaults to the Mon test network. + * @param features? Features of this coin. Defaults to the DEFAULT_FEATURES defined in `AccountCoin` + * @param primaryKeyCurve The elliptic curve for this chain/token + */ +export function tmonErc20( + id: string, + name: string, + fullName: string, + decimalPlaces: number, + contractAddress: string, + asset: UnderlyingAsset, + features: CoinFeature[] = AccountCoin.DEFAULT_FEATURES, + prefix = '', + suffix: string = name.toUpperCase(), + network: AccountNetwork = Networks.test.mon, + primaryKeyCurve: KeyCurve = KeyCurve.Secp256k1 +) { + return monErc20( + id, + name, + fullName, + decimalPlaces, + contractAddress, + asset, + features, + prefix, + suffix, + network, + primaryKeyCurve + ); +} + /** * Factory function for xrp token instances. * diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index 8cf940c789..de503d68cd 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -16,6 +16,8 @@ import { erc721Token, fiat, flrErc20, + monErc20, + tmonErc20, xdcErc20, gasTankAccount, hederaCoin, @@ -1516,12 +1518,7 @@ export const allCoinsAndTokens = [ 18, UnderlyingAsset.MON, BaseUnit.ETH, - [ - ...EVM_FEATURES, - CoinFeature.SHARED_EVM_SIGNING, - CoinFeature.SHARED_EVM_MESSAGE_SIGNING, - CoinFeature.SUPPORTS_ERC20, - ] + [...EVM_FEATURES, CoinFeature.SHARED_EVM_SIGNING, CoinFeature.SHARED_EVM_MESSAGE_SIGNING] ), account( '5c5ebe50-fa27-4312-ae3d-7032520aedb5', @@ -1538,7 +1535,6 @@ export const allCoinsAndTokens = [ CoinFeature.SHARED_EVM_MESSAGE_SIGNING, CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, CoinFeature.EVM_NON_BITGO_RECOVERY, - CoinFeature.SUPPORTS_ERC20, ] ), account( @@ -3034,23 +3030,31 @@ export const allCoinsAndTokens = [ ), // MON mainnet tokens - erc20Token( + monErc20( '5f15df50-7409-45b8-a7a8-00294a113fcb', 'mon:wmon', 'Wrapped MON', 18, '0x3bd359c1119da7da1d913d1c4d2b7c461115433a', - UnderlyingAsset['mon:wmon'], - Networks.main.mon + UnderlyingAsset['mon:wmon'] ), - erc20Token( + monErc20( '5b648116-5138-438f-9835-eba792d5c133', 'mon:usdc', 'Monad USDC', 6, '0x754704bc059f8c67012fed69bc8a327a5aafb603', - UnderlyingAsset['mon:usdc'], - Networks.main.mon + UnderlyingAsset['mon:usdc'] + ), + + // MON testnet tokens + tmonErc20( + '25053eca-e1aa-48c2-8969-afa4cbe96f12', + 'tmon:tmt', + 'Test Mintable Token', + 6, + '0x119abe0ba145873072e94baa98da26e11da067ad', + UnderlyingAsset['tmon:tmt'] ), // hypeeevm testnet tokens diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index 1e8216d9bf..b26c36fe0f 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -2963,6 +2963,9 @@ export enum UnderlyingAsset { 'mon:usdc' = 'mon:usdc', 'mon:wmon' = 'mon:wmon', + // Monad testnet tokens + 'tmon:tmt' = 'tmon:tmt', + // XDC mainnet tokens 'xdc:usdc' = 'xdc:usdc', 'xdc:lbt' = 'xdc:lbt', diff --git a/modules/statics/src/tokenConfig.ts b/modules/statics/src/tokenConfig.ts index 2c69a9e3c8..d2d4734bb7 100644 --- a/modules/statics/src/tokenConfig.ts +++ b/modules/statics/src/tokenConfig.ts @@ -16,6 +16,7 @@ import { EthLikeERC20Token, EthLikeERC721Token, FlrERC20Token, + MonERC20Token, XdcERC20Token, HederaToken, Nep141Token, @@ -595,7 +596,7 @@ const getFormattedSeievmTokens = (customCoinMap = coins) => return acc; }, []); -function getMonadTokenConfig(coin: EthLikeERC20Token): EthLikeTokenConfig { +function getMonTokenConfig(coin: MonERC20Token): EthLikeTokenConfig { return { type: coin.name, coin: coin.network.type === NetworkType.MAINNET ? 'mon' : 'tmon', @@ -605,10 +606,10 @@ function getMonadTokenConfig(coin: EthLikeERC20Token): EthLikeTokenConfig { decimalPlaces: coin.decimalPlaces, }; } -const getFormattedMonadTokens = (customCoinMap = coins) => +const getFormattedMonTokens = (customCoinMap = coins) => customCoinMap.reduce((acc: EthLikeTokenConfig[], coin) => { - if (coin instanceof EthLikeERC20Token && (coin.name.includes('mon:') || coin.name.includes('tmon:'))) { - acc.push(getMonadTokenConfig(coin)); + if (coin instanceof MonERC20Token) { + acc.push(getMonTokenConfig(coin)); } return acc; }, []); @@ -1249,7 +1250,7 @@ const getFormattedTokensByNetwork = (network: 'Mainnet' | 'Testnet', coinMap: ty tokens: getFormattedFlowTokens(coinMap).filter((token) => token.network === network), }, mon: { - tokens: getFormattedMonadTokens(coinMap).filter((token) => token.network === network), + tokens: getFormattedMonTokens(coinMap).filter((token) => token.network === network), }, xdc: { tokens: getFormattedXdcTokens(coinMap).filter((token) => token.network === network), @@ -1467,6 +1468,8 @@ export function getFormattedTokenConfigForCoin(coin: Readonly): TokenC return getJettonTokenConfig(coin); } else if (coin instanceof FlrERC20Token) { return getFlrTokenConfig(coin); + } else if (coin instanceof MonERC20Token) { + return getMonTokenConfig(coin); } else if (coin instanceof XdcERC20Token) { return getXdcTokenConfig(coin); } else if (coin instanceof EthLikeERC20Token) {