Skip to content
Merged
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
96 changes: 52 additions & 44 deletions modules/sdk-coin-iota/src/iota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {
BitGoBase,
EDDSAMethods,
EDDSAMethodTypes,
EddsaSigningMaterial,
Environments,
getEddsaSigningMaterial,
getEddsaSigningMaterial as sharedGetEddsaSigningMaterial,
KeyPair,
MPCAlgorithm,
MPCConsolidationRecoveryOptions,
Expand Down Expand Up @@ -287,11 +288,16 @@ export class Iota extends BaseCoin {
*
* @param {IotaRecoveryOptions} params parameters needed to construct and
* (maybe) sign the transaction
* @param {EddsaSigningMaterial} [precomputedMaterial] signing material detected once by the
* caller (e.g. recoverConsolidations) to avoid re-decrypting the keycard on every loop iteration
*
* @returns {MPCTx | MPCSweepTxs} array of the serialized transaction hex strings and indices
* of the addresses being swept
*/
async recover(params: IotaRecoveryOptions): Promise<MPCTxs | MPCSweepTxs> {
async recover(
params: IotaRecoveryOptions,
precomputedMaterial?: EddsaSigningMaterial
): Promise<MPCTxs | MPCSweepTxs> {
if (!params.bitgoKey) {
throw new Error('Missing bitgoKey');
}
Expand All @@ -305,9 +311,6 @@ export class Iota extends BaseCoin {
const bitgoKey = params.bitgoKey.replace(/\s/g, '');
const MPC = await EDDSAMethods.getInitializedMpcInstance();

// Detect MPCv2 keycard format once up front, to avoid decrypting on every scan iteration.
const isMpcV2 = await this.isMpcv2SigningMaterial(params.userKey, params.backupKey, params.walletPassphrase);

for (let idx = startIdx; idx < endIdx; idx++) {
const derivationPath = (params.seed ? getDerivationPath(params.seed) : 'm') + `/${idx}`;
const derivedPublicKey = MPC.deriveUnhardened(bitgoKey, derivationPath).slice(0, 64);
Expand Down Expand Up @@ -343,7 +346,7 @@ export class Iota extends BaseCoin {
derivedPublicKey,
idx,
bitgoKey,
isMpcV2
precomputedMaterial
);
} catch (e) {
continue;
Expand Down Expand Up @@ -405,8 +408,7 @@ export class Iota extends BaseCoin {
derivationPath,
derivedPublicKey,
unsignedTx,
isMpcV2,
bitgoKey
precomputedMaterial
);

// Build and return signed transaction
Expand Down Expand Up @@ -476,8 +478,15 @@ export class Iota extends BaseCoin {
}

const bitgoKey = params.bitgoKey.replace(/\s/g, '');
const MPC = await EDDSAMethods.getInitializedMpcInstance();
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 basePath = (params.seed ? getDerivationPath(params.seed) : 'm') + '/0';
const derivedBasePublicKey = MPC.deriveUnhardened(bitgoKey, basePath).slice(0, 64);
const baseAddress = utils.getAddressFromPublicKey(derivedBasePublicKey);
Expand All @@ -500,7 +509,7 @@ export class Iota extends BaseCoin {

let recoveryTransaction: MPCTxs | MPCSweepTxs;
try {
recoveryTransaction = await this.recover(recoverParams);
recoveryTransaction = await this.recover(recoverParams, signingMaterial);
} catch (e) {
if ((e as Error).message.startsWith('Did not find an address with sufficient funds to recover.')) {
lastScanIndex = idx;
Expand Down Expand Up @@ -715,7 +724,7 @@ export class Iota extends BaseCoin {
derivedPublicKey: string,
idx: number,
bitgoKey: string,
isMpcV2: boolean
precomputedMaterial?: EddsaSigningMaterial
): Promise<MPCTxs | MPCSweepTxs> {
tokenObjectsWithBalance = tokenObjectsWithBalance.sort((a, b) => (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1));
if (tokenObjectsWithBalance.length > MAX_OBJECT_LIMIT) {
Expand Down Expand Up @@ -790,8 +799,7 @@ export class Iota extends BaseCoin {
derivationPath,
derivedPublicKey,
unsignedTx,
isMpcV2,
bitgoKey
precomputedMaterial
);

const finalTx = (await txBuilder.build()) as TransferTransaction;
Expand All @@ -811,16 +819,20 @@ export class Iota extends BaseCoin {
};
}

private async isMpcv2SigningMaterial(
userKey?: string,
backupKey?: string,
walletPassphrase?: string
): Promise<boolean> {
if (!walletPassphrase) return false;
if (!userKey) throw new Error('missing userKey');
if (!backupKey) throw new Error('missing backupKey');
const material = await getEddsaSigningMaterial(userKey.replace(/\s/g, ''), walletPassphrase, this.bitgo);
return material.version === 'v2';
/**
* Detects MPCv1 vs MPCv2 keycard format and returns typed signing material.
* Wrapped as a protected method so sinon can stub it in tests (matching DOT/SUI).
*/
protected async getEddsaSigningMaterial(userKey: string, walletPassphrase: string): Promise<EddsaSigningMaterial> {
return sharedGetEddsaSigningMaterial(userKey, walletPassphrase, this.bitgo);
}

/**
* Runs the MPCv2 (MPS) recovery signing flow and returns the raw 64-byte Ed25519 signature.
* Wrapped as a protected method so sinon can stub it in tests (matching DOT/SUI).
*/
protected async signIotaMpcV2Recovery(params: Parameters<typeof signEddsaMpcV2RecoveryTx>[0]): Promise<Buffer> {
return signEddsaMpcV2RecoveryTx(params);
}

private async signRecoveryTransaction(
Expand All @@ -829,8 +841,7 @@ export class Iota extends BaseCoin {
derivationPath: string,
derivedPublicKey: string,
unsignedTx: TransferTransaction,
isMpcV2: boolean,
bitgoKey: string
precomputedMaterial?: EddsaSigningMaterial
): Promise<string> {
if (!params.userKey) {
throw new Error('missing userKey');
Expand All @@ -844,18 +855,25 @@ export class Iota extends BaseCoin {

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

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

let signatureBuffer: Buffer;

if (!isMpcV2) {
// Decrypt private keys from KeyCard values
let userPrv: string;
try {
userPrv = await this.bitgo.decrypt({ input: userKey, password: params.walletPassphrase });
} catch (e) {
throw new Error(`Error decrypting user keychain: ${(e as Error).message}`);
}
const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial;
if (signingMaterial.version === 'v2') {
signatureBuffer = await this.signIotaMpcV2Recovery({
message: unsignedTx.signablePayload,
userKey: signingMaterial.encryptedUserKey,
backupKey,
walletPassphrase: params.walletPassphrase,
bitgoKey,
derivationPath,
bitgo: this.bitgo,
});
} else {
const userSigningMaterial = JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial;

let backupPrv: string;
try {
Expand All @@ -872,16 +890,6 @@ export class Iota extends BaseCoin {
derivationPath,
unsignedTx
);
} else {
signatureBuffer = await signEddsaMpcV2RecoveryTx({
message: unsignedTx.signablePayload,
userKey,
backupKey,
walletPassphrase: params.walletPassphrase,
bitgoKey,
derivationPath,
bitgo: this.bitgo,
});
}

// Build full signature: scheme_flag (1 byte) + signature (64 bytes) + public_key (32 bytes)
Expand Down
88 changes: 88 additions & 0 deletions modules/sdk-coin-iota/test/unit/iota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,17 @@ describe('IOTA:', function () {
});

it('should throw missing userKey error on MPCv2 path', async function () {
sandBox.stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota).resolves([
{
objectId: '0xc05c765e26e6ae84c78fa245f38a23fb20406a5cf3f61b57bd323a0df9d98003',
version: '195',
digest: validDigest,
balance: '1900000000',
},
]);
sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000);
sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(1997880);

await basecoin
.recover({
backupKey: mpcV2BackupKey,
Expand All @@ -786,6 +797,17 @@ describe('IOTA:', function () {
});

it('should throw missing backupKey error on MPCv2 path', async function () {
sandBox.stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota).resolves([
{
objectId: '0xc05c765e26e6ae84c78fa245f38a23fb20406a5cf3f61b57bd323a0df9d98003',
version: '195',
digest: validDigest,
balance: '1900000000',
},
]);
sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000);
sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(1997880);

await basecoin
.recover({
userKey: mpcV2UserKey,
Expand Down Expand Up @@ -1087,6 +1109,72 @@ describe('IOTA:', function () {
});
});

describe('Recover Consolidations (MPCv2):', () => {
const consolidationSandbox = sinon.createSandbox();
const walletPassphrase = 'p$Sw<RjvAgf{nYAYI2xM';
let mpcV2UserKey: string;
let mpcV2BackupKey: string;
let mpcV2CommonKeyChain: 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();
mpcV2Address1 = utils.getAddressFromPublicKey(mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/1').slice(0, 64));
mpcV2Address2 = utils.getAddressFromPublicKey(mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/2').slice(0, 64));
});

beforeEach(function () {
consolidationSandbox
.stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota)
.withArgs(mpcV2Address1)
.resolves([
{
objectId: '0x' + '1'.repeat(64),
version: '1',
digest: '7BJLb32LKN7wt5uv4xgXW4AbFKoMNcPE76o41TQEvUZb',
balance: '200000000',
},
])
.withArgs(mpcV2Address2)
.resolves([
{
objectId: '0x' + '2'.repeat(64),
version: '2',
digest: '7BJLb32LKN7wt5uv4xgXW4AbFKoMNcPE76o41TQEvUZb',
balance: '200000000',
},
]);
consolidationSandbox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000);
consolidationSandbox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(1997880);
});

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

it('should sign two recoveries and sweep them to the MPCv2 base address', async function () {
const res = (await basecoin.recoverConsolidations({
userKey: mpcV2UserKey,
backupKey: mpcV2BackupKey,
bitgoKey: mpcV2CommonKeyChain,
walletPassphrase,
startingScanIndex: 1,
endingScanIndex: 3,
})) as { transactions: Array<{ scanIndex: number; serializedTx: string }> };

res.transactions.length.should.equal(2);
res.transactions[0].scanIndex.should.equal(1);
res.transactions[1].scanIndex.should.equal(2);
res.transactions[0].serializedTx.should.be.String();
res.transactions[1].serializedTx.should.be.String();
});
});

describe('Recover Token Transactions:', () => {
const sandBox = sinon.createSandbox();
const senderAddress0 = '0xfd36d2ad48edf5671abf04f5c0eef3464bf92cf45ae655aff471cfaedb61fa99';
Expand Down
Loading