Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/SMC/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import assert from 'assert';
import {
EddsaBitgoToOVC1Round1Response,
EddsaBitgoToOVC1Round2Response,
EddsaKeyCreationMPCv2StateEnum,
EddsaMPCv2KeyGenRound1Response,
EddsaMPCv2KeyGenRound2Response,
EddsaOVC1ToBitgoRound1Payload,
EddsaOVC2ToBitgoRound2Payload,
OVCIndexEnum,
} from '@bitgo/public-types';
import { IBaseCoin } from '../../../../baseCoin';
import { BitGoBase } from '../../../../bitgoBase';
import { decodeOrElse, Keychain } from '../../../..';
import { EddsaMPCv2Utils } from '../eddsaMPCv2';
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from '../eddsaMPCv2KeyGenSender';

export class MPCv2SMCUtils {
private MPCv2Utils: EddsaMPCv2Utils;

constructor(private bitgo: BitGoBase, private baseCoin: IBaseCoin) {
this.MPCv2Utils = new EddsaMPCv2Utils(bitgo, baseCoin);
}

public async keyGenRound1(
enterprise: string,
payload: EddsaOVC1ToBitgoRound1Payload
): Promise<EddsaBitgoToOVC1Round1Response> {
return this.keyGenRound1BySender(KeyGenSenderForEnterprise(this.bitgo, enterprise), payload);
}

public async keyGenRound2(
enterprise: string,
payload: EddsaOVC2ToBitgoRound2Payload
): Promise<EddsaBitgoToOVC1Round2Response> {
return this.keyGenRound2BySender(KeyGenSenderForEnterprise(this.bitgo, enterprise), payload);
}

public async keyGenRound1BySender(
senderFn: EddsaMPCv2KeyGenSendFn<EddsaMPCv2KeyGenRound1Response>,
payload: EddsaOVC1ToBitgoRound1Payload
): Promise<EddsaBitgoToOVC1Round1Response> {
assert(
payload.state === EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data,
`Invalid state for round 1, expected: ${EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data}, got: ${payload.state}`
);
decodeOrElse(EddsaOVC1ToBitgoRound1Payload.name, EddsaOVC1ToBitgoRound1Payload, payload, (errors) => {
throw new Error(`error(s) parsing payload: ${errors}`);
});

const ovc1 = payload.ovc[OVCIndexEnum.ONE];
const ovc2 = payload.ovc[OVCIndexEnum.TWO];
const result = await this.MPCv2Utils.sendKeyGenerationRound1BySender(senderFn, {
userGpgPublicKey: ovc1.gpgPubKey,
backupGpgPublicKey: ovc2.gpgPubKey,
userMsg1: ovc1.ovcMsg1,
backupMsg1: ovc2.ovcMsg1,
});

const response = {
state: EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data,
tssVersion: payload.tssVersion,
walletType: payload.walletType,
coin: payload.coin,
ovc: payload.ovc,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: unsafe as cast hiding a type definition gap.

EddsaMPCv2KeyGenRound1Response is typed as { sessionId, bitgoMsg1 } only — walletGpgPubKeySigs is not declared on it. The cast (result as EddsaMPCv2KeyGenRound1Response & { walletGpgPubKeySigs: string }) works around this gap.

For ECDSA, MPCv2KeyGenRound1Response properly declares walletGpgPubKeySigs: NonEmptyString and is accessed directly without any cast.

Two scenarios:

  1. If the EdDSA /mpc/keygen/round1 API does return walletGpgPubKeySigsEddsaMPCv2KeyGenRound1Response in @bitgo/public-types needs to be updated to include the field, and this cast should be removed.
  2. If the EdDSA API does not return this fieldwalletGpgPubKeySigs will be undefined at runtime. If EddsaBitgoToOVC1Round1Response.platform.walletGpgPubKeySigs is a required NonEmptyString (as it is for the ECDSA equivalent), decodeOrElse will throw "error(s) parsing response" on every call, making keyGenRound1BySender permanently broken.

Either fix the type in @bitgo/public-types or remove walletGpgPubKeySigs from the platform construction if EdDSA doesn't use it.

platform: {
walletGpgPubKeySigs: (result as EddsaMPCv2KeyGenRound1Response & { walletGpgPubKeySigs: string })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type gap on walletGpgPubKeySigs. EddsaMPCv2KeyGenRound1Response is typed as { sessionId, bitgoMsg1 } only in @bitgo/public-typeswalletGpgPubKeySigs is absent from the io-ts codec. ECDSA's MPCv2KeyGenRound1Response declares it as a required NonEmptyString and accesses it directly (result.walletGpgPubKeySigs, no cast needed).

If the EdDSA round-1 endpoint returns this field, add it to EddsaMPCv2KeyGenRound1Response in @bitgo/public-types and drop the cast. If it doesn't, walletGpgPubKeySigs will be undefined at runtime — and if EddsaBitgoToOVC1Round1Response.platform.walletGpgPubKeySigs is a required NonEmptyString (as it is in the ECDSA codec), decodeOrElse will throw "error(s) parsing response" on every call.

.walletGpgPubKeySigs,
sessionId: result.sessionId,
bitgoMsg1: result.bitgoMsg1,
},
};

return decodeOrElse(EddsaBitgoToOVC1Round1Response.name, EddsaBitgoToOVC1Round1Response, response, (errors) => {
throw new Error(`error(s) parsing response: ${errors}`);
});
}

public async keyGenRound2BySender(
senderFn: EddsaMPCv2KeyGenSendFn<EddsaMPCv2KeyGenRound2Response>,
payload: EddsaOVC2ToBitgoRound2Payload
): Promise<EddsaBitgoToOVC1Round2Response> {
assert(
payload.state === EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data,
`Invalid state for round 2, expected: ${EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data}, got: ${payload.state}`
);
decodeOrElse(EddsaOVC2ToBitgoRound2Payload.name, EddsaOVC2ToBitgoRound2Payload, payload, (errors) => {
throw new Error(`error(s) parsing payload: ${errors}`);
});

const ovc1 = payload.ovc[OVCIndexEnum.ONE];
const ovc2 = payload.ovc[OVCIndexEnum.TWO];
const sessionId = payload.platform.sessionId;
const result = await this.MPCv2Utils.sendKeyGenerationRound2BySender(senderFn, {
sessionId,
userMsg2: ovc1.ovcMsg2,
backupMsg2: ovc2.ovcMsg2,
});

assert.equal(sessionId, result.sessionId, 'Round 1 and round 2 session IDs do not match');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pattern inconsistency with ECDSA for sessionId in round 2.

ECDSA keyGenRound2BySender explicitly overwrites the session ID from the API result:

platform: {
  ...payload.platform,
  sessionId: result.sessionId,  // updated from result
  bitgoCommitment2: ...,
}

This PR asserts equality (assert.equal(sessionId, result.sessionId, ...)) and then keeps the original via ...payload.platform — the response never includes result.sessionId directly.

These two choices are consistent with each other (assert equality, then propagate original), but it's unclear if the assertion is correct. If the EdDSA round 2 API returns a different sessionId — as the ECDSA code implies is possible (since it overwrites it) — this assertion will throw unexpectedly.

Please add a comment documenting why EdDSA session IDs are stable across rounds (if that's the API contract), or switch to the ECDSA pattern and remove the assertion.


const keychains = this.baseCoin.keychains();
const bitgoKeychain = await keychains.add({
source: 'bitgo',
keyType: 'tss',
commonKeychain: result.commonPublicKeychain,
isMPCv2: true,
});

const response = {
state: EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1GenerateKey,
bitGoKeyId: bitgoKeychain.id,
tssVersion: payload.tssVersion,
walletType: payload.walletType,
coin: payload.coin,
ovc: payload.ovc,
platform: {
...payload.platform,
commonPublicKeychain: result.commonPublicKeychain,
bitgoMsg2: result.bitgoMsg2,
},
};

return decodeOrElse(EddsaBitgoToOVC1Round2Response.name, EddsaBitgoToOVC1Round2Response, response, (errors) => {
throw new Error(`error(s) parsing response: ${errors}`);
});
}

public async uploadClientKeys(
bitgoKeyId: string,
userCommonKeychain: string,
backupCommonKeychain: string
): Promise<{ userKeychain: Keychain; backupKeychain: Keychain; bitgoKeychain: Keychain }> {
assert(
userCommonKeychain === backupCommonKeychain,
'Common keychain mismatch between the user and backup keychains'
);

const keychains = this.baseCoin.keychains();
const bitgoKeychain = await keychains.get({ id: bitgoKeyId });
assert(bitgoKeychain, 'Keychain not found');
assert(bitgoKeychain.source === 'bitgo', 'The keychain is not a BitGo keychain');
assert(bitgoKeychain.type === 'tss', 'BitGo keychain is not a TSS keychain');
assert(bitgoKeychain.commonKeychain, 'BitGo keychain does not have a common keychain');
assert(bitgoKeychain.commonKeychain === userCommonKeychain, 'Common keychain mismatch between the OVCs and BitGo');

const userKeychainPromise = keychains.add({
source: 'user',
keyType: 'tss',
commonKeychain: userCommonKeychain,
isMPCv2: true,
});
const backupKeychainPromise = keychains.add({
source: 'backup',
keyType: 'tss',
commonKeychain: backupCommonKeychain,
isMPCv2: true,
});

const [userKeychain, backupKeychain] = await Promise.all([userKeychainPromise, backupKeychainPromise]);
return { userKeychain, backupKeychain, bitgoKeychain };
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No unit tests added. At minimum: (1) a state-guard failure test for each of the two assert guards (payload.state !== WaitingForBitgoRound1Data and !== WaitingForBitgoRound2Data), and (2) a happy-path test for keyGenRound1BySender and keyGenRound2BySender using a stub senderFn that returns a mock API response. ECDSA MPCv2SMCUtils also lacks tests, but that's not a reason to skip them here. Follow-up safe.

1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export {
export * from './eddsaMPCv2';
export * from './eddsaMPCv2KeyGenSender';
export * from './typesEddsaMPCv2';
export * from './SMC/utils';
Loading