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
7 changes: 5 additions & 2 deletions ethereum/script/deploy/DeployAll.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ import { RgbSettlementModule } from '../../src/settlement/RgbSettlementModule.so
/// PRIVATE_KEY, USDT0_ADDRESS, BTC_RELAY_ADDRESS,
/// ENCLAVE_SIGNERS, ENCLAVE_THRESHOLD,
/// FEDERATION_SIGNERS, FEDERATION_THRESHOLD,
/// COMMISSION_RECIPIENT, TIMELOCK_DURATION, MIN_TIMELOCK
/// COMMISSION_RECIPIENT, TIMELOCK_DURATION, MIN_TIMELOCK,
/// MIN_FUNDS_IN_AMOUNT
///
/// Env (optional):
/// ETH_USD_FEED — Chainlink ETH/USD aggregator (wired in before CM
Expand Down Expand Up @@ -74,6 +75,7 @@ contract DeployAll is Script {
uint256 minTimelock = vm.envUint('MIN_TIMELOCK');
address ethUsdFeed = vm.envOr('ETH_USD_FEED', address(0));
uint256 ethUsdHb = vm.envOr('ETH_USD_HEARTBEAT', uint256(0));
uint256 minFundsIn = vm.envUint('MIN_FUNDS_IN_AMOUNT');

address deployer = vm.addr(pk);
uint64 startNonce = vm.getNonce(deployer);
Expand All @@ -96,7 +98,8 @@ contract DeployAll is Script {
usdt0,
address(routeRegistry),
payable(address(cm)),
address(0)
address(0),
minFundsIn
);

// ---- 5. Route plugins (nonce n+3, n+4) ---------------------------
Expand Down
10 changes: 9 additions & 1 deletion ethereum/script/deploy/DeployBridge.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import { Bridge } from '../../src/Bridge.sol';
/// `Bridge.setLZAdapter(adapter)`. The Bridge
/// accepts adapter-only `fundsInFromAdapter`
/// calls *only* from the configured adapter.
/// MIN_FUNDS_IN_AMOUNT — Minimum accepted `fundsIn` deposit in token
/// smallest units (USDT0 has 6 decimals, so e.g.
/// 10000 = 0.01 USDT0). Required and must be
/// non-zero; retune later via federation
/// governance with
/// `Bridge.setMinFundsInAmount(newMinimum)`.
///
/// Usage:
/// forge script script/deploy/DeployBridge.s.sol \
Expand All @@ -40,9 +46,10 @@ contract DeployBridge is Script {
address routeRegistry = vm.envAddress('ROUTE_REGISTRY_ADDRESS');
address commissionManager = vm.envAddress('COMMISSION_MANAGER');
address lzAdapter = vm.envOr('LZ_ADAPTER', address(0));
uint256 minFundsInAmount = vm.envUint('MIN_FUNDS_IN_AMOUNT');

vm.startBroadcast(pk);
bridge = new Bridge(usdt0, routeRegistry, payable(commissionManager), lzAdapter);
bridge = new Bridge(usdt0, routeRegistry, payable(commissionManager), lzAdapter, minFundsInAmount);
vm.stopBroadcast();

console2.log('Bridge deployed at: ', address(bridge));
Expand All @@ -51,5 +58,6 @@ contract DeployBridge is Script {
console2.log('RouteRegistry: ', address(bridge.routeRegistry()));
console2.log('CommissionManager: ', address(bridge.commissionManager()));
console2.log('LZ adapter: ', bridge.lzAdapter());
console2.log('Min fundsIn amount: ', bridge.minFundsInAmount());
}
}
50 changes: 49 additions & 1 deletion ethereum/src/Bridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
// State
// =========================================================================

/// @notice Upper bound on the `destinationAddress` (fundsIn) and
/// `sourceAddress` (fundsOut) byte length. These strings are echoed
/// into event logs, so an unbounded value would let a caller inflate
/// log-storage and indexer cost. 512 bytes covers every supported
/// destination representation (RGB invoices and other chains) with
/// ample headroom.
uint256 public constant MAX_ADDRESS_LENGTH = 512;

/// @notice Upper bound on the `fundsOut` `proof` byte length. `proof` is
/// forwarded to the route verifier, so an unbounded blob would let a
/// release inflate calldata + verifier gas. 1024 bytes comfortably
/// fits the verifier formats (RGB is 64 bytes today; a future SPV
/// inclusion proof stays well under this).
uint256 public constant MAX_PROOF_LENGTH = 1024;

/// @notice CommissionManager that receives and custodies protocol fees.
ICommissionManager public immutable commissionManager;

Expand All @@ -56,6 +71,12 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
/// `fundsOut`.
mapping(uint256 burnId => bool consumed) public consumedBurnIds;

/// @inheritdoc IBridge
/// @dev Always non-zero (validated at the constructor and setter). Mutable
/// so federation can retune the dust floor via the `MultisigProxy`
/// propose -> timelock -> execute flow without redeploying the Bridge.
uint256 public override minFundsInAmount;

// =========================================================================
// Modifiers
// =========================================================================
Expand All @@ -80,18 +101,24 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
/// `address(0)` if it has not been deployed yet
/// (federation can wire it up later via
/// `setLZAdapter`).
/// @param minFundsInAmount_ Initial minimum accepted `fundsIn` deposit in
/// token smallest units. Must be non-zero; it can
/// be retuned later via `setMinFundsInAmount`.
constructor(
address usdt0_,
address routeRegistry_,
address payable commissionManager_,
address lzAdapter_
address lzAdapter_,
uint256 minFundsInAmount_
) BridgeBase(usdt0_) {
if (routeRegistry_ == address(0)) revert InvalidRouteRegistryAddress();
if (commissionManager_ == address(0)) revert InvalidCommissionManagerAddress();
if (minFundsInAmount_ == 0) revert InvalidMinFundsInAmount();

routeRegistry = routeRegistry_;
commissionManager = ICommissionManager(commissionManager_);
lzAdapter = lzAdapter_;
minFundsInAmount = minFundsInAmount_;
}

// =========================================================================
Expand All @@ -117,6 +144,18 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
emit RouteRegistryUpdated(old, newRouteRegistry);
}

/// @inheritdoc IBridge
/// @dev Owner is `MultisigProxy`; federation gates this on its M-of-N
/// timelock flow (generic `proposeAdminExecute` -> execute). Must be
/// non-zero: a non-zero floor is what rejects zero-amount and dust
/// deposits on the inbound path.
function setMinFundsInAmount(uint256 newMinimum) external override onlyOwner {
if (newMinimum == 0) revert InvalidMinFundsInAmount();
uint256 old = minFundsInAmount;
minFundsInAmount = newMinimum;
emit MinFundsInAmountUpdated(old, newMinimum);
}

// =========================================================================
// External — user-facing
// =========================================================================
Expand Down Expand Up @@ -175,9 +214,14 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
bytes calldata proof,
bytes calldata settlementData
) external override onlyOwner nonReentrant whenOutflowNotPaused {
if (amount == 0) revert ZeroAmount();
if (recipient == address(0)) revert InvalidRecipientAddress();
if (sourceChainId == 0) revert InvalidSourceChainId();
if (destinationChainId == 0) revert InvalidDestinationChainId();
if (bytes(sourceAddress).length > MAX_ADDRESS_LENGTH) {
revert AddressTooLong(bytes(sourceAddress).length, MAX_ADDRESS_LENGTH);
}
if (proof.length > MAX_PROOF_LENGTH) revert ProofTooLong(proof.length, MAX_PROOF_LENGTH);
if (amount > IERC20(TOKEN).balanceOf(address(this))) revert AmountExceedBridgePool();

// Common replay guard. Set the flag before any external interaction
Expand Down Expand Up @@ -263,7 +307,11 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard {
uint256 operationId,
bytes calldata settlementData
) private {
if (amount < minFundsInAmount) revert AmountBelowMinimum(amount, minFundsInAmount);
if (bytes(destinationAddress).length == 0) revert InvalidDestinationAddress();
if (bytes(destinationAddress).length > MAX_ADDRESS_LENGTH) {
revert AddressTooLong(bytes(destinationAddress).length, MAX_ADDRESS_LENGTH);
}
if (sourceChainId == 0) revert InvalidSourceChainId();
if (destinationChainId == 0) revert InvalidDestinationChainId();

Expand Down
63 changes: 59 additions & 4 deletions ethereum/src/MultisigProxy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,22 @@ contract MultisigProxy is IMultisigProxy {
/// @notice Maximum allowed time between proposal creation and its deadline.
uint256 public constant MAX_PROPOSAL_LIFETIME = 30 days;

/// @notice Maximum allowed lifetime of a TEE-signed `execute` / `executeBatch`
/// deadline. Tighter than `MAX_PROPOSAL_LIFETIME` because enclave
/// operations (e.g. `fundsOut`) are meant to be executed promptly
/// after signing; a short ceiling limits how long a leaked or
/// pre-signed payload stays executable while its nonce is unconsumed.
uint256 public constant MAX_TEE_DEADLINE = 1 days;

/// @notice Hard upper bound on `executeBatch` size to keep gas use bounded.
uint256 public constant MAX_BATCH_SIZE = 3;

/// @notice Hard upper bound on the size of either signer set. Bounds the
/// O(N^2) duplicate/disjointness checks and stays far within the
/// `uint256` signature-bitmap width (so no signer index is ever
/// unreachable). 20 is ample for an enclave/federation committee.
uint256 public constant MAX_SIGNERS = 20;

/// @notice Length of a Solidity function selector (`bytes4`) in bytes. Used
/// to guard `callData` against payloads too short to even carry a
/// selector before it is sliced via `calldataload`.
Expand Down Expand Up @@ -176,16 +189,19 @@ contract MultisigProxy is IMultisigProxy {
if (bridge_ == address(0)) revert ZeroBridge();
if (commissionManager_ == address(0)) revert ZeroCommissionManager();
if (enclaveSigners_.length == 0) revert NoSigners();
if (enclaveThreshold_ == 0 || enclaveThreshold_ > enclaveSigners_.length) revert InvalidThreshold();
_requireValidThreshold(enclaveThreshold_, enclaveSigners_.length);
if (federationSigners_.length == 0) revert NoSigners();
if (federationThreshold_ == 0 || federationThreshold_ > federationSigners_.length) revert InvalidThreshold();
_requireValidThreshold(federationThreshold_, federationSigners_.length);
if (commissionRecipient_ == address(0)) revert ZeroCommissionRecipient();
if (minTimelock_ == 0 || minTimelock_ >= MAX_PROPOSAL_LIFETIME) revert InvalidMinTimelock();
if (timelockDuration_ < minTimelock_) revert TimelockTooShort();
if (timelockDuration_ >= MAX_PROPOSAL_LIFETIME) revert TimelockTooLong();

_validateSigners(enclaveSigners_);
_validateSigners(federationSigners_);
// The enclave and federation are distinct trust domains; an address in
// both would count toward quorum in each, collapsing that separation.
_requireDisjoint(enclaveSigners_, federationSigners_);

bridge = bridge_;
commissionManager = commissionManager_;
Expand Down Expand Up @@ -228,6 +244,10 @@ contract MultisigProxy is IMultisigProxy {
bytes[] calldata enclaveSigs
) external {
if (block.timestamp > deadline) revert Expired();
// Bound how far in the future a signed deadline may sit, so a leaked or
// pre-signed payload cannot stay executable indefinitely while its
// nonce is unconsumed.
if (deadline > block.timestamp + MAX_TEE_DEADLINE) revert DeadlineTooFar();
if (callData.length < SELECTOR_LENGTH) revert CallDataTooShort();

bytes4 selector;
Expand Down Expand Up @@ -258,6 +278,8 @@ contract MultisigProxy is IMultisigProxy {
bytes[] calldata enclaveSigs
) external payable {
if (block.timestamp > deadline) revert Expired();
// Bound the signed deadline's distance into the future;
if (deadline > block.timestamp + MAX_TEE_DEADLINE) revert DeadlineTooFar();
uint256 n = targets.length;
if (n == 0) revert BatchEmpty();
if (n > MAX_BATCH_SIZE) revert BatchTooLarge();
Expand Down Expand Up @@ -818,6 +840,7 @@ contract MultisigProxy is IMultisigProxy {
) private returns (bytes32 proposalId) {
if (block.timestamp > deadline) revert Expired();
if (deadline > block.timestamp + MAX_PROPOSAL_LIFETIME) revert DeadlineTooFar();
if (deadline < block.timestamp + timelockDuration) revert DeadlineBeforeTimelock();
if (nonce != proposalNonce) revert InvalidNonce();

bytes32 digest = _hashTypedData(structHash);
Expand Down Expand Up @@ -855,17 +878,19 @@ contract MultisigProxy is IMultisigProxy {
} else if (opType == OperationType.UpdateEnclaveSigners) {
(address[] memory newSigners, uint256 newThreshold) = abi.decode(opData, (address[], uint256));
if (newSigners.length == 0) revert NoSigners();
if (newThreshold == 0 || newThreshold > newSigners.length) revert InvalidThreshold();
_requireValidThreshold(newThreshold, newSigners.length);
_validateSigners(newSigners);
_requireDisjoint(newSigners, _federationSigners);
_enclaveSigners = newSigners;
enclaveThreshold = newThreshold;
emit EnclaveSignersUpdated(newSigners, newThreshold);

} else if (opType == OperationType.UpdateFederationSigners) {
(address[] memory newSigners, uint256 newThreshold) = abi.decode(opData, (address[], uint256));
if (newSigners.length == 0) revert NoSigners();
if (newThreshold == 0 || newThreshold > newSigners.length) revert InvalidThreshold();
_requireValidThreshold(newThreshold, newSigners.length);
_validateSigners(newSigners);
_requireDisjoint(newSigners, _enclaveSigners);
_federationSigners = newSigners;
federationThreshold = newThreshold;
emit FederationSignersUpdated(newSigners, newThreshold);
Expand Down Expand Up @@ -1064,7 +1089,37 @@ contract MultisigProxy is IMultisigProxy {
// =========================================================================

/// @dev Validates no zero addresses and no duplicates. O(n^2), fine for <20 signers.
/// @dev Enforces the quorum policy shared by both signer sets (enclave and
/// federation), in the constructor and the signer-update handlers:
/// - at least 2 signers (no single-key set);
/// - threshold of at least 2 (no single signer can authorise alone);
/// - threshold a strict majority (`2 * threshold > n`), which rejects
/// 1-of-N and any sub-majority quorum;
/// - threshold not exceeding the signer count.
/// The smallest valid sets are 2-of-2 and 2-of-3. This upholds the
/// spec invariant "one signer MUST NOT authorise fundsOut alone".
function _requireValidThreshold(uint256 threshold, uint256 n) private pure {
if (n < 2 || threshold < 2 || threshold > n || 2 * threshold <= n) {
revert InvalidThreshold();
}
}

/// @dev Reverts if any address in `candidate` also appears in `counterpart`.
/// Used to keep the enclave and federation signer sets disjoint so the
/// two trust domains stay independent (R-W-14). O(n*m), bounded by the
/// signer-set sizes.
function _requireDisjoint(address[] memory candidate, address[] memory counterpart) private pure {
for (uint256 i = 0; i < candidate.length; i++) {
for (uint256 j = 0; j < counterpart.length; j++) {
if (candidate[i] == counterpart[j]) revert SignerSetsOverlap(candidate[i]);
}
}
}

function _validateSigners(address[] memory signers) private pure {
// Bound the set before the O(N^2) duplicate scan below, and keep every
// signer index within the uint256 signature bitmap (R-I-06).
if (signers.length > MAX_SIGNERS) revert TooManySigners(signers.length, MAX_SIGNERS);
for (uint256 i = 0; i < signers.length; i++) {
if (signers[i] == address(0)) revert ZeroAddressSigner();
for (uint256 j = i + 1; j < signers.length; j++) {
Expand Down
20 changes: 20 additions & 0 deletions ethereum/src/interfaces/IBridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ interface IBridge {
error InvalidDestinationAddress();
error InvalidDestinationChainId();
error InvalidSourceChainId();
error ZeroAmount();
error AmountBelowMinimum(uint256 amount, uint256 minimum);
error InvalidMinFundsInAmount();
error AddressTooLong(uint256 length, uint256 maxLength);
error ProofTooLong(uint256 length, uint256 maxLength);
error InvalidRouteRegistryAddress();
error InvalidCommissionManagerAddress();
error NotLZAdapter();
Expand All @@ -30,6 +35,11 @@ interface IBridge {
/// @param newRegistry New registry (non-zero by `setRouteRegistry` guard).
event RouteRegistryUpdated(address indexed oldRegistry, address indexed newRegistry);

/// @notice Emitted on every successful `setMinFundsInAmount`.
/// @param oldMinimum Previous minimum (constructor value before first update).
/// @param newMinimum New minimum (in token smallest units; always non-zero).
event MinFundsInAmountUpdated(uint256 oldMinimum, uint256 newMinimum);

/// @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 +158,16 @@ interface IBridge {
/// `onFundsIn` / `beforeFundsOut` through. Owner-only.
function setRouteRegistry(address newRouteRegistry) external;

/// @notice Updates the minimum accepted `fundsIn` deposit (token smallest
/// units). Owner-only (MultisigProxy via the federation
/// propose -> timelock -> execute flow). Must be non-zero; reverts
/// `InvalidMinFundsInAmount` otherwise.
function setMinFundsInAmount(uint256 newMinimum) external;

/// @notice Current minimum accepted `fundsIn` deposit in token smallest
/// units. Always non-zero.
function minFundsInAmount() external view returns (uint256);

/// @notice Current trusted adapter; `address(0)` means the adapter
/// overload is closed.
function lzAdapter() external view returns (address);
Expand Down
3 changes: 3 additions & 0 deletions ethereum/src/interfaces/IMultisigProxy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ interface IMultisigProxy {
error ProposalExpired();
error DataMismatch();
error DeadlineTooFar();
error DeadlineBeforeTimelock();
error ProposalExists();
error IndexOutOfRange();
error BitmapOutOfRange();
Expand All @@ -62,6 +63,8 @@ interface IMultisigProxy {
error InvalidSignature();
error ZeroAddressSigner();
error DuplicateSigner();
error SignerSetsOverlap(address signer);
error TooManySigners(uint256 count, uint256 max);
error CallFailed();
error UnknownOperationType();
error ZeroRecipient();
Expand Down
Loading