UNPKG

@shogun-sdk/money-legos

Version:

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

386 lines (332 loc) 12.4 kB
import axios from 'axios'; import { MIN_NATIVE_TOKEN_BALANCE } from '../utils/rpc.js'; import { getNativeBalanceRpc } from '../utils/getNativeBalance.js'; import { CHAIN_MAP } from '../config/chains.js'; import { TokenBalanceResults } from '../types/index.js'; import { compareAddresses } from '../utils/address.js'; import { fromWei } from '../utils/eth.js'; import { AffiliateFeeService, MAX_AFFILIATE_FEE_PERCENT } from './AffiliateFeeService.js'; import { ShogunBalancesApiClient } from '../clients/shogun-balances-api-client.js'; type SwapParams = { weiAmount: bigint; srcToken: string; destToken: string; srcNetwork: number; destNetwork: number; }; export class GasStationCalculator { private errorBuilder: ( network: string, tokenSymbol?: string, minAmountValue?: number, minUsdValue?: number, ) => string; private balancesClient: ShogunBalancesApiClient; constructor( errorBuilder: (network: string, tokenSymbol?: string, minAmountValue?: number, minUsdValue?: number) => string, balancesClient: ShogunBalancesApiClient, ) { this.errorBuilder = errorBuilder; this.balancesClient = balancesClient; } protected async getNativeDestinationNetworkPrice(network: number, tokens: TokenBalanceResults[]): Promise<number> { const nativeToken = tokens.find((token) => token.network === network && token.nativeToken); if (nativeToken) { return nativeToken.usdPrice; } const tokenAddress = CHAIN_MAP[network]?.wrapped; if (!tokenAddress) { return 0; } const coingeckoData = await this.balancesClient.getTokenUSDPrice(tokenAddress, network); return coingeckoData?.priceUsd || 0; } protected getMinNativeBalance(network: number): bigint | undefined { return MIN_NATIVE_TOKEN_BALANCE[network]; } protected calculateRefuelFeePercent(refuelAmountUsd: number, amountInUsd: number): number { if (amountInUsd <= 0) { return 0; } return Number((refuelAmountUsd / amountInUsd) * 110); } protected async calculateRefuelAmount( srcNetwork: number, destNetwork: number, tokenInput: TokenBalanceResults, tokens: TokenBalanceResults[], destinationNativeBalance: bigint, swapParams: SwapParams, systemFeePercent: number, isEoaAccount: boolean, externalServiceTxCostUsd: number, ): Promise<{ additionalAffiliateFeePercent: number; affiliateFeeTotalValidation: { isValid: boolean; error?: string; totalFeePercent?: number }; refuelAmount: bigint; gasRefuelWarning?: string; }> { const minDestinationBalance = this.getMinNativeBalance(destNetwork); const DEFAULT_STATE = { additionalAffiliateFeePercent: 0, refuelAmount: BigInt(0), affiliateFeeTotalValidation: { isValid: true, error: '', totalFeePercent: MAX_AFFILIATE_FEE_PERCENT, }, }; if (!minDestinationBalance) { return DEFAULT_STATE; } const refuelDestinationAmount = minDestinationBalance - destinationNativeBalance; // Calculate required amount of ETH to reach MIN_NATIVE_TOKEN_BALANCE const refuelAmount = refuelDestinationAmount > 0 ? refuelDestinationAmount : BigInt(0); if (refuelAmount <= 0) { return DEFAULT_STATE; } const weiAmount = swapParams.weiAmount; // amount of src token in USD const amountInUsd = weiAmount ? Number(fromWei(weiAmount.toString(), tokenInput.decimals)) * tokenInput.usdPrice : 0; if (!amountInUsd) { return DEFAULT_STATE; } const nativeDestinationNetworkPrice = await this.getNativeDestinationNetworkPrice(destNetwork, tokens); if (!CHAIN_MAP[destNetwork]?.decimals) { return DEFAULT_STATE; } const refuelDestinationAmountUsd = Number(fromWei(refuelAmount.toString(), CHAIN_MAP[destNetwork]?.decimals)) * nativeDestinationNetworkPrice; const additionalAffiliateFeePercent = this.calculateRefuelFeePercent(refuelDestinationAmountUsd, amountInUsd); const excludeFee = false /*excludeAffiliateFee({ srcToken: tokenInput.tokenAddress, destToken: swapParams.destToken, srcChainId: srcNetwork, destChainId: destNetwork, });*/ // We are using AffiliateFeeService just to valdiate possible affiliate fee for gas refuel // If it is not valid, we will not refuel const affiliateFeeTotalValidation = await AffiliateFeeService.validateAffiliateFeeWithServiceFees({ excludeAffiliateFee: excludeFee, isTransfer: false, tokenInAddress: tokenInput.tokenAddress, tokeinInDecimals: tokenInput.decimals, sourceNetwork: srcNetwork, amountBigInt: weiAmount.toString(), errorBuilder: this.errorBuilder, systemFeePercent, balances: tokens, isEoaAccount, additionalAffiliateFee: additionalAffiliateFeePercent, externalServiceTxCostUsd, }); // no refuel if affiliate fee is not valid or max affiliate fee if ( !affiliateFeeTotalValidation?.isValid || affiliateFeeTotalValidation.totalFeePercent === MAX_AFFILIATE_FEE_PERCENT // handle case when affiliate fee is max, we don't want to block the transaction but we will skip the refuel ) { console.log( '[GasStationService] Affiliate fee is not valid or max affiliate fee', affiliateFeeTotalValidation?.isValid, affiliateFeeTotalValidation?.totalFeePercent, ); return { ...DEFAULT_STATE, affiliateFeeTotalValidation: affiliateFeeTotalValidation, gasRefuelWarning: destinationNativeBalance === 0n && this.isCrossChainRefuel(srcNetwork, destNetwork) ? 'Select a native token on destination network to have a gas for future swaps' : undefined, }; } return { additionalAffiliateFeePercent, affiliateFeeTotalValidation, refuelAmount }; } protected isCrossChainRefuel(srcNetwork: number, destNetwork: number): boolean { return srcNetwork !== destNetwork; } protected findTokenIn( tokens: TokenBalanceResults[], srcTokenAddress: string, srcNetwork: number, walletAddress: string, ): TokenBalanceResults | undefined { return tokens?.find( (token) => compareAddresses(token.tokenAddress, srcTokenAddress) && token.network === srcNetwork && compareAddresses(token.walletAddress, walletAddress), ); } public async ignoreGasRefuel( tokenOut: TokenBalanceResults | undefined, walletAddress: string, network: number, ): Promise<boolean> { if (tokenOut && this.isTokenOutNative(tokenOut.tokenAddress, network)) { return true; } const balance = await getNativeBalanceRpc(walletAddress, network); const minBalance = this.getMinNativeBalance(network); return !minBalance || balance >= minBalance; } protected isTokenOutNative(address: string, destinationNetwork: number): boolean { const chain = CHAIN_MAP[destinationNetwork]; if (!chain?.tokenAddress) { return false; } return compareAddresses(address, chain.tokenAddress); } public async calculateGasRefuelAmount( walletAddress: string, destinationWalletAddress: string | undefined, balances: TokenBalanceResults[], swapParams: SwapParams, systemFeePercent: number, isEoaAccount: boolean, externalServiceTxCostUsd: number, findTokenFallback?: ( tokens: TokenBalanceResults[], srcTokenAddress: string, srcNetwork: number, walletAddress: string, ) => TokenBalanceResults, ): Promise<{ success: boolean; refuelAmount?: bigint; tokenIn?: TokenBalanceResults; destinationWalletAddress?: string; additionalAffiliateFeePercent: number; gasRefuelWarning?: string; affiliateFeeTotalValidation: { isValid: boolean; error?: string; totalFeePercent?: number }; }> { const destinationNetwork = swapParams.destNetwork; const DEFAULT_STATE = { success: false, additionalAffiliateFeePercent: 0, refuelAmount: BigInt(0), tokenIn: undefined, destinationWalletAddress: undefined, gasRefuelWarning: undefined, affiliateFeeTotalValidation: { isValid: true, error: '', totalFeePercent: MAX_AFFILIATE_FEE_PERCENT, }, }; if (!destinationWalletAddress) { return DEFAULT_STATE; } const tokenIn = findTokenFallback ? findTokenFallback(balances ?? [], swapParams.srcToken, swapParams.srcNetwork, walletAddress) : this.findTokenIn(balances ?? [], swapParams.srcToken, swapParams.srcNetwork, walletAddress); if (!tokenIn) { return DEFAULT_STATE; } const tokenOut = findTokenFallback ? findTokenFallback(balances ?? [], swapParams.destToken, swapParams.destNetwork, destinationWalletAddress) : this.findTokenIn(balances ?? [], swapParams.destToken, swapParams.destNetwork, destinationWalletAddress); // Check if destination wallet already has enough balance if (await this.ignoreGasRefuel(tokenOut, destinationWalletAddress, destinationNetwork)) { return DEFAULT_STATE; } const balance = await getNativeBalanceRpc(destinationWalletAddress, destinationNetwork); const { additionalAffiliateFeePercent, refuelAmount, gasRefuelWarning, affiliateFeeTotalValidation } = await this.calculateRefuelAmount( swapParams.srcNetwork, swapParams.destNetwork, tokenIn, balances ?? [], balance, swapParams, systemFeePercent, isEoaAccount, externalServiceTxCostUsd, ); return { success: refuelAmount > BigInt(0), refuelAmount, tokenIn, destinationWalletAddress, additionalAffiliateFeePercent: additionalAffiliateFeePercent, gasRefuelWarning, affiliateFeeTotalValidation, }; } } export class GasStationService extends GasStationCalculator { private apiKey: string; private apiUrl: string; private errorHandler: (error: Error | string) => void; constructor( apiKey: string, apiUrl: string, errorHandler: (error: Error | string) => void, errorBuilder: (network: string, tokenSymbol?: string, minAmountValue?: number, minUsdValue?: number) => string, balancesClient: ShogunBalancesApiClient, ) { super(errorBuilder, balancesClient); this.apiKey = apiKey; this.apiUrl = apiUrl; this.errorHandler = errorHandler; } protected async makeGasStationApiRequest( url: string, payload: Record<string, string | number>, headers: Record<string, string>, ): Promise<boolean> { try { await axios.post(url, payload, { headers }); return true; } catch (error) { const errorMessage = `Error sending to gas station: ${error instanceof Error ? error.message : 'Unknown error'}`; this.errorHandler(errorMessage); console.error(errorMessage); return false; } } /** * Sends a request to the Gas Station API to refuel a wallet with native tokens * * Example curl command to test locally: * ``` curl -X POST http://localhost:3000 \ -H "Content-Type: application/json" \ -H "x-api-key: your_api_key" \ -d '{ "chainId": 7565164, "amount": "10000", "recipient": "HxQpBRpH7sRHpidTWZ55xAsMxddhjHBE8BRkkj2ewHa1" }' * ``` */ public async sendGasStationRequest( amount: bigint, network: number, walletAddress: string, ): Promise<{ success: boolean; error?: string }> { if (!amount || amount === BigInt(0)) { return { success: true }; } // Double-check balance before sending request if (await this.ignoreGasRefuel(undefined, walletAddress, network)) { return { success: false, error: 'Wallet already has sufficient balance' }; } if (!CHAIN_MAP[network]?.id) { return { success: false, error: `Unsupported network ID: ${network}` }; } const url = `${this.apiUrl}/api/refuel`; const payload = { chainId: CHAIN_MAP[network].id, amount: amount.toString(), recipient: walletAddress, }; const headers: Record<string, string> = { 'Content-Type': 'application/json', 'x-api-key': this.apiKey, }; const success = await this.makeGasStationApiRequest(url, payload, headers); return { success, error: success ? undefined : 'Failed to send gas station request' }; } }