Skip to content

Commit

Permalink
improve(Relayer): Step towards intelligent UBA refund chain selection (
Browse files Browse the repository at this point in the history
…#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.
  • Loading branch information
pxrl committed Jun 28, 2023
1 parent 7f95b5e commit 75a9d8a
Show file tree
Hide file tree
Showing 4 changed files with 74 additions and 62 deletions.
28 changes: 19 additions & 9 deletions src/clients/ProfitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {
Expand Down Expand Up @@ -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,
Expand All @@ -254,7 +254,7 @@ export class ProfitClient {
relayerCapitalUsd,
netRelayerFeePct,
netRelayerFeeUsd,
fillProfitable,
profitable,
};
}

Expand All @@ -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;

Expand All @@ -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}`,
Expand All @@ -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 {
Expand Down
65 changes: 35 additions & 30 deletions src/relayer/Relayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -334,38 +332,45 @@ export class Relayer {
}
}

protected async resolveRepaymentChain(deposit: Deposit, fillAmount: BigNumber): Promise<number> {
protected async resolveRepaymentChain(
version: number,
deposit: DepositWithBlock,
fillAmount: BigNumber,
hubPoolToken: L1Token
): Promise<number | undefined> {
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
// unlikely that a relayer could enqueue a 1 wei fill (lacking balance to fully fill it) for a deposit and
// 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(
Expand Down Expand Up @@ -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<BigNumber | undefined> {
): Promise<BigNumber[]> {
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() {
Expand Down
34 changes: 16 additions & 18 deletions test/ProfitClient.ConsiderProfitability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
});
});
});
Expand Down Expand Up @@ -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),
Expand All @@ -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);
});
});
});
Expand Down
9 changes: 4 additions & 5 deletions test/Relayer.BasicFill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 75a9d8a

Please sign in to comment.