Skip to content
Merged
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
102 changes: 102 additions & 0 deletions contracts/bridge/BridgeGateway.sol
Original file line number Diff line number Diff line change
@@ -1,4 +1,105 @@
// 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);
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
Expand Down Expand Up @@ -163,5 +264,6 @@ contract BridgeGateway is BridgeEvents {

IERC20(token).safeTransfer(to, amount);
emit LiquidityWithdrawn(token, to, amount);

}
}
162 changes: 162 additions & 0 deletions test/bridge/BridgeGateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import hre from "hardhat";

describe("BridgeGateway", () => {
let ethers: any;
let RECIPIENT: string;

let owner: any;
let user: any;
let token: any;
Expand All @@ -19,6 +21,166 @@ describe("BridgeGateway", () => {
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");
=======
});

async function deploy() {
Expand Down
Loading