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
38 changes: 30 additions & 8 deletions modules/sdk-coin-sui/src/sui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,10 @@ export class Sui extends BaseCoin {
*
* @returns {MPCTx | MPCSweepTxs} array of the serialized transaction hex strings and indices
* of the addresses being swept
* @param {EddsaSigningMaterial} [precomputedMaterial] signing material detected once by the
* caller (e.g. recoverConsolidations) to avoid re-decrypting the keycard on every loop iteration
*/
async recover(params: MPCRecoveryOptions): Promise<MPCTxs | MPCSweepTxs> {
async recover(params: MPCRecoveryOptions, precomputedMaterial?: EddsaSigningMaterial): Promise<MPCTxs | MPCSweepTxs> {
if (!params.bitgoKey) {
throw new Error('missing bitgoKey');
}
Expand Down Expand Up @@ -388,7 +390,16 @@ export class Sui extends BaseCoin {
} catch (e) {
continue;
}
return this.recoverSuiToken(params, token, senderAddress, derivationPath, derivedPublicKey, idx, bitgoKey);
return this.recoverSuiToken(
params,
token,
senderAddress,
derivationPath,
derivedPublicKey,
idx,
bitgoKey,
precomputedMaterial
);
}

let inputCoins = await this.getInputCoins(senderAddress);
Expand Down Expand Up @@ -468,7 +479,7 @@ export class Sui extends BaseCoin {
return this.buildUnsignedSweepTransaction(txBuilder, senderAddress, bitgoKey, idx, derivationPath);
}

await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, false);
await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, false, precomputedMaterial);
const tx = (await txBuilder.build()) as TransferTransaction;
return {
transactions: [
Expand Down Expand Up @@ -496,7 +507,8 @@ export class Sui extends BaseCoin {
derivationPath: string,
derivedPublicKey: string,
idx: number,
bitgoKey: string
bitgoKey: string,
precomputedMaterial?: EddsaSigningMaterial
): Promise<MPCTxs | MPCSweepTxs> {
const coinType = `${token.packageId}::${token.module}::${token.symbol}`;
let tokenObjects = await this.getInputCoins(senderAddress, coinType);
Expand Down Expand Up @@ -559,7 +571,7 @@ export class Sui extends BaseCoin {
return this.buildUnsignedSweepTransaction(txBuilder, senderAddress, bitgoKey, idx, derivationPath, token);
}

await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, true);
await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, true, precomputedMaterial);
const tx = (await txBuilder.build()) as TokenTransferTransaction;
return {
transactions: [
Expand Down Expand Up @@ -656,7 +668,8 @@ export class Sui extends BaseCoin {
params: MPCRecoveryOptions,
derivationPath: string,
derivedPublicKey: string,
isTokenTransaction: boolean
isTokenTransaction: boolean,
precomputedMaterial?: EddsaSigningMaterial
) {
// TODO(BG-51092): This looks like a common part which can be extracted out too
const unsignedTx = isTokenTransaction
Expand All @@ -671,7 +684,8 @@ export class Sui extends BaseCoin {
const backupKey = params.backupKey.replace(/\s/g, '');
const bitgoKey = params.bitgoKey.replace(/\s/g, '');

const signingMaterial = await this.getEddsaSigningMaterial(userKey, params.walletPassphrase);
const signingMaterial =
precomputedMaterial ?? (await this.getEddsaSigningMaterial(userKey, params.walletPassphrase));

if (signingMaterial.version === 'v2') {
const signature = await this.signSuiMpcV2Recovery({
Expand Down Expand Up @@ -806,6 +820,14 @@ export class Sui extends BaseCoin {
}

const bitgoKey = params.bitgoKey.replace(/\s/g, '');
const userKey = params.userKey?.replace(/\s/g, '');

// Detect signing material once to avoid re-decrypting the keycard on every loop iteration.
const signingMaterial =
userKey && params.walletPassphrase
? await this.getEddsaSigningMaterial(userKey, params.walletPassphrase)
: undefined;

const MPC = await EDDSAMethods.getInitializedMpcInstance();
const derivationPath = (params.seed ? getDerivationPath(params.seed) : 'm') + '/0';
const derivedPublicKey = MPC.deriveUnhardened(bitgoKey, derivationPath).slice(0, 64);
Expand All @@ -828,7 +850,7 @@ export class Sui extends BaseCoin {

let recoveryTransaction: MPCTxs | MPCSweepTxs;
try {
recoveryTransaction = await this.recover(recoverParams);
recoveryTransaction = await this.recover(recoverParams, signingMaterial);
} catch (e) {
if (e.message.startsWith('Did not find an address with sufficient funds to recover.')) {
lastScanIndex = idx;
Expand Down
229 changes: 229 additions & 0 deletions modules/sdk-coin-sui/test/unit/sui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,74 @@ describe('SUI:', function () {
sandBox.assert.callCount(basecoin.getInputCoins, 2);
sandBox.assert.callCount(basecoin.getFeeEstimate, 1);
});

describe('MPCv2 signed recovery', function () {
const mpcV2WalletPassphrase = 'test-passphrase-mpcv2-token';

let mpcV2UserKey: string;
let mpcV2BackupKey: string;
let mpcV2CommonKeyChain: string;
let mpcV2WalletAddress: string;

before(async function () {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
mpcV2CommonKeyChain = userDkg.getCommonKeychain();
mpcV2UserKey = await encrypt(mpcV2WalletPassphrase, userDkg.getReducedKeyShare().toString('base64'));
mpcV2BackupKey = await encrypt(mpcV2WalletPassphrase, backupDkg.getReducedKeyShare().toString('base64'));

const mpc = await EDDSAMethods.getInitializedMpcInstance();
const accountId = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64);
mpcV2WalletAddress = utils.getAddressFromPublicKey(accountId);
});

it('should recover a token txn using MPCv2 signing material without calling getTSSSignature', async function () {
getBalanceStub
.withArgs(mpcV2WalletAddress)
.resolves({ totalBalance: '1900000000', coinObjectBalance: '1900000000', fundsInAddressBalance: '0' })
.withArgs(mpcV2WalletAddress, coinType)
.resolves({ totalBalance: '1000', coinObjectBalance: '1000', fundsInAddressBalance: '0' });
getInputCoinsStub.withArgs(mpcV2WalletAddress, coinType).resolves([
{
coinType: '0x36dbef866a1d62bf7328989a10fb2f07d769f4ee587c0de4a0a256e57e0a58a8::deep::DEEP',
objectId: '0x924ab69ebba304f2975a588372b41e4e1f5db7fa824868f84199eeb1e0a15a2d',
version: '34696807',
digest: '7XRbWQTiwAUCjLLsZVpJMrABCheJBkzKVfCr7aTZZVkd',
balance: new BigNumber(1000),
},
]);
getInputCoinsStub.withArgs(mpcV2WalletAddress).resolves([
{
coinType: '0x2::sui::SUI',
objectId: '0x9146928f557cb8ab1915a5886c1362435a05b4709b586bb01d4c70e85bb53161',
version: '239',
digest: 'GLSzR6HJ319nPKAFm5x3TWHcaHZzCFSBCqhvZ1qwT5wr',
balance: new BigNumber('1230261076'),
},
]);
getFeeEstimateStub.resolves(new BigNumber(2345504));

const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature');

const res = (await basecoin.recover({
userKey: mpcV2UserKey,
backupKey: mpcV2BackupKey,
bitgoKey: mpcV2CommonKeyChain,
recoveryDestination,
walletPassphrase: mpcV2WalletPassphrase,
tokenContractAddress,
})) as MPCTxs;

res.should.not.be.empty();
res.should.hasOwnProperty('transactions');
const tx = res.transactions[0];
should.equal(tx.scanIndex, 0);
(tx.serializedTx as string).should.be.a.String().and.not.be.empty();
sandBox.assert.notCalled(getTSSSignatureSpy);

// The SUI signature envelope is 1 (flag) + 64 (signature) + 32 (pubkey) bytes.
Buffer.from(tx.signature as string, 'base64').length.should.equal(97);
});
});
});

describe('Recover Transactions for wallet with multiple addresses:', () => {
Expand Down Expand Up @@ -1700,6 +1768,167 @@ describe('SUI:', function () {
});
});

describe('Build Consolidation Recoveries (MPCv2):', () => {
const sandBox = sinon.createSandbox();
const walletPassphrase = 'p$Sw<RjvAgf{nYAYI2xM';

let mpcV2UserKey: string;
let mpcV2BackupKey: string;
let mpcV2CommonKeyChain: string;
let baseAddr: string;
let mpcV2Address1: string;
let mpcV2Address2: string;

before(async function () {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();

mpcV2UserKey = await encrypt(walletPassphrase, userDkg.getReducedKeyShare().toString('base64'));
mpcV2BackupKey = await encrypt(walletPassphrase, backupDkg.getReducedKeyShare().toString('base64'));
mpcV2CommonKeyChain = userDkg.getCommonKeychain();

const MPC = await EDDSAMethods.getInitializedMpcInstance();
baseAddr = utils.getAddressFromPublicKey(MPC.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64));
mpcV2Address1 = utils.getAddressFromPublicKey(MPC.deriveUnhardened(mpcV2CommonKeyChain, 'm/1').slice(0, 64));
mpcV2Address2 = utils.getAddressFromPublicKey(MPC.deriveUnhardened(mpcV2CommonKeyChain, 'm/2').slice(0, 64));
});

beforeEach(function () {
const getBalanceStub = sandBox.stub(Sui.prototype, 'getBalance' as keyof Sui);
getBalanceStub
.withArgs(mpcV2Address1)
.resolves({ totalBalance: '0', coinObjectBalance: '0', fundsInAddressBalance: '0' });
getBalanceStub
.withArgs(mpcV2Address2)
.resolves({ totalBalance: '200000000', coinObjectBalance: '200000000', fundsInAddressBalance: '0' });

const getInputCoinsStub = sandBox.stub(Sui.prototype, 'getInputCoins' as keyof Sui);
getInputCoinsStub.withArgs(mpcV2Address2).resolves([
{
coinType: '0x2::sui::SUI',
objectId: '0xfa04105eedebdabf729dccecf01d0cf5f1b770892fac2ed8f1e69d71a32a2d24',
version: '202',
digest: 'DeApRVSrTa9ttXvNyLexT4PJcAkyyxSpi3JQeUg4ua8Q',
balance: new BigNumber('200000000'),
},
]);

const getFeeEstimateStub = sandBox.stub(Sui.prototype, 'getFeeEstimate' as keyof Sui);
getFeeEstimateStub.resolves(new BigNumber('1997880'));
});

afterEach(function () {
sandBox.restore();
});

it('should build MPCv2 signed consolidation recoveries, sweeping to the MPCv2 base address', async function () {
const res = await basecoin.recoverConsolidations({
userKey: mpcV2UserKey,
backupKey: mpcV2BackupKey,
bitgoKey: mpcV2CommonKeyChain,
walletPassphrase,
startingScanIndex: 1,
endingScanIndex: 3,
});

res.should.not.be.empty();
res.transactions.length.should.equal(1);
res.lastScanIndex.should.equal(2);

const rebuilt = new TransferTransaction(basecoin);
rebuilt.fromRawTransaction(res.transactions[0].serializedTx);
should.equal(rebuilt.toJson().gasData.owner, mpcV2Address2);

const outputs = rebuilt.explainTransaction().outputs;
outputs[0].address.should.equal(baseAddr);
});

it('should detect signing material exactly once at the top of the scan loop, not per iteration', async function () {
const getEddsaMaterialSpy = sandBox.spy(
basecoin as unknown as { getEddsaSigningMaterial: unknown },
'getEddsaSigningMaterial'
);

await basecoin.recoverConsolidations({
userKey: mpcV2UserKey,
backupKey: mpcV2BackupKey,
bitgoKey: mpcV2CommonKeyChain,
walletPassphrase,
startingScanIndex: 1,
endingScanIndex: 3,
});

sandBox.assert.calledOnce(getEddsaMaterialSpy);
});

it('should leave MPCv1 base address derivation and signing unchanged (regression)', async function () {
const receiveAddress1 = '0x32d8e57ee6d91e5558da0677154c2f085795348e317f95acc9efade1b4112fcc';
const receiveAddress2 = '0xdf407e3e25e9400f9779ac7571537c2361684194f1aa5db126a8f574b5ed851c';
(Sui.prototype as unknown as { getBalance: sinon.SinonStub }).getBalance
.withArgs(receiveAddress1)
.resolves({ totalBalance: '200101976', coinObjectBalance: '200101976', fundsInAddressBalance: '0' });
(Sui.prototype as unknown as { getBalance: sinon.SinonStub }).getBalance
.withArgs(receiveAddress2)
.resolves({ totalBalance: '200000000', coinObjectBalance: '200000000', fundsInAddressBalance: '0' });
(Sui.prototype as unknown as { getInputCoins: sinon.SinonStub }).getInputCoins
.withArgs(receiveAddress1)
.resolves([
{
coinType: '0x2::sui::SUI',
objectId: '0x996aab365d4551b6d1274f520bbfa7b0a566d548b2d590b5565c623812e7e76d',
version: '201',
digest: 'HXpNTfx9TBdxFcXHi4RziZsQuDAHavRasK6Ri15rVwuA',
balance: new BigNumber('200000000'),
},
{
coinType: '0x2::sui::SUI',
objectId: '0xb39c5f380208cce7fe1ba1258c8d19befb02a80f14952617ed37098dbd4d2df0',
version: '199',
digest: 'mqk37hXLkiUYgkYxk2MyqNykCkCXwe97uMus7bDPhe2',
balance: new BigNumber('101976'),
},
]);
(Sui.prototype as unknown as { getInputCoins: sinon.SinonStub }).getInputCoins
.withArgs(receiveAddress2)
.resolves([
{
coinType: '0x2::sui::SUI',
objectId: '0xfa04105eedebdabf729dccecf01d0cf5f1b770892fac2ed8f1e69d71a32a2d24',
version: '202',
digest: 'DeApRVSrTa9ttXvNyLexT4PJcAkyyxSpi3JQeUg4ua8Q',
balance: new BigNumber('200000000'),
},
]);
(Sui.prototype as unknown as { getFeeEstimate: sinon.SinonStub }).getFeeEstimate.resolves(
new BigNumber('1997880')
);

const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature');

const res = await basecoin.recoverConsolidations({
userKey: keys.userKey,
backupKey: keys.backupKey,
bitgoKey: keys.bitgoKey,
walletPassphrase,
startingScanIndex: 1,
endingScanIndex: 3,
});

res.should.not.be.empty();
sandBox.assert.calledTwice(getTSSSignatureSpy);
});

it('should leave the unsigned (no passphrase) cold path unchanged when keycard is MPCv2', async function () {
const res = await basecoin.recoverConsolidations({
bitgoKey: mpcV2CommonKeyChain,
startingScanIndex: 1,
endingScanIndex: 3,
});

res.should.not.be.empty();
res.txRequests.length.should.equal(1);
});
});

describe('Recover Token Consolidation Transactions', () => {
const sandBox = sinon.createSandbox();
const walletPassphrase = 'p$Sw<RjvAgf{nYAYI2xM';
Expand Down