Skip to content

feat: Handle cases where a deposit's inputToken and originChain cannot be mapped to a PoolRebalanceRoute #2177

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/clients/InventoryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
Profiler,
getNativeTokenSymbol,
getL1TokenInfo,
depositHasPoolRebalanceRouteMapping,
} from "../utils";
import { HubPoolClient, TokenClient, BundleDataClient } from ".";
import { Deposit, ProposedRootBundle } from "../interfaces";
Expand Down Expand Up @@ -423,6 +424,16 @@ export class InventoryClient {
const { originChainId, destinationChainId, inputToken, outputToken, inputAmount } = deposit;
const hubChainId = this.hubPoolClient.chainId;

// If the token cannot be mapped to any PoolRebalanceRoute, then the decision for now is to return zero repayment
// chains and force the relayer to ignore this deposit.
if (!depositHasPoolRebalanceRouteMapping(deposit, this.hubPoolClient)) {
return [];
}

if (sdkUtils.invalidOutputToken(deposit)) {
return [];
}

if (!this.isInventoryManagementEnabled()) {
return [deposit.fromLiteChain ? originChainId : destinationChainId];
}
Expand Down
15 changes: 13 additions & 2 deletions src/dataworker/DataworkerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,16 @@ export function _buildRelayerRefundRoot(
Object.entries(combinedRefunds).forEach(([_repaymentChainId, refundsForChain]) => {
const repaymentChainId = Number(_repaymentChainId);
Object.entries(refundsForChain).forEach(([l2TokenAddress, refunds]) => {
// If the token cannot be mapped to any PoolRebalanceRoute, then the amount to return must be 0 since there
// is no way to send the token back to the HubPool.
if (!clients.hubPoolClient.l2TokenHasPoolRebalanceRoute(l2TokenAddress, repaymentChainId, endBlockForMainnet)) {
relayerRefundLeaves.push(
..._getRefundLeaves(refunds, bnZero, repaymentChainId, l2TokenAddress, maxRefundCount)
);
return;
}
// If the token can be mapped to a PoolRebalanceRoute, then we need to calculate the amount to return based
// on its running balances.
const l1TokenCounterpart = clients.hubPoolClient.getL1TokenForL2TokenAtBlock(
l2TokenAddress,
repaymentChainId,
Expand All @@ -255,8 +265,9 @@ export function _buildRelayerRefundRoot(
runningBalances[repaymentChainId][l1TokenCounterpart]
);

const _refundLeaves = _getRefundLeaves(refunds, amountToReturn, repaymentChainId, l2TokenAddress, maxRefundCount);
relayerRefundLeaves.push(..._refundLeaves);
relayerRefundLeaves.push(
..._getRefundLeaves(refunds, amountToReturn, repaymentChainId, l2TokenAddress, maxRefundCount)
);
});
});

Expand Down
20 changes: 20 additions & 0 deletions src/relayer/Relayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Profiler,
formatGwei,
toBytes32,
depositHasPoolRebalanceRouteMapping,
} from "../utils";
import { RelayerClients } from "./RelayerClientHelper";
import { RelayerConfig } from "./RelayerConfig";
Expand Down Expand Up @@ -234,6 +235,25 @@ export class Relayer {
return ignoreDeposit();
}

if (!depositHasPoolRebalanceRouteMapping(deposit, this.clients.hubPoolClient)) {
this.logger.debug({
at: "Relayer::filterDeposit",
message: `Skipping ${srcChain} deposit for input token ${inputToken} due to missing pool rebalance route.`,
deposit,
transactionHash: deposit.transactionHash,
});
return ignoreDeposit();
}

if (sdkUtils.invalidOutputToken(deposit)) {
this.logger.debug({
at: "Relayer::filterDeposit",
message: `Skipping ${srcChain} deposit for invalid output token ${deposit.outputToken}.`,
transactionHash: deposit.transactionHash,
});
return ignoreDeposit();
}

if (!this.routeEnabled(originChainId, destinationChainId)) {
this.logger.debug({
at: "Relayer::filterDeposit",
Expand Down
7 changes: 7 additions & 0 deletions src/utils/FillUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export function getUnfilledDeposits(
});
}

export function depositHasPoolRebalanceRouteMapping(
deposit: Pick<DepositWithBlock, "inputToken" | "originChainId">,
hubPoolClient: HubPoolClient
): boolean {
return hubPoolClient.l2TokenHasPoolRebalanceRoute(deposit.inputToken, deposit.originChainId);
}

export function getAllUnfilledDeposits(
spokePoolClients: SpokePoolClientsByChain,
hubPoolClient: HubPoolClient
Expand Down
20 changes: 20 additions & 0 deletions test/Dataworker.loadData.deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,26 @@ describe("Dataworker: Load bundle data", async function () {
expect(data1.expiredDepositsToRefundV3).to.deep.equal({});
});

it("Can refund expired deposits with unresolvable output token", async function () {
const bundleBlockTimestamps = await dataworkerInstance.clients.bundleDataClient.getBundleBlockTimestamps(
[originChainId, destinationChainId],
getDefaultBlockRange(5),
spokePoolClients
);

generateV3Deposit({
inputToken: ZERO_ADDRESS,
fillDeadline: bundleBlockTimestamps[destinationChainId][1] - 1,
});

await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const data1 = await dataworkerInstance.clients.bundleDataClient.loadData(
getDefaultBlockRange(5),
spokePoolClients
);
expect(data1.expiredDepositsToRefundV3[originChainId][erc20_1.address].length).to.equal(1);
});

it("Does not consider expired zero value deposits from prior bundle", async function () {
const bundleBlockTimestamps = await dataworkerInstance.clients.bundleDataClient.getBundleBlockTimestamps(
[originChainId, destinationChainId],
Expand Down
19 changes: 19 additions & 0 deletions test/Dataworker.loadData.fill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,25 @@ describe("Dataworker: Load bundle data", async function () {
expect(spy.getCalls().filter((e) => e.lastArg.message.includes("invalid fills")).length).to.equal(0);
});

it("Does not refund fills for zero address output token deposits", async function () {
generateV3Deposit({
outputToken: ZERO_ADDRESS,
});

await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const deposits = mockOriginSpokePoolClient.getDeposits();
generateV3FillFromDeposit(deposits[0]);

await mockDestinationSpokePoolClient.update(["FilledV3Relay"]);
const data1 = await dataworkerInstance.clients.bundleDataClient.loadData(
getDefaultBlockRange(5),
spokePoolClients
);

expect(data1.bundleFillsV3).to.deep.equal({});
expect(spy.getCalls().filter((e) => e.lastArg.message.includes("invalid fills")).length).to.equal(0);
});

describe("Duplicate deposits in same bundle as fill", function () {
it("Sends duplicate deposit refunds for fills in bundle", async function () {
// Send duplicate deposits.
Expand Down
7 changes: 7 additions & 0 deletions test/mocks/MockHubPoolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ export class MockHubPoolClient extends clients.mocks.MockHubPoolClient {
}
return this.enableAllL2Tokens;
}

l2TokenHasPoolRebalanceRoute(l2Token: string, l2ChainId: number): boolean {
if (this.enableAllL2Tokens === undefined) {
return super.l2TokenHasPoolRebalanceRoute(l2Token, l2ChainId);
}
return this.enableAllL2Tokens;
}
}

export class SimpleMockHubPoolClient extends HubPoolClient {
Expand Down