Skip to content

Commit ca7f269

Browse files
Merge pull request #8291 from BitGo/CECHO-439/dynamic-coin-network
feat(statics): add DynamicNetwork and DynamicCoin for AMS-discovered chains
2 parents 71f7492 + f834571 commit ca7f269

6 files changed

Lines changed: 396 additions & 30 deletions

File tree

modules/statics/src/base.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3963,3 +3963,49 @@ export abstract class BaseCoin {
39633963
return baseFeatures.filter((feature) => !excludedFeatures.includes(feature));
39643964
}
39653965
}
3966+
3967+
export interface DynamicCoinConstructorOptions {
3968+
id: string;
3969+
fullName: string;
3970+
name: string;
3971+
alias?: string;
3972+
prefix?: string;
3973+
suffix?: string;
3974+
denom?: string;
3975+
baseUnit: string;
3976+
kind: string;
3977+
isToken: boolean;
3978+
features: string[];
3979+
decimalPlaces: number;
3980+
asset: string;
3981+
network: BaseNetwork;
3982+
primaryKeyCurve: string;
3983+
}
3984+
3985+
/**
3986+
* Concrete coin class for AMS-discovered chains not yet registered in local statics.
3987+
*
3988+
* Extends {@link BaseCoin} directly with empty required/disallowed
3989+
* feature sets — AMS is the source of truth for features. Accepts string-typed enum
3990+
* fields and casts internally (safe since CoinKind, CoinFeature, UnderlyingAsset,
3991+
* KeyCurve are all string enums).
3992+
*/
3993+
export class DynamicCoin extends BaseCoin {
3994+
protected requiredFeatures(): Set<CoinFeature> {
3995+
return new Set();
3996+
}
3997+
3998+
protected disallowedFeatures(): Set<CoinFeature> {
3999+
return new Set();
4000+
}
4001+
4002+
constructor(options: DynamicCoinConstructorOptions) {
4003+
super({
4004+
...options,
4005+
kind: options.kind as CoinKind,
4006+
features: options.features as CoinFeature[],
4007+
asset: options.asset as UnderlyingAsset,
4008+
primaryKeyCurve: options.primaryKeyCurve as KeyCurve,
4009+
});
4010+
}
4011+
}

modules/statics/src/coins.ts

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ import {
3030
erc721Token,
3131
} from './account';
3232
import { ofcToken } from './ofc';
33-
import { BaseCoin, CoinFeature } from './base';
33+
import { BaseCoin, CoinFeature, DynamicCoin } from './base';
3434
import { AmsTokenConfig, TrimmedAmsTokenConfig } from './tokenConfig';
3535
import { CoinMap } from './map';
36-
import { Networks, NetworkType } from './networks';
36+
import { BaseNetwork, getNetwork, getNetworksMap, NetworkType } from './networks';
3737
import { networkFeatureMapForTokens } from './networkFeatureMapForTokens';
3838
import { ofcErc20Coins, tOfcErc20Coins } from './coins/ofcErc20Coins';
3939
import { ofcCoins } from './coins/ofcCoins';
@@ -74,6 +74,14 @@ allCoinsAndTokens.forEach((coin) => {
7474
});
7575

7676
export function createToken(token: AmsTokenConfig): Readonly<BaseCoin> | undefined {
77+
if (!token.isToken) {
78+
try {
79+
return buildDynamicCoin(token);
80+
} catch (error) {
81+
console.warn(`Failed to build dynamic coin for ${token.name} (${token.id}):`, error);
82+
return undefined;
83+
}
84+
}
7785
const initializerMap: Record<string, unknown> = {
7886
algo: algoToken,
7987
apt: aptToken,
@@ -352,6 +360,34 @@ export function createToken(token: AmsTokenConfig): Readonly<BaseCoin> | undefin
352360
}
353361
}
354362

363+
/**
364+
* Build a real DynamicCoin + DynamicNetwork instance for AMS-discovered base chains
365+
* whose family is not yet registered in the SDK's initializerMap.
366+
* Called from createToken() as a fallback when no initializer exists and isToken is false.
367+
*/
368+
function buildDynamicCoin(token: AmsTokenConfig): Readonly<BaseCoin> {
369+
const network = token.network instanceof BaseNetwork ? token.network : getNetwork(token.network as string);
370+
371+
return Object.freeze(
372+
new DynamicCoin({
373+
id: token.id,
374+
name: token.name,
375+
fullName: token.fullName,
376+
decimalPlaces: token.decimalPlaces,
377+
asset: token.asset as string,
378+
isToken: token.isToken,
379+
features: (token.features ?? []) as string[],
380+
network,
381+
primaryKeyCurve: (token.primaryKeyCurve as string) ?? 'secp256k1',
382+
prefix: token.prefix ?? '',
383+
suffix: token.suffix ?? token.name.toUpperCase(),
384+
baseUnit: (token.baseUnit as string) ?? '',
385+
kind: (token.kind as string) ?? 'crypto',
386+
alias: token.alias,
387+
})
388+
);
389+
}
390+
355391
function getAptTokenInitializer(token: AmsTokenConfig) {
356392
if (token.assetId) {
357393
// used for fungible-assets / legacy coins etc.
@@ -424,11 +460,7 @@ export function createTokenMapUsingConfigDetails(tokenConfigMap: Record<string,
424460
for (const tokenConfigs of Object.values(tokenConfigMap)) {
425461
const tokenConfig = tokenConfigs[0];
426462

427-
if (
428-
!isCoinPresentInCoinMap({ ...tokenConfig }) &&
429-
tokenConfig.isToken &&
430-
!nftAndOtherTokens.has(tokenConfig.name)
431-
) {
463+
if (!isCoinPresentInCoinMap({ ...tokenConfig }) && !nftAndOtherTokens.has(tokenConfig.name)) {
432464
const token = createToken(tokenConfig);
433465
if (token) {
434466
BaseCoins.set(token.name, token);
@@ -443,21 +475,22 @@ export function createTokenMapUsingTrimmedConfigDetails(
443475
reducedTokenConfigMap: Record<string, TrimmedAmsTokenConfig[]>
444476
): CoinMap {
445477
const amsTokenConfigMap: Record<string, AmsTokenConfig[]> = {};
446-
const networkNameMap = new Map(
447-
Object.values(Networks).flatMap((networkType) =>
448-
Object.values(networkType).map((network) => [network.name, network])
449-
)
450-
);
478+
const networkNameMap = getNetworksMap();
451479

452480
for (const tokenConfigs of Object.values(reducedTokenConfigMap)) {
453481
const tokenConfig = tokenConfigs[0];
454482
const network = networkNameMap.get(tokenConfig.network.name);
455-
if (
456-
!isCoinPresentInCoinMap({ ...tokenConfig }) &&
457-
network &&
458-
tokenConfig.isToken &&
459-
networkFeatureMapForTokens[network.family]
460-
) {
483+
484+
if (isCoinPresentInCoinMap({ ...tokenConfig })) continue;
485+
486+
if (!tokenConfig.isToken) {
487+
// Dynamic base chain — network must be pre-registered in networkByName map before calling this function.
488+
if (network) {
489+
amsTokenConfigMap[tokenConfig.name] = [
490+
{ ...tokenConfig, features: tokenConfig.additionalFeatures ?? [], network },
491+
];
492+
}
493+
} else if (network && networkFeatureMapForTokens[network.family]) {
461494
const features = new Set([
462495
...(networkFeatureMapForTokens[network.family] || []),
463496
...(tokenConfig.additionalFeatures || []),
@@ -474,18 +507,20 @@ export function createTokenUsingTrimmedConfigDetails(
474507
tokenConfig: TrimmedAmsTokenConfig
475508
): Readonly<BaseCoin> | undefined {
476509
let fullTokenConfig: AmsTokenConfig | undefined;
477-
const networkNameMap = new Map(
478-
Object.values(Networks).flatMap((networkType) =>
479-
Object.values(networkType).map((network) => [network.name, network])
480-
)
481-
);
510+
const networkNameMap = getNetworksMap();
482511
const network = networkNameMap.get(tokenConfig.network.name);
483-
if (
484-
!isCoinPresentInCoinMap({ ...tokenConfig }) &&
485-
network &&
486-
tokenConfig.isToken &&
487-
networkFeatureMapForTokens[network.family]
488-
) {
512+
513+
if (isCoinPresentInCoinMap({ ...tokenConfig })) return undefined;
514+
515+
if (!tokenConfig.isToken) {
516+
// Dynamic base chain — network must be pre-registered in networkByName map before calling this function.
517+
if (network) {
518+
return createToken({ ...tokenConfig, features: tokenConfig.additionalFeatures ?? [], network } as AmsTokenConfig);
519+
}
520+
return undefined;
521+
}
522+
523+
if (network && networkFeatureMapForTokens[network.family]) {
489524
const features = new Set([
490525
...(networkFeatureMapForTokens[network.family] || []),
491526
...(tokenConfig.additionalFeatures || []),

modules/statics/src/networks.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2539,6 +2539,95 @@ class TempoTestnet extends Testnet implements EthereumNetwork {
25392539
tokenOperationHashPrefix = '42431';
25402540
}
25412541

2542+
/**
2543+
* Constructor options for {@link DynamicNetwork}.
2544+
* Accepts string-typed `type` and `family` so AMS JSON can be passed directly.
2545+
* Fields mirror BaseNetwork + AccountNetwork + EthereumNetwork.
2546+
*/
2547+
export interface DynamicNetworkOptions {
2548+
// BaseNetwork
2549+
name: string;
2550+
type: string;
2551+
family: string;
2552+
explorerUrl?: string;
2553+
// AccountNetwork
2554+
accountExplorerUrl?: string;
2555+
blockExplorerUrl?: string;
2556+
// EthereumNetwork
2557+
chainId?: number;
2558+
batcherContractAddress?: string;
2559+
forwarderFactoryAddress?: string;
2560+
forwarderImplementationAddress?: string;
2561+
walletFactoryAddress?: string;
2562+
walletImplementationAddress?: string;
2563+
walletV2FactoryAddress?: string;
2564+
walletV2ImplementationAddress?: string;
2565+
walletV4FactoryAddress?: string;
2566+
walletV4ImplementationAddress?: string;
2567+
walletV2ForwarderFactoryAddress?: string;
2568+
walletV2ForwarderImplementationAddress?: string;
2569+
walletV4ForwarderFactoryAddress?: string;
2570+
walletV4ForwarderImplementationAddress?: string;
2571+
nativeCoinOperationHashPrefix?: string;
2572+
tokenOperationHashPrefix?: string;
2573+
}
2574+
2575+
/**
2576+
* Concrete network class for AMS-discovered chains not yet registered in local statics.
2577+
* Accepts string-typed type/family and casts to enums internally (safe since both are string enums).
2578+
* Currently covers BaseNetwork + AccountNetwork + EthereumNetwork fields.
2579+
*/
2580+
export class DynamicNetwork extends BaseNetwork {
2581+
public readonly name: string;
2582+
public readonly type: NetworkType;
2583+
public readonly family: CoinFamily;
2584+
public readonly explorerUrl: string | undefined;
2585+
public readonly accountExplorerUrl?: string;
2586+
public readonly blockExplorerUrl?: string;
2587+
public readonly chainId?: number;
2588+
public readonly batcherContractAddress?: string;
2589+
public readonly forwarderFactoryAddress?: string;
2590+
public readonly forwarderImplementationAddress?: string;
2591+
public readonly walletFactoryAddress?: string;
2592+
public readonly walletImplementationAddress?: string;
2593+
public readonly walletV2FactoryAddress?: string;
2594+
public readonly walletV2ImplementationAddress?: string;
2595+
public readonly walletV4FactoryAddress?: string;
2596+
public readonly walletV4ImplementationAddress?: string;
2597+
public readonly walletV2ForwarderFactoryAddress?: string;
2598+
public readonly walletV2ForwarderImplementationAddress?: string;
2599+
public readonly walletV4ForwarderFactoryAddress?: string;
2600+
public readonly walletV4ForwarderImplementationAddress?: string;
2601+
public readonly nativeCoinOperationHashPrefix?: string;
2602+
public readonly tokenOperationHashPrefix?: string;
2603+
2604+
constructor(options: DynamicNetworkOptions) {
2605+
super();
2606+
this.name = options.name;
2607+
this.type = options.type as NetworkType;
2608+
this.family = options.family as CoinFamily;
2609+
this.explorerUrl = options.explorerUrl;
2610+
this.accountExplorerUrl = options.accountExplorerUrl;
2611+
this.blockExplorerUrl = options.blockExplorerUrl;
2612+
this.chainId = options.chainId;
2613+
this.batcherContractAddress = options.batcherContractAddress;
2614+
this.forwarderFactoryAddress = options.forwarderFactoryAddress;
2615+
this.forwarderImplementationAddress = options.forwarderImplementationAddress;
2616+
this.walletFactoryAddress = options.walletFactoryAddress;
2617+
this.walletImplementationAddress = options.walletImplementationAddress;
2618+
this.walletV2FactoryAddress = options.walletV2FactoryAddress;
2619+
this.walletV2ImplementationAddress = options.walletV2ImplementationAddress;
2620+
this.walletV4FactoryAddress = options.walletV4FactoryAddress;
2621+
this.walletV4ImplementationAddress = options.walletV4ImplementationAddress;
2622+
this.walletV2ForwarderFactoryAddress = options.walletV2ForwarderFactoryAddress;
2623+
this.walletV2ForwarderImplementationAddress = options.walletV2ForwarderImplementationAddress;
2624+
this.walletV4ForwarderFactoryAddress = options.walletV4ForwarderFactoryAddress;
2625+
this.walletV4ForwarderImplementationAddress = options.walletV4ForwarderImplementationAddress;
2626+
this.nativeCoinOperationHashPrefix = options.nativeCoinOperationHashPrefix;
2627+
this.tokenOperationHashPrefix = options.tokenOperationHashPrefix;
2628+
}
2629+
}
2630+
25422631
export const Networks = {
25432632
main: {
25442633
ada: Object.freeze(new Ada()),
@@ -2798,3 +2887,57 @@ const networkByName: Map<string, BaseNetwork> = new Map(
27982887
export function getNetworkByName(name: string): BaseNetwork | undefined {
27992888
return networkByName.get(name);
28002889
}
2890+
2891+
export function getNetworksMap(): Map<string, BaseNetwork> {
2892+
return new Map(networkByName);
2893+
}
2894+
2895+
/**
2896+
* Dynamically register a new network in the lookup map.
2897+
* Throws if a network with the same name is already registered.
2898+
*/
2899+
export function registerNetwork(network: BaseNetwork): void {
2900+
if (networkByName.has(network.name)) {
2901+
throw new Error(`Network '${network.name}' is already registered`);
2902+
}
2903+
networkByName.set(network.name, network);
2904+
}
2905+
2906+
/**
2907+
* Look up a network by its display name or JSON representation.
2908+
*
2909+
* Resolution order:
2910+
* 1. If `network` is a JSON string representing a DynamicNetworkOptions object
2911+
* (must have `name`, `type`, and `family` fields), construct and
2912+
* return a new DynamicNetwork instance.
2913+
* 2. Local statics cache via getNetworkByName().
2914+
*
2915+
* @param network - A network display name (e.g. "bitcoin") or a JSON-encoded
2916+
* DynamicNetworkOptions object.
2917+
* @returns The matching BaseNetwork (or DynamicNetwork) instance.
2918+
* @throws {Error} If the network is not found in local statics and the input
2919+
* is not a valid DynamicNetworkOptions JSON string.
2920+
*/
2921+
export function getNetwork(network: string): BaseNetwork {
2922+
// Check if the input is a JSON-encoded DynamicNetworkOptions object.
2923+
try {
2924+
const parsed = JSON.parse(network);
2925+
if (
2926+
parsed !== null &&
2927+
typeof parsed === 'object' &&
2928+
typeof parsed.name === 'string' &&
2929+
typeof parsed.type === 'string' &&
2930+
typeof parsed.family === 'string'
2931+
) {
2932+
return new DynamicNetwork(parsed as DynamicNetworkOptions);
2933+
}
2934+
} catch {
2935+
// Not valid JSON — fall through to local lookup by name.
2936+
}
2937+
2938+
const networkObj = getNetworkByName(network);
2939+
if (!networkObj) {
2940+
throw new Error(`Network ${network} not found`);
2941+
}
2942+
return networkObj;
2943+
}

0 commit comments

Comments
 (0)