diff --git a/src/clients/ProfitClient.ts b/src/clients/ProfitClient.ts index 8dec13e22..f25200015 100644 --- a/src/clients/ProfitClient.ts +++ b/src/clients/ProfitClient.ts @@ -20,8 +20,16 @@ import { Deposit, DepositWithBlock, L1Token, SpokePoolClientsByChain } from "../ import { HubPoolClient } from "."; const { formatEther } = ethersUtils; + const { EMPTY_MESSAGE, DEFAULT_SIMULATED_RELAYER_ADDRESS: TEST_RELAYER } = sdkConsts; -const { bnOne, bnUint32Max, fixedPointAdjustment: fixedPoint, isMessageEmpty, resolveDepositMessage } = sdkUtils; +const { + bnOne, + bnUint32Max, + bnUint256Max: uint256Max, + fixedPointAdjustment: fixedPoint, + isMessageEmpty, + resolveDepositMessage, +} = sdkUtils; // We use wrapped ERC-20 versions instead of the native tokens such as ETH, MATIC for ease of computing prices. export const MATIC = TOKEN_SYMBOLS_MAP.MATIC.addresses[CHAIN_IDs.MAINNET]; @@ -29,7 +37,7 @@ export const USDC = TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET]; export const WBTC = TOKEN_SYMBOLS_MAP.WBTC.addresses[CHAIN_IDs.MAINNET]; export const WETH = TOKEN_SYMBOLS_MAP.WETH.addresses[CHAIN_IDs.MAINNET]; -// note: All FillProfit BigNumbers are scaled to 18 decimals unless specified otherwise. +// @note All FillProfit BigNumbers are scaled to 18 decimals unless specified otherwise. export type FillProfit = { grossRelayerFeePct: BigNumber; // Max of relayerFeePct and newRelayerFeePct from Deposit. tokenPriceUsd: BigNumber; // Resolved USD price of the bridged token. @@ -46,6 +54,12 @@ export type FillProfit = { profitable: boolean; // Fill profitability indicator. }; +type UnprofitableFill = { + deposit: DepositWithBlock; + fillAmount: BigNumber; + nativeGasCost: BigNumber; +}; + export const GAS_TOKEN_BY_CHAIN_ID: { [chainId: number]: string } = { 1: WETH, 10: WETH, @@ -99,7 +113,7 @@ export class ProfitClient { private readonly priceClient; protected minRelayerFees: { [route: string]: BigNumber } = {}; protected tokenPrices: { [l1Token: string]: BigNumber } = {}; - private unprofitableFills: { [chainId: number]: { deposit: DepositWithBlock; fillAmount: BigNumber }[] } = {}; + private unprofitableFills: { [chainId: number]: UnprofitableFill[] } = {}; // Track total gas costs of a relay on each chain. protected totalGasCosts: { [chainId: number]: BigNumber } = {}; @@ -171,11 +185,7 @@ export class ProfitClient { } // Estimate the gas cost of filling this relay. - estimateFillCost(deposit: Deposit): { - nativeGasCost: BigNumber; - gasPriceUsd: BigNumber; - gasCostUsd: BigNumber; - } { + estimateFillCost(deposit: Deposit): Pick { const { destinationChainId: chainId } = deposit; const gasPriceUsd = this.getPriceOfToken(GAS_TOKEN_BY_CHAIN_ID[chainId]); const nativeGasCost = this.getTotalGasCost(deposit); // gas cost in native token @@ -305,27 +315,10 @@ export class ProfitClient { return fillAmount.mul(tokenPriceInUsd).div(toBN(10).pow(l1TokenInfo.decimals)); } - getFillProfitability( - deposit: Deposit, - fillAmount: BigNumber, - refundFee: BigNumber, - l1Token: L1Token - ): FillProfit | undefined { + getFillProfitability(deposit: Deposit, fillAmount: BigNumber, refundFee: BigNumber, l1Token: L1Token): FillProfit { const minRelayerFeePct = this.minRelayerFeePct(l1Token.symbol, deposit.originChainId, deposit.destinationChainId); - let fill: FillProfit; - - try { - fill = this.calculateFillProfitability(deposit, fillAmount, refundFee, l1Token, minRelayerFeePct); - } catch (err) { - this.logger.debug({ - at: "ProfitClient#isFillProfitable", - message: `Unable to determine fill profitability (${err}).`, - deposit, - fillAmount, - }); - return undefined; - } + const fill = this.calculateFillProfitability(deposit, fillAmount, refundFee, l1Token, minRelayerFeePct); if (!fill.profitable || this.debugProfitability) { const { depositId, originChainId } = deposit; const profitable = fill.profitable ? "profitable" : "unprofitable"; @@ -354,14 +347,34 @@ export class ProfitClient { return fill; } - isFillProfitable(deposit: Deposit, fillAmount: BigNumber, refundFee: BigNumber, l1Token: L1Token): boolean { - const { profitable } = this.getFillProfitability(deposit, fillAmount, refundFee, l1Token); - return profitable ?? this.isTestnet; + isFillProfitable( + deposit: Deposit, + fillAmount: BigNumber, + refundFee: BigNumber, + l1Token: L1Token + ): Pick { + let profitable = false; + let nativeGasCost = uint256Max; + try { + ({ profitable, nativeGasCost } = this.getFillProfitability(deposit, fillAmount, refundFee, l1Token)); + } catch (err) { + this.logger.debug({ + at: "ProfitClient#isFillProfitable", + message: `Unable to determine fill profitability (${err}).`, + deposit, + fillAmount, + }); + } + + return { + profitable: profitable || this.isTestnet, + nativeGasCost, + }; } - captureUnprofitableFill(deposit: DepositWithBlock, fillAmount: BigNumber): void { - this.logger.debug({ at: "ProfitClient", message: "Handling unprofitable fill", deposit, fillAmount }); - assign(this.unprofitableFills, [deposit.originChainId], [{ deposit, fillAmount }]); + captureUnprofitableFill(deposit: DepositWithBlock, fillAmount: BigNumber, gasCost: BigNumber): void { + this.logger.debug({ at: "ProfitClient", message: "Handling unprofitable fill", deposit, fillAmount, gasCost }); + assign(this.unprofitableFills, [deposit.originChainId], [{ deposit, fillAmount, gasCost }]); } anyCapturedUnprofitableFills(): boolean { diff --git a/src/relayer/Relayer.ts b/src/relayer/Relayer.ts index b2d07d9ef..9af96f6e8 100644 --- a/src/relayer/Relayer.ts +++ b/src/relayer/Relayer.ts @@ -264,11 +264,16 @@ export class Relayer { // The SpokePool guarantees the sum of the fees is <= 100% of the deposit amount. deposit.realizedLpFeePct = await this.computeRealizedLpFeePct(version, deposit); - const repaymentChainId = await this.resolveRepaymentChain(version, deposit, unfilledAmount, l1Token); + const { repaymentChainId, gasLimit: gasCost } = await this.resolveRepaymentChain( + version, + deposit, + unfilledAmount, + l1Token + ); if (isDefined(repaymentChainId)) { this.fillRelay(deposit, unfilledAmount, repaymentChainId); } else { - profitClient.captureUnprofitableFill(deposit, unfilledAmount); + profitClient.captureUnprofitableFill(deposit, unfilledAmount, gasCost); } } else { tokenClient.captureTokenShortfallForFill(deposit, unfilledAmount); @@ -288,7 +293,7 @@ export class Relayer { } } - fillRelay(deposit: Deposit, fillAmount: BigNumber, repaymentChainId: number): void { + fillRelay(deposit: Deposit, fillAmount: BigNumber, repaymentChainId: number, gasLimit?: BigNumber): void { // Skip deposits that this relayer has already filled completely before to prevent double filling (which is a waste // of gas as the second fill would fail). // TODO: Handle the edge case scenario where the first fill failed due to transient errors and needs to be retried @@ -329,6 +334,7 @@ export class Relayer { chainId: deposit.destinationChainId, method, args: argBuilder(deposit, repaymentChainId, fillAmount), + gasLimit, message, mrkdwn: this.constructRelayFilledMrkdwn(deposit, repaymentChainId, fillAmount), }); @@ -508,7 +514,7 @@ export class Relayer { deposit: DepositWithBlock, fillAmount: BigNumber, hubPoolToken: L1Token - ): Promise { + ): Promise<{ repaymentChainId?: number; gasLimit: BigNumber }> { const { depositId, originChainId, destinationChainId, transactionHash: depositHash } = deposit; const { inventoryClient, profitClient } = this.clients; @@ -520,14 +526,19 @@ export class Relayer { message: `Skipping repayment chain determination for partial fill on ${destinationChain}`, deposit: { originChain, depositId, destinationChain, depositHash }, }); - return destinationChainId; } - const preferredChainId = await inventoryClient.determineRefundChainId(deposit, hubPoolToken.address); + const preferredChainId = fillAmount.eq(deposit.amount) + ? await inventoryClient.determineRefundChainId(deposit, hubPoolToken.address) + : destinationChainId; + const refundFee = this.computeRefundFee(version, deposit); + const { profitable, nativeGasCost } = profitClient.isFillProfitable(deposit, fillAmount, refundFee, hubPoolToken); - const profitable = profitClient.isFillProfitable(deposit, fillAmount, refundFee, hubPoolToken); - return profitable ? preferredChainId : undefined; + return { + repaymentChainId: profitable ? preferredChainId : undefined, + gasLimit: nativeGasCost, + }; } protected async computeRealizedLpFeePct(version: number, deposit: DepositWithBlock): Promise { @@ -608,15 +619,13 @@ export class Relayer { Object.keys(unprofitableDeposits).forEach((chainId) => { let depositMrkdwn = ""; Object.keys(unprofitableDeposits[chainId]).forEach((depositId) => { - const unprofitableDeposit = unprofitableDeposits[chainId][depositId]; - const deposit: DepositWithBlock = unprofitableDeposit.deposit; - const fillAmount: BigNumber = unprofitableDeposit.fillAmount; + const { deposit, fillAmount, gasCost: _gasCost } = unprofitableDeposits[chainId][depositId]; // Skip notifying if the unprofitable fill happened too long ago to avoid spamming. if (deposit.quoteTimestamp + UNPROFITABLE_DEPOSIT_NOTICE_PERIOD < getCurrentTime()) { return; } + const gasCost = _gasCost.toString(); - const gasCost = this.clients.profitClient.getTotalGasCost(deposit).toString(); const { symbol, decimals } = this.clients.hubPoolClient.getTokenInfoForDeposit(deposit); const formatFunction = createFormatFunction(2, 4, false, decimals); const gasFormatFunction = createFormatFunction(2, 10, false, 18); diff --git a/test/ProfitClient.ConsiderProfitability.ts b/test/ProfitClient.ConsiderProfitability.ts index 7e920451f..d17c43cf3 100644 --- a/test/ProfitClient.ConsiderProfitability.ts +++ b/test/ProfitClient.ConsiderProfitability.ts @@ -1,3 +1,4 @@ +import { random } from "lodash"; import { assert } from "chai"; import { constants as sdkConstants, utils as sdkUtils } from "@across-protocol/sdk-v2"; import { @@ -314,7 +315,7 @@ describe("ProfitClient: Consider relay profit", () => { netRelayerFeeUsd: expected.netRelayerFeeUsd, }); - const profitable = profitClient.isFillProfitable(deposit, nativeFillAmount, zeroRefundFee, l1Token); + const { profitable } = profitClient.isFillProfitable(deposit, nativeFillAmount, zeroRefundFee, l1Token); expect(profitable).to.equal(expected.profitable); }); }); @@ -367,7 +368,7 @@ describe("ProfitClient: Consider relay profit", () => { netRelayerFeeUsd: formatEther(expected.netRelayerFeeUsd), }); - const profitable = profitClient.isFillProfitable(deposit, nativeFillAmount, nativeRefundFee, l1Token); + const { profitable } = profitClient.isFillProfitable(deposit, nativeFillAmount, nativeRefundFee, l1Token); expect(profitable).to.equal(expected.profitable); }); }); @@ -453,7 +454,8 @@ describe("ProfitClient: Consider relay profit", () => { it("Captures unprofitable fills", () => { const deposit = { relayerFeePct: toBNWei("0.003"), originChainId: 1, depositId: 42 } as DepositWithBlock; - profitClient.captureUnprofitableFill(deposit, toBNWei(1)); - expect(profitClient.getUnprofitableFills()).to.deep.equal({ 1: [{ deposit, fillAmount: toBNWei(1) }] }); + const gasCost = toGWei(random(1, 100_000)); + profitClient.captureUnprofitableFill(deposit, toBNWei(1), gasCost); + expect(profitClient.getUnprofitableFills()).to.deep.equal({ 1: [{ deposit, fillAmount: toBNWei(1), gasCost }] }); }); });