Skip to content

Commit e321956

Browse files
committed
feat(sdk-coin-sui): add MPCv2 signed hot recovery support
Detect keycard format (MPCv1 JSON vs MPCv2 CBOR) in Sui.recover() and its private helpers (recoverSuiToken, signRecoveryTransaction) via the shared getEddsaSigningMaterial helper, and dispatch MPCv2 keycards to signEddsaMpcV2RecoveryTx instead of the MPCv1-only EDDSAMethods.getTSSSignature path. The raw 64-byte signature is wrapped in SUI's existing 0x00-flag + signature + pubkey envelope before being attached to the transaction builder, so both native transfer and token recovery route through the same dispatch. MPCv2 hot wallets could not recover funds via WRW: recover() only understood the MPCv1 JSON keycard format and threw when handed an MPCv2 CBOR keycard. This mirrors the pattern already landed for DOT (WCI-1227) and unblocks WCI-1234 (recoverConsolidations MPCv2 support), which depends on this dispatch existing in recover(). Ticket: WCI-1224 Session-Id: bf130f11-6bd0-4a91-88c6-a9138ecc96ab Task-Id: 179247fb-4ff6-49f3-9849-df120620c98b
1 parent dec692f commit e321956

2 files changed

Lines changed: 310 additions & 31 deletions

File tree

modules/sdk-coin-sui/src/sui.ts

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import assert from 'assert';
12
import crypto from 'crypto';
23
import {
34
BaseBroadcastTransactionOptions,
45
BaseBroadcastTransactionResult,
56
BaseCoin,
67
BaseTransaction,
78
BitGoBase,
9+
decryptKeychainPrivateKey,
810
EDDSAMethods,
911
EDDSAMethodTypes,
12+
EddsaSigningMaterial,
1013
Environments,
14+
getEddsaSigningMaterial as sharedGetEddsaSigningMaterial,
1115
KeyPair,
1216
MPCAlgorithm,
1317
MPCRecoveryOptions,
@@ -20,6 +24,7 @@ import {
2024
ParsedTransaction,
2125
ParseTransactionOptions as BaseParseTransactionOptions,
2226
RecoveryTxRequest,
27+
signEddsaMpcV2RecoveryTx,
2328
SignedTransaction,
2429
SignTransactionOptions,
2530
TransactionExplanation,
@@ -463,7 +468,7 @@ export class Sui extends BaseCoin {
463468
return this.buildUnsignedSweepTransaction(txBuilder, senderAddress, bitgoKey, idx, derivationPath);
464469
}
465470

466-
await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, false);
471+
await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, bitgoKey, false);
467472
const tx = (await txBuilder.build()) as TransferTransaction;
468473
return {
469474
transactions: [
@@ -554,7 +559,7 @@ export class Sui extends BaseCoin {
554559
return this.buildUnsignedSweepTransaction(txBuilder, senderAddress, bitgoKey, idx, derivationPath, token);
555560
}
556561

557-
await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, true);
562+
await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, bitgoKey, true);
558563
const tx = (await txBuilder.build()) as TokenTransferTransaction;
559564
return {
560565
transactions: [
@@ -630,55 +635,64 @@ export class Sui extends BaseCoin {
630635
return { txRequests: [txRequest] };
631636
}
632637

638+
/**
639+
* Detects whether a keycard's decrypted plaintext is MPCv1 JSON or MPCv2 CBOR.
640+
* Protected so tests can stub it via sinon; wraps the shared sdk-core helper.
641+
*/
642+
protected async getEddsaSigningMaterial(userKey: string, walletPassphrase: string): Promise<EddsaSigningMaterial> {
643+
return sharedGetEddsaSigningMaterial(userKey, walletPassphrase, this.bitgo);
644+
}
645+
646+
// Protected so tests can stub via instance overrides — direct module function bindings
647+
// cannot be intercepted by sinon after import.
648+
protected async signSuiMpcV2Recovery(params: Parameters<typeof signEddsaMpcV2RecoveryTx>[0]): Promise<Buffer> {
649+
return signEddsaMpcV2RecoveryTx(params);
650+
}
651+
633652
private async signRecoveryTransaction(
634653
txBuilder: TransactionBuilder,
635654
params: MPCRecoveryOptions,
636655
derivationPath: string,
637656
derivedPublicKey: string,
657+
bitgoKey: string,
638658
isTokenTransaction: boolean
639659
) {
640660
// TODO(BG-51092): This looks like a common part which can be extracted out too
641661
const unsignedTx = isTokenTransaction
642662
? ((await txBuilder.build()) as TokenTransferTransaction)
643663
: ((await txBuilder.build()) as TransferTransaction);
644-
if (!params.userKey) {
645-
throw new Error('missing userKey');
646-
}
647-
if (!params.backupKey) {
648-
throw new Error('missing backupKey');
649-
}
650-
if (!params.walletPassphrase) {
651-
throw new Error('missing wallet passphrase');
652-
}
664+
assert(params.userKey, 'missing userKey');
665+
assert(params.backupKey, 'missing backupKey');
666+
assert(params.walletPassphrase, 'missing wallet passphrase');
653667

654668
// Clean up whitespace from entered values
655669
const userKey = params.userKey.replace(/\s/g, '');
656670
const backupKey = params.backupKey.replace(/\s/g, '');
657671

658-
// Decrypt private keys from KeyCard values
659-
let userPrv: string;
660-
try {
661-
userPrv = await this.bitgo.decrypt({
662-
input: userKey,
663-
password: params.walletPassphrase,
672+
const signingMaterial = await this.getEddsaSigningMaterial(userKey, params.walletPassphrase);
673+
674+
if (signingMaterial.version === 'v2') {
675+
const signature = await this.signSuiMpcV2Recovery({
676+
message: unsignedTx.signablePayload,
677+
userKey: signingMaterial.encryptedUserKey,
678+
backupKey,
679+
walletPassphrase: params.walletPassphrase,
680+
bitgoKey,
681+
derivationPath,
682+
bitgo: this.bitgo,
664683
});
665-
} catch (e) {
666-
throw new Error(`Error decrypting user keychain: ${e.message}`);
684+
txBuilder.addSignature({ pub: derivedPublicKey }, signature);
685+
return;
667686
}
687+
668688
/** TODO BG-52419 Implement Codec for parsing */
669-
const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial;
689+
const userSigningMaterial = JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial;
670690

671-
let backupPrv: string;
672-
try {
673-
backupPrv = await this.bitgo.decrypt({
674-
input: backupKey,
675-
password: params.walletPassphrase,
676-
});
677-
} catch (e) {
678-
throw new Error(`Error decrypting backup keychain: ${e.message}`);
691+
const backupPrv = await decryptKeychainPrivateKey(this.bitgo, { encryptedPrv: backupKey }, params.walletPassphrase);
692+
if (!backupPrv) {
693+
throw new Error('Error decrypting backup keychain: invalid password or corrupted key');
679694
}
680695
const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial;
681-
/* ********************** END ***********************************/
682696

683697
// add signature
684698
const signatureHex = await EDDSAMethods.getTSSSignature(

0 commit comments

Comments
 (0)