From 497c3b13d1f2859c70a43dc311a5289a6dbbc128 Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 17:58:12 -0500 Subject: [PATCH] Harden bridge lockbox settlement spine --- contracts/FlowChainSettlementSpine.sol | 164 +++++++++++++ contracts/bridge/BaseBridgeLockbox.sol | 168 +++++++++++-- docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md | 128 +++++++++- script/DeployBridgeSpine.s.sol | 120 ++++++++++ tests/FlowChainSettlementSpine.t.sol | 257 ++++++++++++++++++++ tests/bridge/BaseBridgeLockbox.t.sol | 285 ++++++++++++++++++++--- 6 files changed, 1061 insertions(+), 61 deletions(-) create mode 100644 contracts/FlowChainSettlementSpine.sol create mode 100644 script/DeployBridgeSpine.s.sol create mode 100644 tests/FlowChainSettlementSpine.t.sol diff --git a/contracts/FlowChainSettlementSpine.sol b/contracts/FlowChainSettlementSpine.sol new file mode 100644 index 00000000..b423e6e2 --- /dev/null +++ b/contracts/FlowChainSettlementSpine.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title FlowChainSettlementSpine +/// @notice Compact local/test settlement event spine for FlowChain object commitments. +/// @dev This records commitment metadata only. Raw objects, verifier logic, and +/// runtime state transitions remain off-chain or in the private/local runtime. +contract FlowChainSettlementSpine { + struct ObjectCommitment { + address submitter; + bytes32 objectType; + bytes32 rootfieldId; + bytes32 commitment; + bytes32 parentObjectId; + uint64 sequence; + uint64 committedAt; + bool exists; + } + + bytes32 public constant BRIDGE_DEPOSIT_OBJECT = keccak256("flowchain.object.bridge-deposit.v0"); + bytes32 public constant MEMORY_OBJECT = keccak256("flowchain.object.memory.v0"); + bytes32 public constant FINALITY_OBJECT = keccak256("flowchain.object.finality.v0"); + + address public owner; + uint64 public nextSequence = 1; + + mapping(address submitter => bool authorized) public authorizedSubmitters; + mapping(bytes32 objectId => ObjectCommitment commitment) private _commitments; + + error NotOwner(address caller); + error SubmitterNotAuthorized(address submitter); + error ZeroOwner(); + error ZeroSubmitter(); + error ZeroObjectType(); + error ZeroObjectId(); + error ZeroRootfieldId(); + error ZeroCommitment(); + error ObjectAlreadyCommitted(bytes32 objectId); + error ObjectNotCommitted(bytes32 objectId); + error TimestampOverflow(uint256 timestamp); + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event SubmitterAuthorizationSet(address indexed submitter, bool authorized); + event FlowChainObjectCommitted( + bytes32 indexed objectId, + bytes32 indexed rootfieldId, + bytes32 indexed objectType, + address submitter, + bytes32 commitment, + bytes32 parentObjectId, + uint64 sequence, + uint64 committedAt, + string evidenceURI + ); + + modifier onlyOwner() { + if (msg.sender != owner) { + revert NotOwner(msg.sender); + } + _; + } + + modifier onlyAuthorizedSubmitter() { + if (!authorizedSubmitters[msg.sender]) { + revert SubmitterNotAuthorized(msg.sender); + } + _; + } + + constructor(address initialOwner) { + if (initialOwner == address(0)) { + revert ZeroOwner(); + } + owner = initialOwner; + authorizedSubmitters[initialOwner] = true; + emit OwnershipTransferred(address(0), initialOwner); + emit SubmitterAuthorizationSet(initialOwner, true); + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) { + revert ZeroOwner(); + } + address previousOwner = owner; + owner = newOwner; + emit OwnershipTransferred(previousOwner, newOwner); + } + + function setSubmitterAuthorization(address submitter, bool authorized) external onlyOwner { + if (submitter == address(0)) { + revert ZeroSubmitter(); + } + authorizedSubmitters[submitter] = authorized; + emit SubmitterAuthorizationSet(submitter, authorized); + } + + function commitObject( + bytes32 objectType, + bytes32 objectId, + bytes32 rootfieldId, + bytes32 commitment, + bytes32 parentObjectId, + string calldata evidenceURI + ) external onlyAuthorizedSubmitter returns (uint64 sequence) { + if (objectType == bytes32(0)) { + revert ZeroObjectType(); + } + if (objectId == bytes32(0)) { + revert ZeroObjectId(); + } + if (rootfieldId == bytes32(0)) { + revert ZeroRootfieldId(); + } + if (commitment == bytes32(0)) { + revert ZeroCommitment(); + } + if (_commitments[objectId].exists) { + revert ObjectAlreadyCommitted(objectId); + } + + sequence = nextSequence++; + uint64 committedAt = _blockTimestamp(); + _commitments[objectId] = ObjectCommitment({ + submitter: msg.sender, + objectType: objectType, + rootfieldId: rootfieldId, + commitment: commitment, + parentObjectId: parentObjectId, + sequence: sequence, + committedAt: committedAt, + exists: true + }); + + emit FlowChainObjectCommitted({ + objectId: objectId, + rootfieldId: rootfieldId, + objectType: objectType, + submitter: msg.sender, + commitment: commitment, + parentObjectId: parentObjectId, + sequence: sequence, + committedAt: committedAt, + evidenceURI: evidenceURI + }); + } + + function getObjectCommitment(bytes32 objectId) external view returns (ObjectCommitment memory commitment) { + commitment = _commitments[objectId]; + if (!commitment.exists) { + revert ObjectNotCommitted(objectId); + } + } + + function isObjectCommitted(bytes32 objectId) external view returns (bool) { + return _commitments[objectId].exists; + } + + function _blockTimestamp() private view returns (uint64) { + if (block.timestamp > type(uint64).max) { + revert TimestampOverflow(block.timestamp); + } + return uint64(block.timestamp); + } +} diff --git a/contracts/bridge/BaseBridgeLockbox.sol b/contracts/bridge/BaseBridgeLockbox.sol index a3ceefce..a6b0c4b4 100644 --- a/contracts/bridge/BaseBridgeLockbox.sol +++ b/contracts/bridge/BaseBridgeLockbox.sol @@ -18,29 +18,55 @@ contract BaseBridgeLockbox { uint256 totalLocked; } + struct DepositRecord { + address sender; + address token; + uint256 amount; + uint256 released; + bytes32 flowchainRecipient; + uint256 nonce; + bytes32 metadataHash; + bool exists; + } + address public constant NATIVE_TOKEN = address(0); + bytes32 public constant BRIDGE_DEPOSIT_SCHEMA_ID = keccak256("flowmemory.bridge.deposit.v0"); + bytes32 public constant BRIDGE_RELEASE_SCHEMA_ID = keccak256("flowmemory.bridge.release.v0"); address public owner; + address public releaseAuthority; bool public paused; uint256 public nextNonce = 1; mapping(address token => TokenConfig config) public tokenConfigs; mapping(bytes32 depositId => bool seen) public deposits; + mapping(bytes32 depositId => DepositRecord record) public depositRecords; mapping(bytes32 releaseId => bool seen) public releases; + bool private _entered; + error NotOwner(address caller); + error NotReleaseAuthority(address caller); error Paused(); + error ReentrantCall(); error ZeroOwner(); + error ZeroReleaseAuthority(); error ZeroRecipient(); error ZeroToken(); error ZeroAmount(); + error ZeroEvidenceHash(); error TokenNotAllowed(address token); error PerDepositCapExceeded(address token, uint256 amount, uint256 cap); error TotalCapExceeded(address token, uint256 nextTotal, uint256 cap); error TransferFailed(); + error DepositAlreadyRecorded(bytes32 depositId); + error DepositNotRecorded(bytes32 depositId); + error ReleaseTokenMismatch(bytes32 depositId, address expectedToken, address actualToken); + error ReleaseAmountExceeded(bytes32 depositId, uint256 requested, uint256 available); error ReleaseAlreadyProcessed(bytes32 releaseId); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event ReleaseAuthoritySet(address indexed previousAuthority, address indexed newAuthority); event PausedSet(bool paused); event TokenConfigured(address indexed token, bool allowed, uint256 perDepositCap, uint256 totalCap); event BridgeDeposit( @@ -69,6 +95,13 @@ contract BaseBridgeLockbox { _; } + modifier onlyReleaseAuthority() { + if (msg.sender != releaseAuthority) { + revert NotReleaseAuthority(msg.sender); + } + _; + } + modifier whenNotPaused() { if (paused) { revert Paused(); @@ -76,12 +109,26 @@ contract BaseBridgeLockbox { _; } - constructor(address initialOwner) { + modifier nonReentrant() { + if (_entered) { + revert ReentrantCall(); + } + _entered = true; + _; + _entered = false; + } + + constructor(address initialOwner, address initialReleaseAuthority) { if (initialOwner == address(0)) { revert ZeroOwner(); } + if (initialReleaseAuthority == address(0)) { + revert ZeroReleaseAuthority(); + } owner = initialOwner; + releaseAuthority = initialReleaseAuthority; emit OwnershipTransferred(address(0), initialOwner); + emit ReleaseAuthoritySet(address(0), initialReleaseAuthority); } receive() external payable { @@ -97,25 +144,32 @@ contract BaseBridgeLockbox { emit OwnershipTransferred(previousOwner, newOwner); } + function setReleaseAuthority(address newAuthority) external onlyOwner { + if (newAuthority == address(0)) { + revert ZeroReleaseAuthority(); + } + address previousAuthority = releaseAuthority; + releaseAuthority = newAuthority; + emit ReleaseAuthoritySet(previousAuthority, newAuthority); + } + function setPaused(bool value) external onlyOwner { paused = value; emit PausedSet(value); } function configureToken(address token, bool allowed, uint256 perDepositCap, uint256 totalCap) external onlyOwner { - if (token != NATIVE_TOKEN && token == address(0)) { - revert ZeroToken(); - } + TokenConfig storage config = tokenConfigs[token]; if (allowed && perDepositCap == 0) { revert ZeroAmount(); } - if (allowed && totalCap != 0 && totalCap < tokenConfigs[token].totalLocked) { - revert TotalCapExceeded(token, tokenConfigs[token].totalLocked, totalCap); + if (allowed && totalCap != 0 && totalCap < config.totalLocked) { + revert TotalCapExceeded(token, config.totalLocked, totalCap); } - tokenConfigs[token].allowed = allowed; - tokenConfigs[token].perDepositCap = perDepositCap; - tokenConfigs[token].totalCap = totalCap; + config.allowed = allowed; + config.perDepositCap = perDepositCap; + config.totalCap = totalCap; emit TokenConfigured(token, allowed, perDepositCap, totalCap); } @@ -123,6 +177,7 @@ contract BaseBridgeLockbox { external payable whenNotPaused + nonReentrant returns (bytes32 depositId) { depositId = _lock(NATIVE_TOKEN, msg.value, msg.sender, flowchainRecipient, metadataHash); @@ -131,6 +186,7 @@ contract BaseBridgeLockbox { function lockERC20(address token, uint256 amount, bytes32 flowchainRecipient, bytes32 metadataHash) external whenNotPaused + nonReentrant returns (bytes32 depositId) { if (token == NATIVE_TOKEN) { @@ -144,7 +200,8 @@ contract BaseBridgeLockbox { function releaseNative(bytes32 depositId, address payable recipient, uint256 amount, bytes32 evidenceHash) external - onlyOwner + onlyReleaseAuthority + nonReentrant returns (bytes32 releaseId) { releaseId = _recordRelease(depositId, recipient, NATIVE_TOKEN, amount, evidenceHash); @@ -156,7 +213,8 @@ contract BaseBridgeLockbox { function releaseERC20(bytes32 depositId, address recipient, address token, uint256 amount, bytes32 evidenceHash) external - onlyOwner + onlyReleaseAuthority + nonReentrant returns (bytes32 releaseId) { if (token == NATIVE_TOKEN) { @@ -168,6 +226,18 @@ contract BaseBridgeLockbox { } } + function remainingDepositAmount(bytes32 depositId) external view returns (uint256) { + DepositRecord storage record = depositRecords[depositId]; + if (!record.exists) { + return 0; + } + return record.amount - record.released; + } + + function getDepositRecord(bytes32 depositId) external view returns (DepositRecord memory) { + return depositRecords[depositId]; + } + function _lock(address token, uint256 amount, address sender, bytes32 flowchainRecipient, bytes32 metadataHash) private returns (bytes32 depositId) @@ -193,8 +263,34 @@ contract BaseBridgeLockbox { } uint256 nonce = nextNonce++; - depositId = keccak256(abi.encode(block.chainid, address(this), sender, token, amount, flowchainRecipient, nonce)); + depositId = keccak256( + abi.encode( + BRIDGE_DEPOSIT_SCHEMA_ID, + block.chainid, + address(this), + sender, + token, + amount, + flowchainRecipient, + nonce, + metadataHash + ) + ); + if (deposits[depositId]) { + revert DepositAlreadyRecorded(depositId); + } + deposits[depositId] = true; + depositRecords[depositId] = DepositRecord({ + sender: sender, + token: token, + amount: amount, + released: 0, + flowchainRecipient: flowchainRecipient, + nonce: nonce, + metadataHash: metadataHash, + exists: true + }); config.totalLocked = nextTotal; emit BridgeDeposit({ @@ -209,32 +305,54 @@ contract BaseBridgeLockbox { }); } - function _recordRelease( - bytes32 depositId, - address recipient, - address token, - uint256 amount, - bytes32 evidenceHash - ) private returns (bytes32 releaseId) { + function _recordRelease(bytes32 depositId, address recipient, address token, uint256 amount, bytes32 evidenceHash) + private + returns (bytes32 releaseId) + { if (recipient == address(0)) { revert ZeroRecipient(); } if (amount == 0) { revert ZeroAmount(); } + if (evidenceHash == bytes32(0)) { + revert ZeroEvidenceHash(); + } - releaseId = keccak256(abi.encode(block.chainid, address(this), depositId, recipient, token, amount, evidenceHash)); + DepositRecord storage record = depositRecords[depositId]; + if (!record.exists) { + revert DepositNotRecorded(depositId); + } + if (record.token != token) { + revert ReleaseTokenMismatch(depositId, record.token, token); + } + + releaseId = keccak256( + abi.encode( + BRIDGE_RELEASE_SCHEMA_ID, + block.chainid, + address(this), + depositId, + recipient, + token, + amount, + evidenceHash + ) + ); if (releases[releaseId]) { revert ReleaseAlreadyProcessed(releaseId); } + + uint256 available = record.amount - record.released; + if (amount > available) { + revert ReleaseAmountExceeded(depositId, amount, available); + } + releases[releaseId] = true; + record.released += amount; TokenConfig storage config = tokenConfigs[token]; - if (config.totalLocked >= amount) { - config.totalLocked -= amount; - } else { - config.totalLocked = 0; - } + config.totalLocked -= amount; emit BridgeRelease({ releaseId: releaseId, diff --git a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md index 1d86f6f2..3cd06040 100644 --- a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md +++ b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md @@ -9,11 +9,15 @@ public bridge, and not approved for broad mainnet use. ## What Exists - `contracts/bridge/BaseBridgeLockbox.sol`: non-upgradeable lockbox with owner, - pause, allowlisted tokens, per-deposit caps, total caps, deposit events, and - owner-only release helpers. + explicit test release authority, pause, allowlisted tokens, per-deposit caps, + total caps, deposit records, replay guards, deposit events, and release hooks. +- `contracts/FlowChainSettlementSpine.sol`: compact local/test event spine for + bridge and FlowChain object commitments. - `tests/bridge/BaseBridgeLockbox.t.sol`: Foundry coverage for token allowlisting, ERC-20 deposits, native deposits, caps, pause behavior, ownership, release, and replay protection. +- `tests/FlowChainSettlementSpine.t.sol`: Foundry coverage for authorized object + commitments and stable settlement event shape. - `services/bridge-relayer/`: fixture-first observer that converts explicit bridge deposit records into FlowChain bridge observation JSON. - `fixtures/bridge/base-sepolia-mock-deposit.json`: deterministic test deposit. @@ -28,8 +32,9 @@ public bridge, and not approved for broad mainnet use. ```text Base Sepolia user/test wallet -> BaseBridgeLockbox.lockERC20 or lockNative - -> BridgeDeposit event + -> BridgeDeposit event and DepositRecord state -> bridge-relayer explicit reader/mock observer + -> optional FlowChainSettlementSpine.commitObject bridge-deposit commitment -> FlowChain bridge deposit observation fixture -> local control plane / workbench / devnet handoff ``` @@ -42,8 +47,9 @@ bridge deposit objects. - Base mainnet uses real funds. Mainnet canary reads require `--acknowledge-real-funds` and `--max-usd 25` or lower. -- The lockbox owner can pause and release funds. That is a test operator model, - not a decentralized bridge model. +- The lockbox owner can configure tokens, caps, pause state, and the explicit + release authority. Only the release authority can call release hooks. That is + a test operator model, not a decentralized bridge model. - The relayer reads explicit chains, contracts, and block ranges. It must not broad-scan Base mainnet. - No secrets, RPC keys, private keys, or seed phrases should be committed. @@ -79,6 +85,117 @@ powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-se The script checks Base Sepolia chain id `84532`, requires an explicit lockbox, requires an explicit block range, and writes a local observation output. +## Foundry Deploy Script + +The contract-side bridge spine has a dry-run-by-default Foundry script: + +```powershell +$env:FLOWCHAIN_BRIDGE_OWNER = "0x..." +$env:FLOWCHAIN_BRIDGE_RELEASE_AUTHORITY = "0x..." +$env:FLOWCHAIN_SETTLEMENT_SUBMITTER = "0x..." +$env:FLOWCHAIN_BRIDGE_ALLOW_NATIVE = "true" +$env:FLOWCHAIN_BRIDGE_NATIVE_PER_DEPOSIT_CAP = "100000000000000000" +$env:FLOWCHAIN_BRIDGE_NATIVE_TOTAL_CAP = "1000000000000000000" +$env:FLOWCHAIN_BRIDGE_ALLOW_ERC20 = "false" +$env:FLOWCHAIN_BRIDGE_ERC20_TOKEN = "0x0000000000000000000000000000000000000000" +$env:FLOWCHAIN_BRIDGE_ERC20_PER_DEPOSIT_CAP = "0" +$env:FLOWCHAIN_BRIDGE_ERC20_TOTAL_CAP = "0" + +forge script script/DeployBridgeSpine.s.sol:DeployBridgeSpine ` + --rpc-url http://127.0.0.1:8545 +``` + +For Base Sepolia dry-run, use `--rpc-url $env:BASE_SEPOLIA_RPC_URL`. Add +`--broadcast` only after the environment values are explicit and the owner key +is intentionally supplied to Foundry. Do not commit RPC URLs or private keys. + +## Contract Event Schema + +`BridgeDeposit` is the relayer-facing deposit event: + +```solidity +event BridgeDeposit( + bytes32 indexed depositId, + uint256 indexed sourceChainId, + address indexed sender, + address token, + uint256 amount, + bytes32 flowchainRecipient, + uint256 nonce, + bytes32 metadataHash +); +``` + +`depositId` is: + +```text +keccak256(abi.encode( + BRIDGE_DEPOSIT_SCHEMA_ID, + block.chainid, + lockboxAddress, + sender, + token, + amount, + flowchainRecipient, + nonce, + metadataHash +)) +``` + +`BridgeRelease` is a test-only release event: + +```solidity +event BridgeRelease( + bytes32 indexed releaseId, + bytes32 indexed depositId, + address indexed recipient, + address token, + uint256 amount, + bytes32 evidenceHash +); +``` + +`releaseId` is: + +```text +keccak256(abi.encode( + BRIDGE_RELEASE_SCHEMA_ID, + block.chainid, + lockboxAddress, + depositId, + recipient, + token, + amount, + evidenceHash +)) +``` + +Release hooks require the configured release authority, a recorded deposit, +matching token, nonzero evidence hash, and available unreleased deposit amount. +They do not mint anything and do not prove FlowChain finality. + +`FlowChainSettlementSpine` can record the local/private runtime's accepted +object commitments without implementing the runtime in Solidity: + +```solidity +event FlowChainObjectCommitted( + bytes32 indexed objectId, + bytes32 indexed rootfieldId, + bytes32 indexed objectType, + address submitter, + bytes32 commitment, + bytes32 parentObjectId, + uint64 sequence, + uint64 committedAt, + string evidenceURI +); +``` + +Bridge agents should use `BRIDGE_DEPOSIT_OBJECT` as `objectType` when committing +a FlowChain bridge-deposit object derived from a `BridgeDeposit`. Indexers still +derive `txHash`, `logIndex`, and block metadata from receipts and logs; those +fields are not emitted by the contracts. + ## Base Mainnet Canary Read Only after review, and only for a tiny capped canary: @@ -100,6 +217,7 @@ The script checks Base mainnet chain id `8453` and refuses a canary above ```powershell forge test --match-path tests/bridge/BaseBridgeLockbox.t.sol +forge test --match-path tests/FlowChainSettlementSpine.t.sol npm run bridge:test npm run bridge:mock git diff --check diff --git a/script/DeployBridgeSpine.s.sol b/script/DeployBridgeSpine.s.sol new file mode 100644 index 00000000..0792e9b1 --- /dev/null +++ b/script/DeployBridgeSpine.s.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {BaseBridgeLockbox} from "../contracts/bridge/BaseBridgeLockbox.sol"; +import {FlowChainSettlementSpine} from "../contracts/FlowChainSettlementSpine.sol"; + +interface BridgeSpineVm { + function startBroadcast(address signer) external; + function stopBroadcast() external; + function envAddress(string calldata key) external returns (address value); + function envBool(string calldata key) external returns (bool value); + function envUint(string calldata key) external returns (uint256 value); +} + +/// @title DeployBridgeSpine +/// @notice Foundry script for local Anvil and Base Sepolia bridge-spine testing. +/// @dev Dry-run with `forge script` by default. Add `--broadcast` only after +/// setting explicit test environment variables. +contract DeployBridgeSpine { + BridgeSpineVm private constant VM = BridgeSpineVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + struct Deployment { + address lockbox; + address settlementSpine; + address owner; + address releaseAuthority; + address settlementSubmitter; + address erc20Token; + bool allowNative; + bool allowErc20; + } + + struct Config { + address owner; + address releaseAuthority; + address settlementSubmitter; + address erc20Token; + bool allowNative; + bool allowErc20; + uint256 nativePerDepositCap; + uint256 nativeTotalCap; + uint256 erc20PerDepositCap; + uint256 erc20TotalCap; + } + + error Erc20TokenRequired(); + + event FlowChainBridgeSpineDeployed( + address indexed lockbox, + address indexed settlementSpine, + address indexed owner, + address releaseAuthority, + address settlementSubmitter, + address erc20Token, + bool allowNative, + bool allowErc20 + ); + + function run() external returns (Deployment memory deployment) { + Config memory config = _readConfig(); + + if (config.allowErc20 && config.erc20Token == address(0)) { + revert Erc20TokenRequired(); + } + + VM.startBroadcast(config.owner); + + BaseBridgeLockbox lockbox = new BaseBridgeLockbox(config.owner, config.releaseAuthority); + FlowChainSettlementSpine settlementSpine = new FlowChainSettlementSpine(config.owner); + + if (config.allowNative) { + lockbox.configureToken(address(0), true, config.nativePerDepositCap, config.nativeTotalCap); + } + if (config.allowErc20) { + lockbox.configureToken(config.erc20Token, true, config.erc20PerDepositCap, config.erc20TotalCap); + } + if (config.settlementSubmitter != config.owner) { + settlementSpine.setSubmitterAuthorization(config.settlementSubmitter, true); + } + + deployment = Deployment({ + lockbox: address(lockbox), + settlementSpine: address(settlementSpine), + owner: config.owner, + releaseAuthority: config.releaseAuthority, + settlementSubmitter: config.settlementSubmitter, + erc20Token: config.erc20Token, + allowNative: config.allowNative, + allowErc20: config.allowErc20 + }); + + emit FlowChainBridgeSpineDeployed( + address(lockbox), + address(settlementSpine), + config.owner, + config.releaseAuthority, + config.settlementSubmitter, + config.erc20Token, + config.allowNative, + config.allowErc20 + ); + + VM.stopBroadcast(); + } + + function _readConfig() private returns (Config memory config) { + config = Config({ + owner: VM.envAddress("FLOWCHAIN_BRIDGE_OWNER"), + releaseAuthority: VM.envAddress("FLOWCHAIN_BRIDGE_RELEASE_AUTHORITY"), + settlementSubmitter: VM.envAddress("FLOWCHAIN_SETTLEMENT_SUBMITTER"), + erc20Token: VM.envAddress("FLOWCHAIN_BRIDGE_ERC20_TOKEN"), + allowNative: VM.envBool("FLOWCHAIN_BRIDGE_ALLOW_NATIVE"), + allowErc20: VM.envBool("FLOWCHAIN_BRIDGE_ALLOW_ERC20"), + nativePerDepositCap: VM.envUint("FLOWCHAIN_BRIDGE_NATIVE_PER_DEPOSIT_CAP"), + nativeTotalCap: VM.envUint("FLOWCHAIN_BRIDGE_NATIVE_TOTAL_CAP"), + erc20PerDepositCap: VM.envUint("FLOWCHAIN_BRIDGE_ERC20_PER_DEPOSIT_CAP"), + erc20TotalCap: VM.envUint("FLOWCHAIN_BRIDGE_ERC20_TOTAL_CAP") + }); + } +} diff --git a/tests/FlowChainSettlementSpine.t.sol b/tests/FlowChainSettlementSpine.t.sol new file mode 100644 index 00000000..39e38ac7 --- /dev/null +++ b/tests/FlowChainSettlementSpine.t.sol @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {FlowChainSettlementSpine} from "../contracts/FlowChainSettlementSpine.sol"; + +interface SettlementVm { + struct Log { + bytes32[] topics; + bytes data; + address emitter; + } + + function expectRevert(bytes4 revertData) external; + function expectRevert(bytes calldata revertData) external; + function recordLogs() external; + function getRecordedLogs() external returns (Log[] memory); +} + +contract SettlementSubmitter { + function commitObject( + FlowChainSettlementSpine spine, + bytes32 objectType, + bytes32 objectId, + bytes32 rootfieldId, + bytes32 commitment, + bytes32 parentObjectId, + string calldata evidenceURI + ) external returns (uint64) { + return spine.commitObject(objectType, objectId, rootfieldId, commitment, parentObjectId, evidenceURI); + } + + function setSubmitterAuthorization(FlowChainSettlementSpine spine, address submitter, bool authorized) external { + spine.setSubmitterAuthorization(submitter, authorized); + } +} + +contract FlowChainSettlementSpineTest { + SettlementVm private constant vm = SettlementVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + bytes32 private constant OBJECT_COMMITTED_SIGNATURE = + keccak256("FlowChainObjectCommitted(bytes32,bytes32,bytes32,address,bytes32,bytes32,uint64,uint64,string)"); + + FlowChainSettlementSpine private spine; + SettlementSubmitter private submitter; + + error AssertionFailed(); + + function setUp() public { + spine = new FlowChainSettlementSpine(address(this)); + submitter = new SettlementSubmitter(); + } + + function testConstructorRequiresOwnerAndAuthorizesOwner() public { + vm.expectRevert(FlowChainSettlementSpine.ZeroOwner.selector); + new FlowChainSettlementSpine(address(0)); + + _assertTrue(spine.owner() == address(this)); + _assertTrue(spine.authorizedSubmitters(address(this))); + } + + function testOwnerCanAuthorizeSubmitterAndTransferOwnership() public { + spine.setSubmitterAuthorization(address(submitter), true); + _assertTrue(spine.authorizedSubmitters(address(submitter))); + + spine.transferOwnership(address(submitter)); + _assertTrue(spine.owner() == address(submitter)); + } + + function testNonOwnerCannotAuthorizeSubmitter() public { + vm.expectRevert(abi.encodeWithSelector(FlowChainSettlementSpine.NotOwner.selector, address(submitter))); + submitter.setSubmitterAuthorization(spine, address(submitter), true); + } + + function testCommitBridgeDepositObjectEmitsStableEventAndStoresRecord() public { + bytes32 objectType = spine.BRIDGE_DEPOSIT_OBJECT(); + bytes32 objectId = keccak256("bridge.deposit.1"); + bytes32 rootfieldId = keccak256("rootfield.bridge"); + bytes32 commitment = keccak256("bridge.deposit.commitment"); + bytes32 parentObjectId = keccak256("parent.bridge.deposit"); + + vm.recordLogs(); + uint64 sequence = + spine.commitObject(objectType, objectId, rootfieldId, commitment, parentObjectId, "bridge://evidence/1"); + SettlementVm.Log[] memory logs = vm.getRecordedLogs(); + + _assertTrue(sequence == 1); + _assertTrue(spine.nextSequence() == 2); + _assertTrue(spine.isObjectCommitted(objectId)); + + FlowChainSettlementSpine.ObjectCommitment memory record = spine.getObjectCommitment(objectId); + _assertTrue(record.submitter == address(this)); + _assertTrue(record.objectType == objectType); + _assertTrue(record.rootfieldId == rootfieldId); + _assertTrue(record.commitment == commitment); + _assertTrue(record.parentObjectId == parentObjectId); + _assertTrue(record.sequence == 1); + _assertTrue(record.committedAt > 0); + _assertTrue(record.exists); + + _assertObjectCommittedLog( + logs[logs.length - 1], objectId, rootfieldId, objectType, commitment, parentObjectId, sequence + ); + } + + function testAuthorizedSubmitterCanCommitAndRevocationBlocksFutureCommits() public { + bytes32 bridgeDepositObject = spine.BRIDGE_DEPOSIT_OBJECT(); + bytes32 objectId = keccak256("bridge.deposit.authorized"); + spine.setSubmitterAuthorization(address(submitter), true); + + uint64 sequence = submitter.commitObject( + spine, + bridgeDepositObject, + objectId, + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + _assertTrue(sequence == 1); + + spine.setSubmitterAuthorization(address(submitter), false); + vm.expectRevert( + abi.encodeWithSelector(FlowChainSettlementSpine.SubmitterNotAuthorized.selector, address(submitter)) + ); + submitter.commitObject( + spine, + bridgeDepositObject, + keccak256("bridge.deposit.revoked"), + keccak256("rootfield.bridge"), + keccak256("commitment.2"), + bytes32(0), + "" + ); + } + + function testCommitRejectsUnauthorizedZeroFieldsAndDuplicates() public { + bytes32 bridgeDepositObject = spine.BRIDGE_DEPOSIT_OBJECT(); + + vm.expectRevert( + abi.encodeWithSelector(FlowChainSettlementSpine.SubmitterNotAuthorized.selector, address(submitter)) + ); + submitter.commitObject( + spine, + bridgeDepositObject, + keccak256("bridge.deposit.unauthorized"), + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(FlowChainSettlementSpine.ZeroObjectType.selector); + spine.commitObject( + bytes32(0), + keccak256("bridge.deposit.zero-type"), + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(FlowChainSettlementSpine.ZeroObjectId.selector); + spine.commitObject( + bridgeDepositObject, + bytes32(0), + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(FlowChainSettlementSpine.ZeroRootfieldId.selector); + spine.commitObject( + bridgeDepositObject, + keccak256("bridge.deposit.zero-rootfield"), + bytes32(0), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(FlowChainSettlementSpine.ZeroCommitment.selector); + spine.commitObject( + bridgeDepositObject, + keccak256("bridge.deposit.zero-commitment"), + keccak256("rootfield.bridge"), + bytes32(0), + bytes32(0), + "" + ); + + bytes32 objectId = keccak256("bridge.deposit.duplicate"); + spine.commitObject( + bridgeDepositObject, + objectId, + keccak256("rootfield.bridge"), + keccak256("commitment"), + bytes32(0), + "" + ); + + vm.expectRevert(abi.encodeWithSelector(FlowChainSettlementSpine.ObjectAlreadyCommitted.selector, objectId)); + spine.commitObject( + bridgeDepositObject, + objectId, + keccak256("rootfield.bridge"), + keccak256("commitment.2"), + bytes32(0), + "" + ); + } + + function testMissingObjectLookupReverts() public { + bytes32 objectId = keccak256("missing.object"); + + _assertTrue(!spine.isObjectCommitted(objectId)); + vm.expectRevert(abi.encodeWithSelector(FlowChainSettlementSpine.ObjectNotCommitted.selector, objectId)); + spine.getObjectCommitment(objectId); + } + + function _assertObjectCommittedLog( + SettlementVm.Log memory log, + bytes32 objectId, + bytes32 rootfieldId, + bytes32 objectType, + bytes32 commitment, + bytes32 parentObjectId, + uint64 sequence + ) private view { + _assertTrue(log.emitter == address(spine)); + _assertTrue(log.topics[0] == OBJECT_COMMITTED_SIGNATURE); + _assertTrue(log.topics[1] == objectId); + _assertTrue(log.topics[2] == rootfieldId); + _assertTrue(log.topics[3] == objectType); + + ( + address decodedSubmitter, + bytes32 decodedCommitment, + bytes32 decodedParentObjectId, + uint64 decodedSequence, + uint64 committedAt, + string memory evidenceURI + ) = abi.decode(log.data, (address, bytes32, bytes32, uint64, uint64, string)); + + _assertTrue(decodedSubmitter == address(this)); + _assertTrue(decodedCommitment == commitment); + _assertTrue(decodedParentObjectId == parentObjectId); + _assertTrue(decodedSequence == sequence); + _assertTrue(committedAt > 0); + _assertTrue(keccak256(bytes(evidenceURI)) == keccak256("bridge://evidence/1")); + } + + function _assertTrue(bool value) private pure { + if (!value) { + revert AssertionFailed(); + } + } +} diff --git a/tests/bridge/BaseBridgeLockbox.t.sol b/tests/bridge/BaseBridgeLockbox.t.sol index 3b4329d9..a9a5842c 100644 --- a/tests/bridge/BaseBridgeLockbox.t.sol +++ b/tests/bridge/BaseBridgeLockbox.t.sol @@ -63,6 +63,45 @@ contract BridgeCaller { return lockbox.lockNative{value: msg.value}(recipient, keccak256("metadata")); } + function setPaused(BaseBridgeLockbox lockbox, bool paused) external { + lockbox.setPaused(paused); + } + + function configureToken( + BaseBridgeLockbox lockbox, + address token, + bool allowed, + uint256 perDepositCap, + uint256 totalCap + ) external { + lockbox.configureToken(token, allowed, perDepositCap, totalCap); + } + + function setReleaseAuthority(BaseBridgeLockbox lockbox, address authority) external { + lockbox.setReleaseAuthority(authority); + } + + function releaseERC20( + BaseBridgeLockbox lockbox, + bytes32 depositId, + address recipient, + address token, + uint256 amount, + bytes32 evidenceHash + ) external returns (bytes32) { + return lockbox.releaseERC20(depositId, recipient, token, amount, evidenceHash); + } + + function releaseNative( + BaseBridgeLockbox lockbox, + bytes32 depositId, + address payable recipient, + uint256 amount, + bytes32 evidenceHash + ) external returns (bytes32) { + return lockbox.releaseNative(depositId, recipient, amount, evidenceHash); + } + receive() external payable {} } @@ -71,7 +110,10 @@ contract BaseBridgeLockboxTest { bytes32 private constant BRIDGE_DEPOSIT_SIGNATURE = keccak256("BridgeDeposit(bytes32,uint256,address,address,uint256,bytes32,uint256,bytes32)"); + bytes32 private constant BRIDGE_RELEASE_SIGNATURE = + keccak256("BridgeRelease(bytes32,bytes32,address,address,uint256,bytes32)"); bytes32 private constant RECIPIENT = keccak256("flowchain.recipient.alice"); + bytes32 private constant EVIDENCE_HASH = keccak256("flowchain.local.acceptance"); BaseBridgeLockbox private lockbox; MockToken private token; @@ -80,7 +122,7 @@ contract BaseBridgeLockboxTest { error AssertionFailed(); function setUp() public { - lockbox = new BaseBridgeLockbox(address(this)); + lockbox = new BaseBridgeLockbox(address(this), address(this)); token = new MockToken(); caller = new BridgeCaller(); token.mint(address(caller), 1_000 ether); @@ -88,7 +130,15 @@ contract BaseBridgeLockboxTest { lockbox.configureToken(address(0), true, 1 ether, 2 ether); } - function testOwnerCanConfigureAllowlistedToken() public { + function testConstructorRequiresExplicitOwnerAndReleaseAuthority() public { + vm.expectRevert(BaseBridgeLockbox.ZeroOwner.selector); + new BaseBridgeLockbox(address(0), address(this)); + + vm.expectRevert(BaseBridgeLockbox.ZeroReleaseAuthority.selector); + new BaseBridgeLockbox(address(this), address(0)); + } + + function testOwnerCanConfigureAllowlistedTokenAndReleaseAuthority() public { (bool allowed, uint256 perDepositCap, uint256 totalCap, uint256 totalLocked) = lockbox.tokenConfigs(address(token)); @@ -96,35 +146,68 @@ contract BaseBridgeLockboxTest { _assertTrue(perDepositCap == 25 ether); _assertTrue(totalCap == 100 ether); _assertTrue(totalLocked == 0); + + lockbox.setReleaseAuthority(address(caller)); + _assertTrue(lockbox.releaseAuthority() == address(caller)); + } + + function testNonOwnerCannotConfigurePauseOrSetReleaseAuthority() public { + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(caller))); + caller.setPaused(lockbox, true); + + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(caller))); + caller.configureToken(lockbox, address(token), true, 1 ether, 1 ether); + + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(caller))); + caller.setReleaseAuthority(lockbox, address(caller)); } - function testLockERC20EmitsDeterministicDepositEvent() public { + function testLockERC20EmitsStableDeterministicDepositEventAndRecord() public { vm.recordLogs(); bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); BridgeVm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 expectedDepositId = keccak256( + abi.encode( + lockbox.BRIDGE_DEPOSIT_SCHEMA_ID(), + block.chainid, + address(lockbox), + address(caller), + address(token), + 10 ether, + RECIPIENT, + uint256(1), + keccak256("metadata") + ) + ); + _assertTrue(depositId == expectedDepositId); _assertTrue(lockbox.deposits(depositId)); _assertTrue(token.balanceOf(address(lockbox)) == 10 ether); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 10 ether); + + BaseBridgeLockbox.DepositRecord memory record = lockbox.getDepositRecord(depositId); + _assertTrue(record.sender == address(caller)); + _assertTrue(record.token == address(token)); + _assertTrue(record.amount == 10 ether); + _assertTrue(record.released == 0); + _assertTrue(record.flowchainRecipient == RECIPIENT); + _assertTrue(record.nonce == 1); + _assertTrue(record.metadataHash == keccak256("metadata")); + _assertTrue(record.exists); (,,, uint256 totalLocked) = lockbox.tokenConfigs(address(token)); _assertTrue(totalLocked == 10 ether); - _assertTrue(logs.length >= 1); - - BridgeVm.Log memory log = logs[logs.length - 1]; - _assertTrue(log.emitter == address(lockbox)); - _assertTrue(log.topics[0] == BRIDGE_DEPOSIT_SIGNATURE); - _assertTrue(log.topics[1] == depositId); - _assertTrue(uint256(log.topics[2]) == block.chainid); - _assertTrue(address(uint160(uint256(log.topics[3]))) == address(caller)); + _assertBridgeDepositLog(logs[logs.length - 1], depositId, address(token), 10 ether, 1); + } - (address eventToken, uint256 amount, bytes32 recipient, uint256 nonce, bytes32 metadataHash) = - abi.decode(log.data, (address, uint256, bytes32, uint256, bytes32)); + function testRepeatedDepositsUseNonceReplayProtection() public { + bytes32 firstDeposit = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + bytes32 secondDeposit = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); - _assertTrue(eventToken == address(token)); - _assertTrue(amount == 10 ether); - _assertTrue(recipient == RECIPIENT); - _assertTrue(nonce == 1); - _assertTrue(metadataHash == keccak256("metadata")); + _assertTrue(firstDeposit != secondDeposit); + _assertTrue(lockbox.deposits(firstDeposit)); + _assertTrue(lockbox.deposits(secondDeposit)); + _assertTrue(lockbox.nextNonce() == 3); } function testLockNativeWorksWhenExplicitlyAllowlisted() public { @@ -134,50 +217,190 @@ contract BaseBridgeLockboxTest { _assertTrue(lockbox.deposits(depositId)); _assertTrue(address(lockbox).balance == 0.2 ether); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 0.2 ether); } - function testRejectsUnallowlistedToken() public { + function testRejectsUnallowlistedDisabledAndZeroTokenDeposits() public { MockToken other = new MockToken(); other.mint(address(caller), 10 ether); vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.TokenNotAllowed.selector, address(other))); caller.lockERC20(lockbox, address(other), 1 ether, RECIPIENT); + + lockbox.configureToken(address(token), false, 0, 0); + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.TokenNotAllowed.selector, address(token))); + caller.lockERC20(lockbox, address(token), 1 ether, RECIPIENT); + + vm.expectRevert(BaseBridgeLockbox.ZeroToken.selector); + lockbox.lockERC20(address(0), 1 ether, RECIPIENT, keccak256("metadata")); + } + + function testRejectsZeroAmountRecipientAndDirectNativeTransfers() public { + vm.expectRevert(BaseBridgeLockbox.ZeroAmount.selector); + caller.lockERC20(lockbox, address(token), 0, RECIPIENT); + + vm.expectRevert(BaseBridgeLockbox.ZeroRecipient.selector); + caller.lockERC20(lockbox, address(token), 1 ether, bytes32(0)); + + (bool ok,) = address(lockbox).call{value: 1 wei}(""); + _assertTrue(!ok); } - function testRejectsPerDepositCapExceeded() public { + function testRejectsPerDepositAndTotalCapExceeded() public { vm.expectRevert( abi.encodeWithSelector(BaseBridgeLockbox.PerDepositCapExceeded.selector, address(token), 30 ether, 25 ether) ); caller.lockERC20(lockbox, address(token), 30 ether, RECIPIENT); + + caller.lockERC20(lockbox, address(token), 25 ether, RECIPIENT); + caller.lockERC20(lockbox, address(token), 25 ether, RECIPIENT); + caller.lockERC20(lockbox, address(token), 25 ether, RECIPIENT); + caller.lockERC20(lockbox, address(token), 25 ether, RECIPIENT); + + vm.expectRevert( + abi.encodeWithSelector(BaseBridgeLockbox.TotalCapExceeded.selector, address(token), 101 ether, 100 ether) + ); + caller.lockERC20(lockbox, address(token), 1 ether, RECIPIENT); } - function testPauseBlocksDeposits() public { + function testCannotLowerTotalCapBelowCurrentlyLockedAmount() public { + caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + + vm.expectRevert( + abi.encodeWithSelector(BaseBridgeLockbox.TotalCapExceeded.selector, address(token), 10 ether, 9 ether) + ); + lockbox.configureToken(address(token), true, 25 ether, 9 ether); + } + + function testPauseBlocksDepositsButNotAuthorizedRelease() public { + bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); lockbox.setPaused(true); vm.expectRevert(BaseBridgeLockbox.Paused.selector); caller.lockERC20(lockbox, address(token), 1 ether, RECIPIENT); + + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 9 ether); } - function testOnlyOwnerCanReleaseAndReplayIsBlocked() public { + function testReleaseERC20RequiresExplicitAuthorityAndKnownDeposit() public { bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); - bytes32 evidenceHash = keccak256("flowchain.local.acceptance"); - bytes32 releaseId = lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, evidenceHash); + lockbox.setReleaseAuthority(address(caller)); + + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotReleaseAuthority.selector, address(this))); + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.DepositNotRecorded.selector, keccak256("missing"))); + caller.releaseERC20(lockbox, keccak256("missing"), address(caller), address(token), 1 ether, EVIDENCE_HASH); + bytes32 releaseId = + caller.releaseERC20(lockbox, depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); _assertTrue(lockbox.releases(releaseId)); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 9 ether); _assertTrue(token.balanceOf(address(caller)) == 991 ether); + } + + function testReleaseERC20EmitsStableSchemaAndBlocksReplay() public { + bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + + vm.recordLogs(); + bytes32 releaseId = lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + BridgeVm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 expectedReleaseId = keccak256( + abi.encode( + lockbox.BRIDGE_RELEASE_SCHEMA_ID(), + block.chainid, + address(lockbox), + depositId, + address(caller), + address(token), + 1 ether, + EVIDENCE_HASH + ) + ); + _assertTrue(releaseId == expectedReleaseId); + _assertBridgeReleaseLog(logs[logs.length - 1], releaseId, depositId, address(caller), address(token), 1 ether); vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.ReleaseAlreadyProcessed.selector, releaseId)); - lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, evidenceHash); + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + } + + function testReleaseBlocksTokenMismatchOverReleaseAndZeroEvidence() public { + bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + + vm.expectRevert( + abi.encodeWithSelector( + BaseBridgeLockbox.ReleaseTokenMismatch.selector, depositId, address(token), address(0) + ) + ); + lockbox.releaseNative(depositId, payable(address(caller)), 1 ether, EVIDENCE_HASH); + + vm.expectRevert( + abi.encodeWithSelector(BaseBridgeLockbox.ReleaseAmountExceeded.selector, depositId, 11 ether, 10 ether) + ); + lockbox.releaseERC20(depositId, address(caller), address(token), 11 ether, EVIDENCE_HASH); + + vm.expectRevert(BaseBridgeLockbox.ZeroEvidenceHash.selector); + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, bytes32(0)); + } + + function testReleaseNativeWorksThroughExplicitAuthorityWhilePaused() public { + vm.deal(address(caller), 0); + bytes32 depositId = caller.lockNative{value: 0.5 ether}(lockbox, RECIPIENT); + lockbox.setReleaseAuthority(address(caller)); + lockbox.setPaused(true); + + bytes32 releaseId = caller.releaseNative(lockbox, depositId, payable(address(caller)), 0.2 ether, EVIDENCE_HASH); + + _assertTrue(lockbox.releases(releaseId)); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 0.3 ether); + _assertTrue(address(lockbox).balance == 0.3 ether); + _assertTrue(address(caller).balance == 0.2 ether); + } + + function _assertBridgeDepositLog( + BridgeVm.Log memory log, + bytes32 depositId, + address expectedToken, + uint256 expectedAmount, + uint256 expectedNonce + ) private view { + _assertTrue(log.emitter == address(lockbox)); + _assertTrue(log.topics[0] == BRIDGE_DEPOSIT_SIGNATURE); + _assertTrue(log.topics[1] == depositId); + _assertTrue(uint256(log.topics[2]) == block.chainid); + _assertTrue(address(uint160(uint256(log.topics[3]))) == address(caller)); + + (address eventToken, uint256 amount, bytes32 recipient, uint256 nonce, bytes32 metadataHash) = + abi.decode(log.data, (address, uint256, bytes32, uint256, bytes32)); + + _assertTrue(eventToken == expectedToken); + _assertTrue(amount == expectedAmount); + _assertTrue(recipient == RECIPIENT); + _assertTrue(nonce == expectedNonce); + _assertTrue(metadataHash == keccak256("metadata")); } - function testNonOwnerCannotConfigurePauseOrRelease() public { - BaseBridgeLockbox otherOwnerLockbox = new BaseBridgeLockbox(address(caller)); + function _assertBridgeReleaseLog( + BridgeVm.Log memory log, + bytes32 releaseId, + bytes32 depositId, + address recipient, + address expectedToken, + uint256 expectedAmount + ) private view { + _assertTrue(log.emitter == address(lockbox)); + _assertTrue(log.topics[0] == BRIDGE_RELEASE_SIGNATURE); + _assertTrue(log.topics[1] == releaseId); + _assertTrue(log.topics[2] == depositId); + _assertTrue(address(uint160(uint256(log.topics[3]))) == recipient); - vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(this))); - otherOwnerLockbox.setPaused(true); + (address eventToken, uint256 amount, bytes32 evidenceHash) = abi.decode(log.data, (address, uint256, bytes32)); - vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(this))); - otherOwnerLockbox.configureToken(address(token), true, 1 ether, 1 ether); + _assertTrue(eventToken == expectedToken); + _assertTrue(amount == expectedAmount); + _assertTrue(evidenceHash == EVIDENCE_HASH); } function _assertTrue(bool value) private pure {