From 75a9d8a5c72be31ae64fa19cf44629ae7ba0bc96 Mon Sep 17 00:00:00 2001 From: Paul <108695806+pxrl@users.noreply.github.com> Date: Wed, 28 Jun 2023 18:31:07 +0200 Subject: [PATCH] improve(Relayer): Step towards intelligent UBA refund chain selection (#765) This change initially proposed to implement preferential refund chain selection based on: Destination chain ID HubPool chain ID Profitable chain IDs (if any). As-is, the change now retains the pre-existing repayment chain selection based solely on input from the Inventory Client (or just the destination chain, if inventory management is not configured). The ProfitClient can now compute the profitability of an array of chain IDs, and will return their profitability (yes/no) as well as the net profitability to permit sorting/preferential selection (note: >0% does not guarantee that the fill would be considered profitable). The subsequent work to preferentially select the best repayment chain will be bundled into the InventoryClient update. Closes ACX-977. --- src/clients/ProfitClient.ts | 28 +++++++--- src/relayer/Relayer.ts | 65 ++++++++++++---------- test/ProfitClient.ConsiderProfitability.ts | 34 ++++++----- test/Relayer.BasicFill.ts | 9 ++- 4 files changed, 74 insertions(+), 62 deletions(-) diff --git a/src/clients/ProfitClient.ts b/src/clients/ProfitClient.ts index 4c69991ef..532aa7d7f 100644 --- a/src/clients/ProfitClient.ts +++ b/src/clients/ProfitClient.ts @@ -31,7 +31,7 @@ export type FillProfit = { relayerCapitalUsd: BigNumber; // Amount to be sent by the relayer in USD. netRelayerFeePct: BigNumber; // Relayer fee after gas costs as a portion of relayerCapitalUsd. netRelayerFeeUsd: BigNumber; // Relayer fee in USD after paying for gas costs. - fillProfitable: boolean; // Fill profitability indicator. + profitable: boolean; // Fill profitability indicator. }; export const GAS_TOKEN_BY_CHAIN_ID: { [chainId: number]: string } = { @@ -239,7 +239,7 @@ export class ProfitClient { const netRelayerFeePct = netRelayerFeeUsd.mul(fixedPoint).div(relayerCapitalUsd); // If token price or gas price is unknown, assume the relay is unprofitable. - const fillProfitable = tokenPriceUsd.gt(0) && gasPriceUsd.gt(0) && netRelayerFeePct.gte(minRelayerFeePct); + const profitable = tokenPriceUsd.gt(0) && gasPriceUsd.gt(0) && netRelayerFeePct.gte(minRelayerFeePct); return { grossRelayerFeePct, @@ -254,7 +254,7 @@ export class ProfitClient { relayerCapitalUsd, netRelayerFeePct, netRelayerFeeUsd, - fillProfitable, + profitable, }; } @@ -270,7 +270,12 @@ export class ProfitClient { return fillAmount.mul(tokenPriceInUsd).div(toBN(10).pow(l1TokenInfo.decimals)); } - isFillProfitable(deposit: Deposit, fillAmount: BigNumber, refundFee: BigNumber, l1Token: L1Token): boolean { + getFillProfitability( + deposit: Deposit, + fillAmount: BigNumber, + refundFee: BigNumber, + l1Token: L1Token + ): FillProfit | undefined { const minRelayerFeePct = this.minRelayerFeePct(l1Token.symbol, deposit.originChainId, deposit.destinationChainId); let fill: FillProfit; @@ -283,12 +288,12 @@ export class ProfitClient { deposit, fillAmount, }); - return false; + return undefined; } - if (!fill.fillProfitable || this.debugProfitability) { + if (!fill.profitable || this.debugProfitability) { const { depositId, originChainId } = deposit; - const profitable = fill.fillProfitable ? "profitable" : "unprofitable"; + const profitable = fill.profitable ? "profitable" : "unprofitable"; this.logger.debug({ at: "ProfitClient#isFillProfitable", message: `${l1Token.symbol} deposit ${depositId} on chain ${originChainId} is ${profitable}`, @@ -307,11 +312,16 @@ export class ProfitClient { netRelayerFeeUsd: formatEther(fill.netRelayerFeeUsd), netRelayerFeePct: `${formatFeePct(fill.netRelayerFeePct)}%`, minRelayerFeePct: `${formatFeePct(minRelayerFeePct)}%`, - fillProfitable: fill.fillProfitable, + profitable: fill.profitable, }); } - return fill.fillProfitable; + return fill; + } + + isFillProfitable(deposit: Deposit, fillAmount: BigNumber, refundFee: BigNumber, l1Token: L1Token): boolean { + const { profitable } = this.getFillProfitability(deposit, fillAmount, refundFee, l1Token); + return profitable ?? false; } captureUnprofitableFill(deposit: DepositWithBlock, fillAmount: BigNumber): void { diff --git a/src/relayer/Relayer.ts b/src/relayer/Relayer.ts index 02e69abac..7ed16c388 100644 --- a/src/relayer/Relayer.ts +++ b/src/relayer/Relayer.ts @@ -2,6 +2,7 @@ import { groupBy } from "lodash"; import { clients as sdkClients, utils as sdkUtils } from "@across-protocol/sdk-v2"; import { BigNumber, + isDefined, winston, buildFillRelayProps, getNetworkName, @@ -12,7 +13,7 @@ import { } from "../utils"; import { createFormatFunction, etherscanLink, formatFeePct, toBN, toBNWei } from "../utils"; import { RelayerClients } from "./RelayerClientHelper"; -import { Deposit, DepositWithBlock } from "../interfaces"; +import { Deposit, DepositWithBlock, L1Token } from "../interfaces"; import { RelayerConfig } from "./RelayerConfig"; const { UBAActionType } = sdkClients; @@ -228,11 +229,8 @@ export class Relayer { l1Token.address ); - const repaymentChainId = await this.resolveRepaymentChain(deposit, unfilledAmount); - // @todo: For UBA, compute the anticipated refund fee(s) for *all* candidate refund chain(s). - const refundFee = await this.computeRefundFee(version, unfilledAmount, repaymentChainId, l1Token.symbol); - - if (profitClient.isFillProfitable(deposit, unfilledAmount, refundFee, l1Token)) { + const repaymentChainId = await this.resolveRepaymentChain(version, deposit, unfilledAmount, l1Token); + if (isDefined(repaymentChainId)) { this.fillRelay(deposit, unfilledAmount, repaymentChainId); } else { profitClient.captureUnprofitableFill(deposit, unfilledAmount); @@ -334,7 +332,15 @@ export class Relayer { } } - protected async resolveRepaymentChain(deposit: Deposit, fillAmount: BigNumber): Promise { + protected async resolveRepaymentChain( + version: number, + deposit: DepositWithBlock, + fillAmount: BigNumber, + hubPoolToken: L1Token + ): Promise { + const { depositId, originChainId, destinationChainId, transactionHash: depositHash } = deposit; + const { inventoryClient, profitClient } = this.clients; + // TODO: Consider adding some way for Relayer to delete transactions in Queue for fills for same deposit. // This way the relayer could set a repayment chain ID for any fill that follows a 1 wei fill in the queue. // This isn't implemented due to complexity because its a very rare case in production, because its very @@ -342,30 +348,29 @@ export class Relayer { // then later on in the run have enough balance to fully fill it. const fillsInQueueForSameDeposit = this.clients.multiCallerClient .getQueuedTransactions(deposit.destinationChainId) - .some((tx) => { - const { method, args } = tx; - const { depositId, originChainId } = deposit; + .some(({ method, args }) => { return ( (method === "fillRelay" && args[9] === depositId && args[6] === originChainId) || (method === "fillRelayWithUpdatedDeposit" && args[11] === depositId && args[7] === originChainId) ); }); - // Fetch the repayment chain from the inventory client. Sanity check that it is one of the known chainIds. - // We can only overwrite repayment chain ID if we can fully fill the deposit. - let repaymentChainId = deposit.destinationChainId; - - if (fillAmount.eq(deposit.amount) && !fillsInQueueForSameDeposit) { - const destinationChainId = deposit.destinationChainId.toString(); - repaymentChainId = await this.clients.inventoryClient.determineRefundChainId(deposit); - if (!Object.keys(this.clients.spokePoolClients).includes(destinationChainId)) { - throw new Error("Fatal error! Repayment chain set to a chain that is not part of the defined sets of chains!"); - } - } else { - this.logger.debug({ at: "Relayer", message: "Skipping repayment chain determination for partial fill" }); + if (!fillAmount.eq(deposit.amount) || fillsInQueueForSameDeposit) { + const originChain = getNetworkName(originChainId); + const destinationChain = getNetworkName(destinationChainId); + this.logger.debug({ + at: "Relayer", + message: `Skipping repayment chain determination for partial fill on ${destinationChain}`, + deposit: { originChain, depositId, destinationChain, depositHash }, + }); + return destinationChainId; } - return repaymentChainId; + const preferredChainId = await inventoryClient.determineRefundChainId(deposit); + const refundFee = (await this.computeRefundFees(version, fillAmount, [preferredChainId], hubPoolToken.symbol))[0]; + + const profitable = profitClient.isFillProfitable(deposit, fillAmount, refundFee, hubPoolToken); + return profitable ? preferredChainId : undefined; } protected async computeRealizedLpFeePct( @@ -407,27 +412,27 @@ export class Relayer { return realizedLpFeePct; } - protected async computeRefundFee( + protected async computeRefundFees( version: number, unfilledAmount: BigNumber, - refundChainId: number, + chainIds: number[], symbol: string, hubPoolBlockNumber?: number - ): Promise { + ): Promise { if (!sdkUtils.isUBA(version)) { - return toBN(0); + return chainIds.map(() => toBN(0)); } const { hubPoolClient, ubaClient } = this.clients; - const { balancingFee } = await ubaClient.computeBalancingFee( + const fees = await ubaClient.computeBalancingFees( symbol, unfilledAmount, hubPoolBlockNumber ?? hubPoolClient.latestBlockNumber, - refundChainId, + chainIds, UBAActionType.Refund ); - return balancingFee; + return fees.map(({ balancingFee }) => balancingFee); } private handleTokenShortfall() { diff --git a/test/ProfitClient.ConsiderProfitability.ts b/test/ProfitClient.ConsiderProfitability.ts index 4b737425c..ec4cbe446 100644 --- a/test/ProfitClient.ConsiderProfitability.ts +++ b/test/ProfitClient.ConsiderProfitability.ts @@ -79,14 +79,14 @@ function testProfitability( const netRelayerFeeUsd = grossRelayerFeeUsd.sub(gasCostUsd.add(refundFeeUsd)); const netRelayerFeePct = netRelayerFeeUsd.mul(fixedPoint).div(relayerCapitalUsd); - const fillProfitable = netRelayerFeeUsd.gte(minRelayerFeeUsd); + const profitable = netRelayerFeeUsd.gte(minRelayerFeeUsd); return { grossRelayerFeeUsd, netRelayerFeePct, relayerCapitalUsd, netRelayerFeeUsd, - fillProfitable, + profitable, } as FillProfit; } @@ -283,24 +283,23 @@ describe("ProfitClient: Consider relay profit", async function () { const relayerFeePct = _relayerFeePct.gt(maxRelayerFeePct) ? maxRelayerFeePct : _relayerFeePct; const deposit = { relayerFeePct, destinationChainId } as Deposit; - const fill: FillProfit = testProfitability(deposit, fillAmountUsd, gasCostUsd, zeroRefundFee); + const expected = testProfitability(deposit, fillAmountUsd, gasCostUsd, zeroRefundFee); spyLogger.debug({ - message: `Expect ${l1Token.symbol} deposit is ${fill.fillProfitable ? "" : "un"}profitable:`, + message: `Expect ${l1Token.symbol} deposit is ${expected.profitable ? "" : "un"}profitable:`, fillAmount, fillAmountUsd, gasCostUsd, grossRelayerFeePct: `${formatFeePct(relayerFeePct)} %`, gasCostPct: `${formatFeePct(gasCostPct)} %`, - relayerCapitalUsd: fill.relayerCapitalUsd, + relayerCapitalUsd: expected.relayerCapitalUsd, minRelayerFeePct: `${formatFeePct(minRelayerFeePct)} %`, minRelayerFeeUsd: minRelayerFeePct.mul(fillAmountUsd).div(fixedPoint), - netRelayerFeePct: `${formatFeePct(fill.netRelayerFeePct)} %`, - netRelayerFeeUsd: fill.netRelayerFeeUsd, + netRelayerFeePct: `${formatFeePct(expected.netRelayerFeePct)} %`, + netRelayerFeeUsd: expected.netRelayerFeeUsd, }); - expect(profitClient.isFillProfitable(deposit, nativeFillAmount, zeroRefundFee, l1Token)).to.equal( - fill.fillProfitable - ); + const profitable = profitClient.isFillProfitable(deposit, nativeFillAmount, zeroRefundFee, l1Token); + expect(profitable).to.equal(expected.profitable); }); }); }); @@ -334,9 +333,9 @@ describe("ProfitClient: Consider relay profit", async function () { const refundFee = fillAmount.mul(feeMultiplier).div(fixedPoint); const nativeRefundFee = nativeFillAmount.mul(feeMultiplier).div(fixedPoint); const refundFeeUsd = refundFee.mul(tokenPriceUsd).div(fixedPoint); - const fill: FillProfit = testProfitability(deposit, fillAmountUsd, gasCostUsd, refundFeeUsd); + const expected = testProfitability(deposit, fillAmountUsd, gasCostUsd, refundFeeUsd); spyLogger.debug({ - message: `Expect ${l1Token.symbol} deposit is ${fill.fillProfitable ? "" : "un"}profitable:`, + message: `Expect ${l1Token.symbol} deposit is ${expected.profitable ? "" : "un"}profitable:`, tokenPrice: formatEther(tokenPriceUsd), fillAmount: formatEther(fillAmount), fillAmountUsd: formatEther(fillAmountUsd), @@ -346,16 +345,15 @@ describe("ProfitClient: Consider relay profit", async function () { refundFeeUsd: formatEther(refundFeeUsd), grossRelayerFeePct: `${formatFeePct(relayerFeePct)} %`, gasCostPct: `${formatFeePct(gasCostPct)} %`, - relayerCapitalUsd: formatEther(fill.relayerCapitalUsd), + relayerCapitalUsd: formatEther(expected.relayerCapitalUsd), minRelayerFeePct: `${formatFeePct(minRelayerFeePct)} %`, minRelayerFeeUsd: formatEther(minRelayerFeePct.mul(fillAmountUsd).div(fixedPoint)), - netRelayerFeePct: `${formatFeePct(fill.netRelayerFeePct)} %`, - netRelayerFeeUsd: formatEther(fill.netRelayerFeeUsd), + netRelayerFeePct: `${formatFeePct(expected.netRelayerFeePct)} %`, + netRelayerFeeUsd: formatEther(expected.netRelayerFeeUsd), }); - expect(profitClient.isFillProfitable(deposit, nativeFillAmount, nativeRefundFee, l1Token)).to.equal( - fill.fillProfitable - ); + const profitable = profitClient.isFillProfitable(deposit, nativeFillAmount, nativeRefundFee, l1Token); + expect(profitable).to.equal(expected.profitable); }); }); }); diff --git a/test/Relayer.BasicFill.ts b/test/Relayer.BasicFill.ts index 50dc24811..584a89263 100644 --- a/test/Relayer.BasicFill.ts +++ b/test/Relayer.BasicFill.ts @@ -115,8 +115,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { }, { relayerTokens: [], - slowDepositors: [], - relayerDestinationChains: [], + relayerDestinationChains: [originChainId, destinationChainId], maxRelayerLookBack: 24 * 60 * 60, minDepositConfirmations: defaultMinDepositConfirmations, quoteTimeBuffer: 0, @@ -196,7 +195,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { }, { relayerTokens: [], - relayerDestinationChains: [], + relayerDestinationChains: [originChainId, destinationChainId], minDepositConfirmations: { default: { [originChainId]: 10 }, // This needs to be set large enough such that the deposit is ignored. }, @@ -233,7 +232,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { }, { relayerTokens: [], - relayerDestinationChains: [], + relayerDestinationChains: [originChainId, destinationChainId], minDepositConfirmations: defaultMinDepositConfirmations, quoteTimeBuffer: 100, sendingRelaysEnabled: false, @@ -452,7 +451,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { }, { relayerTokens: [], - relayerDestinationChains: [], + relayerDestinationChains: [originChainId, destinationChainId], maxRelayerLookBack: 24 * 60 * 60, minDepositConfirmations: defaultMinDepositConfirmations, quoteTimeBuffer: 0,