Skip to content

Commit af60c9e

Browse files
authored
Merge pull request #8777 from BitGo/WCI-391
feat(sdk-core): use deriveUnhardenedMps for EdDSA MPCv2 addresses
2 parents 7ba64c6 + dc870bb commit af60c9e

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)