Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 34 additions & 12 deletions src/ethereum/EthereumTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Expand All @@ -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<Object> {
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')
}
Expand All @@ -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')
Expand All @@ -158,11 +182,9 @@ export class EthereumTools implements EdgeCurrencyTools {
return { publicKey: address }
}

async _mnemonicToHex(mnemonic: string): Promise<string> {
const { hdPathCoinType } = this.networkInfo
async _mnemonicToHex(mnemonic: string, path: string): Promise<string> {
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
Expand Down
19 changes: 14 additions & 5 deletions src/ethereum/ethereumTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -461,6 +465,7 @@ export const asSafeEthWalletInfo = asSafeCommonWalletInfo
export interface EthereumPrivateKeys {
mnemonic?: string
privateKey: string
derivationPath?: string
}
export const asEthereumPrivateKeys = (
pluginId: string
Expand All @@ -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<FromKeys> = asObject({
[`${_pluginId}Mnemonic`]: asOptional(asString),
[`${_pluginId}Key`]: asString
[`${_pluginId}Key`]: asString,
derivationPath: asOptional(asString)
}) as Cleaner<any>
const asFromJackedKeys = asObject({ keys: asFromKeys })

Expand All @@ -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
}
Expand All @@ -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
}
}
)
Expand Down
5 changes: 4 additions & 1 deletion src/ethereum/info/avalancheInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
j0ntz marked this conversation as resolved.
legacyHdPathCoinType: 9000,
pluginMnemonicKeyName: 'avalancheMnemonic',
pluginRegularKeyName: 'avalancheKey',
evmGasStationUrl: null,
Expand Down
124 changes: 124 additions & 0 deletions test/ethereum/avalancheDerivation.test.ts
Original file line number Diff line number Diff line change
@@ -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<EdgeCurrencyTools> => {
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<JsonObject> => {
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()
)
})
})