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
103 changes: 103 additions & 0 deletions ethereum/src/Bridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { IBridge } from './interfaces/IBridge.sol';
import { ICommissionManager } from './interfaces/ICommissionManager.sol';
import { IRouteRegistry } from './interfaces/IRouteRegistry.sol';
import { FundsInContext, FundsOutContext } from './interfaces/RouteTypes.sol';
import { OutflowRateLimiter } from './libraries/OutflowRateLimiter.sol';

/// @title Bridge
/// @notice Production bridge for locking USDT0 on Arbitrum and unlocking it
Expand All @@ -35,6 +36,7 @@ import { FundsInContext, FundsOutContext } from './interfaces/RouteTypes.sol';
/// deployments without redeploying the Bridge.
contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
using SafeERC20 for IERC20;
using OutflowRateLimiter for OutflowRateLimiter.Bucket;

// =========================================================================
// State
Expand Down Expand Up @@ -71,6 +73,24 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
/// `fundsOut`.
mapping(uint256 burnId => bool consumed) public consumedBurnIds;

/// @notice Isolated liquidity per non-Arbitrum chain. Tracks the
/// net token amount locked on this chain on behalf of a given
/// remote chain: `fundsIn` credits the deposit's `destinationChainId`
/// bucket, `fundsOut` debits the release's `sourceChainId` bucket.
/// A release can therefore never draw more than was actually
/// bridged toward that chain.
mapping(uint256 chainId => uint256 locked) public lockedLiquidity;

/// @notice Per-source-chain outflow rate limiter. Bounds
/// how fast `fundsOut` can drain a single chain's liquidity.
mapping(uint256 chainId => OutflowRateLimiter.Bucket) public chainBuckets;

/// @notice Global (aggregate) outflow rate limiter. Bounds
/// total `fundsOut` across all source chains, since a compromised
/// shared TEE could otherwise drain every chain's per-chain bucket
/// in the same window (aggregate = sum of per-chain caps).
OutflowRateLimiter.Bucket public globalBucket;

/// @inheritdoc IBridge
/// @dev Always non-zero (validated at the constructor and setter). Mutable
/// so federation can retune the dust floor via the `MultisigProxy`
Expand Down Expand Up @@ -156,6 +176,43 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
emit MinFundsInAmountUpdated(old, newMinimum);
}

/// @inheritdoc IBridge
/// @dev Owner is `MultisigProxy`; federation gates this on its timelock flow.
function setOutflowLimit(uint256 chainId, uint256 capacity, uint256 refillRate)
external
override
onlyOwner
{
if (chainId == 0) revert InvalidOutflowLimit();
OutflowRateLimiter.Settings memory cfg = _buildOutflowConfig(capacity, refillRate);
OutflowRateLimiter.validate(cfg, false);
chainBuckets[chainId].configurePrimed(cfg);
emit OutflowLimitUpdated(chainId, capacity, refillRate, chainBuckets[chainId].tokens);
}

/// @inheritdoc IBridge
/// @dev Owner is `MultisigProxy`; federation gates this on its timelock flow.
function setGlobalOutflowLimit(uint256 capacity, uint256 refillRate)
external
override
onlyOwner
{
OutflowRateLimiter.Settings memory cfg = _buildOutflowConfig(capacity, refillRate);
OutflowRateLimiter.validate(cfg, false);
globalBucket.configurePrimed(cfg);
emit GlobalOutflowLimitUpdated(capacity, refillRate, globalBucket.tokens);
}

/// @inheritdoc IBridge
function availableOutflow(uint256 chainId) external view override returns (uint256) {
return OutflowRateLimiter.currentState(chainBuckets[chainId]).tokens;
}

/// @inheritdoc IBridge
function availableGlobalOutflow() external view override returns (uint256) {
return OutflowRateLimiter.currentState(globalBucket).tokens;
}

// =========================================================================
// External — user-facing
// =========================================================================
Expand Down Expand Up @@ -227,6 +284,28 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
if (consumedBurnIds[fundsOutParams.burnId]) revert BurnIdAlreadyConsumed(fundsOutParams.burnId);
consumedBurnIds[fundsOutParams.burnId] = true;

// Isolated liquidity: a release may only draw from the liquidity
// locked for its source chain. Debit before any external interaction so
// a downstream revert rolls the debit back with the rest of the call.
// `amount` (gross) matches what was minted on the source side, i.e. the
// `netAmount` that `_fundsIn` credited to this bucket.
uint256 srcLiquidity = lockedLiquidity[fundsOutParams.sourceChainId];
if (fundsOutParams.amount > srcLiquidity) {
revert InsufficientChainLiquidity(fundsOutParams.sourceChainId, fundsOutParams.amount, srcLiquidity);
}
lockedLiquidity[fundsOutParams.sourceChainId] = srcLiquidity - fundsOutParams.amount;

// Outflow rate limit. Consume both the per-source-chain
// bucket and the global aggregate bucket; either being short reverts the
// whole call (and rolls back the consume above). Both are debited before
// any external interaction, so a downstream revert restores them too.
// Per-source-chain bucket (token-scoped errors), then the global
// aggregate bucket (aggregate-scoped errors via the address(0) sentinel).
// The limiter is fail-closed: an unconfigured chain/global bucket
// rejects the release instead of falling back to unlimited outflow.
chainBuckets[fundsOutParams.sourceChainId].spend(fundsOutParams.amount, TOKEN);
globalBucket.spend(fundsOutParams.amount, address(0));

// Quote commission. NATIVE on fundsOut is unrepresentable: the
// CommissionManager setters reject a (NATIVE, FUNDS_OUT) rule at config,
// so `nativeCommission` is always 0 on this path. The value is ignored here.
Expand Down Expand Up @@ -293,6 +372,25 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
// Internal
// =========================================================================

/// @dev Build an enabled outflow-limit config from uint256 inputs, safely
/// narrowing to the library's uint128 fields. USDT0 amounts fit in
/// uint128; a value above that is a misconfiguration and reverts.
/// `OutflowRateLimiter.validate` then enforces `0 < rate < capacity`.
function _buildOutflowConfig(uint256 capacity, uint256 refillRate)
private
pure
returns (OutflowRateLimiter.Settings memory)
{
if (capacity > type(uint128).max || refillRate > type(uint128).max) revert InvalidOutflowLimit();
return OutflowRateLimiter.Settings({
isEnabled: true,
// forge-lint: disable-next-line(unsafe-typecast)
capacity: uint128(capacity),
// forge-lint: disable-next-line(unsafe-typecast)
rate: uint128(refillRate)
});
}

/// @dev Shared body for both `fundsIn` overloads.
function _fundsIn(
address from,
Expand Down Expand Up @@ -343,6 +441,11 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
settlementData
);

// Isolated liquidity: credit the net deposit to its destination
// chain's bucket. A later `fundsOut` from that chain can release at most
// the accumulated locked liquidity.
lockedLiquidity[destinationChainId] += netAmount;

// Forward token commission, if any, to the CommissionManager pool.
if (tokenCommission != 0) {
IERC20(TOKEN).safeTransfer(address(commissionManager), tokenCommission);
Expand Down
42 changes: 42 additions & 0 deletions ethereum/src/interfaces/IBridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface IBridge {
error InvalidMinFundsInAmount();
error AddressTooLong(uint256 length, uint256 maxLength);
error ProofTooLong(uint256 length, uint256 maxLength);
error InsufficientChainLiquidity(uint256 chainId, uint256 requested, uint256 available);
error InvalidOutflowLimit();
error InvalidRouteRegistryAddress();
error InvalidCommissionManagerAddress();
error NotLZAdapter();
Expand All @@ -40,6 +42,19 @@ interface IBridge {
/// @param newMinimum New minimum (in token smallest units; always non-zero).
event MinFundsInAmountUpdated(uint256 oldMinimum, uint256 newMinimum);

/// @notice Emitted on `setOutflowLimit` (per-chain outflow token bucket).
/// @param chainId Source chain the limit applies to.
/// @param capacity New bucket capacity (max instant outflow).
/// @param refillRate New refill rate (token units per second).
/// @param available Accrued allowance carried over after the update.
event OutflowLimitUpdated(uint256 indexed chainId, uint256 capacity, uint256 refillRate, uint256 available);

/// @notice Emitted on `setGlobalOutflowLimit` (aggregate outflow token bucket).
/// @param capacity New bucket capacity (max instant aggregate outflow).
/// @param refillRate New refill rate (token units per second).
/// @param available Accrued allowance carried over after the update.
event GlobalOutflowLimitUpdated(uint256 capacity, uint256 refillRate, uint256 available);

/// @param sender Address that deposited the tokens (the EOA on the
/// public overload, or the LZ adapter on the
/// adapter-only overload).
Expand Down Expand Up @@ -148,6 +163,9 @@ interface IBridge {

/// @notice Release tokens to a recipient. Only callable by owner
/// (`MultisigProxy`). Parameters are bundled in `FundsOutParams`.
/// @dev Per-chain / global outflow rate limiting uses the outflow
/// token-bucket library; bucket state is exposed via the `chainBuckets`
/// / `globalBucket` getters and the `availableOutflow` previews.
function fundsOut(FundsOutParams calldata params) external;

// =========================================================================
Expand All @@ -173,6 +191,30 @@ interface IBridge {
/// units. Always non-zero.
function minFundsInAmount() external view returns (uint256);

/// @notice Configure (or reconfigure) the per-chain outflow token bucket for
/// `chainId`. Owner-only (MultisigProxy timelock flow). Reverts
/// `InvalidOutflowLimit` on zero `chainId` or zero `capacity`. A
/// reconfiguration accrues the pending refill under the old settings
/// and preserves the accrued `available` (clamped to the new
/// capacity) — it never gifts a fresh full burst.
function setOutflowLimit(uint256 chainId, uint256 capacity, uint256 refillRate) external;

/// @notice Configure (or reconfigure) the global (aggregate) outflow token
/// bucket that bounds total `fundsOut` across all source chains.
/// Owner-only. Same accrue-and-preserve semantics as
/// `setOutflowLimit`.
function setGlobalOutflowLimit(uint256 capacity, uint256 refillRate) external;

/// @notice Spendable per-chain outflow allowance right now, including the
/// refill accrued since the last update (which the stored
/// `chainBuckets` getter does not materialize). Returns 0 for an
/// unconfigured chain.
function availableOutflow(uint256 chainId) external view returns (uint256);

/// @notice Spendable global outflow allowance right now, including the
/// accrued refill. Returns 0 if the global bucket is unconfigured.
function availableGlobalOutflow() external view returns (uint256);

/// @notice Current trusted adapter; `address(0)` means the adapter
/// overload is closed.
function lzAdapter() external view returns (address);
Expand Down
133 changes: 133 additions & 0 deletions ethereum/src/libraries/OutflowRateLimiter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;

/// @title OutflowRateLimiter
/// @notice Small token-bucket helper used by Bridge withdrawals.
/// @dev Amounts are stored as uint128 to keep bucket state compact; Bridge
/// rejects larger admin inputs before constructing a limit config.
library OutflowRateLimiter {
error LimitNotConfigured();
error StoredAllowanceAboveCapacity(uint256 available, uint256 capacity);
error TokenRequestAboveCapacity(uint256 capacity, uint256 requested, address token);
error AggregateRequestAboveCapacity(uint256 capacity, uint256 requested);
error TokenOutflowThrottled(uint256 retryAfter, uint256 available, address token);
error AggregateOutflowThrottled(uint256 retryAfter, uint256 available);
error InvalidLimitConfig(Settings settings);
error DisabledLimitHasValues(Settings settings);
error LimitMustStayDisabled();
error AmountExceedsUint128(uint256 value);

event AllowanceSpent(uint256 amount);
event LimitSettingsChanged(Settings settings);

struct Bucket {
uint128 tokens;
uint32 lastUpdated;
bool isEnabled;
uint128 capacity;
uint128 rate;
}

struct Settings {
bool isEnabled;
uint128 capacity;
uint128 rate;
}

/// @notice Debit `amount` from an enabled bucket.
/// @dev `token == address(0)` selects aggregate-scope revert reasons.
function spend(Bucket storage bucket, uint256 amount, address token) internal {
if (!bucket.isEnabled) revert LimitNotConfigured();
if (amount == 0) return;

uint256 capacity = bucket.capacity;
uint256 available = _preview(bucket);

if (amount > capacity) {
if (token == address(0)) revert AggregateRequestAboveCapacity(capacity, amount);
revert TokenRequestAboveCapacity(capacity, amount, token);
}

if (amount > available) {
uint256 retryAfter = _ceilDiv(amount - available, bucket.rate);
if (token == address(0)) revert AggregateOutflowThrottled(retryAfter, available);
revert TokenOutflowThrottled(retryAfter, available, token);
}

// Persist the post-spend state in one write each (no separate refill write).
bucket.tokens = _asUint128(available - amount);
bucket.lastUpdated = uint32(block.timestamp);
emit AllowanceSpent(amount);
}

/// @notice Apply settings and fill the bucket on the first enable only.
/// @dev Later changes preserve accrued allowance, clamped to new capacity.
function configurePrimed(Bucket storage bucket, Settings memory settings) internal {
bool firstEnable = !bucket.isEnabled && settings.isEnabled;
configure(bucket, settings);
if (firstEnable) bucket.tokens = settings.capacity;
}

/// @notice Return bucket state including refill accrued up to this block.
function currentState(Bucket memory bucket) internal view returns (Bucket memory updated) {
updated = bucket;
updated.tokens = _asUint128(_preview(bucket));
updated.lastUpdated = uint32(block.timestamp);
}

/// @notice Apply new settings while carrying over accrued allowance.
function configure(Bucket storage bucket, Settings memory settings) internal {
uint256 carried = _preview(bucket);

bucket.tokens = _asUint128(_min(carried, settings.capacity));
bucket.lastUpdated = uint32(block.timestamp);
bucket.isEnabled = settings.isEnabled;
bucket.capacity = settings.capacity;
bucket.rate = settings.rate;

emit LimitSettingsChanged(settings);
}

/// @notice Validate settings before they are written to a bucket.
function validate(Settings memory settings, bool mustBeDisabled) internal pure {
if (mustBeDisabled && settings.isEnabled) revert LimitMustStayDisabled();

if (settings.isEnabled) {
if (settings.capacity == 0 || settings.rate == 0 || settings.rate >= settings.capacity) {
revert InvalidLimitConfig(settings);
}
return;
}

if (settings.capacity != 0 || settings.rate != 0) revert DisabledLimitHasValues(settings);
}

function _preview(Bucket memory bucket) private view returns (uint256) {
uint256 available = bucket.tokens;
uint256 capacity = bucket.capacity;
if (available > capacity) revert StoredAllowanceAboveCapacity(available, capacity);

uint256 elapsed = block.timestamp - bucket.lastUpdated;
if (elapsed == 0 || bucket.rate == 0 || available == capacity) return available;

uint256 room = capacity - available;
uint256 refillRate = bucket.rate;
if (elapsed >= _ceilDiv(room, refillRate)) return capacity;

return available + elapsed * refillRate;
}

function _ceilDiv(uint256 numerator, uint256 denominator) private pure returns (uint256) {
return numerator == 0 ? 0 : ((numerator - 1) / denominator) + 1;
}

function _min(uint256 left, uint256 right) private pure returns (uint256) {
return left < right ? left : right;
}

function _asUint128(uint256 value) internal pure returns (uint128) {
if (value > type(uint128).max) revert AmountExceedsUint128(value);
// forge-lint: disable-next-line(unsafe-typecast)
return uint128(value);
}
}
Loading