@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
text/typescript
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' };
}
}