Skip to content

Commit 2d9df01

Browse files
committed
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 <noreply@anthropic.com> TICKET: WCI-391
1 parent 8b304dd commit 2d9df01

4 files changed

Lines changed: 106 additions & 4 deletions

File tree

modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ export interface VerifyAddressOptions {
159159
* For SMC (Self-Managed Custodial) TSS wallets, this is used to compute the derivation prefix.
160160
*/
161161
derivedFromParentWithSeed?: string;
162+
/**
163+
* Identifies the MPC signing protocol version of the wallet (e.g. 'MPCv2').
164+
* Used to distinguish between MPCv1 and MPCv2 wallets.
165+
*/
166+
multisigTypeVersion?: 'MPCv2';
162167
}
163168

164169
/**
@@ -187,6 +192,11 @@ export interface TssVerifyAddressOptions {
187192
* The derivation path becomes {computedPrefix}/{index} instead of m/{index}.
188193
*/
189194
derivedFromParentWithSeed?: string;
195+
/**
196+
* Identifies the MPC signing protocol version of the wallet (e.g. 'MPCv2').
197+
* Used to distinguish between MPCv1 and MPCv2 wallets.
198+
*/
199+
multisigTypeVersion?: 'MPCv2';
190200
}
191201

192202
export function isTssVerifyAddressOptions<T extends VerifyAddressOptions | TssVerifyAddressOptions>(

modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
1+
import { getDerivationPath, deriveUnhardenedMps } from '@bitgo/sdk-lib-mpc';
22
import { Ecdsa } from '../../../account-lib/mpc';
33
import { TssVerifyAddressOptions } from '../../baseCoin/iBaseCoin';
44
import { InvalidAddressError } from '../../errors';
@@ -72,15 +72,22 @@ export async function verifyMPCWalletAddress(
7272
throw new InvalidAddressError(`invalid address: ${address}`);
7373
}
7474

75-
const MPC = params.keyCurve === 'secp256k1' ? new Ecdsa() : await EDDSAMethods.getInitializedMpcInstance();
7675
const commonKeychain = extractCommonKeychain(keychains);
7776

7877
// Compute derivation path:
7978
// - For SMC wallets with derivedFromParentWithSeed, compute prefix and use: {prefix}/{index}
8079
// - For other wallets, use simple path: m/{index}
8180
const prefix = derivedFromParentWithSeed ? getDerivationPath(derivedFromParentWithSeed.toString()) : undefined;
8281
const derivationPath = prefix ? `${prefix}/${index}` : `m/${index}`;
83-
const derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath);
82+
83+
// MPCv2 EdDSA wallets use a different BIP32-Ed25519 derivation formula than MPCv1 wallets.
84+
let derivedPublicKey: string;
85+
if (params.keyCurve === 'ed25519' && params.multisigTypeVersion === 'MPCv2') {
86+
derivedPublicKey = deriveUnhardenedMps(commonKeychain, derivationPath);
87+
} else {
88+
const MPC = params.keyCurve === 'secp256k1' ? new Ecdsa() : await EDDSAMethods.getInitializedMpcInstance();
89+
derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath);
90+
}
8491

8592
// secp256k1 expects 33 bytes; ed25519 expects 32 bytes
8693
const publicKeySize = params.keyCurve === 'secp256k1' ? 33 : 32;

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,6 +1413,7 @@ export class Wallet implements IWallet {
14131413
const verificationData: VerifyAddressOptions = _.merge({}, newAddress, {
14141414
rootAddress,
14151415
walletVersion: _.get(this._wallet, 'coinSpecific.walletVersion'),
1416+
multisigTypeVersion: this.multisigTypeVersion(),
14161417
});
14171418

14181419
if (verificationData.error) {

modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import * as assert from 'assert';
22
import 'should';
3-
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
3+
import { deriveUnhardenedMps, getDerivationPath } from '@bitgo/sdk-lib-mpc';
44

55
function getAddressVerificationModule() {
66
return require('../../../../../src/bitgo/utils/tss/addressVerification');
77
}
88

99
const getExtractCommonKeychain = () => getAddressVerificationModule().extractCommonKeychain;
10+
const getVerifyEddsaTssWalletAddress = () => getAddressVerificationModule().verifyEddsaTssWalletAddress;
11+
12+
// RFC 8032 test vector: known valid Ed25519 public key + arbitrary chaincode = 128 hex chars.
13+
const TEST_PK = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a';
14+
const TEST_CHAINCODE = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
15+
const TEST_KEYCHAIN = TEST_PK + TEST_CHAINCODE;
1016

1117
describe('TSS Address Verification - Derivation Path with Prefix', function () {
1218
const commonKeychain =
@@ -61,3 +67,81 @@ describe('TSS Address Verification - Derivation Path with Prefix', function () {
6167
});
6268
});
6369
});
70+
71+
describe('verifyEddsaTssWalletAddress', function () {
72+
const keychains = [
73+
{ commonKeychain: TEST_KEYCHAIN },
74+
{ commonKeychain: TEST_KEYCHAIN },
75+
{ commonKeychain: TEST_KEYCHAIN },
76+
];
77+
const isValidAddress = (addr: string) => addr.length === 64;
78+
const getAddressFromPublicKey = (pk: string) => pk;
79+
80+
describe('MPCv2 wallets (Silence Labs / MPS formula)', function () {
81+
it('verifies a correct address derived with deriveUnhardenedMps at index 0', async function () {
82+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
83+
const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64);
84+
85+
const result = await verifyEddsaTssWalletAddress(
86+
{ address: expectedAddress, keychains, index: 0, multisigTypeVersion: 'MPCv2' },
87+
isValidAddress,
88+
getAddressFromPublicKey
89+
);
90+
result.should.be.true();
91+
});
92+
93+
it('verifies a correct address derived with deriveUnhardenedMps at index 1', async function () {
94+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
95+
const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/1').slice(0, 64);
96+
97+
const result = await verifyEddsaTssWalletAddress(
98+
{ address: expectedAddress, keychains, index: 1, multisigTypeVersion: 'MPCv2' },
99+
isValidAddress,
100+
getAddressFromPublicKey
101+
);
102+
result.should.be.true();
103+
});
104+
105+
it('rejects an address derived at a different index', async function () {
106+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
107+
const addressFromIndex0 = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64);
108+
109+
const result = await verifyEddsaTssWalletAddress(
110+
{ address: addressFromIndex0, keychains, index: 1, multisigTypeVersion: 'MPCv2' },
111+
isValidAddress,
112+
getAddressFromPublicKey
113+
);
114+
result.should.be.false();
115+
});
116+
117+
it('rejects a random address that was not derived from the keychain', async function () {
118+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
119+
const randomAddress = 'ab'.repeat(32); // 64 hex chars, wrong address
120+
121+
const result = await verifyEddsaTssWalletAddress(
122+
{ address: randomAddress, keychains, index: 0, multisigTypeVersion: 'MPCv2' },
123+
isValidAddress,
124+
getAddressFromPublicKey
125+
);
126+
result.should.be.false();
127+
});
128+
});
129+
130+
describe('non-MPCv2 wallets (MPCv1 formula)', function () {
131+
it('rejects an MPCv2-derived address when multisigTypeVersion is not set', async function () {
132+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
133+
// MPCv2 (Silence Labs) and MPCv1 formulas produce different addresses for the same keychain.
134+
// Without multisigTypeVersion: 'MPCv2', the MPCv1 formula is used, so the MPCv2-derived
135+
// address should not match.
136+
const mpcv2Address = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64);
137+
138+
const result = await verifyEddsaTssWalletAddress(
139+
{ address: mpcv2Address, keychains, index: 0 },
140+
isValidAddress,
141+
getAddressFromPublicKey
142+
);
143+
144+
result.should.be.false();
145+
});
146+
});
147+
});

0 commit comments

Comments
 (0)