UNPKG

@shogun-sdk/money-legos

Version:

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

258 lines (223 loc) 9.24 kB
import { isEVMChain, SOLANA_CHAIN_ID } from '../config/chains.js'; import { STABLECOINS } from '../config/stablecoins.js'; import type { TokenBalanceResults } from '../types/index.js'; import { compareAddresses, compareChains } from '../utils/address.js'; import { NATIVE_TOKEN, SOL_NATIVE } from '../config/addresses.js'; import { InsufficientFundsErrorBuilder } from './InsufficientFundsErrorBuilder.js'; export const MAX_AFFILIATE_FEE_PERCENT = 5; // 5% fee export const TURNKEY_TX_COST_USD = 0.11; // 0.11 USD (because slippage is 1-10%, 10 cents + 1 cent) export class AffiliateFeeService { /** * Calculates minimum allowed USD value for a token to keep total fees under 5% * @param network The blockchain network * @param excludeAffiliateFees Whether to exclude affiliate fees from calculation * @returns Minimum USD value required to keep fees under 5% */ public static calculateMinAllowedUsdValue( systemFeePercent: number, chainId: number, excludeAffiliateFees: boolean, isEoaAccount: boolean = false, externalServiceTxCostUsd: number = TURNKEY_TX_COST_USD, isSameChainTransfer = false, // just transfer without swap additionalAffiliateFee: number, ): number { const turnkeyFeeUsd = isEoaAccount ? 0 : isSameChainTransfer ? externalServiceTxCostUsd : AffiliateFeeService.getChainFee(chainId, externalServiceTxCostUsd); // Fixed chain fee in USD const affiliateFeePercent = (excludeAffiliateFees ? 0 : systemFeePercent) + additionalAffiliateFee; // Formula derivation: // totalFeePercent = affiliateFeePercent + (chainFee/(amount - affiliateFeePercent * amount/100))*100 <= MAX_TOTAL_FEE_PERCENT // Formula derivation: // Let x be the minimum amount in USD // Let f be affiliate fee percent (e.g. 1%) // Let c be chain fee in USD (fixed) // Let m be max total fee percent (e.g. 5%) // Total fee percent = affiliate fee percent + (chain fee / amount after affiliate fee) * 100 // affiliate fee percent + (c / (x * (1 - f/100))) * 100 <= m // Solving for x: // (c * 100) / (x * (1 - f/100)) <= m - f // (c * 100) / (m - f) <= x * (1 - f/100) // x >= (c * 100) / ((m - f) * (1 - f/100)) // Example calculation: // For $2.77 input with 1% affiliate fee and $0.11 chain fee: // 1 + (0.11/(2.77-1*2.77/100)) * 100 = 5% // This shows that $2.77 is the minimum amount needed to keep total fees under 5% const maxTotalFeePercent = MAX_AFFILIATE_FEE_PERCENT; // If affiliate fee equals or exceeds max total fee, no amount is sufficient if (affiliateFeePercent >= maxTotalFeePercent) { return 5; // $5 is minimum amount to keep total fees under 5% } const denominator = (maxTotalFeePercent - affiliateFeePercent) * (1 - affiliateFeePercent / 100); const minAmount = (turnkeyFeeUsd * 100) / denominator; return Math.max(minAmount, 0); } private static getChainFee = (network: number, externalServiceTxCostUsd: number = TURNKEY_TX_COST_USD): number => { if (network === SOLANA_CHAIN_ID) { return externalServiceTxCostUsd; } else if (isEVMChain(network)) { return externalServiceTxCostUsd * 2; // approve + swap tx cost } return 0; }; /** * Calculates the minimum required amount in token's smallest unit based on its USD price */ /** * Calculates total fee percentage based on input amount and token price */ public static calculateTotalFeePercentage( chainId: number, systemFeePercent: number, excludeAffiliateFees: boolean, amountInTokens: bigint, decimals: number, tokenUsdPrice?: number, isEoaAccount: boolean = false, additionalAffiliateFee: number = 0, ): number { const serviceFees = excludeAffiliateFees ? 0 : systemFeePercent; if (!tokenUsdPrice || tokenUsdPrice <= 0) { return serviceFees; } if (isEoaAccount) { return Number(serviceFees) + additionalAffiliateFee; } const amountInUsd = (Number(amountInTokens) * tokenUsdPrice) / 10 ** decimals; // if amountInUsd less 5$, make affiliate fee 5% if (amountInUsd < 5) { return MAX_AFFILIATE_FEE_PERCENT; } // Then calculate turnkey fee percentage based on remaining amount const chainFee = this.getChainFee(chainId); const turnkeyFeePercent = isEoaAccount ? 0 : (chainFee / amountInUsd) * 100; const totalFeePercent = (Number(serviceFees) + turnkeyFeePercent + additionalAffiliateFee).toFixed(10); return +totalFeePercent; } public static isOverMaxAffiliateFee(totalFeePercent: number): boolean { return totalFeePercent > MAX_AFFILIATE_FEE_PERCENT; } /** * Validates transaction size and returns total fee percentage */ public static validateAffiliateFeeWithServiceFees = ({ systemFeePercent, additionalAffiliateFee, balances, excludeAffiliateFee, isTransfer, tokenInAddress, tokeinInDecimals, sourceNetwork, amountBigInt, errorBuilder, isEoaAccount, externalServiceTxCostUsd, }: { systemFeePercent: number; additionalAffiliateFee: number; balances: TokenBalanceResults[]; excludeAffiliateFee: boolean; isTransfer: boolean; tokenInAddress: string; tokeinInDecimals: number; sourceNetwork: number; amountBigInt: string; errorBuilder: (network: string, tokenSymbol?: string, minAmountValue?: number, minUsdValue?: number) => string; isEoaAccount: boolean; externalServiceTxCostUsd: number; }): { isValid: boolean; error?: string; totalFeePercent?: number } => { const { tokenUsdPrice } = AffiliateFeeService.getAffiliateFeeTokenForQuote(balances, tokenInAddress, sourceNetwork) || {}; const totalFeePercent = AffiliateFeeService.calculateTotalFeePercentage( sourceNetwork, systemFeePercent, excludeAffiliateFee, BigInt(amountBigInt), tokeinInDecimals, tokenUsdPrice, isEoaAccount, additionalAffiliateFee, ); if ( totalFeePercent >= 100 || !Number.isFinite(totalFeePercent) || AffiliateFeeService.isOverMaxAffiliateFee(totalFeePercent) ) { return { totalFeePercent: Math.min(totalFeePercent, MAX_AFFILIATE_FEE_PERCENT), isValid: true, // true, because we don't want to block the transaction and just set affiliate fee as MAX_AFFILIATE_FEE_PERCENT error: InsufficientFundsErrorBuilder.buildAffiliateFeeErrorMsg( amountBigInt, balances, excludeAffiliateFee, tokenInAddress, sourceNetwork, isTransfer, systemFeePercent, isEoaAccount, externalServiceTxCostUsd, additionalAffiliateFee, errorBuilder, ), }; } return { isValid: true, totalFeePercent: Math.min(totalFeePercent, MAX_AFFILIATE_FEE_PERCENT), }; }; /* Affiliate fee can be taken from amount in or amount out We should get the token from user balances (in case of having in balances) In case of `buy` action - fee is taken directly from token in or from token out after swap to token out - in case of sell action: same as buy action In both cases it will be taken directly from token in (from balances) or from token out after swap to token out We are not searching token from queriedToken or from codex, because we can't be sure 100% that swap price is correct. (for example crosschain swap USDC base => ETH base => SOL solana => TRUMP solana, but we always have USDC base in balances), so we can take +- fee USDC base amount It is more simple than calculating fee from token out after swap to token output */ public static getAffiliateFeeTokenForQuote = ( balances: TokenBalanceResults[], tokenAddress: string, sourceNetwork: number, ): { tokenUsdPrice: number; decimals: number; symbol: string; tokenAddress: string } | undefined => { // Check if token is a stablecoin - if so return $1 price for (const chainStablecoins of Object.values(STABLECOINS)) { for (const stablecoin of Object.values(chainStablecoins)) { if (compareAddresses(stablecoin.address, tokenAddress)) { return { tokenUsdPrice: 1, decimals: stablecoin.decimals, symbol: stablecoin.symbol, tokenAddress: stablecoin.address, }; } } } if (!balances) { return undefined; } let fromBalances = balances.find( (token) => compareAddresses(token.tokenAddress, tokenAddress) && compareChains(token.network, sourceNetwork), ); if (!fromBalances && tokenAddress === NATIVE_TOKEN.SOL) { fromBalances = balances.find((token) => compareAddresses(token.tokenAddress, SOL_NATIVE)); } if (!fromBalances && tokenAddress === SOL_NATIVE) { fromBalances = balances.find((token) => compareAddresses(token.tokenAddress, SOL_NATIVE)); } if (fromBalances) { return { tokenUsdPrice: fromBalances.usdPrice, decimals: fromBalances.decimals, symbol: fromBalances.symbol, tokenAddress: fromBalances.tokenAddress, }; } return undefined; }; }