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

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"dependencies": {
"@across-protocol/constants": "^3.1.52",
"@across-protocol/contracts": "^4.0.5",
"@across-protocol/sdk": "4.1.44",
"@across-protocol/sdk": "4.1.45",
"@arbitrum/sdk": "^4.0.2",
"@consensys/linea-sdk": "^0.2.1",
"@defi-wonderland/smock": "^2.3.5",
Expand Down
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({
outputToken: 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
37 changes: 24 additions & 13 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 Expand Up @@ -904,13 +923,9 @@ describe("Dataworker: Load bundle data", async function () {
const bundleDepositsV3 = {};
const bundleFillsV3 = {};
deposits.forEach((deposit) => {
const legacyDeposit = {
...deposit,
messageHash: "", // Superstruct defaults to "" for undefined.
};
bundleDepositsV3[deposit.originChainId] ??= {};
bundleDepositsV3[deposit.originChainId][deposit.inputToken] ??= [];
bundleDepositsV3[deposit.originChainId][deposit.inputToken].push(legacyDeposit);
bundleDepositsV3[deposit.originChainId][deposit.inputToken].push(deposit);
});
fills.forEach((fill) => {
bundleFillsV3[fill.originChainId] ??= {};
Expand Down Expand Up @@ -964,14 +979,10 @@ describe("Dataworker: Load bundle data", async function () {
bundleDepositsV3[deposit.originChainId][deposit.inputToken].push(deposit);
});
fills.forEach((fill) => {
const legacyFill = {
...fill,
messageHash: "", // Superstruct defaults to "" for undefined.
};
bundleFillsV3[fill.originChainId] ??= {};
bundleFillsV3[fill.originChainId][fill.inputToken] ??= {};
bundleFillsV3[fill.originChainId][fill.inputToken]["fills"] ??= [];
bundleFillsV3[fill.originChainId][fill.inputToken].fills.push(legacyFill);
bundleFillsV3[fill.originChainId][fill.inputToken].fills.push(fill);
});
const mockArweaveData = [
{
Expand Down Expand Up @@ -1001,9 +1012,9 @@ describe("Dataworker: Load bundle data", async function () {
const fillV3Events: interfaces.Log[] = [];
const destinationChainId = mockDestinationSpokePoolClient.chainId;
// Create three valid deposits
depositV3Events.push(generateV3Deposit({ outputToken: randomAddress() }));
depositV3Events.push(generateV3Deposit({ outputToken: randomAddress() }));
depositV3Events.push(generateV3Deposit({ outputToken: randomAddress() }));
depositV3Events.push(generateV3Deposit());
depositV3Events.push(generateV3Deposit());
depositV3Events.push(generateV3Deposit());
await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const deposits = mockOriginSpokePoolClient.getDeposits();

Expand Down
14 changes: 7 additions & 7 deletions test/Dataworker.loadData.prefill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ describe("Dataworker: Load bundle data: Pre-fill and Pre-Slow-Fill request logic

describe("Pre-fill has invalid repayment information", function () {
it("Refunds fill to msg.sender if fill is not in-memory and repayment info is invalid", async function () {
generateV3Deposit({ outputToken: randomAddress() });
generateV3Deposit();
await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const deposits = mockOriginSpokePoolClient.getDeposits();

Expand Down Expand Up @@ -287,7 +287,7 @@ describe("Dataworker: Load bundle data: Pre-fill and Pre-Slow-Fill request logic
});

it("Refunds fill to msg.sender if fill is in-memory and repayment info is invalid", async function () {
generateV3Deposit({ outputToken: randomAddress() });
generateV3Deposit();
await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const deposits = mockOriginSpokePoolClient.getDeposits();

Expand Down Expand Up @@ -329,7 +329,7 @@ describe("Dataworker: Load bundle data: Pre-fill and Pre-Slow-Fill request logic
});

it("Does not refund fill to msg.sender if fill is not in-memory and repayment address and msg.sender are invalid for repayment chain", async function () {
generateV3Deposit({ outputToken: randomAddress() });
generateV3Deposit();
await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const deposits = mockOriginSpokePoolClient.getDeposits();

Expand Down Expand Up @@ -373,7 +373,7 @@ describe("Dataworker: Load bundle data: Pre-fill and Pre-Slow-Fill request logic
});

it("Does not refund fill to msg.sender if fill is in-memory and repayment address and msg.sender are invalid for repayment chain", async function () {
generateV3Deposit({ outputToken: randomAddress() });
generateV3Deposit();
await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const deposits = mockOriginSpokePoolClient.getDeposits();

Expand Down Expand Up @@ -404,7 +404,7 @@ describe("Dataworker: Load bundle data: Pre-fill and Pre-Slow-Fill request logic
});

it("Does not refund lite chain fill to msg.sender if fill is not in-memory and repayment address and msg.sender are invalid for origin chain", async function () {
generateV3Deposit({ outputToken: randomAddress(), fromLiteChain: true });
generateV3Deposit({ fromLiteChain: true });
await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const deposits = mockOriginSpokePoolClient.getDeposits();

Expand Down Expand Up @@ -447,7 +447,7 @@ describe("Dataworker: Load bundle data: Pre-fill and Pre-Slow-Fill request logic
});

it("Does not refund lite chain fill to msg.sender if fill is in-memory and repayment address and msg.sender are invalid for origin chain", async function () {
generateV3Deposit({ outputToken: randomAddress(), fromLiteChain: true });
generateV3Deposit({ fromLiteChain: true });
await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const deposits = mockOriginSpokePoolClient.getDeposits();

Expand Down Expand Up @@ -478,7 +478,7 @@ describe("Dataworker: Load bundle data: Pre-fill and Pre-Slow-Fill request logic
});

it("Refunds deposit as a duplicate if fill is not in-memory and is a slow fill", async function () {
generateV3Deposit({ outputToken: randomAddress() });
generateV3Deposit();
await mockOriginSpokePoolClient.update(["V3FundsDeposited"]);
const deposits = mockOriginSpokePoolClient.getDeposits();

Expand Down
95 changes: 3 additions & 92 deletions test/InventoryClient.RefundChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,19 +263,16 @@ describe("InventoryClient: Refund chain selection", async function () {
hubPoolClient.setEnableAllL2Tokens(true);
expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([MAINNET]);
expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"166666666666666666"')).to.be.true;

// 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.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.deep.equal([1]);

sampleDepositData.outputToken = ZERO_ADDRESS;
// In this test, output token has a valid pool rebalance mapping but its not the equivalent token as the
// input token
sampleDepositData.outputToken = l2TokensForUsdc[OPTIMISM];
const srcChain = getNetworkName(sampleDepositData.originChainId);
const dstChain = getNetworkName(sampleDepositData.destinationChainId);
await assertPromiseError(
Expand Down Expand Up @@ -580,90 +577,4 @@ describe("InventoryClient: Refund chain selection", async function () {
expect(possibleRepaymentChains.length).to.equal(4);
});
});

describe("In-protocol swap", async function () {
Copy link
Member Author

Choose a reason for hiding this comment

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

I think this test isn't doing what we think its doing currently. Currently the determineRefundChain should throw if input token and output token are not "equivalent" here and in this test, the input token is native USDC which has no pool rebalance route mappings to bridged USDC, so it should not allow determineRefundChain to return successfully.

const nativeUSDC = TOKEN_SYMBOLS_MAP.USDC.addresses;
const bridgedUSDC = { ...TOKEN_SYMBOLS_MAP["USDC.e"].addresses, ...TOKEN_SYMBOLS_MAP["USDbC"].addresses };

beforeEach(async function () {
// Sub in a nested USDC config for the existing USDC config.
const usdcConfig = {
[nativeUSDC[OPTIMISM]]: {
[OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer },
},
[nativeUSDC[POLYGON]]: {
[POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer },
},
[nativeUSDC[ARBITRUM]]: {
[ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer },
},
[bridgedUSDC[OPTIMISM]]: {
[OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer },
},
[bridgedUSDC[POLYGON]]: {
[POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer },
},
[bridgedUSDC[ARBITRUM]]: {
[ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer },
},
};
inventoryConfig.tokenConfig[mainnetUsdc] = usdcConfig;

const inputAmount = toMegaWei(100);
sampleDepositData = {
depositId: bnZero,
fromLiteChain: false,
toLiteChain: false,
originChainId: ARBITRUM,
destinationChainId: OPTIMISM,
depositor: owner.address,
recipient: owner.address,
inputToken: nativeUSDC[ARBITRUM],
inputAmount,
outputToken: bridgedUSDC[OPTIMISM],
outputAmount: inputAmount,
message: "0x",
messageHash: "0x",
quoteTimestamp: hubPoolClient.currentTime!,
fillDeadline: 0,
exclusivityDeadline: 0,
exclusiveRelayer: ZERO_ADDRESS,
};
});

it("outputToken is not supported as a repaymentToken", async function () {
// Verify that there is no native USDC anywhere. The relayer is responsible for ensuring that it can make the fill.
enabledChainIds
.filter((chainId) => chainId !== MAINNET)
.forEach((chainId) => expect(tokenClient.getBalance(chainId, nativeUSDC[chainId]).eq(bnZero)).to.be.true);

// All chains are at target balance; cumulative balance is unchanged but repaymentToken balances on origin chain increases.
expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.deep.equal([MAINNET]);
expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"78571428571428571"')).to.be.true; // (1,000+100)/(14,000)

// Even when the output amount is equal to the destination's entire balance, take repayment on mainnet.
// This should be th same calculation as above.
sampleDepositData.outputAmount = inventoryClient.getBalanceOnChain(OPTIMISM, mainnetUsdc);
expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.deep.equal([MAINNET]);
expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"78571428571428571"')).to.be.true; // (1,000+100)/(14,000)

// 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.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.deep.equal([
ARBITRUM,
MAINNET,
]);
});
});
});
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
Loading