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 {