UNPKG

@shogun-sdk/money-legos

Version:

Shogun Money Legos: clients and types for quotes, memes, prices, balances, fees, validations, etc.

454 lines (391 loc) 15.9 kB
import { type Commitment, Connection, PublicKey } from '@solana/web3.js'; import { type Chain, createPublicClient, fallback, type FeeValuesEIP1559, http, parseEther, parseGwei } from 'viem'; import { isEVMChain, getRpcUrls, SOLANA_CHAIN_ID, SupportedChainId, isNativeAddress } from '../config/chains.js'; import { arbitrum, avalanche, base, berachain, bsc, mainnet, polygon, sonic } from 'viem/chains'; import type { EVMPublicClient } from '../types/client.js'; import type { Calldatas, QuoteTypes } from '../types/index.js'; import { compareAddresses, isWrappedAddress } from './address.js'; import { NATIVE_TOKEN, SOL_NATIVE } from '../config/addresses.js'; import { returnErrorDuringEstimationGas } from './errors.js'; import { convertToIntegerMultiplier } from './eth.js'; import { ethers } from 'ethers'; import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { erc20TokenABI } from './abi.js'; import { HyperEVM } from './viemChains.custom.js'; import { max } from './math.js'; // you can set those differently export const MIN_NATIVE_TOKEN_BALANCE: { [chainId: number]: bigint } = { [mainnet.id]: 1000000000000000n, // 0.001 ETH [base.id]: 200000000000000n, // 0.0002 ETH [arbitrum.id]: 200000000000000n, // 0.0002 ETH [bsc.id]: 200000000000000n, // 0.0002 ETH [SOLANA_CHAIN_ID]: 5000000n, // 0.005 SOL [berachain.id]: 50000000000000000n, // 0.05 ETH [sonic.id]: 500000000000000000n, // 0.5 SONIC [avalanche.id]: 10000000000000000n, // 0.01 AVAX [polygon.id]: 3000000000000000000n, // 3 POL [HyperEVM.id]: 50000000000000000n, // 0.05 HYPE } as const; // you can set those differently export const MIN_BRIDGE_FEE_NATIVE_TOKEN: { [chainId: number]: bigint } = { [mainnet.id]: BigInt(1000000000000000), // 0.001 ETH [base.id]: BigInt(1_000_000_000_000_000), // 0.001 ETH [arbitrum.id]: BigInt(100_000_000_000_000), // 0.0001 ETH [bsc.id]: BigInt(100_000_000_000_000), // 0.0001 ETH [SOLANA_CHAIN_ID]: BigInt(25_000_000), // 0.025 SOL [berachain.id]: BigInt(1_000_000_000_000_000), // 0.001 BERA [sonic.id]: BigInt(500_000_000_000_000_000), // .5 SONIC [avalanche.id]: BigInt(10_000_000_000_000_000), // 0.01 AVAX [polygon.id]: BigInt(11_000_000_000_000_000_000), // 11 POL [HyperEVM.id]: BigInt(1_000_000_000_000_000), // 0.001 HYPE } as const; export const getEthChainViem = (network: number): Chain => { const chainMap: Record<number, Chain> = { [mainnet.id]: mainnet, [arbitrum.id]: arbitrum, [base.id]: base, [bsc.id]: bsc, [berachain.id]: berachain, [avalanche.id]: avalanche, [sonic.id]: sonic, [polygon.id]: polygon, [HyperEVM.id]: HyperEVM, }; if (network === SOLANA_CHAIN_ID) { throw new Error(`Network "${network}" is not supported in EVM chain configuration.`); } return chainMap[network] ?? base; }; export const getEvmJsonRpcProvider = (network: number): ReturnType<typeof createPublicClient> => { const rpcUrls = getRpcUrls()[network]; const chainViem = getEthChainViem(network); const transports = rpcUrls?.rpc.map((url: string) => http(url)); return createPublicClient({ chain: chainViem, transport: fallback(transports ?? [], { rank: false, retryCount: 5, retryDelay: 1000, }), }); }; /** * Calculates gasLimit based on estimated transaction gas cost. * Increasing gasLimit to cover unexpected expenses * @param estimatedGas Estimated transaction gas cost */ export const getAdjustedGasLimit = (network: number, estimatedGas: bigint | number): bigint => { if (network === arbitrum.id) { return BigInt(Math.ceil(Number(estimatedGas) * GAS_LIMIT_MULTIPLIER_ARBITRUM)); } return BigInt(Math.ceil(Number(estimatedGas) * GAS_LIMIT_MULTIPLIER)); }; /** * @deprecated This method uses legacy gas estimation logic and may not reflect * current network conditions accurately. Prefer using `getAdjustedFeesPerGas` * with up-to-date block and pending fee data instead. */ export const getAdjustedFeesPerGasOld = async ( provider: EVMPublicClient, userPriorityFeeGwei: bigint ): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> => { const fees: FeeValuesEIP1559 = await provider.estimateFeesPerGas(); // Minimum tip allowed: 0.1 gwei (100_000_000 wei) const MIN_PRIORITY_FEE = BigInt(100000000); // Use the greater of user-provided tip or minimum allowed const priorityFee = userPriorityFeeGwei >= MIN_PRIORITY_FEE ? userPriorityFeeGwei : MIN_PRIORITY_FEE; // Calculate base fee as: maxFeePerGas (from node) - priorityFee (from node) const baseFee = BigInt(fees.maxFeePerGas) - BigInt(fees.maxPriorityFeePerGas); // Final maxFeePerGas = baseFee + adjusted priority fee const maxFeePerGas = baseFee + priorityFee; return { maxFeePerGas, maxPriorityFeePerGas: priorityFee, }; } /** * Calculates adjusted `maxFeePerGas` and `maxPriorityFeePerGas` for EIP-1559 transactions * based on a user-defined priority fee in GWEI. * * Ensures the priority fee meets a minimum threshold (0.1 gwei), * and computes `maxFeePerGas` as (baseFee + priorityFee). * * @param provider - An EVM-compatible viem public client (supports `estimateFeesPerGas`) * @param userPriorityFeeGwei - Desired priority fee in wei (e.g., 150_000_000n for 0.15 gwei) * @returns An object containing `maxFeePerGas` and `maxPriorityFeePerGas` in wei */ export const getAdjustedFeesPerGas = async ( provider: EVMPublicClient, userPriorityFeeGwei: bigint ): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> => { const MIN_PRIORITY_FEE = 100_000_000n; // 0.1 Gwei in wei // Get estimated EIP-1559 fees const fees: FeeValuesEIP1559 = await provider.estimateFeesPerGas(); const rawMaxPriorityFee = BigInt(fees.maxPriorityFeePerGas); // Calculate adjusted priority fee with user-defined boost const adjustedMaxPriorityFeePerGas = max( rawMaxPriorityFee + userPriorityFeeGwei, MIN_PRIORITY_FEE ); // Get current base fee from the pending block (fallback to latest if unsupported) let block; try { block = await provider.getBlock({ blockTag:"pending"}); } catch (err) { console.warn( "Pending block not supported by this provider, falling back to latest." ); block = await provider.getBlock(); } const baseFeePerGas = BigInt(block.baseFeePerGas || 0); // Calculate total max fee per gas const adjustedMaxFeePerGas = baseFeePerGas + adjustedMaxPriorityFeePerGas; return { maxFeePerGas: adjustedMaxFeePerGas, maxPriorityFeePerGas: adjustedMaxPriorityFeePerGas, }; }; /* * Calculates adjusted maxFeePerGas based on estimated maxFeePerGas and gas multiplier * @param maxFeePerGas Estimated maxFeePerGas * @param gasMultiplier Gas price priority multiplier */ export const getAdjustedMaxFeePerGas = (maxFeePerGas: bigint | number, gasMultiplier: number): bigint => { const integerMultiplier = convertToIntegerMultiplier(gasMultiplier, Number(GAS_PRICE_SCALE)); return (BigInt(maxFeePerGas) * integerMultiplier) / GAS_PRICE_SCALE; }; const GAS_LIMIT_MULTIPLIER = 1.2; // +20% to gas estimation export const GAS_LIMIT_MULTIPLIER_ARBITRUM = 3; // +300% to gas estimation for Arbitrum const GAS_PRICE_SCALE = BigInt(10000000000); export const fetchProviderEstimation = async (provider: EVMPublicClient, quote: QuoteTypes, from?: string) => { const transaction = quote.calldatas as Calldatas; try { if (quote?.inputAmount?.chainId === sonic.id) { return await provider.estimateGas({ to: transaction.to, data: transaction.data, value: BigInt(transaction.value), account: from, }); } return await provider.estimateGas({ to: transaction.to, data: transaction.data, value: BigInt(transaction.value), account: from, blockTag: 'latest', stateOverride: [ { address: from, balance: parseEther('1000000000'), }, ], }); } catch (error: any) { const blockNumber = await provider.getBlockNumber(); console.log( `>> fetchProviderEstimation error for block ${blockNumber} with token ${quote.inputAmount?.address} ${quote.inputAmount?.chainId} for token ${quote.outputAmount?.address} ${quote.outputAmount?.chainId} with value ${quote.inputAmount?.value}:`, error.message, error.stack, ); returnErrorDuringEstimationGas(quote, error); return BigInt(0); } }; export const getQuoteNativeBridgeFee = ( quote: QuoteTypes, ): { bridgeFee: bigint; isNativeIncludedInAmountIn: boolean; isBridgeFeeInValue: boolean; } => { const isNative = quote?.inputAmount && isNativeAddress(quote.inputAmount?.address); if (isNative && (quote.calldatas as Calldatas)?.value && BigInt((quote.calldatas as Calldatas).value) > BigInt(0)) { const difference = BigInt((quote.calldatas as Calldatas).value) - BigInt(quote.inputAmount?.value); if (difference > BigInt(0)) { return { bridgeFee: difference, isNativeIncludedInAmountIn: true, isBridgeFeeInValue: false, }; } } // TODO: check if this is correct let bridgeFee = BigInt(0); const checkFee = () => { const isCrossChain = quote?.inputAmount?.chainId !== quote?.outputAmount?.chainId; const fee = isCrossChain ? MIN_BRIDGE_FEE_NATIVE_TOKEN[quote?.inputAmount?.chainId] || BigInt(0) : BigInt(0); return fee; }; if (quote?.bridgeFee) { if (isWrappedAddress?.(quote?.bridgeFee?.token)) { bridgeFee += BigInt(quote?.bridgeFee?.amount); } else if (compareAddresses(quote?.bridgeFee?.token, SOL_NATIVE)) { bridgeFee += BigInt(quote?.bridgeFee?.amount); } else if (compareAddresses(quote?.bridgeFee?.token, NATIVE_TOKEN.ETH)) { bridgeFee += BigInt(quote?.bridgeFee?.amount); } else { bridgeFee = checkFee(); } } else { bridgeFee = checkFee(); } return { bridgeFee, isNativeIncludedInAmountIn: false, isBridgeFeeInValue: !!(quote.calldatas as Calldatas)?.value && bridgeFee === BigInt((quote.calldatas as Calldatas).value), }; }; export const estimateEvmTransaction = async ( network: number, quote: QuoteTypes, from: string, userDefinedFeeGwei: number, ): Promise<{ // maxFeePerGas to use in transaction maxFeePerGas: bigint; // maxPriorityFeePerGas to use in transaction maxPriorityFeePerGas: bigint; // gasLimit to use in transaction gasLimit: bigint; // transaction fees in native tokens wei estimatedFees: bigint; // minimum required balance to execute transaction minimumNativeBalance: bigint; // provider used to estimate gas provider: EVMPublicClient; // transaction value value: bigint; // bridge fee bridgeFee: bigint; }> => { const provider = getEvmJsonRpcProvider(network); const transaction = quote.calldatas as Calldatas; const value = BigInt(transaction.value); const userDefinedFeeGweiUnit = parseGwei(String(userDefinedFeeGwei)) // Get gas price and estimated transaction gas cost in parallel const [{ maxFeePerGas, maxPriorityFeePerGas }, gasEstimate] = await Promise.all([ getAdjustedFeesPerGas(provider as any, userDefinedFeeGweiUnit), // Estimate gas usage for the transaction fetchProviderEstimation(provider as any, quote, from), ]); const gasLimit = getAdjustedGasLimit(network, gasEstimate); const { bridgeFee, isNativeIncludedInAmountIn, isBridgeFeeInValue } = getQuoteNativeBridgeFee(quote); // Calculate total gas cost in Wei const totalGasCostInWei = BigInt(gasLimit) * maxFeePerGas + (isBridgeFeeInValue ? BigInt(0) : bridgeFee); const minimumNativeBalance = totalGasCostInWei + BigInt(isNativeIncludedInAmountIn ? quote.inputAmount?.value : value); // not adding bridgeFee, because it's already included in the quote.inputAmount.value from dextra // Return the result return { maxFeePerGas, maxPriorityFeePerGas, gasLimit, value: transaction.value ? BigInt(transaction.value) : BigInt(0), estimatedFees: totalGasCostInWei, minimumNativeBalance, provider: provider as unknown as EVMPublicClient, bridgeFee, }; }; /** * Creates a EVM connection using ethers with fallback RPC URLs. * Attempts to connect to each RPC URL in order until a working connection is established. * @returns ethers.JsonRpcProvider - a working EVM connection * @throws Error if no RPC URLs are defined or if all connections fail */ export const getEVMEthersProviderWithFallback = async (chainId: SupportedChainId) => { const rpcUrls = getRpcUrls()[chainId]?.rpc; if (!rpcUrls?.length) { throw new Error(`No RPC URLs defined for chain ${chainId}`); } for (const url of rpcUrls) { const provider = new ethers.JsonRpcProvider(url); try { // Test the provider by requesting the latest block number const blockNumber = await provider.getBlockNumber(); if (typeof blockNumber === 'number') { return provider; } } catch (error) { console.warn(`Failed to connect to RPC URL: ${url}`, error); } } throw new Error('All EVM RPC connections failed'); }; /** * Creates a Solana connection with fallback RPC URLs. * Attempts to connect to each RPC URL in order until a working connection is established. * @returns Promise<Connection> A working Solana connection * @throws Error if no RPC URLs are defined or if all connections fail */ export const getSolanaProviderWithFallback = async (): Promise<Connection> => { const rpcUrls = getRpcUrls()[SOLANA_CHAIN_ID]?.rpc; if (!rpcUrls?.length) { throw new Error('No RPC URLs defined for Solana network'); } const commitment: Commitment = 'confirmed'; for (const url of rpcUrls) { try { const connection = new Connection(url, { commitment }); await connection.getEpochInfo(); // Verify connection works return connection; } catch (error) { console.warn(`Failed to connect to RPC URL: ${url}`, error); } } throw new Error('All Solana RPC connections failed'); }; export const getSolanaProvider = async (): Promise<Connection> => { return getSolanaProviderWithFallback(); }; export const ZERO_BALANCE = BigInt('0'); export const getTokenBalanceRpc = async ( walletAddress: string, tokenAddress: string, network: number, ): Promise<bigint> => { try { if (!walletAddress || !tokenAddress) { return ZERO_BALANCE; } if (network === SOLANA_CHAIN_ID) { const ownerPubKey = new PublicKey(walletAddress); const mintPubKey = new PublicKey(tokenAddress); const provider = await getSolanaProvider(); const tokenAccounts = await Promise.all([ provider.getParsedTokenAccountsByOwner(ownerPubKey, { programId: TOKEN_PROGRAM_ID }), provider.getParsedTokenAccountsByOwner(ownerPubKey, { programId: TOKEN_2022_PROGRAM_ID }), ]); const tokenAccount = tokenAccounts .flatMap((accounts) => accounts.value) .find((account) => account.account.data.parsed.info.mint === mintPubKey.toBase58()); if (!tokenAccount) { return ZERO_BALANCE; } else { return BigInt(tokenAccount.account.data.parsed.info.tokenAmount.amount); } } else if (isEVMChain(network)) { const provider = getEvmJsonRpcProvider(network); const balance: bigint = (await provider.readContract({ address: tokenAddress as `0x${string}`, abi: erc20TokenABI, functionName: 'balanceOf', args: [walletAddress], // ...(txReceipt // ? { // blockNumber: (txReceipt as EthTransactionResponse).blockNumber, // } // : {}), })) as bigint; return balance; } return ZERO_BALANCE; } catch (error: unknown) { console.error('Error getting token balance:', error); return ZERO_BALANCE; } };