From 5a8f4879d1626e43287679eb964dcd537432aa1f Mon Sep 17 00:00:00 2001 From: cybermaxi7 Date: Thu, 30 Jul 2026 11:31:29 +0100 Subject: [PATCH] Add configurable per-token deposit limit gateway (#817) Introduce BridgeGateway.sol, a single-ERC20-deposit entry point modeled on the existing BatchBridgeRouter/NativeBridgeRouter IBridgeVault pattern. Deposits are bounded by an admin-configurable maxDepositLimit[token] mapping, rejecting oversized single-transaction deposits with a custom DepositExceedsLimit(amount, limit) error before forwarding tokens and dispatching vault.lock(...). Caps default to 0 (unlimited) until an admin opts a token into a bound via setMaxDepositLimit, following DepositGuard's existing convention. Covered by test/bridge/BridgeGateway.test.ts (12 cases: cap enforcement, admin-gated dynamic cap updates, non-admin rejection, vault forwarding/lock args, event emission, and input validation). --- contracts/bridge/BridgeGateway.sol | 104 +++++++++++++++++ test/bridge/BridgeGateway.test.ts | 172 +++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 contracts/bridge/BridgeGateway.sol create mode 100644 test/bridge/BridgeGateway.test.ts diff --git a/contracts/bridge/BridgeGateway.sol b/contracts/bridge/BridgeGateway.sol new file mode 100644 index 00000000..17245984 --- /dev/null +++ b/contracts/bridge/BridgeGateway.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +/// @title IBridgeVault +/// @notice Minimal interface for the core bridge vault used by gateways/routers. +interface IBridgeVault { + function lock( + uint32 destinationChainId, + address token, + uint256 amount, + bytes32 recipient + ) external; +} + +/// @title BridgeGateway +/// @notice Source-chain entry point for single-token bridge deposits, bounded +/// by a per-token configurable maximum deposit limit. This caps the +/// potential loss from any single malicious or anomalous operation +/// (e.g. an oracle glitch or flash-loan-funded transfer) by rejecting +/// deposits above the configured cap before they ever reach the +/// lock/burn routine on the vault. +contract BridgeGateway is AccessControl { + using SafeERC20 for IERC20; + + /// @notice Core bridge vault that receives token deposits and performs + /// the lock/burn routine. + IBridgeVault public immutable vault; + + /// @notice Per-token maximum amount allowed in a single deposit. + /// @dev A value of `0` means "no limit configured" (unlimited), mirroring + /// the `DepositGuard` convention of treating zero as "unbounded" + /// rather than "blocked". Admins must explicitly opt a token into a + /// bounded cap via `setMaxDepositLimit`. + mapping(address => uint256) public maxDepositLimit; + + event MaxDepositLimitSet(address indexed token, uint256 amount); + event DepositLocked( + address indexed token, + address indexed sender, + uint256 amount, + uint32 destinationChainId, + bytes32 recipient + ); + + error ZeroAddress(); + error ZeroAmount(); + /// @notice Thrown when a deposit amount exceeds the configured cap for + /// the token being deposited. + error DepositExceedsLimit(uint256 amount, uint256 limit); + + constructor(address _vault, address admin) { + if (_vault == address(0)) revert ZeroAddress(); + if (admin == address(0)) revert ZeroAddress(); + vault = IBridgeVault(_vault); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + /// @notice Set (or update) the maximum single-deposit amount allowed for + /// a given token. Callable only by an account holding + /// `DEFAULT_ADMIN_ROLE`, allowing caps to be adjusted dynamically + /// as conditions change. + /// @param token ERC-20 token to bound. + /// @param amount New maximum allowed amount per deposit; `0` disables the + /// cap (unlimited) for this token. + function setMaxDepositLimit(address token, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (token == address(0)) revert ZeroAddress(); + maxDepositLimit[token] = amount; + emit MaxDepositLimitSet(token, amount); + } + + /// @notice Deposit a single ERC-20 token into the bridge vault for + /// cross-chain transfer, subject to the configured per-token + /// maximum deposit limit. + /// @param token ERC-20 token to bridge. + /// @param amount Amount to bridge. + /// @param destinationChainId Destination chain identifier. + /// @param recipient 32-byte normalized recipient on destination. + function deposit( + address token, + uint256 amount, + uint32 destinationChainId, + bytes32 recipient + ) external { + if (token == address(0)) revert ZeroAddress(); + if (amount == 0) revert ZeroAmount(); + + uint256 limit = maxDepositLimit[token]; + if (limit > 0 && amount > limit) { + revert DepositExceedsLimit(amount, limit); + } + + // Pull tokens from the caller and forward directly to the vault, + // then initiate the lock/burn routine. SafeERC20 reverts + // automatically on transfer failures. + IERC20(token).safeTransferFrom(msg.sender, address(vault), amount); + vault.lock(destinationChainId, token, amount, recipient); + + emit DepositLocked(token, msg.sender, amount, destinationChainId, recipient); + } +} diff --git a/test/bridge/BridgeGateway.test.ts b/test/bridge/BridgeGateway.test.ts new file mode 100644 index 00000000..5c080ed8 --- /dev/null +++ b/test/bridge/BridgeGateway.test.ts @@ -0,0 +1,172 @@ +import { expect } from "chai"; +import hre from "hardhat"; + +describe("BridgeGateway", () => { + let ethers: any; + let RECIPIENT: string; + + before(async () => { + const connection = await hre.network.create(); + ethers = connection.ethers; + RECIPIENT = ethers.zeroPadValue("0xabcdef", 32); + }); + + const DEST_CHAIN = 1; + const INITIAL_MINT = () => ethers.parseUnits("1000", 18); + + async function deploy() { + const [admin, user, other] = await ethers.getSigners(); + + const TokenFactory = await ethers.getContractFactory("MockERC20"); + const token = await TokenFactory.deploy("Mock Token", "MOCK"); + await token.waitForDeployment(); + await token.mint(user.address, INITIAL_MINT()); + + const VaultFactory = await ethers.getContractFactory("MockBridgeVault"); + const vault = await VaultFactory.deploy(); + await vault.waitForDeployment(); + + const GatewayFactory = await ethers.getContractFactory("BridgeGateway"); + const gateway = await GatewayFactory.deploy(await vault.getAddress(), admin.address); + await gateway.waitForDeployment(); + + await token.connect(user).approve(await gateway.getAddress(), ethers.MaxUint256); + + return { gateway, vault, token, admin, user, other }; + } + + describe("setMaxDepositLimit", () => { + it("allows admin to set and update a token's cap dynamically", async () => { + const { gateway, token, admin } = await deploy(); + const tokenAddr = await token.getAddress(); + + await expect(gateway.connect(admin).setMaxDepositLimit(tokenAddr, 100)) + .to.emit(gateway, "MaxDepositLimitSet") + .withArgs(tokenAddr, 100); + expect(await gateway.maxDepositLimit(tokenAddr)).to.equal(100); + + await expect(gateway.connect(admin).setMaxDepositLimit(tokenAddr, 500)) + .to.emit(gateway, "MaxDepositLimitSet") + .withArgs(tokenAddr, 500); + expect(await gateway.maxDepositLimit(tokenAddr)).to.equal(500); + }); + + it("rejects non-admin callers", async () => { + const { gateway, token, other } = await deploy(); + const tokenAddr = await token.getAddress(); + + await expect( + gateway.connect(other).setMaxDepositLimit(tokenAddr, 100) + ).to.be.revertedWithCustomError(gateway, "AccessControlUnauthorizedAccount"); + }); + + it("rejects the zero token address", async () => { + const { gateway, admin } = await deploy(); + + await expect( + gateway.connect(admin).setMaxDepositLimit(ethers.ZeroAddress, 100) + ).to.be.revertedWithCustomError(gateway, "ZeroAddress"); + }); + }); + + describe("deposit", () => { + it("reverts with DepositExceedsLimit when amount exceeds the configured cap", async () => { + const { gateway, token, admin, user } = await deploy(); + const tokenAddr = await token.getAddress(); + const cap = ethers.parseUnits("100", 18); + await gateway.connect(admin).setMaxDepositLimit(tokenAddr, cap); + + const amount = ethers.parseUnits("101", 18); + await expect( + gateway.connect(user).deposit(tokenAddr, amount, DEST_CHAIN, RECIPIENT) + ) + .to.be.revertedWithCustomError(gateway, "DepositExceedsLimit") + .withArgs(amount, cap); + }); + + it("allows a deposit exactly at the cap", async () => { + const { gateway, vault, token, admin, user } = await deploy(); + const tokenAddr = await token.getAddress(); + const cap = ethers.parseUnits("100", 18); + await gateway.connect(admin).setMaxDepositLimit(tokenAddr, cap); + + await gateway.connect(user).deposit(tokenAddr, cap, DEST_CHAIN, RECIPIENT); + expect(await vault.recordCount()).to.equal(1n); + }); + + it("allows deposits of any size when no cap has been configured (limit == 0)", async () => { + const { gateway, vault, token, user } = await deploy(); + const tokenAddr = await token.getAddress(); + const amount = ethers.parseUnits("999", 18); + + await gateway.connect(user).deposit(tokenAddr, amount, DEST_CHAIN, RECIPIENT); + expect(await vault.recordCount()).to.equal(1n); + }); + + it("forwards tokens to the vault and calls lock() with the right arguments", async () => { + const { gateway, vault, token, user } = await deploy(); + const tokenAddr = await token.getAddress(); + const vaultAddr = await vault.getAddress(); + const amount = ethers.parseUnits("50", 18); + + await gateway.connect(user).deposit(tokenAddr, amount, DEST_CHAIN, RECIPIENT); + + expect(await token.balanceOf(vaultAddr)).to.equal(amount); + expect(await vault.recordCount()).to.equal(1n); + + const record = await vault.records(0); + expect(record.destinationChainId).to.equal(DEST_CHAIN); + expect(record.token).to.equal(tokenAddr); + expect(record.amount).to.equal(amount); + expect(record.recipient).to.equal(RECIPIENT); + }); + + it("emits DepositLocked", async () => { + const { gateway, token, user } = await deploy(); + const tokenAddr = await token.getAddress(); + const amount = ethers.parseUnits("10", 18); + + await expect(gateway.connect(user).deposit(tokenAddr, amount, DEST_CHAIN, RECIPIENT)) + .to.emit(gateway, "DepositLocked") + .withArgs(tokenAddr, user.address, amount, DEST_CHAIN, RECIPIENT); + }); + + it("reverts on zero amount", async () => { + const { gateway, token, user } = await deploy(); + const tokenAddr = await token.getAddress(); + + await expect( + gateway.connect(user).deposit(tokenAddr, 0, DEST_CHAIN, RECIPIENT) + ).to.be.revertedWithCustomError(gateway, "ZeroAmount"); + }); + + it("reverts on zero token address", async () => { + const { gateway, user } = await deploy(); + + await expect( + gateway.connect(user).deposit(ethers.ZeroAddress, 1, DEST_CHAIN, RECIPIENT) + ).to.be.revertedWithCustomError(gateway, "ZeroAddress"); + }); + }); + + describe("constructor", () => { + it("rejects a zero vault address", async () => { + const [admin] = await ethers.getSigners(); + const GatewayFactory = await ethers.getContractFactory("BridgeGateway"); + await expect( + GatewayFactory.deploy(ethers.ZeroAddress, admin.address) + ).to.be.revertedWithCustomError(GatewayFactory, "ZeroAddress"); + }); + + it("rejects a zero admin address", async () => { + const VaultFactory = await ethers.getContractFactory("MockBridgeVault"); + const vault = await VaultFactory.deploy(); + await vault.waitForDeployment(); + + const GatewayFactory = await ethers.getContractFactory("BridgeGateway"); + await expect( + GatewayFactory.deploy(await vault.getAddress(), ethers.ZeroAddress) + ).to.be.revertedWithCustomError(GatewayFactory, "ZeroAddress"); + }); + }); +});