diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 9a102036e2..89e3bd9482 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -41,8 +41,11 @@ import { VerifyTransactionOptions, Wallet, verifyMPCWalletAddress, + deriveMPCWalletAddress, TssVerifyAddressOptions, isTssVerifyAddressOptions, + DeriveAddressOptions, + DeriveAddressResult, } from '@bitgo/sdk-core'; import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { bip32 } from '@bitgo/secp256k1'; @@ -3040,6 +3043,43 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { throw new Error(`Base address verification not supported for wallet version ${params.walletVersion}`); } + /** + * Locally derive an ETH wallet receive address from a derivation path, using the wallet's + * commonKeychain only (no private keys, no network access). The inverse of + * {@link isWalletAddress}: it *produces* the address via the same secp256k1 MPC derivation + * that isWalletAddress checks against (`deriveMPCWalletAddress` + `KeyPair.getAddress()`), + * so derive and verify can never diverge. + * + * Supports MPC/TSS wallets only (wallet versions 3, 5, 6). Legacy BIP32 forwarder wallets + * (versions 1, 2, 4) derive per-index forwarder contract addresses and are handled separately. + * @param params keychains (commonKeychain), derivation index, walletVersion, and optional SMC seed + * @returns the derived address, the index used, and the HD derivation path + */ + async deriveAddress(params: DeriveAddressOptions): Promise { + const isMpcWallet = params.walletVersion === 3 || params.walletVersion === 5 || params.walletVersion === 6; + if (!isMpcWallet) { + throw new Error( + `deriveAddress currently supports only MPC/TSS ETH wallets (wallet versions 3, 5, 6). ` + + `Legacy BIP32 forwarder wallets (versions 1, 2, 4) are not yet supported. ` + + `Got walletVersion ${params.walletVersion}.` + ); + } + + const { address, derivationPath } = await deriveMPCWalletAddress( + { + // extractCommonKeychain validates the commonKeychain is present at runtime + keychains: (params.keychains ?? []) as TssVerifyAddressOptions['keychains'], + index: params.index, + derivedFromParentWithSeed: params.derivedFromParentWithSeed, + multisigTypeVersion: params.multisigTypeVersion, + keyCurve: 'secp256k1', + }, + (pubKey) => new KeyPairLib({ pub: pubKey }).getAddress() + ); + + return { address, index: params.index, derivationPath }; + } + /** * * @param {TransactionPrebuild} txPrebuild diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index b544ea7bb0..8534388aa4 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -1486,6 +1486,56 @@ describe('ETH:', function () { }); }); + describe('deriveAddress (MPC)', function () { + // Same vector as the MPC isWalletAddress tests above: + // commonKeychain @ index 0 -> 0x01153f3adfe454a72589ca9ef74f013c19e54961 + const commonKeychain = + '03f9c2fb2e5a8b78a44f5d1e4f906f8e3d7a0e6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9e8d7c6b5a4' + + '93827160594857463728190a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + const keychains = [ + { pub: 'user_pub', commonKeychain }, + { pub: 'backup_pub', commonKeychain }, + { pub: 'bitgo_pub', commonKeychain }, + ]; + + it('should derive the expected MPC address for a commonKeychain at index 0', async function () { + const coin = bitgo.coin('teth') as Teth; + const result = await coin.deriveAddress({ keychains, index: 0, walletVersion: 3 }); + result.address.should.equal('0x01153f3adfe454a72589ca9ef74f013c19e54961'); + result.index.should.equal(0); + assert.strictEqual(result.derivationPath, 'm/0'); + }); + + it('round-trips with isWalletAddress (derived address verifies as true)', async function () { + const coin = bitgo.coin('teth') as Teth; + const derived = await coin.deriveAddress({ keychains, index: 3, walletVersion: 6 }); + const verified = await coin.isWalletAddress({ + address: derived.address, + coinSpecific: { forwarderVersion: 5 }, + keychains, + index: 3, + walletVersion: 6, + } as unknown as TssVerifyEthAddressOptions); + verified.should.equal(true); + }); + + it('should throw for legacy BIP32 forwarder wallet versions (1/2/4)', async function () { + const coin = bitgo.coin('teth') as Teth; + await assert.rejects( + async () => coin.deriveAddress({ keychains, index: 0, walletVersion: 1 }), + /only MPC\/TSS ETH wallets/ + ); + }); + + it('should throw if keychains are missing', async function () { + const coin = bitgo.coin('teth') as Teth; + await assert.rejects( + async () => coin.deriveAddress({ keychains: [], index: 0, walletVersion: 3 }), + /keychains/ + ); + }); + }); + describe('Base Address Verification', function () { it('should verify base address for wallet version 6 (TSS)', async function () { const coin = bitgo.coin('hteth') as Hteth;