@shogun-sdk/money-legos
Version:
Shogun Money Legos: clients and types for quotes, memes, prices, balances, fees, validations, etc.
351 lines • 15.2 kB
JavaScript
import { Connection, PublicKey } from '@solana/web3.js';
import { createPublicClient, fallback, http, parseEther, parseGwei } from 'viem';
import { isEVMChain, getRpcUrls, SOLANA_CHAIN_ID, isNativeAddress } from '../config/chains.js';
import { arbitrum, avalanche, base, berachain, bsc, mainnet, polygon, sonic } from 'viem/chains';
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 = {
[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
};
// you can set those differently
export const MIN_BRIDGE_FEE_NATIVE_TOKEN = {
[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
};
export const getEthChainViem = (network) => {
const chainMap = {
[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) => {
const rpcUrls = getRpcUrls()[network];
const chainViem = getEthChainViem(network);
const transports = rpcUrls?.rpc.map((url) => 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, estimatedGas) => {
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, userPriorityFeeGwei) => {
const fees = 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, userPriorityFeeGwei) => {
const MIN_PRIORITY_FEE = 100000000n; // 0.1 Gwei in wei
// Get estimated EIP-1559 fees
const fees = 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, gasMultiplier) => {
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, quote, from) => {
const transaction = quote.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) {
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) => {
const isNative = quote?.inputAmount && isNativeAddress(quote.inputAmount?.address);
if (isNative && quote.calldatas?.value && BigInt(quote.calldatas.value) > BigInt(0)) {
const difference = BigInt(quote.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?.value && bridgeFee === BigInt(quote.calldatas.value),
};
};
export const estimateEvmTransaction = async (network, quote, from, userDefinedFeeGwei) => {
const provider = getEvmJsonRpcProvider(network);
const transaction = quote.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, userDefinedFeeGweiUnit),
// Estimate gas usage for the transaction
fetchProviderEstimation(provider, 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,
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) => {
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 () => {
const rpcUrls = getRpcUrls()[SOLANA_CHAIN_ID]?.rpc;
if (!rpcUrls?.length) {
throw new Error('No RPC URLs defined for Solana network');
}
const 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 () => {
return getSolanaProviderWithFallback();
};
export const ZERO_BALANCE = BigInt('0');
export const getTokenBalanceRpc = async (walletAddress, tokenAddress, network) => {
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 = (await provider.readContract({
address: tokenAddress,
abi: erc20TokenABI,
functionName: 'balanceOf',
args: [walletAddress],
// ...(txReceipt
// ? {
// blockNumber: (txReceipt as EthTransactionResponse).blockNumber,
// }
// : {}),
}));
return balance;
}
return ZERO_BALANCE;
}
catch (error) {
console.error('Error getting token balance:', error);
return ZERO_BALANCE;
}
};
//# sourceMappingURL=rpc.js.map