diff --git a/src/auction.ts b/src/auction.ts index a144298..60b3859 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -1,40 +1,263 @@ +import axios from 'axios'; +import { CHAIN_ID_SOLANA, WhChainIdToEvm } from './config/chains'; +import { RpcConfig } from './config/rpc'; +import { Token } from './config/tokens'; +import { driverConfig } from './driver.conf'; +import { get1InchQuote } from './driver/routers'; import { Swap } from './swap.dto'; import { SwiftCosts } from './utils/fees'; +import logger from './utils/logger'; export class AuctionFulfillerConfig { - /** - * Specfiy if the driver will participate in the auction for this `swap` or not - * @param swap User's order details - * @param effectiveAmountIn Total available after considering gas costs which is equivalent to `swap.fromAmount - totalCosts` - * @param costDetails Gas cost details to consider more factors - **/ - async shouldParticipate(swap: Swap, effectiveAmountIn: number, costDetails: SwiftCosts): Promise { - return true; + private readonly bidAggressionPercent = driverConfig.bidAggressionPercent; + private readonly fulfillAggressionPercent = driverConfig.fulfillAggressionPercent; + private readonly forceBid = true; + + constructor(private readonly rpcConfig: RpcConfig) {} + + async normalizedBidAmount( + driverToken: Token, + effectiveAmountIn: number, + swap: Swap, + costs: SwiftCosts, + ): Promise { + if (swap.fromAmount.toNumber() * costs.fromTokenPrice > driverConfig.volumeLimitUsd) { + throw new Error(`Volume limit exceeded for ${swap.sourceTxHash} and dropping bid`); + } + + const normalizedMinAmountOut = BigInt(swap.minAmountOut64); + + let output64: bigint; + if (swap.destChain === CHAIN_ID_SOLANA) { + output64 = await this.getSolanaEquivalentOutput(driverToken, effectiveAmountIn, swap.toToken); + } else { + output64 = await this.getEvmEquivalentOutput( + swap.destChain, + driverToken, + effectiveAmountIn, + normalizedMinAmountOut, + swap.toToken, + ); + } + let output = Number(output64) / 10 ** swap.toToken.decimals; + + const bpsFees = await this.calcProtocolAndRefBps( + swap.fromAmount64, + swap.fromToken, + swap.toToken, + swap.destChain, + swap.referrerBps, + ); + const realMinAmountOut = + swap.toToken.decimals > 8 + ? normalizedMinAmountOut * BigInt(10 ** (swap.toToken.decimals - 8)) + : normalizedMinAmountOut; + const minAmountNeededForFulfill64 = realMinAmountOut + (realMinAmountOut * bpsFees) / 10000n; + const minAmountNeededForFulfill = Number(minAmountNeededForFulfill64) / 10 ** swap.toToken.decimals; + + const mappedMinAmountIn = minAmountNeededForFulfill * (effectiveAmountIn / output); + const mappedBpsAmountIn = (swap.fromAmount.toNumber() * Number(bpsFees)) / 10000; // upper estimate + + if (mappedMinAmountIn > effectiveAmountIn - mappedBpsAmountIn) { + logger.warn( + `AuctionFulfillerConfig.normalizedBidAmount: mappedMinAmountIn > effectiveAmountIn ${mappedMinAmountIn} > ${effectiveAmountIn}`, + ); + throw new Error(`mappedMinAmountIn > effectiveAmountIn for ${swap.sourceTxHash}`); + } + + const bidAggressionPercent = this.bidAggressionPercent; // 0 - 100 + + const profitMargin = effectiveAmountIn - mappedMinAmountIn - mappedBpsAmountIn; + + const finalAmountIn = mappedMinAmountIn + (profitMargin * bidAggressionPercent) / 100; + + const mappedAmountOut = (finalAmountIn * Number(output)) / effectiveAmountIn; + let normalizedAmountOut; + if (swap.toToken.decimals > 8) { + normalizedAmountOut = BigInt(Math.floor(mappedAmountOut * 10 ** 8)); + } else { + normalizedAmountOut = BigInt(Math.floor(mappedAmountOut * 10 ** swap.toToken.decimals)); + } + + if (normalizedAmountOut < normalizedMinAmountOut && this.forceBid) { + logger.warn(`normalizedBidAmount is less than minAmountOut`); + normalizedAmountOut = normalizedMinAmountOut; + } + + return normalizedAmountOut; + } + + async getEvmEquivalentOutput( + destChain: number, + driverToken: Token, + effectiveAmountInDriverToken: number, + normalizedMinAmountOut: bigint, + toToken: Token, + ): Promise { + let output: bigint; + if (driverToken.contract === toToken.contract) { + output = BigInt(Math.floor(effectiveAmountInDriverToken * 10 ** driverToken.decimals)); + } else { + const quoteRes = await get1InchQuote( + { + realChainId: WhChainIdToEvm[destChain], + srcToken: driverToken.contract, + destToken: toToken.contract, + amountIn: BigInt(Math.floor(effectiveAmountInDriverToken * 10 ** driverToken.decimals)).toString(), + timeout: 2000, + }, + this.rpcConfig.oneInchApiKey, + true, + 3, + ); + + if (!quoteRes) { + throw new Error('1inch quote for bid in swift failed'); + } + + output = BigInt(Math.floor(Number(quoteRes.toAmount))); + } + + return output; } - /** - * Calculate the bid amount for the auction. The bid should be in the same token as the fromToken used in the swap on the destination chain. - * For example, if the swap is from `Ethereum (ETH)` to the `Arbitrum (any token)` network, the bid should be in ETH. - * The bid amount will then be converted to the destination chain token via a swap using the Jupiter or 1inch aggregator. - * We are bidding effectiveAmountIn by default because we assume no risks or additional benefits and only deduct the costs. - * @param swap user's order details - * @param effectiveAmountIn effective amount in which swap.fromAmount - totalCosts - * @param costDetails cost details to consider more factors - * @returns the amount to bid - **/ - async bidAmount(swap: Swap, effectiveAmountIn: number, costDetails: SwiftCosts): Promise { - return effectiveAmountIn * 0.97; - // if (effectiveAmountIn * 0.98 >= minOut) { - // return effectiveAmountIn * 0.98; - // } else if (effectiveAmountIn * 0.99 >= minOut) { - // return effectiveAmountIn * 0.99; - // } else if (effectiveAmountIn * 0.999 >= minOut) { - // return effectiveAmountIn * 0.999; - // } - // return effectiveAmountIn; + async getSolanaEquivalentOutput( + driverToken: Token, + effectiveAmountInDriverToken: number, + toToken: Token, + ): Promise { + let output: bigint; + if (driverToken.contract === toToken.contract) { + output = BigInt(Math.floor(effectiveAmountInDriverToken * 10 ** driverToken.decimals)); + } else { + const quoteRes = await this.getJupQuoteWithRetry( + BigInt(Math.floor(effectiveAmountInDriverToken * 10 ** driverToken.decimals)), + driverToken.mint, + toToken.mint, + 0.1, // 10% + ); + + if (!quoteRes || !quoteRes.raw) { + throw new Error('jupiter quote for bid in swift failed'); + } + + output = BigInt(Math.floor(Number(quoteRes.expectedAmountOut))); + } + + return output; + } + + async fulfillAmount(driverToken: Token, effectiveAmountIn: number, swap: Swap, costs: SwiftCosts): Promise { + if (swap.fromAmount.toNumber() * costs.fromTokenPrice > driverConfig.volumeLimitUsd) { + throw new Error(`Volume limit exceeded for ${swap.sourceTxHash} and dropping fulfill`); + } + + const normalizedMinAmountOut = BigInt(swap.minAmountOut64); + + let output64: bigint; + if (swap.destChain === CHAIN_ID_SOLANA) { + output64 = await this.getSolanaEquivalentOutput(driverToken, effectiveAmountIn, swap.toToken); + } else { + output64 = await this.getEvmEquivalentOutput( + swap.destChain, + driverToken, + effectiveAmountIn, + normalizedMinAmountOut, + swap.toToken, + ); + } + let output = Number(output64) / 10 ** swap.toToken.decimals; + + const bpsFees = await this.calcProtocolAndRefBps( + swap.fromAmount64, + swap.fromToken, + swap.toToken, + swap.destChain, + swap.referrerBps, + ); + const realMinAmountOut = + swap.toToken.decimals > 8 + ? normalizedMinAmountOut * BigInt(10 ** (swap.toToken.decimals - 8)) + : normalizedMinAmountOut; + const minAmountNeededForFulfill64 = realMinAmountOut + (realMinAmountOut * bpsFees) / 10000n; + const minAmountNeededForFulfill = Number(minAmountNeededForFulfill64) / 10 ** swap.toToken.decimals; + + const mappedMinAmountIn = minAmountNeededForFulfill * (effectiveAmountIn / output); + const mappedBpsAmountIn = (swap.fromAmount.toNumber() * Number(bpsFees)) / 10000; // upper estimate + + if (mappedMinAmountIn > effectiveAmountIn - mappedBpsAmountIn) { + logger.warn( + `AuctionFulfillerConfig.normalizedBidAmount: mappedMinAmountIn > effectiveAmountIn ${mappedMinAmountIn} > ${effectiveAmountIn}`, + ); + throw new Error(`mappedMinAmountIn > effectiveAmountIn for ${swap.sourceTxHash}`); + } + + const aggressionPercent = this.fulfillAggressionPercent; // 0 - 100 + + const profitMargin = effectiveAmountIn - mappedMinAmountIn - mappedBpsAmountIn; + + const finalAmountIn = mappedMinAmountIn + (profitMargin * aggressionPercent) / 100; + + return finalAmountIn; + } + + private async getJupQuoteWithRetry( + amountIn: bigint, + fromMint: string, + toMint: string, + slippage: number, + retry: number = 10, + ): Promise { + let res; + do { + try { + let params: any = { + inputMint: fromMint, + outputMint: toMint, + slippageBps: slippage * 10000, + maxAccounts: 64 - 7, // 7 accounts reserved for other instructions + amount: amountIn, + }; + if (!!this.rpcConfig.jupApiKey) { + params['token'] = this.rpcConfig.jupApiKey; + } + const { data } = await axios.get(`${this.rpcConfig.jupV6Endpoint}/quote`, { + params: params, + }); + res = data; + } catch (err) { + logger.warn(`error in fetch jupiter ${err} try ${retry}`); + } finally { + retry--; + } + } while ((!res || !res.outAmount) && retry > 0); + + if (!res) { + logger.error(`juptier quote failed ${fromMint} ${toMint} ${amountIn}`); + return null; + } + + return { + effectiveAmountIn: res.inAmount, + expectedAmountOut: res.outAmount, + priceImpact: res.priceImpactPct, + minAmountOut: res.otherAmountThreshold, + route: [], + raw: res, + }; } - async fulfillAmount(swap: Swap, effectiveAmountIn: number, costDetails: SwiftCosts): Promise { - return effectiveAmountIn; + private async calcProtocolAndRefBps( + amountIn: bigint, + tokenIn: Token, + tokenOut: Token, + destChain: number, + referrerBps: number, + ): Promise { + if (referrerBps > 3) { + return BigInt(referrerBps * 2); + } else { + return BigInt(3 + referrerBps); + } } } diff --git a/src/config/global.ts b/src/config/global.ts index 3f5f275..9940a7f 100644 --- a/src/config/global.ts +++ b/src/config/global.ts @@ -25,6 +25,7 @@ export interface SwiftFeeParams { } export type GlobalConfig = { + ignoreReferrers: Set; blackListedReferrerAddresses: Set; auctionTimeSeconds: number; batchUnlockThreshold: number; // Optimal Number of swaps to select for unlocking diff --git a/src/config/init.ts b/src/config/init.ts index 57bb9d0..14becf8 100644 --- a/src/config/init.ts +++ b/src/config/init.ts @@ -20,6 +20,7 @@ export async function fetchDynamicSdkParams(): Promise<{ singleBatchChainIds: string; scheduleUnlockInterval: number; feeParams: SwiftFeeParams; + ignoreReferrers: string[]; }> { const result = await axios.get(initEndpoint); const serverChains = Object.keys(result.data.swiftContracts).map((k) => +k); diff --git a/src/driver.conf.ts b/src/driver.conf.ts new file mode 100644 index 0000000..cd4a871 --- /dev/null +++ b/src/driver.conf.ts @@ -0,0 +1,36 @@ +import { + CHAIN_ID_ARBITRUM, + CHAIN_ID_AVAX, + CHAIN_ID_BASE, + CHAIN_ID_BSC, + CHAIN_ID_ETH, + CHAIN_ID_OPTIMISM, + CHAIN_ID_POLYGON, + CHAIN_ID_SOLANA, +} from './config/chains'; + +export const driverConfig = { + bidAggressionPercent: 1, // 1% above minamout out + fulfillAggressionPercent: 99, // take 1% of approximated available profit + volumeLimitUsd: 20_000, // 20k USD + acceptedInputChains: new Set([ + CHAIN_ID_BSC, + CHAIN_ID_AVAX, + CHAIN_ID_ETH, + CHAIN_ID_ARBITRUM, + CHAIN_ID_POLYGON, + CHAIN_ID_OPTIMISM, + CHAIN_ID_BASE, + CHAIN_ID_SOLANA, + ]), + acceptedOutputChains: new Set([ + CHAIN_ID_BSC, + CHAIN_ID_AVAX, + CHAIN_ID_ETH, + CHAIN_ID_ARBITRUM, + CHAIN_ID_POLYGON, + CHAIN_ID_OPTIMISM, + CHAIN_ID_BASE, + CHAIN_ID_SOLANA, + ]), +}; diff --git a/src/driver/driver.ts b/src/driver/driver.ts index 930c45e..1a9220a 100644 --- a/src/driver/driver.ts +++ b/src/driver/driver.ts @@ -200,27 +200,18 @@ export class DriverService { throw new Error('Shall not bid because effectiveAmountIn is less than 0'); } - const bidAmount = await this.auctionFulfillerCfg.bidAmount(swap, effectiveAmountIn, expenses); - - let normalizedBidAmount: bigint; + let driverToken: Token; if (dstChain === CHAIN_ID_SOLANA) { - const driverToken = this.getDriverSolanaTokenForBidAndSwap(srcChain, fromToken); - normalizedBidAmount = await this.solanaFulfiller.getNormalizedBid( - driverToken, - bidAmount, - normalizedMinAmountOut, - toToken, - ); + driverToken = this.getDriverSolanaTokenForBidAndSwap(srcChain, fromToken); } else { - const driverToken = this.getDriverEvmTokenForBidAndSwap(srcChain, dstChain, fromToken); - normalizedBidAmount = await this.evmFulFiller.getNormalizedBid( - dstChain, - driverToken, - bidAmount, - normalizedMinAmountOut, - toToken, - ); + driverToken = this.getDriverEvmTokenForBidAndSwap(srcChain, dstChain, fromToken); } + let normalizedBidAmount = await this.auctionFulfillerCfg.normalizedBidAmount( + driverToken, + effectiveAmountIn, + swap, + expenses, + ); if (normalizedBidAmount < normalizedMinAmountOut) { logger.error( @@ -255,8 +246,6 @@ export class DriverService { 70_000, ); logger.info(`Sent bid transaction for ${swap.sourceTxHash} with ${hash}`); - - swap.bidAmount = bidAmount; } async postBid( @@ -498,10 +487,21 @@ export class DriverService { throw new Error('Shall not bid because effectiveAmountIn is less than 0'); } - const fulfillAmount = await this.auctionFulfillerCfg.fulfillAmount(swap, effectiveAmntIn, expenses); + let driverToken: Token; + if (swap.destChain === CHAIN_ID_SOLANA) { + driverToken = this.getDriverSolanaTokenForBidAndSwap(srcChain, fromToken); + } else { + driverToken = this.getDriverEvmTokenForBidAndSwap(srcChain, dstChain, fromToken); + } + + const fulfillAmount = await this.auctionFulfillerCfg.fulfillAmount( + driverToken, + effectiveAmntIn, + swap, + expenses, + ); if (swap.destChain === CHAIN_ID_SOLANA) { - let driverToken = this.getDriverSolanaTokenForBidAndSwap(srcChain, fromToken); const normalizeMinAmountOut = BigInt(swap.minAmountOut64); const realMinAmountOut = normalizeMinAmountOut * BigInt(Math.ceil(10 ** Math.max(0, toToken.decimals - WORMHOLE_DECIMALS))); @@ -537,7 +537,6 @@ export class DriverService { ); logger.info(`Sent fulfill transaction for ${swap.sourceTxHash} with ${hash}`); } else { - let driverToken = this.getDriverEvmTokenForBidAndSwap(srcChain, dstChain, fromToken); const normalizeMinAmountOut = BigInt(swap.minAmountOut64); const realMinAmountOut = normalizeMinAmountOut * BigInt(Math.ceil(10 ** Math.max(0, toToken.decimals - WORMHOLE_DECIMALS))); diff --git a/src/index.ts b/src/index.ts index 1a87f28..bbaa820 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export async function main() { rpcConfig.wormholeGuardianRpcs = initialDynamicConfig.wormholeGuardianRpcs.split(','); const globalConfig: GlobalConfig = { + ignoreReferrers: new Set(initialDynamicConfig.ignoreReferrers), auctionTimeSeconds: initialDynamicConfig.auctionTimeSeconds, batchUnlockThreshold: initialDynamicConfig.batchUnlockThreshold, registerInterval: initialDynamicConfig.registerInterval, @@ -143,7 +144,7 @@ export async function main() { await evmFulFiller.init(); const driverSvc = new DriverService( new SimpleFulfillerConfig(), - new AuctionFulfillerConfig(), + new AuctionFulfillerConfig(rpcConfig), solanaConnection, walletConf, rpcConfig, diff --git a/src/relayer.ts b/src/relayer.ts index f6f9fe7..3840e8f 100644 --- a/src/relayer.ts +++ b/src/relayer.ts @@ -9,6 +9,7 @@ import { GlobalConfig } from './config/global'; import { RpcConfig } from './config/rpc'; import { TokenList } from './config/tokens'; import { WalletConfig } from './config/wallet'; +import { driverConfig } from './driver.conf'; import { DriverService } from './driver/driver'; import { WalletsHelper } from './driver/wallet-helper'; import { SWAP_STATUS, Swap } from './swap.dto'; @@ -111,12 +112,23 @@ export class Relayer { async relay(swap: Swap) { try { if (!supportedChainIds.includes(swap.sourceChain) || !supportedChainIds.includes(swap.destChain)) { - logger.warn(`Swap chain id is not supported yet on sdk`); + logger.warn(`Swap chain id is not supported yet on sdk for ${swap.sourceTxHash}`); return; } - if (this.gConf.blackListedReferrerAddresses.has(swap.referrerAddress)) { - logger.warn(`Referrer address is blacklisted for ${swap.sourceTxHash}. discarding...`); + if ( + !driverConfig.acceptedInputChains.has(swap.sourceChain) || + !driverConfig.acceptedOutputChains.has(swap.destChain) + ) { + logger.warn(`Swap chain id is disabled in driver conf for ${swap.sourceTxHash}`); + return; + } + + if ( + this.gConf.ignoreReferrers.has(swap.referrerAddress) || + this.gConf.blackListedReferrerAddresses.has(swap.referrerAddress) + ) { + logger.warn(`Referrer address is blacklisted/ignored for ${swap.sourceTxHash}. discarding...`); return; } diff --git a/src/utils/fees.ts b/src/utils/fees.ts index 21213bc..5b76471 100644 --- a/src/utils/fees.ts +++ b/src/utils/fees.ts @@ -234,6 +234,7 @@ export class FeeService { fulfillCost: fulfillCost, //fulfillCost, unlockSource: unlockFee, //unlockFee, fulfillAndUnlock: fulfillCost + unlockFee, + fromTokenPrice: fromTokenPrice, }; } @@ -364,6 +365,7 @@ export type SwiftCosts = { fulfillCost: number; unlockSource: number; fulfillAndUnlock: number; + fromTokenPrice: number; }; export type ExpenseParams = {