diff --git a/modules/sdk-coin-iota/src/iota.ts b/modules/sdk-coin-iota/src/iota.ts index d1fdd43c4b..4cc72d32de 100644 --- a/modules/sdk-coin-iota/src/iota.ts +++ b/modules/sdk-coin-iota/src/iota.ts @@ -2,25 +2,36 @@ import { AuditDecryptedKeyParams, BaseCoin, BitGoBase, + EDDSAMethods, + EDDSAMethodTypes, + Environments, KeyPair, - ParsedTransaction, - SignTransactionOptions, - SignedTransaction, - VerifyTransactionOptions, - MultisigType, - multisigTypes, MPCAlgorithm, - TssVerifyAddressOptions, + MPCConsolidationRecoveryOptions, + MPCRecoveryOptions, + MPCSweepTxs, + MPCTx, + MPCTxs, MPCType, + MPCUnsignedTx, + MultisigType, + multisigTypes, + ParsedTransaction, PopulatedIntent, PrebuildTransactionWithIntentOptions, + RecoveryTxRequest, + SignedTransaction, + SignTransactionOptions, TransactionRecipient, + TransactionType, + TssVerifyAddressOptions, verifyEddsaTssWalletAddress, + VerifyTransactionOptions, } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, CoinFamily, coins } from '@bitgo/statics'; import utils from './lib/utils'; -import { KeyPair as IotaKeyPair, Transaction, TransactionBuilderFactory } from './lib'; -import { auditEddsaPrivateKey } from '@bitgo/sdk-lib-mpc'; +import { KeyPair as IotaKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib'; +import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import BigNumber from 'bignumber.js'; import * as _ from 'lodash'; import { @@ -28,8 +39,24 @@ import { IotaParseTransactionOptions, TransactionExplanation, TransferTxData, + TransactionObjectInput, } from './lib/iface'; import { TransferTransaction } from './lib/transferTransaction'; +import { + DEFAULT_GAS_OVERHEAD, + DEFAULT_SCAN_FACTOR, + MAX_GAS_BUDGET, + MAX_GAS_OBJECTS, + MAX_OBJECT_LIMIT, +} from './lib/constants'; + +export interface IotaRecoveryOptions extends MPCRecoveryOptions { + fullnodeRpcUrl?: string; // Override default RPC URL +} + +interface IotaObjectWithBalance extends TransactionObjectInput { + balance: string; +} /** * IOTA coin implementation. @@ -253,6 +280,669 @@ export class Iota extends BaseCoin { intent.unspents = params.unspents; } + /** + * Builds funds recovery transaction(s) without BitGo + * + * @param {IotaRecoveryOptions} params parameters needed to construct and + * (maybe) sign the transaction + * + * @returns {MPCTx | MPCSweepTxs} array of the serialized transaction hex strings and indices + * of the addresses being swept + */ + async recover(params: IotaRecoveryOptions): Promise { + if (!params.bitgoKey) { + throw new Error('Missing bitgoKey'); + } + if (!params.recoveryDestination || !this.isValidAddress(params.recoveryDestination)) { + throw new Error('Invalid recoveryDestination address'); + } + + const startIdx = utils.getSafeNumber(0, 'Invalid starting index to scan for addresses', params.startingScanIndex); + const numIterations = utils.getSafeNumber(DEFAULT_SCAN_FACTOR, 'Invalid scanning factor', params.scan); + const endIdx = startIdx + numIterations; + const bitgoKey = params.bitgoKey.replace(/\s/g, ''); + const MPC = await EDDSAMethods.getInitializedMpcInstance(); + + 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); + const senderAddress = utils.getAddressFromPublicKey(derivedPublicKey); + + // Token recovery path: recover the token provided by user + if (params.tokenContractAddress) { + if (!(await this.hasTokenBalance(senderAddress, params))) { + continue; + } + + let tokenObjects: IotaObjectWithBalance[]; + try { + tokenObjects = await this.fetchOwnedObjects( + senderAddress, + params.fullnodeRpcUrl, + params.tokenContractAddress + ); + } catch (e) { + continue; + } + + if (tokenObjects.length === 0) { + continue; + } + + try { + return await this.recoverIotaToken( + params, + tokenObjects, + senderAddress, + derivationPath, + derivedPublicKey, + idx, + bitgoKey + ); + } catch (e) { + continue; + } + } + + let ownedObjects: IotaObjectWithBalance[]; + try { + ownedObjects = await this.fetchOwnedObjects(senderAddress, params.fullnodeRpcUrl); + } catch (e) { + continue; + } + + if (ownedObjects.length === 0) { + continue; + } + + // Cap objects to prevent oversized transactions (IOTA max tx size = 128 KiB) + if (ownedObjects.length > MAX_GAS_OBJECTS) { + ownedObjects = ownedObjects + .sort((a, b) => (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)) + .slice(0, MAX_GAS_OBJECTS); + } + + const { gasBudget, gasPrice, gasObjects, totalBalance } = await this.prepareGasAndObjects( + ownedObjects, + senderAddress, + params + ); + + const netBalance = totalBalance - BigInt(gasBudget); + + if (netBalance <= 0n) { + continue; + } + const recoveryAmount = netBalance.toString(); + + const factory = this.getTxBuilderFactory(); + const txBuilder = factory.getTransferBuilder(); + + txBuilder + .sender(senderAddress) + .recipients([{ address: params.recoveryDestination, amount: recoveryAmount }]) + .gasData({ gasBudget, gasPrice, gasPaymentObjects: gasObjects }); + + // Return unsigned transaction for cold/custody wallets + const isUnsignedSweep = !params.walletPassphrase; + if (isUnsignedSweep) { + return this.buildUnsignedSweepTransaction(txBuilder, senderAddress, bitgoKey, idx, derivationPath); + } + + // Build transaction for signing + const unsignedTx = (await txBuilder.build()) as TransferTransaction; + + // Sign the transaction with decrypted keys + const fullSignatureBase64 = await this.signRecoveryTransaction( + txBuilder, + params, + derivationPath, + derivedPublicKey, + unsignedTx + ); + + // Build and return signed transaction + const finalTx = (await txBuilder.build()) as TransferTransaction; + const serializedTx = await finalTx.toBroadcastFormat(); + + return { + transactions: [ + { + scanIndex: idx, + recoveryAmount, + serializedTx, + signature: fullSignatureBase64, + coin: this.getChain(), + }, + ], + lastScanIndex: idx, + }; + } + + throw new Error( + `Did not find an address with sufficient funds to recover. ` + + `Scanned addresses from index ${startIdx} to ${endIdx - 1}. ` + + `Please start the next scan at address index ${endIdx}.` + ); + } + + /** + * Checks whether the address holds a positive balance of the specified token. + */ + private async hasTokenBalance(senderAddress: string, params: IotaRecoveryOptions): Promise { + try { + const balance = await this.getBalance(senderAddress, params.fullnodeRpcUrl, params.tokenContractAddress); + return balance > 0n; + } catch (e) { + return false; + } + } + + /** + * Consolidates funds from multiple receive addresses to the base address (index 0). + * If walletPassphrase is not provided, returns unsigned transactions for offline signing + * (cold/custody wallet recovery). Otherwise, returns signed transactions. + * + * @param params - Consolidation recovery parameters + * @param params.bitgoKey - The commonKeychain (combined TSS public key) + * @param params.startingScanIndex - Starting address index to scan (default: 1) + * @param params.endingScanIndex - Ending address index to scan (default: startingScanIndex + 20) + * @param params.walletPassphrase - Optional passphrase for signing (omit for unsigned transactions) + * @returns MPCTxs (signed) or MPCSweepTxs (unsigned) containing all consolidation transactions + * @throws Error if no addresses with funds are found in the scan range + */ + async recoverConsolidations(params: MPCConsolidationRecoveryOptions): Promise { + const isUnsignedSweep = !params.walletPassphrase; + + const startIdx = utils.getSafeNumber(1, 'Invalid starting index to scan for addresses', params.startingScanIndex); + const endIdx = utils.getSafeNumber( + startIdx + DEFAULT_SCAN_FACTOR, + 'Invalid ending index to scan for addresses', + params.endingScanIndex + ); + + if (startIdx < 1 || endIdx <= startIdx || endIdx - startIdx > 10 * DEFAULT_SCAN_FACTOR) { + throw new Error( + `Invalid starting or ending index to scan for addresses. startingScanIndex: ${startIdx}, endingScanIndex: ${endIdx}.` + ); + } + + const bitgoKey = params.bitgoKey.replace(/\s/g, ''); + 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); + + const consolidationTransactions: any[] = []; + let lastScanIndex = startIdx; + + for (let idx = startIdx; idx < endIdx; idx++) { + const recoverParams: IotaRecoveryOptions = { + userKey: params.userKey, + backupKey: params.backupKey, + bitgoKey: params.bitgoKey, + walletPassphrase: params.walletPassphrase, + seed: params.seed, + tokenContractAddress: params.tokenContractAddress, + recoveryDestination: baseAddress, // Consolidate to base address + startingScanIndex: idx, + scan: 1, + }; + + let recoveryTransaction: MPCTxs | MPCSweepTxs; + try { + recoveryTransaction = await this.recover(recoverParams); + } catch (e) { + if ((e as Error).message.startsWith('Did not find an address with sufficient funds to recover.')) { + lastScanIndex = idx; + continue; + } + throw e; + } + + if (isUnsignedSweep) { + consolidationTransactions.push((recoveryTransaction as MPCSweepTxs).txRequests[0]); + } else { + consolidationTransactions.push((recoveryTransaction as MPCTxs).transactions[0]); + } + lastScanIndex = idx; + } + + if (consolidationTransactions.length === 0) { + throw new Error( + `Did not find an address with sufficient funds to recover. Please start the next scan at address index ${ + lastScanIndex + 1 + }.` + ); + } + + if (isUnsignedSweep) { + consolidationTransactions[ + consolidationTransactions.length - 1 + ].transactions[0].unsignedTx.coinSpecific.lastScanIndex = lastScanIndex; + return { txRequests: consolidationTransactions }; + } + + return { transactions: consolidationTransactions, lastScanIndex }; + } + + /** + * Gets the total coin balance for an address. + * + * @param address - IOTA address to query + * @param rpcUrl - Optional RPC URL override + * @param coinType - Optional coin type (defaults to native IOTA) + * @returns Total balance as a bigint + */ + private async getBalance(address: string, rpcUrl?: string, coinType?: string): Promise { + const url = rpcUrl || this.getPublicNodeUrl(); + const normalizedCoinType = coinType || this.getNativeCoinType(); + const response = await this.makeRpcCall(url, 'iotax_getBalance', [address, normalizedCoinType]); + return BigInt(response.totalBalance); + } + + /** + * Fetches owned objects for an address via fullnode RPC. + * Handles pagination to retrieve all objects. + * + * @param address - IOTA address to query + * @param rpcUrl - Optional RPC URL override + * @param coinType - Optional coin type to filter objects (defaults to native IOTA) + * @returns Array of owned objects with balance information + */ + private async fetchOwnedObjects( + address: string, + rpcUrl?: string, + coinType?: string + ): Promise { + const url = rpcUrl || this.getPublicNodeUrl(); + const allObjects: IotaObjectWithBalance[] = []; + const normalizedCoinType = coinType || this.getNativeCoinType(); + let cursor: string | null = null; + let hasNextPage = true; + const MAX_PAGES = 500; + let pageCount = 0; + while (hasNextPage && pageCount < MAX_PAGES) { + pageCount++; + const query = { + filter: { StructType: normalizedCoinType }, + options: { showContent: true, showType: true }, + }; + + const response = await this.makeRpcCall(url, 'iotax_getOwnedObjects', [address, query, cursor, 50]); + const { data, nextCursor, hasNextPage: more } = response; + + for (const item of data || []) { + const { objectId, version, digest } = item.data; + const balance = item.data.content?.fields?.balance || '0'; + if (BigInt(balance) > 0n) { + allObjects.push({ objectId, version: version.toString(), digest, balance }); + } + } + + if (nextCursor === cursor) { + break; + } + hasNextPage = more; + cursor = nextCursor; + } + + if (pageCount >= MAX_PAGES) { + console.warn(`fetchOwnedObjects: Hit max page limit (${MAX_PAGES}) for ${address}`); + } + return allObjects; + } + + /** + * Makes JSON-RPC call to IOTA fullnode. + * + * @param url - Fullnode RPC URL + * @param method - RPC method name + * @param params - RPC parameters + * @returns RPC result + * @throws Error if RPC call fails + */ + private async makeRpcCall(url: string, method: string, params: any[]): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params, + }), + }); + + if (!response.ok) { + throw new Error(`RPC call failed with status ${response.status}`); + } + + const json = await response.json(); + + if (json.error) { + throw new Error(`RPC error: ${json.error.message || JSON.stringify(json.error)}`); + } + + return json.result; + } + + /** + * Gets the public node RPC URL from the centralized environment configuration. + * + * @returns RPC URL for the current BitGo environment + */ + protected getPublicNodeUrl(): string { + return Environments[this.bitgo.getEnv()].iotaNodeUrl; + } + + /** + * Gets the native coin type identifier for filtering owned objects. + * + * @returns Native coin type string + */ + private getNativeCoinType(): string { + return '0x2::iota::IOTA'; + } + + /** + * Prepares gas configuration for a recovery transaction. + * + * Gas estimation is done via a dry-run of a temporary transaction, then + * multiplied by DEFAULT_GAS_OVERHEAD (1.1x) as a safety buffer. + * + * @param ownedObjects - Native IOTA coin objects to use as gas payment + * @param senderAddress - Sender address for the transaction + * @param params - Recovery parameters (includes recoveryDestination, rpcUrl) + * @param paymentObjects - Optional token objects for token recovery + * @returns Gas budget, gas price, gas objects array, and total native balance + */ + private async prepareGasAndObjects( + ownedObjects: IotaObjectWithBalance[], + senderAddress: string, + params: IotaRecoveryOptions, + paymentObjects?: TransactionObjectInput[] + ): Promise<{ + gasBudget: number; + gasPrice: number; + gasObjects: TransactionObjectInput[]; + totalBalance: bigint; + }> { + const gasObjects: TransactionObjectInput[] = ownedObjects.map((obj) => ({ + objectId: obj.objectId, + version: obj.version, + digest: obj.digest, + })); + const totalBalance = ownedObjects.reduce((sum, obj) => sum + BigInt(obj.balance), 0n); + + const gasPrice = await this.fetchGasPrice(params.fullnodeRpcUrl); + + // Build temp transaction for estimation + const factory = this.getTxBuilderFactory(); + const tempBuilder = factory.getTransferBuilder(); + const estimationAmount = totalBalance > 0n ? '1' : '0'; + + tempBuilder.sender(senderAddress).recipients([{ address: params.recoveryDestination, amount: estimationAmount }]); + + if (paymentObjects && paymentObjects.length > 0) { + tempBuilder.paymentObjects(paymentObjects); + } else { + tempBuilder.paymentObjects(gasObjects); + } + + const tempTx = await tempBuilder.build(); + const estimatedGas = await this.estimateGas(await tempTx.toBroadcastFormat(), params.fullnodeRpcUrl); + const gasBudget = Math.min(MAX_GAS_BUDGET, Math.trunc(estimatedGas * DEFAULT_GAS_OVERHEAD)); + + return { gasBudget, gasPrice, gasObjects, totalBalance }; + } + + private async recoverIotaToken( + params: IotaRecoveryOptions, + tokenObjectsWithBalance: IotaObjectWithBalance[], + senderAddress: string, + derivationPath: string, + derivedPublicKey: string, + idx: number, + bitgoKey: string + ): Promise { + tokenObjectsWithBalance = tokenObjectsWithBalance.sort((a, b) => (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)); + if (tokenObjectsWithBalance.length > MAX_OBJECT_LIMIT) { + tokenObjectsWithBalance = tokenObjectsWithBalance.slice(0, MAX_OBJECT_LIMIT); + } + + const tokenObjects: TransactionObjectInput[] = tokenObjectsWithBalance.map((obj) => ({ + objectId: obj.objectId, + version: obj.version, + digest: obj.digest, + })); + const tokenBalance = tokenObjectsWithBalance.reduce((sum, obj) => sum + BigInt(obj.balance), 0n); + if (tokenBalance <= 0n) { + throw new Error('Token balance is zero'); + } + + let gasObjectsWithBalance: IotaObjectWithBalance[]; + try { + gasObjectsWithBalance = await this.fetchOwnedObjects(senderAddress, params.fullnodeRpcUrl); + } catch (e) { + throw new Error('Failed to fetch gas objects for token recovery'); + } + if (gasObjectsWithBalance.length === 0) { + throw new Error('No gas objects found for token recovery'); + } + + gasObjectsWithBalance = gasObjectsWithBalance.sort((a, b) => (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)); + if (gasObjectsWithBalance.length >= MAX_GAS_OBJECTS) { + gasObjectsWithBalance = gasObjectsWithBalance.slice(0, MAX_GAS_OBJECTS - 1); + } + + const { gasBudget, gasPrice, gasObjects, totalBalance } = await this.prepareGasAndObjects( + gasObjectsWithBalance, + senderAddress, + params, + tokenObjects + ); + const netGasBalance = totalBalance - BigInt(gasBudget); + if (netGasBalance <= 0n) { + throw new Error('Insufficient gas balance for token recovery'); + } + + const recoveryAmount = tokenBalance.toString(); + const factory = this.getTxBuilderFactory(); + const txBuilder = factory.getTransferBuilder(); + + txBuilder + .sender(senderAddress) + .recipients([{ address: params.recoveryDestination, amount: recoveryAmount }]) + .paymentObjects(tokenObjects) + .gasData({ gasBudget, gasPrice, gasPaymentObjects: gasObjects }); + + const isUnsignedSweep = !params.walletPassphrase; + const tokenCoin = params.tokenContractAddress || this.getChain(); + + if (isUnsignedSweep) { + return this.buildUnsignedSweepTransaction( + txBuilder, + senderAddress, + bitgoKey, + idx, + derivationPath, + params.tokenContractAddress + ); + } + + const unsignedTx = (await txBuilder.build()) as TransferTransaction; + + const fullSignatureBase64 = await this.signRecoveryTransaction( + txBuilder, + params, + derivationPath, + derivedPublicKey, + unsignedTx + ); + + const finalTx = (await txBuilder.build()) as TransferTransaction; + const serializedTx = await finalTx.toBroadcastFormat(); + + return { + transactions: [ + { + scanIndex: idx, + recoveryAmount, + serializedTx, + signature: fullSignatureBase64, + coin: tokenCoin, + }, + ], + lastScanIndex: idx, + }; + } + + private async signRecoveryTransaction( + txBuilder: TransactionBuilder, + params: IotaRecoveryOptions, + derivationPath: string, + derivedPublicKey: string, + unsignedTx: TransferTransaction + ): Promise { + if (!params.userKey) { + throw new Error('missing userKey'); + } + if (!params.backupKey) { + throw new Error('missing backupKey'); + } + if (!params.walletPassphrase) { + throw new Error('missing wallet passphrase'); + } + + const userKey = params.userKey.replace(/\s/g, ''); + const backupKey = params.backupKey.replace(/\s/g, ''); + + // Decrypt private keys from KeyCard values + let userPrv: string; + try { + userPrv = 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; + + let backupPrv: string; + try { + backupPrv = this.bitgo.decrypt({ input: backupKey, password: params.walletPassphrase }); + } catch (e) { + throw new Error(`Error decrypting backup keychain: ${(e as Error).message}`); + } + const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; + + // Generate TSS signature + const signatureBuffer = await EDDSAMethods.getTSSSignature( + userSigningMaterial, + backupSigningMaterial, + derivationPath, + unsignedTx + ); + + // Build full signature: scheme_flag (1 byte) + signature (64 bytes) + public_key (32 bytes) + const schemeFlag = Buffer.alloc(1, 0x00); // Ed25519 scheme + const publicKeyBytes = Buffer.from(derivedPublicKey, 'hex'); + const fullSignature = Buffer.concat([schemeFlag, signatureBuffer, publicKeyBytes]); + + txBuilder.addSignature({ pub: derivedPublicKey }, signatureBuffer); + + return fullSignature.toString('base64'); + } + + /** + * Fetches current reference gas price from fullnode. + * + * @param rpcUrl - Optional RPC URL override + * @returns Current gas price + */ + private async fetchGasPrice(rpcUrl?: string): Promise { + const url = rpcUrl || this.getPublicNodeUrl(); + const result = await this.makeRpcCall(url, 'iotax_getReferenceGasPrice', []); + return parseInt(result, 10); + } + + /** + * Estimates gas for a transaction via dry run. + * + * @param txBase64 - Transaction in base64 format + * @param rpcUrl - Optional RPC URL override + * @returns Estimated gas cost + */ + private async estimateGas(txBase64: string, rpcUrl?: string): Promise { + const url = rpcUrl || this.getPublicNodeUrl(); + const result = await this.makeRpcCall(url, 'iota_dryRunTransactionBlock', [txBase64]); + + const computationCost = parseInt(result.effects.gasUsed.computationCost, 10); + const storageCost = parseInt(result.effects.gasUsed.storageCost, 10); + const storageRebate = parseInt(result.effects.gasUsed.storageRebate, 10); + + return Math.max(computationCost + storageCost - storageRebate, computationCost); + } + + private async buildUnsignedSweepTransaction( + txBuilder: TransactionBuilder, + senderAddress: string, + bitgoKey: string, + scanIndex: number, + derivationPath: string, + tokenContractAddress?: string + ): Promise { + const unsignedTransaction = (await txBuilder.build()) as TransferTransaction; + const serializedTx = await unsignedTransaction.toBroadcastFormat(); + const serializedTxHex = Buffer.from(serializedTx, 'base64').toString('hex'); + const parsedTx = await this.parseTransaction({ txHex: serializedTxHex }); + const walletCoin = tokenContractAddress || this.getChain(); + const parsedOutputs = parsedTx.outputs as Array<{ address: string; amount: string }>; + const output = parsedOutputs[0]; + + // Build parsed transaction structure from parsed data + const inputs = [ + { + address: senderAddress, + valueString: output.amount, + value: new BigNumber(output.amount), + }, + ]; + const outputs = [ + { + address: output.address, + valueString: output.amount, + coinName: walletCoin, + }, + ]; + + const completedParsedTx = { + inputs: inputs, + outputs: outputs, + spendAmount: output.amount, + type: TransactionType.Send, + }; + + const fee = parsedTx.fee as BigNumber; + const feeInfo = { fee: fee.toNumber(), feeString: fee.toString() }; + const coinSpecific = { commonKeychain: bitgoKey }; + + const transaction: MPCTx = { + serializedTx: serializedTxHex, + scanIndex, + coin: walletCoin, + signableHex: unsignedTransaction.signablePayload.toString('hex'), + derivationPath, + parsedTx: completedParsedTx, + feeInfo: feeInfo, + coinSpecific: coinSpecific, + }; + + const unsignedTxWrapper: MPCUnsignedTx = { unsignedTx: transaction, signatureShares: [] }; + const txRequest: RecoveryTxRequest = { transactions: [unsignedTxWrapper], walletCoin }; + return { txRequests: [txRequest] }; + } + // ======================================== // Private Helper Methods // ======================================== diff --git a/modules/sdk-coin-iota/src/lib/constants.ts b/modules/sdk-coin-iota/src/lib/constants.ts index a5a5f2cd17..bd7a781d97 100644 --- a/modules/sdk-coin-iota/src/lib/constants.ts +++ b/modules/sdk-coin-iota/src/lib/constants.ts @@ -88,3 +88,23 @@ export const MAX_GAS_PRICE = 100000; * - TransferObjects: Transfer coins/objects to recipients */ export const TRANSFER_TRANSACTION_COMMANDS = ['SplitCoins', 'MergeCoins', 'TransferObjects']; + +/** + * Maximum number of coin objects to include in a single recovery transaction. + * IOTA transactions have a max size of 128 KiB, which practically limits + * transactions to ~1600 objects depending on other details. + * We use 1280 as a safe limit, keeping room for recipients, gas data, etc. + * (IOTA protocol max_input_objects = 2048, max_tx_size_bytes = 131072) + */ +export const MAX_OBJECT_LIMIT = 1280; + +/** + * Maximum number of gas payment objects in a token recovery transaction. + */ +export const MAX_GAS_OBJECTS = 256; + +/** + * Default number of addresses to scan during recovery. + */ +export const DEFAULT_SCAN_FACTOR = 20; +export const DEFAULT_GAS_OVERHEAD = 1.1; diff --git a/modules/sdk-coin-iota/src/lib/utils.ts b/modules/sdk-coin-iota/src/lib/utils.ts index 8dbf9b30b4..59af353f2a 100644 --- a/modules/sdk-coin-iota/src/lib/utils.ts +++ b/modules/sdk-coin-iota/src/lib/utils.ts @@ -173,6 +173,26 @@ export class Utils implements BaseUtils { const iotaPublicKey = new Ed25519PublicKey(Buffer.from(publicKey, 'hex')); return iotaPublicKey.toIotaAddress(); } + + // ======================================== + // Recovery Validation Methods + // ======================================== + + getSafeNumber(defaultVal: number, errorMsg: string, inputVal?: number): number { + if (inputVal === undefined) { + return defaultVal; + } + let nonNegativeNum: number; + try { + nonNegativeNum = Number(inputVal); + } catch (e) { + throw new Error(errorMsg); + } + if (isNaN(nonNegativeNum.valueOf()) || nonNegativeNum < 0) { + throw new Error(errorMsg); + } + return nonNegativeNum; + } } /** diff --git a/modules/sdk-coin-iota/test/resources/iota.ts b/modules/sdk-coin-iota/test/resources/iota.ts index 5769705d84..210f0161fa 100644 --- a/modules/sdk-coin-iota/test/resources/iota.ts +++ b/modules/sdk-coin-iota/test/resources/iota.ts @@ -105,3 +105,52 @@ export const testGasSponsorSignature = { pub: gasSponsor.publicKey, }, }; + +export const keys = { + userKey: + '{"iv":"5zIleVnaLFh4t7RkmDuYWA==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' + + ':"ccm","adata":"","cipher":"aes","salt":"a2GdyuPHoHc=","ct":"FNmholWWRDg/3r\n' + + 'TNqx7NxEHz6vZ14iJsJGKysKA6S2hXCsZXUjyUEaMBLQFgbo4w88UptMHS3mtJNUD3i8y6Zk5Kg\n' + + 'jAI35wSrofCfIlrTPjghh+z/pKTJIuGvcTGKLmYUVbtoZCei/76GkJvgR0HFy9f44ZZIoW771VS\n' + + 'u2OfxuQjAsP8qUYl9JZNs7nY0PDHSVSjJbXnTkgFf1y9KgJQ3Gv3nSRD+/YFpFzHqlbxfizt5R1\n' + + 'WzUBkLWTmV9SDT4qm/Qv9NA57Fc+6YPm1J9xL/HPBCLKmok4CHXYvfX4qZYAHeRfYTTFYN9Cg0d\n' + + 'svHL4CUKJA4g5f2eoDmPRguAK4jvbkQqTcpj7YgWZsJPp8FRJD6SRCpKRHYD/VNzV9Pz2KgzFxt\n' + + 'E941kD5Bjz9Lvo8q8CcoPuUx8L8+D9qm3APnkrXQXJ3rFxO/r9li681XNdkUOp6FvY28OeEBgdu\n' + + 'jzCpjtyEwZDh19a1AjNoa6tGLK+GwE7qNeZ/VxVl1YsK3wfwIyfZvrqgbUI91fcYYUPTGfRv+lY\n' + + 'dcOFsiimUAYRKFpEEawLGAqGwzRTEAz9cVvtg/zPj/rVsEZ6oMl0ogFPIsuGInCFw/Tqk4RWlGZ\n' + + 'S1FTsnP44QSYNV3JjK3MJxZ51ToV04cwm5yt4DjjhbQZhSdHXxvOe5axerGwCFKYJDq5lojabGn\n' + + 'MLI7hTtLd29qcBL7FHgtXuapiSLHOXrdkS7qui2oXS59JUBr6nlVBpAqoVwisizdy1kUrjanVwk\n' + + '9VlqYSQtv1I9VMrM+JiWqZ+oBZd58lQiJsteCz2BwbCthd+7tlOD04TbJyFff9JOiVgSWBfcsL9\n' + + 'u+hWPZcpi7xU04QkPD8ru1ayK5L6LVQQvN7Nne2tIJCIBVE67BQ34icyjV6+VY/Y+YbAayX6X2x\n' + + 'hgrR3AW08LNuKiVxDJx3iNxmnXrqOCzGUzdaX+54qs1npP9ri9YpKDiD/u8VmcnXdQgzCE0sSMT\n' + + 'kUbAdS4ru3uXjNoJe848S8mr/YmgjwK4URmK1S5R6G5k48pQjal7aLUnp7Hvj9KcNsjDK+KaIxi\n' + + '0IspfwMeaN5dc5rbmj5ARPskHiSNny1Rivf4cxkoa5ecQCgmzMtuutExL2XgkdRdVAVIIXyM5hc\n' + + 'S01ANvxyrDKwKMJfwpU8mWc5fyJeK+BCm8NJTzprDmHY/Myb6cQF1bvxFrOLI7Uut"}', + backupKey: + '{"iv":"H3k/G/X2nxj07c+G357WTg==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' + + ':"ccm","adata":"","cipher":"aes","salt":"FLhYgX8tlRQ=","ct":"5DikpaMGVA0gEj\n' + + 'gagN4niJcyyhmGGwoJGWbmz8fwsvIdRxUA9WJWvuILX+jnqSUZrUFPzHVMGuhIKz6zE0OtpKIOU\n' + + 'Wnnp0O6mXfVnZ6n88Ti9q4H7cBoO655T100TTgm9Mqp8rMa7+SGf+KeDIYXPpX1hGSnKxFt8UlZ\n' + + '+b+KoZndl87EKYsAjuSQLCl+WeB0uPr3CqohIu0dGF+d9gJOOkJsiosEKAZ5kY1SR6j8aLL6uDn\n' + + 'RKS6EiQfjeXQWBFJYFUS2cvZgKpfcAn0PXy2txlQmkJPMtJfclMqHvubOA8Bcj7p6UEjxJsMOUx\n' + + 'WKMvaynGenzoUcV8o4fkA0fKGGt+/i/tBfAV23tERjOdAvkUApGZgkOeaUySYPvo0m1NnQl9+1S\n' + + 't0kiTsJ14KoxKfVp3iAOJY6wr5gnB9cJIy27OWeQC1FaTXo40yE7FizS/fagxOoE2KUMvYxi1F+\n' + + '6sg9L3ygUumfA2rv1x25NBvYHvwK8KzYGf1W4A4dRSdeGDXpIlUuE1QjDNnH9PH3nxgmY9mnJgF\n' + + 'pYmUTBz27FgtsFAE0Et0Lv0ESBmpKBNGMG8lncX56cTdGUbDQTEEXjOy+kkO09GV9QCq/mRlIKT\n' + + '0HUCymfVn/MTm9V26W6wj5P/A1/HmlCJxzeVCrHftietHIB+rRGpoTvzN6WOeZ0Nbwybjr78CfR\n' + + 'FZJ5RIJieUg+S4bLqyMDb1QRsiY9N8JWEvTssBSacbpJMLVu++Ef+MqrLt3chaqTPY+vXNmFtt3\n' + + 'dDJ1OA69MM3EXYGmF2NyHCJ703y++qRAKNZrYyJP5FsBmwz1DAlsTEXc7p6k8r4zZfTg6JuIEIn\n' + + '8f0XpLW5rm841kGR5GZYn4d5VF5CXxWq5naNByZRPFOeqEquz1T5vjtVaS96GZUyyirVcqIeSkM\n' + + '0QiVdgCM+V18B7XQP6YJWn+zO+siVoyOvdwU6TIeKhAN4s0QTlG7DhNTbg8JNM1JWZPm8LUHk5q\n' + + 'CCXJBpFbZkPabpXWVSRxkBBU2oIui9fgn0F0Dk8fObmkJMd9GM3XWKZasriEoeXI4E1sp2RCBZB\n' + + 'CWyJBXa7jwGpUkQkyMuStG/1cPOEqHohSUEhFPDgxZdMkOq3KKxpe+0xZxb59ZYlbn1LFFC+olq\n' + + 'kj/FttWirJQ7MqFXXlvqvvBmckHyZnYEzJZct2DdbinIm3/nHoP4fSPotJKjkUw=="}', + bitgoKey: + '3b89eec9d2d2f3b049ecda2e7b5f47827f7927fe6618d6e8b13f64e7c95f4b00b9577ab0139\n' + + '5ecf8eeb804b590cedae14ff5fd3947bf3b7a95b9327c49e27c54', + bitgoKeyColdWallet: + '79d4b9b594df028fee3725a6af51ae3ab6a3519e9d2c322f2c8fd815b96496323c5aba7ea87\n' + + '4c102f966f1a61d3c9a42b5f3177c6a85712cf313715afddf83d8', + bitgoKeyWithSeed: + 'ca0a014ba6f11106a155ef8e2cab2f76d277e4f01cffa591a9b40848343823b3d910752a49c96bf5813985206e23c9f9cd3a78f1cccf5cf88def52b573cedc93', +}; diff --git a/modules/sdk-coin-iota/test/unit/iota.ts b/modules/sdk-coin-iota/test/unit/iota.ts index 6961f815d2..307c0a997c 100644 --- a/modules/sdk-coin-iota/test/unit/iota.ts +++ b/modules/sdk-coin-iota/test/unit/iota.ts @@ -7,6 +7,8 @@ import { coins, GasTankAccountCoin } from '@bitgo/statics'; import * as testData from '../resources/iota'; import { TransactionType } from '@bitgo/sdk-core'; import { createTransferBuilderWithGas } from './helpers/testHelpers'; +import sinon from 'sinon'; +import { keys } from '../resources/iota'; describe('IOTA:', function () { let bitgo: TestBitGoAPI; @@ -599,4 +601,706 @@ describe('IOTA:', function () { }); }); }); + + describe('Recover Transactions:', () => { + const sandBox = sinon.createSandbox(); + const senderAddress0 = '0xfd36d2ad48edf5671abf04f5c0eef3464bf92cf45ae655aff471cfaedb61fa99'; + const recoveryDestination = '0xda97e166d40fa6a0c949b6aeb862e391c29139b563ae0430b2419c589a02a6e0'; + const walletPassphrase = 'p$Sw { + sandBox.stub(Iota.prototype, 'getBalance' as keyof Iota).resolves(1900000000n); + sandBox.stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota).resolves([ + { + objectId: '0xc05c765e26e6ae84c78fa245f38a23fb20406a5cf3f61b57bd323a0df9d98003', + version: '195', + digest: '7BJLb32LKN7wt5uv4xgXW4AbFKoMNcPE76o41TQEvUZb', + balance: '1900000000', + }, + ]); + sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000); + sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(1997880); + }); + + afterEach(() => { + sandBox.restore(); + }); + + it('should recover a txn for non-bitgo recovery', async function () { + const res = await basecoin.recover({ + userKey: keys.userKey, + backupKey: keys.backupKey, + bitgoKey: keys.bitgoKey, + recoveryDestination, + walletPassphrase, + }); + res.should.not.be.empty(); + res.should.hasOwnProperty('transactions'); + const tx = res.transactions[0]; + tx.scanIndex.should.equal(0); + tx.recoveryAmount.should.equal('1897802332'); + tx.serializedTx.should.equal( + 'AAACAAhcKh5xAAAAAAAg2pfhZtQPpqDJSbauuGLjkcKRObVjrgQwskGcWJoCpuACAgABAQAAAQEDAAAAAAEBAP020q1I7fVnGr8E9cDu80ZL+Sz0WuZVr/Rxz67bYfqZAcBcdl4m5q6Ex4+iRfOKI/sgQGpc8/YbV70yOg352YADwwAAAAAAAAAgW8mIkF0QCGLXhzFxKv8dQiEFkxssx3itzNRMIwhvdSb9NtKtSO31Zxq/BPXA7vNGS/ks9FrmVa/0cc+u22H6megDAAAAAAAApIghAAAAAAAA' + ); + + sandBox.assert.callCount(basecoin.fetchOwnedObjects, 1); + sandBox.assert.callCount(basecoin.estimateGas, 1); + }); + + it('should recover a txn for unsigned sweep recovery', async function () { + const res = await basecoin.recover({ + bitgoKey: keys.bitgoKey, + recoveryDestination, + }); + + res.should.not.be.empty(); + res.should.hasOwnProperty('txRequests'); + const unsignedTx = res.txRequests[0].transactions[0].unsignedTx; + unsignedTx.scanIndex.should.equal(0); + unsignedTx.coin.should.equal('tiota'); + unsignedTx.derivationPath.should.equal('m/0'); + unsignedTx.parsedTx.inputs[0].address.should.equal(senderAddress0); + unsignedTx.parsedTx.outputs[0].address.should.equal(recoveryDestination); + unsignedTx.parsedTx.spendAmount.should.equal('1897802332'); + unsignedTx.feeInfo.fee.should.equal(2197668); + unsignedTx.feeInfo.feeString.should.equal('2197668'); + unsignedTx.coinSpecific.commonKeychain.should.equal( + '3b89eec9d2d2f3b049ecda2e7b5f47827f7927fe6618d6e8b13f64e7c95f4b00b9577ab01395ecf8eeb804b590cedae14ff5fd3947bf3b7a95b9327c49e27c54' + ); + + sandBox.assert.callCount(basecoin.fetchOwnedObjects, 1); + sandBox.assert.callCount(basecoin.estimateGas, 1); + }); + }); + + describe('Recover Transactions for wallet with multiple addresses:', () => { + const sandBox = sinon.createSandbox(); + const senderAddress0 = '0xfd36d2ad48edf5671abf04f5c0eef3464bf92cf45ae655aff471cfaedb61fa99'; + const senderAddress1 = '0x62d5c86e6578d54ad9545fe9b323e54e8792964e0189d7f094d4c79865b9b827'; + const recoveryDestination = '0xda97e166d40fa6a0c949b6aeb862e391c29139b563ae0430b2419c589a02a6e0'; + const walletPassphrase = 'p$Sw { + const sandBox = sinon.createSandbox(); + const receiveAddress1 = '0x62d5c86e6578d54ad9545fe9b323e54e8792964e0189d7f094d4c79865b9b827'; + const receiveAddress2 = '0x0898a1bc18fd1c9f7c8fdf266bfdc633fe2ac73314bf071b2e0e9800131bfb85'; + const walletPassphrase = 'p$Sw { + const sandBox = sinon.createSandbox(); + const senderAddress0 = '0xfd36d2ad48edf5671abf04f5c0eef3464bf92cf45ae655aff471cfaedb61fa99'; + const coldWalletAddress0 = '0x749fa49b82a76c995b9bd953c83b3291dcd8845854189f342f190d0ea3a435ea'; + const recoveryDestination = '0xda97e166d40fa6a0c949b6aeb862e391c29139b563ae0430b2419c589a02a6e0'; + const walletPassphrase = 'p$Sw { + sandBox.restore(); + }); + + function stubTokenRecovery(senderAddr: string, tokenBalance: string, gasBalance: string): void { + sandBox.stub(Iota.prototype, 'hasTokenBalance' as keyof Iota).callsFake(function (addr: string) { + return Promise.resolve(addr === senderAddr); + }); + sandBox + .stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota) + .callsFake(function (addr: string, _rpc: unknown, coinType: string) { + if (addr === senderAddr && coinType === tokenContractAddress) { + return Promise.resolve([ + { objectId: '0xaaaa' + senderAddr.slice(6), version: '100', digest: validDigest, balance: tokenBalance }, + ]); + } + if (addr === senderAddr && !coinType) { + return Promise.resolve([ + { objectId: '0xbbbb' + senderAddr.slice(6), version: '200', digest: validDigest, balance: gasBalance }, + ]); + } + return Promise.resolve([]); + }); + sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000); + sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(2345504); + } + + it('should recover a token txn for non-bitgo recovery', async function () { + stubTokenRecovery(senderAddress0, '1000', '500000000'); + + const res = await basecoin.recover({ + userKey: keys.userKey, + backupKey: keys.backupKey, + bitgoKey: keys.bitgoKey, + recoveryDestination, + walletPassphrase, + tokenContractAddress, + }); + res.should.not.be.empty(); + res.should.hasOwnProperty('transactions'); + const tx = res.transactions[0]; + tx.scanIndex.should.equal(0); + tx.recoveryAmount.should.equal('1000'); + tx.coin.should.equal(tokenContractAddress); + + sandBox.assert.callCount(basecoin.hasTokenBalance, 1); + sandBox.assert.callCount(basecoin.fetchOwnedObjects, 2); + sandBox.assert.callCount(basecoin.estimateGas, 1); + }); + + it('should recover a token txn for unsigned sweep recovery', async function () { + stubTokenRecovery(coldWalletAddress0, '1000', '200000000'); + + const res = await basecoin.recover({ + bitgoKey: keys.bitgoKeyColdWallet, + recoveryDestination, + tokenContractAddress, + }); + res.should.not.be.empty(); + res.should.hasOwnProperty('txRequests'); + const unsignedTx = res.txRequests[0].transactions[0].unsignedTx; + unsignedTx.scanIndex.should.equal(0); + unsignedTx.coin.should.equal(tokenContractAddress); + unsignedTx.derivationPath.should.equal('m/0'); + unsignedTx.parsedTx.inputs[0].address.should.equal(coldWalletAddress0); + unsignedTx.parsedTx.outputs[0].address.should.equal(recoveryDestination); + unsignedTx.parsedTx.spendAmount.should.equal('1000'); + unsignedTx.feeInfo.fee.should.equal(2580054); + res.txRequests[0].walletCoin.should.equal(tokenContractAddress); + + sandBox.assert.callCount(basecoin.hasTokenBalance, 1); + sandBox.assert.callCount(basecoin.fetchOwnedObjects, 2); + sandBox.assert.callCount(basecoin.estimateGas, 1); + }); + }); + + describe('Recover Token Transactions for wallet with multiple addresses:', () => { + const sandBox = sinon.createSandbox(); + const senderAddress1 = '0x62d5c86e6578d54ad9545fe9b323e54e8792964e0189d7f094d4c79865b9b827'; + const recoveryDestination = '0xda97e166d40fa6a0c949b6aeb862e391c29139b563ae0430b2419c589a02a6e0'; + const walletPassphrase = 'p$Sw { + const sandBox = sinon.createSandbox(); + const walletPassphrase = 'p$Sw): void { + sandBox.stub(Iota.prototype, 'hasTokenBalance' as keyof Iota).callsFake(function (addr: string) { + return Promise.resolve(!!addrsMap[addr]); + }); + sandBox + .stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota) + .callsFake(function (addr: string, _rpc: unknown, coinType: string) { + const m = addrsMap[addr]; + if (!m) return Promise.resolve([]); + if (coinType === tokenContractAddress) { + return Promise.resolve([ + { objectId: '0xaaaa' + addr.slice(6), version: '400', digest: validDigest, balance: m.tb }, + ]); + } + if (!coinType) { + return Promise.resolve([ + { objectId: '0xbbbb' + addr.slice(6), version: '401', digest: validDigest, balance: m.gb }, + ]); + } + return Promise.resolve([]); + }); + sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000); + sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(2345504); + } + + it('should build signed token consolidation transactions for hot wallet', async function () { + stubMultiTokenRecovery({ + [hotWalletAddress1]: { tb: '1500', gb: '116720144' }, + [hotWalletAddress2]: { tb: '2000', gb: '120101976' }, + }); + + const res = await basecoin.recoverConsolidations({ + userKey: keys.userKey, + backupKey: keys.backupKey, + bitgoKey: keys.bitgoKey, + walletPassphrase, + tokenContractAddress, + startingScanIndex: 1, + endingScanIndex: 3, + }); + + const transactions = res.transactions; + transactions.length.should.equal(2); + + const txn1 = transactions[0]; + txn1.scanIndex.should.equal(1); + txn1.recoveryAmount.should.equal('1500'); + txn1.coin.should.equal(tokenContractAddress); + + const txn2 = transactions[1]; + txn2.scanIndex.should.equal(2); + txn2.recoveryAmount.should.equal('2000'); + txn2.coin.should.equal(tokenContractAddress); + + res.lastScanIndex.should.equal(2); + + sandBox.assert.callCount(basecoin.fetchOwnedObjects, 4); + sandBox.assert.callCount(basecoin.estimateGas, 2); + }); + + it('should build unsigned token consolidation transactions for cold wallet', async function () { + stubMultiTokenRecovery({ + [coldWalletAddress1]: { tb: '4000', gb: '116720144' }, + [coldWalletAddress2]: { tb: '6000', gb: '120101976' }, + }); + + const res = await basecoin.recoverConsolidations({ + bitgoKey: keys.bitgoKeyColdWallet, + tokenContractAddress, + startingScanIndex: 1, + endingScanIndex: 3, + }); + + res.should.hasOwnProperty('txRequests'); + res.txRequests.length.should.equal(2); + + const unsignedTx1 = res.txRequests[0].transactions[0].unsignedTx; + unsignedTx1.scanIndex.should.equal(1); + unsignedTx1.coin.should.equal(tokenContractAddress); + unsignedTx1.derivationPath.should.equal('m/1'); + unsignedTx1.parsedTx.inputs[0].address.should.equal(coldWalletAddress1); + unsignedTx1.parsedTx.spendAmount.should.equal('4000'); + unsignedTx1.feeInfo.fee.should.equal(2580054); + res.txRequests[0].walletCoin.should.equal(tokenContractAddress); + + const unsignedTx2 = res.txRequests[1].transactions[0].unsignedTx; + unsignedTx2.scanIndex.should.equal(2); + unsignedTx2.coin.should.equal(tokenContractAddress); + unsignedTx2.derivationPath.should.equal('m/2'); + unsignedTx2.parsedTx.inputs[0].address.should.equal(coldWalletAddress2); + unsignedTx2.parsedTx.spendAmount.should.equal('6000'); + unsignedTx2.feeInfo.fee.should.equal(2580054); + res.txRequests[1].walletCoin.should.equal(tokenContractAddress); + + sandBox.assert.callCount(basecoin.fetchOwnedObjects, 4); + sandBox.assert.callCount(basecoin.estimateGas, 2); + }); + }); + + describe('Recover Consolidation Transactions with seed', () => { + const sandBox = sinon.createSandbox(); + const validDigest = '7BJLb32LKN7wt5uv4xgXW4AbFKoMNcPE76o41TQEvUZb'; + const seedReceiveAddress1 = '0x9d33bc56f9d47c473cbea009f7d448670a9d32640c5b1b50a75c6f7879bc5994'; + const seedReceiveAddress2 = '0xb32633913130a613807de9f83356d22baef778d78da5799ef4e566095a9ffe50'; + const seedBaseAddress = '0x087a72a698ace16e252b8b66bf1184b4829a373a0d9ab546921d19efdb849034'; + + beforeEach(function () { + sandBox.stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota).callsFake(function (addr: string) { + if (addr === seedReceiveAddress1) { + return Promise.resolve([ + { + objectId: '0x3333' + seedReceiveAddress1.slice(6), + version: '600', + digest: validDigest, + balance: '500000000', + }, + ]); + } + if (addr === seedReceiveAddress2) { + return Promise.resolve([ + { + objectId: '0x3333' + seedReceiveAddress2.slice(6), + version: '601', + digest: validDigest, + balance: '200000000', + }, + ]); + } + return Promise.resolve([]); + }); + sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000); + sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(1997880); + }); + + afterEach(function () { + sandBox.restore(); + }); + + it('should build unsigned consolidation transactions for cold wallet with seed', async function () { + const res = await basecoin.recoverConsolidations({ + bitgoKey: keys.bitgoKeyWithSeed, + startingScanIndex: 1, + endingScanIndex: 3, + seed: '123', + }); + + res.should.hasOwnProperty('txRequests'); + res.txRequests.length.should.equal(2); + + const unsignedTx1 = res.txRequests[0].transactions[0].unsignedTx; + unsignedTx1.scanIndex.should.equal(1); + unsignedTx1.coin.should.equal('tiota'); + unsignedTx1.derivationPath.should.equal('m/999999/94862622/157363509/1'); + unsignedTx1.parsedTx.inputs[0].address.should.equal(seedReceiveAddress1); + unsignedTx1.parsedTx.outputs[0].address.should.equal(seedBaseAddress); + unsignedTx1.parsedTx.spendAmount.should.equal('497802332'); + unsignedTx1.feeInfo.fee.should.equal(2197668); + + const unsignedTx2 = res.txRequests[1].transactions[0].unsignedTx; + unsignedTx2.scanIndex.should.equal(2); + unsignedTx2.coin.should.equal('tiota'); + unsignedTx2.derivationPath.should.equal('m/999999/94862622/157363509/2'); + unsignedTx2.parsedTx.inputs[0].address.should.equal(seedReceiveAddress2); + unsignedTx2.parsedTx.outputs[0].address.should.equal(seedBaseAddress); + unsignedTx2.parsedTx.spendAmount.should.equal('197802332'); + + sandBox.assert.callCount(basecoin.fetchOwnedObjects, 2); + sandBox.assert.callCount(basecoin.estimateGas, 2); + }); + }); + + describe('Recover Transaction Failures:', () => { + const sandBox = sinon.createSandbox(); + const senderAddress0 = '0xfd36d2ad48edf5671abf04f5c0eef3464bf92cf45ae655aff471cfaedb61fa99'; + const recoveryDestination = '0xda97e166d40fa6a0c949b6aeb862e391c29139b563ae0430b2419c589a02a6e0'; + const walletPassphrase = 'p$Sw { + it('should fail due to insufficient funds in receive address', async function () { + const sandBox = sinon.createSandbox(); + const receiveAddress1 = '0x62d5c86e6578d54ad9545fe9b323e54e8792964e0189d7f094d4c79865b9b827'; + const walletPassphrase = 'p$Sw