Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(profitability): Remove hardcoded min revenue map and add logic to consider gas cost #212

Closed
wants to merge 14 commits into from
2 changes: 1 addition & 1 deletion src/clients/MultiCallerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface AugmentedTransaction {
export class MultiCallerClient {
private transactions: AugmentedTransaction[] = [];
// eslint-disable-next-line no-useless-constructor
constructor(readonly logger: winston.Logger, readonly gasEstimator: any, readonly maxTxWait: number = 180) {}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused vars

constructor(readonly logger: winston.Logger) {}

// Adds all information associated with a transaction to the transaction queue. This is the intention of the
// caller to send a transaction. The transaction might not be executable, which should be filtered later.
Expand Down
27 changes: 23 additions & 4 deletions src/clients/ProfitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Coingecko } from "@uma/sdk";
// Define the minimum revenue, in USD, that a relay must yield in order to be considered "profitable". This is a short
// term solution to enable us to avoid DOS relays that yield negative profits. In the future this should be updated
// to actual factor in the cost of sending transactions on the associated target chains.
const chainIdToMinRevenue = {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Context: https://risklabs.slack.com/archives/D03GKFYCDJ7/p1657999254013629. tldr; is this hardcoded limit is filtering out a lot of small deposits with profits of < $1. When running with this previous logic, my relayer was skipping 9/10 deposits. We'll follow up with data analysis to understand profitability of small deposits.

const MIN_REVENUE_BY_CHAIN_ID = {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Constants should be ALL_CAPS

// Mainnet and L1 testnets.
1: toBNWei(10),
4: toBNWei(10),
Expand All @@ -22,6 +22,17 @@ const chainIdToMinRevenue = {
80001: toBNWei(1),
};

// We use wrapped ERC-20 versions instead of the native tokens such as ETH, MATIC for ease of computing prices.
const GAS_TOKEN_BY_CHAIN_ID = {
1: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH
10: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH
137: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", // WMATIC
288: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH
42161: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH
};
// TODO: Make this dynamic once we support chains with gas tokens that have different decimals.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope there is no world where a chain has a non-18 decimal gas token but sadly this will probably be the case

const GAS_TOKEN_DECIMALS = 18;

export class ProfitClient {
private readonly coingecko;
protected tokenPrices: { [l1Token: string]: BigNumber } = {};
Expand Down Expand Up @@ -55,7 +66,7 @@ export class ProfitClient {
this.unprofitableFills = {};
}

isFillProfitable(deposit: Deposit, fillAmount: BigNumber) {
isFillProfitable(deposit: Deposit, fillAmount: BigNumber, gasUsed: BigNumber) {
if (toBN(this.relayerDiscount).eq(toBNWei(1))) {
this.logger.debug({ at: "ProfitClient", message: "Relayer discount set to 100%. Accepting relay" });
return true;
Expand All @@ -69,10 +80,18 @@ export class ProfitClient {
const tokenPriceInUsd = this.getPriceOfToken(l1Token);
const fillRevenueInRelayedToken = toBN(deposit.relayerFeePct).mul(fillAmount).div(toBN(10).pow(decimals));
const fillRevenueInUsd = fillRevenueInRelayedToken.mul(tokenPriceInUsd).div(toBNWei(1));

// Consider gas cost.
const gasCostInUsd = gasUsed
.mul(this.getPriceOfToken(GAS_TOKEN_BY_CHAIN_ID[deposit.destinationChainId]))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need to compute the current gas price to take into account fluctuations in relayer costs? I think most chains have gas costs that can vary by up to 10x week-to-week, so I don't think hardcoding USD revenue will be sufficient to match the dynamic estimations being done by the frontend.

Note: maybe I'm missing the point of this PR! Feel free to clarify in the description. I'm just going off of the title.

Copy link
Contributor Author

@kevinuma kevinuma Jul 18, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gasUsed already includes the gas fee (gasUsed = gas cost * gas fee) as passed in from Relayer.

Regarding the hardcoded min USD revenue. We'll likely remove them as the first step as we currently have a lot of small deposits with < $1 profit (after gas costs). Depending on external relayer demand, we might or might add back the ability to configure a min profit amount.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, maybe we're just using different language to describe the same thing.

I'm talking about the USD costs, which would amount to gasUsed * gasPrice * gasTokenPrice. I don't think this code takes gasPrice into account, so if the gas price swings very high, we could relay unprofitable deposits, and if it swings low, we could block profitable deposits.

I think if we want this code to correctly take into account the true cost of relaying a transaction, we need to estimate the gas cost that will be paid.

Do you agree with that assessment?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, i understand matt's confusion. Its that usually "gas used" in web3/ethers context refers the EVM gas assessed for a smart contract function. Also, estimateGas is a common function name that refers to the EVM gas cost of a function. Perhaps this would be reduce confusion if you renamed to estimateGasCost or estimateTotalCost wherever you call estimateGas. (I believe this is in the Relayer.ts file)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, exactly. Sorry about that @kevinuma. You're completely right. Thanks for the clarification!

.div(toBN(10).pow(GAS_TOKEN_DECIMALS));

// How much minimumAcceptableRevenue is scaled. If relayer discount is 0 then need minimumAcceptableRevenue at min.
const revenueScalar = toBNWei(1).sub(this.relayerDiscount);
const minimumAcceptableRevenue = chainIdToMinRevenue[deposit.destinationChainId].mul(revenueScalar).div(toBNWei(1));
const fillProfitable = fillRevenueInUsd.gte(minimumAcceptableRevenue);
const minimumAcceptableRevenue = MIN_REVENUE_BY_CHAIN_ID[deposit.destinationChainId]
.mul(revenueScalar)
.div(toBNWei(1));
const fillProfitable = fillRevenueInUsd.sub(gasCostInUsd).gte(minimumAcceptableRevenue);
this.logger.debug({
at: "ProfitClient",
message: "Considered fill profitability",
Expand Down
2 changes: 1 addition & 1 deletion src/common/ClientHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export async function constructClients(logger: winston.Logger, config: CommonCon
);

// const gasEstimator = new GasEstimator() // todo when this is implemented in the SDK.
const multiCallerClient = new MultiCallerClient(logger, null, config.maxTxWait);
const multiCallerClient = new MultiCallerClient(logger);

const profitClient = new ProfitClient(logger, hubPoolClient);

Expand Down
47 changes: 33 additions & 14 deletions src/relayer/Relayer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { BigNumber, winston, buildFillRelayProps, getNetworkName, getUnfilledDeposits, getCurrentTime } from "../utils";
import {
BigNumber,
winston,
buildFillRelayProps,
getNetworkName,
getUnfilledDeposits,
getCurrentTime,
estimateGas,
} from "../utils";
import { createFormatFunction, etherscanLink, toBN } from "../utils";
import { RelayerClients } from "./RelayerClientHelper";

import { Deposit } from "../interfaces";
import { AugmentedTransaction } from "../clients";

const UNPROFITABLE_DEPOSIT_NOTICE_PERIOD = 60 * 60; // 1 hour

Expand Down Expand Up @@ -59,8 +68,16 @@ export class Relayer {
}

if (this.clients.tokenClient.hasBalanceForFill(deposit, unfilledAmount)) {
if (this.clients.profitClient.isFillProfitable(deposit, unfilledAmount)) {
this.fillRelay(deposit, unfilledAmount);
// Fetch the repayment chain from the inventory client. Sanity check that it is one of the known chainIds.
const repaymentChain = this.clients.inventoryClient.determineRefundChainId(deposit);
// Create the transaction, so we can simulate to get the gas cost for profitability calculation.
const fillTransaction = this.createFillTransaction(deposit, unfilledAmount, repaymentChain);
// TODO: Potentially skip the entire sequence if the transaction would fail. We currently handle this in
// fillRelay separately, so we'll need to figure out how to consolidate the logic.
const fillSimulation = await estimateGas(fillTransaction);

if (this.clients.profitClient.isFillProfitable(deposit, unfilledAmount, fillSimulation.gasUsed)) {
this.fillRelay(deposit, unfilledAmount, repaymentChain, fillTransaction);
} else {
this.clients.profitClient.captureUnprofitableFill(deposit, unfilledAmount);
}
Expand All @@ -77,23 +94,25 @@ export class Relayer {
if (this.clients.profitClient.anyCapturedUnprofitableFills()) this.handleUnprofitableFill();
}

fillRelay(deposit: Deposit, fillAmount: BigNumber) {
createFillTransaction(deposit: Deposit, fillAmount: BigNumber, repaymentChain: number): AugmentedTransaction {
return {
contract: this.clients.spokePoolClients[deposit.destinationChainId].spokePool, // target contract
chainId: deposit.destinationChainId,
method: "fillRelay", // method called.
args: buildFillRelayProps(deposit, repaymentChain, fillAmount), // props sent with function call.
message: "Relay instantly sent 🚀", // message sent to logger.
mrkdwn: this.constructRelayFilledMrkdwn(deposit, repaymentChain, fillAmount), // message details mrkdwn
};
}

fillRelay(deposit: Deposit, fillAmount: BigNumber, repaymentChain: number, transaction: AugmentedTransaction) {
try {
// Fetch the repayment chain from the inventory client. Sanity check that it is one of the known chainIds.
const repaymentChain = this.clients.inventoryClient.determineRefundChainId(deposit);
if (!Object.keys(this.clients.spokePoolClients).includes(deposit.destinationChainId.toString()))
throw new Error("Fatal error! Repayment chain set to a chain that is not part of the defined sets of chains!");

this.logger.debug({ at: "Relayer", message: "Filling deposit", deposit, repaymentChain });
// Add the fill transaction to the multiCallerClient so it will be executed with the next batch.
this.clients.multiCallerClient.enqueueTransaction({
contract: this.clients.spokePoolClients[deposit.destinationChainId].spokePool, // target contract
chainId: deposit.destinationChainId,
method: "fillRelay", // method called.
args: buildFillRelayProps(deposit, repaymentChain, fillAmount), // props sent with function call.
message: "Relay instantly sent 🚀", // message sent to logger.
mrkdwn: this.constructRelayFilledMrkdwn(deposit, repaymentChain, fillAmount), // message details mrkdwn
});
this.clients.multiCallerClient.enqueueTransaction(transaction);

// Decrement tokens in token client used in the fill. This ensures that we dont try and fill more than we have.
this.clients.tokenClient.decrementLocalBalance(deposit.destinationChainId, deposit.destinationToken, fillAmount);
Expand Down
13 changes: 13 additions & 0 deletions src/utils/TransactionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ export async function willSucceed(
}
}

export async function estimateGas(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think it might be clearer if we called this estimateGasCost, since estimateGas is the name of the method that is usually used to report the amount of gas rather than the amount of tokens used for gas.

transaction: AugmentedTransaction
): Promise<{ transaction: AugmentedTransaction; succeed: boolean; reason: string; gasUsed: BigNumber }> {
try {
const args = transaction.value ? [...transaction.args, { value: transaction.value }] : transaction.args;
const gasUsed = await transaction.contract.estimateGas[transaction.method](...args);
return { transaction, succeed: true, reason: null, gasUsed };
} catch (error) {
console.error(error);
return { transaction, succeed: false, reason: error.reason, gasUsed: toBN(0) };
}
}

export function getTarget(targetAddress: string) {
try {
return { targetAddress, ...getContractInfoFromAddress(targetAddress) };
Expand Down
2 changes: 1 addition & 1 deletion test/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
TokenTransferClient,
BalanceAllocator,
} from "../src/clients";
import { CrossChainTransferClient, AdapterManager } from "../src/clients/bridges";
import { CrossChainTransferClient } from "../src/clients/bridges";
import { Monitor, ALL_CHAINS_NAME, UNKNOWN_TRANSFERS_NAME } from "../src/monitor/Monitor";
import { MonitorConfig } from "../src/monitor/MonitorConfig";
import { amountToDeposit, destinationChainId, mockTreeRoot, originChainId, repaymentChainId } from "./constants";
Expand Down
2 changes: 1 addition & 1 deletion test/Relayer.BasicFill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () {
hubPoolClient = new HubPoolClient(spyLogger, hubPool);
configStoreClient = new AcrossConfigStoreClient(spyLogger, configStore, hubPoolClient);

multiCallerClient = new MultiCallerClient(spyLogger, null); // leave out the gasEstimator for now.
multiCallerClient = new MultiCallerClient(spyLogger); // leave out the gasEstimator for now.

spokePoolClient_1 = new SpokePoolClient(spyLogger, spokePool_1.connect(relayer), configStoreClient, originChainId);
spokePoolClient_2 = new SpokePoolClient(
Expand Down
2 changes: 1 addition & 1 deletion test/Relayer.IterativeFill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe("Relayer: Iterative fill", async function () {
({ configStore } = await deployConfigStore(relayer, []));
hubPoolClient = new HubPoolClient(spyLogger, hubPool);
configStoreClient = new AcrossConfigStoreClient(spyLogger, configStore, hubPoolClient);
multiCallerClient = new MultiCallerClient(spyLogger, null); // leave out the gasEstimator for now.
multiCallerClient = new MultiCallerClient(spyLogger);

({ spokePools, l1TokenToL2Tokens } = await deployIterativeSpokePoolsAndToken(
spyLogger,
Expand Down
2 changes: 1 addition & 1 deletion test/Relayer.SlowFill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe("Relayer: Zero sized fill for slow relay", async function () {
hubPoolClient = new HubPoolClient(spyLogger, hubPool);
configStoreClient = new AcrossConfigStoreClient(spyLogger, configStore, hubPoolClient);

multiCallerClient = new MultiCallerClient(spyLogger, null); // leave out the gasEstimator for now.
multiCallerClient = new MultiCallerClient(spyLogger);

spokePoolClient_1 = new SpokePoolClient(spyLogger, spokePool_1.connect(relayer), configStoreClient, originChainId);
spokePoolClient_2 = new SpokePoolClient(
Expand Down
2 changes: 1 addition & 1 deletion test/Relayer.TokenShortfall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("Relayer: Token balance shortfall", async function () {
({ configStore } = await deployConfigStore(owner, [l1Token]));
hubPoolClient = new HubPoolClient(spyLogger, hubPool);
configStoreClient = new AcrossConfigStoreClient(spyLogger, configStore, hubPoolClient);
multiCallerClient = new MultiCallerClient(spyLogger, null); // leave out the gasEstimator for now.
multiCallerClient = new MultiCallerClient(spyLogger); // leave out the gasEstimator for now.
spokePoolClient_1 = new SpokePoolClient(spyLogger, spokePool_1.connect(relayer), configStoreClient, originChainId);
spokePoolClient_2 = new SpokePoolClient(
spyLogger,
Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/Dataworker.Fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export async function setupDataworker(
const hubPoolClient = new clients.HubPoolClient(spyLogger, hubPool);
const configStoreClient = new clients.AcrossConfigStoreClient(spyLogger, configStore, hubPoolClient);

const multiCallerClient = new clients.MultiCallerClient(spyLogger, null); // leave out the gasEstimator for now.
const multiCallerClient = new clients.MultiCallerClient(spyLogger); // leave out the gasEstimator for now.
const profitClient = new clients.ProfitClient(spyLogger, hubPoolClient, toBNWei(1)); // Set relayer discount to 100%.

const spokePoolClient_1 = new clients.SpokePoolClient(
Expand Down