diff --git a/CHANGELOG.md b/CHANGELOG.md index d46b2c7de..244f24080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- added: (EVM) Save the BIP-44 derivation path in wallet keys, so wallets created before a coin type correction keep deriving at the path their stored private key was built from. +- fixed: (Avalanche) Derive C-Chain addresses at coin type 60 so seeds imported from EVM wallets (Exodus, MetaMask, Trust) produce a matching receive address. Existing wallets are unaffected. + ## 4.82.1 (2026-05-27) - changed: (FIO) Replace forked `@fioprotocol/fiosdk` with official npm 1.10.3. diff --git a/src/ethereum/EthereumTools.ts b/src/ethereum/EthereumTools.ts index af6dfbeb5..b27d50ddc 100644 --- a/src/ethereum/EthereumTools.ts +++ b/src/ethereum/EthereumTools.ts @@ -37,6 +37,12 @@ import { } from './ethereumTypes' import { RpcAdapterConfig } from './networkAdapters/RpcAdapter' +/** + * The BIP-44 path an EVM wallet's first account lives at. + */ +const makeHdPath = (hdPathCoinType: number): string => + `m/44'/${hdPathCoinType}'/0'/0/0` + export class EthereumTools implements EdgeCurrencyTools { builtinTokens: EdgeTokenMap currencyInfo: EdgeCurrencyInfo @@ -94,10 +100,12 @@ export class EthereumTools implements EdgeCurrencyTools { // was just the wrong length throw new Error('Invalid input') } - const hexKey = await this._mnemonicToHex(userInput) + const derivationPath = makeHdPath(this.networkInfo.hdPathCoinType) + const hexKey = await this._mnemonicToHex(userInput, derivationPath) return { [pluginMnemonicKeyName]: userInput, - [pluginRegularKeyName]: hexKey + [pluginRegularKeyName]: hexKey, + derivationPath } } } @@ -113,17 +121,24 @@ export class EthereumTools implements EdgeCurrencyTools { const entropy = Buffer.from(this.io.random(32)) const mnemonicKey = entropyToMnemonic(entropy) - const hexKey = await this._mnemonicToHex(mnemonicKey) // will not have 0x in it + const derivationPath = makeHdPath(this.networkInfo.hdPathCoinType) + // will not have 0x in it: + const hexKey = await this._mnemonicToHex(mnemonicKey, derivationPath) return { [pluginMnemonicKeyName]: mnemonicKey, - [pluginRegularKeyName]: hexKey + [pluginRegularKeyName]: hexKey, + derivationPath } } async derivePublicKey(walletInfo: EdgeWalletInfo): Promise { const { pluginId } = this.currencyInfo - const { hdPathCoinType, pluginMnemonicKeyName, pluginRegularKeyName } = - this.networkInfo + const { + hdPathCoinType, + legacyHdPathCoinType, + pluginMnemonicKeyName, + pluginRegularKeyName + } = this.networkInfo if (walletInfo.type !== `wallet:${pluginId}`) { throw new Error('Invalid wallet type') } @@ -134,8 +149,17 @@ export class EthereumTools implements EdgeCurrencyTools { walletInfo.keys[pluginMnemonicKeyName] ) const hdwallet = hdKey.fromMasterSeed(seedBuffer) - const walletHdpath = `m/44'/${hdPathCoinType}'/0'/0` - const walletPathDerivation = hdwallet.derivePath(`${walletHdpath}/0`) + // Wallets created since the plugin started saving `derivationPath` carry + // the path they were built from. Older ones do not, and their stored + // private key was derived from the coin type of that era, so they must + // keep deriving from it or the address would stop matching the key that + // signs for it. + const savedPath = walletInfo.keys.derivationPath + const walletHdpath = + typeof savedPath === 'string' + ? savedPath + : makeHdPath(legacyHdPathCoinType ?? hdPathCoinType) + const walletPathDerivation = hdwallet.derivePath(walletHdpath) const wallet = walletPathDerivation.getWallet() const publicKey = wallet.getPublicKey() const addressHex = EthereumUtil.pubToAddress(publicKey).toString('hex') @@ -158,11 +182,9 @@ export class EthereumTools implements EdgeCurrencyTools { return { publicKey: address } } - async _mnemonicToHex(mnemonic: string): Promise { - const { hdPathCoinType } = this.networkInfo + async _mnemonicToHex(mnemonic: string, path: string): Promise { const hdwallet = hdKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)) - const walletHdpath = `m/44'/${hdPathCoinType}'/0'/0` - const walletPathDerivation = hdwallet.derivePath(`${walletHdpath}/0`) + const walletPathDerivation = hdwallet.derivePath(path) const wallet = walletPathDerivation.getWallet() const privKey = wallet.getPrivateKeyString().replace(/^0x/, '') return privKey diff --git a/src/ethereum/ethereumTypes.ts b/src/ethereum/ethereumTypes.ts index 744bb36b5..1ade4bc5b 100644 --- a/src/ethereum/ethereumTypes.ts +++ b/src/ethereum/ethereumTypes.ts @@ -110,6 +110,10 @@ export interface EthereumNetworkInfo { ercTokenStandard: string evmGasStationUrl: string | null hdPathCoinType: number + // Coin type wallets used before `hdPathCoinType` was corrected. Wallets + // created back then have no `derivationPath` in their keys, so they derive + // from this instead and keep the address their stored private key signs for. + legacyHdPathCoinType?: number networkFees: EthereumFees pluginMnemonicKeyName: string pluginRegularKeyName: string @@ -461,6 +465,7 @@ export const asSafeEthWalletInfo = asSafeCommonWalletInfo export interface EthereumPrivateKeys { mnemonic?: string privateKey: string + derivationPath?: string } export const asEthereumPrivateKeys = ( pluginId: string @@ -472,12 +477,13 @@ export const asEthereumPrivateKeys = ( } & { [key in `${PluginId}Mnemonic`]?: string - } + } & { derivationPath?: string } const _pluginId = pluginId as PluginId // Derived cleaners from the generic parameter: const asFromKeys: Cleaner = asObject({ [`${_pluginId}Mnemonic`]: asOptional(asString), - [`${_pluginId}Key`]: asString + [`${_pluginId}Key`]: asString, + derivationPath: asOptional(asString) }) as Cleaner const asFromJackedKeys = asObject({ keys: asFromKeys }) @@ -488,7 +494,8 @@ export const asEthereumPrivateKeys = ( if (fromJacked != null) { const to: EthereumPrivateKeys = { mnemonic: fromJacked.keys[`${_pluginId}Mnemonic`], - privateKey: fromJacked.keys[`${_pluginId}Key`] + privateKey: fromJacked.keys[`${_pluginId}Key`], + derivationPath: fromJacked.keys.derivationPath } return to } @@ -497,14 +504,16 @@ export const asEthereumPrivateKeys = ( const from = asFromKeys(value) const to: EthereumPrivateKeys = { mnemonic: from[`${_pluginId}Mnemonic`], - privateKey: from[`${_pluginId}Key`] + privateKey: from[`${_pluginId}Key`], + derivationPath: from.derivationPath } return to }, ethPrivateKey => { return { [`${_pluginId}Mnemonic`]: ethPrivateKey.mnemonic, - [`${_pluginId}Key`]: ethPrivateKey.privateKey + [`${_pluginId}Key`]: ethPrivateKey.privateKey, + derivationPath: ethPrivateKey.derivationPath } } ) diff --git a/src/ethereum/info/avalancheInfo.ts b/src/ethereum/info/avalancheInfo.ts index 98d2ecd0f..639d33943 100644 --- a/src/ethereum/info/avalancheInfo.ts +++ b/src/ethereum/info/avalancheInfo.ts @@ -212,7 +212,10 @@ const networkInfo: EthereumNetworkInfo = { name: 'AVAX Mainnet' }, supportsEIP1559: true, - hdPathCoinType: 9000, + // AVAX C-Chain is EVM and standard wallets (Exodus, MetaMask, Trust) derive it + // at Ethereum's coin type so imported seeds yield a matching receive address. + hdPathCoinType: 60, + legacyHdPathCoinType: 9000, pluginMnemonicKeyName: 'avalancheMnemonic', pluginRegularKeyName: 'avalancheKey', evmGasStationUrl: null, diff --git a/test/ethereum/avalancheDerivation.test.ts b/test/ethereum/avalancheDerivation.test.ts new file mode 100644 index 000000000..db75835ab --- /dev/null +++ b/test/ethereum/avalancheDerivation.test.ts @@ -0,0 +1,124 @@ +import { assert } from 'chai' +import { + EdgeCorePluginOptions, + EdgeCurrencyPlugin, + EdgeCurrencyTools, + JsonObject, + makeFakeIo +} from 'edge-core-js' +import { before, describe, it } from 'mocha' +import fetch from 'node-fetch' + +import edgeCorePlugins from '../../src/index' +import { fakeLog } from '../fake/fakeLog' + +// A 12-word seed and the receive address it yields on EVM wallets (Exodus, +// MetaMask, Trust) which all derive at coin type 60. Ethereum, Polygon and +// Avalanche C-Chain are all EVM, so importing this seed into Edge must produce +// the same address on each. Avalanche was the regression (it derived at coin +// type 9000); Ethereum and Polygon are the chains the report flagged as most +// important, so they are locked here too. +const MNEMONIC = + 'room soda device label bicycle hill fork nest lion knee purpose hen' +const EXPECTED_EVM_ADDRESS = '0x21D45Fd06e291C49AbFa135460DE827b6579Cef5' + +// What the same seed yields at Avalanche's old coin type 9000. Wallets created +// before the correction hold a private key derived at that path, so they must +// keep resolving to this address. +const LEGACY_AVAX_ADDRESS = '0xc0Ee5411B61513Bea1853692463e28D6c32A0b50' + +const makeOpts = (): EdgeCorePluginOptions => { + const fakeIo = makeFakeIo() + return { + infoPayload: {}, + initOptions: {}, + io: { ...fakeIo, fetch, fetchCors: fetch }, + log: fakeLog, + nativeIo: {}, + pluginDisklet: fakeIo.disklet + } +} + +const makeTools = async ( + pluginId: keyof typeof edgeCorePlugins +): Promise => { + const factory = edgeCorePlugins[pluginId] + const plugin: EdgeCurrencyPlugin = factory(makeOpts()) + return await plugin.makeCurrencyTools() +} + +/** `EdgeCurrencyTools.importPrivateKey` is optional, but EVM plugins have it. */ +const importPrivateKey = async ( + tools: EdgeCurrencyTools, + userInput: string +): Promise => { + if (tools.importPrivateKey == null) { + throw new Error('Plugin does not support importPrivateKey') + } + return await tools.importPrivateKey(userInput) +} + +const CASES = [ + { pluginId: 'ethereum', mnemonicKey: 'ethereumMnemonic' }, + { pluginId: 'polygon', mnemonicKey: 'polygonMnemonic' }, + { pluginId: 'avalanche', mnemonicKey: 'avalancheMnemonic' } +] as const + +describe('EVM derivation parity (coin type 60)', function () { + for (const { pluginId, mnemonicKey } of CASES) { + describe(pluginId, function () { + let tools: EdgeCurrencyTools + + before('Tools', async function () { + tools = await makeTools(pluginId) + }) + + it('derives the Exodus-matching EVM address from an imported seed', async function () { + const importedKeys = await importPrivateKey(tools, MNEMONIC) + assert.equal(importedKeys[mnemonicKey], MNEMONIC) + assert.equal(importedKeys.derivationPath, "m/44'/60'/0'/0/0") + + const keys = await tools.derivePublicKey({ + id: 'id', + keys: importedKeys, + type: `wallet:${pluginId}` + }) + assert.equal(keys.publicKey, EXPECTED_EVM_ADDRESS) + }) + }) + } +}) + +describe('Avalanche legacy wallets', function () { + let tools: EdgeCurrencyTools + + before('Tools', async function () { + tools = await makeTools('avalanche') + }) + + it('keeps the coin type 9000 address when no path was saved', async function () { + // The shape of a wallet created before the plugin saved `derivationPath`: + // its stored private key was derived at coin type 9000, so re-deriving the + // public key on a new device has to land on the same address. + const keys = await tools.derivePublicKey({ + id: 'id', + keys: { avalancheMnemonic: MNEMONIC }, + type: 'wallet:avalanche' + }) + assert.equal(keys.publicKey, LEGACY_AVAX_ADDRESS) + }) + + it('derives the private key at the saved path', async function () { + const importedKeys = await importPrivateKey(tools, MNEMONIC) + const publicKeys = await tools.derivePublicKey({ + id: 'id', + // Only the private key, as if the mnemonic were never saved: + keys: { avalancheKey: importedKeys.avalancheKey }, + type: 'wallet:avalanche' + }) + assert.equal( + publicKeys.publicKey.toLowerCase(), + EXPECTED_EVM_ADDRESS.toLowerCase() + ) + }) +})