Skip to content

Commit dc870bb

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 ab22209 commit dc870bb

5 files changed

Lines changed: 261 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: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
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';
4+
import { Ecdsa } from '../../../../../src/account-lib/mpc';
45

56
function getAddressVerificationModule() {
67
return require('../../../../../src/bitgo/utils/tss/addressVerification');
78
}
89

910
const getExtractCommonKeychain = () => getAddressVerificationModule().extractCommonKeychain;
11+
const getVerifyEddsaTssWalletAddress = () => getAddressVerificationModule().verifyEddsaTssWalletAddress;
12+
const getVerifyMPCWalletAddress = () => getAddressVerificationModule().verifyMPCWalletAddress;
13+
14+
// RFC 8032 test vector: known valid Ed25519 public key + arbitrary chaincode = 128 hex chars.
15+
const TEST_PK = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a';
16+
const TEST_CHAINCODE = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
17+
const TEST_KEYCHAIN = TEST_PK + TEST_CHAINCODE;
18+
19+
// secp256k1 generator point G (compressed, 33 bytes = 66 hex) + same chaincode = 130 hex chars.
20+
const ECDSA_PK = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
21+
const ECDSA_KEYCHAIN = ECDSA_PK + TEST_CHAINCODE;
1022

1123
describe('TSS Address Verification - Derivation Path with Prefix', function () {
1224
const commonKeychain =
@@ -61,3 +73,174 @@ describe('TSS Address Verification - Derivation Path with Prefix', function () {
6173
});
6274
});
6375
});
76+
77+
describe('verifyEddsaTssWalletAddress', function () {
78+
const keychains = [
79+
{ commonKeychain: TEST_KEYCHAIN },
80+
{ commonKeychain: TEST_KEYCHAIN },
81+
{ commonKeychain: TEST_KEYCHAIN },
82+
];
83+
const isValidAddress = (addr: string) => addr.length === 64;
84+
const getAddressFromPublicKey = (pk: string) => pk;
85+
86+
describe('MPCv2 wallets (Silence Labs / MPS formula)', function () {
87+
it('verifies a correct address derived with deriveUnhardenedMps at index 0', async function () {
88+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
89+
const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64);
90+
91+
const result = await verifyEddsaTssWalletAddress(
92+
{ address: expectedAddress, keychains, index: 0, multisigTypeVersion: 'MPCv2' },
93+
isValidAddress,
94+
getAddressFromPublicKey
95+
);
96+
result.should.be.true();
97+
});
98+
99+
it('verifies a correct address derived with deriveUnhardenedMps at index 1', async function () {
100+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
101+
const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/1').slice(0, 64);
102+
103+
const result = await verifyEddsaTssWalletAddress(
104+
{ address: expectedAddress, keychains, index: 1, multisigTypeVersion: 'MPCv2' },
105+
isValidAddress,
106+
getAddressFromPublicKey
107+
);
108+
result.should.be.true();
109+
});
110+
111+
it('rejects an address derived at a different index', async function () {
112+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
113+
const addressFromIndex0 = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64);
114+
115+
const result = await verifyEddsaTssWalletAddress(
116+
{ address: addressFromIndex0, keychains, index: 1, multisigTypeVersion: 'MPCv2' },
117+
isValidAddress,
118+
getAddressFromPublicKey
119+
);
120+
result.should.be.false();
121+
});
122+
123+
it('rejects a random address that was not derived from the keychain', async function () {
124+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
125+
const randomAddress = 'ab'.repeat(32); // 64 hex chars, wrong address
126+
127+
const result = await verifyEddsaTssWalletAddress(
128+
{ address: randomAddress, keychains, index: 0, multisigTypeVersion: 'MPCv2' },
129+
isValidAddress,
130+
getAddressFromPublicKey
131+
);
132+
result.should.be.false();
133+
});
134+
135+
it('verifies a correct MPCv2 address for SMC wallet using derivedFromParentWithSeed', async function () {
136+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
137+
const seed = 'smc-seed-123';
138+
const prefix = getDerivationPath(seed);
139+
const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, `${prefix}/0`).slice(0, 64);
140+
141+
const result = await verifyEddsaTssWalletAddress(
142+
{
143+
address: expectedAddress,
144+
keychains,
145+
index: 0,
146+
multisigTypeVersion: 'MPCv2',
147+
derivedFromParentWithSeed: seed,
148+
},
149+
isValidAddress,
150+
getAddressFromPublicKey
151+
);
152+
result.should.be.true();
153+
});
154+
155+
it('rejects an MPCv2 address derived at the wrong index when derivedFromParentWithSeed is set', async function () {
156+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
157+
const seed = 'smc-seed-123';
158+
const prefix = getDerivationPath(seed);
159+
const addressAtIndex1 = deriveUnhardenedMps(TEST_KEYCHAIN, `${prefix}/1`).slice(0, 64);
160+
161+
const result = await verifyEddsaTssWalletAddress(
162+
{
163+
address: addressAtIndex1,
164+
keychains,
165+
index: 0,
166+
multisigTypeVersion: 'MPCv2',
167+
derivedFromParentWithSeed: seed,
168+
},
169+
isValidAddress,
170+
getAddressFromPublicKey
171+
);
172+
result.should.be.false();
173+
});
174+
});
175+
176+
describe('non-MPCv2 wallets (MPCv1 formula)', function () {
177+
it('rejects an MPCv2-derived address when multisigTypeVersion is not set', async function () {
178+
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
179+
// MPCv2 (Silence Labs) and MPCv1 formulas produce different addresses for the same keychain.
180+
// Without multisigTypeVersion: 'MPCv2', the MPCv1 formula is used, so the MPCv2-derived
181+
// address should not match.
182+
const mpcv2Address = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64);
183+
184+
const result = await verifyEddsaTssWalletAddress(
185+
{ address: mpcv2Address, keychains, index: 0 },
186+
isValidAddress,
187+
getAddressFromPublicKey
188+
);
189+
190+
result.should.be.false();
191+
});
192+
});
193+
});
194+
195+
describe('verifyMPCWalletAddress - ECDSA (secp256k1)', function () {
196+
const ecdsaKeychains = [
197+
{ commonKeychain: ECDSA_KEYCHAIN },
198+
{ commonKeychain: ECDSA_KEYCHAIN },
199+
{ commonKeychain: ECDSA_KEYCHAIN },
200+
];
201+
// secp256k1 compressed public key is 33 bytes = 66 hex chars
202+
const isValidEcdsaAddress = (addr: string) => addr.length === 66;
203+
const getAddressFromPublicKey = (pk: string) => pk;
204+
205+
it('ignores multisigTypeVersion MPCv2 and uses Ecdsa derivation for secp256k1 wallets', async function () {
206+
const verifyMPCWalletAddress = getVerifyMPCWalletAddress();
207+
const expectedAddress = new Ecdsa().deriveUnhardened(ECDSA_KEYCHAIN, 'm/0').slice(0, 66);
208+
209+
const result = await verifyMPCWalletAddress(
210+
{
211+
address: expectedAddress,
212+
keychains: ecdsaKeychains,
213+
index: 0,
214+
multisigTypeVersion: 'MPCv2',
215+
keyCurve: 'secp256k1',
216+
},
217+
isValidEcdsaAddress,
218+
getAddressFromPublicKey
219+
);
220+
result.should.be.true();
221+
});
222+
223+
it('verifies a correct secp256k1 address at index 1', async function () {
224+
const verifyMPCWalletAddress = getVerifyMPCWalletAddress();
225+
const expectedAddress = new Ecdsa().deriveUnhardened(ECDSA_KEYCHAIN, 'm/1').slice(0, 66);
226+
227+
const result = await verifyMPCWalletAddress(
228+
{ address: expectedAddress, keychains: ecdsaKeychains, index: 1, keyCurve: 'secp256k1' },
229+
isValidEcdsaAddress,
230+
getAddressFromPublicKey
231+
);
232+
result.should.be.true();
233+
});
234+
235+
it('rejects an address derived at a different index', async function () {
236+
const verifyMPCWalletAddress = getVerifyMPCWalletAddress();
237+
const addressAtIndex0 = new Ecdsa().deriveUnhardened(ECDSA_KEYCHAIN, 'm/0').slice(0, 66);
238+
239+
const result = await verifyMPCWalletAddress(
240+
{ address: addressAtIndex0, keychains: ecdsaKeychains, index: 1, keyCurve: 'secp256k1' },
241+
isValidEcdsaAddress,
242+
getAddressFromPublicKey
243+
);
244+
result.should.be.false();
245+
});
246+
});

modules/sdk-core/test/unit/bitgo/wallet/walletTssAddressVerification.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,62 @@ describe('Wallet - TSS Address Verification with Derivation Prefix', function ()
197197
});
198198
});
199199

200+
describe('MPCv2 Wallet - multisigTypeVersion threading', function () {
201+
beforeEach(function () {
202+
mockWalletData.multisigTypeVersion = 'MPCv2';
203+
wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);
204+
});
205+
206+
it('should thread multisigTypeVersion MPCv2 into verificationData', async function () {
207+
const mockAddressResponse = {
208+
id: 'address-id',
209+
address: '6FjshVqwmDH74wfxkZrJaRGEjTeJQL4ViL6X18VXUNAY',
210+
index: 0,
211+
coinSpecific: {},
212+
};
213+
214+
mockBitGo.post.returns({
215+
send: sinon.stub().returns({
216+
result: sinon.stub().resolves(mockAddressResponse),
217+
}),
218+
});
219+
220+
mockBaseCoin.isWalletAddress.resolves(true);
221+
222+
await wallet.createAddress({ chain: 0 });
223+
224+
const verificationCall = mockBaseCoin.isWalletAddress.getCall(0);
225+
const verificationData = verificationCall.args[0];
226+
assert.strictEqual(verificationData.multisigTypeVersion, 'MPCv2');
227+
});
228+
229+
it('should not set multisigTypeVersion when wallet does not have it', async function () {
230+
mockWalletData.multisigTypeVersion = undefined;
231+
wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);
232+
233+
const mockAddressResponse = {
234+
id: 'address-id',
235+
address: '6FjshVqwmDH74wfxkZrJaRGEjTeJQL4ViL6X18VXUNAY',
236+
index: 0,
237+
coinSpecific: {},
238+
};
239+
240+
mockBitGo.post.returns({
241+
send: sinon.stub().returns({
242+
result: sinon.stub().resolves(mockAddressResponse),
243+
}),
244+
});
245+
246+
mockBaseCoin.isWalletAddress.resolves(true);
247+
248+
await wallet.createAddress({ chain: 0 });
249+
250+
const verificationCall = mockBaseCoin.isWalletAddress.getCall(0);
251+
const verificationData = verificationCall.args[0];
252+
assert.strictEqual(verificationData.multisigTypeVersion, undefined);
253+
});
254+
});
255+
200256
describe('Edge Cases', function () {
201257
it('should handle wallet without USER keychain', async function () {
202258
// Set keys array to only have backup keychain (no USER keychain at index 0)

0 commit comments

Comments
 (0)