diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index 985ad317ed..3b1ff24c53 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -3942,3 +3942,49 @@ export abstract class BaseCoin { return baseFeatures.filter((feature) => !excludedFeatures.includes(feature)); } } + +export interface DynamicCoinConstructorOptions { + id: string; + fullName: string; + name: string; + alias?: string; + prefix?: string; + suffix?: string; + denom?: string; + baseUnit: string; + kind: string; + isToken: boolean; + features: string[]; + decimalPlaces: number; + asset: string; + network: BaseNetwork; + primaryKeyCurve: string; +} + +/** + * Concrete coin class for AMS-discovered chains not yet registered in local statics. + * + * Extends {@link BaseCoin} directly with empty required/disallowed + * feature sets — AMS is the source of truth for features. Accepts string-typed enum + * fields and casts internally (safe since CoinKind, CoinFeature, UnderlyingAsset, + * KeyCurve are all string enums). + */ +export class DynamicCoin extends BaseCoin { + protected requiredFeatures(): Set { + return new Set(); + } + + protected disallowedFeatures(): Set { + return new Set(); + } + + constructor(options: DynamicCoinConstructorOptions) { + super({ + ...options, + kind: options.kind as CoinKind, + features: options.features as CoinFeature[], + asset: options.asset as UnderlyingAsset, + primaryKeyCurve: options.primaryKeyCurve as KeyCurve, + }); + } +} diff --git a/modules/statics/src/coins.ts b/modules/statics/src/coins.ts index 4ea9e62458..bfa400acff 100644 --- a/modules/statics/src/coins.ts +++ b/modules/statics/src/coins.ts @@ -30,10 +30,10 @@ import { erc721Token, } from './account'; import { ofcToken } from './ofc'; -import { BaseCoin, CoinFeature } from './base'; +import { BaseCoin, CoinFeature, DynamicCoin } from './base'; import { AmsTokenConfig, TrimmedAmsTokenConfig } from './tokenConfig'; import { CoinMap } from './map'; -import { Networks, NetworkType } from './networks'; +import { BaseNetwork, getNetwork, getNetworksMap, NetworkType } from './networks'; import { networkFeatureMapForTokens } from './networkFeatureMapForTokens'; import { ofcErc20Coins, tOfcErc20Coins } from './coins/ofcErc20Coins'; import { ofcCoins } from './coins/ofcCoins'; @@ -74,6 +74,14 @@ allCoinsAndTokens.forEach((coin) => { }); export function createToken(token: AmsTokenConfig): Readonly | undefined { + if (!token.isToken) { + try { + return buildDynamicCoin(token); + } catch (error) { + console.warn(`Failed to build dynamic coin for ${token.name} (${token.id}):`, error); + return undefined; + } + } const initializerMap: Record = { algo: algoToken, apt: aptToken, @@ -352,6 +360,34 @@ export function createToken(token: AmsTokenConfig): Readonly | undefin } } +/** + * Build a real DynamicCoin + DynamicNetwork instance for AMS-discovered base chains + * whose family is not yet registered in the SDK's initializerMap. + * Called from createToken() as a fallback when no initializer exists and isToken is false. + */ +function buildDynamicCoin(token: AmsTokenConfig): Readonly { + const network = token.network instanceof BaseNetwork ? token.network : getNetwork(token.network as string); + + return Object.freeze( + new DynamicCoin({ + id: token.id, + name: token.name, + fullName: token.fullName, + decimalPlaces: token.decimalPlaces, + asset: token.asset as string, + isToken: token.isToken, + features: (token.features ?? []) as string[], + network, + primaryKeyCurve: (token.primaryKeyCurve as string) ?? 'secp256k1', + prefix: token.prefix ?? '', + suffix: token.suffix ?? token.name.toUpperCase(), + baseUnit: (token.baseUnit as string) ?? '', + kind: (token.kind as string) ?? 'crypto', + alias: token.alias, + }) + ); +} + function getAptTokenInitializer(token: AmsTokenConfig) { if (token.assetId) { // used for fungible-assets / legacy coins etc. @@ -424,11 +460,7 @@ export function createTokenMapUsingConfigDetails(tokenConfigMap: Record ): CoinMap { const amsTokenConfigMap: Record = {}; - const networkNameMap = new Map( - Object.values(Networks).flatMap((networkType) => - Object.values(networkType).map((network) => [network.name, network]) - ) - ); + const networkNameMap = getNetworksMap(); for (const tokenConfigs of Object.values(reducedTokenConfigMap)) { const tokenConfig = tokenConfigs[0]; const network = networkNameMap.get(tokenConfig.network.name); - if ( - !isCoinPresentInCoinMap({ ...tokenConfig }) && - network && - tokenConfig.isToken && - networkFeatureMapForTokens[network.family] - ) { + + if (isCoinPresentInCoinMap({ ...tokenConfig })) continue; + + if (!tokenConfig.isToken) { + // Dynamic base chain — network must be pre-registered in networkByName map before calling this function. + if (network) { + amsTokenConfigMap[tokenConfig.name] = [ + { ...tokenConfig, features: tokenConfig.additionalFeatures ?? [], network }, + ]; + } + } else if (network && networkFeatureMapForTokens[network.family]) { const features = new Set([ ...(networkFeatureMapForTokens[network.family] || []), ...(tokenConfig.additionalFeatures || []), @@ -474,18 +507,20 @@ export function createTokenUsingTrimmedConfigDetails( tokenConfig: TrimmedAmsTokenConfig ): Readonly | undefined { let fullTokenConfig: AmsTokenConfig | undefined; - const networkNameMap = new Map( - Object.values(Networks).flatMap((networkType) => - Object.values(networkType).map((network) => [network.name, network]) - ) - ); + const networkNameMap = getNetworksMap(); const network = networkNameMap.get(tokenConfig.network.name); - if ( - !isCoinPresentInCoinMap({ ...tokenConfig }) && - network && - tokenConfig.isToken && - networkFeatureMapForTokens[network.family] - ) { + + if (isCoinPresentInCoinMap({ ...tokenConfig })) return undefined; + + if (!tokenConfig.isToken) { + // Dynamic base chain — network must be pre-registered in networkByName map before calling this function. + if (network) { + return createToken({ ...tokenConfig, features: tokenConfig.additionalFeatures ?? [], network } as AmsTokenConfig); + } + return undefined; + } + + if (network && networkFeatureMapForTokens[network.family]) { const features = new Set([ ...(networkFeatureMapForTokens[network.family] || []), ...(tokenConfig.additionalFeatures || []), diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index 95532d5226..f6922b74c7 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -2539,6 +2539,95 @@ class TempoTestnet extends Testnet implements EthereumNetwork { tokenOperationHashPrefix = '42431'; } +/** + * Constructor options for {@link DynamicNetwork}. + * Accepts string-typed `type` and `family` so AMS JSON can be passed directly. + * Fields mirror BaseNetwork + AccountNetwork + EthereumNetwork. + */ +export interface DynamicNetworkOptions { + // BaseNetwork + name: string; + type: string; + family: string; + explorerUrl?: string; + // AccountNetwork + accountExplorerUrl?: string; + blockExplorerUrl?: string; + // EthereumNetwork + chainId?: number; + batcherContractAddress?: string; + forwarderFactoryAddress?: string; + forwarderImplementationAddress?: string; + walletFactoryAddress?: string; + walletImplementationAddress?: string; + walletV2FactoryAddress?: string; + walletV2ImplementationAddress?: string; + walletV4FactoryAddress?: string; + walletV4ImplementationAddress?: string; + walletV2ForwarderFactoryAddress?: string; + walletV2ForwarderImplementationAddress?: string; + walletV4ForwarderFactoryAddress?: string; + walletV4ForwarderImplementationAddress?: string; + nativeCoinOperationHashPrefix?: string; + tokenOperationHashPrefix?: string; +} + +/** + * Concrete network class for AMS-discovered chains not yet registered in local statics. + * Accepts string-typed type/family and casts to enums internally (safe since both are string enums). + * Currently covers BaseNetwork + AccountNetwork + EthereumNetwork fields. + */ +export class DynamicNetwork extends BaseNetwork { + public readonly name: string; + public readonly type: NetworkType; + public readonly family: CoinFamily; + public readonly explorerUrl: string | undefined; + public readonly accountExplorerUrl?: string; + public readonly blockExplorerUrl?: string; + public readonly chainId?: number; + public readonly batcherContractAddress?: string; + public readonly forwarderFactoryAddress?: string; + public readonly forwarderImplementationAddress?: string; + public readonly walletFactoryAddress?: string; + public readonly walletImplementationAddress?: string; + public readonly walletV2FactoryAddress?: string; + public readonly walletV2ImplementationAddress?: string; + public readonly walletV4FactoryAddress?: string; + public readonly walletV4ImplementationAddress?: string; + public readonly walletV2ForwarderFactoryAddress?: string; + public readonly walletV2ForwarderImplementationAddress?: string; + public readonly walletV4ForwarderFactoryAddress?: string; + public readonly walletV4ForwarderImplementationAddress?: string; + public readonly nativeCoinOperationHashPrefix?: string; + public readonly tokenOperationHashPrefix?: string; + + constructor(options: DynamicNetworkOptions) { + super(); + this.name = options.name; + this.type = options.type as NetworkType; + this.family = options.family as CoinFamily; + this.explorerUrl = options.explorerUrl; + this.accountExplorerUrl = options.accountExplorerUrl; + this.blockExplorerUrl = options.blockExplorerUrl; + this.chainId = options.chainId; + this.batcherContractAddress = options.batcherContractAddress; + this.forwarderFactoryAddress = options.forwarderFactoryAddress; + this.forwarderImplementationAddress = options.forwarderImplementationAddress; + this.walletFactoryAddress = options.walletFactoryAddress; + this.walletImplementationAddress = options.walletImplementationAddress; + this.walletV2FactoryAddress = options.walletV2FactoryAddress; + this.walletV2ImplementationAddress = options.walletV2ImplementationAddress; + this.walletV4FactoryAddress = options.walletV4FactoryAddress; + this.walletV4ImplementationAddress = options.walletV4ImplementationAddress; + this.walletV2ForwarderFactoryAddress = options.walletV2ForwarderFactoryAddress; + this.walletV2ForwarderImplementationAddress = options.walletV2ForwarderImplementationAddress; + this.walletV4ForwarderFactoryAddress = options.walletV4ForwarderFactoryAddress; + this.walletV4ForwarderImplementationAddress = options.walletV4ForwarderImplementationAddress; + this.nativeCoinOperationHashPrefix = options.nativeCoinOperationHashPrefix; + this.tokenOperationHashPrefix = options.tokenOperationHashPrefix; + } +} + export const Networks = { main: { ada: Object.freeze(new Ada()), @@ -2798,3 +2887,57 @@ const networkByName: Map = new Map( export function getNetworkByName(name: string): BaseNetwork | undefined { return networkByName.get(name); } + +export function getNetworksMap(): Map { + return new Map(networkByName); +} + +/** + * Dynamically register a new network in the lookup map. + * Throws if a network with the same name is already registered. + */ +export function registerNetwork(network: BaseNetwork): void { + if (networkByName.has(network.name)) { + throw new Error(`Network '${network.name}' is already registered`); + } + networkByName.set(network.name, network); +} + +/** + * Look up a network by its display name or JSON representation. + * + * Resolution order: + * 1. If `network` is a JSON string representing a DynamicNetworkOptions object + * (must have `name`, `type`, and `family` fields), construct and + * return a new DynamicNetwork instance. + * 2. Local statics cache via getNetworkByName(). + * + * @param network - A network display name (e.g. "bitcoin") or a JSON-encoded + * DynamicNetworkOptions object. + * @returns The matching BaseNetwork (or DynamicNetwork) instance. + * @throws {Error} If the network is not found in local statics and the input + * is not a valid DynamicNetworkOptions JSON string. + */ +export function getNetwork(network: string): BaseNetwork { + // Check if the input is a JSON-encoded DynamicNetworkOptions object. + try { + const parsed = JSON.parse(network); + if ( + parsed !== null && + typeof parsed === 'object' && + typeof parsed.name === 'string' && + typeof parsed.type === 'string' && + typeof parsed.family === 'string' + ) { + return new DynamicNetwork(parsed as DynamicNetworkOptions); + } + } catch { + // Not valid JSON — fall through to local lookup by name. + } + + const networkObj = getNetworkByName(network); + if (!networkObj) { + throw new Error(`Network ${network} not found`); + } + return networkObj; +} diff --git a/modules/statics/test/unit/coins.ts b/modules/statics/test/unit/coins.ts index 7bfcb896e9..db7b71ddeb 100644 --- a/modules/statics/test/unit/coins.ts +++ b/modules/statics/test/unit/coins.ts @@ -10,6 +10,7 @@ import { createTokenMapUsingConfigDetails, createTokenMapUsingTrimmedConfigDetails, createTokenUsingTrimmedConfigDetails, + DynamicCoin, EosCoin, Erc20Coin, EthereumNetwork, @@ -18,6 +19,7 @@ import { HederaToken, Networks, NetworkType, + registerNetwork, SolCoin, SuiCoin, tokens, @@ -31,9 +33,12 @@ import { amsTokenConfig, amsTokenConfigWithCustomToken, amsTokenWithUnsupportedNetwork, + dynamicBaseChainFullConfig, + dynamicTestNetwork, incorrectAmsTokenConfig, reducedAmsTokenConfig, reducedTokenConfigForAllChains, + trimmedDynamicBaseChainConfig, } from './resources/amsTokenConfig'; import { EthLikeErc20Token } from '../../../sdk-coin-evm/src'; import { allCoinsAndTokens } from '../../src/allCoinsAndTokens'; @@ -1422,3 +1427,81 @@ describe('create token map using config details', () => { }); }); }); + +describe('DynamicCoin and dynamic base chain support', function () { + describe('createToken with dynamic base chain', function () { + it('should return a DynamicCoin when isToken is false with a BaseNetwork instance', function () { + const config = dynamicBaseChainFullConfig['mydynchain'][0]; + const coin = createToken(config); + coin.should.not.be.undefined(); + coin.should.be.instanceOf(DynamicCoin); + coin.isToken.should.equal(false); + coin.name.should.equal('mydynchain'); + coin.decimalPlaces.should.equal(18); + coin.network.should.be.instanceOf(BaseNetwork); + Object.isFrozen(coin).should.be.true(); + }); + + it('should resolve network from a JSON string via getNetwork', function () { + const config = { + ...dynamicBaseChainFullConfig['mydynchain'][0], + network: JSON.stringify({ name: 'AmsJsonNet', type: 'testnet', family: 'mydynfamily' }), + }; + const coin = createToken(config); + coin.should.not.be.undefined(); + coin.should.be.instanceOf(DynamicCoin); + coin.network.name.should.equal('AmsJsonNet'); + }); + + it('should return undefined when network cannot be resolved', function () { + const config = { + ...dynamicBaseChainFullConfig['mydynchain'][0], + network: 'NonExistentNetwork', + }; + const coin = createToken(config); + should(coin).be.undefined(); + }); + }); + + describe('createTokenMapUsingTrimmedConfigDetails with base chains', function () { + before(function () { + try { + registerNetwork(dynamicTestNetwork); + } catch { + // already registered from a prior test run in the same process + } + }); + + it('should include a dynamic base chain when its network is pre-registered', function () { + const coinMap = createTokenMapUsingTrimmedConfigDetails(trimmedDynamicBaseChainConfig); + coinMap.has('mydynchain').should.be.true(); + const coin = coinMap.get('mydynchain'); + coin.features.should.containDeep(['account-model']); + }); + + it('should skip a base chain whose network is not pre-registered', function () { + const config = { + mydynchain2: [ + { + ...trimmedDynamicBaseChainConfig['mydynchain'][0], + name: 'mydynchain2', + network: { name: 'UnregisteredXYZ' }, + }, + ], + }; + const coinMap = createTokenMapUsingTrimmedConfigDetails(config); + coinMap.has('mydynchain2').should.be.false(); + }); + + it('should return undefined when the coin already exists in the static coin map', function () { + const config = { + ...trimmedDynamicBaseChainConfig['mydynchain'][0], + name: 'btc', + id: 'btc', + isToken: false, + }; + const coin = createTokenUsingTrimmedConfigDetails(config); + should(coin).be.undefined(); + }); + }); +}); diff --git a/modules/statics/test/unit/networks.ts b/modules/statics/test/unit/networks.ts index 2f306d3bb9..65bb5b2e2a 100644 --- a/modules/statics/test/unit/networks.ts +++ b/modules/statics/test/unit/networks.ts @@ -1,5 +1,5 @@ import 'should'; -import { Networks, NetworkType } from '../../src/networks'; +import { BaseNetwork, DynamicNetwork, getNetwork, Networks, NetworkType } from '../../src/networks'; Object.entries(Networks).forEach(([category, networks]) => { Object.entries(networks).forEach(([networkName, network]) => { @@ -63,3 +63,26 @@ Object.entries(Networks).forEach(([category, networks]) => { }); }); }); + +describe('DynamicNetwork and getNetwork', function () { + it('DynamicNetwork should be an instance of BaseNetwork', function () { + const network = new DynamicNetwork({ name: 'TestDynNet', type: 'testnet', family: 'eth' }); + network.should.be.instanceOf(BaseNetwork); + network.name.should.equal('TestDynNet'); + network.type.should.equal(NetworkType.TESTNET); + }); + + it('getNetwork should resolve JSON string, static name, and throw for unknown', function () { + // JSON-encoded DynamicNetworkOptions + const jsonNetwork = getNetwork(JSON.stringify({ name: 'AmsNet', type: 'mainnet', family: 'sol' })); + jsonNetwork.should.be.instanceOf(BaseNetwork); + jsonNetwork.name.should.equal('AmsNet'); + + // Static network by name + const staticNetwork = getNetwork('Ethereum'); + staticNetwork.should.deepEqual(Networks.main.ethereum); + + // Unknown name throws + (() => getNetwork('NonExistentNetworkXYZ')).should.throw('Network NonExistentNetworkXYZ not found'); + }); +}); diff --git a/modules/statics/test/unit/resources/amsTokenConfig.ts b/modules/statics/test/unit/resources/amsTokenConfig.ts index 87bd9f96ed..709ed3317d 100644 --- a/modules/statics/test/unit/resources/amsTokenConfig.ts +++ b/modules/statics/test/unit/resources/amsTokenConfig.ts @@ -1,3 +1,39 @@ +import { DynamicNetwork } from '../../../src/networks'; + +export const dynamicTestNetwork = new DynamicNetwork({ + name: 'MyDynamicTestnet', + type: 'testnet', + family: 'mydynfamily', + explorerUrl: 'https://explorer.mydyn.io/tx/', + chainId: 99999, +}); + +const dynamicBaseChainEntry = { + id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + fullName: 'My Dynamic Chain', + name: 'mydynchain', + family: 'mydynfamily', + isToken: false, + decimalPlaces: 18, + asset: 'mydynchain', + primaryKeyCurve: 'secp256k1', + features: ['account-model'], + prefix: '', + suffix: 'MYDYN', + baseUnit: 'mydynwei', + kind: 'crypto', +}; + +export const dynamicBaseChainFullConfig = { + mydynchain: [{ ...dynamicBaseChainEntry, network: dynamicTestNetwork }], +}; + +export const trimmedDynamicBaseChainConfig = { + mydynchain: [ + { ...dynamicBaseChainEntry, network: { name: 'MyDynamicTestnet' }, additionalFeatures: ['account-model'] }, + ], +}; + export const amsTokenConfig = { 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L': [ {