From 1b9d2f879adae880fc5923028ac31b9691d4872a Mon Sep 17 00:00:00 2001 From: Marzooqa Naeema Kather Date: Thu, 14 May 2026 23:49:46 +0530 Subject: [PATCH] feat(sdk-core): use deriveUnhardenedMps for EdDSA MPCv2 addresses Thread multisigTypeVersion from the wallet document through VerifyAddressOptions and TssVerifyAddressOptions so that verifyMPCWalletAddress can select the correct derivation formula. MPCv2 wallets use deriveUnhardenedMps (Silence Labs formula); MPCv1 wallets continue using Eddsa.deriveUnhardened. Co-Authored-By: Claude Sonnet 4.6 TICKET: WCI-391 --- .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 10 +++ .../bitgo/utils/tss/addressVerification.ts | 13 ++- modules/sdk-core/src/bitgo/wallet/wallet.ts | 1 + .../bitgo/utils/tss/addressVerification.ts | 86 ++++++++++++++++++- 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index a9ce31b312..80cf6d3236 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -159,6 +159,11 @@ export interface VerifyAddressOptions { * For SMC (Self-Managed Custodial) TSS wallets, this is used to compute the derivation prefix. */ derivedFromParentWithSeed?: string; + /** + * Identifies the MPC signing protocol version of the wallet (e.g. 'MPCv2'). + * Used to distinguish between MPCv1 and MPCv2 wallets. + */ + multisigTypeVersion?: 'MPCv2'; } /** @@ -187,6 +192,11 @@ export interface TssVerifyAddressOptions { * The derivation path becomes {computedPrefix}/{index} instead of m/{index}. */ derivedFromParentWithSeed?: string; + /** + * Identifies the MPC signing protocol version of the wallet (e.g. 'MPCv2'). + * Used to distinguish between MPCv1 and MPCv2 wallets. + */ + multisigTypeVersion?: 'MPCv2'; } export function isTssVerifyAddressOptions( diff --git a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts index 67e41d1ade..20e1a75332 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts @@ -1,4 +1,4 @@ -import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; +import { getDerivationPath, deriveUnhardenedMps } from '@bitgo/sdk-lib-mpc'; import { Ecdsa } from '../../../account-lib/mpc'; import { TssVerifyAddressOptions } from '../../baseCoin/iBaseCoin'; import { InvalidAddressError } from '../../errors'; @@ -72,7 +72,6 @@ export async function verifyMPCWalletAddress( throw new InvalidAddressError(`invalid address: ${address}`); } - const MPC = params.keyCurve === 'secp256k1' ? new Ecdsa() : await EDDSAMethods.getInitializedMpcInstance(); const commonKeychain = extractCommonKeychain(keychains); // Compute derivation path: @@ -80,7 +79,15 @@ export async function verifyMPCWalletAddress( // - For other wallets, use simple path: m/{index} const prefix = derivedFromParentWithSeed ? getDerivationPath(derivedFromParentWithSeed.toString()) : undefined; const derivationPath = prefix ? `${prefix}/${index}` : `m/${index}`; - const derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath); + + // MPCv2 EdDSA wallets use a different BIP32-Ed25519 derivation formula than MPCv1 wallets. + let derivedPublicKey: string; + if (params.keyCurve === 'ed25519' && params.multisigTypeVersion === 'MPCv2') { + derivedPublicKey = deriveUnhardenedMps(commonKeychain, derivationPath); + } else { + const MPC = params.keyCurve === 'secp256k1' ? new Ecdsa() : await EDDSAMethods.getInitializedMpcInstance(); + derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath); + } // secp256k1 expects 33 bytes; ed25519 expects 32 bytes const publicKeySize = params.keyCurve === 'secp256k1' ? 33 : 32; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index d05427cff5..a16b5d0574 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -1413,6 +1413,7 @@ export class Wallet implements IWallet { const verificationData: VerifyAddressOptions = _.merge({}, newAddress, { rootAddress, walletVersion: _.get(this._wallet, 'coinSpecific.walletVersion'), + multisigTypeVersion: this.multisigTypeVersion(), }); if (verificationData.error) { diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts index c9ce76bc84..e4926e3bf3 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts @@ -1,12 +1,18 @@ import * as assert from 'assert'; import 'should'; -import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; +import { deriveUnhardenedMps, getDerivationPath } from '@bitgo/sdk-lib-mpc'; function getAddressVerificationModule() { return require('../../../../../src/bitgo/utils/tss/addressVerification'); } const getExtractCommonKeychain = () => getAddressVerificationModule().extractCommonKeychain; +const getVerifyEddsaTssWalletAddress = () => getAddressVerificationModule().verifyEddsaTssWalletAddress; + +// RFC 8032 test vector: known valid Ed25519 public key + arbitrary chaincode = 128 hex chars. +const TEST_PK = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'; +const TEST_CHAINCODE = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; +const TEST_KEYCHAIN = TEST_PK + TEST_CHAINCODE; describe('TSS Address Verification - Derivation Path with Prefix', function () { const commonKeychain = @@ -61,3 +67,81 @@ describe('TSS Address Verification - Derivation Path with Prefix', function () { }); }); }); + +describe('verifyEddsaTssWalletAddress', function () { + const keychains = [ + { commonKeychain: TEST_KEYCHAIN }, + { commonKeychain: TEST_KEYCHAIN }, + { commonKeychain: TEST_KEYCHAIN }, + ]; + const isValidAddress = (addr: string) => addr.length === 64; + const getAddressFromPublicKey = (pk: string) => pk; + + describe('MPCv2 wallets (Silence Labs / MPS formula)', function () { + it('verifies a correct address derived with deriveUnhardenedMps at index 0', async function () { + const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress(); + const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64); + + const result = await verifyEddsaTssWalletAddress( + { address: expectedAddress, keychains, index: 0, multisigTypeVersion: 'MPCv2' }, + isValidAddress, + getAddressFromPublicKey + ); + result.should.be.true(); + }); + + it('verifies a correct address derived with deriveUnhardenedMps at index 1', async function () { + const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress(); + const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/1').slice(0, 64); + + const result = await verifyEddsaTssWalletAddress( + { address: expectedAddress, keychains, index: 1, multisigTypeVersion: 'MPCv2' }, + isValidAddress, + getAddressFromPublicKey + ); + result.should.be.true(); + }); + + it('rejects an address derived at a different index', async function () { + const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress(); + const addressFromIndex0 = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64); + + const result = await verifyEddsaTssWalletAddress( + { address: addressFromIndex0, keychains, index: 1, multisigTypeVersion: 'MPCv2' }, + isValidAddress, + getAddressFromPublicKey + ); + result.should.be.false(); + }); + + it('rejects a random address that was not derived from the keychain', async function () { + const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress(); + const randomAddress = 'ab'.repeat(32); // 64 hex chars, wrong address + + const result = await verifyEddsaTssWalletAddress( + { address: randomAddress, keychains, index: 0, multisigTypeVersion: 'MPCv2' }, + isValidAddress, + getAddressFromPublicKey + ); + result.should.be.false(); + }); + }); + + describe('non-MPCv2 wallets (MPCv1 formula)', function () { + it('rejects an MPCv2-derived address when multisigTypeVersion is not set', async function () { + const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress(); + // MPCv2 (Silence Labs) and MPCv1 formulas produce different addresses for the same keychain. + // Without multisigTypeVersion: 'MPCv2', the MPCv1 formula is used, so the MPCv2-derived + // address should not match. + const mpcv2Address = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64); + + const result = await verifyEddsaTssWalletAddress( + { address: mpcv2Address, keychains, index: 0 }, + isValidAddress, + getAddressFromPublicKey + ); + + result.should.be.false(); + }); + }); +});