From 066733b1d56174a89d3c4564c781cea8cc930b83 Mon Sep 17 00:00:00 2001 From: nicholaspai <9457025+nicholaspai@users.noreply.github.com> Date: Mon, 20 May 2024 13:00:26 -0400 Subject: [PATCH] feat(Relayer): Consider all eligible repayment chains if top one is unprofitable (#1426) * feat(Relayer): Consider all eligible repayment chains if top one is unprofitable Currently the algorithm is to consider destination chain only if top preferred chain is unprofitable and not equal to destination chain This PR changes the algo to have `determineRefundChain` to return all eligible repayment chain so that the relayer considers them all before falling back to destination chain, assuming that destination chain wasn't already considered * lint * Update Relayer.ts * Update Relayer.ts * Re add back tests * fix tests * Use net, not gross relayer fee pct in logs * refactor and run profitablity loop i parallel * Update Relayer.ts * Update InventoryClient.ts * Update InventoryClient.RefundChain.ts * Update InventoryClient.RefundChain.ts * Update InventoryClient.RefundChain.ts * Update InventoryClient.RefundChain.ts * Update InventoryClient.RefundChain.ts * Update InventoryClient.RefundChain.ts --- src/clients/InventoryClient.ts | 49 ++--- src/clients/ProfitClient.ts | 25 ++- src/relayer/Relayer.ts | 210 +++++++++++++-------- test/InventoryClient.RefundChain.ts | 77 +++++--- test/ProfitClient.ConsiderProfitability.ts | 9 +- test/mocks/MockInventoryClient.ts | 4 +- 6 files changed, 235 insertions(+), 139 deletions(-) diff --git a/src/clients/InventoryClient.ts b/src/clients/InventoryClient.ts index f05cb4cb8..58065ced2 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -383,26 +383,29 @@ export class InventoryClient { return isInputTokenUSDC && isOutputTokenBridgedUSDC; } - // Work out where a relay should be refunded to optimally manage the bots inventory. If the inventory management logic - // not enabled then return funds on the chain the deposit was filled on Else, use the following algorithm for each - // of the origin and destination chain: - // a) Find the chain virtual balance (current balance + pending relays + pending refunds) minus current shortfall. - // b) Find the cumulative virtual balance, including the total refunds on all chains and excluding current shortfall. - // c) Consider the size of a and b post relay (i.e after the relay is paid and all current transfers are settled what - // will the balances be on the target chain and the overall cumulative balance). - // d) Use c to compute what the post relay post current in-flight transactions allocation would be. Compare this - // number to the target threshold and: - // If this number is less than the target for the destination chain + rebalance then select destination chain. We - // slightly prefer destination to origin chain to support relayer capital efficiency. - // Else, if this number is less than the target for the origin chain + rebalance then select origin - // chain. - // Else, take repayment on the Hub chain for ease of transferring out of L1 to any L2. - async determineRefundChainId(deposit: V3Deposit, l1Token?: string): Promise { + /* + * Return all eligible repayment chains for a deposit. If inventory management is enabled, then this function will + * only choose chains where the post-relay balance allocation for a potential repayment chain is under the maximum + * allowed allocation on that chain. Origin, Destination, and HubChains are always evaluated as potential + * repayment chains in addition to "Slow Withdrawal chains" such as Base, Optimism and Arbitrum for which + * taking repayment would reduce HubPool utilization. Post-relay allocation percentages take into + * account pending cross-chain inventory-management transfers, upcoming bundle refunds, token shortfalls + * needed to cover other unfilled deposits in addition to current token balances. Slow withdrawal chains are only + * selected if the SpokePool's running balance for that chain is over the system's desired target. + * @dev The HubChain is always evaluated as a fallback option if the inventory management is enabled and all other + * chains are over-allocated. + * @dev If inventory management is disabled, then destinationChain is used as a default. + * @param deposit Deposit to determine repayment chains for. + * @param l1Token L1Token linked with deposited inputToken and repayement chain refund token. + * @returns list of chain IDs that are possible repayment chains for the deposit, sorted from highest + * to lowest priority. + */ + async determineRefundChainId(deposit: V3Deposit, l1Token?: string): Promise { const { originChainId, destinationChainId, inputToken, outputToken, outputAmount, inputAmount } = deposit; const hubChainId = this.hubPoolClient.chainId; if (!this.isInventoryManagementEnabled()) { - return destinationChainId; + return [destinationChainId]; } // The InventoryClient assumes 1:1 equivalency between input and output tokens. At the moment there is no support @@ -469,6 +472,7 @@ export class InventoryClient { chainsToEvaluate.push(originChainId); } + const eligibleRefundChains: number[] = []; // At this point, all chains to evaluate have defined token configs and are sorted in order of // highest priority to take repayment on, assuming the chain is under-allocated. for (const _chain of chainsToEvaluate) { @@ -535,15 +539,16 @@ export class InventoryClient { } ); if (expectedPostRelayAllocation.lte(thresholdPct)) { - return _chain; + eligibleRefundChains.push(_chain); } } - // None of the chain allocation percentages are lower than their target so take - // repayment on the hub chain by default. The caller has also set a token config so they are not expecting - // repayments to default to destination chain. If caller wanted repayments to default to destination - // chain, then they should not set a token config. - return hubChainId; + // Always add hubChain as a fallback option if inventory management is enabled. If none of the chainsToEvaluate + // were selected, then this function will return just the hub chain as a fallback option. + if (!eligibleRefundChains.includes(hubChainId)) { + eligibleRefundChains.push(hubChainId); + } + return eligibleRefundChains; } /** diff --git a/src/clients/ProfitClient.ts b/src/clients/ProfitClient.ts index 7ed094444..c53e904fd 100644 --- a/src/clients/ProfitClient.ts +++ b/src/clients/ProfitClient.ts @@ -431,17 +431,22 @@ export class ProfitClient { return fillAmount.mul(tokenPriceInUsd).div(bn10.pow(l1TokenInfo.decimals)); } - async getFillProfitability(deposit: V3Deposit, lpFeePct: BigNumber, l1Token: L1Token): Promise { + async getFillProfitability( + deposit: V3Deposit, + lpFeePct: BigNumber, + l1Token: L1Token, + repaymentChainId: number + ): Promise { const minRelayerFeePct = this.minRelayerFeePct(l1Token.symbol, deposit.originChainId, deposit.destinationChainId); const fill = await this.calculateFillProfitability(deposit, lpFeePct, minRelayerFeePct); if (!fill.profitable || this.debugProfitability) { - const { depositId, originChainId } = deposit; + const { depositId } = deposit; const profitable = fill.profitable ? "profitable" : "unprofitable"; this.logger.debug({ at: "ProfitClient#getFillProfitability", - message: `${l1Token.symbol} v3 deposit ${depositId} on chain ${originChainId} is ${profitable}`, + message: `${l1Token.symbol} v3 deposit ${depositId} with repayment on ${repaymentChainId} is ${profitable}`, deposit, inputTokenPriceUsd: formatEther(fill.inputTokenPriceUsd), inputTokenAmountUsd: formatEther(fill.inputAmountUsd), @@ -470,17 +475,19 @@ export class ProfitClient { async isFillProfitable( deposit: V3Deposit, lpFeePct: BigNumber, - l1Token: L1Token - ): Promise> { + l1Token: L1Token, + repaymentChainId: number + ): Promise> { let profitable = false; - let grossRelayerFeePct = bnZero; + let netRelayerFeePct = bnZero; let nativeGasCost = uint256Max; let tokenGasCost = uint256Max; try { - ({ profitable, grossRelayerFeePct, nativeGasCost, tokenGasCost } = await this.getFillProfitability( + ({ profitable, netRelayerFeePct, nativeGasCost, tokenGasCost } = await this.getFillProfitability( deposit, lpFeePct, - l1Token + l1Token, + repaymentChainId )); } catch (err) { this.logger.debug({ @@ -495,7 +502,7 @@ export class ProfitClient { profitable: profitable || (this.isTestnet && nativeGasCost.lt(uint256Max)), nativeGasCost, tokenGasCost, - grossRelayerFeePct, + netRelayerFeePct, }; } diff --git a/src/relayer/Relayer.ts b/src/relayer/Relayer.ts index d12326b90..7a73c6a16 100644 --- a/src/relayer/Relayer.ts +++ b/src/relayer/Relayer.ts @@ -26,6 +26,12 @@ const UNPROFITABLE_DEPOSIT_NOTICE_PERIOD = 60 * 60; // 1 hour type RepaymentFee = { paymentChainId: number; lpFeePct: BigNumber }; type BatchLPFees = { [depositKey: string]: RepaymentFee[] }; +type RepaymentChainProfitability = { + gasLimit: BigNumber; + gasCost: BigNumber; + relayerFeePct: BigNumber; + lpFeePct: BigNumber; +}; export class Relayer { public readonly relayerAddress: string; @@ -330,13 +336,12 @@ export class Relayer { const l1Token = hubPoolClient.getL1TokenInfoForL2Token(inputToken, originChainId); const selfRelay = [depositor, recipient].every((address) => address === this.relayerAddress); if (tokenClient.hasBalanceForFill(deposit) && !selfRelay) { - const { - repaymentChainId, - realizedLpFeePct, - relayerFeePct, - gasLimit: _gasLimit, - gasCost, - } = await this.resolveRepaymentChain(deposit, l1Token, lpFees); + const { repaymentChainId, repaymentChainProfitability } = await this.resolveRepaymentChain( + deposit, + l1Token, + lpFees + ); + const { relayerFeePct, gasCost, gasLimit: _gasLimit, lpFeePct: realizedLpFeePct } = repaymentChainProfitability; if (isDefined(repaymentChainId)) { const gasLimit = isMessageEmpty(resolveDepositMessage(deposit)) ? undefined : _gasLimit; this.fillRelay(deposit, repaymentChainId, realizedLpFeePct, gasLimit); @@ -545,7 +550,7 @@ export class Relayer { const { spokePoolClients, multiCallerClient } = this.clients; this.logger.debug({ at: "Relayer::fillRelay", - message: "Filling v3 deposit.", + message: `Filling v3 deposit ${deposit.depositId} with repayment on ${repaymentChainId}.`, deposit, repaymentChainId, realizedLpFeePct, @@ -573,16 +578,23 @@ export class Relayer { multiCallerClient.enqueueTransaction({ contract, chainId, method, args, gasLimit, message, mrkdwn }); } + /** + * @notice Returns repayment chain choice for deposit given repayment fees and the hubPoolToken associated with the + * deposit inputToken. + * @param deposit + * @param hubPoolToken L1 token object associated with the deposit inputToken. + * @param repaymentFees + * @returns repaymentChainId is defined if and only if a profitable repayment chain is found. + * @returns repaymentChainProfitability contains the profitability data of the repaymentChainId if it is defined + * or the profitability data of the most preferred repayment chain otherwise. + */ protected async resolveRepaymentChain( deposit: V3DepositWithBlock, hubPoolToken: L1Token, repaymentFees: RepaymentFee[] ): Promise<{ - gasLimit: BigNumber; repaymentChainId?: number; - realizedLpFeePct: BigNumber; - relayerFeePct: BigNumber; - gasCost: BigNumber; + repaymentChainProfitability: RepaymentChainProfitability; }> { const { inventoryClient, profitClient } = this.clients; const { depositId, originChainId, destinationChainId, inputAmount, outputAmount, transactionHash } = deposit; @@ -590,54 +602,128 @@ export class Relayer { const destinationChain = getNetworkName(destinationChainId); const start = performance.now(); - const preferredChainId = await inventoryClient.determineRefundChainId(deposit, hubPoolToken.address); + const preferredChainIds = await inventoryClient.determineRefundChainId(deposit, hubPoolToken.address); + assert(preferredChainIds.length > 0, `No preferred repayment chains found for deposit ${depositId}.`); this.logger.debug({ at: "Relayer::resolveRepaymentChain", - message: `Determined preferred repayment chain ${preferredChainId} for deposit from ${originChain} to ${destinationChain} in ${ + message: `Determined eligible repayment chains ${JSON.stringify( + preferredChainIds + )} for deposit ${depositId} from ${originChain} to ${destinationChain} in ${ Math.round(performance.now() - start) / 1000 }s.`, }); - const repaymentFee = repaymentFees?.find(({ paymentChainId }) => paymentChainId === preferredChainId); - assert(isDefined(repaymentFee)); - const { lpFeePct } = repaymentFee; - - const { - profitable, - nativeGasCost: gasLimit, - tokenGasCost: gasCost, - grossRelayerFeePct: relayerFeePct, // gross relayer fee is equal to total fee minus the lp fee. - } = await profitClient.isFillProfitable(deposit, lpFeePct, hubPoolToken); - // If preferred chain is different from the destination chain and the preferred chain - // is not profitable, then check if the destination chain is profitable. + const _repaymentFees = preferredChainIds.map((chainId) => + repaymentFees.find(({ paymentChainId }) => paymentChainId === chainId) + ); + const lpFeePcts = _repaymentFees.map(({ lpFeePct }) => lpFeePct); + + // For each eligible repayment chain, compute profitability and pick the one that is profitable. If none are + // profitable, then finally check the destination chain even if its not a preferred repayment chain. The idea + // here is that depositors are receiving quoted lp fees from the API that assumes repayment on the destination + // chain, so we should honor all repayments on the destination chain if it's profitable, even if it doesn't + // fit within our inventory management. + + const getRepaymentChainProfitability = async ( + preferredChainId: number, + lpFeePct: BigNumber + ): Promise<{ profitable: boolean; gasLimit: BigNumber; gasCost: BigNumber; relayerFeePct: BigNumber }> => { + const { + profitable, + nativeGasCost: gasLimit, + tokenGasCost: gasCost, + netRelayerFeePct: relayerFeePct, // net relayer fee is equal to total fee minus the lp fee. + } = await profitClient.isFillProfitable(deposit, lpFeePct, hubPoolToken, preferredChainId); + return { + profitable, + gasLimit, + gasCost, + relayerFeePct, + }; + }; + + const repaymentChainProfitabilities = await Promise.all( + preferredChainIds.map(async (preferredChainId, i) => { + const lpFeePct = lpFeePcts[i]; + assert(isDefined(lpFeePct), `Missing lp fee pct for chain potential repayment chain ${preferredChainId}`); + return getRepaymentChainProfitability(preferredChainId, lpFeePcts[i]); + }) + ); + const profitableRepaymentChainIds = preferredChainIds.filter((_, i) => repaymentChainProfitabilities[i].profitable); + + // @dev preferredChainId will not be defined until a chain is found to be profitable. + let preferredChain: number | undefined = undefined; + + // @dev The following internal function should be the only one used to set `preferredChain` above. + const getProfitabilityDataForPreferredChainIndex = (preferredChainIndex: number): RepaymentChainProfitability => { + const lpFeePct = lpFeePcts[preferredChainIndex]; + const { gasLimit, gasCost, relayerFeePct } = repaymentChainProfitabilities[preferredChainIndex]; + return { + gasLimit, + gasCost, + relayerFeePct, + lpFeePct, + }; + }; + let profitabilityData: RepaymentChainProfitability = getProfitabilityDataForPreferredChainIndex(0); + + // If there are any profitable repayment chains, then set preferred chain to the first one since the preferred + // chains are given to us by the InventoryClient sorted in priority order. + + if (profitableRepaymentChainIds.length > 0) { + preferredChain = profitableRepaymentChainIds[0]; + const preferredChainIndex = preferredChainIds.indexOf(preferredChain); + profitabilityData = getProfitabilityDataForPreferredChainIndex(preferredChainIndex); + this.logger.debug({ + at: "Relayer::resolveRepaymentChain", + message: `Selected preferred repayment chain ${preferredChain} for deposit ${depositId}, #${ + preferredChainIndex + 1 + } in eligible chains ${JSON.stringify(preferredChainIds)} list.`, + profitableRepaymentChainIds, + }); + } + + // If none of the preferred chains are profitable and they also don't include the destination chain, + // then check if the destination chain is profitable. // This assumes that the depositor is getting quotes from the /suggested-fees endpoint // in the frontend-v2 repo which assumes that repayment is the destination chain. If this is profitable, then // go ahead and use the preferred chain as repayment and log the lp fee delta. This is a temporary solution // so that depositors can continue to quote lp fees assuming repayment is on the destination chain until - // we come up with a smarter profitability check. - if (!profitable && preferredChainId !== destinationChainId) { + // we come up with a smarter fee quoting algorithm that takes into account relayer inventory management more + // accurately. + if (!isDefined(preferredChain) && !preferredChainIds.includes(destinationChainId)) { this.logger.debug({ at: "Relayer::resolveRepaymentChain", - message: `Preferred chain ${preferredChainId} is not profitable. Checking destination chain ${destinationChainId} profitability.`, + message: `Preferred chains ${JSON.stringify( + preferredChainIds + )} are not profitable. Checking destination chain ${destinationChainId} profitability.`, deposit: { originChain, depositId, destinationChain, transactionHash }, }); + // Evaluate destination chain profitability to see if we can reset preferred chain. const { lpFeePct: destinationChainLpFeePct } = repaymentFees.find( ({ paymentChainId }) => paymentChainId === destinationChainId ); - assert(isDefined(lpFeePct)); - + assert(isDefined(destinationChainLpFeePct)); const fallbackProfitability = await profitClient.isFillProfitable( deposit, destinationChainLpFeePct, - hubPoolToken + hubPoolToken, + destinationChainId ); + + // If destination chain is profitable, then use the top preferred chain as a favor to the depositor + // but log that we might be taking a loss. This is to not penalize an honest depositor who set their + // fees according to the API that assumes destination chain repayment. if (fallbackProfitability.profitable) { + preferredChain = preferredChainIds[0]; + const deltaRelayerFee = profitabilityData.relayerFeePct.sub(fallbackProfitability.netRelayerFeePct); // This is the delta in the gross relayer fee. If negative, then the destination chain would have had a higher // gross relayer fee, and therefore represents a virtual loss to the relayer. However, the relayer is // maintaining its inventory allocation by sticking to its preferred repayment chain. - const deltaRelayerFee = relayerFeePct.sub(fallbackProfitability.grossRelayerFeePct); this.logger[this.config.sendingRelaysEnabled ? "info" : "debug"]({ at: "Relayer::resolveRepaymentChain", - message: `🦦 Taking repayment for filling deposit ${depositId} on preferred chain ${preferredChainId} is unprofitable but taking repayment on destination chain ${destinationChainId} is profitable. Electing to take repayment on preferred chain as favor to depositor who assumed repayment on destination chain in their quote. Delta in gross relayer fee: ${formatFeePct( + message: `🦦 Taking repayment for filling deposit ${depositId} on preferred chains ${JSON.stringify( + preferredChainIds + )} is unprofitable but taking repayment on destination chain ${destinationChainId} is profitable. Electing to take repayment on top preferred chain ${preferredChain} as favor to depositor who assumed repayment on destination chain in their quote. Delta in net relayer fee: ${formatFeePct( deltaRelayerFee )}%`, deposit: { @@ -646,33 +732,24 @@ export class Relayer { token: hubPoolToken.symbol, txnHash: blockExplorerLink(transactionHash, originChainId), }, - preferredChain: getNetworkName(preferredChainId), - preferredChainLpFeePct: `${formatFeePct(lpFeePct)}%`, + preferredChain: getNetworkName(preferredChain), + preferredChainLpFeePct: `${formatFeePct(profitabilityData.lpFeePct)}%`, destinationChainLpFeePct: `${formatFeePct(destinationChainLpFeePct)}%`, // The delta will cut into the gross relayer fee. If negative, then taking the repayment on destination chain // would have been more profitable to the relayer because the lp fee would have been lower. - deltaLpFeePct: `${formatFeePct(destinationChainLpFeePct.sub(lpFeePct))}%`, + deltaLpFeePct: `${formatFeePct(destinationChainLpFeePct.sub(profitabilityData.lpFeePct))}%`, // relayer fee is the gross relayer fee using the destination chain lp fee: inputAmount - outputAmount - lpFee. - preferredChainRelayerFeePct: `${formatFeePct(relayerFeePct)}%`, - destinationChainRelayerFeePct: `${formatFeePct(fallbackProfitability.grossRelayerFeePct)}%`, + preferredChainRelayerFeePct: `${formatFeePct(profitabilityData.relayerFeePct)}%`, + destinationChainRelayerFeePct: `${formatFeePct(fallbackProfitability.netRelayerFeePct)}%`, deltaRelayerFee: `${formatFeePct(deltaRelayerFee)}%`, }); - - // We've checked that the user set the output amount honestly and assumed that the payment would be on - // destination chain, therefore we will fill them using the original preferred chain to maintain - // inventory assumptions and also quote the original relayer fee pct. - return { - repaymentChainId: preferredChainId, - realizedLpFeePct: lpFeePct, - relayerFeePct, - gasCost, - gasLimit, - }; } else { // If preferred chain is not profitable and neither is fallback, then return the original profitability result. this.logger.debug({ at: "Relayer::resolveRepaymentChain", - message: `Taking repayment on destination chain ${destinationChainId} would also not be profitable.`, + message: `Taking repayment for deposit ${depositId} with preferred chains ${JSON.stringify( + preferredChainIds + )} on destination chain ${destinationChainId} would also not be profitable.`, deposit: { originChain, depositId, @@ -682,37 +759,18 @@ export class Relayer { inputAmount, outputAmount, }, - preferredChain: getNetworkName(preferredChainId), - preferredChainLpFeePct: `${formatFeePct(lpFeePct)}%`, + preferredChain: getNetworkName(preferredChainIds[0]), + preferredChainLpFeePct: `${formatFeePct(profitabilityData.lpFeePct)}%`, destinationChainLpFeePct: `${formatFeePct(destinationChainLpFeePct)}%`, - preferredChainRelayerFeePct: `${formatFeePct(relayerFeePct)}%`, - destinationChainRelayerFeePct: `${formatFeePct(fallbackProfitability.grossRelayerFeePct)}%`, + preferredChainRelayerFeePct: `${formatFeePct(profitabilityData.relayerFeePct)}%`, + destinationChainRelayerFeePct: `${formatFeePct(fallbackProfitability.netRelayerFeePct)}%`, }); } } - this.logger.debug({ - at: "Relayer::resolveRepaymentChain", - message: `Preferred chain ${preferredChainId} is${profitable ? "" : " not"} profitable.`, - deposit: { - originChain, - depositId, - destinationChain, - transactionHash, - token: hubPoolToken.symbol, - inputAmount, - outputAmount, - }, - preferredChainLpFeePct: `${formatFeePct(lpFeePct)}%`, - preferredChainRelayerFeePct: `${formatFeePct(relayerFeePct)}%`, - }); - return { - repaymentChainId: profitable ? preferredChainId : undefined, - realizedLpFeePct: lpFeePct, - relayerFeePct, - gasCost, - gasLimit, + repaymentChainProfitability: profitabilityData, + repaymentChainId: preferredChain, }; } diff --git a/test/InventoryClient.RefundChain.ts b/test/InventoryClient.RefundChain.ts index cf6b3000a..36534c0b0 100644 --- a/test/InventoryClient.RefundChain.ts +++ b/test/InventoryClient.RefundChain.ts @@ -13,6 +13,7 @@ import { toBNWei, toWei, winston, + spyLogIncludes, } from "./utils"; import { ConfigStoreClient, InventoryClient } from "../src/clients"; // Tested @@ -160,7 +161,7 @@ describe("InventoryClient: Refund chain selection", async function () { // above the threshold of 12 and so the bot should choose to be refunded on L1. sampleDepositData.inputAmount = toWei(1); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(MAINNET); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"136690647482014388"')).to.be.true; // (20-1)/(140-1)=0.136 // Now consider a case where the relayer is filling a marginally larger relay of size 5 WETH. Now the post relay @@ -168,7 +169,7 @@ describe("InventoryClient: Refund chain selection", async function () { // choose to refund on the L2. sampleDepositData.inputAmount = toWei(5); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(OPTIMISM); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([OPTIMISM, MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"111111111111111111"')).to.be.true; // (20-5)/(140-5)=0.11 // Now consider a bigger relay that should force refunds on the L2 chain. Set the relay size to 10 WETH. now post @@ -176,7 +177,7 @@ describe("InventoryClient: Refund chain selection", async function () { // set the refund on L2. sampleDepositData.inputAmount = toWei(10); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(OPTIMISM); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([OPTIMISM, MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"76923076923076923"')).to.be.true; // (20-10)/(140-10)=0.076 }); @@ -215,7 +216,7 @@ describe("InventoryClient: Refund chain selection", async function () { sampleDepositData.outputToken = l2TokensForWeth[ARBITRUM]; sampleDepositData.inputAmount = toWei(1.69); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(ARBITRUM); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([ARBITRUM, MAINNET]); expect(lastSpyLogIncludes(spy, 'chainShortfall":"15000000000000000000"')).to.be.true; expect(lastSpyLogIncludes(spy, 'chainVirtualBalance":"24800000000000000000"')).to.be.true; // (10+14.8)=24.8 @@ -233,7 +234,7 @@ describe("InventoryClient: Refund chain selection", async function () { // relay allocation is 4.8/120 = 0.04. This is below the threshold of 0.05 so the bot should refund on the target. sampleDepositData.inputAmount = toWei(5); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(ARBITRUM); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([ARBITRUM, MAINNET]); // Check only the final step in the computation. expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"40000000000000000"')).to.be.true; // 4.8/120 = 0.04 @@ -248,7 +249,7 @@ describe("InventoryClient: Refund chain selection", async function () { l2TokensForWeth[ARBITRUM], initialAllocation[ARBITRUM][mainnetWeth].add(toWei(10)) ); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(MAINNET); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([MAINNET]); }); it("Correctly decides where to refund based on upcoming refunds", async function () { @@ -270,21 +271,22 @@ describe("InventoryClient: Refund chain selection", async function () { // L1 token and destination chain ID, otherwise it won't be counted in upcoming // refunds. hubPoolClient.setEnableAllL2Tokens(true); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(MAINNET); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"166666666666666666"')).to.be.true; // (20-5)/(140-5)=0.11 // If we set this to false in this test, the destination chain will be default used since the refund data // will be ignored. hubPoolClient.setEnableAllL2Tokens(false); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(OPTIMISM); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([OPTIMISM, MAINNET]); }); it("Correctly throws when Deposit tokens are not equivalent", async function () { sampleDepositData.inputAmount = toWei(5); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal( - sampleDepositData.destinationChainId - ); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([ + sampleDepositData.destinationChainId, + 1, + ]); sampleDepositData.outputToken = ZERO_ADDRESS; const srcChain = getNetworkName(sampleDepositData.originChainId); @@ -313,9 +315,9 @@ describe("InventoryClient: Refund chain selection", async function () { false, // simMode false // prioritizeUtilization ); - expect(await _inventoryClient.determineRefundChainId(sampleDepositData)).to.equal( - sampleDepositData.destinationChainId - ); + expect(await _inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([ + sampleDepositData.destinationChainId, + ]); }); it("includes origin, destination in repayment chain list", async function () { const possibleRepaymentChains = inventoryClient.getPossibleRepaymentChainIds(sampleDepositData); @@ -356,8 +358,12 @@ describe("InventoryClient: Refund chain selection", async function () { // Relayer should choose to refund on destination over origin if both are under allocated sampleDepositData.inputAmount = toWei(5); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(OPTIMISM); - expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"111940298507462686"')).to.be.true; + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([ + OPTIMISM, + POLYGON, + MAINNET, + ]); + expect(spyLogIncludes(spy, -2, 'expectedPostRelayAllocation":"111940298507462686"')).to.be.true; }); it("Origin chain allocation does not depend on subtracting from numerator", async function () { // Post relay allocation does not subtract anything from chain virtual balance, unlike @@ -374,7 +380,7 @@ describe("InventoryClient: Refund chain selection", async function () { // Relayer should default to hub chain. sampleDepositData.inputAmount = toWei(10); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(MAINNET); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"71428571428571428"')).to.be.true; }); it("Origin allocation is below target", async function () { @@ -389,7 +395,7 @@ describe("InventoryClient: Refund chain selection", async function () { // Relayer should choose to refund origin since destination isn't an option. sampleDepositData.inputAmount = toWei(5); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(POLYGON); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([POLYGON, MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"35714285714285714"')).to.be.true; }); it("Origin allocation depends on outstanding transfers", async function () { @@ -404,7 +410,7 @@ describe("InventoryClient: Refund chain selection", async function () { // Relayer should choose to refund origin since destination isn't an option. sampleDepositData.inputAmount = toWei(5); sampleDepositData.outputAmount = await computeOutputAmount(sampleDepositData); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(POLYGON); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([POLYGON, MAINNET]); // Now add outstanding transfers to Polygon that make the allocation above the target. Note that this // increases cumulative balance a bit. @@ -415,7 +421,7 @@ describe("InventoryClient: Refund chain selection", async function () { // Optimism (destination chain): (30-5)/(160-5)=16.1% > 12% // Polygon (origin chain): (15)/(160-5)=9.6% > 7% // Relayer should now default to hub chain. - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(MAINNET); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"100000000000000000"')).to.be.true; }); it("Origin allocation depends on short falls", async function () { @@ -430,7 +436,7 @@ describe("InventoryClient: Refund chain selection", async function () { // Optimism (destination chain): (25-5)/(145-5)=14.3% > 12% // Polygon (origin chain): (0)/(145-5)=0% < 7% // Relayer should still use origin chain - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(POLYGON); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([POLYGON, MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"0"')).to.be.true; // (20-5)/(140-5)=0.11 }); it("Origin allocation depends on upcoming refunds", async function () { @@ -458,7 +464,7 @@ describe("InventoryClient: Refund chain selection", async function () { // Optimism (destination chain): (30-5)/(155-5)=16.7% > 12% // Polygon (origin chain): (10)/(155-5)=6.7% > 7% // Relayer should still pick origin chain but compute a different allocation. - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(POLYGON); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([POLYGON, MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"68965517241379310"')).to.be.true; }); it("includes origin, destination and hub chain in repayment chain list", async function () { @@ -519,13 +525,22 @@ describe("InventoryClient: Refund chain selection", async function () { it("selects slow withdrawal chain with excess running balance and under relayer allocation", async function () { // Initial allocations are all under allocated so the first slow withdrawal chain should be selected since it has // the highest overage. - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(ARBITRUM); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([ + ARBITRUM, + OPTIMISM, + POLYGON, + MAINNET, + ]); // If we instead drop the excess on Arbitrum to 0, then we should take repayment on // the next slow withdrawal chain. excessRunningBalances[ARBITRUM] = toWei("0"); (inventoryClient as MockInventoryClient).setExcessRunningBalances(mainnetWeth, excessRunningBalances); - expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.equal(OPTIMISM); + expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([ + OPTIMISM, + POLYGON, + MAINNET, + ]); }); it("includes slow withdrawal chains in possible repayment chain list", async function () { const possibleRepaymentChains = inventoryClient.getPossibleRepaymentChainIds(sampleDepositData); @@ -593,25 +608,31 @@ describe("InventoryClient: Refund chain selection", async function () { .forEach((chainId) => expect(tokenClient.getBalance(chainId, nativeUSDC[chainId]).eq(bnZero)).to.be.true); // All chains are at target balance; cumulative balance will go down but repaymentToken balances on all chains are unaffected. - expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.equal(MAINNET); + expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.deep.equal([MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"71942446043165467"')).to.be.true; // (10000-0)/(14000-100)=0.71942 // Even when the output amount is equal to the destination's entire balance, take repayment on mainnet. sampleDepositData.outputAmount = inventoryClient.getBalanceOnChain(OPTIMISM, mainnetUsdc); - expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.equal(MAINNET); + expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.deep.equal([MAINNET]); expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"83333333333333333"')).to.be.true; // (10000-0)/(14000-2000)=0.8333 // Drop the relayer's repaymentToken balance on Optimism. Repayment chain should now be Optimism. let balance = tokenClient.getBalance(OPTIMISM, bridgedUSDC[OPTIMISM]); tokenClient.setTokenData(OPTIMISM, bridgedUSDC[OPTIMISM], bnZero); - expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.equal(OPTIMISM); + expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.deep.equal([ + OPTIMISM, + MAINNET, + ]); // Restore the Optimism balance and drop the Arbitrum balance. Repayment chain should now be Arbitrum. tokenClient.setTokenData(OPTIMISM, bridgedUSDC[OPTIMISM], balance); balance = tokenClient.getBalance(ARBITRUM, bridgedUSDC[ARBITRUM]); tokenClient.setTokenData(ARBITRUM, bridgedUSDC[ARBITRUM], bnZero); - expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.equal(ARBITRUM); + expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.deep.equal([ + ARBITRUM, + MAINNET, + ]); }); }); }); diff --git a/test/ProfitClient.ConsiderProfitability.ts b/test/ProfitClient.ConsiderProfitability.ts index 5cb4f4423..b12b33480 100644 --- a/test/ProfitClient.ConsiderProfitability.ts +++ b/test/ProfitClient.ConsiderProfitability.ts @@ -359,7 +359,7 @@ describe("ProfitClient: Consider relay profit", () => { netRelayerFeeUsd: formatEther(expected.netRelayerFeeUsd), }); - const { profitable } = await profitClient.isFillProfitable(deposit, lpFeePct, token); + const { profitable } = await profitClient.isFillProfitable(deposit, lpFeePct, token, destinationChainId); expect(profitable).to.equal(expected.profitable); } } @@ -429,7 +429,12 @@ describe("ProfitClient: Consider relay profit", () => { netRelayerFeeUsd: formatEther(expected.netRelayerFeeUsd), }); - const { profitable } = await profitClient.isFillProfitable(deposit, effectiveLpFeePct, token); + const { profitable } = await profitClient.isFillProfitable( + deposit, + effectiveLpFeePct, + token, + destinationChainId + ); expect(profitable).to.equal(expected.profitable); } } diff --git a/test/mocks/MockInventoryClient.ts b/test/mocks/MockInventoryClient.ts index 74da30567..a2333df2a 100644 --- a/test/mocks/MockInventoryClient.ts +++ b/test/mocks/MockInventoryClient.ts @@ -33,8 +33,8 @@ export class MockInventoryClient extends InventoryClient { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - override async determineRefundChainId(_deposit: Deposit): Promise { - return this.inventoryConfig === null ? 1 : super.determineRefundChainId(_deposit); + override async determineRefundChainId(_deposit: Deposit): Promise { + return this.inventoryConfig === null ? [1] : super.determineRefundChainId(_deposit); } setExcessRunningBalances(l1Token: string, balances: { [chainId: number]: BigNumber }): void {