diff --git a/packages/chain-adapters/src/starknet/StarknetChainAdapter.ts b/packages/chain-adapters/src/starknet/StarknetChainAdapter.ts index 0795632e08b..135026c6d04 100644 --- a/packages/chain-adapters/src/starknet/StarknetChainAdapter.ts +++ b/packages/chain-adapters/src/starknet/StarknetChainAdapter.ts @@ -5,6 +5,7 @@ import { supportsStarknet } from '@shapeshiftoss/hdwallet-core' import type { Bip44Params, RootBip44Params } from '@shapeshiftoss/types' import { KnownChainIds } from '@shapeshiftoss/types' import { TransferType, TxStatus } from '@shapeshiftoss/unchained-client' +import { bnOrZero } from '@shapeshiftoss/utils' import PQueue from 'p-queue' import type { Call } from 'starknet' import { CallData, hash, num, RpcProvider, validateAndParseAddress } from 'starknet' @@ -40,19 +41,18 @@ import type { TokenInfo, TxHashOrObject, } from './types' +import { + calculateFeeTiers, + OPENZEPPELIN_ACCOUNT_CLASS_HASH, + STATIC_FEE_ESTIMATES, + STRK_TOKEN_ADDRESS, +} from './utils' export interface ChainAdapterArgs { rpcUrl: string getKnownTokens?: () => TokenInfo[] } -// STRK token contract address on Starknet mainnet (native gas token) -const STRK_TOKEN_ADDRESS = '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d' - -// OpenZeppelin account contract class hash - same as used in hdwallet -const OPENZEPPELIN_ACCOUNT_CLASS_HASH = - '0x05b4b537eaa2399e3aa99c4e2e0208ebd6c71bc1467938cd52c798c601e43564' - export class ChainAdapter implements IChainAdapter { static readonly rootBip44Params: RootBip44Params = { purpose: 44, @@ -210,7 +210,10 @@ export class ChainAdapter implements IChainAdapter BigInt(0)) { // Find token info if it's not STRK (STRK is first, so idx > 0 means it's from knownTokens) const tokenInfo = idx > 0 ? knownTokens[idx - 1] : undefined - tokenBalances.set(tokenAddress, { balance: balance.toString(), info: tokenInfo }) + tokenBalances.set(tokenAddress, { + balance: balance.toString(), + info: tokenInfo, + }) } } }) @@ -268,9 +271,15 @@ export class ChainAdapter implements IChainAdapter { try { validateAndParseAddress(address) - return Promise.resolve({ valid: true, result: ValidAddressResultType.Valid }) + return Promise.resolve({ + valid: true, + result: ValidAddressResultType.Valid, + }) } catch (err) { - return Promise.resolve({ valid: false, result: ValidAddressResultType.Invalid }) + return Promise.resolve({ + valid: false, + result: ValidAddressResultType.Invalid, + }) } } @@ -318,6 +327,129 @@ export class ChainAdapter implements IChainAdapter> { + try { + const { accountNumber, wallet } = input + + this.assertSupportsChain(wallet) + + const address = await this.getAddress({ accountNumber, wallet }) + const isDeployed = await this.isAccountDeployed(address) + + const publicKey = await wallet.starknetGetPublicKey({ + addressNList: toAddressNList(this.getBip44Params({ accountNumber })), + }) + + if (!publicKey) { + throw new Error('error getting public key from wallet') + } + + const constructorCalldata = CallData.compile([publicKey]) + const salt = publicKey + const version = '0x3' as const + const nonce = '0x0' + + const formattedCalldata = constructorCalldata.map((data: string) => { + if (!data.startsWith('0x')) { + return num.toHex(data) + } + return data + }) + + const formattedSalt = salt.startsWith('0x') ? salt : `0x${salt}` + + const estimateTx = { + type: 'DEPLOY_ACCOUNT', + version, + signature: [], + nonce, + contract_address_salt: formattedSalt, + constructor_calldata: formattedCalldata, + class_hash: OPENZEPPELIN_ACCOUNT_CLASS_HASH, + resource_bounds: { + l1_gas: { max_amount: '0x186a0', max_price_per_unit: '0x5f5e100' }, + l2_gas: { max_amount: '0x0', max_price_per_unit: '0x0' }, + l1_data_gas: { max_amount: '0x186a0', max_price_per_unit: '0x1' }, + }, + tip: '0x0', + paymaster_data: [], + nonce_data_availability_mode: 'L1', + fee_data_availability_mode: 'L1', + } + + if (!isDeployed) { + try { + const estimateResponse = await this.provider.fetch('starknet_estimateFee', [ + [estimateTx], + ['SKIP_VALIDATE'], + 'latest', + ]) + const estimateResult: RpcJsonResponse = + await estimateResponse.json() + + if (!estimateResult.error && estimateResult.result?.[0]) { + const feeEstimate = estimateResult.result[0] + return calculateFeeTiers({ + l1GasConsumed: feeEstimate.l1_gas_consumed ?? '0x186a0', + l1GasPrice: feeEstimate.l1_gas_price ?? '0x5f5e100', + l2GasConsumed: feeEstimate.l2_gas_consumed ?? '0x0', + l2GasPrice: feeEstimate.l2_gas_price ?? '0x0', + l1DataGasConsumed: feeEstimate.l1_data_gas_consumed ?? '0x186a0', + l1DataGasPrice: feeEstimate.l1_data_gas_price ?? '0x1', + }) + } + } catch (error) { + console.log( + '[StarknetChainAdapter.getDeployAccountFeeData] RPC estimation failed for undeployed account, using static estimates:', + error, + ) + } + + console.log( + '[StarknetChainAdapter.getDeployAccountFeeData] Using static estimates for undeployed account', + ) + return calculateFeeTiers(STATIC_FEE_ESTIMATES) + } + + const estimateResponse = await this.provider.fetch('starknet_estimateFee', [ + [estimateTx], + ['SKIP_VALIDATE'], + 'latest', + ]) + const estimateResult: RpcJsonResponse = await estimateResponse.json() + + if (estimateResult.error) { + const errorMessage = estimateResult.error.message || JSON.stringify(estimateResult.error) + throw new Error(`Fee estimation failed: ${errorMessage}`) + } + + const feeEstimate = estimateResult.result?.[0] + if (!feeEstimate) { + throw new Error('Fee estimation failed: no estimate returned') + } + + return calculateFeeTiers({ + l1GasConsumed: feeEstimate.l1_gas_consumed ?? '0x186a0', + l1GasPrice: feeEstimate.l1_gas_price ?? '0x5f5e100', + l2GasConsumed: feeEstimate.l2_gas_consumed ?? '0x0', + l2GasPrice: feeEstimate.l2_gas_price ?? '0x0', + l1DataGasConsumed: feeEstimate.l1_data_gas_consumed ?? '0x186a0', + l1DataGasPrice: feeEstimate.l1_data_gas_price ?? '0x1', + }) + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.getFeeData', + }) + } + } + /** * Deploy a Starknet account contract * This must be done before an account can send transactions @@ -364,7 +496,22 @@ export class ChainAdapter implements IChainAdapter = await estimateResponse.json() + try { + const estimateResponse = await this.provider.fetch('starknet_estimateFee', [ + [estimateTx], + ['SKIP_VALIDATE'], + 'latest', + ]) + const estimateResult: RpcJsonResponse = await estimateResponse.json() + + if (!estimateResult.error && estimateResult.result?.[0]) { + const feeEstimate = estimateResult.result[0] + console.log('[StarknetChainAdapter.deployAccount] Using RPC estimate:', feeEstimate) + + resourceBounds = { + l1_gas: { + max_amount: + (BigInt(feeEstimate.l1_gas_consumed ?? '0x186a0') * BigInt(150)) / BigInt(100), + max_price_per_unit: + (BigInt(feeEstimate.l1_gas_price ?? '0x5f5e100') * BigInt(150)) / BigInt(100), + }, + l2_gas: { + max_amount: + (BigInt(feeEstimate.l2_gas_consumed ?? '0x0') * BigInt(150)) / BigInt(100), + max_price_per_unit: + (BigInt(feeEstimate.l2_gas_price ?? '0x0') * BigInt(150)) / BigInt(100), + }, + l1_data_gas: { + max_amount: + (BigInt(feeEstimate.l1_data_gas_consumed ?? '0x186a0') * BigInt(150)) / BigInt(100), + max_price_per_unit: + (BigInt(feeEstimate.l1_data_gas_price ?? '0x1') * BigInt(150)) / BigInt(100), + }, + } + } else { + throw new Error('RPC estimation failed or returned empty result') + } + } catch (error) { + console.log( + '[StarknetChainAdapter.deployAccount] RPC estimation failed, using static estimates:', + error, + ) - if (estimateResult.error) { - throw new Error(`Fee estimation failed: ${estimateResult.error.message}`) - } + const { + l1GasConsumed, + l1GasPrice, + l2GasConsumed, + l2GasPrice, + l1DataGasConsumed, + l1DataGasPrice, + } = STATIC_FEE_ESTIMATES - const feeEstimate = estimateResult.result?.[0] - if (!feeEstimate) { - throw new Error('Fee estimation failed: no estimate returned') + resourceBounds = { + l1_gas: { + max_amount: BigInt(bnOrZero(l1GasConsumed).times(1.5).toFixed(0)), + max_price_per_unit: BigInt(bnOrZero(l1GasPrice).times(1.5).toFixed(0)), + }, + l2_gas: { + max_amount: BigInt(bnOrZero(l2GasConsumed).times(1.5).toFixed(0)), + max_price_per_unit: BigInt(bnOrZero(l2GasPrice).times(1.5).toFixed(0)), + }, + l1_data_gas: { + max_amount: BigInt(bnOrZero(l1DataGasConsumed).times(1.5).toFixed(0)), + max_price_per_unit: BigInt(bnOrZero(l1DataGasPrice).times(1.5).toFixed(0)), + }, + } } - const l1GasConsumed = feeEstimate.l1_gas_consumed - ? BigInt(feeEstimate.l1_gas_consumed) - : BigInt('0x186a0') - const l1GasPrice = feeEstimate.l1_gas_price - ? BigInt(feeEstimate.l1_gas_price) - : BigInt('0x5f5e100') - const l2GasConsumed = feeEstimate.l2_gas_consumed - ? BigInt(feeEstimate.l2_gas_consumed) - : BigInt('0x0') - const l2GasPrice = feeEstimate.l2_gas_price ? BigInt(feeEstimate.l2_gas_price) : BigInt('0x0') - const l1DataGasConsumed = feeEstimate.l1_data_gas_consumed - ? BigInt(feeEstimate.l1_data_gas_consumed) - : BigInt('0x186a0') - const l1DataGasPrice = feeEstimate.l1_data_gas_price - ? BigInt(feeEstimate.l1_data_gas_price) - : BigInt('0x1') + const totalMaxFee = + resourceBounds.l1_gas.max_amount * resourceBounds.l1_gas.max_price_per_unit + + resourceBounds.l2_gas.max_amount * resourceBounds.l2_gas.max_price_per_unit + + resourceBounds.l1_data_gas.max_amount * resourceBounds.l1_data_gas.max_price_per_unit - const resourceBounds = { - l1_gas: { - max_amount: (l1GasConsumed * BigInt(500)) / BigInt(100), - max_price_per_unit: (l1GasPrice * BigInt(200)) / BigInt(100), - }, - l2_gas: { - max_amount: (l2GasConsumed * BigInt(500)) / BigInt(100), - max_price_per_unit: (l2GasPrice * BigInt(200)) / BigInt(100), - }, - l1_data_gas: { - max_amount: (l1DataGasConsumed * BigInt(500)) / BigInt(100), - max_price_per_unit: (l1DataGasPrice * BigInt(200)) / BigInt(100), + console.log('[StarknetChainAdapter.deployAccount] Balance check:', { + address, + balance: balance.toString(), + totalMaxFee: totalMaxFee.toString(), + hasEnoughBalance: balance >= totalMaxFee, + resourceBounds: { + l1_gas: { + max_amount: resourceBounds.l1_gas.max_amount.toString(), + max_price_per_unit: resourceBounds.l1_gas.max_price_per_unit.toString(), + }, + l1_data_gas: { + max_amount: resourceBounds.l1_data_gas.max_amount.toString(), + max_price_per_unit: resourceBounds.l1_data_gas.max_price_per_unit.toString(), + }, }, + }) + + if (balance < totalMaxFee) { + throw new Error( + `Insufficient STRK balance for deployment. Balance: ${balance.toString()}, Required: ${totalMaxFee.toString()}`, + ) } const hashInputs = { @@ -539,15 +732,6 @@ export class ChainAdapter implements IChainAdapter = await nonceResponse.json() - - if (nonceResult.error) { - throw new Error(`Failed to fetch nonce: ${nonceResult.error.message}`) - } - - if (!nonceResult.result) { - throw new Error('Nonce result is missing') + // Get account nonce - use '0x0' for undeployed accounts + let nonce = '0x0' + try { + const nonceResponse = await this.provider.fetch('starknet_getNonce', ['pending', from]) + const nonceResult: RpcJsonResponse = await nonceResponse.json() + if (!nonceResult.error && nonceResult.result) { + nonce = nonceResult.result + } + } catch (error) { + // If nonce fetch fails (e.g., account not deployed, method not supported), use '0x0' + nonce = '0x0' } - const nonce = nonceResult.result - const chainIdHex = await this.provider.getChainId() const version = '0x3' as const // Use v3 for Lava RPC @@ -845,7 +1031,12 @@ export class ChainAdapter implements IChainAdapter = await nonceResponse.json() - const nonce = nonceResult.result || '0x0' + const isDeployed = await this.isAccountDeployed(from) + + if (!isDeployed) { + return calculateFeeTiers(STATIC_FEE_ESTIMATES) + } + + const nonce = await this.getNonce(from) - // Build estimate transaction const estimateTx = { type: 'INVOKE', version: '0x3', @@ -1081,7 +1274,6 @@ export class ChainAdapter implements IChainAdapter = await estimateResponse.json() if (estimateResult.error) { - throw new Error(`Fee estimation failed: ${estimateResult.error.message}`) + const errorMessage = estimateResult.error.message || JSON.stringify(estimateResult.error) + throw new Error(`Fee estimation failed: ${errorMessage}`) } const feeEstimate = estimateResult.result?.[0] @@ -1098,68 +1291,14 @@ export class ChainAdapter implements IChainAdapter => { + const { + l1GasConsumed, + l1GasPrice, + l2GasConsumed, + l2GasPrice, + l1DataGasConsumed, + l1DataGasPrice, + } = params + + const baseFee = bnOrZero(l1GasConsumed) + .times(l1GasPrice) + .plus(bnOrZero(l2GasConsumed).times(l2GasPrice)) + .plus(bnOrZero(l1DataGasConsumed).times(l1DataGasPrice)) + + const slowMaxFee = bnOrZero(l1GasConsumed) + .times(1.5) + .times(bnOrZero(l1GasPrice).times(1.2)) + .plus(bnOrZero(l2GasConsumed).times(1.5).times(bnOrZero(l2GasPrice).times(1.2))) + .plus(bnOrZero(l1DataGasConsumed).times(1.5).times(bnOrZero(l1DataGasPrice).times(1.2))) + .toFixed(0) + + const averageMaxFee = bnOrZero(l1GasConsumed) + .times(3) + .times(bnOrZero(l1GasPrice).times(1.5)) + .plus(bnOrZero(l2GasConsumed).times(3).times(bnOrZero(l2GasPrice).times(1.5))) + .plus(bnOrZero(l1DataGasConsumed).times(3).times(bnOrZero(l1DataGasPrice).times(1.5))) + .toFixed(0) + + const fastMaxFee = bnOrZero(l1GasConsumed) + .times(5) + .times(bnOrZero(l1GasPrice).times(2)) + .plus(bnOrZero(l2GasConsumed).times(5).times(bnOrZero(l2GasPrice).times(2))) + .plus(bnOrZero(l1DataGasConsumed).times(5).times(bnOrZero(l1DataGasPrice).times(2))) + .toFixed(0) + + return { + slow: { + txFee: baseFee.times(1.8).toFixed(0), + chainSpecific: { maxFee: slowMaxFee }, + }, + average: { + txFee: baseFee.times(4.5).toFixed(0), + chainSpecific: { maxFee: averageMaxFee }, + }, + fast: { + txFee: baseFee.times(10).toFixed(0), + chainSpecific: { maxFee: fastMaxFee }, + }, + } +} diff --git a/src/components/Modals/Send/views/SendAmountDetails.tsx b/src/components/Modals/Send/views/SendAmountDetails.tsx index 1fae39915c1..4df234897a4 100644 --- a/src/components/Modals/Send/views/SendAmountDetails.tsx +++ b/src/components/Modals/Send/views/SendAmountDetails.tsx @@ -125,16 +125,9 @@ export const SendAmountDetails = () => { const adapter = chainAdapterManager.get(chainId) if (!isStarknetChainAdapter(adapter)) throw new Error('Invalid chain adapter') - const { account } = fromAccountId(accountId) - - const feeData = await adapter.getFeeData({ - to: account, // Deploying to the account itself - value: '0', // No token transfer during deployment - chainSpecific: { - from: account, - tokenContractAddress: undefined, - }, - sendMax: false, + const feeData = await adapter.getDeployAccountFeeData({ + accountNumber: accountMetadata?.bip44Params.accountNumber ?? 0, + wallet, }) const maxFee = feeData.fast.chainSpecific.maxFee diff --git a/src/components/MultiHopTrade/components/TradeConfirm/TradeFooterButton.tsx b/src/components/MultiHopTrade/components/TradeConfirm/TradeFooterButton.tsx index d8a1ea96d87..aeeed78ef01 100644 --- a/src/components/MultiHopTrade/components/TradeConfirm/TradeFooterButton.tsx +++ b/src/components/MultiHopTrade/components/TradeConfirm/TradeFooterButton.tsx @@ -104,16 +104,9 @@ export const TradeFooterButton: FC = ({ const adapter = chainAdapterManager.get(chainId) if (!isStarknetChainAdapter(adapter)) throw new Error('Invalid chain adapter') - const { account } = fromAccountId(sellAccountId) - - const feeData = await adapter.getFeeData({ - to: account, // Deploying to the account itself - value: '0', // No token transfer during deployment - chainSpecific: { - from: account, - tokenContractAddress: undefined, - }, - sendMax: false, + const feeData = await adapter.getDeployAccountFeeData({ + accountNumber: accountMetadata?.bip44Params.accountNumber ?? 0, + wallet, }) const maxFee = feeData.fast.chainSpecific.maxFee