From 1f8ec030e98fe42a01364ef0f9ef24cc8fc32772 Mon Sep 17 00:00:00 2001 From: sh3ifu Date: Fri, 29 May 2026 15:31:23 +0300 Subject: [PATCH 1/2] chore: add Atomiq BtcRelay SPV light client --- README.md | 9 + ethereum/src/btc_relay/BtcRelay.sol | 239 ++++++++++++++++ ethereum/src/btc_relay/BtcRelayTestnet.sol | 239 ++++++++++++++++ ethereum/src/btc_relay/Constants.sol | 21 ++ ethereum/src/btc_relay/Events.sol | 8 + .../src/btc_relay/btc_utils/Endianness.sol | 33 +++ .../src/btc_relay/state/BtcRelayState.sol | 37 +++ ethereum/src/btc_relay/state/Fork.sol | 22 ++ .../btc_relay/structs/CompactBlockHeader.sol | 41 +++ .../btc_relay/structs/StoredBlockHeader.sol | 256 +++++++++++++++++ .../structs/StoredBlockHeaderTestnet.sol | 259 ++++++++++++++++++ ethereum/src/btc_relay/utils/Difficulty.sol | 93 +++++++ ethereum/src/btc_relay/utils/Nbits.sol | 79 ++++++ 13 files changed, 1336 insertions(+) create mode 100644 ethereum/src/btc_relay/BtcRelay.sol create mode 100644 ethereum/src/btc_relay/BtcRelayTestnet.sol create mode 100644 ethereum/src/btc_relay/Constants.sol create mode 100644 ethereum/src/btc_relay/Events.sol create mode 100644 ethereum/src/btc_relay/btc_utils/Endianness.sol create mode 100644 ethereum/src/btc_relay/state/BtcRelayState.sol create mode 100644 ethereum/src/btc_relay/state/Fork.sol create mode 100644 ethereum/src/btc_relay/structs/CompactBlockHeader.sol create mode 100644 ethereum/src/btc_relay/structs/StoredBlockHeader.sol create mode 100644 ethereum/src/btc_relay/structs/StoredBlockHeaderTestnet.sol create mode 100644 ethereum/src/btc_relay/utils/Difficulty.sol create mode 100644 ethereum/src/btc_relay/utils/Nbits.sol diff --git a/README.md b/README.md index 40790f6..59c7979 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,12 @@ Each transfer deducts a service commission, configured per-route: source side (` ### Replay protection Each network enforces replay protection at the smart-contract level. On EVM the `Bridge` records consumed `burnId`s on-chain (each `FundsOut` carries the `burnId` extracted from the source-side burn consignment and is rejected if already seen) and `MultisigProxy` enforces per-selector sequential nonces on `execute` plus a sequential `batchNonce` on `executeBatch`. Route-specific bookkeeping — e.g. matching `FundsOut` against the exact source-side deposits being settled — lives in the per-route `SettlementModule` (for the RGB route, `RgbSettlementModule` tracks net deposit balances and consumes them atomically with the release). + +## Third-party code + +The Bitcoin SPV light client consulted on the RGB route lives under `ethereum/src/btc_relay/`. This code is vendored from the upstream Atomiq project: + +- **Source:** [atomiqlabs/atomiq-contracts-evm](https://github.com/atomiqlabs/atomiq-contracts-evm) +- **License:** Apache License 2.0 — preserved in the SPDX headers of the vendored files. + +`RGBVerifier` does not import the vendored `BtcRelay` directly — it talks to a deployed relay through the minimal local `IBtcRelayView` interface (`ethereum/src/interfaces/IBtcRelayView.sol`), which mirrors only the read methods the verifier relies on. diff --git a/ethereum/src/btc_relay/BtcRelay.sol b/ethereum/src/btc_relay/BtcRelay.sol new file mode 100644 index 0000000..b411454 --- /dev/null +++ b/ethereum/src/btc_relay/BtcRelay.sol @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +import {ForkImpl, Fork} from "./state/Fork.sol"; +import {StoredBlockHeaderImpl, StoredBlockHeader, StoredBlockHeaderByteLength} from "./structs/StoredBlockHeader.sol"; +import {CompactBlockHeaderByteLength} from "./structs/CompactBlockHeader.sol"; +import {BtcRelayState, BtcRelayStateImpl} from "./state/BtcRelayState.sol"; +import {Events} from "./Events.sol"; + +interface IBtcRelay { + function submitMainBlockheaders(bytes calldata data) external; + function submitShortForkBlockheaders(bytes calldata data) external; + function submitForkBlockheaders(uint256 forkId, bytes calldata data) external; +} + +interface IBtcRelayView { + function getChainwork() external view returns (uint224); + function getBlockheight() external view returns (uint32); + function verifyBlockheader(StoredBlockHeader memory storedHeader) external view returns (uint256 confirmations); + function verifyBlockheaderHash(uint256 height, bytes32 commitmentHash) external view returns (uint256 confirmations); + function getCommitHash(uint256 height) external view returns (bytes32); + function getTipCommitHash() external view returns (bytes32); +} + +contract BtcRelay is IBtcRelay, IBtcRelayView { + + using StoredBlockHeaderImpl for StoredBlockHeader; + using ForkImpl for Fork; + using BtcRelayStateImpl for BtcRelayState; + + BtcRelayState _relayState; + + //Mapping of the blockHeight => main chain blockheader commitment + mapping(uint256 => bytes32) _mainChain; + //Mapping of the submitter address => fork id => Fork struct + mapping(address => mapping(uint256 => Fork)) _forks; + + //Whether to clamp block target (enforce the maximum PoW block target of 0x00000000FFFF0000000000000000000000000000000000000000000000000000), + // only used during testing + bool immutable _clampBlockTarget; + + //Initialize the btc relay with the provided stored_header + constructor(StoredBlockHeader memory storedHeader, bool clampBlockTarget) { + _clampBlockTarget = clampBlockTarget; + + //Save the initial stored header + bytes32 commitHash = storedHeader.hash(); + uint32 blockHeight = storedHeader.blockHeight(); + _mainChain[blockHeight] = commitHash; + _relayState.write(blockHeight, uint224(storedHeader.chainWork() & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff)); + + //Emit event + emit Events.StoreHeader(commitHash, storedHeader.header_blockhash()); + } + + //Internal functions + function _verifyBlockheaderHash(uint256 height, bytes32 commitmentHash) internal view returns (uint256 confirmations) { + uint256 mainBlockheight = _relayState.blockHeight; + //Check that the block height isn't past the tip, this can happen if there is a reorg, where a shorter + // chain becomes the cannonical one, this can happen due to the heaviest work rule (and not lonest chain rule) + require(height <= mainBlockheight, 'verify: future block'); + + require( + _mainChain[height] == commitmentHash, + 'verify: block commitment' + ); + + confirmations = mainBlockheight - height + 1; + } + + //Mutating functions + function submitMainBlockheaders(bytes calldata data) external { + require(data.length >= StoredBlockHeaderByteLength + CompactBlockHeaderByteLength, "submitMain: no headers"); + + StoredBlockHeader memory storedHeader = StoredBlockHeaderImpl.fromCalldata(data, 0); //160-byte previous blockheader + + //Verify stored header is latest committed + uint32 blockHeight = _relayState.blockHeight; + require(blockHeight == storedHeader.blockHeight(), "submitMain: block height"); + require(_mainChain[blockHeight] == storedHeader.hash(), "submitMain: block commitment"); + + //Proccess new block headers, start at offset 160 and read 48-byte blockheaders + for(uint256 i = StoredBlockHeaderByteLength; i < data.length; i += CompactBlockHeaderByteLength) { + //Process the blockheader + bytes32 blockHash = storedHeader.updateChain(data, i, block.timestamp, _clampBlockTarget); + blockHeight = storedHeader.blockHeight(); + + //Write header commitment + bytes32 commitHash = storedHeader.hash(); + _mainChain[blockHeight] = commitHash; + + //Emit event + emit Events.StoreHeader(commitHash, blockHash); + } + + //Update globals + _relayState.write(blockHeight, uint224(storedHeader.chainWork() & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff)); + } + + + function submitShortForkBlockheaders(bytes calldata data) external { + require(data.length >= StoredBlockHeaderByteLength + CompactBlockHeaderByteLength, "submitMain: no headers"); + + StoredBlockHeader memory storedHeader = StoredBlockHeaderImpl.fromCalldata(data, 0); + + //Verify stored header is committed + (uint32 tipBlockHeight, uint256 chainWork) = _relayState.read(); + uint32 blockHeight = storedHeader.blockHeight(); + require(blockHeight <= tipBlockHeight, "shortFork: future block"); + require(_mainChain[blockHeight] == storedHeader.hash(), "shortFork: block commitment"); + + uint256 startHeight = uint256(blockHeight) + 1; + + //Proccess new block headers + bytes32 commitHash; + bytes32 blockHash; + for(uint256 i = StoredBlockHeaderByteLength; i < data.length; i += CompactBlockHeaderByteLength) { + //Process the blockheader + blockHash = storedHeader.updateChain(data, i, block.timestamp, _clampBlockTarget); + blockHeight = storedHeader.blockHeight(); + + //Write header commitment + commitHash = storedHeader.hash(); + _mainChain[blockHeight] = commitHash; + + //Emit event - here we can already emit main chain submission events + emit Events.StoreHeader(commitHash, blockHash); + } + + //Check if this fork's chainwork is higher than main chainwork + uint256 newChainWork = storedHeader.chainWork(); + require(newChainWork > chainWork, 'shortFork: not enough work'); + + //Emit chain re-org event + emit Events.ChainReorg(commitHash, blockHash, 0, msg.sender, startHeight); + + //Update globals + _relayState.write(blockHeight, uint224(newChainWork & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff)); + } + + function submitForkBlockheaders(uint256 forkId, bytes calldata data) external { + require(data.length >= StoredBlockHeaderByteLength + CompactBlockHeaderByteLength, "fork: no headers"); + require(forkId != 0, "fork: forkId 0 reserved"); + Fork storage fork = _forks[msg.sender][forkId]; + + StoredBlockHeader memory storedHeader = StoredBlockHeaderImpl.fromCalldata(data, 0); + + (uint32 tipBlockHeight, uint256 chainWork) = _relayState.read(); + bytes32 commitHash = storedHeader.hash(); + uint256 forkStartBlockheight = fork.startHeight; + if(forkStartBlockheight==0) { + //Verify stored header is committed in the main chain + uint256 storedHeaderBlockHeight = storedHeader.blockHeight(); + require(storedHeaderBlockHeight <= tipBlockHeight, "fork: future block"); + require(_mainChain[storedHeaderBlockHeight] == commitHash, "fork: block commitment"); + forkStartBlockheight = storedHeaderBlockHeight + 1; + //Save the block start height and also the commitment of the fork root block (latest + // block that is still committed in the main chain) + fork.startHeight = uint32(forkStartBlockheight); + fork.chain[storedHeaderBlockHeight] = commitHash; + } else { + //Verify stored header is the tip of the fork chain + uint256 forkTipHeight = fork.tipHeight; + require(fork.chain[forkTipHeight] == commitHash, "fork: fork block commitment"); + } + + //Proccess new block headers + bytes32 blockHash; + uint32 forkTipBlockHeight; + mapping(uint256 => bytes32) storage forkChain = fork.chain; + for(uint256 i = StoredBlockHeaderByteLength; i < data.length; i += CompactBlockHeaderByteLength) { + //Process the blockheader + blockHash = storedHeader.updateChain(data, i, block.timestamp, _clampBlockTarget); + forkTipBlockHeight = storedHeader.blockHeight(); + + //Write header commitment + commitHash = storedHeader.hash(); + forkChain[forkTipBlockHeight] = commitHash; + + //Emit event - here we can already emit main chain submission events + emit Events.StoreForkHeader(commitHash, blockHash, forkId); + } + + //Update tip height of the fork + fork.tipHeight = forkTipBlockHeight; + + //Check if this fork's chainwork is higher than main chainwork + uint256 newChainWork = storedHeader.chainWork(); + if(chainWork < newChainWork) { + //This fork has just overtaken the main chain in chainwork + //Make this fork main chain + uint256 blockHeight = forkStartBlockheight-1; + + //Make sure that the fork's root block is still committed + require(_mainChain[blockHeight] == forkChain[blockHeight], "fork: reorg block commitment"); + delete forkChain[blockHeight]; + + blockHeight++; + + for(; blockHeight <= forkTipBlockHeight; blockHeight++) { + _mainChain[blockHeight] = forkChain[blockHeight]; + delete forkChain[blockHeight]; + } + fork.remove(); + + //Emit chain re-org event + emit Events.ChainReorg(commitHash, blockHash, forkId, msg.sender, forkStartBlockheight); + + //Update globals + _relayState.write(forkTipBlockHeight, uint224(newChainWork & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff)); + } + } + + //Read-only functions + function getChainwork() external view returns (uint224 chainWork) { + return _relayState.chainWork; + } + + function getBlockheight() external view returns (uint32 blockheight) { + return _relayState.blockHeight; + } + + function verifyBlockheader(StoredBlockHeader memory storedHeader) external view returns (uint256 confirmations) { + return _verifyBlockheaderHash(storedHeader.blockHeight(), storedHeader.hash()); + } + + function verifyBlockheaderHash(uint256 height, bytes32 commitmentHash) external view returns (uint256 confirmations) { + return _verifyBlockheaderHash(height, commitmentHash); + } + + function getCommitHash(uint256 height) external view returns (bytes32) { + return _mainChain[height]; + } + + function getTipCommitHash() external view returns (bytes32) { + return _mainChain[_relayState.blockHeight]; + } + +} diff --git a/ethereum/src/btc_relay/BtcRelayTestnet.sol b/ethereum/src/btc_relay/BtcRelayTestnet.sol new file mode 100644 index 0000000..96c506c --- /dev/null +++ b/ethereum/src/btc_relay/BtcRelayTestnet.sol @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +import {ForkImpl, Fork} from "./state/Fork.sol"; +import {StoredBlockHeaderImpl, StoredBlockHeader, StoredBlockHeaderByteLength} from "./structs/StoredBlockHeaderTestnet.sol"; +import {CompactBlockHeaderByteLength} from "./structs/CompactBlockHeader.sol"; +import {BtcRelayState, BtcRelayStateImpl} from "./state/BtcRelayState.sol"; +import {Events} from "./Events.sol"; + +interface IBtcRelay { + function submitMainBlockheaders(bytes calldata data) external; + function submitShortForkBlockheaders(bytes calldata data) external; + function submitForkBlockheaders(uint256 forkId, bytes calldata data) external; +} + +interface IBtcRelayView { + function getChainwork() external view returns (uint224); + function getBlockheight() external view returns (uint32); + function verifyBlockheader(StoredBlockHeader memory storedHeader) external view returns (uint256 confirmations); + function verifyBlockheaderHash(uint256 height, bytes32 commitmentHash) external view returns (uint256 confirmations); + function getCommitHash(uint256 height) external view returns (bytes32); + function getTipCommitHash() external view returns (bytes32); +} + +contract BtcRelayTestnet is IBtcRelay, IBtcRelayView { + + using StoredBlockHeaderImpl for StoredBlockHeader; + using ForkImpl for Fork; + using BtcRelayStateImpl for BtcRelayState; + + BtcRelayState _relayState; + + //Mapping of the blockHeight => main chain blockheader commitment + mapping(uint256 => bytes32) _mainChain; + //Mapping of the submitter address => fork id => Fork struct + mapping(address => mapping(uint256 => Fork)) _forks; + + //Whether to clamp block target (enforce the maximum PoW block target of 0x00000000FFFF0000000000000000000000000000000000000000000000000000), + // only used during testing + bool immutable _clampBlockTarget; + + //Initialize the btc relay with the provided stored_header + constructor(StoredBlockHeader memory storedHeader, bool clampBlockTarget) { + _clampBlockTarget = clampBlockTarget; + + //Save the initial stored header + bytes32 commitHash = storedHeader.hash(); + uint32 blockHeight = storedHeader.blockHeight(); + _mainChain[blockHeight] = commitHash; + _relayState.write(blockHeight, uint224(storedHeader.chainWork() & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff)); + + //Emit event + emit Events.StoreHeader(commitHash, storedHeader.header_blockhash()); + } + + //Internal functions + function _verifyBlockheaderHash(uint256 height, bytes32 commitmentHash) internal view returns (uint256 confirmations) { + uint256 mainBlockheight = _relayState.blockHeight; + //Check that the block height isn't past the tip, this can happen if there is a reorg, where a shorter + // chain becomes the cannonical one, this can happen due to the heaviest work rule (and not lonest chain rule) + require(height <= mainBlockheight, 'verify: future block'); + + require( + _mainChain[height] == commitmentHash, + 'verify: block commitment' + ); + + confirmations = mainBlockheight - height + 1; + } + + //Mutating functions + function submitMainBlockheaders(bytes calldata data) external { + require(data.length >= StoredBlockHeaderByteLength + CompactBlockHeaderByteLength, "submitMain: no headers"); + + StoredBlockHeader memory storedHeader = StoredBlockHeaderImpl.fromCalldata(data, 0); //160-byte previous blockheader + + //Verify stored header is latest committed + uint32 blockHeight = _relayState.blockHeight; + require(blockHeight == storedHeader.blockHeight(), "submitMain: block height"); + require(_mainChain[blockHeight] == storedHeader.hash(), "submitMain: block commitment"); + + //Proccess new block headers, start at offset 160 and read 48-byte blockheaders + for(uint256 i = StoredBlockHeaderByteLength; i < data.length; i += CompactBlockHeaderByteLength) { + //Process the blockheader + bytes32 blockHash = storedHeader.updateChain(data, i, block.timestamp); + blockHeight = storedHeader.blockHeight(); + + //Write header commitment + bytes32 commitHash = storedHeader.hash(); + _mainChain[blockHeight] = commitHash; + + //Emit event + emit Events.StoreHeader(commitHash, blockHash); + } + + //Update globals + _relayState.write(blockHeight, uint224(storedHeader.chainWork() & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff)); + } + + + function submitShortForkBlockheaders(bytes calldata data) external { + require(data.length >= StoredBlockHeaderByteLength + CompactBlockHeaderByteLength, "submitMain: no headers"); + + StoredBlockHeader memory storedHeader = StoredBlockHeaderImpl.fromCalldata(data, 0); + + //Verify stored header is committed + (uint32 tipBlockHeight, uint256 chainWork) = _relayState.read(); + uint32 blockHeight = storedHeader.blockHeight(); + require(blockHeight <= tipBlockHeight, "shortFork: future block"); + require(_mainChain[blockHeight] == storedHeader.hash(), "shortFork: block commitment"); + + uint256 startHeight = uint256(blockHeight) + 1; + + //Proccess new block headers + bytes32 commitHash; + bytes32 blockHash; + for(uint256 i = StoredBlockHeaderByteLength; i < data.length; i += CompactBlockHeaderByteLength) { + //Process the blockheader + blockHash = storedHeader.updateChain(data, i, block.timestamp); + blockHeight = storedHeader.blockHeight(); + + //Write header commitment + commitHash = storedHeader.hash(); + _mainChain[blockHeight] = commitHash; + + //Emit event - here we can already emit main chain submission events + emit Events.StoreHeader(commitHash, blockHash); + } + + //Check if this fork's chainwork is higher than main chainwork + uint256 newChainWork = storedHeader.chainWork(); + require(newChainWork > chainWork, 'shortFork: not enough work'); + + //Emit chain re-org event + emit Events.ChainReorg(commitHash, blockHash, 0, msg.sender, startHeight); + + //Update globals + _relayState.write(blockHeight, uint224(newChainWork & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff)); + } + + function submitForkBlockheaders(uint256 forkId, bytes calldata data) external { + require(data.length >= StoredBlockHeaderByteLength + CompactBlockHeaderByteLength, "fork: no headers"); + require(forkId != 0, "fork: forkId 0 reserved"); + Fork storage fork = _forks[msg.sender][forkId]; + + StoredBlockHeader memory storedHeader = StoredBlockHeaderImpl.fromCalldata(data, 0); + + (uint32 tipBlockHeight, uint256 chainWork) = _relayState.read(); + bytes32 commitHash = storedHeader.hash(); + uint256 forkStartBlockheight = fork.startHeight; + if(forkStartBlockheight==0) { + //Verify stored header is committed in the main chain + uint256 storedHeaderBlockHeight = storedHeader.blockHeight(); + require(storedHeaderBlockHeight <= tipBlockHeight, "fork: future block"); + require(_mainChain[storedHeaderBlockHeight] == commitHash, "fork: block commitment"); + forkStartBlockheight = storedHeaderBlockHeight + 1; + //Save the block start height and also the commitment of the fork root block (latest + // block that is still committed in the main chain) + fork.startHeight = uint32(forkStartBlockheight); + fork.chain[storedHeaderBlockHeight] = commitHash; + } else { + //Verify stored header is the tip of the fork chain + uint256 forkTipHeight = fork.tipHeight; + require(fork.chain[forkTipHeight] == commitHash, "fork: fork block commitment"); + } + + //Proccess new block headers + bytes32 blockHash; + uint32 forkTipBlockHeight; + mapping(uint256 => bytes32) storage forkChain = fork.chain; + for(uint256 i = StoredBlockHeaderByteLength; i < data.length; i += CompactBlockHeaderByteLength) { + //Process the blockheader + blockHash = storedHeader.updateChain(data, i, block.timestamp); + forkTipBlockHeight = storedHeader.blockHeight(); + + //Write header commitment + commitHash = storedHeader.hash(); + forkChain[forkTipBlockHeight] = commitHash; + + //Emit event - here we can already emit main chain submission events + emit Events.StoreForkHeader(commitHash, blockHash, forkId); + } + + //Update tip height of the fork + fork.tipHeight = forkTipBlockHeight; + + //Check if this fork's chainwork is higher than main chainwork + uint256 newChainWork = storedHeader.chainWork(); + if(chainWork < newChainWork) { + //This fork has just overtaken the main chain in chainwork + //Make this fork main chain + uint256 blockHeight = forkStartBlockheight-1; + + //Make sure that the fork's root block is still committed + require(_mainChain[blockHeight] == forkChain[blockHeight], "fork: reorg block commitment"); + delete forkChain[blockHeight]; + + blockHeight++; + + for(; blockHeight <= forkTipBlockHeight; blockHeight++) { + _mainChain[blockHeight] = forkChain[blockHeight]; + delete forkChain[blockHeight]; + } + fork.remove(); + + //Emit chain re-org event + emit Events.ChainReorg(commitHash, blockHash, forkId, msg.sender, forkStartBlockheight); + + //Update globals + _relayState.write(forkTipBlockHeight, uint224(newChainWork & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff)); + } + } + + //Read-only functions + function getChainwork() external view returns (uint224 chainWork) { + return _relayState.chainWork; + } + + function getBlockheight() external view returns (uint32 blockheight) { + return _relayState.blockHeight; + } + + function verifyBlockheader(StoredBlockHeader memory storedHeader) external view returns (uint256 confirmations) { + return _verifyBlockheaderHash(storedHeader.blockHeight(), storedHeader.hash()); + } + + function verifyBlockheaderHash(uint256 height, bytes32 commitmentHash) external view returns (uint256 confirmations) { + return _verifyBlockheaderHash(height, commitmentHash); + } + + function getCommitHash(uint256 height) external view returns (bytes32) { + return _mainChain[height]; + } + + function getTipCommitHash() external view returns (bytes32) { + return _mainChain[_relayState.blockHeight]; + } + +} diff --git a/ethereum/src/btc_relay/Constants.sol b/ethereum/src/btc_relay/Constants.sol new file mode 100644 index 0000000..132566d --- /dev/null +++ b/ethereum/src/btc_relay/Constants.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +//Interval (in blocks) of the difficulty adjustment +uint256 constant DIFFICULTY_ADJUSTMENT_INTERVAL = 2016; + +//Maximum positive difference between bitcoin block's timestamp and EVM chain's on-chain clock +//Nodes in bitcoin network generally reject any block with timestamp more than 2 hours in the future +//As we are dealing with another blockchain here, +// with the possibility of the EVM chain's on-chain clock being skewed, we chose double the value -> 4 hours +uint256 constant MAX_FUTURE_BLOCKTIME = 4 * 60 * 60; + +//Lowest possible mining difficulty - highest possible target +uint256 constant UNROUNDED_MAX_TARGET = 0x00000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; +uint256 constant ROUNDED_MAX_TARGET = 0x00000000FFFF0000000000000000000000000000000000000000000000000000; +uint32 constant ROUNDED_MAX_TARGET_NBITS = 0xFFFF001D; + +//Bitcoin epoch timespan +uint256 constant TARGET_TIMESPAN = 14 * 24 * 60 * 60; //2 weeks +uint256 constant TARGET_TIMESPAN_DIV_4 = TARGET_TIMESPAN / 4; +uint256 constant TARGET_TIMESPAN_MUL_4 = TARGET_TIMESPAN * 4; diff --git a/ethereum/src/btc_relay/Events.sol b/ethereum/src/btc_relay/Events.sol new file mode 100644 index 0000000..56a5d45 --- /dev/null +++ b/ethereum/src/btc_relay/Events.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +library Events { + event StoreHeader(bytes32 indexed commitHash, bytes32 indexed blockHash); + event StoreForkHeader(bytes32 indexed commitHash, bytes32 indexed blockHash, uint256 indexed forkId); + event ChainReorg(bytes32 indexed commitHash, bytes32 indexed blockHash, uint256 indexed forkId, address submitter, uint256 startHeight); +} diff --git a/ethereum/src/btc_relay/btc_utils/Endianness.sol b/ethereum/src/btc_relay/btc_utils/Endianness.sol new file mode 100644 index 0000000..c2cedad --- /dev/null +++ b/ethereum/src/btc_relay/btc_utils/Endianness.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +library Endianness { + function reverseUint32(uint32 input) internal pure returns (uint32) { + assembly { + input := or(shr(8, and(input, 0xFF00FF00)), shl(8, and(input, 0x00FF00FF))) + input := or(shr(16, and(input, 0xFFFF0000)), shl(16, and(input, 0x0000FFFF))) + } + return input; + } + + function reverseUint64(uint64 input) internal pure returns (uint64) { + assembly { + input := or(shr(8, and(input, 0xFF00FF00FF00FF00)), shl(8, and(input, 0x00FF00FF00FF00FF))) + input := or(shr(16, and(input, 0xFFFF0000FFFF0000)), shl(16, and(input, 0x0000FFFF0000FFFF))) + input := or(shr(32, and(input, 0xFFFFFFFF00000000)), shl(32, and(input, 0x00000000FFFFFFFF))) + } + return input; + } + + function reverseBytes32(bytes32 input) internal pure returns (bytes32) { + assembly { + // swap bytes + input := or(shr(8, and(input, 0xFF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00)), shl(8, and(input, 0x00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF))) + input := or(shr(16, and(input, 0xFFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000)), shl(16, and(input, 0x0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF))) + input := or(shr(32, and(input, 0xFFFFFFFF00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF00000000)), shl(32, and(input, 0x00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF))) + input := or(shr(64, and(input, 0xFFFFFFFFFFFFFFFF0000000000000000FFFFFFFFFFFFFFFF0000000000000000)), shl(64, and(input, 0x0000000000000000FFFFFFFFFFFFFFFF0000000000000000FFFFFFFFFFFFFFFF))) + input := or(shr(128, input), shl(128, input)) + } + return input; + } +} diff --git a/ethereum/src/btc_relay/state/BtcRelayState.sol b/ethereum/src/btc_relay/state/BtcRelayState.sol new file mode 100644 index 0000000..17afce9 --- /dev/null +++ b/ethereum/src/btc_relay/state/BtcRelayState.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +struct BtcRelayState { + uint32 blockHeight; + uint224 chainWork; +} + +library BtcRelayStateImpl { + + //Optimized read function, that reads all the values at once + function read(BtcRelayState storage self) view internal returns (uint32 blockHeight, uint224 chainWork) { + //The following assembly is equivalent to: + // blockHeight = self.blockHeight; + // chainWork = self.chainWork; + assembly { + let value := sload(self.slot) //All the data is stored at slot 0 + blockHeight := and(value, 0xffffffff) + chainWork := shr(32, value) + } + } + + //Optimized write function that writes all the values at once + function write(BtcRelayState storage self, uint32 blockHeight, uint224 chainWork) internal { + //The following assembly is equivalent to: + // self.blockHeight = blockHeight; + // self.chainWork = chainWork; + assembly { + let value := or( + blockHeight, + shl(32, chainWork) + ) + sstore(self.slot, value) + } + } + +} diff --git a/ethereum/src/btc_relay/state/Fork.sol b/ethereum/src/btc_relay/state/Fork.sol new file mode 100644 index 0000000..cfc449c --- /dev/null +++ b/ethereum/src/btc_relay/state/Fork.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +struct Fork { + //Slot 0 + mapping(uint256 => bytes32) chain; + + //Slot 1 + uint32 startHeight; + uint32 tipHeight; +} + +library ForkImpl { + + //Deletes startHeight and tipHeight saved in the fork storage slot + function remove(Fork storage self) internal { + assembly { + sstore(add(self.slot, 1), 0) + } + } + +} diff --git a/ethereum/src/btc_relay/structs/CompactBlockHeader.sol b/ethereum/src/btc_relay/structs/CompactBlockHeader.sol new file mode 100644 index 0000000..df522e8 --- /dev/null +++ b/ethereum/src/btc_relay/structs/CompactBlockHeader.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +import {Endianness} from "../btc_utils/Endianness.sol"; + +uint256 constant CompactBlockHeaderByteLength = 48; + +/** + * Bitcoin blockheader decoding from bytes (previous block hash is not included and is instead + * fetched from latest stored blockheader) + * Structure (total 48 bytes): + * - uint32 versionLE + * - bytes32 merkleRoot + * - uint32 timestampLE + * - uint32 nBitsLE + * - uint32 nonce + */ +library CompactBlockHeaderImpl { + + function verifyOutOfBounds(bytes calldata self, uint256 offset) pure internal { + require(self.length >= offset + CompactBlockHeaderByteLength, "BlockHeader: out of bounds"); + } + + //Getters + + //Gets the timestamp of the blockheader, NOTE: This doesn't check whether the offset is out of bounds! + function timestamp(bytes calldata self, uint256 offset) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, calldataload(add(add(self.offset, offset), 36))) + } + result = Endianness.reverseUint32(result); + } + + //Gets the nBits of the blockheader in little-endian format, NOTE: This doesn't check whether the offset is out of bounds! + function nBitsLE(bytes calldata self, uint256 offset) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, calldataload(add(add(self.offset, offset), 40))) + } + } + +} \ No newline at end of file diff --git a/ethereum/src/btc_relay/structs/StoredBlockHeader.sol b/ethereum/src/btc_relay/structs/StoredBlockHeader.sol new file mode 100644 index 0000000..ea06af4 --- /dev/null +++ b/ethereum/src/btc_relay/structs/StoredBlockHeader.sol @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +import {CompactBlockHeaderImpl} from "./CompactBlockHeader.sol"; +import {Nbits} from "../utils/Nbits.sol"; +import {Difficulty} from "../utils/Difficulty.sol"; +import {Endianness} from "../btc_utils/Endianness.sol"; +import {DIFFICULTY_ADJUSTMENT_INTERVAL, MAX_FUTURE_BLOCKTIME} from "../Constants.sol"; + +/** + * Bitcoin stored blockheader defined as a fixed-length bytes32 array + * Structure (total 160 bytes): + * - bytes blockheader (80 bytes) + * - uint256 chainWork + * - uint32 blockHeight + * - uint32 lastDiffAdjustment + * - uint32[10] prevBlockTimestamps + */ +struct StoredBlockHeader { + bytes32[5] data; +} +uint256 constant StoredBlockHeaderByteLength = 160; +uint256 constant BitcoinBlockHeaderByteLength = 80; + +library StoredBlockHeaderImpl { + + using CompactBlockHeaderImpl for bytes; + + function fromCalldata(bytes calldata data, uint256 offset) pure internal returns (StoredBlockHeader memory storedHeader) { + require(data.length >= StoredBlockHeaderByteLength+offset, "StoredBlockHeader: out of bounds"); + assembly ("memory-safe") { + calldatacopy(mload(storedHeader), add(data.offset, offset), StoredBlockHeaderByteLength) //Store stored header data + } + } + + //Getters + function header_version(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(mload(self))) + } + result = Endianness.reverseUint32(result); + } + + function header_previousBlockhash(StoredBlockHeader memory self) pure internal returns (bytes32 result) { + assembly ("memory-safe") { + result := mload(add(mload(self), 4)) + } + } + + function header_merkleRoot(StoredBlockHeader memory self) pure internal returns (bytes32 result) { + assembly ("memory-safe") { + result := mload(add(mload(self), 36)) + } + } + + function header_timestamp(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 68))) + } + result = Endianness.reverseUint32(result); + } + + function header_nBitsLE(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 72))) + } + } + + function header_nonce(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 76))) + } + result = Endianness.reverseUint32(result); + } + + function chainWork(StoredBlockHeader memory self) pure internal returns (uint256 result) { + assembly ("memory-safe") { + result := mload(add(mload(self), 80)) + } + } + + function blockHeight(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 112))) + } + } + + function lastDiffAdjustment(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 116))) + } + } + + function previousBlockTimestamps(StoredBlockHeader memory self) pure internal returns (uint32[10] memory result) { + assembly ("memory-safe") { + let ptr := mload(self) + let prevBlockTimestampsArray1 := mload(add(ptr, 120)) //offset(120) Stores first 8 last block timestamps + let prevBlockTimestampsArray2 := mload(add(ptr, 128)) //offset(120 + 8) Stores last 2 last block timestamps in least significant bits + mstore(result, shr(224, prevBlockTimestampsArray1)) + mstore(add(result, 32), and(shr(192, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 64), and(shr(160, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 96), and(shr(128, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 128), and(shr(96, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 160), and(shr(64, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 192), and(shr(32, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 224), and(prevBlockTimestampsArray1, 0xffffffff)) + mstore(add(result, 256), and(shr(32, prevBlockTimestampsArray2), 0xffffffff)) + mstore(add(result, 288), and(prevBlockTimestampsArray2, 0xffffffff)) + } + } + + //Functions + function header_blockhash(StoredBlockHeader memory self) view internal returns (bytes32 result) { + assembly ("memory-safe") { + //Invoke first sha256 hash on the memory region now storing the current blockheader, destination is scratch space at 0x00 + pop(staticcall(gas(), 0x02, mload(self), BitcoinBlockHeaderByteLength, 0x00, 32)) + //Invoke second sha256 on the scratch space at 0x00 + pop(staticcall(gas(), 0x02, 0x00, 32, 0x00, 32)) + + //Load and return the result + result := mload(0x00) + } + } + + function hash(StoredBlockHeader memory self) pure internal returns (bytes32 result) { + assembly ("memory-safe") { + result := keccak256(mload(self), StoredBlockHeaderByteLength) + } + } + + //Writes new blockheader to the stored header memory and computes the double sha256 hash of this new blockheader + function writeHeaderAndGetDblSha256Hash(StoredBlockHeader memory self, bytes calldata headers, uint256 offset) private view returns (bytes32 result) { + assembly ("memory-safe") { + let ptr := mload(self) + + //Invoke first sha256 hash on the memory region storing the previous blockheader, destination is scratch space at 0x00 + pop(staticcall(gas(), 0x02, ptr, BitcoinBlockHeaderByteLength, 0x00, 32)) + //Invoke second sha256 on the scratch space at 0x00, copy directly to where the previous blockhash should be stored for next stored blockheader + pop(staticcall(gas(), 0x02, 0x00, 32, add(ptr, 4), 32)) + + //Copy other data to the stored blockheader from calldata + calldatacopy(ptr, add(headers.offset, offset), 4) + calldatacopy(add(ptr, 36), add(headers.offset, add(offset, 4)), 44) + + //Invoke first sha256 hash on the memory region now storing the current blockheader, destination is scratch space at 0x00 + pop(staticcall(gas(), 0x02, ptr, BitcoinBlockHeaderByteLength, 0x00, 32)) + //Invoke second sha256 on the scratch space at 0x00 + pop(staticcall(gas(), 0x02, 0x00, 32, 0x00, 32)) + + //Load and return the result + result := mload(0x00) + } + } + + function updateChain( + StoredBlockHeader memory self, bytes calldata headers, uint256 offset, uint256 timestamp, bool clampTarget + ) internal view returns (bytes32 blockHash) { + //We don't check whether pevious header matches since submitted headers are submitted + // without previousBlockHash fields, which is instead taken automatically from the + // current StoredBlockHeader, this allows us to save at least 512 gas on calldata + + uint32 prevBlockTimestamp = header_timestamp(self); + uint32 currBlockTimestamp = headers.timestamp(offset); + + //Check correct nbits + uint256 currBlockHeight = blockHeight(self) + 1; + uint32 _lastDiffAdjustment = lastDiffAdjustment(self); + uint32 newNbits = headers.nBitsLE(offset); + uint256 newTarget; + if(currBlockHeight % DIFFICULTY_ADJUSTMENT_INTERVAL == 0) { + //Compute new nbits, bitcoin uses the timestamp of the last block in the epoch to re-target PoW difficulty + // https://github.com/bitcoin/bitcoin/blob/78dae8caccd82cfbfd76557f1fb7d7557c7b5edb/src/pow.cpp#L49 + uint256 computedNbits; + (newTarget, computedNbits) = Difficulty.computeNewTarget( + prevBlockTimestamp, + _lastDiffAdjustment, + header_nBitsLE(self), + clampTarget + ); + require(newNbits == computedNbits, "updateChain: new nbits"); + //Even though timestamp of the last block in epoch is used to re-target PoW difficulty, the first + // block in a new epoch is used as last_diff_adjustment, the time it takes to mine the first block + // in every epoch is therefore not taken into consideration when retargetting PoW - one of many + // bitcoin's quirks + _lastDiffAdjustment = currBlockTimestamp; + } else { + //nbits must be same as last block + require(newNbits == header_nBitsLE(self), "updateChain: nbits"); + newTarget = Nbits.toTarget(newNbits); + } + + //Check PoW + blockHash = writeHeaderAndGetDblSha256Hash(self, headers, offset); + require(uint256(Endianness.reverseBytes32(blockHash)) < Nbits.toTarget(newNbits), "updateChain: invalid PoW"); + + //Verify timestamp is larger than median of last 11 block timestamps + uint256 count = 0; + uint256 prevBlockTimestampsArray1; + uint256 prevBlockTimestampsArray2; + assembly ("memory-safe") { + let ptr := mload(self) + prevBlockTimestampsArray1 := mload(add(ptr, 120)) //offset(120) Stores first 8 last block timestamps + prevBlockTimestampsArray2 := mload(add(ptr, 128)) //offset(120 + 8) Stores last 2 last block timestamps in least significant bits + count := gt(currBlockTimestamp, prevBlockTimestamp) + count := add(count, gt(currBlockTimestamp, shr(224, prevBlockTimestampsArray1))) + count := add(count, gt(currBlockTimestamp, and(shr(192, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(160, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(128, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(96, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(64, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(32, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(prevBlockTimestampsArray1, 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(32, prevBlockTimestampsArray2), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(prevBlockTimestampsArray2, 0xffffffff))) + } + require(count > 5, "updateChain: timestamp median"); + require(currBlockTimestamp < timestamp + MAX_FUTURE_BLOCKTIME, 'updateChain: timestamp future'); + + //Update prev block timestamps + assembly { + prevBlockTimestampsArray1 := shl(32, prevBlockTimestampsArray1) //Shift to the left, to remove oldest timestamp + prevBlockTimestampsArray1 := or(prevBlockTimestampsArray1, and(shr(32, prevBlockTimestampsArray2), 0xffffffff)) //Push timestamp from arr2 to arr1 + prevBlockTimestampsArray2 := shl(32, prevBlockTimestampsArray2) //Shift to the left + prevBlockTimestampsArray2 := or(prevBlockTimestampsArray2, prevBlockTimestamp) //Add previous block timestamp + } + + uint256 _chainWork = chainWork(self) + Difficulty.getChainWork(newTarget); + + //Save the stored blockheader to memory + assembly ("memory-safe") { + //Blockheader is already written to memory with prior writeHeaderAndGetDblSha256Hash() call + let ptr := mload(self) + + //Write chainwork at offset 80..112 + mstore(add(ptr, 80), _chainWork) + mstore(add(ptr, 112), + or( + or( + shl(224, currBlockHeight), //Current block height at offset 112..116 + shl(192, and(_lastDiffAdjustment, 0xffffffff)) //Last difficulty adjustment at offset 116..120 + ), + shr(64, prevBlockTimestampsArray1) //First 6 values of previous block timestamps at offset 120..144 + ) + ) + //Ensure we don't write outside the region of the stored blockheader byte array, so we + // have a little bit of an overlap here + mstore(add(ptr, 128), + or( + shl(64, prevBlockTimestampsArray1), + and(prevBlockTimestampsArray2, 0xffffffffffffffff) + ) + ) + } + } + +} diff --git a/ethereum/src/btc_relay/structs/StoredBlockHeaderTestnet.sol b/ethereum/src/btc_relay/structs/StoredBlockHeaderTestnet.sol new file mode 100644 index 0000000..2dc5bff --- /dev/null +++ b/ethereum/src/btc_relay/structs/StoredBlockHeaderTestnet.sol @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +import {CompactBlockHeaderImpl} from "./CompactBlockHeader.sol"; +import {Nbits} from "../utils/Nbits.sol"; +import {Difficulty} from "../utils/Difficulty.sol"; +import {Endianness} from "../btc_utils/Endianness.sol"; +import {DIFFICULTY_ADJUSTMENT_INTERVAL, MAX_FUTURE_BLOCKTIME} from "../Constants.sol"; + +/** + * Bitcoin stored blockheader defined as a fixed-length bytes32 array + * Structure (total 160 bytes): + * - bytes blockheader (80 bytes) + * - uint256 chainWork + * - uint32 blockHeight + * - uint32 lastDiffAdjustment + * - uint32[10] prevBlockTimestamps + */ +struct StoredBlockHeader { + bytes32[5] data; +} +uint256 constant StoredBlockHeaderByteLength = 160; +uint256 constant BitcoinBlockHeaderByteLength = 80; + +library StoredBlockHeaderImpl { + + using CompactBlockHeaderImpl for bytes; + + function fromCalldata(bytes calldata data, uint256 offset) pure internal returns (StoredBlockHeader memory storedHeader) { + require(data.length >= StoredBlockHeaderByteLength+offset, "StoredBlockHeader: out of bounds"); + assembly ("memory-safe") { + calldatacopy(mload(storedHeader), add(data.offset, offset), StoredBlockHeaderByteLength) //Store stored header data + } + } + + //Getters + function header_version(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(mload(self))) + } + result = Endianness.reverseUint32(result); + } + + function header_previousBlockhash(StoredBlockHeader memory self) pure internal returns (bytes32 result) { + assembly ("memory-safe") { + result := mload(add(mload(self), 4)) + } + } + + function header_merkleRoot(StoredBlockHeader memory self) pure internal returns (bytes32 result) { + assembly ("memory-safe") { + result := mload(add(mload(self), 36)) + } + } + + function header_timestamp(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 68))) + } + result = Endianness.reverseUint32(result); + } + + function header_nBitsLE(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 72))) + } + } + + function header_nonce(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 76))) + } + result = Endianness.reverseUint32(result); + } + + function chainWork(StoredBlockHeader memory self) pure internal returns (uint256 result) { + assembly ("memory-safe") { + result := mload(add(mload(self), 80)) + } + } + + function blockHeight(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 112))) + } + } + + function lastDiffAdjustment(StoredBlockHeader memory self) pure internal returns (uint32 result) { + assembly ("memory-safe") { + result := shr(224, mload(add(mload(self), 116))) + } + } + + function previousBlockTimestamps(StoredBlockHeader memory self) pure internal returns (uint32[10] memory result) { + assembly ("memory-safe") { + let ptr := mload(self) + let prevBlockTimestampsArray1 := mload(add(ptr, 120)) //offset(120) Stores first 8 last block timestamps + let prevBlockTimestampsArray2 := mload(add(ptr, 128)) //offset(120 + 8) Stores last 2 last block timestamps in least significant bits + mstore(result, shr(224, prevBlockTimestampsArray1)) + mstore(add(result, 32), and(shr(192, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 64), and(shr(160, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 96), and(shr(128, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 128), and(shr(96, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 160), and(shr(64, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 192), and(shr(32, prevBlockTimestampsArray1), 0xffffffff)) + mstore(add(result, 224), and(prevBlockTimestampsArray1, 0xffffffff)) + mstore(add(result, 256), and(shr(32, prevBlockTimestampsArray2), 0xffffffff)) + mstore(add(result, 288), and(prevBlockTimestampsArray2, 0xffffffff)) + } + } + + //Functions + function header_blockhash(StoredBlockHeader memory self) view internal returns (bytes32 result) { + assembly ("memory-safe") { + //Invoke first sha256 hash on the memory region now storing the current blockheader, destination is scratch space at 0x00 + pop(staticcall(gas(), 0x02, mload(self), BitcoinBlockHeaderByteLength, 0x00, 32)) + //Invoke second sha256 on the scratch space at 0x00 + pop(staticcall(gas(), 0x02, 0x00, 32, 0x00, 32)) + + //Load and return the result + result := mload(0x00) + } + } + + function hash(StoredBlockHeader memory self) pure internal returns (bytes32 result) { + assembly ("memory-safe") { + result := keccak256(mload(self), StoredBlockHeaderByteLength) + } + } + + //Writes new blockheader to the stored header memory and computes the double sha256 hash of this new blockheader + function writeHeaderAndGetDblSha256Hash(StoredBlockHeader memory self, bytes calldata headers, uint256 offset) private view returns (bytes32 result) { + assembly ("memory-safe") { + let ptr := mload(self) + + //Invoke first sha256 hash on the memory region storing the previous blockheader, destination is scratch space at 0x00 + pop(staticcall(gas(), 0x02, ptr, BitcoinBlockHeaderByteLength, 0x00, 32)) + //Invoke second sha256 on the scratch space at 0x00, copy directly to where the previous blockhash should be stored for next stored blockheader + pop(staticcall(gas(), 0x02, 0x00, 32, add(ptr, 4), 32)) + + //Copy other data to the stored blockheader from calldata + calldatacopy(ptr, add(headers.offset, offset), 4) + calldatacopy(add(ptr, 36), add(headers.offset, add(offset, 4)), 44) + + //Invoke first sha256 hash on the memory region now storing the current blockheader, destination is scratch space at 0x00 + pop(staticcall(gas(), 0x02, ptr, BitcoinBlockHeaderByteLength, 0x00, 32)) + //Invoke second sha256 on the scratch space at 0x00 + pop(staticcall(gas(), 0x02, 0x00, 32, 0x00, 32)) + + //Load and return the result + result := mload(0x00) + } + } + + function updateChain( + StoredBlockHeader memory self, bytes calldata headers, uint256 offset, uint256 timestamp + ) internal view returns (bytes32 blockHash) { + //We don't check whether pevious header matches since submitted headers are submitted + // without previousBlockHash fields, which is instead taken automatically from the + // current StoredBlockHeader, this allows us to save at least 512 gas on calldata + + uint32 prevBlockTimestamp = header_timestamp(self); + uint32 currBlockTimestamp = headers.timestamp(offset); + + //Check correct nbits + uint256 currBlockHeight = blockHeight(self) + 1; + uint32 _lastDiffAdjustment = lastDiffAdjustment(self); + uint32 newNbits = headers.nBitsLE(offset); + uint256 newTarget = Nbits.toTarget(newNbits); + + //Skip difficulty adjustment enforcement on testnet! + if(currBlockHeight % DIFFICULTY_ADJUSTMENT_INTERVAL == 0) { + // //Compute new nbits, bitcoin uses the timestamp of the last block in the epoch to re-target PoW difficulty + // // https://github.com/bitcoin/bitcoin/blob/78dae8caccd82cfbfd76557f1fb7d7557c7b5edb/src/pow.cpp#L49 + // uint256 computedNbits; + // (newTarget, computedNbits) = Difficulty.computeNewTarget( + // prevBlockTimestamp, + // _lastDiffAdjustment, + // header_nBitsLE(self), + // clampTarget + // ); + // require(newNbits == computedNbits, "updateChain: new nbits"); + // //Even though timestamp of the last block in epoch is used to re-target PoW difficulty, the first + // // block in a new epoch is used as last_diff_adjustment, the time it takes to mine the first block + // // in every epoch is therefore not taken into consideration when retargetting PoW - one of many + // // bitcoin's quirks + _lastDiffAdjustment = currBlockTimestamp; + } + // } else { + // //nbits must be same as last block + // require(newNbits == header_nBitsLE(self), "updateChain: nbits"); + // newTarget = Nbits.toTarget(newNbits); + // } + + //Check PoW + blockHash = writeHeaderAndGetDblSha256Hash(self, headers, offset); + require(uint256(Endianness.reverseBytes32(blockHash)) < Nbits.toTarget(newNbits), "updateChain: invalid PoW"); + + //Verify timestamp is larger than median of last 11 block timestamps + uint256 count = 0; + uint256 prevBlockTimestampsArray1; + uint256 prevBlockTimestampsArray2; + assembly ("memory-safe") { + let ptr := mload(self) + prevBlockTimestampsArray1 := mload(add(ptr, 120)) //offset(120) Stores first 8 last block timestamps + prevBlockTimestampsArray2 := mload(add(ptr, 128)) //offset(120 + 8) Stores last 2 last block timestamps in least significant bits + count := gt(currBlockTimestamp, prevBlockTimestamp) + count := add(count, gt(currBlockTimestamp, shr(224, prevBlockTimestampsArray1))) + count := add(count, gt(currBlockTimestamp, and(shr(192, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(160, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(128, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(96, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(64, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(32, prevBlockTimestampsArray1), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(prevBlockTimestampsArray1, 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(shr(32, prevBlockTimestampsArray2), 0xffffffff))) + count := add(count, gt(currBlockTimestamp, and(prevBlockTimestampsArray2, 0xffffffff))) + } + require(count > 5, "updateChain: timestamp median"); + require(currBlockTimestamp < timestamp + MAX_FUTURE_BLOCKTIME, 'updateChain: timestamp future'); + + //Update prev block timestamps + assembly { + prevBlockTimestampsArray1 := shl(32, prevBlockTimestampsArray1) //Shift to the left, to remove oldest timestamp + prevBlockTimestampsArray1 := or(prevBlockTimestampsArray1, and(shr(32, prevBlockTimestampsArray2), 0xffffffff)) //Push timestamp from arr2 to arr1 + prevBlockTimestampsArray2 := shl(32, prevBlockTimestampsArray2) //Shift to the left + prevBlockTimestampsArray2 := or(prevBlockTimestampsArray2, prevBlockTimestamp) //Add previous block timestamp + } + + uint256 _chainWork = chainWork(self) + Difficulty.getChainWork(newTarget); + + //Save the stored blockheader to memory + assembly ("memory-safe") { + //Blockheader is already written to memory with prior writeHeaderAndGetDblSha256Hash() call + let ptr := mload(self) + + //Write chainwork at offset 80..112 + mstore(add(ptr, 80), _chainWork) + mstore(add(ptr, 112), + or( + or( + shl(224, currBlockHeight), //Current block height at offset 112..116 + shl(192, and(_lastDiffAdjustment, 0xffffffff)) //Last difficulty adjustment at offset 116..120 + ), + shr(64, prevBlockTimestampsArray1) //First 6 values of previous block timestamps at offset 120..144 + ) + ) + //Ensure we don't write outside the region of the stored blockheader byte array, so we + // have a little bit of an overlap here + mstore(add(ptr, 128), + or( + shl(64, prevBlockTimestampsArray1), + and(prevBlockTimestampsArray2, 0xffffffffffffffff) + ) + ) + } + } + +} diff --git a/ethereum/src/btc_relay/utils/Difficulty.sol b/ethereum/src/btc_relay/utils/Difficulty.sol new file mode 100644 index 0000000..3bb8920 --- /dev/null +++ b/ethereum/src/btc_relay/utils/Difficulty.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +import {Nbits} from "./Nbits.sol"; +import { + TARGET_TIMESPAN, TARGET_TIMESPAN_DIV_4, TARGET_TIMESPAN_MUL_4, + ROUNDED_MAX_TARGET, ROUNDED_MAX_TARGET_NBITS +} from "../Constants.sol"; + +library Difficulty { + + //Old version of the target computation, operates on targets and uses uint256 arithmetics + // function _computeNewTarget(uint32 prevTimestamp, uint32 startTimestamp, uint256 prevTarget) pure internal returns (uint256 newTarget) { + // uint256 timespan = uint256(prevTimestamp) - uint256(startTimestamp); + + // //Difficulty increase/decrease multiples are clamped between 0.25 (-75%) and 4 (+300%) + // if(timespan < TARGET_TIMESPAN_DIV_4) timespan = TARGET_TIMESPAN_DIV_4; + // if(timespan > TARGET_TIMESPAN_MUL_4) timespan = TARGET_TIMESPAN_MUL_4; + + // newTarget = prevTarget * timespan / TARGET_TIMESPAN; + // if(newTarget > UNROUNDED_MAX_TARGET) newTarget = UNROUNDED_MAX_TARGET; + // } + + //New version of the target computation, works directly with nBits + function computeNewTarget(uint32 prevTimestamp, uint32 startTimestamp, uint32 prevNbitsLE, bool clampTarget) pure internal returns (uint256 newTarget, uint32 newNbitsLE) { + uint256 timespan = uint256(prevTimestamp) - uint256(startTimestamp); + //Difficulty increase/decrease multiples are clamped between 0.25 (-75%) and 4 (+300%) + if(timespan < TARGET_TIMESPAN_DIV_4) timespan = TARGET_TIMESPAN_DIV_4; + if(timespan > TARGET_TIMESPAN_MUL_4) timespan = TARGET_TIMESPAN_MUL_4; + + uint256 targetTimespan = TARGET_TIMESPAN; + assembly { + let nSize := and(prevNbitsLE, 0xFF) + let nWord := or( + or( + and(shl(16, prevNbitsLE), 0x7f000000), + and(prevNbitsLE, 0xff0000) + ), + and(shr(16, prevNbitsLE), 0xff00) + ) //Shift it 1 more byte to the left, so we have enough precision when we do multiplication and division + //The maximum value of the nWord is 0x7fffff00 (due to the extra shift to the left) + //The range of values for the newNWord is from nWord/4 to nWord*4 + let newNWord := div(mul(nWord, timespan), targetTimespan) //Adjust the nWord based on timestamp + + if gt(and(newNWord, 0xff00000000), 0) { + //The result requires increasing the nSize + nSize := add(nSize, 1) + newNWord := shr(8, newNWord) + } + if iszero(and(newNWord, 0xff000000)) { + //The result requires decreasing the nSize + nSize := sub(nSize, 1) + newNWord := shl(8, newNWord) + } + //Any other possibility cannot happen, because of the bounded div 4 and mul 4 adjustments + + //Check that nbits are not encoding negative number, in case yes, shift + // the result one byte to the right and adjust nSize accordingly + if eq(and(newNWord, 0x80000000), 0x80000000) { + newNWord := shr(8, newNWord) + nSize := add(nSize, 1) + } + + newNbitsLE := or( + or( + and(shl(16, newNWord), 0xff000000), + and(newNWord, 0xff0000) + ), + or( + and(shr(16, newNWord), 0xff00), + nSize + ) + ) + } + + newTarget = Nbits.toTarget(newNbitsLE); + + if(clampTarget) { + if(newTarget > ROUNDED_MAX_TARGET) { + newNbitsLE = ROUNDED_MAX_TARGET_NBITS; + newTarget = ROUNDED_MAX_TARGET; + } + } + } + + //Compute chainwork according to bitcoin core implementation + // https://github.com/bitcoin/bitcoin/blob/master/src/chain.cpp#L131 + function getChainWork(uint256 target) pure internal returns (uint256 chainwork) { + assembly { + chainwork := add(div(not(target), add(target, 1)), 1) + } + } +} diff --git a/ethereum/src/btc_relay/utils/Nbits.sol b/ethereum/src/btc_relay/utils/Nbits.sol new file mode 100644 index 0000000..e6f7a65 --- /dev/null +++ b/ethereum/src/btc_relay/utils/Nbits.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +library Nbits { + + //Calculates difficulty target from nBits + //Description: https://btcinformation.org/en/developer-reference#target-nbits + //This implementation panics on negative targets, accepts oveflown targets + function toTarget(uint32 nBitsLE) pure internal returns (uint256 target) { + uint256 nSize; + assembly { + nSize := and(nBitsLE, 0xFF) + let nWord := or( + or( + and(shl(8, nBitsLE), 0x7f0000), + and(shr(8, nBitsLE), 0xff00) + ), + and(shr(24, nBitsLE), 0xff) + ) + + switch lt(nSize, 3) + case 1 { + target := shr(shl(3, sub(3, nSize)), nWord) //shl(3, sub(3, nSize)) == mul(sub(3, nSize), 8) + } + default { + target := shl(shl(3, sub(nSize, 3)), nWord) //shl(3, sub(nSize, 3)) == mul(sub(nSize, 3), 8) + } + } + require(target == 0 || nBitsLE & 0x8000 == 0, "Nbits: negative"); + } + + //Compresses difficulty target to nBits + //Description: https://btcinformation.org/en/developer-reference#target-nbits + function toReversedNbits(uint256 target) pure internal returns (uint32 nBitsLE) { + assembly { + switch target + case 0 { + nBitsLE := 0x00000000 + } + default { + //Find first non-zero byte + let start := 0 + for + { } + iszero(byte(start, target)) + { start := add(start, 1) } + {} + let nSize := sub(32, start) + + let result + switch lt(nSize, 3) case 1 { + result := shl(shl(3, sub(3, nSize)), target) //shl(3, sub(3, nSize)) == mul(sub(3, nSize), 8) + } + default { + result := shr(shl(3, sub(nSize, 3)), target) //shl(3, sub(nSize, 3)) == mul(sub(nSize, 3), 8) + } + + //Check that nbits are not encoding negative number, in case yes, shift + // the result one byte to the right and adjust nSize accordingly + if eq(and(result, 0x00800000), 0x00800000) { + result := shr(8, result) + nSize := add(nSize, 1) + } + + nBitsLE := or( + or( + and(shl(24, result), 0xff000000), + and(shl(8, result), 0xff0000) + ), + or( + and(shr(8, result), 0xff00), + nSize + ) + ) + } + } + } + +} \ No newline at end of file From 520430ae92018a3953e6830ac436c4831d4a72e4 Mon Sep 17 00:00:00 2001 From: Denys <53053282+sh3ifu@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:58:13 +0300 Subject: [PATCH 2/2] Update dependencies (#31) * chore: bump forge-std to v1.16.1 * chore: bump Solidity to 0.8.35 and pin solc version * test: fix flaky timelock test under via_ir * chore: vendor Chainlink AggregatorV3Interface, drop archived dependency * fix(R-W-05): gate fundsOut behind pause; add two-tier pause * test(R-W-05): two-tier pause regression + suite API sync * fix(R-W-11): enforce immutable MIN_TIMELOCK floor on timelockDuration * test(R-W-11): MIN_TIMELOCK floor regression + constructor arg sync (#37) --- .gitmodules | 3 - ethereum/foundry.toml | 2 +- ethereum/lib/chainlink-brownie-contracts | 1 - ethereum/lib/forge-std | 2 +- ethereum/script/deploy/DeployAll.s.sol | 2 +- ethereum/script/deploy/DeployBaseBridge.s.sol | 2 +- ethereum/script/deploy/DeployBridge.s.sol | 2 +- .../deploy/DeployCommissionManager.s.sol | 2 +- .../script/deploy/DeployMultisigProxy.s.sol | 2 +- .../script/deploy/DeployRGBVerifier.s.sol | 2 +- .../deploy/DeployRgbSettlementModule.s.sol | 2 +- .../script/deploy/DeployRouteRegistry.s.sol | 2 +- ethereum/script/interact/BridgeFundsIn.s.sol | 2 +- ethereum/script/interact/EmergencyPause.s.sol | 2 +- .../script/interact/EmergencyUnpause.s.sol | 2 +- .../interact/MultisigExecuteFundsOut.s.sol | 2 +- .../interact/MultisigProposeSetRoute.s.sol | 2 +- ethereum/src/BaseBridge.sol | 4 +- ethereum/src/Bridge.sol | 4 +- ethereum/src/BridgeBase.sol | 79 +++++++- ethereum/src/CommissionManager.sol | 4 +- ethereum/src/MultisigProxy.sol | 83 +++++++- ethereum/src/RouteRegistry.sol | 2 +- ethereum/src/btc_relay/BtcRelay.sol | 2 +- ethereum/src/btc_relay/BtcRelayTestnet.sol | 2 +- ethereum/src/btc_relay/Constants.sol | 2 +- ethereum/src/btc_relay/Events.sol | 2 +- .../src/btc_relay/btc_utils/Endianness.sol | 2 +- .../src/btc_relay/state/BtcRelayState.sol | 2 +- ethereum/src/btc_relay/state/Fork.sol | 2 +- .../btc_relay/structs/CompactBlockHeader.sol | 2 +- .../btc_relay/structs/StoredBlockHeader.sol | 2 +- .../structs/StoredBlockHeaderTestnet.sol | 2 +- ethereum/src/btc_relay/utils/Difficulty.sol | 2 +- ethereum/src/btc_relay/utils/Nbits.sol | 2 +- .../src/interfaces/AggregatorV3Interface.sol | 25 +++ ethereum/src/interfaces/IBridge.sol | 2 +- ethereum/src/interfaces/IBtcRelayView.sol | 2 +- .../src/interfaces/ICommissionManager.sol | 2 +- ethereum/src/interfaces/IFinalityVerifier.sol | 2 +- ethereum/src/interfaces/IMultisigProxy.sol | 30 ++- ethereum/src/interfaces/IRouteRegistry.sol | 2 +- ethereum/src/interfaces/ISettlementModule.sol | 2 +- ethereum/src/interfaces/RouteTypes.sol | 2 +- .../src/settlement/NullSettlementModule.sol | 2 +- .../src/settlement/RgbSettlementModule.sol | 2 +- ethereum/src/verifiers/NullVerifier.sol | 2 +- ethereum/src/verifiers/RGBVerifier.sol | 2 +- ethereum/test/BaseBridge.t.sol | 126 +++++++++++- ethereum/test/Bridge.t.sol | 10 +- ethereum/test/CommissionManager.t.sol | 2 +- ethereum/test/Integration.t.sol | 8 +- ethereum/test/MultisigProxy.t.sol | 183 ++++++++++++++++-- ethereum/test/RgbSettlementModule.t.sol | 2 +- ethereum/test/RouteRegistry.t.sol | 2 +- ethereum/test/mocks/MockAggregatorV3.sol | 4 +- ethereum/test/mocks/MockBtcRelay.sol | 2 +- ethereum/test/mocks/MockERC20.sol | 2 +- ethereum/test/mocks/MockFinalityVerifier.sol | 2 +- ethereum/test/mocks/MockSettlementModule.sol | 2 +- ethereum/test/mocks/MultisigHelper.sol | 18 +- 61 files changed, 572 insertions(+), 102 deletions(-) delete mode 160000 ethereum/lib/chainlink-brownie-contracts create mode 100644 ethereum/src/interfaces/AggregatorV3Interface.sol diff --git a/.gitmodules b/.gitmodules index 106faf9..032e5d7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,3 @@ [submodule "ethereum/lib/openzeppelin-contracts"] path = ethereum/lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts -[submodule "ethereum/lib/chainlink-brownie-contracts"] - path = ethereum/lib/chainlink-brownie-contracts - url = https://github.com/smartcontractkit/chainlink-brownie-contracts diff --git a/ethereum/foundry.toml b/ethereum/foundry.toml index 1c75904..0f55507 100644 --- a/ethereum/foundry.toml +++ b/ethereum/foundry.toml @@ -2,6 +2,7 @@ src = "src" out = "out" libs = ["lib"] +solc_version = "0.8.35" optimizer = true optimizer_runs = 200 via_ir = true @@ -9,5 +10,4 @@ via_ir = true # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options remappings = [ "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", - "@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/", ] diff --git a/ethereum/lib/chainlink-brownie-contracts b/ethereum/lib/chainlink-brownie-contracts deleted file mode 160000 index 5cb41fb..0000000 --- a/ethereum/lib/chainlink-brownie-contracts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5cb41fbc9b525338b6098da5ea7dd0b7e92f89e4 diff --git a/ethereum/lib/forge-std b/ethereum/lib/forge-std index 0844d7e..620536f 160000 --- a/ethereum/lib/forge-std +++ b/ethereum/lib/forge-std @@ -1 +1 @@ -Subproject commit 0844d7e1fc5e60d77b68e469bff60265f236c398 +Subproject commit 620536fa5277db4e3fd46772d5cbc1ea0696fb43 diff --git a/ethereum/script/deploy/DeployAll.s.sol b/ethereum/script/deploy/DeployAll.s.sol index ca17ddf..14a74ee 100644 --- a/ethereum/script/deploy/DeployAll.s.sol +++ b/ethereum/script/deploy/DeployAll.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; diff --git a/ethereum/script/deploy/DeployBaseBridge.s.sol b/ethereum/script/deploy/DeployBaseBridge.s.sol index 11df467..3aea955 100644 --- a/ethereum/script/deploy/DeployBaseBridge.s.sol +++ b/ethereum/script/deploy/DeployBaseBridge.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { BaseBridge } from '../../src/BaseBridge.sol'; diff --git a/ethereum/script/deploy/DeployBridge.s.sol b/ethereum/script/deploy/DeployBridge.s.sol index 1421ce3..6f194e9 100644 --- a/ethereum/script/deploy/DeployBridge.s.sol +++ b/ethereum/script/deploy/DeployBridge.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { Bridge } from '../../src/Bridge.sol'; diff --git a/ethereum/script/deploy/DeployCommissionManager.s.sol b/ethereum/script/deploy/DeployCommissionManager.s.sol index 613feb8..5ea4dbe 100644 --- a/ethereum/script/deploy/DeployCommissionManager.s.sol +++ b/ethereum/script/deploy/DeployCommissionManager.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { CommissionManager } from '../../src/CommissionManager.sol'; diff --git a/ethereum/script/deploy/DeployMultisigProxy.s.sol b/ethereum/script/deploy/DeployMultisigProxy.s.sol index a54f326..45240a8 100644 --- a/ethereum/script/deploy/DeployMultisigProxy.s.sol +++ b/ethereum/script/deploy/DeployMultisigProxy.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { MultisigProxy } from '../../src/MultisigProxy.sol'; diff --git a/ethereum/script/deploy/DeployRGBVerifier.s.sol b/ethereum/script/deploy/DeployRGBVerifier.s.sol index fae57ef..f321733 100644 --- a/ethereum/script/deploy/DeployRGBVerifier.s.sol +++ b/ethereum/script/deploy/DeployRGBVerifier.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { RGBVerifier } from '../../src/verifiers/RGBVerifier.sol'; diff --git a/ethereum/script/deploy/DeployRgbSettlementModule.s.sol b/ethereum/script/deploy/DeployRgbSettlementModule.s.sol index 63e8a00..128fbfa 100644 --- a/ethereum/script/deploy/DeployRgbSettlementModule.s.sol +++ b/ethereum/script/deploy/DeployRgbSettlementModule.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { RgbSettlementModule } from '../../src/settlement/RgbSettlementModule.sol'; diff --git a/ethereum/script/deploy/DeployRouteRegistry.s.sol b/ethereum/script/deploy/DeployRouteRegistry.s.sol index d866706..e64bfa3 100644 --- a/ethereum/script/deploy/DeployRouteRegistry.s.sol +++ b/ethereum/script/deploy/DeployRouteRegistry.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { RouteRegistry } from '../../src/RouteRegistry.sol'; diff --git a/ethereum/script/interact/BridgeFundsIn.s.sol b/ethereum/script/interact/BridgeFundsIn.s.sol index 2ebe8ba..3b26944 100644 --- a/ethereum/script/interact/BridgeFundsIn.s.sol +++ b/ethereum/script/interact/BridgeFundsIn.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; diff --git a/ethereum/script/interact/EmergencyPause.s.sol b/ethereum/script/interact/EmergencyPause.s.sol index 041f668..58fde87 100644 --- a/ethereum/script/interact/EmergencyPause.s.sol +++ b/ethereum/script/interact/EmergencyPause.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { MultisigProxy } from '../../src/MultisigProxy.sol'; diff --git a/ethereum/script/interact/EmergencyUnpause.s.sol b/ethereum/script/interact/EmergencyUnpause.s.sol index cd20bba..3cac2a0 100644 --- a/ethereum/script/interact/EmergencyUnpause.s.sol +++ b/ethereum/script/interact/EmergencyUnpause.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { MultisigProxy } from '../../src/MultisigProxy.sol'; diff --git a/ethereum/script/interact/MultisigExecuteFundsOut.s.sol b/ethereum/script/interact/MultisigExecuteFundsOut.s.sol index 77ea4ed..a1d7e24 100644 --- a/ethereum/script/interact/MultisigExecuteFundsOut.s.sol +++ b/ethereum/script/interact/MultisigExecuteFundsOut.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { MultisigProxy } from '../../src/MultisigProxy.sol'; diff --git a/ethereum/script/interact/MultisigProposeSetRoute.s.sol b/ethereum/script/interact/MultisigProposeSetRoute.s.sol index 634f83f..b78ae4b 100644 --- a/ethereum/script/interact/MultisigProposeSetRoute.s.sol +++ b/ethereum/script/interact/MultisigProposeSetRoute.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Script, console2 } from 'forge-std/Script.sol'; import { MultisigProxy } from '../../src/MultisigProxy.sol'; diff --git a/ethereum/src/BaseBridge.sol b/ethereum/src/BaseBridge.sol index f05f7ab..2bdee51 100644 --- a/ethereum/src/BaseBridge.sol +++ b/ethereum/src/BaseBridge.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; import { SafeERC20 } from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; @@ -68,7 +68,7 @@ contract BaseBridge is BridgeBase { uint256 amount, uint256 operationId, string calldata sourceAddress - ) external onlyOwner { + ) external onlyOwner whenOutflowNotPaused { if (recipient == address(0)) revert InvalidRecipientAddress(); if (amount > IERC20(TOKEN).balanceOf(address(this))) revert AmountExceedBridgePool(); diff --git a/ethereum/src/Bridge.sol b/ethereum/src/Bridge.sol index 211da14..117ecea 100644 --- a/ethereum/src/Bridge.sol +++ b/ethereum/src/Bridge.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; import { SafeERC20 } from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; @@ -174,7 +174,7 @@ contract Bridge is BridgeBase, IBridge, ReentrancyGuard { string calldata sourceAddress, bytes calldata proof, bytes calldata settlementData - ) external override onlyOwner nonReentrant { + ) external override onlyOwner nonReentrant whenOutflowNotPaused { if (recipient == address(0)) revert InvalidRecipientAddress(); if (sourceChainId == 0) revert InvalidSourceChainId(); if (destinationChainId == 0) revert InvalidDestinationChainId(); diff --git a/ethereum/src/BridgeBase.sol b/ethereum/src/BridgeBase.sol index 1d75309..443c9ed 100644 --- a/ethereum/src/BridgeBase.sol +++ b/ethereum/src/BridgeBase.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Ownable } from '@openzeppelin/contracts/access/Ownable.sol'; import { SafeERC20, IERC20 } from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; @@ -23,6 +23,15 @@ abstract contract BridgeBase is Ownable, Pausable { /// @notice The only accepted ERC-20 token. Immutable after deploy. address public immutable TOKEN; + /// @notice Outflow (withdrawal) freeze flag, independent of the inflow + /// freeze. Inflow reuses OpenZeppelin `Pausable._paused` (gated by + /// `whenNotPaused` on `fundsIn`); this second flag gates `fundsOut` + /// via `whenOutflowNotPaused`. Two independent flags are required so + /// that a planned inflow-only pause (deposits frozen, withdrawals + /// still open for liquidity migration) and an emergency pause (both + /// frozen) can be expressed separately. + bool private _outflowPaused; + // ========================================================================= // Events // ========================================================================= @@ -37,6 +46,14 @@ abstract contract BridgeBase is Ownable, Pausable { uint256 amount ); + /// @notice Emitted when the outflow (withdrawal) path is frozen. + /// @dev The inflow path reuses OpenZeppelin `Pausable`, which emits its own + /// `Paused` / `Unpaused` events. + event OutflowPaused(address account); + + /// @notice Emitted when the outflow (withdrawal) path is unfrozen. + event OutflowUnpaused(address account); + // ========================================================================= // Errors // ========================================================================= @@ -46,6 +63,22 @@ abstract contract BridgeBase is Ownable, Pausable { error AmountExceedBridgePool(); error RenounceOwnershipBlocked(); + /// @notice Thrown by `whenOutflowNotPaused` when the outflow path is frozen. + error OutflowEnforcedPause(); + + // ========================================================================= + // Modifiers + // ========================================================================= + + /// @dev Reverts when the outflow path is frozen. Mirrors OpenZeppelin's + /// `whenNotPaused` (which gates inflow) but for the independent + /// `_outflowPaused` flag, so `fundsOut` can be frozen without freezing + /// `fundsIn` and vice versa. + modifier whenOutflowNotPaused() { + if (_outflowPaused) revert OutflowEnforcedPause(); + _; + } + // ========================================================================= // Constructor // ========================================================================= @@ -59,11 +92,41 @@ abstract contract BridgeBase is Ownable, Pausable { // Owner-only // ========================================================================= - /// @notice Pause all user-facing operations. - function pause() external onlyOwner { _pause(); } + /// @notice Freeze the inflow (deposit) path only; withdrawals stay open. + /// @dev Intended for planned operations such as a bridge upgrade or + /// liquidity migration. On the production `Bridge` this is reached + /// through the timelocked `MultisigProxy` propose -> execute path + /// (`PauseInflow` operation), giving the federation an observation + /// window before it takes effect. + function pauseInflow() external onlyOwner { _pause(); } + + /// @notice Resume the inflow (deposit) path. + function unpauseInflow() external onlyOwner { _unpause(); } + + /// @notice Emergency freeze of BOTH inflow and outflow, set atomically. + /// @dev No-timelock control for incident response. On the production + /// `Bridge` it is reached through the federation-signed + /// `MultisigProxy.emergencyPause` (multisig only, no propose step). + /// Each flag is set idempotently, so the call does not revert if one + /// side is already frozen (e.g. inflow already paused via the planned + /// path). Freezing `fundsOut` also freezes the enclave/TEE release + /// path, which routes through `fundsOut`. + function emergencyPauseAll() external onlyOwner { + if (!paused()) _pause(); // inflow (OpenZeppelin flag), guarded against double-pause + if (!_outflowPaused) { + _outflowPaused = true; + emit OutflowPaused(_msgSender()); + } + } - /// @notice Resume all user-facing operations. - function unpause() external onlyOwner { _unpause(); } + /// @notice Lift the emergency freeze on BOTH inflow and outflow. Idempotent. + function emergencyUnpauseAll() external onlyOwner { + if (paused()) _unpause(); + if (_outflowPaused) { + _outflowPaused = false; + emit OutflowUnpaused(_msgSender()); + } + } /// @notice Permanently blocks renouncing ownership. function renounceOwnership() public view virtual override onlyOwner { @@ -79,6 +142,12 @@ abstract contract BridgeBase is Ownable, Pausable { return IERC20(TOKEN).balanceOf(address(this)); } + /// @notice Whether the outflow (withdrawal) path is currently frozen. + /// @dev The inflow freeze is exposed by OpenZeppelin's `paused()`. + function outflowPaused() external view returns (bool) { + return _outflowPaused; + } + /// @notice Returns the current chain ID. function getChainId() public view returns (uint256) { uint256 id; diff --git a/ethereum/src/CommissionManager.sol b/ethereum/src/CommissionManager.sol index b0eaed1..0ff9e02 100644 --- a/ethereum/src/CommissionManager.sol +++ b/ethereum/src/CommissionManager.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; @@ -7,7 +7,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; +import {AggregatorV3Interface} from "./interfaces/AggregatorV3Interface.sol"; import { CommissionConfig, diff --git a/ethereum/src/MultisigProxy.sol b/ethereum/src/MultisigProxy.sol index 7585d39..8956c22 100644 --- a/ethereum/src/MultisigProxy.sol +++ b/ethereum/src/MultisigProxy.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity 0.8.35; import { ECDSA } from '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; import { IMultisigProxy } from './interfaces/IMultisigProxy.sol'; @@ -48,6 +48,13 @@ contract MultisigProxy is IMultisigProxy { /// @notice Minimum delay (seconds) between proposal creation and execution. uint256 public timelockDuration; + /// @notice Lower bound for `timelockDuration`, fixed at deploy time. + /// @dev Immutable so the governance observation/veto window can never be + /// reduced below this floor — not by a `SetTimelockDuration` proposal + /// and not by any later action. Set in the constructor and validated to + /// be in (0, MAX_PROPOSAL_LIFETIME). + uint256 public immutable MIN_TIMELOCK; + // ========================================================================= // Constants // ========================================================================= @@ -143,6 +150,14 @@ contract MultisigProxy is IMultisigProxy { 'EmergencyUnpause(uint256 nonce,uint256 deadline)' ); + // Federation propose — planned inflow-only pause (timelocked) + bytes32 private constant _PROPOSE_PAUSE_INFLOW_TYPEHASH = keccak256( + 'ProposePauseInflow(uint256 nonce,uint256 deadline)' + ); + bytes32 private constant _PROPOSE_UNPAUSE_INFLOW_TYPEHASH = keccak256( + 'ProposeUnpauseInflow(uint256 nonce,uint256 deadline)' + ); + // ========================================================================= // Constructor // ========================================================================= @@ -155,7 +170,8 @@ contract MultisigProxy is IMultisigProxy { address[] memory federationSigners_, uint256 federationThreshold_, address commissionRecipient_, - uint256 timelockDuration_ + uint256 timelockDuration_, + uint256 minTimelock_ ) { if (bridge_ == address(0)) revert ZeroBridge(); if (commissionManager_ == address(0)) revert ZeroCommissionManager(); @@ -164,6 +180,8 @@ contract MultisigProxy is IMultisigProxy { if (federationSigners_.length == 0) revert NoSigners(); if (federationThreshold_ == 0 || federationThreshold_ > federationSigners_.length) revert InvalidThreshold(); 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_); @@ -177,6 +195,7 @@ contract MultisigProxy is IMultisigProxy { federationThreshold = federationThreshold_; commissionRecipient = commissionRecipient_; timelockDuration = timelockDuration_; + MIN_TIMELOCK = minTimelock_; // Default TEE allowlist: Bridge.fundsOut. Additional (target, selector) pairs // (e.g. for the LayerZero adapter's outbound `sendOut`) are added later via @@ -298,7 +317,10 @@ contract MultisigProxy is IMultisigProxy { proposalNonce++; - (bool ok, bytes memory ret) = bridge.call(abi.encodeWithSignature('pause()')); + // Emergency freeze of BOTH inflow and outflow — no timelock, federation + // signatures only. Also halts the enclave/TEE release path (it routes + // through Bridge.fundsOut, now gated by whenOutflowNotPaused). + (bool ok, bytes memory ret) = bridge.call(abi.encodeWithSignature('emergencyPauseAll()')); _propagateRevert(ok, ret); emit EmergencyPaused(nonce, fedBitmap); @@ -321,7 +343,8 @@ contract MultisigProxy is IMultisigProxy { proposalNonce++; - (bool ok, bytes memory ret) = bridge.call(abi.encodeWithSignature('unpause()')); + // Lift the emergency freeze on BOTH inflow and outflow. + (bool ok, bytes memory ret) = bridge.call(abi.encodeWithSignature('emergencyUnpauseAll()')); _propagateRevert(ok, ret); emit EmergencyUnpaused(nonce, fedBitmap); @@ -654,6 +677,48 @@ contract MultisigProxy is IMultisigProxy { ); } + /// @inheritdoc IMultisigProxy + /// @dev Planned inflow-only pause: freezes deposits while leaving + /// withdrawals open (e.g. to migrate liquidity during an upgrade). + /// Runs through the timelocked propose -> execute path, so the + /// federation has an observation window. The emergency, no-timelock + /// freeze of BOTH paths is `emergencyPause` instead. Carries no payload. + function proposePauseInflow( + uint256 nonce, + uint256 deadline, + uint256 fedBitmap, + bytes[] calldata fedSigs + ) external returns (bytes32) { + bytes32 structHash = keccak256(abi.encode( + _PROPOSE_PAUSE_INFLOW_TYPEHASH, nonce, deadline + )); + + return _propose( + OperationType.PauseInflow, + '', + nonce, deadline, structHash, fedBitmap, fedSigs + ); + } + + /// @inheritdoc IMultisigProxy + /// @dev Resumes the inflow path, reversing `proposePauseInflow`. Timelocked. + function proposeUnpauseInflow( + uint256 nonce, + uint256 deadline, + uint256 fedBitmap, + bytes[] calldata fedSigs + ) external returns (bytes32) { + bytes32 structHash = keccak256(abi.encode( + _PROPOSE_UNPAUSE_INFLOW_TYPEHASH, nonce, deadline + )); + + return _propose( + OperationType.UnpauseInflow, + '', + nonce, deadline, structHash, fedBitmap, fedSigs + ); + } + // ========================================================================= // Cancel // ========================================================================= @@ -827,6 +892,7 @@ contract MultisigProxy is IMultisigProxy { } else if (opType == OperationType.SetTimelockDuration) { uint256 newDuration = abi.decode(opData, (uint256)); + if (newDuration < MIN_TIMELOCK) revert TimelockTooShort(); if (newDuration >= MAX_PROPOSAL_LIFETIME) revert TimelockTooLong(); timelockDuration = newDuration; emit TimelockDurationUpdated(newDuration); @@ -905,6 +971,15 @@ contract MultisigProxy is IMultisigProxy { address newRegistry = abi.decode(opData, (address)); IBridge(bridge).setRouteRegistry(newRegistry); + } else if (opType == OperationType.PauseInflow) { + // Planned inflow-only freeze (no payload). Withdrawals stay open. + (bool ok, bytes memory ret) = bridge.call(abi.encodeWithSignature('pauseInflow()')); + _propagateRevert(ok, ret); + + } else if (opType == OperationType.UnpauseInflow) { + (bool ok, bytes memory ret) = bridge.call(abi.encodeWithSignature('unpauseInflow()')); + _propagateRevert(ok, ret); + } else { revert UnknownOperationType(); } diff --git a/ethereum/src/RouteRegistry.sol b/ethereum/src/RouteRegistry.sol index cf9ea6b..e050a96 100644 --- a/ethereum/src/RouteRegistry.sol +++ b/ethereum/src/RouteRegistry.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Ownable } from '@openzeppelin/contracts/access/Ownable.sol'; diff --git a/ethereum/src/btc_relay/BtcRelay.sol b/ethereum/src/btc_relay/BtcRelay.sol index b411454..4bddd70 100644 --- a/ethereum/src/btc_relay/BtcRelay.sol +++ b/ethereum/src/btc_relay/BtcRelay.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; import {ForkImpl, Fork} from "./state/Fork.sol"; import {StoredBlockHeaderImpl, StoredBlockHeader, StoredBlockHeaderByteLength} from "./structs/StoredBlockHeader.sol"; diff --git a/ethereum/src/btc_relay/BtcRelayTestnet.sol b/ethereum/src/btc_relay/BtcRelayTestnet.sol index 96c506c..10cc362 100644 --- a/ethereum/src/btc_relay/BtcRelayTestnet.sol +++ b/ethereum/src/btc_relay/BtcRelayTestnet.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; import {ForkImpl, Fork} from "./state/Fork.sol"; import {StoredBlockHeaderImpl, StoredBlockHeader, StoredBlockHeaderByteLength} from "./structs/StoredBlockHeaderTestnet.sol"; diff --git a/ethereum/src/btc_relay/Constants.sol b/ethereum/src/btc_relay/Constants.sol index 132566d..73507db 100644 --- a/ethereum/src/btc_relay/Constants.sol +++ b/ethereum/src/btc_relay/Constants.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; //Interval (in blocks) of the difficulty adjustment uint256 constant DIFFICULTY_ADJUSTMENT_INTERVAL = 2016; diff --git a/ethereum/src/btc_relay/Events.sol b/ethereum/src/btc_relay/Events.sol index 56a5d45..52ea26c 100644 --- a/ethereum/src/btc_relay/Events.sol +++ b/ethereum/src/btc_relay/Events.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; library Events { event StoreHeader(bytes32 indexed commitHash, bytes32 indexed blockHash); diff --git a/ethereum/src/btc_relay/btc_utils/Endianness.sol b/ethereum/src/btc_relay/btc_utils/Endianness.sol index c2cedad..43867fd 100644 --- a/ethereum/src/btc_relay/btc_utils/Endianness.sol +++ b/ethereum/src/btc_relay/btc_utils/Endianness.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; library Endianness { function reverseUint32(uint32 input) internal pure returns (uint32) { diff --git a/ethereum/src/btc_relay/state/BtcRelayState.sol b/ethereum/src/btc_relay/state/BtcRelayState.sol index 17afce9..c0fc2e6 100644 --- a/ethereum/src/btc_relay/state/BtcRelayState.sol +++ b/ethereum/src/btc_relay/state/BtcRelayState.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; struct BtcRelayState { uint32 blockHeight; diff --git a/ethereum/src/btc_relay/state/Fork.sol b/ethereum/src/btc_relay/state/Fork.sol index cfc449c..f60b778 100644 --- a/ethereum/src/btc_relay/state/Fork.sol +++ b/ethereum/src/btc_relay/state/Fork.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; struct Fork { //Slot 0 diff --git a/ethereum/src/btc_relay/structs/CompactBlockHeader.sol b/ethereum/src/btc_relay/structs/CompactBlockHeader.sol index df522e8..38f8623 100644 --- a/ethereum/src/btc_relay/structs/CompactBlockHeader.sol +++ b/ethereum/src/btc_relay/structs/CompactBlockHeader.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; import {Endianness} from "../btc_utils/Endianness.sol"; diff --git a/ethereum/src/btc_relay/structs/StoredBlockHeader.sol b/ethereum/src/btc_relay/structs/StoredBlockHeader.sol index ea06af4..9d778fd 100644 --- a/ethereum/src/btc_relay/structs/StoredBlockHeader.sol +++ b/ethereum/src/btc_relay/structs/StoredBlockHeader.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; import {CompactBlockHeaderImpl} from "./CompactBlockHeader.sol"; import {Nbits} from "../utils/Nbits.sol"; diff --git a/ethereum/src/btc_relay/structs/StoredBlockHeaderTestnet.sol b/ethereum/src/btc_relay/structs/StoredBlockHeaderTestnet.sol index 2dc5bff..50e9e8f 100644 --- a/ethereum/src/btc_relay/structs/StoredBlockHeaderTestnet.sol +++ b/ethereum/src/btc_relay/structs/StoredBlockHeaderTestnet.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; import {CompactBlockHeaderImpl} from "./CompactBlockHeader.sol"; import {Nbits} from "../utils/Nbits.sol"; diff --git a/ethereum/src/btc_relay/utils/Difficulty.sol b/ethereum/src/btc_relay/utils/Difficulty.sol index 3bb8920..7d8ff86 100644 --- a/ethereum/src/btc_relay/utils/Difficulty.sol +++ b/ethereum/src/btc_relay/utils/Difficulty.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; import {Nbits} from "./Nbits.sol"; import { diff --git a/ethereum/src/btc_relay/utils/Nbits.sol b/ethereum/src/btc_relay/utils/Nbits.sol index e6f7a65..b773be9 100644 --- a/ethereum/src/btc_relay/utils/Nbits.sol +++ b/ethereum/src/btc_relay/utils/Nbits.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.20; +pragma solidity 0.8.35; library Nbits { diff --git a/ethereum/src/interfaces/AggregatorV3Interface.sol b/ethereum/src/interfaces/AggregatorV3Interface.sol new file mode 100644 index 0000000..031f930 --- /dev/null +++ b/ethereum/src/interfaces/AggregatorV3Interface.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +// Vendored from @chainlink/contracts (chainlink-brownie-contracts v1.3.0), +// src/v0.8/shared/interfaces/AggregatorV3Interface.sol. Inlined here to drop the +// dependency on the deprecated/archived chainlink-brownie-contracts repository. +// The interface is self-contained (no imports) and matches Chainlink upstream. + +// solhint-disable-next-line interface-starts-with-i +interface AggregatorV3Interface { + function decimals() external view returns (uint8); + + function description() external view returns (string memory); + + function version() external view returns (uint256); + + function getRoundData( + uint80 _roundId + ) external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); + + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} diff --git a/ethereum/src/interfaces/IBridge.sol b/ethereum/src/interfaces/IBridge.sol index 0a56d20..30ccf2f 100644 --- a/ethereum/src/interfaces/IBridge.sol +++ b/ethereum/src/interfaces/IBridge.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; interface IBridge { // ========================================================================= diff --git a/ethereum/src/interfaces/IBtcRelayView.sol b/ethereum/src/interfaces/IBtcRelayView.sol index 05adfff..7734bc7 100644 --- a/ethereum/src/interfaces/IBtcRelayView.sol +++ b/ethereum/src/interfaces/IBtcRelayView.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; /// @title IBtcRelayView /// @notice Read-only interface for the Atomiq BtcRelay contract. diff --git a/ethereum/src/interfaces/ICommissionManager.sol b/ethereum/src/interfaces/ICommissionManager.sol index a29de2e..269ac1c 100644 --- a/ethereum/src/interfaces/ICommissionManager.sol +++ b/ethereum/src/interfaces/ICommissionManager.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; /** * @title ICommissionManager types & interface diff --git a/ethereum/src/interfaces/IFinalityVerifier.sol b/ethereum/src/interfaces/IFinalityVerifier.sol index 5a9e446..6f7c3bf 100644 --- a/ethereum/src/interfaces/IFinalityVerifier.sol +++ b/ethereum/src/interfaces/IFinalityVerifier.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { FundsOutContext } from './RouteTypes.sol'; diff --git a/ethereum/src/interfaces/IMultisigProxy.sol b/ethereum/src/interfaces/IMultisigProxy.sol index 880fdb8..a6d1323 100644 --- a/ethereum/src/interfaces/IMultisigProxy.sol +++ b/ethereum/src/interfaces/IMultisigProxy.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity 0.8.35; /// @title IMultisigProxy /// @notice Two-level ECDSA multisig proxy that owns the Bridge and the CommissionManager. @@ -43,6 +43,8 @@ interface IMultisigProxy { error InvalidThreshold(); error ZeroCommissionRecipient(); error TimelockTooLong(); + error TimelockTooShort(); + error InvalidMinTimelock(); error Expired(); error CallDataTooShort(); error CallNotAllowed(address target, bytes4 selector); @@ -88,7 +90,9 @@ interface IMultisigProxy { AdminExecuteAdapter, // 11 — generic call into LZAdapter (setTrustedEntrypoint, refundStuckFunds, …) UpdateLZAdapter, // 12 — rotate the routing target for AdminExecuteAdapter SetRoute, // 13 — RouteRegistry.setRoute(src, dst, enabled, verifier, module) - UpdateRouteRegistry // 14 — Bridge.setRouteRegistry(newRouteRegistry) + UpdateRouteRegistry, // 14 — Bridge.setRouteRegistry(newRouteRegistry) + PauseInflow, // 15 — Bridge.pauseInflow() (planned inflow-only freeze, timelocked) + UnpauseInflow // 16 — Bridge.unpauseInflow() } enum ProposalStatus { None, Pending, Executed, Cancelled } @@ -373,6 +377,27 @@ interface IMultisigProxy { bytes[] calldata fedSigs ) external returns (bytes32); + /// @notice Propose a planned inflow-only pause on Bridge (`pauseInflow`). + /// @dev Freezes deposits while leaving withdrawals open — intended for a + /// bridge upgrade / liquidity migration. Runs through the timelocked + /// propose -> execute path. For an immediate freeze of BOTH paths use + /// `emergencyPause`. Carries no payload (opData is empty). + function proposePauseInflow( + uint256 nonce, + uint256 deadline, + uint256 fedBitmap, + bytes[] calldata fedSigs + ) external returns (bytes32); + + /// @notice Propose resuming the inflow path on Bridge (`unpauseInflow`). + /// @dev Reverses `proposePauseInflow`. Timelocked. Carries no payload. + function proposeUnpauseInflow( + uint256 nonce, + uint256 deadline, + uint256 fedBitmap, + bytes[] calldata fedSigs + ) external returns (bytes32); + // ========================================================================= // Cancel & Execute // ========================================================================= @@ -416,5 +441,6 @@ interface IMultisigProxy { function DOMAIN_SEPARATOR() external view returns (bytes32); function proposalNonce() external view returns (uint256); function timelockDuration() external view returns (uint256); + function MIN_TIMELOCK() external view returns (uint256); function getProposal(bytes32 proposalId) external view returns (Proposal memory); } diff --git a/ethereum/src/interfaces/IRouteRegistry.sol b/ethereum/src/interfaces/IRouteRegistry.sol index 2a3f127..a633a24 100644 --- a/ethereum/src/interfaces/IRouteRegistry.sol +++ b/ethereum/src/interfaces/IRouteRegistry.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { FundsInContext, FundsOutContext, RouteConfig } from './RouteTypes.sol'; diff --git a/ethereum/src/interfaces/ISettlementModule.sol b/ethereum/src/interfaces/ISettlementModule.sol index 4102a92..1a9ab4f 100644 --- a/ethereum/src/interfaces/ISettlementModule.sol +++ b/ethereum/src/interfaces/ISettlementModule.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { FundsInContext, FundsOutContext } from './RouteTypes.sol'; diff --git a/ethereum/src/interfaces/RouteTypes.sol b/ethereum/src/interfaces/RouteTypes.sol index a0f7d40..c2d46e3 100644 --- a/ethereum/src/interfaces/RouteTypes.sol +++ b/ethereum/src/interfaces/RouteTypes.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; /// @title RouteTypes /// @notice Shared data shapes used by `IRouteRegistry`, `ISettlementModule` diff --git a/ethereum/src/settlement/NullSettlementModule.sol b/ethereum/src/settlement/NullSettlementModule.sol index 20cf995..64e591f 100644 --- a/ethereum/src/settlement/NullSettlementModule.sol +++ b/ethereum/src/settlement/NullSettlementModule.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { ISettlementModule } from '../interfaces/ISettlementModule.sol'; import { FundsInContext, FundsOutContext } from '../interfaces/RouteTypes.sol'; diff --git a/ethereum/src/settlement/RgbSettlementModule.sol b/ethereum/src/settlement/RgbSettlementModule.sol index e1b02a7..114fe0c 100644 --- a/ethereum/src/settlement/RgbSettlementModule.sol +++ b/ethereum/src/settlement/RgbSettlementModule.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { ISettlementModule } from '../interfaces/ISettlementModule.sol'; import { FundsInContext, FundsOutContext } from '../interfaces/RouteTypes.sol'; diff --git a/ethereum/src/verifiers/NullVerifier.sol b/ethereum/src/verifiers/NullVerifier.sol index 08bfbf5..ef12dd8 100644 --- a/ethereum/src/verifiers/NullVerifier.sol +++ b/ethereum/src/verifiers/NullVerifier.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { IFinalityVerifier } from '../interfaces/IFinalityVerifier.sol'; import { FundsOutContext } from '../interfaces/RouteTypes.sol'; diff --git a/ethereum/src/verifiers/RGBVerifier.sol b/ethereum/src/verifiers/RGBVerifier.sol index 7221bf0..beeadf4 100644 --- a/ethereum/src/verifiers/RGBVerifier.sol +++ b/ethereum/src/verifiers/RGBVerifier.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { IFinalityVerifier } from '../interfaces/IFinalityVerifier.sol'; import { FundsOutContext } from '../interfaces/RouteTypes.sol'; diff --git a/ethereum/test/BaseBridge.t.sol b/ethereum/test/BaseBridge.t.sol index 64bb834..e2e491a 100644 --- a/ethereum/test/BaseBridge.t.sol +++ b/ethereum/test/BaseBridge.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Test } from 'forge-std/Test.sol'; import { BaseBridge } from '../src/BaseBridge.sol'; @@ -95,7 +95,7 @@ contract BaseBridgeTest is Test { function test_fundsIn_revertsWhenPaused() public { vm.prank(owner); - bridge.pause(); + bridge.pauseInflow(); vm.expectRevert(Pausable.EnforcedPause.selector); vm.prank(user); @@ -154,23 +154,23 @@ contract BaseBridgeTest is Test { function test_pause_onlyOwner() public { vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); vm.prank(user); - bridge.pause(); + bridge.pauseInflow(); } function test_unpause_onlyOwner() public { vm.prank(owner); - bridge.pause(); + bridge.pauseInflow(); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); vm.prank(user); - bridge.unpause(); + bridge.unpauseInflow(); } function test_unpause_ownerCanUnpause() public { vm.prank(owner); - bridge.pause(); + bridge.pauseInflow(); vm.prank(owner); - bridge.unpause(); + bridge.unpauseInflow(); // fundsIn works again vm.prank(user); @@ -184,6 +184,118 @@ contract BaseBridgeTest is Test { bridge.renounceOwnership(); } + // ======================================================================== + // R-W-05 — two-tier pause (inflow vs outflow / emergency) + // ======================================================================== + + /// @dev UT-FIX-08: with outflow frozen, fundsOut reverts. The + /// whenOutflowNotPaused modifier runs before the body, so a dummy + /// release is enough to exercise the gate. + function test_fundsOut_revertsWhenOutflowPaused() public { + vm.prank(user); + bridge.fundsIn(AMOUNT, OPERATION_ID); // seed the pool + + vm.prank(owner); + bridge.emergencyPauseAll(); + + vm.expectRevert(BridgeBase.OutflowEnforcedPause.selector); + vm.prank(owner); + bridge.fundsOut(recipient, AMOUNT, OPERATION_ID, SRC_ADDR); + } + + /// @dev The planned inflow-only pause blocks deposits but leaves + /// withdrawals open — the core two-tier distinction (liquidity can be + /// migrated out while new deposits are frozen). + function test_fundsOut_worksWhenOnlyInflowPaused() public { + vm.prank(user); + bridge.fundsIn(AMOUNT, OPERATION_ID); // seed before freezing inflow + + vm.prank(owner); + bridge.pauseInflow(); + + // Deposits are frozen... + vm.expectRevert(Pausable.EnforcedPause.selector); + vm.prank(user); + bridge.fundsIn(AMOUNT, OPERATION_ID); + + // ...but withdrawals still work. + vm.prank(owner); + bridge.fundsOut(recipient, AMOUNT, OPERATION_ID, SRC_ADDR); + assertEq(token.balanceOf(recipient), AMOUNT); + } + + /// @dev Emergency pause freezes BOTH paths. + function test_emergencyPauseAll_freezesBothPaths() public { + vm.prank(user); + bridge.fundsIn(AMOUNT, OPERATION_ID); // seed the pool + + vm.prank(owner); + bridge.emergencyPauseAll(); + + assertTrue(bridge.paused(), 'inflow frozen'); + assertTrue(bridge.outflowPaused(), 'outflow frozen'); + + vm.expectRevert(Pausable.EnforcedPause.selector); + vm.prank(user); + bridge.fundsIn(AMOUNT, OPERATION_ID); + + vm.expectRevert(BridgeBase.OutflowEnforcedPause.selector); + vm.prank(owner); + bridge.fundsOut(recipient, AMOUNT, OPERATION_ID, SRC_ADDR); + } + + /// @dev Emergency unpause lifts BOTH freezes. + function test_emergencyUnpauseAll_liftsBoth() public { + vm.startPrank(owner); + bridge.emergencyPauseAll(); + bridge.emergencyUnpauseAll(); + vm.stopPrank(); + + assertFalse(bridge.paused(), 'inflow resumed'); + assertFalse(bridge.outflowPaused(), 'outflow resumed'); + + vm.prank(user); + bridge.fundsIn(AMOUNT, OPERATION_ID); + assertEq(token.balanceOf(address(bridge)), AMOUNT); + } + + /// @dev emergencyPauseAll must be idempotent per flag: it must not revert + /// if inflow is already frozen via the planned path. + function test_emergencyPauseAll_idempotentWhenInflowAlreadyPaused() public { + vm.startPrank(owner); + bridge.pauseInflow(); // inflow already frozen + bridge.emergencyPauseAll(); // must not revert on the already-set inflow flag + vm.stopPrank(); + + assertTrue(bridge.paused(), 'inflow still frozen'); + assertTrue(bridge.outflowPaused(), 'outflow now frozen'); + } + + function test_pauseInflow_onlyOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + vm.prank(user); + bridge.pauseInflow(); + } + + function test_emergencyPauseAll_onlyOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + vm.prank(user); + bridge.emergencyPauseAll(); + } + + function test_emergencyUnpauseAll_onlyOwner() public { + vm.prank(owner); + bridge.emergencyPauseAll(); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + vm.prank(user); + bridge.emergencyUnpauseAll(); + } + + function test_outflowPaused_defaultsFalse() public view { + assertFalse(bridge.outflowPaused()); + } + // ======================================================================== // views // ======================================================================== diff --git a/ethereum/test/Bridge.t.sol b/ethereum/test/Bridge.t.sol index 58c8b08..ea8f650 100644 --- a/ethereum/test/Bridge.t.sol +++ b/ethereum/test/Bridge.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Test } from 'forge-std/Test.sol'; @@ -404,7 +404,7 @@ contract BridgeTest is Test { function test_fundsIn_revertsWhenPaused() public { vm.prank(multisig); - bridge.pause(); + bridge.pauseInflow(); vm.expectRevert(Pausable.EnforcedPause.selector); vm.prank(user); @@ -762,16 +762,16 @@ contract BridgeTest is Test { function test_pause_onlyOwner() public { vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); vm.prank(user); - bridge.pause(); + bridge.pauseInflow(); } function test_unpause_onlyOwner() public { vm.prank(multisig); - bridge.pause(); + bridge.pauseInflow(); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); vm.prank(user); - bridge.unpause(); + bridge.unpauseInflow(); } function test_renounceOwnership_alwaysReverts() public { diff --git a/ethereum/test/CommissionManager.t.sol b/ethereum/test/CommissionManager.t.sol index cb153f0..712b718 100644 --- a/ethereum/test/CommissionManager.t.sol +++ b/ethereum/test/CommissionManager.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Test } from 'forge-std/Test.sol'; import { Ownable } from '@openzeppelin/contracts/access/Ownable.sol'; diff --git a/ethereum/test/Integration.t.sol b/ethereum/test/Integration.t.sol index 7fdb864..34c780a 100644 --- a/ethereum/test/Integration.t.sol +++ b/ethereum/test/Integration.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Test } from 'forge-std/Test.sol'; @@ -91,7 +91,8 @@ contract IntegrationTest is Test { bytes32 constant COMMITMENT_HASH = keccak256('integration-btc-block'); uint256 constant BTC_CONFIRMATIONS = 6; - uint256 constant TIMELOCK = 1 hours; + uint256 constant TIMELOCK = 1 hours; + uint256 constant MIN_TIMELOCK = 1 hours; // floor passed to the proxy constructor in tests /// @dev New 8-arg fundsOut selector: /// fundsOut( @@ -189,7 +190,8 @@ contract IntegrationTest is Test { enc, 2, fed, 2, commissionReceiver, - TIMELOCK + TIMELOCK, + MIN_TIMELOCK ); cm.transferOwnership(address(proxy)); diff --git a/ethereum/test/MultisigProxy.t.sol b/ethereum/test/MultisigProxy.t.sol index 71e7ff6..ca79ae0 100644 --- a/ethereum/test/MultisigProxy.t.sol +++ b/ethereum/test/MultisigProxy.t.sol @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Test } from 'forge-std/Test.sol'; import { MultisigProxy } from '../src/MultisigProxy.sol'; import { IMultisigProxy } from '../src/interfaces/IMultisigProxy.sol'; import { Bridge } from '../src/Bridge.sol'; +import { BridgeBase } from '../src/BridgeBase.sol'; +import { Pausable } from '@openzeppelin/contracts/utils/Pausable.sol'; import { CommissionManager } from '../src/CommissionManager.sol'; import { RouteRegistry } from '../src/RouteRegistry.sol'; import { IRouteRegistry } from '../src/interfaces/IRouteRegistry.sol'; @@ -87,7 +89,8 @@ contract MultisigProxyTest is Test { address recipient = makeAddr('recipient'); address commissionReceiver = makeAddr('commissionReceiver'); - uint256 constant TIMELOCK = 1 hours; + uint256 constant TIMELOCK = 1 hours; + uint256 constant MIN_TIMELOCK = 1 hours; // floor passed to the proxy constructor in tests bytes32 domainSep; @@ -175,7 +178,8 @@ contract MultisigProxyTest is Test { enc, 2, fed, 2, commissionReceiver, - TIMELOCK + TIMELOCK, + MIN_TIMELOCK ); // Production-flow ownership transfer. @@ -254,56 +258,87 @@ contract MultisigProxyTest is Test { address[] memory enc = new address[](1); enc[0] = encA1; address[] memory fed = new address[](1); fed[0] = fedA1; vm.expectRevert(IMultisigProxy.ZeroBridge.selector); - new MultisigProxy(address(0), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK); + new MultisigProxy(address(0), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK, MIN_TIMELOCK); } function test_constructor_revertsOnZeroCommissionManager() public { address[] memory enc = new address[](1); enc[0] = encA1; address[] memory fed = new address[](1); fed[0] = fedA1; vm.expectRevert(IMultisigProxy.ZeroCommissionManager.selector); - new MultisigProxy(address(bridge), address(0), enc, 1, fed, 1, commissionReceiver, TIMELOCK); + new MultisigProxy(address(bridge), address(0), enc, 1, fed, 1, commissionReceiver, TIMELOCK, MIN_TIMELOCK); } function test_constructor_revertsOnNoEnclaveSigners() public { address[] memory enc = new address[](0); address[] memory fed = new address[](1); fed[0] = fedA1; vm.expectRevert(IMultisigProxy.NoSigners.selector); - new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK); + new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK, MIN_TIMELOCK); } function test_constructor_revertsOnBadEnclaveThreshold() public { address[] memory enc = new address[](2); enc[0] = encA1; enc[1] = encA2; address[] memory fed = new address[](1); fed[0] = fedA1; vm.expectRevert(IMultisigProxy.InvalidThreshold.selector); - new MultisigProxy(address(bridge), address(cm), enc, 3, fed, 1, commissionReceiver, TIMELOCK); + new MultisigProxy(address(bridge), address(cm), enc, 3, fed, 1, commissionReceiver, TIMELOCK, MIN_TIMELOCK); } function test_constructor_revertsOnZeroCommission() public { address[] memory enc = new address[](1); enc[0] = encA1; address[] memory fed = new address[](1); fed[0] = fedA1; vm.expectRevert(IMultisigProxy.ZeroCommissionRecipient.selector); - new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, address(0), TIMELOCK); + new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, address(0), TIMELOCK, MIN_TIMELOCK); } function test_constructor_revertsOnTimelockTooLong() public { address[] memory enc = new address[](1); enc[0] = encA1; address[] memory fed = new address[](1); fed[0] = fedA1; vm.expectRevert(IMultisigProxy.TimelockTooLong.selector); - new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, 30 days); + new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, 30 days, MIN_TIMELOCK); + } + + // ---- R-W-11: MIN_TIMELOCK floor (post-fix) ---- + + /// @dev UT-FIX-13: deploying with a timelock below the requested floor reverts. + function test_constructor_revertsOnTimelockBelowMinTimelock() public { + address[] memory enc = new address[](1); enc[0] = encA1; + address[] memory fed = new address[](1); fed[0] = fedA1; + // timelock (1h) is below the requested floor (2h) -> TimelockTooShort + vm.expectRevert(IMultisigProxy.TimelockTooShort.selector); + new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, 1 hours, 2 hours); + } + + /// @dev A zero floor is rejected — it would defeat the purpose of the fix. + function test_constructor_revertsOnZeroMinTimelock() public { + address[] memory enc = new address[](1); enc[0] = encA1; + address[] memory fed = new address[](1); fed[0] = fedA1; + vm.expectRevert(IMultisigProxy.InvalidMinTimelock.selector); + new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK, 0); + } + + /// @dev A floor at/above the upper bound leaves no valid range — rejected. + function test_constructor_revertsOnMinTimelockTooLong() public { + address[] memory enc = new address[](1); enc[0] = encA1; + address[] memory fed = new address[](1); fed[0] = fedA1; + vm.expectRevert(IMultisigProxy.InvalidMinTimelock.selector); + new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK, 30 days); + } + + function test_minTimelock_returnsConfiguredFloor() public view { + assertEq(proxy.MIN_TIMELOCK(), MIN_TIMELOCK); } function test_constructor_revertsOnDuplicateSigner() public { address[] memory enc = new address[](2); enc[0] = encA1; enc[1] = encA1; address[] memory fed = new address[](1); fed[0] = fedA1; vm.expectRevert(IMultisigProxy.DuplicateSigner.selector); - new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK); + new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK, MIN_TIMELOCK); } function test_constructor_revertsOnZeroAddressSigner() public { address[] memory enc = new address[](1); enc[0] = address(0); address[] memory fed = new address[](1); fed[0] = fedA1; vm.expectRevert(IMultisigProxy.ZeroAddressSigner.selector); - new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK); + new MultisigProxy(address(bridge), address(cm), enc, 1, fed, 1, commissionReceiver, TIMELOCK, MIN_TIMELOCK); } // ======================================================================== @@ -355,7 +390,7 @@ contract MultisigProxyTest is Test { } function test_execute_revertsOnDisallowedSelector() public { - bytes memory callData = abi.encodeWithSignature('pause()'); + bytes memory callData = abi.encodeWithSignature('pauseInflow()'); uint256 nonce = 0; uint256 deadline = block.timestamp + 1 hours; @@ -434,7 +469,7 @@ contract MultisigProxyTest is Test { bytes[] memory callDatas = new bytes[](1); uint256[] memory values = new uint256[](1); targets[0] = makeAddr('random-target'); - callDatas[0] = abi.encodeWithSignature('pause()'); + callDatas[0] = abi.encodeWithSignature('pauseInflow()'); uint256 nonce = proxy.batchNonce(); uint256 deadline = block.timestamp + 1 hours; @@ -626,6 +661,79 @@ contract MultisigProxyTest is Test { assertFalse(bridge.paused()); } + // ======================================================================== + // R-W-05 — two-tier pause (integration via MultisigProxy) + // ======================================================================== + + /// @dev After R-W-05, the no-timelock emergency pause freezes the OUTFLOW + /// path too — including the enclave/TEE release, which routes through + /// Bridge.fundsOut. A signed fundsOut executed straight after + /// emergencyPause must revert OutflowEnforcedPause, and inbound deposits + /// must be frozen as well. + function test_emergencyPause_freezesEnclaveFundsOut() public { + // Federation triggers the emergency freeze (both paths). + uint256 nonce = proxy.proposalNonce(); + uint256 deadline = block.timestamp + 1 hours; + bytes32 digest = MultisigHelper.digestEmergencyPause(domainSep, nonce, deadline); + (uint256[] memory fpks, uint256 fbitmap) = _fedSigSet2of3(); + proxy.emergencyPause(nonce, deadline, fbitmap, MultisigHelper.signAll(vm, digest, fpks)); + + assertTrue(bridge.paused(), 'inflow frozen'); + assertTrue(bridge.outflowPaused(), 'outflow frozen'); + + // Inbound deposits are frozen. + vm.expectRevert(Pausable.EnforcedPause.selector); + vm.prank(user); + bridge.fundsIn(AMOUNT, RGB_CHAIN_ID, DST_ADDR, TX_ID + 1, ''); + + // Enclave-signed release is frozen too — the revert propagates from + // Bridge.fundsOut through proxy.execute. + bytes memory callData = _fundsOutCalldata(); + uint256 encNonce = proxy.getNonce(FUNDS_OUT_SELECTOR); + bytes32 encDigest = MultisigHelper.digestBridgeOp(domainSep, FUNDS_OUT_SELECTOR, callData, encNonce, deadline); + (uint256[] memory epks, uint256 ebitmap) = _encSigSet2of3(); + bytes[] memory esigs = MultisigHelper.signAll(vm, encDigest, epks); + + vm.expectRevert(BridgeBase.OutflowEnforcedPause.selector); + proxy.execute(callData, encNonce, deadline, ebitmap, esigs); + } + + /// @dev The planned inflow-only pause runs through the timelocked + /// propose -> execute path and blocks deposits while leaving the + /// enclave release path open (liquidity migration scenario). + function test_proposePauseInflow_blocksFundsInButAllowsFundsOut() public { + uint256 t = block.timestamp; // read once; derive all timing from it (via_ir-safe) + + // Propose the inflow-only pause (federation signed). + uint256 nonce = proxy.proposalNonce(); + uint256 deadline = t + 1 days; + bytes32 digest = MultisigHelper.digestProposePauseInflow(domainSep, nonce, deadline); + (uint256[] memory fpks, uint256 fbitmap) = _fedSigSet2of3(); + bytes32 id = proxy.proposePauseInflow(nonce, deadline, fbitmap, MultisigHelper.signAll(vm, digest, fpks)); + + // Execute it after the timelock (no payload). + vm.warp(t + TIMELOCK + 1); + proxy.executeProposal(id, ''); + + assertTrue(bridge.paused(), 'inflow frozen'); + assertFalse(bridge.outflowPaused(), 'outflow stays open'); + + // Deposits are frozen... + vm.expectRevert(Pausable.EnforcedPause.selector); + vm.prank(user); + bridge.fundsIn(AMOUNT, RGB_CHAIN_ID, DST_ADDR, TX_ID + 1, ''); + + // ...but the enclave release still executes. + bytes memory callData = _fundsOutCalldata(); + uint256 encNonce = proxy.getNonce(FUNDS_OUT_SELECTOR); + bytes32 encDigest = MultisigHelper.digestBridgeOp(domainSep, FUNDS_OUT_SELECTOR, callData, encNonce, t + 1 days); + (uint256[] memory epks, uint256 ebitmap) = _encSigSet2of3(); + bytes[] memory esigs = MultisigHelper.signAll(vm, encDigest, epks); + + proxy.execute(callData, encNonce, t + 1 days, ebitmap, esigs); + assertEq(token.balanceOf(recipient), AMOUNT, 'withdrawal succeeded while inflow paused'); + } + // ======================================================================== // Propose + Execute — UpdateBridge // ======================================================================== @@ -758,12 +866,47 @@ contract MultisigProxyTest is Test { assertEq(proxy.timelockDuration(), newDuration); } + /// @dev UT-FIX-13: a SetTimelockDuration proposal below the immutable + /// MIN_TIMELOCK floor is rejected at execution; the floor holds. + function test_proposeSetTimelockDuration_revertsBelowMinTimelock_afterFix() public { + uint256 t = block.timestamp; + uint256 newDuration = MIN_TIMELOCK - 1; // just under the floor + uint256 nonce = proxy.proposalNonce(); + uint256 deadline = t + 1 days; + + bytes32 digest = MultisigHelper.digestProposeSetTimelockDuration(domainSep, newDuration, nonce, deadline); + (uint256[] memory pks, uint256 bitmap) = _fedSigSet2of3(); + bytes32 id = proxy.proposeSetTimelockDuration(newDuration, nonce, deadline, bitmap, MultisigHelper.signAll(vm, digest, pks)); + + vm.warp(t + TIMELOCK + 1); + vm.expectRevert(IMultisigProxy.TimelockTooShort.selector); + proxy.executeProposal(id, abi.encode(newDuration)); + + assertEq(proxy.timelockDuration(), TIMELOCK, 'timelock unchanged'); + } + + /// @dev A value exactly at MIN_TIMELOCK is still accepted (boundary). + function test_proposeSetTimelockDuration_acceptsAtMinTimelock_afterFix() public { + uint256 t = block.timestamp; + uint256 newDuration = MIN_TIMELOCK; // exactly the floor + uint256 nonce = proxy.proposalNonce(); + uint256 deadline = t + 1 days; + + bytes32 digest = MultisigHelper.digestProposeSetTimelockDuration(domainSep, newDuration, nonce, deadline); + (uint256[] memory pks, uint256 bitmap) = _fedSigSet2of3(); + bytes32 id = proxy.proposeSetTimelockDuration(newDuration, nonce, deadline, bitmap, MultisigHelper.signAll(vm, digest, pks)); + + vm.warp(t + TIMELOCK + 1); + proxy.executeProposal(id, abi.encode(newDuration)); + assertEq(proxy.timelockDuration(), newDuration); + } + // ======================================================================== // Propose + Execute — AdminExecute // ======================================================================== function test_proposeAdminExecute_canCallBridge() public { - bytes memory callData = abi.encodeWithSignature('pause()'); + bytes memory callData = abi.encodeWithSignature('pauseInflow()'); uint256 nonce = proxy.proposalNonce(); uint256 deadline = block.timestamp + 1 days; @@ -1057,13 +1200,19 @@ contract MultisigProxyTest is Test { function test_proposeAdminExecuteAdapter_executesCallOnAdapter() public { MockERC20 adapter = new MockERC20('LZ Stub', 'LZS'); bytes32 idSet = _proposeUpdateLZAdapter(address(adapter)); - vm.warp(block.timestamp + TIMELOCK + 1); + // Read block.timestamp once and derive every warp target from it. Re-reading + // block.timestamp after a vm.warp within the same function is unreliable under + // via_ir: the optimizer treats TIMESTAMP as tx-invariant and may reuse a + // pre-warp read, so a second `block.timestamp + ...` warp can collapse onto a + // stale value and leave the timelock unexpired. + uint256 firstExec = block.timestamp + TIMELOCK + 1; + vm.warp(firstExec); proxy.executeProposal(idSet, abi.encode(address(adapter))); assertEq(proxy.lzAdapter(), address(adapter)); bytes memory callData = abi.encodeWithSignature('mint(address,uint256)', recipient, 1e18); uint256 nonce = proxy.proposalNonce(); - uint256 deadline = block.timestamp + 1 days; + uint256 deadline = firstExec + 1 days; bytes32 digest = MultisigHelper.digestProposeAdminExecuteAdapter( domainSep, bytes4(callData), callData, nonce, deadline @@ -1073,7 +1222,7 @@ contract MultisigProxyTest is Test { bytes32 id = proxy.proposeAdminExecuteAdapter(callData, nonce, deadline, bitmap, sigs); - vm.warp(block.timestamp + TIMELOCK + 1); + vm.warp(firstExec + TIMELOCK + 1); proxy.executeProposal(id, callData); assertEq(adapter.balanceOf(recipient), 1e18); diff --git a/ethereum/test/RgbSettlementModule.t.sol b/ethereum/test/RgbSettlementModule.t.sol index 5d23d03..e6b3e76 100644 --- a/ethereum/test/RgbSettlementModule.t.sol +++ b/ethereum/test/RgbSettlementModule.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Test } from 'forge-std/Test.sol'; import { RgbSettlementModule } from '../src/settlement/RgbSettlementModule.sol'; diff --git a/ethereum/test/RouteRegistry.t.sol b/ethereum/test/RouteRegistry.t.sol index b2be1bc..fa185e5 100644 --- a/ethereum/test/RouteRegistry.t.sol +++ b/ethereum/test/RouteRegistry.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Test } from 'forge-std/Test.sol'; import { Ownable } from '@openzeppelin/contracts/access/Ownable.sol'; diff --git a/ethereum/test/mocks/MockAggregatorV3.sol b/ethereum/test/mocks/MockAggregatorV3.sol index d7c8dcf..14ff811 100644 --- a/ethereum/test/mocks/MockAggregatorV3.sol +++ b/ethereum/test/mocks/MockAggregatorV3.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; -import { AggregatorV3Interface } from '@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol'; +import { AggregatorV3Interface } from '../../src/interfaces/AggregatorV3Interface.sol'; /// @title MockAggregatorV3 /// @notice Minimal Chainlink-compatible price feed for tests. Lets each test diff --git a/ethereum/test/mocks/MockBtcRelay.sol b/ethereum/test/mocks/MockBtcRelay.sol index 323bb5a..eff5e3a 100644 --- a/ethereum/test/mocks/MockBtcRelay.sol +++ b/ethereum/test/mocks/MockBtcRelay.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { IBtcRelayView } from '../../src/interfaces/IBtcRelayView.sol'; diff --git a/ethereum/test/mocks/MockERC20.sol b/ethereum/test/mocks/MockERC20.sol index 3e08bec..6db6705 100644 --- a/ethereum/test/mocks/MockERC20.sol +++ b/ethereum/test/mocks/MockERC20.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; diff --git a/ethereum/test/mocks/MockFinalityVerifier.sol b/ethereum/test/mocks/MockFinalityVerifier.sol index a553a28..6ee61e1 100644 --- a/ethereum/test/mocks/MockFinalityVerifier.sol +++ b/ethereum/test/mocks/MockFinalityVerifier.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { IFinalityVerifier } from '../../src/interfaces/IFinalityVerifier.sol'; import { FundsOutContext } from '../../src/interfaces/RouteTypes.sol'; diff --git a/ethereum/test/mocks/MockSettlementModule.sol b/ethereum/test/mocks/MockSettlementModule.sol index 200621a..0922550 100644 --- a/ethereum/test/mocks/MockSettlementModule.sol +++ b/ethereum/test/mocks/MockSettlementModule.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { ISettlementModule } from '../../src/interfaces/ISettlementModule.sol'; import { FundsInContext, FundsOutContext } from '../../src/interfaces/RouteTypes.sol'; diff --git a/ethereum/test/mocks/MultisigHelper.sol b/ethereum/test/mocks/MultisigHelper.sol index ed0c253..3853045 100644 --- a/ethereum/test/mocks/MultisigHelper.sol +++ b/ethereum/test/mocks/MultisigHelper.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.35; import { Vm } from 'forge-std/Vm.sol'; @@ -26,6 +26,14 @@ library MultisigHelper { 'EmergencyUnpause(uint256 nonce,uint256 deadline)' ); + bytes32 internal constant PROPOSE_PAUSE_INFLOW_TYPEHASH = keccak256( + 'ProposePauseInflow(uint256 nonce,uint256 deadline)' + ); + + bytes32 internal constant PROPOSE_UNPAUSE_INFLOW_TYPEHASH = keccak256( + 'ProposeUnpauseInflow(uint256 nonce,uint256 deadline)' + ); + bytes32 internal constant PROPOSE_ADMIN_EXECUTE_TYPEHASH = keccak256( 'ProposeAdminExecute(bytes4 selector,bytes callData,uint256 nonce,uint256 deadline)' ); @@ -161,6 +169,14 @@ library MultisigHelper { return toTypedDataHash(domainSep, keccak256(abi.encode(EMERGENCY_UNPAUSE_TYPEHASH, nonce, deadline))); } + function digestProposePauseInflow(bytes32 domainSep, uint256 nonce, uint256 deadline) internal pure returns (bytes32) { + return toTypedDataHash(domainSep, keccak256(abi.encode(PROPOSE_PAUSE_INFLOW_TYPEHASH, nonce, deadline))); + } + + function digestProposeUnpauseInflow(bytes32 domainSep, uint256 nonce, uint256 deadline) internal pure returns (bytes32) { + return toTypedDataHash(domainSep, keccak256(abi.encode(PROPOSE_UNPAUSE_INFLOW_TYPEHASH, nonce, deadline))); + } + function digestProposeAdminExecute( bytes32 domainSep, bytes4 selector,