From e86f97aaca299347e06c6c020569da1d4944c873 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Fri, 2 Oct 2026 11:50:23 -0400 Subject: [PATCH] feat(statics): add bip44CoinType per coin family Ticket: WCN-2955 --- .../sdk-core/test/unit/bitgo/safe/rootCoin.ts | 12 ++ modules/sdk-lib-safes/src/safeDerivation.ts | 2 +- modules/statics/src/account.ts | 1 + modules/statics/src/ada.ts | 1 + modules/statics/src/avaxp.ts | 1 + modules/statics/src/base.ts | 30 ++++ modules/statics/src/bip44CoinTypes.ts | 160 ++++++++++++++++++ modules/statics/src/canton.ts | 1 + modules/statics/src/constants.ts | 6 + modules/statics/src/errors.ts | 7 + modules/statics/src/flrp.ts | 1 + modules/statics/src/index.ts | 3 + modules/statics/src/kaspa.ts | 1 + modules/statics/src/lightning.ts | 1 + modules/statics/src/safe.ts | 32 ++++ modules/statics/src/utxo.ts | 1 + modules/statics/test/unit/base.ts | 59 ++++++- modules/statics/test/unit/bip44CoinTypes.ts | 40 +++++ modules/statics/test/unit/safe.ts | 7 + 19 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/safe/rootCoin.ts create mode 100644 modules/statics/src/bip44CoinTypes.ts create mode 100644 modules/statics/src/safe.ts create mode 100644 modules/statics/test/unit/bip44CoinTypes.ts create mode 100644 modules/statics/test/unit/safe.ts diff --git a/modules/sdk-core/test/unit/bitgo/safe/rootCoin.ts b/modules/sdk-core/test/unit/bitgo/safe/rootCoin.ts new file mode 100644 index 00000000000..d9f01c3d327 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/safe/rootCoin.ts @@ -0,0 +1,12 @@ +import { SAFE_ROOT_SLOT_ORDINALS } from '@bitgo/statics'; +import type { RootKeyType } from '@bitgo/public-types'; +import { SAFE_ROOT_SLOTS } from '../../../../src/bitgo/safe/rootCoin'; + +describe('SAFE_ROOT_SLOTS', function () { + it('should match the slot ordinals served from statics, in order', function () { + // typed as Record, so a slot missing from statics is a compile error + const ordinals: Record = SAFE_ROOT_SLOT_ORDINALS; + SAFE_ROOT_SLOTS.map((slot) => ordinals[slot]).should.deepEqual(SAFE_ROOT_SLOTS.map((_, i) => i + 1)); + Object.keys(SAFE_ROOT_SLOT_ORDINALS).should.deepEqual(SAFE_ROOT_SLOTS); + }); +}); diff --git a/modules/sdk-lib-safes/src/safeDerivation.ts b/modules/sdk-lib-safes/src/safeDerivation.ts index c755c4a8522..59f66e214b3 100644 --- a/modules/sdk-lib-safes/src/safeDerivation.ts +++ b/modules/sdk-lib-safes/src/safeDerivation.ts @@ -12,11 +12,11 @@ import * as t from 'io-ts'; import * as nacl from 'tweetnacl'; import { bip32, BIP32Interface } from '@bitgo/utxo-lib'; +import { MAX_BIP32_INDEX } from '@bitgo/statics'; import { Ed25519KeyDeriver } from './ed25519KeyDeriver'; import { decodeWithCodec } from './codecs'; import { decodeEd25519StrKeySecretSeed, encodeEd25519StrKeyPublicKey } from './ed25519Pub'; -const MAX_BIP32_INDEX = 0x7fffffff; export const DERIVED_FROM_PARENT_WITH_HARDENED_PATH = /^m\/(\d+)'$/; export type SafeChildKeyName = 'user' | 'backup' | 'bitgo'; diff --git a/modules/statics/src/account.ts b/modules/statics/src/account.ts index 1f8dd3aabf6..981811bb8b8 100644 --- a/modules/statics/src/account.ts +++ b/modules/statics/src/account.ts @@ -138,6 +138,7 @@ export interface AccountConstructorOptions { prefix?: string; suffix?: string; primaryKeyCurve: KeyCurve; + bip44CoinType?: number; } /** diff --git a/modules/statics/src/ada.ts b/modules/statics/src/ada.ts index 198289f471b..79c92e3c542 100644 --- a/modules/statics/src/ada.ts +++ b/modules/statics/src/ada.ts @@ -11,6 +11,7 @@ export interface AdaConstructorOptions { prefix?: string; suffix?: string; primaryKeyCurve: KeyCurve; + bip44CoinType?: number; } export class Ada extends BaseCoin { diff --git a/modules/statics/src/avaxp.ts b/modules/statics/src/avaxp.ts index dd4b2c28a7f..19209fa283c 100644 --- a/modules/statics/src/avaxp.ts +++ b/modules/statics/src/avaxp.ts @@ -11,6 +11,7 @@ export interface AVAXPConstructorOptions { prefix?: string; suffix?: string; primaryKeyCurve: KeyCurve; + bip44CoinType?: number; } export class AVAXPCoin extends BaseCoin { diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index 8930af6e262..431b61088c6 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -1,9 +1,12 @@ import { ConflictingCoinFeaturesError, DisallowedCoinFeatureError, + InvalidBip44CoinTypeError, InvalidIdError, MissingRequiredCoinFeatureError, } from './errors'; +import { getBip44CoinType } from './bip44CoinTypes'; +import { MAX_BIP32_INDEX } from './constants'; import { BaseNetwork } from './networks'; export enum CoinKind { @@ -4941,6 +4944,17 @@ export interface BaseCoinConstructorOptions { network: BaseNetwork; primaryKeyCurve: KeyCurve; otherSupportedKeyCurves?: KeyCurve[]; + /** + * BIP44 coin type used by the safe child derivation scheme + * `m/44'/'/'/'` (`` comes from SAFE_ROOT_SLOT_ORDINALS in safe.ts). + * + * BitGo's own value for the coin family (some values match SLIP-44 by choice). + * Tokens inherit their parent chain's value; testnets mirror their mainnet counterpart's. + * When omitted it is resolved from the coin family (see bip44CoinTypes.ts), which is how + * tokens and testnets are filled. + * OFC and fiat coins carry no value — they are not BIP44-derivable. + */ + bip44CoinType?: number; } export abstract class BaseCoin { @@ -4993,6 +5007,12 @@ export abstract class BaseCoin { */ public readonly otherSupportedKeyCurves?: KeyCurve[]; + /** + * The BIP44 coin type of this coin, as used by the safe child derivation scheme. + * See {@link BaseCoinConstructorOptions.bip44CoinType}. + */ + public readonly bip44CoinType?: number; + /** * Set of features which are required by a coin subclass * @return {Set} @@ -5055,6 +5075,14 @@ export abstract class BaseCoin { if (!BaseCoin.isValidUuidV4(options.id)) { throw new InvalidIdError(options.name, options.id); } + + // the bip44 coin type is the ' segment of safe child derivation paths + if (options.bip44CoinType !== undefined) { + const { bip44CoinType } = options; + if (!Number.isInteger(bip44CoinType) || bip44CoinType < 0 || bip44CoinType > MAX_BIP32_INDEX) { + throw new InvalidBip44CoinTypeError(options.name, bip44CoinType); + } + } } protected constructor(options: BaseCoinConstructorOptions) { @@ -5077,6 +5105,7 @@ export abstract class BaseCoin { this.network = options.network; this.primaryKeyCurve = options.primaryKeyCurve; this.otherSupportedKeyCurves = options.otherSupportedKeyCurves; + this.bip44CoinType = options.bip44CoinType ?? getBip44CoinType(options.network.family); } /** @@ -5112,6 +5141,7 @@ export interface DynamicCoinConstructorOptions { asset: string; network: BaseNetwork; primaryKeyCurve: string; + bip44CoinType?: number; } /** diff --git a/modules/statics/src/bip44CoinTypes.ts b/modules/statics/src/bip44CoinTypes.ts new file mode 100644 index 00000000000..3dc76184b58 --- /dev/null +++ b/modules/statics/src/bip44CoinTypes.ts @@ -0,0 +1,160 @@ +import type { CoinFamily } from './base'; + +/** + * BIP44 coin types are assigned per coin family: a coin family covers a mainnet chain, its testnet + * mirror (`btc`/`tbtc`) and every token on it, which all share one coin type by design. Safe child + * key uniqueness comes from per-(slot, coinType) account allocation, never from coin type uniqueness. + */ +type CoinFamilyName = `${CoinFamily}`; + +/** + * Families with no BIP44 coin type: OFC and fiat are not BIP44-derivable, and `dydx` and `eth2` have no + * key-holding coins yet. A new family must be added to the table below or to this list, so skipping + * the decision is a compile error. + */ +type CoinFamilyWithoutCoinType = 'ofc' | 'fiat' | 'dydx' | 'eth2'; + +/** + * BitGo's BIP44 coin type per coin family, used as `m/44'/'/'/'`. + * The values are BitGo's own: some deliberately match SLIP-44 (btc 0, eth 60, ...), but the registry + * is not binding. Values are unique per family and paths are immutable once keys exist, so never + * change one. A new coin family takes the next free value above the highest (0x70000000 and up). + */ +export const BIP44_COIN_TYPES: Record, number> = { + btc: 0, + ltc: 2, + doge: 3, + dash: 5, + eth: 60, + etc: 61, + atom: 118, + zec: 133, + rbtc: 137, + xrp: 144, + bch: 145, + xlm: 148, + btg: 156, + eos: 194, + trx: 195, + icp: 223, + bsv: 236, + algo: 283, + dot: 354, + near: 397, + kavacosmos: 459, + sol: 501, + hash: 505, + cspr: 506, + flow: 539, + xdc: 550, + bld: 564, + ctc: 583, + polyx: 595, + ton: 607, + apt: 637, + oas: 685, + baby: 736, + sui: 784, + vet: 818, + bcha: 899, + thor: 931, + polygon: 966, + lnbtc: 998, + tao: 1005, + fantom: 1007, + coredao: 1116, + islm: 1348, + xtz: 1729, + ada: 1815, + hyperliquid: 2457, + hbar: 3030, + phrs: 3172, + irys: 3282, + iota: 4218, + somi: 5031, + stx: 5757, + canton: 6767, + zeta: 7000, + bera: 8008, + kaia: 8217, + starknet: 9004, + avaxc: 9005, + sonic: 10007, + celo: 52752, + kaspa: 111111, + scrolleth: 534352, + osmo: 10000118, + sei: 19000118, + dydxcosmos: 22000118, + injective: 22000119, + mon: 268435779, + abstracteth: 0x70000000, + apechain: 0x70000001, + arbeth: 0x70000002, + arcusdc: 0x70000003, + asi: 0x70000004, + avaxp: 0x70000005, + baseeth: 0x70000006, + bobaeth: 0x70000007, + bsc: 0x70000008, + chiliz: 0x70000009, + codexeth: 0x7000000a, + coreum: 0x7000000b, + cotieth: 0x7000000c, + cronos: 0x7000000d, + dogeos: 0x7000000e, + ethw: 0x7000000f, + fetchai: 0x70000010, + flr: 0x70000011, + flrp: 0x70000012, + fluenteth: 0x70000013, + gasevm: 0x70000014, + h: 0x70000015, + hbarevm: 0x70000016, + hemieth: 0x70000017, + hoodeth: 0x70000018, + hppeth: 0x70000019, + hypeevm: 0x7000001a, + initia: 0x7000001b, + inketh: 0x7000001c, + ip: 0x7000001d, + jovayeth: 0x7000001e, + katanaeth: 0x7000001f, + kavaevm: 0x70000020, + lineaeth: 0x70000021, + mantle: 0x70000022, + mantra: 0x70000023, + megaeth: 0x70000024, + morph: 0x70000025, + morpheth: 0x70000026, + og: 0x70000027, + okbxlayer: 0x70000028, + opbnb: 0x70000029, + opeth: 0x7000002a, + pearl: 0x7000002b, + plume: 0x7000002c, + prividiumeth: 0x7000002d, + seievm: 0x7000002e, + sgb: 0x7000002f, + soneium: 0x70000030, + stt: 0x70000031, + susd: 0x70000032, + tempo: 0x70000033, + tia: 0x70000034, + unieth: 0x70000035, + usdt0: 0x70000036, + wemix: 0x70000037, + world: 0x70000038, + xpl: 0x70000039, + xtzevm: 0x7000003a, + zketh: 0x7000003b, + zksyncera: 0x7000003c, +}; + +/** + * The BIP44 coin type of a coin family, or undefined if the family has none (OFC and fiat). + */ +export function getBip44CoinType(family: CoinFamily): number | undefined { + const coinTypes: Partial> = BIP44_COIN_TYPES; + return coinTypes[family]; +} diff --git a/modules/statics/src/canton.ts b/modules/statics/src/canton.ts index 86b11647cdc..fed3ced1baf 100644 --- a/modules/statics/src/canton.ts +++ b/modules/statics/src/canton.ts @@ -11,6 +11,7 @@ export interface CantonConstructorOptions { prefix?: string; suffix?: string; primaryKeyCurve: KeyCurve; + bip44CoinType?: number; } export class Canton extends BaseCoin { diff --git a/modules/statics/src/constants.ts b/modules/statics/src/constants.ts index 905b42094bf..7c00ea4d91e 100644 --- a/modules/statics/src/constants.ts +++ b/modules/statics/src/constants.ts @@ -1,3 +1,9 @@ export const DOMAIN_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/; export const HEDERA_NODE_ACCCOUNT_ID = '0.0.3'; + +/** + * Highest BIP32 child index below the hardened offset (2^31 - 1), which is also the highest valid + * BIP44 coin type. + */ +export const MAX_BIP32_INDEX = 0x7fffffff; diff --git a/modules/statics/src/errors.ts b/modules/statics/src/errors.ts index 4f11343d4f7..2fbdbd43b53 100644 --- a/modules/statics/src/errors.ts +++ b/modules/statics/src/errors.ts @@ -82,6 +82,13 @@ export class InvalidDomainError extends BitGoStaticsError { } } +export class InvalidBip44CoinTypeError extends BitGoStaticsError { + public constructor(coinName: string, coinType: number) { + super(`invalid bip44CoinType '${coinType}' for coin '${coinName}' — must be an integer between 0 and 0x7fffffff`); + Object.setPrototypeOf(this, InvalidBip44CoinTypeError.prototype); + } +} + export class ConflictingCoinFeaturesError extends BitGoStaticsError { public constructor(coinName: string, conflictingFeatures: CoinFeature[]) { super( diff --git a/modules/statics/src/flrp.ts b/modules/statics/src/flrp.ts index 96b61f2fbf8..ffc0746297e 100644 --- a/modules/statics/src/flrp.ts +++ b/modules/statics/src/flrp.ts @@ -11,6 +11,7 @@ export interface FLRPConstructorOptions { prefix?: string; suffix?: string; primaryKeyCurve: KeyCurve; + bip44CoinType?: number; } export class FLRPCoin extends BaseCoin { diff --git a/modules/statics/src/index.ts b/modules/statics/src/index.ts index fa213032396..0e6c40ed513 100644 --- a/modules/statics/src/index.ts +++ b/modules/statics/src/index.ts @@ -3,6 +3,9 @@ export * from './coins'; export * from './networks'; export * from './errors'; export * from './tokenConfig'; +export * from './safe'; +export * from './bip44CoinTypes'; +export { MAX_BIP32_INDEX } from './constants'; export { KaspaCoin } from './kaspa'; export { OfcCoin } from './ofc'; export { UtxoCoin } from './utxo'; diff --git a/modules/statics/src/kaspa.ts b/modules/statics/src/kaspa.ts index ba165e87485..7170e6d2216 100644 --- a/modules/statics/src/kaspa.ts +++ b/modules/statics/src/kaspa.ts @@ -27,6 +27,7 @@ export class KaspaCoin extends BaseCoin { prefix?: string; suffix?: string; primaryKeyCurve: KeyCurve; + bip44CoinType?: number; }) { super({ ...options, diff --git a/modules/statics/src/lightning.ts b/modules/statics/src/lightning.ts index e9625630a1f..c0f4a7ff0d9 100644 --- a/modules/statics/src/lightning.ts +++ b/modules/statics/src/lightning.ts @@ -12,6 +12,7 @@ interface LightningConstructorOptions { prefix?: string; suffix?: string; primaryKeyCurve: KeyCurve; + bip44CoinType?: number; } export class LightningCoin extends BaseCoin { diff --git a/modules/statics/src/safe.ts b/modules/statics/src/safe.ts new file mode 100644 index 00000000000..56dd144a6d4 --- /dev/null +++ b/modules/statics/src/safe.ts @@ -0,0 +1,32 @@ +import { BaseCoin, CoinKind } from './base'; +import { OfcCoin } from './ofc'; + +/** + * A safe's root slot, by (curve, scheme). Mirrors `RootKeyType` in `@bitgo/public-types`, which is the + * canonical source of these names; statics cannot depend on that package. + * @experimental + */ +export type SafeRootSlot = 'secp256k1Multisig' | 'ed25519Multisig' | 'ecdsaMpc' | 'eddsaMpc'; + +/** + * Fixed ordinal (1–4) of each safe root slot: the `` segment of the safe child derivation path. + * User children are hardened-derived at `m/44'/'/'/'` and multisig + * co-signers are soft-derived at the same numeric path without hardening. The ordinals follow + * `SAFE_ROOT_SLOTS` in `@bitgo/sdk-core`. + * @experimental + */ +export const SAFE_ROOT_SLOT_ORDINALS: Record = { + secp256k1Multisig: 1, + ed25519Multisig: 2, + ecdsaMpc: 3, + eddsaMpc: 4, +}; + +/** + * Whether safe child keys can be derived for the coin. OFC (off-chain virtual assets) and fiat + * coins carry no `bip44CoinType` and cannot mint a safe child key. + * @experimental + */ +export function isBip44Derivable(coin: Readonly): boolean { + return !(coin instanceof OfcCoin) && coin.kind !== CoinKind.FIAT; +} diff --git a/modules/statics/src/utxo.ts b/modules/statics/src/utxo.ts index 82d7e76f252..a7bf94f834d 100644 --- a/modules/statics/src/utxo.ts +++ b/modules/statics/src/utxo.ts @@ -13,6 +13,7 @@ interface UtxoConstructorOptions { suffix?: string; primaryKeyCurve: KeyCurve; otherSupportedKeyCurves?: KeyCurve[]; + bip44CoinType?: number; } export class UtxoCoin extends BaseCoin { diff --git a/modules/statics/test/unit/base.ts b/modules/statics/test/unit/base.ts index 5401b9bf550..325a5a9b502 100644 --- a/modules/statics/test/unit/base.ts +++ b/modules/statics/test/unit/base.ts @@ -1,4 +1,16 @@ -import { CoinFamily, CoinFeature, Networks, coins } from '../../src'; +import { + CoinFamily, + CoinFeature, + CoinKind, + KeyCurve, + Networks, + AccountCoin, + BaseUnit, + OfcCoin, + coins, +} from '../../src'; +import { MAX_BIP32_INDEX } from '../../src/constants'; +import { InvalidBip44CoinTypeError } from '../../src/errors'; const should = require('should'); const { UnderlyingAsset } = require('../../src/base'); @@ -412,3 +424,48 @@ describe('ZAMA staking feature', function () { ); }); }); + +describe('bip44CoinType', function () { + function accountCoinOptions(bip44CoinType?: number): ConstructorParameters[0] { + return { + id: '00000000-0000-4000-8000-000000000001', + fullName: 'Test Coin', + name: 'testcoin', + network: Networks.main.ethereum, + baseUnit: BaseUnit.ETH, + features: AccountCoin.DEFAULT_FEATURES, + decimalPlaces: 18, + isToken: false, + asset: UnderlyingAsset.ETH, + primaryKeyCurve: KeyCurve.Secp256k1, + bip44CoinType, + }; + } + + it('should default to the coin type of its family', function () { + new AccountCoin(accountCoinOptions()).bip44CoinType.should.equal(60); + }); + + it('should carry the coin type on the coin when provided', function () { + new AccountCoin(accountCoinOptions(519)).bip44CoinType.should.equal(519); + }); + + [MAX_BIP32_INDEX + 1, -1, 0.5].forEach((bip44CoinType) => { + it(`should reject out-of-range or non-integer coin type ${bip44CoinType}`, function () { + should(() => new AccountCoin(accountCoinOptions(bip44CoinType))).throw(InvalidBip44CoinTypeError); + }); + }); + + it('should accept boundary coin types 0 and 0x7fffffff', function () { + new AccountCoin(accountCoinOptions(0)).bip44CoinType.should.equal(0); + new AccountCoin(accountCoinOptions(MAX_BIP32_INDEX)).bip44CoinType.should.equal(MAX_BIP32_INDEX); + }); + + it('invariant: OFC and fiat coins carry no bip44CoinType', function () { + coins.forEach((coin, name) => { + should(coin instanceof OfcCoin || coin.kind === CoinKind.FIAT ? coin.bip44CoinType === undefined : true).be.true( + `'${name}' must not carry a bip44CoinType` + ); + }); + }); +}); diff --git a/modules/statics/test/unit/bip44CoinTypes.ts b/modules/statics/test/unit/bip44CoinTypes.ts new file mode 100644 index 00000000000..33caa2da446 --- /dev/null +++ b/modules/statics/test/unit/bip44CoinTypes.ts @@ -0,0 +1,40 @@ +import should from 'should'; +import { BIP44_COIN_TYPES, coins, getBip44CoinType, isBip44Derivable } from '../../src'; +import { MAX_BIP32_INDEX } from '../../src/constants'; + +describe('bip44 coin types', function () { + const values = Object.values(BIP44_COIN_TYPES); + + it('values should be valid coin types', function () { + Object.entries(BIP44_COIN_TYPES).forEach(([family, coinType]) => { + Number.isInteger(coinType).should.be.true(`${family} must be an integer`); + coinType.should.be.within(0, MAX_BIP32_INDEX, `${family} must be a valid coin type`); + }); + }); + + it('values should be unique between families', function () { + new Set(values).size.should.equal(values.length); + }); + + it('every coin should carry its family value', function () { + coins.forEach((coin, name) => { + should(coin.bip44CoinType).equal(getBip44CoinType(coin.family), `'${name}' must carry its family's value`); + }); + }); + + it('testnets and tokens should mirror their parent chain', function () { + should(coins.get('btc').bip44CoinType).equal(0); + should(coins.get('tbtc').bip44CoinType).equal(coins.get('btc').bip44CoinType); + should(coins.get('usdc').bip44CoinType).equal(coins.get('eth').bip44CoinType); + }); + + it('every BIP44-derivable coin should have a bip44CoinType', function () { + const missing = new Set(); + coins.forEach((coin) => { + if (isBip44Derivable(coin) && coin.bip44CoinType === undefined) { + missing.add(coin.family); + } + }); + [...missing].sort().should.be.empty(); + }); +}); diff --git a/modules/statics/test/unit/safe.ts b/modules/statics/test/unit/safe.ts new file mode 100644 index 00000000000..afa675ad168 --- /dev/null +++ b/modules/statics/test/unit/safe.ts @@ -0,0 +1,7 @@ +import { SAFE_ROOT_SLOT_ORDINALS } from '../../src'; + +describe('SAFE_ROOT_SLOT_ORDINALS', function () { + it('should assign the ordinals 1..4 in slot order', function () { + Object.values(SAFE_ROOT_SLOT_ORDINALS).should.deepEqual([1, 2, 3, 4]); + }); +});