diff --git a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts index 20e1a75332..203d97c30e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts @@ -49,28 +49,35 @@ export async function verifyEddsaTssWalletAddress( } /** - * Verifies if an address belongs to a wallet using ECDSA TSS MPC derivation. - * This is a common implementation for ECDSA-based MPC coins (ETH, BTC, etc.) + * Options for deriving an MPC wallet address. This is the subset of + * {@link TssVerifyAddressOptions} needed to *produce* an address (no `address` to check), + * plus the key curve. + */ +export type DeriveMPCWalletAddressOptions = Pick< + TssVerifyAddressOptions, + 'keychains' | 'index' | 'derivedFromParentWithSeed' | 'multisigTypeVersion' +> & { + keyCurve: 'secp256k1' | 'ed25519'; +}; + +/** + * Derives a wallet address using TSS/MPC HD derivation from the commonKeychain, + * using public key material only (no private keys, no network access). + * Shared by EdDSA- and ECDSA-based MPC coins. * - * @param params - Verification options including keychains, address, and derivation index - * @param isValidAddress - Coin-specific function to validate address format + * This is the derivation half of {@link verifyMPCWalletAddress}: it *produces* the address + * rather than comparing it against a candidate, so the two can never diverge. + * + * @param params - keychains (commonKeychain), derivation index, optional seed/version, and keyCurve * @param getAddressFromPublicKey - Coin-specific function to convert public key to address - * @returns true if the address matches the derived address, false otherwise - * @throws {InvalidAddressError} if the address is invalid + * @returns the derived address and the HD derivation path used to derive it * @throws {Error} if required parameters are missing or invalid */ -export async function verifyMPCWalletAddress( - params: TssVerifyAddressOptions & { - keyCurve: 'secp256k1' | 'ed25519'; - }, - isValidAddress: (address: string) => boolean, +export async function deriveMPCWalletAddress( + params: DeriveMPCWalletAddressOptions, getAddressFromPublicKey: (publicKey: string) => string -): Promise { - const { keychains, address, index, derivedFromParentWithSeed } = params; - - if (!isValidAddress(address)) { - throw new InvalidAddressError(`invalid address: ${address}`); - } +): Promise<{ address: string; derivationPath: string }> { + const { keychains, index, derivedFromParentWithSeed } = params; const commonKeychain = extractCommonKeychain(keychains); @@ -93,7 +100,34 @@ export async function verifyMPCWalletAddress( const publicKeySize = params.keyCurve === 'secp256k1' ? 33 : 32; const publicKeyOnly = Buffer.from(derivedPublicKey, 'hex').subarray(0, publicKeySize).toString('hex'); - const expectedAddress = getAddressFromPublicKey(publicKeyOnly); + return { address: getAddressFromPublicKey(publicKeyOnly), derivationPath }; +} + +/** + * Verifies if an address belongs to a wallet using ECDSA TSS MPC derivation. + * This is a common implementation for ECDSA-based MPC coins (ETH, BTC, etc.) + * + * @param params - Verification options including keychains, address, and derivation index + * @param isValidAddress - Coin-specific function to validate address format + * @param getAddressFromPublicKey - Coin-specific function to convert public key to address + * @returns true if the address matches the derived address, false otherwise + * @throws {InvalidAddressError} if the address is invalid + * @throws {Error} if required parameters are missing or invalid + */ +export async function verifyMPCWalletAddress( + params: TssVerifyAddressOptions & { + keyCurve: 'secp256k1' | 'ed25519'; + }, + isValidAddress: (address: string) => boolean, + getAddressFromPublicKey: (publicKey: string) => string +): Promise { + const { address } = params; + + if (!isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + const { address: expectedAddress } = await deriveMPCWalletAddress(params, getAddressFromPublicKey); return address === expectedAddress; } 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 8f72b179e9..c9b84c4d0b 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts @@ -10,6 +10,7 @@ function getAddressVerificationModule() { const getExtractCommonKeychain = () => getAddressVerificationModule().extractCommonKeychain; const getVerifyEddsaTssWalletAddress = () => getAddressVerificationModule().verifyEddsaTssWalletAddress; const getVerifyMPCWalletAddress = () => getAddressVerificationModule().verifyMPCWalletAddress; +const getDeriveMPCWalletAddress = () => getAddressVerificationModule().deriveMPCWalletAddress; // RFC 8032 test vector: known valid Ed25519 public key + arbitrary chaincode = 128 hex chars. const TEST_PK = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'; @@ -244,3 +245,78 @@ describe('verifyMPCWalletAddress - ECDSA (secp256k1)', function () { result.should.be.false(); }); }); + +describe('deriveMPCWalletAddress', function () { + const getAddressFromPublicKey = (pk: string) => pk; + + describe('ed25519 (MPCv2)', function () { + const keychains = [{ commonKeychain: TEST_KEYCHAIN }, { commonKeychain: TEST_KEYCHAIN }]; + + it('derives the address and path for the simple m/{index} path', async function () { + const deriveMPCWalletAddress = getDeriveMPCWalletAddress(); + const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/3').slice(0, 64); + + const result = await deriveMPCWalletAddress( + { keychains, index: 3, multisigTypeVersion: 'MPCv2', keyCurve: 'ed25519' }, + getAddressFromPublicKey + ); + + result.address.should.equal(expectedAddress); + result.derivationPath.should.equal('m/3'); + }); + + it('uses the SMC prefix path when derivedFromParentWithSeed is set', async function () { + const deriveMPCWalletAddress = getDeriveMPCWalletAddress(); + const seed = 'smc-seed-123'; + const prefix = getDerivationPath(seed); + const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, `${prefix}/0`).slice(0, 64); + + const result = await deriveMPCWalletAddress( + { keychains, index: 0, multisigTypeVersion: 'MPCv2', derivedFromParentWithSeed: seed, keyCurve: 'ed25519' }, + getAddressFromPublicKey + ); + + result.address.should.equal(expectedAddress); + result.derivationPath.should.equal(`${prefix}/0`); + }); + }); + + describe('secp256k1', function () { + const ecdsaKeychains = [{ commonKeychain: ECDSA_KEYCHAIN }, { commonKeychain: ECDSA_KEYCHAIN }]; + + it('derives the secp256k1 address (33-byte pubkey)', async function () { + const deriveMPCWalletAddress = getDeriveMPCWalletAddress(); + const expectedAddress = new Ecdsa().deriveUnhardened(ECDSA_KEYCHAIN, 'm/2').slice(0, 66); + + const result = await deriveMPCWalletAddress( + { keychains: ecdsaKeychains, index: 2, keyCurve: 'secp256k1' }, + getAddressFromPublicKey + ); + + result.address.should.equal(expectedAddress); + result.derivationPath.should.equal('m/2'); + }); + }); + + describe('round-trip with verifyMPCWalletAddress', function () { + it('an address produced by deriveMPCWalletAddress verifies as true', async function () { + const deriveMPCWalletAddress = getDeriveMPCWalletAddress(); + const verifyMPCWalletAddress = getVerifyMPCWalletAddress(); + const ecdsaKeychains = [{ commonKeychain: ECDSA_KEYCHAIN }, { commonKeychain: ECDSA_KEYCHAIN }]; + const isValidEcdsaAddress = (addr: string) => addr.length === 66; + + const { address } = await deriveMPCWalletAddress( + { keychains: ecdsaKeychains, index: 7, keyCurve: 'secp256k1' }, + getAddressFromPublicKey + ); + + const verified = await verifyMPCWalletAddress( + { address, keychains: ecdsaKeychains, index: 7, keyCurve: 'secp256k1' }, + isValidEcdsaAddress, + getAddressFromPublicKey + ); + + verified.should.be.true(); + }); + }); +});