diff --git a/modules/sdk-coin-starknet/src/lib/constants.ts b/modules/sdk-coin-starknet/src/lib/constants.ts index 398cdd249f..e5bfb4d7cd 100644 --- a/modules/sdk-coin-starknet/src/lib/constants.ts +++ b/modules/sdk-coin-starknet/src/lib/constants.ts @@ -42,3 +42,10 @@ export function defaultResourceBounds(): StarknetResourceBounds { l1_data_gas: { max_amount: '0x3e8', max_price_per_unit: '0x2540be400' }, }; } + +// Fixed gas amounts per tx-type. EthAccount secp256k1 __validate__ costs ~24M L2 gas; 40M ≈ 1.6x buffer. +export const RECOVERY_L2_GAS_MAX_AMOUNT = '0x2625a00'; // 40,000,000 +export const RECOVERY_L1_DATA_GAS_MAX_AMOUNT = '0xbb8'; // 3,000 +export const RECOVERY_GAS_PRICE_BUFFER_MULTIPLIER = 2n; +// Floor for the committed L2 price. The Starknet sequencer currently requires ≥ ~29 GFri; +export const RECOVERY_L2_GAS_MIN_PRICE_PER_UNIT = 50_000_000_000n; diff --git a/modules/sdk-coin-starknet/src/lib/index.ts b/modules/sdk-coin-starknet/src/lib/index.ts index 047e8281a1..be7c4a2148 100644 --- a/modules/sdk-coin-starknet/src/lib/index.ts +++ b/modules/sdk-coin-starknet/src/lib/index.ts @@ -8,3 +8,4 @@ export { WalletInitializationBuilder } from './walletInitializationBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { Transaction } from './transaction'; export { Utils }; +export * from './recovery'; diff --git a/modules/sdk-coin-starknet/src/lib/recovery.ts b/modules/sdk-coin-starknet/src/lib/recovery.ts new file mode 100644 index 0000000000..1ed9126179 --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/recovery.ts @@ -0,0 +1,360 @@ +import { ECDSAUtils, Ecdsa } from '@bitgo/sdk-core'; +import { NetworkType } from '@bitgo/statics'; +import { Starknet, StarknetRecoveryOptions } from '../starknet'; +import { + STRK_TOKEN_CONTRACT, + RECOVERY_L2_GAS_MAX_AMOUNT, + RECOVERY_L1_DATA_GAS_MAX_AMOUNT, + RECOVERY_GAS_PRICE_BUFFER_MULTIPLIER, + RECOVERY_L2_GAS_MIN_PRICE_PER_UNIT, +} from './constants'; +import { TransferBuilder } from './transferBuilder'; +import { WalletInitializationBuilder } from './walletInitializationBuilder'; +import { Transaction } from './transaction'; +import utils from './utils'; +import { StarknetResourceBounds } from './iface'; + +export async function queryStarknetNode(nodeUrl: string, method: string, params: unknown): Promise { + const response = await fetch(nodeUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params, + }), + }); + if (!response.ok) { + throw new Error(`Starknet Node RPC HTTP error: ${response.status} ${response.statusText}`); + } + const result = await response.json(); + if (result.error) { + throw new Error( + `Starknet Node RPC error (code ${result.error.code}): ${result.error.message || JSON.stringify(result.error)}` + ); + } + return result.result; +} + +interface StarknetGasPrices { + l1: bigint; + l2: bigint; + l1Data: bigint; +} + +/** Read live per-resource gas prices (in fri) from the latest block header. */ +async function getGasPrices(nodeUrl: string): Promise { + const block = await queryStarknetNode(nodeUrl, 'starknet_getBlockWithTxHashes', ['latest']); + const toFri = (gasPrice: any): bigint => { + try { + return BigInt(gasPrice?.price_in_fri ?? '0x0'); + } catch { + return 0n; + } + }; + return { + l1: toFri(block?.l1_gas_price), + l2: toFri(block?.l2_gas_price), + l1Data: toFri(block?.l1_data_gas_price), + }; +} + +/** + * Build V3 resource bounds (mirrors bitgo-microservices wallet-platform `buildResourceBounds`): + * fixed generous amounts per resource, live prices with a 2x buffer, and an L2 price floor. + */ +function buildRecoveryResourceBounds(gasPrices: StarknetGasPrices): StarknetResourceBounds { + const bufferedL2Price = gasPrices.l2 * RECOVERY_GAS_PRICE_BUFFER_MULTIPLIER; + const l2Price = + bufferedL2Price > RECOVERY_L2_GAS_MIN_PRICE_PER_UNIT ? bufferedL2Price : RECOVERY_L2_GAS_MIN_PRICE_PER_UNIT; + return { + l2_gas: { max_amount: RECOVERY_L2_GAS_MAX_AMOUNT, max_price_per_unit: '0x' + l2Price.toString(16) }, + // V3 txns don't consume L1 gas (data goes through L1_DATA), so amount is 0. + l1_gas: { + max_amount: '0x0', + max_price_per_unit: '0x' + (gasPrices.l1 * RECOVERY_GAS_PRICE_BUFFER_MULTIPLIER).toString(16), + }, + l1_data_gas: { + max_amount: RECOVERY_L1_DATA_GAS_MAX_AMOUNT, + max_price_per_unit: '0x' + (gasPrices.l1Data * RECOVERY_GAS_PRICE_BUFFER_MULTIPLIER).toString(16), + }, + }; +} + +/** Maximum fee the sequencer can charge = sum of (max_amount x max_price_per_unit) over resources. */ +function maxFeeFromResourceBounds(rb: StarknetResourceBounds): bigint { + const product = (amount: string, price: string): bigint => BigInt(amount) * BigInt(price); + return ( + product(rb.l2_gas.max_amount, rb.l2_gas.max_price_per_unit) + + product(rb.l1_gas.max_amount, rb.l1_gas.max_price_per_unit) + + product(rb.l1_data_gas.max_amount, rb.l1_data_gas.max_price_per_unit) + ); +} + +async function buildDeployTransaction( + coin: Starknet, + derivedPublicKey: string, + chainId: string, + resourceBounds: any, + isUnsignedSweep: boolean, + userKeyShare?: Buffer, + backupKeyShare?: Buffer, + commonKeyChain?: string +): Promise<{ deployTx: Transaction; deployTxHex: string }> { + const deployBuilder = new WalletInitializationBuilder((coin as any)._staticsCoin); + deployBuilder.fromPublicKey(derivedPublicKey); + deployBuilder.nonce('0x0'); + deployBuilder.chainId(chainId); + deployBuilder.resourceBounds(resourceBounds); + const deployTx = (await deployBuilder.build()) as Transaction; + + if (!isUnsignedSweep) { + const deployMessageHash = Buffer.from(deployTx.signableHex, 'hex'); + const deploySignature = await coin.signRecoveryTransaction( + deployMessageHash, + userKeyShare!, + backupKeyShare!, + commonKeyChain! + ); + const deployFormattedSig = utils.formatEthAccountSignature( + deploySignature.r, + deploySignature.s, + deploySignature.recid + ); + deployTx.starknetTransactionData.signature = deployFormattedSig; + deployTx.signedTransaction = deployTx.toInternalHex(); + } + + return { + deployTx, + deployTxHex: deployTx.toInternalHex(), + }; +} + +export async function recoverStarknetWallet(coin: Starknet, params: StarknetRecoveryOptions): Promise { + if (!params.bitgoKey) { + throw new Error('Missing bitgoKey (Box C)'); + } + if (!params.recoveryDestination || !coin.isValidAddress(params.recoveryDestination)) { + throw new Error('Invalid recoveryDestination'); + } + + const bitgoKey = params.bitgoKey.replace(/\s/g, ''); + const isUnsignedSweep = !params.walletPassphrase || !params.userKey || !params.backupKey; + + const index = params.index || 0; + const derivationPath = `m/${index}`; + const ecdsa = new Ecdsa(); + + let commonKeyChain = bitgoKey; + let userKeyShare: Buffer | undefined; + let backupKeyShare: Buffer | undefined; + + if (!isUnsignedSweep) { + const userKey = params.userKey!.replace(/\s/g, ''); + const backupKey = params.backupKey!.replace(/\s/g, ''); + + const shares = await ECDSAUtils.getMpcV2RecoveryKeyShares( + userKey, + backupKey, + params.walletPassphrase, + (coin as any).bitgo + ); + userKeyShare = shares.userKeyShare; + backupKeyShare = shares.backupKeyShare; + commonKeyChain = shares.commonKeyChain; + } + + // Derive public key and Starknet address from common keychain + const derivedCommonKeyChain = ecdsa.deriveUnhardened(commonKeyChain, derivationPath); + const derivedPublicKey = derivedCommonKeyChain.slice(0, 66); // 33 bytes compressed hex + const senderAddress = utils.getAddressFromPublicKey(derivedPublicKey); + + // Get Starknet node URL from options or defaults + const nodeUrl = + params.nodeUrl || + params.starknetNodeUrl || + ((coin as any)._staticsCoin.network.type === NetworkType.TESTNET + ? 'https://starknet-sepolia-rpc.publicnode.com/' + : 'https://starknet-mainnet-rpc.publicnode.com/'); + + // Check if account is deployed on-chain. Counterfactual accounts that have received funds + // but never transacted are not yet deployed and need a DEPLOY_ACCOUNT before the INVOKE sweep. + let isDeployed = true; + try { + await queryStarknetNode(nodeUrl, 'starknet_getClassHashAt', ['latest', senderAddress]); + } catch (e: any) { + const msg = (e.message || '').toLowerCase(); + if (msg.includes('contract not found') || msg.includes('code 20') || msg.includes('code 28')) { + isDeployed = false; + } else { + throw new Error(`Failed to check account deployment status: ${e.message}`); + } + } + + // Determine the nonce for the INVOKE sweep. + // - Deployed account: query the live nonce. + // - Undeployed account: the DEPLOY_ACCOUNT consumes nonce 0, so the following sweep uses nonce 1. + let sweepNonce: string; + if (isDeployed) { + try { + const nonceResult = await queryStarknetNode(nodeUrl, 'starknet_getNonce', ['latest', senderAddress]); + sweepNonce = '0x' + BigInt(nonceResult).toString(16); + } catch (e) { + sweepNonce = '0x0'; + } + } else { + sweepNonce = '0x1'; + } + + // Query node for balance of the token + const tokenContractAddress = params.tokenContractAddress || STRK_TOKEN_CONTRACT; + const balanceOfSelector = '0x' + utils.getSelectorFromName('balance_of').toString(16); + let balance = 0n; + try { + const balanceResult = await queryStarknetNode(nodeUrl, 'starknet_call', [ + { + contract_address: tokenContractAddress, + entry_point_selector: balanceOfSelector, + calldata: [senderAddress], + }, + 'latest', + ]); + if (Array.isArray(balanceResult) && balanceResult.length >= 2) { + const low = BigInt(balanceResult[0]); + const high = BigInt(balanceResult[1]); + balance = (high << 128n) | low; + } + } catch (e: any) { + throw new Error(`Failed to query balance of token ${tokenContractAddress}: ${e.message}`); + } + + // Build V3 resource bounds from live per-resource gas prices. + const gasPrices = await getGasPrices(nodeUrl); + const resourceBounds = buildRecoveryResourceBounds(gasPrices); + const maxFee = maxFeeFromResourceBounds(resourceBounds); + + const chainId = + (coin as any)._staticsCoin.network.type === NetworkType.TESTNET ? '0x534e5f5345504f4c4941' : '0x534e5f4d41494e'; + + let deployTxHex: string | undefined; + let deployTx: Transaction | undefined; + + if (!isDeployed) { + ({ deployTx, deployTxHex } = await buildDeployTransaction( + coin, + derivedPublicKey, + chainId, + resourceBounds, + isUnsignedSweep, + userKeyShare, + backupKeyShare, + commonKeyChain + )); + } + + // When sweeping the gas token (STRK), reserve the fee from the swept amount. Both the deploy + // and the sweep fees are paid in STRK from the same account, so reserve 2x when also deploying. + let amountToSend = balance; + if (tokenContractAddress.toLowerCase() === STRK_TOKEN_CONTRACT.toLowerCase()) { + const totalFeeReserve = isDeployed ? maxFee : maxFee * 2n; + amountToSend = balance - totalFeeReserve; + if (amountToSend <= 0n) { + throw new Error( + `Insufficient STRK balance to cover recovery fee of ${totalFeeReserve.toString()} fri. Balance: ${balance.toString()} fri.` + ); + } + } + + const factory = (coin as any).getBuilderFactory(); + const transferBuilder = factory.getTransferBuilder() as TransferBuilder; + transferBuilder + .sender(senderAddress) + .receiverId(params.recoveryDestination) + .amount(amountToSend.toString()) + .nonce(sweepNonce) + .chainId(chainId) + .resourceBounds(resourceBounds) + .tokenContractAddress(tokenContractAddress); + + const unsignedTransaction = (await transferBuilder.build()) as Transaction; + const signableHex = unsignedTransaction.signableHex; + + let serializedTx: string; + if (isUnsignedSweep) { + serializedTx = unsignedTransaction.toInternalHex(); + } else { + const cleanHex = signableHex.startsWith('0x') ? signableHex.slice(2) : signableHex; + const messageHash = Buffer.from(cleanHex, 'hex'); + const signature = await coin.signRecoveryTransaction(messageHash, userKeyShare!, backupKeyShare!, commonKeyChain); + + const formattedSignature = utils.formatEthAccountSignature(signature.r, signature.s, signature.recid); + + const txData = unsignedTransaction.starknetTransactionData; + txData.signature = formattedSignature; + unsignedTransaction.starknetTransactionData = txData; + unsignedTransaction.signedTransaction = unsignedTransaction.toInternalHex(); + + serializedTx = unsignedTransaction.toInternalHex(); + } + + // Build return metadata matching WRW requirements + const feeInfo = { + fee: 0, + feeString: maxFee.toString(), + }; + const coinSpecific = { + commonKeychain: bitgoKey, + }; + const parsedTx = { + inputs: [{ address: senderAddress, value: balance.toString(), valueString: balance.toString() }], + outputs: [ + { address: params.recoveryDestination, value: amountToSend.toString(), valueString: amountToSend.toString() }, + ], + spendAmount: amountToSend.toString(), + type: 'send', + }; + + const buildTxItem = (tx: Transaction, serialized: string, parsed: any) => { + const item: any = { + unsignedTx: { + serializedTx: serialized, + scanIndex: index, + coin: coin.getChain(), + signableHex: tx.signableHex, + derivationPath, + parsedTx: parsed, + feeInfo, + coinSpecific, + }, + signatureShares: [], + }; + if (!isUnsignedSweep) { + item.unsignedTx.broadcastFormat = JSON.parse(tx.toBroadcastFormat()); + } + return item; + }; + + const transactions: any[] = []; + if (deployTxHex && deployTx) { + transactions.push( + buildTxItem(deployTx, deployTxHex, { + inputs: [], + outputs: [], + spendAmount: '0', + type: 'deploy_account', + }) + ); + } + transactions.push(buildTxItem(unsignedTransaction, serializedTx, parsedTx)); + + return { + txRequests: [ + { + transactions, + walletCoin: coin.getChain(), + }, + ], + }; +} diff --git a/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts b/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts index ad985d2683..2e0218c844 100644 --- a/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts @@ -161,6 +161,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { tip: this._tip, }); + // Preserve signature if already signed + const existingSignature = this._transaction.starknetTransactionData?.signature; + const data: StarknetTransactionData = { senderAddress: sender, calls: this._calls, @@ -171,6 +174,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { tip: this._tip, transactionHash, compiledCalldata, + ...(existingSignature && existingSignature.length > 0 ? { signature: existingSignature } : {}), }; this._transaction.starknetTransactionData = data; diff --git a/modules/sdk-coin-starknet/src/lib/walletInitializationBuilder.ts b/modules/sdk-coin-starknet/src/lib/walletInitializationBuilder.ts index 2c239db36c..8e25c65025 100644 --- a/modules/sdk-coin-starknet/src/lib/walletInitializationBuilder.ts +++ b/modules/sdk-coin-starknet/src/lib/walletInitializationBuilder.ts @@ -81,6 +81,9 @@ export class WalletInitializationBuilder extends TransactionBuilder { tip: this._tip, }); + // Preserve signature if already signed + const existingSignature = this._transaction.starknetTransactionData?.signature; + const data: StarknetTransactionData = { senderAddress: contractAddress, contractAddress, @@ -94,6 +97,7 @@ export class WalletInitializationBuilder extends TransactionBuilder { classHash: this._classHash, constructorCalldata, contractAddressSalt, + ...(existingSignature && existingSignature.length > 0 ? { signature: existingSignature } : {}), }; this._transaction.starknetTransactionData = data; diff --git a/modules/sdk-coin-starknet/src/starknet.ts b/modules/sdk-coin-starknet/src/starknet.ts index 8265fed72f..6e2fcf9679 100644 --- a/modules/sdk-coin-starknet/src/starknet.ts +++ b/modules/sdk-coin-starknet/src/starknet.ts @@ -16,6 +16,7 @@ import { verifyMPCWalletAddress, UnexpectedAddressError, SignableTransaction, + ECDSAUtils, } from '@bitgo/sdk-core'; import { coins, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; import { createHash, Hash } from 'crypto'; @@ -24,6 +25,7 @@ import { StarknetTransactionExplanation, TransactionHexParams, TssVerifyStarknet import { TransactionBuilderFactory } from './lib/transactionBuilderFactory'; import utils from './lib/utils'; import { auditEcdsaPrivateKey } from '@bitgo/sdk-lib-mpc'; +import { recoverStarknetWallet } from './lib/recovery'; export class Starknet extends BaseCoin { protected readonly _staticsCoin: Readonly; @@ -192,4 +194,31 @@ export class Starknet extends BaseCoin { } auditEcdsaPrivateKey(prv as string, publicKey as string); } + + public async signRecoveryTransaction( + messageHash: Buffer, + userKeyShare: Buffer, + backupKeyShare: Buffer, + commonKeyChain: string + ): Promise<{ r: string; s: string; recid: number }> { + return ECDSAUtils.signRecoveryMpcV2(messageHash, userKeyShare, backupKeyShare, commonKeyChain); + } + + /** @inheritDoc */ + async recover(params: StarknetRecoveryOptions): Promise { + return recoverStarknetWallet(this, params); + } +} + +export interface StarknetRecoveryOptions { + userKey?: string; + backupKey?: string; + bitgoKey?: string; + walletPassphrase?: string; + recoveryDestination: string; + nodeUrl?: string; + starknetNodeUrl?: string; + tokenContractAddress?: string; + index?: number; + fee?: string; } diff --git a/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionRecover.ts b/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionRecover.ts new file mode 100644 index 0000000000..a342561fac --- /dev/null +++ b/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionRecover.ts @@ -0,0 +1,188 @@ +import should from 'should'; +import sinon from 'sinon'; +import { TestBitGo } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { Starknet } from '../../../src/index'; + +describe('Starknet Recovery', function () { + let bitgo; + let basecoin; + let fetchStub; + + // Test keys from sdk-coin-icp resources (encrypted with walletPassphrase below) + const userKey = + '{"iv":"ZfhJQF9+MUj7hZ8OoesfcA==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"+f/agM4bM8s=","ct":"2dQxSuUKSyFbe3vSYHSRG4p4PJ4XWA/yz7Af9dPpmFDN+2G4iXsUdkyscBsU1QGZ1gDgB7EUPnNIoa36Kbm2Ioh9QR1pms2xPzkHMvdO9UtMwch+tDPFMSYBCOfIWXjAVIIDpJcJthepIK+f2W8JiuWIz9m+TGV+R6kA1ahBURgyKBA7pyUuPrnXmWWj4ihEOOvxjt5df14ZcQ11KjtnaE4Mal2Zm+oXQj4VwW39CUF7QI+5XIBlhq3uXfJ6NLhRQ1DjH2imQVp8iCE1to8lBLj9V09beXNdXQBAomm4fugl6ejTp5tsig/75VKazYJzjNuOAAKaEHDkdMOUzdp8oOWq3eiBFMgD+9Zy31tYxCHGlKyMNjgOlwrKxmuv1zWrhEbYkALB+m7AUc2+qkCYUK+L+FfAPO/U0Ww3gq/mYtFDvdqSF6wDa68r5eab9fc04k1phrxRRuL1K02Hf68z6nvw0I9CCzaW9C2Gmyz8K06o7YlRBy7fkya11L++OWpEL5zGs8Fnamaz3EImLakL/gKSvJVNXLRxrh2btjAbs/hEXek3WMntJCK1RiwALbMVakBYZiKgKCXlD0AvMdz+s8/pFyyQuDk1fmJtrnaCNnR6ozcvmd4+ZLtVOcte5f6t7DCHlIvEy3ys4sCQlr6zAXAtg2kX7uHkuEls2lTMwRb4PekNAoO4oxLRbKo+L9t4FnmnXBSDQW0+TqBfduMZ8rzLqppoTyep8dyFySBXQLQAaCrNsWgEnuHk7dKLWwKzYTCDJbX/UClS2ehoyoJcMQwmRIMjY9FmJPNK03RTBA9jllUk/JrNfEXkHwKeT+SWuQgAeMCqbWJ8A/b9SIPDRJFdR5mt1+H9sL5Y+6+2lcqXtAvSUnUgTMt9oUZirAXE7Wt2qZewaXYmaRarFRH/bw/xzVkSfjrLD22iribAKivIGDzPLIirhN+9xAXBlsErAOT/V8aejuPw9k9oL9Ae/Ok0NZfPZMR8/7uutiGvDgw7vJVDelYMIjEOJHXFDnj+rH3vwPnMNI4Y6M6fNt0yrgMR+eMjgbxxGFYTZO9vlsQRiL/pxP6ceM9ReampgOWLmnYfIhTx91DMURfN"}'; + const backupKey = + '{"iv":"ZKCXaP1L5fVxDOjVKKZuCg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"4mmZz3KxTqs=","ct":"R2UVujh0H0FmPFkxTLnAGg+/P50DVnNP8d7VbsVJWJJWJvbV5tf+eYpvuz+5dpCC7D6xR7vN08ZXuZf6whUFerYOev+LSTcq2T1uar5xLvZBTd7alD889aJQJcd9+Om2JIjdPq3drFaqQF366d2H9tsVY+3iGsuJwCMHf6k8pxePxx5vk3iu4lcy4mJWp4d0zdo95nc4IZCrDp9i9i1p+w/mPhR0Rn+9c6T770vblRm87ft8vfyLZwMEqvJp3QW2XR+6vSyCkzbeZ/+m2nJmsK/Wt6sRqv27KDGVh23YEKp+yY3T9hT4FK0kzaF3tR8yq62Nj40eQ2iHIz50teiyW6HFm7IL4BT/vhL7qFa+VBz6qowON9p/96/21D2Nq40QnAxnOVfxW9DfQwnfBWyZJ8cLvHQ2s24LJX/YdHilPbElbjHncrpqf1jT/AELfBar/i5rrQZ5T0kxNC6t1VJpTUqiWuGUU42GTfzj12XHdqEdj+PcycLWjx8/DoqNPxqcPiEenBl8mst5SWNp1LW/FfEFgyB9p7L2UkxHhRYEzQ4WqIpQ6wERFqmpF6tRgXcYvwu5qc903C9CkRp2HXx2zmryW/vpODBXqwtRiwK1TGXQ0FPuEML+vwhh2LoYRGKOqcfQDTY4qX25kcly6D0zyY7YPTqALJnQYEGXOP42CBO+i5NkTjNCWsJRQMyNqRgEuAE8m1MWjcUIFQWebSJEyss6Ty14HHv+p6ACk6bDVMSLQLhVW3eccvRV5cBu4O6xFAehtvJ74Hc44iDZd5MFjBCZhj9dB3qfrkVFuIjT9WJkXYAn4f6b8Src+COrscklpYvcObGjeel5/Hx80q3jzboYmo9wgisKVpGhtz0XuqrxfZUiHUOGCoWMXFsdLmruh6u3CKKLnobBFgcmFAHJZaotYKOvpK0Lge7qN5vsGVZQhLu6ba/mUJdueDnUPmIJfMczi/yZ+600OcYjD2hetxzzrhkJ7qYRx0WCAyWUKHDl/1QqmavS+wKbnmbziAhgq6BL9cOG7hlPYIx0OERHzpmA3BCpeojI1Fgu27sADyZWLzO1YNfqeTX9fYvgEUE1XmTiSshvkwQxa/KNNHE9+A=="}'; + const bitgoKey = + '036ded8b5a849409935a4fa1a1cf921233f2c755162987804c861ab3aff95cf8fd8553beb55f568dc886b05c5b6831d946e7c442468fef9c953f62f9b1e06ac9d9'; + const walletPassphrase = 'Eaglefenaus@1994'; + const recoveryDestination = '0x02e153ef86ae7682160f69f4218b6a41aebc79ca11dabb1a4fcd7cc55f16f977'; + + const DEPLOYED_CLASS_HASH = '0x3940bc18abf1df6bc540cabadb1cad9486c6803b95801e57b6153ae21abfe06'; + // 100 STRK in fri — comfortably above the BGMS-style maxFee ceiling (fixed 40M L2 gas amount). + const BALANCE_100_STRK = '0x56bc75e2d63100000'; + + const mockRpcResponse = (result: any) => ({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, result }), + }); + + const mockRpcError = (code: number, message: string) => ({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, error: { code, message } }), + }); + + // Live-shaped Sepolia block header gas prices (price_in_fri). + const mockBlock = () => + mockRpcResponse({ + l1_gas_price: { price_in_fri: '0x62234cebb523' }, + l2_gas_price: { price_in_fri: '0x682341b1e' }, + l1_data_gas_price: { price_in_fri: '0x6447b483a9' }, + }); + + const decodeTx = (hex: string) => JSON.parse(Buffer.from(hex, 'hex').toString('utf-8')); + + before(async function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.safeRegister('starknet', Starknet.createInstance); + bitgo.safeRegister('tstarknet', Starknet.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('tstarknet'); + }); + + beforeEach(function () { + fetchStub = sinon.stub(global, 'fetch'); + }); + + afterEach(function () { + sinon.restore(); + }); + + /** + * RPC call order in recover(): + * 1. starknet_getClassHashAt (deployment check) + * 2. starknet_getNonce (deployed accounts only) + * 3. starknet_call (balance_of) + * 4. starknet_getBlockWithTxHashes (live gas prices) + * 5+ broadcast / receipt-poll calls (signed only) + */ + + it('should successfully build a signed sweep transaction for already deployed account', async function () { + let i = 0; + fetchStub.onCall(i++).resolves(mockRpcResponse(DEPLOYED_CLASS_HASH)); // classHash (deployed) + fetchStub.onCall(i++).resolves(mockRpcResponse('0x0')); // nonce + fetchStub.onCall(i++).resolves(mockRpcResponse([BALANCE_100_STRK, '0x0'])); // balance + fetchStub.onCall(i++).resolves(mockBlock()); // gas prices + + const signStub = sinon.stub(Starknet.prototype, 'signRecoveryTransaction' as any).resolves({ + r: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + s: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + recid: 0, + }); + + const recovery = await basecoin.recover({ userKey, backupKey, walletPassphrase, recoveryDestination, bitgoKey }); + + should.exist(recovery.txRequests); + recovery.txRequests.length.should.equal(1); + const txRequest = recovery.txRequests[0]; + txRequest.transactions.length.should.equal(1); + + const txItem = txRequest.transactions[0]; + txItem.unsignedTx.parsedTx.type.should.equal('send'); + should.exist(txItem.unsignedTx.broadcastFormat); + txItem.unsignedTx.broadcastFormat.signature.should.not.be.empty(); + + signStub.calledOnce.should.be.true(); + (signStub.firstCall.args[0] as Buffer).length.should.equal(32); + }); + + it('should successfully build and sign deploy + sweep for undeployed account', async function () { + let i = 0; + fetchStub.onCall(i++).resolves(mockRpcError(20, 'Contract not found')); // classHash (undeployed) + fetchStub.onCall(i++).resolves(mockRpcResponse([BALANCE_100_STRK, '0x0'])); // balance + fetchStub.onCall(i++).resolves(mockBlock()); // gas prices + + const signStub = sinon.stub(Starknet.prototype, 'signRecoveryTransaction' as any).resolves({ + r: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + s: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + recid: 0, + }); + + const recovery = await basecoin.recover({ userKey, backupKey, walletPassphrase, recoveryDestination, bitgoKey }); + + should.exist(recovery.txRequests); + recovery.txRequests.length.should.equal(1); + const txRequest = recovery.txRequests[0]; + txRequest.transactions.length.should.equal(2); + + txRequest.transactions[0].unsignedTx.parsedTx.type.should.equal('deploy_account'); + should.exist(txRequest.transactions[0].unsignedTx.broadcastFormat); + txRequest.transactions[0].unsignedTx.broadcastFormat.signature.should.not.be.empty(); + + txRequest.transactions[1].unsignedTx.parsedTx.type.should.equal('send'); + should.exist(txRequest.transactions[1].unsignedTx.broadcastFormat); + txRequest.transactions[1].unsignedTx.broadcastFormat.signature.should.not.be.empty(); + + signStub.calledTwice.should.be.true(); + (signStub.firstCall.args[0] as Buffer).length.should.equal(32); + (signStub.secondCall.args[0] as Buffer).length.should.equal(32); + }); + + it('should generate unsigned sweep with deploy + transfer for undeployed account (nonce 0 then 1)', async function () { + let i = 0; + fetchStub.onCall(i++).resolves(mockRpcError(20, 'Contract not found')); // classHash (undeployed) + fetchStub.onCall(i++).resolves(mockRpcResponse([BALANCE_100_STRK, '0x0'])); // balance + fetchStub.onCall(i++).resolves(mockBlock()); // gas prices + + const recovery = await basecoin.recover({ recoveryDestination, bitgoKey }); + + should.exist(recovery.txRequests); + recovery.txRequests.length.should.equal(1); + const txRequest = recovery.txRequests[0]; + txRequest.transactions.length.should.equal(2); + txRequest.transactions[0].unsignedTx.parsedTx.type.should.equal('deploy_account'); + txRequest.transactions[1].unsignedTx.parsedTx.type.should.equal('send'); + + // Nonce fix: deploy uses nonce 0x0, the following sweep uses nonce 1. + const deployData = decodeTx(txRequest.transactions[0].unsignedTx.serializedTx); + const sweepData = decodeTx(txRequest.transactions[1].unsignedTx.serializedTx); + deployData.nonce.should.equal('0x0'); + sweepData.nonce.should.equal('0x1'); + }); + + it('should generate unsigned sweep for already deployed account', async function () { + let i = 0; + fetchStub.onCall(i++).resolves(mockRpcResponse(DEPLOYED_CLASS_HASH)); // classHash (deployed) + fetchStub.onCall(i++).resolves(mockRpcResponse('0x5')); // nonce + fetchStub.onCall(i++).resolves(mockRpcResponse([BALANCE_100_STRK, '0x0'])); // balance + fetchStub.onCall(i++).resolves(mockBlock()); // gas prices + + const recovery = await basecoin.recover({ recoveryDestination, bitgoKey }); + + should.exist(recovery.txRequests); + const txRequest = recovery.txRequests[0]; + txRequest.transactions.length.should.equal(1); + txRequest.transactions[0].unsignedTx.parsedTx.type.should.equal('send'); + // Deployed account: sweep keeps the live nonce. + decodeTx(txRequest.transactions[0].unsignedTx.serializedTx).nonce.should.equal('0x5'); + }); + + it('should throw if STRK balance cannot cover estimated fee', async function () { + let i = 0; + fetchStub.onCall(i++).resolves(mockRpcResponse(DEPLOYED_CLASS_HASH)); // classHash (deployed) + fetchStub.onCall(i++).resolves(mockRpcResponse('0x0')); // nonce + fetchStub.onCall(i++).resolves(mockRpcResponse(['0x5af3107a4000', '0x0'])); // balance ~0.0001 STRK + fetchStub.onCall(i++).resolves(mockBlock()); // gas prices + + await basecoin.recover({ recoveryDestination, bitgoKey }).should.be.rejectedWith(/Insufficient STRK balance/); + }); + + it('should throw if recoveryDestination is missing', async function () { + await basecoin.recover({ bitgoKey }).should.be.rejectedWith(/Invalid recoveryDestination/); + }); + + it('should throw if bitgoKey is missing', async function () { + await basecoin.recover({ recoveryDestination }).should.be.rejectedWith(/Missing bitgoKey/); + }); +});