diff --git a/package.json b/package.json index 69188cf35..af7b63b13 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "@across-protocol/constants-v2": "1.0.14", "@across-protocol/contracts-v2": "2.5.3", - "@across-protocol/sdk-v2": "0.22.15", + "@across-protocol/sdk-v2": "0.22.16", "@arbitrum/sdk": "^3.1.3", "@consensys/linea-sdk": "^0.2.1", "@defi-wonderland/smock": "^2.3.5", diff --git a/src/clients/BalanceAllocator.ts b/src/clients/BalanceAllocator.ts index ac4bcf19c..d86bf1c27 100644 --- a/src/clients/BalanceAllocator.ts +++ b/src/clients/BalanceAllocator.ts @@ -114,10 +114,6 @@ export class BalanceAllocator { return this.balances[chainId][token][holder]; } - async getVirtualBalance(chainId: number, token: string, holder: string): Promise { - return (await this.getBalance(chainId, token, holder)).sub(this.getUsed(chainId, token, holder)); - } - getUsed(chainId: number, token: string, holder: string): BigNumber { if (!this.used?.[chainId]?.[token]?.[holder]) { // Note: cannot use assign because it breaks the BigNumber object. diff --git a/src/dataworker/Dataworker.ts b/src/dataworker/Dataworker.ts index ebe6b672a..6c1f9bf61 100644 --- a/src/dataworker/Dataworker.ts +++ b/src/dataworker/Dataworker.ts @@ -1452,23 +1452,6 @@ export class Dataworker { return; } - // Call `exchangeRateCurrent` on the HubPool before accumulating fees from the executed bundle leaves and before - // exiting early if challenge period isn't passed. This ensures that there is a maximum amount of time between - // exchangeRateCurrent calls and that these happen before pool leaves are executed. This is to - // address the situation where `addLiquidity` and `removeLiquidity` have not been called for an L1 token for a - // while, which are the other methods that trigger an internal call to `_exchangeRateCurrent()`. Calling - // this method triggers a recompounding of fees before new fees come in. - const l1TokensInBundle = expectedTrees.poolRebalanceTree.leaves.reduce((l1TokenSet, leaf) => { - const currLeafL1Tokens = leaf.l1Tokens; - currLeafL1Tokens.forEach((l1Token) => { - if (!l1TokenSet.includes(l1Token)) { - l1TokenSet.push(l1Token); - } - }); - return l1TokenSet; - }, []); - await this._updateExchangeRates(l1TokensInBundle, submitExecution); - // Exit early if challenge period timestamp has not passed: if (this.clients.hubPoolClient.currentTime <= pendingRootBundle.challengePeriodEndTimestamp) { this.logger.debug({ @@ -1492,11 +1475,28 @@ export class Dataworker { return; } + // There are three times that we should look to update the HubPool's liquid reserves: + // 1. First, before we attempt to execute the HubChain PoolRebalance leaves and RelayerRefund leaves. + // We should see if there are new liquid reserves we need to account for before sending out these + // netSendAmounts. + // 2. Second, before we attempt to execute the PoolRebalance leaves for the other chains. We should + // see if there are new liquid reserves we need to account for before sending out these netSendAmounts. This + // updated liquid reserves balance could be from previous finalizations or any amountToReturn value sent + // back from the Ethereum RelayerRefundLeaves. + // 3. Third, we haven't updated the exchange rate for an L1 token on a PoolRebalanceLeaf in a while that + // we're going to execute, so we should batch in an update. + let updatedLiquidReserves: Record = {}; + // First, execute mainnet pool rebalance leaves. Then try to execute any relayer refund and slow leaves for the // expected relayed root hash, then proceed with remaining pool rebalance leaves. This is an optimization that // takes advantage of the fact that mainnet transfers between HubPool and SpokePool are atomic. const mainnetLeaves = unexecutedLeaves.filter((leaf) => leaf.chainId === hubPoolChainId); if (mainnetLeaves.length > 0) { + assert(mainnetLeaves.length === 1); + updatedLiquidReserves = await this._updateExchangeRatesBeforeExecutingHubChainLeaves( + mainnetLeaves[0], + submitExecution + ); await this._executePoolRebalanceLeaves( spokePoolClients, mainnetLeaves, @@ -1531,61 +1531,36 @@ export class Dataworker { // Before executing the other pool rebalance leaves, see if we should update any exchange rates to account for // any tokens returned to the hub pool via the EthereumSpokePool that we'll need to use to execute - // any of the remaining pool rebalance leaves. + // any of the remaining pool rebalance leaves. This might include tokens we've already enqueued to update + // in the previous step, but this captures any tokens that are sent back from the Ethereum_SpokePool to the + // HubPool that we want to capture an increased liquidReserves for. const nonHubChainPoolRebalanceLeaves = unexecutedLeaves.filter((leaf) => leaf.chainId !== hubPoolChainId); - await sdkUtils.forEachAsync(nonHubChainPoolRebalanceLeaves, async (leaf) => { - await sdkUtils.forEachAsync(leaf.l1Tokens, async (l1Token, idx) => { - // If leaf's netSendAmount is negative, then we don't need to updateExchangeRates since the Hub will not - // have a liquidity constraint because it won't be sending any tokens. - if (leaf.netSendAmounts[idx].lte(0)) { - return; - } - // The virtual hubPoolBalance kept in the BalanceAllocator should have adjusted for the netSendAmounts and relayer refund leaf - // executions above. Therefore, check if the current hubPoolBalance is less than the pool rebalance leaf's netSendAmount - // and the virtual hubPoolBalance would be enough to execute it. If so, then add an update exchange rate call to make sure that - // the HubPool becomes "aware" of its inflow following the relayre refund leaf execution. - const currHubPoolBalance = await balanceAllocator.getBalance( - hubPoolChainId, - l1Token, - this.clients.hubPoolClient.hubPool.address - ); - // We only need to update the exchange rate in the case where tokens are returned to the HubPool increasing - // its balance enough that it can execute a pool rebalance leaf it otherwise would not be able to. - // This would only happen if the starting hub pool balance is below the net send amount. If it started - // above, then the dataworker would not purposefully send tokens out of it to fulfill the Ethereum - // PoolRebalanceLeaf and then return tokens to it to execute another chain's PoolRebalanceLeaf. - if (currHubPoolBalance.lt(leaf.netSendAmounts[idx])) { - // @dev: Virtual balance = current balance + any used balance. - const virtualHubPoolBalance = await balanceAllocator.getVirtualBalance( - hubPoolChainId, - l1Token, - this.clients.hubPoolClient.hubPool.address - ); - if (virtualHubPoolBalance.gte(leaf.netSendAmounts[idx])) { - const tokenSymbol = this.clients.hubPoolClient.getTokenInfo(hubPoolChainId, l1Token)?.symbol; - this.logger.debug({ - at: "Dataworker#executePoolRebalanceLeaves", - message: `Relayer refund leaf will return enough funds to HubPool to execute PoolRebalanceLeaf, updating exchange rate for ${tokenSymbol}`, - currHubPoolBalance, - virtualHubPoolBalance, - netSendAmount: leaf.netSendAmounts[idx], - leaf, - }); - if (submitExecution) { - this.clients.multiCallerClient.enqueueTransaction({ - contract: this.clients.hubPoolClient.hubPool, - chainId: hubPoolChainId, - method: "exchangeRateCurrent", - args: [l1Token], - message: "Updated exchange rate ♻️!", - mrkdwn: `Updated exchange rate for l1 token: ${tokenSymbol}`, - unpermissioned: true, - }); - } - } + if (nonHubChainPoolRebalanceLeaves.length === 0) { + return; + } + const updatedL1Tokens = await this._updateExchangeRatesBeforeExecutingNonHubChainLeaves( + updatedLiquidReserves, + balanceAllocator, + nonHubChainPoolRebalanceLeaves, + submitExecution + ); + Object.keys(updatedLiquidReserves).forEach((token) => { + if (!updatedL1Tokens.has(token)) { + updatedL1Tokens.add(token); + } + }); + + // Save all L1 tokens that we haven't updated exchange rates for in a different step. + const l1TokensWithPotentiallyOlderUpdate = expectedTrees.poolRebalanceTree.leaves.reduce((l1TokenSet, leaf) => { + const currLeafL1Tokens = leaf.l1Tokens; + currLeafL1Tokens.forEach((l1Token) => { + if (!l1TokenSet[l1Token] && !updatedL1Tokens.has(l1Token)) { + l1TokenSet.push(l1Token); } }); - }); + return l1TokenSet; + }, []); + await this._updateOldExchangeRates(l1TokensWithPotentiallyOlderUpdate, submitExecution); // Perform similar funding checks for remaining non-mainnet pool rebalance leaves. await this._executePoolRebalanceLeaves( @@ -1720,31 +1695,201 @@ export class Dataworker { }); } - async _updateExchangeRates(l1Tokens: string[], submitExecution: boolean): Promise { - const syncedL1Tokens: string[] = []; + async _updateExchangeRatesBeforeExecutingHubChainLeaves( + poolRebalanceLeaf: Pick, + submitExecution: boolean + ): Promise> { + const hubPool = this.clients.hubPoolClient.hubPool; + const chainId = this.clients.hubPoolClient.chainId; + + const updatedL1Tokens: Record = {}; + const { netSendAmounts, l1Tokens } = poolRebalanceLeaf; + await sdk.utils.forEachAsync(l1Tokens, async (l1Token, idx) => { + const tokenSymbol = this.clients.hubPoolClient.getTokenInfo(chainId, l1Token)?.symbol; + + // If netSendAmounts is negative, there is no need to update this exchange rate. + if (netSendAmounts[idx].lte(0)) { + return; + } + + const multicallInput = [ + hubPool.interface.encodeFunctionData("pooledTokens", [l1Token]), + hubPool.interface.encodeFunctionData("sync", [l1Token]), + hubPool.interface.encodeFunctionData("pooledTokens", [l1Token]), + ]; + const multicallOutput = await hubPool.callStatic.multicall(multicallInput); + const currentPooledTokens = hubPool.interface.decodeFunctionResult("pooledTokens", multicallOutput[0]); + const updatedPooledTokens = hubPool.interface.decodeFunctionResult("pooledTokens", multicallOutput[2]); + const currentLiquidReserves = currentPooledTokens.liquidReserves; + const updatedLiquidReserves = updatedPooledTokens.liquidReserves; + + // If current liquid reserves can cover the netSendAmount, then there is no need to update the exchange rate. + if (currentLiquidReserves.gte(netSendAmounts[idx])) { + this.logger.debug({ + at: "Dataworker#_updateExchangeRatesBeforeExecutingHubChainLeaves", + message: `Skipping exchange rate update for ${tokenSymbol} because current liquid reserves > netSendAmount`, + currentLiquidReserves, + netSendAmount: netSendAmounts[idx], + l1Token, + }); + return; + } + + // If updated liquid reserves are not enough to cover the payment, then send a warning that + // we're short on funds. + if (updatedLiquidReserves.lt(netSendAmounts[idx])) { + this.logger.error({ + at: "Dataworker#_updateExchangeRatesBeforeExecutingHubChainLeaves", + message: `Not enough funds to execute pool rebalance leaf on HubPool for token: ${tokenSymbol}`, + poolRebalanceLeaf, + netSendAmount: netSendAmounts[idx], + currentPooledTokens, + updatedPooledTokens, + }); + return; + } + + this.logger.debug({ + at: "Dataworker#_updateExchangeRatesBeforeExecutingHubChainLeaves", + message: `Updating exchange rate update for ${tokenSymbol} because we need to update the liquid reserves of the contract to execute the poolRebalanceLeaf.`, + poolRebalanceLeaf, + netSendAmount: netSendAmounts[idx], + currentPooledTokens, + updatedPooledTokens, + }); + updatedL1Tokens[l1Token] = updatedPooledTokens.liquidReserves; + if (submitExecution) { + this.clients.multiCallerClient.enqueueTransaction({ + contract: hubPool, + chainId, + method: "exchangeRateCurrent", + args: [l1Token], + message: "Updated exchange rate ♻️!", + mrkdwn: `Updated exchange rate for l1 token: ${tokenSymbol}`, + unpermissioned: true, + }); + } + }); + return updatedL1Tokens; + } + + async _updateExchangeRatesBeforeExecutingNonHubChainLeaves( + latestLiquidReserves: Record, + balanceAllocator: BalanceAllocator, + poolRebalanceLeaves: Pick[], + submitExecution: boolean + ): Promise> { + const updatedL1Tokens = new Set(); + const hubPool = this.clients.hubPoolClient.hubPool; + const hubPoolChainId = this.clients.hubPoolClient.chainId; + + await sdkUtils.forEachAsync(poolRebalanceLeaves, async (leaf) => { + await sdkUtils.forEachAsync(leaf.l1Tokens, async (l1Token, idx) => { + const tokenSymbol = this.clients.hubPoolClient.getTokenInfo(hubPoolChainId, l1Token)?.symbol; + + if (updatedL1Tokens.has(l1Token)) { + return; + } + // If leaf's netSendAmount is negative, then we don't need to updateExchangeRates since the Hub will not + // have a liquidity constraint because it won't be sending any tokens. + if (leaf.netSendAmounts[idx].lte(0)) { + return; + } + // The "used" balance kept in the BalanceAllocator should have adjusted for the netSendAmounts and relayer refund leaf + // executions above. Therefore, check if the current liquidReserves is less than the pool rebalance leaf's netSendAmount + // and the virtual hubPoolBalance would be enough to execute it. If so, then add an update exchange rate call to make sure that + // the HubPool becomes "aware" of its inflow following the relayre refund leaf execution. + let currHubPoolLiquidReserves = latestLiquidReserves[l1Token]; + if (!currHubPoolLiquidReserves) { + // @dev If there aren't liquid reserves for this token then set them to max value so we won't update them. + currHubPoolLiquidReserves = this.clients.hubPoolClient.getLpTokenInfoForL1Token(l1Token).liquidReserves; + } + assert(currHubPoolLiquidReserves !== undefined); + // We only need to update the exchange rate in the case where tokens are returned to the HubPool increasing + // its balance enough that it can execute a pool rebalance leaf it otherwise would not be able to. + // This would only happen if the starting hub pool balance is below the net send amount. If it started + // above, then the dataworker would not purposefully send tokens out of it to fulfill the Ethereum + // PoolRebalanceLeaf and then return tokens to it to execute another chain's PoolRebalanceLeaf. + if (currHubPoolLiquidReserves.gte(leaf.netSendAmounts[idx])) { + this.logger.debug({ + at: "Dataworker#_updateExchangeRatesBeforeExecutingNonHubChainLeaves", + message: `Skipping exchange rate update for ${tokenSymbol} because current liquid reserves > netSendAmount`, + currHubPoolLiquidReserves, + netSendAmount: leaf.netSendAmounts[idx], + l1Token, + }); + return; + } + // @dev: Virtual balance = current balance + any used balance. + const virtualHubPoolBalance = currHubPoolLiquidReserves.sub( + balanceAllocator.getUsed(hubPoolChainId, l1Token, hubPool.address) + ); + + // If the virtual balance is still too low to execute the pool leaf, then log an error that this will + // pool rebalance leaf execution will fail. + if (virtualHubPoolBalance.lt(leaf.netSendAmounts[idx])) { + this.logger.error({ + at: "Dataworker#executePoolRebalanceLeaves", + message: "Executing pool rebalance leaf on HubPool will fail due to lack of funds to send.", + leaf: leaf, + l1Token, + netSendAmount: leaf.netSendAmounts[idx], + virtualHubPoolBalance, + }); + return; + } + this.logger.debug({ + at: "Dataworker#executePoolRebalanceLeaves", + message: `Relayer refund leaf will return enough funds to HubPool to execute PoolRebalanceLeaf, updating exchange rate for ${tokenSymbol}`, + currHubPoolLiquidReserves, + virtualHubPoolBalance, + netSendAmount: leaf.netSendAmounts[idx], + leaf, + }); + updatedL1Tokens.add(l1Token); + if (submitExecution) { + this.clients.multiCallerClient.enqueueTransaction({ + contract: this.clients.hubPoolClient.hubPool, + chainId: hubPoolChainId, + method: "exchangeRateCurrent", + args: [l1Token], + message: "Updated exchange rate ♻️!", + mrkdwn: `Updated exchange rate for l1 token: ${tokenSymbol}`, + unpermissioned: true, + }); + } + }); + }); + return updatedL1Tokens; + } + + async _updateOldExchangeRates(l1Tokens: string[], submitExecution: boolean): Promise { + const hubPool = this.clients.hubPoolClient.hubPool; + const chainId = this.clients.hubPoolClient.chainId; + const seenL1Tokens = new Set(); + await sdk.utils.forEachAsync(l1Tokens, async (l1Token) => { - // Exit early if we already synced this l1 token on this loop - if (syncedL1Tokens.includes(l1Token)) { + if (seenL1Tokens.has(l1Token)) { return; - } else { - syncedL1Tokens.push(l1Token); } + seenL1Tokens.add(l1Token); + const tokenSymbol = this.clients.hubPoolClient.getTokenInfo(chainId, l1Token)?.symbol; // Exit early if we recently synced this token. const lastestFeesCompoundedTime = this.clients.hubPoolClient.getLpTokenInfoForL1Token(l1Token)?.lastLpFeeUpdate ?? 0; if ( this.clients.hubPoolClient.currentTime === undefined || - this.clients.hubPoolClient.currentTime - lastestFeesCompoundedTime <= 7200 // 2 hours + this.clients.hubPoolClient.currentTime - lastestFeesCompoundedTime <= 2 * 24 * 60 * 60 // 2 day ) { + this.logger.debug({ + at: "Dataworker#_updateOldExchangeRates", + message: `Skipping exchange rate update for ${tokenSymbol} because it was recently updated`, + lastUpdateTime: lastestFeesCompoundedTime, + }); return; } - // Check how liquidReserves will be affected by the exchange rate update and skip it if it wouldn't increase. - // Updating exchange rate current or sync-ing pooled tokens is used only to potentially increase liquid - // reserves available to the HubPool to execute pool rebalance leaves, particularly fot tokens that haven't - // updated recently. If the liquid reserves would not increase, then we skip the update. - const hubPool = this.clients.hubPoolClient.hubPool; const multicallInput = [ hubPool.interface.encodeFunctionData("pooledTokens", [l1Token]), hubPool.interface.encodeFunctionData("sync", [l1Token]), @@ -1753,25 +1898,24 @@ export class Dataworker { const multicallOutput = await hubPool.callStatic.multicall(multicallInput); const currentPooledTokens = hubPool.interface.decodeFunctionResult("pooledTokens", multicallOutput[0]); const updatedPooledTokens = hubPool.interface.decodeFunctionResult("pooledTokens", multicallOutput[2]); - const liquidReservesDelta = updatedPooledTokens.liquidReserves.sub(currentPooledTokens.liquidReserves); - - // If the delta is positive, then the update will increase liquid reserves and - // at this point, we want to update the liquid reserves to make more available - // for executing a pool rebalance leaf. - const chainId = this.clients.hubPoolClient.chainId; - const tokenSymbol = this.clients.hubPoolClient.getTokenInfo(chainId, l1Token)?.symbol; - - if (liquidReservesDelta.lte(0)) { + const currentLiquidReserves = currentPooledTokens.liquidReserves; + const updatedLiquidReserves = updatedPooledTokens.liquidReserves; + if (currentLiquidReserves.gte(updatedLiquidReserves)) { this.logger.debug({ - at: "Dataworker#_updateExchangeRates", + at: "Dataworker#_updateOldExchangeRates", message: `Skipping exchange rate update for ${tokenSymbol} because liquid reserves would not increase`, - currentPooledTokens, - updatedPooledTokens, - liquidReservesDelta, + currentLiquidReserves, + updatedLiquidReserves, }); return; } + this.logger.debug({ + at: "Dataworker#_updateOldExchangeRates", + message: `Updating exchange rate for ${tokenSymbol}`, + lastUpdateTime: lastestFeesCompoundedTime, + l1Token, + }); if (submitExecution) { this.clients.multiCallerClient.enqueueTransaction({ contract: hubPool, diff --git a/test/Dataworker.executePoolRebalances.ts b/test/Dataworker.executePoolRebalances.ts index c03d20eb3..d80df387e 100644 --- a/test/Dataworker.executePoolRebalances.ts +++ b/test/Dataworker.executePoolRebalances.ts @@ -7,7 +7,19 @@ import { ZERO_ADDRESS, } from "./constants"; import { setupDataworker } from "./fixtures/Dataworker.Fixture"; -import { Contract, FakeContract, SignerWithAddress, depositV3, ethers, expect, fillV3, smock } from "./utils"; +import { + Contract, + FakeContract, + SignerWithAddress, + depositV3, + ethers, + expect, + fillV3, + lastSpyLogLevel, + smock, + sinon, + lastSpyLogIncludes, +} from "./utils"; // Tested import { BalanceAllocator } from "../src/clients/BalanceAllocator"; @@ -20,7 +32,7 @@ const destinationChainId = 42161; let spokePool_1: Contract, erc20_1: Contract, spokePool_2: Contract, erc20_2: Contract; let l1Token_1: Contract, hubPool: Contract; -let depositor: SignerWithAddress; +let depositor: SignerWithAddress, spy: sinon.SinonSpy; let hubPoolClient: HubPoolClient; let dataworkerInstance: Dataworker, multiCallerClient: MultiCallerClient; @@ -43,6 +55,7 @@ describe("Dataworker: Execute pool rebalances", async function () { multiCallerClient, updateAllClients, spokePoolClients, + spy, } = await setupDataworker( ethers, MAX_REFUNDS_PER_RELAYER_REFUND_LEAF, @@ -104,101 +117,334 @@ describe("Dataworker: Execute pool rebalances", async function () { await dataworkerInstance.executePoolRebalanceLeaves(spokePoolClients, new BalanceAllocator(providers)); expect(multiCallerClient.transactionCount()).to.equal(0); }); - describe("_updateExchangeRates", function () { + describe("update exchange rates", function () { let mockHubPoolClient: MockHubPoolClient, fakeHubPool: FakeContract; beforeEach(async function () { fakeHubPool = await smock.fake(hubPool.interface, { address: hubPool.address }); mockHubPoolClient = new MockHubPoolClient(hubPoolClient.logger, fakeHubPool, hubPoolClient.configStoreClient); mockHubPoolClient.setTokenInfoToReturn({ address: l1Token_1.address, decimals: 18, symbol: "TEST" }); dataworkerInstance.clients.hubPoolClient = mockHubPoolClient; + await updateAllClients(); }); - it("exits early if we recently synced l1 token", async function () { - mockHubPoolClient.currentTime = 10_000; - mockHubPoolClient.setLpTokenInfo(l1Token_1.address, 10_000); - await dataworkerInstance._updateExchangeRates([l1Token_1.address], true); - expect(multiCallerClient.transactionCount()).to.equal(0); + describe("_updateExchangeRatesBeforeExecutingHubChainLeaves", function () { + it("exits early if net send amount is negative", async function () { + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingHubChainLeaves( + { netSendAmounts: [toBNWei(-1)], l1Tokens: [l1Token_1.address] }, + true + ); + expect(Object.keys(updated)).to.have.length(0); + expect(multiCallerClient.transactionCount()).to.equal(0); + }); + it("exits early if current reserves are sufficient to pay for net send amounts", async function () { + const netSendAmount = toBNWei("1"); + + fakeHubPool.multicall.returns([ + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + netSendAmount, // liquid reserves + bnZero, // unaccumulated fees + ]), + ZERO_ADDRESS, // sync output + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + bnZero, // liquid reserves, post update. Doesn't matter for this test + // because we should be early exiting if current liquid reserves are sufficient. + bnZero, // unaccumulated fees + ]), + ]); + + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingHubChainLeaves( + { netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }, + true + ); + expect(Object.keys(updated)).to.have.length(0); + expect(multiCallerClient.transactionCount()).to.equal(0); + }); + it("logs error if updated liquid reserves aren't enough to execute leaf", async function () { + const netSendAmount = toBNWei("1"); + + fakeHubPool.multicall.returns([ + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + bnZero, // liquid reserves, set less than netSendAmount + bnZero, // unaccumulated fees + ]), + ZERO_ADDRESS, // sync output + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + bnZero, // liquid reserves, still less than net send amount + bnZero, // unaccumulated fees + ]), + ]); + + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingHubChainLeaves( + { netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }, + true + ); + expect(Object.keys(updated)).to.have.length(0); + expect(lastSpyLogLevel(spy)).to.equal("error"); + expect(lastSpyLogIncludes(spy, "Not enough funds to execute")).to.be.true; + expect(multiCallerClient.transactionCount()).to.equal(0); + }); + it("submits update", async function () { + const netSendAmount = toBNWei("1"); + const updatedLiquidReserves = netSendAmount.add(1); + + fakeHubPool.multicall.returns([ + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + bnZero, // liquid reserves, set less than netSendAmount + bnZero, // unaccumulated fees + ]), + ZERO_ADDRESS, // sync output + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + updatedLiquidReserves, // liquid reserves, >= than netSendAmount + bnZero, // unaccumulated fees + ]), + ]); + + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingHubChainLeaves( + { netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }, + true + ); + expect(Object.keys(updated)).to.have.length(1); + expect(updated[l1Token_1.address]).to.equal(updatedLiquidReserves); + expect(multiCallerClient.transactionCount()).to.equal(1); + }); }); - it("exits early if liquid reserves wouldn't increase for token post-update", async function () { - // Last update was at time 0, current time is at 10_000, so definitely past the update threshold - mockHubPoolClient.currentTime = 10_000; - mockHubPoolClient.setLpTokenInfo(l1Token_1.address, 0); - - // Hardcode multicall output such that it looks like liquid reserves stayed the same - fakeHubPool.multicall.returns([ - hubPool.interface.encodeFunctionResult("pooledTokens", [ - ZERO_ADDRESS, // lp token address - true, // enabled - 0, // last lp fee update - bnZero, // utilized reserves - bnZero, // liquid reserves - bnZero, // unaccumulated fees - ]), - ZERO_ADDRESS, // sync output - hubPool.interface.encodeFunctionResult("pooledTokens", [ - ZERO_ADDRESS, // lp token address - true, // enabled - 0, // last lp fee update - bnZero, // utilized reserves - bnZero, // liquid reserves, equal to "current" reserves - bnZero, // unaccumulated fees - ]), - ]); - - await dataworkerInstance._updateExchangeRates([l1Token_1.address], true); - expect(multiCallerClient.transactionCount()).to.equal(0); - - // Add test when liquid reserves decreases - fakeHubPool.multicall.returns([ - hubPool.interface.encodeFunctionResult("pooledTokens", [ - ZERO_ADDRESS, // lp token address - true, // enabled - 0, // last lp fee update - bnZero, // utilized reserves - toBNWei(1), // liquid reserves - bnZero, // unaccumulated fees - ]), - ZERO_ADDRESS, // sync output - hubPool.interface.encodeFunctionResult("pooledTokens", [ - ZERO_ADDRESS, // lp token address - true, // enabled - 0, // last lp fee update - bnZero, // utilized reserves - toBNWei(1).sub(1), // liquid reserves, less than "current" reserves - bnZero, // unaccumulated fees - ]), - ]); - - await dataworkerInstance._updateExchangeRates([l1Token_1.address], true); - expect(multiCallerClient.transactionCount()).to.equal(0); + describe("_updateExchangeRatesBeforeExecutingNonHubChainLeaves", function () { + let balanceAllocator; + beforeEach(async function () { + const providers = { + ...spokePoolClientsToProviders(spokePoolClients), + [hubPoolClient.chainId]: hubPool.provider, + }; + balanceAllocator = new BalanceAllocator(providers); + }); + it("exits early if net send amount is negative", async function () { + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingNonHubChainLeaves( + {}, + balanceAllocator, + [{ netSendAmounts: [toBNWei(-1)], l1Tokens: [l1Token_1.address] }], + true + ); + expect(updated.size).to.equal(0); + expect(multiCallerClient.transactionCount()).to.equal(0); + }); + it("exits early if current liquid reserves are greater than net send amount", async function () { + const netSendAmount = toBNWei("1"); + const liquidReserves = toBNWei("2"); + // For this test, do not pass in a liquid reserves object and force dataworker to load + // from HubPoolClient + mockHubPoolClient.setLpTokenInfo(l1Token_1.address, 0, liquidReserves); + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingNonHubChainLeaves( + {}, + balanceAllocator, + [{ netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }], + true + ); + expect(updated.size).to.equal(0); + expect(multiCallerClient.transactionCount()).to.equal(0); + }); + it("exits early if passed in liquid reserves are greater than net send amount", async function () { + const netSendAmount = toBNWei("1"); + const liquidReserves = toBNWei("2"); + // For this test,pass in a liquid reserves object + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingNonHubChainLeaves( + { + [l1Token_1.address]: liquidReserves, + }, + balanceAllocator, + [{ netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }], + true + ); + expect(updated.size).to.equal(0); + expect(multiCallerClient.transactionCount()).to.equal(0); + }); + it("logs error if updated liquid reserves aren't enough to execute leaf", async function () { + const netSendAmount = toBNWei("1"); + const liquidReserves = toBNWei("0"); + mockHubPoolClient.setLpTokenInfo(l1Token_1.address, 0, liquidReserves); + balanceAllocator.addUsed(hubPoolClient.chainId, l1Token_1.address, hubPool.address, toBNWei(0)); + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingNonHubChainLeaves( + {}, + balanceAllocator, + [{ netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }], + true + ); + expect(lastSpyLogLevel(spy)).to.equal("error"); + expect(lastSpyLogIncludes(spy, "will fail due to lack of funds to send")).to.be.true; + expect(updated.size).to.equal(0); + expect(multiCallerClient.transactionCount()).to.equal(0); + }); + it("submits update: liquid reserves read from HubPoolClient", async function () { + const netSendAmount = toBNWei("1"); + + // Liquid reserves are read from HubPoolClient. + // Liquid reserves are below net send amount, but virtual balance is above net send amount. + const liquidReserves = toBNWei("0"); + mockHubPoolClient.setLpTokenInfo(l1Token_1.address, 0, liquidReserves); + balanceAllocator.addUsed(1, l1Token_1.address, hubPool.address, netSendAmount.mul(-1)); + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingNonHubChainLeaves( + {}, + balanceAllocator, + [{ netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }], + true + ); + expect(updated.size).to.equal(1); + expect(updated.has(l1Token_1.address)).to.be.true; + expect(multiCallerClient.transactionCount()).to.equal(1); + }); + it("submits update: liquid reserves parameterized", async function () { + const netSendAmount = toBNWei("1"); + + // Liquid reserves are passed as input. + // Liquid reserves are below net send amount, but virtual balance is above net send amount. + const liquidReserves = toBNWei("0"); + balanceAllocator.addUsed(1, l1Token_1.address, hubPool.address, netSendAmount.mul(-1)); + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingNonHubChainLeaves( + { + [l1Token_1.address]: liquidReserves, + }, + balanceAllocator, + [{ netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }], + true + ); + expect(updated.size).to.equal(1); + expect(updated.has(l1Token_1.address)).to.be.true; + expect(multiCallerClient.transactionCount()).to.equal(1); + }); + it("Skips duplicate L1 tokens", async function () { + const netSendAmount = toBNWei("1"); + + // Liquid reserves are passed as input. + // Liquid reserves are below net send amount, but virtual balance is above net send amount. + const liquidReserves = toBNWei("0"); + balanceAllocator.addUsed(1, l1Token_1.address, hubPool.address, netSendAmount.mul(-1)); + const updated = await dataworkerInstance._updateExchangeRatesBeforeExecutingNonHubChainLeaves( + { + [l1Token_1.address]: liquidReserves, + }, + balanceAllocator, + [ + { netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }, + { netSendAmounts: [netSendAmount], l1Tokens: [l1Token_1.address] }, + ], + true + ); + expect(updated.size).to.equal(1); + expect(updated.has(l1Token_1.address)).to.be.true; + expect(multiCallerClient.transactionCount()).to.equal(1); + }); }); - it("submits update if liquid reserves would increase for token post-update and last update was old enough", async function () { - // Last update was at time 0, current time is at 10_000, so definitely past the update threshold - mockHubPoolClient.currentTime = 10_000; - mockHubPoolClient.setLpTokenInfo(l1Token_1.address, 0); - - // Hardcode multicall output such that it looks like liquid reserves increased - fakeHubPool.multicall.returns([ - hubPool.interface.encodeFunctionResult("pooledTokens", [ - ZERO_ADDRESS, // lp token address - true, // enabled - 0, // last lp fee update - bnZero, // utilized reserves - toBNWei(1), // liquid reserves - bnZero, // unaccumulated fees - ]), - ZERO_ADDRESS, - hubPool.interface.encodeFunctionResult("pooledTokens", [ - ZERO_ADDRESS, // lp token address - true, // enabled - 0, // last lp fee update - bnZero, // utilized reserves - toBNWei(1).add(1), // liquid reserves, higher than "current" reserves - bnZero, // unaccumulated fees - ]), - ]); - - await dataworkerInstance._updateExchangeRates([l1Token_1.address], true); - expect(multiCallerClient.transactionCount()).to.equal(1); + describe("_updateOldExchangeRates", function () { + it("exits early if we recently synced l1 token", async function () { + mockHubPoolClient.currentTime = 10_000; + mockHubPoolClient.setLpTokenInfo(l1Token_1.address, 10_000, toBNWei("0")); + await dataworkerInstance._updateOldExchangeRates([l1Token_1.address], true); + expect(multiCallerClient.transactionCount()).to.equal(0); + }); + it("exits early if liquid reserves wouldn't increase for token post-update", async function () { + // Last update was at time 0, current time is at 1_000_000, so definitely past the update threshold + mockHubPoolClient.currentTime = 1_000_000; + mockHubPoolClient.setLpTokenInfo(l1Token_1.address, 0); + + // Hardcode multicall output such that it looks like liquid reserves stayed the same + fakeHubPool.multicall.returns([ + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + bnZero, // liquid reserves + bnZero, // unaccumulated fees + ]), + ZERO_ADDRESS, // sync output + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + bnZero, // liquid reserves, equal to "current" reserves + bnZero, // unaccumulated fees + ]), + ]); + + await dataworkerInstance._updateOldExchangeRates([l1Token_1.address], true); + expect(multiCallerClient.transactionCount()).to.equal(0); + + // Add test when liquid reserves decreases + fakeHubPool.multicall.returns([ + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + toBNWei(1), // liquid reserves + bnZero, // unaccumulated fees + ]), + ZERO_ADDRESS, // sync output + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + toBNWei(1).sub(1), // liquid reserves, less than "current" reserves + bnZero, // unaccumulated fees + ]), + ]); + + await dataworkerInstance._updateOldExchangeRates([l1Token_1.address], true); + expect(multiCallerClient.transactionCount()).to.equal(0); + }); + it("submits update if liquid reserves would increase for token post-update and last update was old enough", async function () { + // Last update was at time 0, current time is at 1_000_000, so definitely past the update threshold + mockHubPoolClient.currentTime = 1_000_000; + mockHubPoolClient.setLpTokenInfo(l1Token_1.address, 0); + + // Hardcode multicall output such that it looks like liquid reserves increased + fakeHubPool.multicall.returns([ + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + toBNWei(1), // liquid reserves + bnZero, // unaccumulated fees + ]), + ZERO_ADDRESS, + hubPool.interface.encodeFunctionResult("pooledTokens", [ + ZERO_ADDRESS, // lp token address + true, // enabled + 0, // last lp fee update + bnZero, // utilized reserves + toBNWei(1).add(1), // liquid reserves, higher than "current" reserves + bnZero, // unaccumulated fees + ]), + ]); + + await dataworkerInstance._updateOldExchangeRates([l1Token_1.address], true); + expect(multiCallerClient.transactionCount()).to.equal(1); + }); }); }); }); diff --git a/test/mocks/MockHubPoolClient.ts b/test/mocks/MockHubPoolClient.ts index e15648b8b..07047a69a 100644 --- a/test/mocks/MockHubPoolClient.ts +++ b/test/mocks/MockHubPoolClient.ts @@ -1,5 +1,5 @@ import { clients } from "@across-protocol/sdk-v2"; -import { Contract, winston } from "../utils"; +import { Contract, winston, BigNumber } from "../utils"; import { ConfigStoreClient } from "../../src/clients"; import { MockConfigStoreClient } from "./MockConfigStoreClient"; @@ -27,7 +27,7 @@ export class MockHubPoolClient extends clients.mocks.MockHubPoolClient { 0 ); } - setLpTokenInfo(l1Token: string, lastLpFeeUpdate: number): void { - this.lpTokens[l1Token] = { lastLpFeeUpdate }; + setLpTokenInfo(l1Token: string, lastLpFeeUpdate: number, liquidReserves: BigNumber): void { + this.lpTokens[l1Token] = { lastLpFeeUpdate, liquidReserves }; } } diff --git a/yarn.lock b/yarn.lock index e28cd1928..19dd265b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -55,10 +55,10 @@ "@openzeppelin/contracts" "4.1.0" "@uma/core" "^2.18.0" -"@across-protocol/sdk-v2@0.22.15": - version "0.22.15" - resolved "https://registry.yarnpkg.com/@across-protocol/sdk-v2/-/sdk-v2-0.22.15.tgz#2465bc52b2d86185bca2f43b7433710b9518ea25" - integrity sha512-XjyWmevcLeMhuf0B0bOtUaPcuO2wQjTZP45EIPH22fOvs+8SoHs4jvQZT0mqvgd8QPmvOFMK9SGy0geKb+/0AQ== +"@across-protocol/sdk-v2@0.22.16": + version "0.22.16" + resolved "https://registry.yarnpkg.com/@across-protocol/sdk-v2/-/sdk-v2-0.22.16.tgz#5e898dfcd98f7805e8deea350aef1fcc591ac458" + integrity sha512-HgtcoF1m7SFETy5gThCGm1tPJdYpgCTYMDNtIl2c2CiN1LsJ9i29+VeEzLA13/rcEwDMZIQpDahOODc02e1yaQ== dependencies: "@across-protocol/across-token" "^1.0.0" "@across-protocol/constants-v2" "^1.0.12"