From 9fc3cf888df276cfce7f0f730c294e7e1d212f3a Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 21 Feb 2026 14:43:29 -0300 Subject: [PATCH 01/48] Add `IWithdrawer` interface --- src/withdrawers/IWithdrawer.sol | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/withdrawers/IWithdrawer.sol diff --git a/src/withdrawers/IWithdrawer.sol b/src/withdrawers/IWithdrawer.sol new file mode 100644 index 00000000..f2cdcf18 --- /dev/null +++ b/src/withdrawers/IWithdrawer.sol @@ -0,0 +1,14 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +interface IWithdrawer { + /// @notice Withdraw the funds of an account. + /// The encoding of accounts is application-specific. + /// This function will be called via `delegatecall`, + /// so it should not attempt to access its storage space. + /// @param account The account + /// @return accountOwner The account owner + function withdraw(bytes calldata account) external returns (address accountOwner); +} From 2c2ef394a7dadf7e2400620b459a9d096f30969a Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 21 Feb 2026 21:40:55 -0300 Subject: [PATCH 02/48] Add `WithdrawalConfig` structure --- src/common/WithdrawalConfig.sol | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/common/WithdrawalConfig.sol diff --git a/src/common/WithdrawalConfig.sol b/src/common/WithdrawalConfig.sol new file mode 100644 index 00000000..4004aef0 --- /dev/null +++ b/src/common/WithdrawalConfig.sol @@ -0,0 +1,20 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; + +// @notice Withdrawal configuration parameters. +// @param log2LeavesPerAccount The base-2 log of leaves per account +// @param log2MaxNumOfAccounts The base-2 log of max. num. of accounts +// @param accountsDriveStartIndex The offset of the accounts drive +// @param guardian The address of the account with guardian priviledges +// @param withdrawer The address of the withdrawer delegatecall contract +struct WithdrawalConfig { + uint8 log2LeavesPerAccount; + uint8 log2MaxNumOfAccounts; + uint64 accountsDriveStartIndex; + address guardian; + IWithdrawer withdrawer; +} From 78ee2ee8c478b4d6291c3f40aaf2c0d2fd09d3e0 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 21 Feb 2026 15:01:32 -0300 Subject: [PATCH 03/48] Add `AccountValidityProof` structure --- src/common/AccountValidityProof.sol | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/common/AccountValidityProof.sol diff --git a/src/common/AccountValidityProof.sol b/src/common/AccountValidityProof.sol new file mode 100644 index 00000000..a095a43e --- /dev/null +++ b/src/common/AccountValidityProof.sol @@ -0,0 +1,14 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +/// @notice Proof of inclusion of an account in the accounts drive. +/// @param accountIndex Index of account in the accounts drive +/// @param accountRootSiblings Siblings of the account root in the accounts drive +/// @dev From the index and siblings, one can calculate the accounts drive root. +/// @dev The siblings array should have size equal to the log2 of the maximum number of accounts. +struct AccountValidityProof { + uint64 accountIndex; + bytes32[] accountRootSiblings; +} From 114e58ff873b7c34c2f8d4ec3191d62f03260356 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 21 Feb 2026 21:58:17 -0300 Subject: [PATCH 04/48] Add `WithdrawalConfig` parameter to app deployment entrypoints --- src/dapp/Application.sol | 4 +- src/dapp/ApplicationFactory.sol | 21 ++++++-- src/dapp/IApplicationFactory.sol | 13 ++++- src/dapp/ISelfHostedApplicationFactory.sol | 7 +++ src/dapp/SelfHostedApplicationFactory.sol | 6 ++- test/dapp/Application.t.sol | 21 ++++++-- test/dapp/ApplicationFactory.t.sol | 56 ++++++++++++++++---- test/dapp/SelfHostedApplicationFactory.t.sol | 45 ++++++++++++++-- 8 files changed, 147 insertions(+), 26 deletions(-) diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index c3459d7c..b2e5361a 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.8; import {IOwnable} from "../access/IOwnable.sol"; import {OutputValidityProof} from "../common/OutputValidityProof.sol"; import {Outputs} from "../common/Outputs.sol"; +import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; import {LibAddress} from "../library/LibAddress.sol"; import {LibOutputValidityProof} from "../library/LibOutputValidityProof.sol"; @@ -64,7 +65,8 @@ contract Application is IOutputsMerkleRootValidator outputsMerkleRootValidator, address initialOwner, bytes32 templateHash, - bytes memory dataAvailability + bytes memory dataAvailability, + WithdrawalConfig memory ) Ownable(initialOwner) { TEMPLATE_HASH = templateHash; _outputsMerkleRootValidator = outputsMerkleRootValidator; diff --git a/src/dapp/ApplicationFactory.sol b/src/dapp/ApplicationFactory.sol index a4330a47..ebf6e572 100644 --- a/src/dapp/ApplicationFactory.sol +++ b/src/dapp/ApplicationFactory.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.8; import {Create2} from "@openzeppelin-contracts-5.2.0/utils/Create2.sol"; +import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; import {Application} from "./Application.sol"; import {IApplication} from "./IApplication.sol"; @@ -17,10 +18,15 @@ contract ApplicationFactory is IApplicationFactory { IOutputsMerkleRootValidator outputsMerkleRootValidator, address appOwner, bytes32 templateHash, - bytes calldata dataAvailability + bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig ) external override returns (IApplication) { IApplication appContract = new Application( - outputsMerkleRootValidator, appOwner, templateHash, dataAvailability + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig ); emit ApplicationCreated( @@ -39,10 +45,15 @@ contract ApplicationFactory is IApplicationFactory { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external override returns (IApplication) { IApplication appContract = new Application{salt: salt}( - outputsMerkleRootValidator, appOwner, templateHash, dataAvailability + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig ); emit ApplicationCreated( @@ -61,6 +72,7 @@ contract ApplicationFactory is IApplicationFactory { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external view override returns (address) { return Create2.computeAddress( @@ -72,7 +84,8 @@ contract ApplicationFactory is IApplicationFactory { outputsMerkleRootValidator, appOwner, templateHash, - dataAvailability + dataAvailability, + withdrawalConfig ) ) ) diff --git a/src/dapp/IApplicationFactory.sol b/src/dapp/IApplicationFactory.sol index 98d1457f..57da3b05 100644 --- a/src/dapp/IApplicationFactory.sol +++ b/src/dapp/IApplicationFactory.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.8; +import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; import {IApplication} from "./IApplication.sol"; @@ -14,6 +15,7 @@ interface IApplicationFactory { /// @param outputsMerkleRootValidator The initial outputs Merkle root validator contract /// @param appOwner The initial application owner /// @param templateHash The initial machine state hash + /// @param dataAvailability The data availability solution /// @param appContract The application contract /// @dev MUST be triggered on a successful call to `newApplication`. event ApplicationCreated( @@ -30,6 +32,8 @@ interface IApplicationFactory { /// @param outputsMerkleRootValidator The initial outputs Merkle root validator contract /// @param appOwner The initial application owner /// @param templateHash The initial machine state hash + /// @param dataAvailability The data availability solution + /// @param withdrawalConfig The withdrawal configuration /// @return The application /// @dev On success, MUST emit an `ApplicationCreated` event. /// @dev Reverts if the application owner address is zero. @@ -37,13 +41,16 @@ interface IApplicationFactory { IOutputsMerkleRootValidator outputsMerkleRootValidator, address appOwner, bytes32 templateHash, - bytes calldata dataAvailability + bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig ) external returns (IApplication); /// @notice Deploy a new application deterministically. /// @param outputsMerkleRootValidator The initial outputs Merkle root validator contract /// @param appOwner The initial application owner /// @param templateHash The initial machine state hash + /// @param dataAvailability The data availability solution + /// @param withdrawalConfig The withdrawal configuration /// @param salt The salt used to deterministically generate the application contract address /// @return The application /// @dev On success, MUST emit an `ApplicationCreated` event. @@ -53,6 +60,7 @@ interface IApplicationFactory { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external returns (IApplication); @@ -60,6 +68,8 @@ interface IApplicationFactory { /// @param outputsMerkleRootValidator The initial outputs Merkle root validator contract /// @param appOwner The initial application owner /// @param templateHash The initial machine state hash + /// @param dataAvailability The data availability solution + /// @param withdrawalConfig The withdrawal configuration /// @param salt The salt used to deterministically generate the application contract address /// @return The deterministic application contract address /// @dev Beware that only the `newApplication` function with the `salt` parameter @@ -69,6 +79,7 @@ interface IApplicationFactory { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external view returns (address); } diff --git a/src/dapp/ISelfHostedApplicationFactory.sol b/src/dapp/ISelfHostedApplicationFactory.sol index 61271dbd..211f92d6 100644 --- a/src/dapp/ISelfHostedApplicationFactory.sol +++ b/src/dapp/ISelfHostedApplicationFactory.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.8; +import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; import {IAuthority} from "../consensus/authority/IAuthority.sol"; import {IAuthorityFactory} from "../consensus/authority/IAuthorityFactory.sol"; import {IApplication} from "./IApplication.sol"; @@ -23,6 +24,8 @@ interface ISelfHostedApplicationFactory { /// @param epochLength The epoch length /// @param appOwner The initial application owner /// @param templateHash The initial machine state hash + /// @param dataAvailability The data availability solution + /// @param withdrawalConfig The withdrawal configuration /// @param salt The salt used to deterministically generate the addresses /// @return The application contract /// @return The authority contract @@ -35,6 +38,7 @@ interface ISelfHostedApplicationFactory { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external returns (IApplication, IAuthority); @@ -44,6 +48,8 @@ interface ISelfHostedApplicationFactory { /// @param epochLength The epoch length /// @param appOwner The initial application owner /// @param templateHash The initial machine state hash + /// @param dataAvailability The data availability solution + /// @param withdrawalConfig The withdrawal configuration /// @param salt The salt used to deterministically generate the addresses /// @return The application address /// @return The authority address @@ -53,6 +59,7 @@ interface ISelfHostedApplicationFactory { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external view returns (address, address); } diff --git a/src/dapp/SelfHostedApplicationFactory.sol b/src/dapp/SelfHostedApplicationFactory.sol index bde395ed..9e6443ba 100644 --- a/src/dapp/SelfHostedApplicationFactory.sol +++ b/src/dapp/SelfHostedApplicationFactory.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.8; +import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; import {IAuthority} from "../consensus/authority/IAuthority.sol"; import {IAuthorityFactory} from "../consensus/authority/IAuthorityFactory.sol"; @@ -46,12 +47,13 @@ contract SelfHostedApplicationFactory is ISelfHostedApplicationFactory { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external returns (IApplication application, IAuthority authority) { authority = AUTHORITY_FACTORY.newAuthority(authorityOwner, epochLength, salt); application = APPLICATION_FACTORY.newApplication( - authority, appOwner, templateHash, dataAvailability, salt + authority, appOwner, templateHash, dataAvailability, withdrawalConfig, salt ); } @@ -61,6 +63,7 @@ contract SelfHostedApplicationFactory is ISelfHostedApplicationFactory { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external view returns (address application, address authority) { authority = AUTHORITY_FACTORY.calculateAuthorityAddress( @@ -72,6 +75,7 @@ contract SelfHostedApplicationFactory is ISelfHostedApplicationFactory { appOwner, templateHash, dataAvailability, + withdrawalConfig, salt ); } diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index c78ad40d..d7311182 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -7,6 +7,7 @@ import {IOwnable} from "src/access/IOwnable.sol"; import {DataAvailability} from "src/common/DataAvailability.sol"; import {OutputValidityProof} from "src/common/OutputValidityProof.sol"; import {Outputs} from "src/common/Outputs.sol"; +import {WithdrawalConfig} from "src/common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "src/consensus/IOutputsMerkleRootValidator.sol"; import {Authority} from "src/consensus/authority/Authority.sol"; import {Application} from "src/dapp/Application.sol"; @@ -91,10 +92,13 @@ contract ApplicationTest is Test, OwnableTest { // ----------- function testConstructorRevertsInvalidOwner() external { + WithdrawalConfig memory withdrawalConfig; vm.expectRevert( abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) ); - new Application(_authority, address(0), TEMPLATE_HASH, new bytes(0)); + new Application( + _authority, address(0), TEMPLATE_HASH, new bytes(0), withdrawalConfig + ); } function testConstructor( @@ -102,7 +106,8 @@ contract ApplicationTest is Test, OwnableTest { IOutputsMerkleRootValidator outputsMerkleRootValidator, address owner, bytes32 templateHash, - bytes calldata dataAvailability + bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig ) external { vm.assume(owner != address(0)); @@ -112,7 +117,11 @@ contract ApplicationTest is Test, OwnableTest { emit Ownable.OwnershipTransferred(address(0), owner); IApplication appContract = new Application( - outputsMerkleRootValidator, owner, templateHash, dataAvailability + outputsMerkleRootValidator, + owner, + templateHash, + dataAvailability, + withdrawalConfig ); assertEq( @@ -342,8 +351,10 @@ contract ApplicationTest is Test, OwnableTest { _inputBox = new InputBox(); _authority = new Authority(_authorityOwner, EPOCH_LENGTH); _dataAvailability = abi.encodeCall(DataAvailability.InputBox, (_inputBox)); - _appContract = - new Application(_authority, _appOwner, TEMPLATE_HASH, _dataAvailability); + WithdrawalConfig memory withdrawalConfig; + _appContract = new Application( + _authority, _appOwner, TEMPLATE_HASH, _dataAvailability, withdrawalConfig + ); _safeErc20Transfer = new SafeERC20Transfer(); } diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index e44ba861..f09606c3 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -4,6 +4,7 @@ /// @title Application Factory Test pragma solidity ^0.8.22; +import {WithdrawalConfig} from "src/common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "src/consensus/IOutputsMerkleRootValidator.sol"; import {ApplicationFactory} from "src/dapp/ApplicationFactory.sol"; import {IApplication} from "src/dapp/IApplication.sol"; @@ -24,14 +25,19 @@ contract ApplicationFactoryTest is Test { IOutputsMerkleRootValidator outputsMerkleRootValidator, address appOwner, bytes32 templateHash, - bytes calldata dataAvailability + bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig ) public { vm.assume(appOwner != address(0)); vm.roll(blockNumber); IApplication appContract = _factory.newApplication( - outputsMerkleRootValidator, appOwner, templateHash, dataAvailability + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig ); assertEq( @@ -50,6 +56,7 @@ contract ApplicationFactoryTest is Test { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) public { vm.assume(appOwner != address(0)); @@ -57,11 +64,21 @@ contract ApplicationFactoryTest is Test { vm.roll(blockNumber); address precalculatedAddress = _factory.calculateApplicationAddress( - outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt ); IApplication appContract = _factory.newApplication( - outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt ); // Precalculated address must match actual address @@ -77,7 +94,12 @@ contract ApplicationFactoryTest is Test { assertEq(appContract.getDeploymentBlockNumber(), blockNumber); precalculatedAddress = _factory.calculateApplicationAddress( - outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt ); // Precalculated address must STILL match actual address @@ -86,7 +108,12 @@ contract ApplicationFactoryTest is Test { // Cannot deploy an application with the same salt twice vm.expectRevert(bytes("")); _factory.newApplication( - outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt ); } @@ -94,14 +121,19 @@ contract ApplicationFactoryTest is Test { IOutputsMerkleRootValidator outputsMerkleRootValidator, address appOwner, bytes32 templateHash, - bytes calldata dataAvailability + bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig ) public { vm.assume(appOwner != address(0)); vm.recordLogs(); IApplication appContract = _factory.newApplication( - outputsMerkleRootValidator, appOwner, templateHash, dataAvailability + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig ); _testApplicationCreatedEventAux( @@ -118,6 +150,7 @@ contract ApplicationFactoryTest is Test { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) public { vm.assume(appOwner != address(0)); @@ -125,7 +158,12 @@ contract ApplicationFactoryTest is Test { vm.recordLogs(); IApplication appContract = _factory.newApplication( - outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt ); _testApplicationCreatedEventAux( diff --git a/test/dapp/SelfHostedApplicationFactory.t.sol b/test/dapp/SelfHostedApplicationFactory.t.sol index dae7a043..569fc9c8 100644 --- a/test/dapp/SelfHostedApplicationFactory.t.sol +++ b/test/dapp/SelfHostedApplicationFactory.t.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.22; import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; +import {WithdrawalConfig} from "src/common/WithdrawalConfig.sol"; import {AuthorityFactory} from "src/consensus/authority/AuthorityFactory.sol"; import {IAuthority} from "src/consensus/authority/IAuthority.sol"; import {IAuthorityFactory} from "src/consensus/authority/IAuthorityFactory.sol"; @@ -41,6 +42,7 @@ contract SelfHostedApplicationFactoryTest is Test { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external { vm.assume(appOwner != address(0)); @@ -50,7 +52,13 @@ contract SelfHostedApplicationFactoryTest is Test { abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) ); factory.deployContracts( - address(0), epochLength, appOwner, templateHash, dataAvailability, salt + address(0), + epochLength, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt ); } @@ -59,6 +67,7 @@ contract SelfHostedApplicationFactoryTest is Test { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external { vm.assume(appOwner != address(0)); @@ -66,7 +75,13 @@ contract SelfHostedApplicationFactoryTest is Test { vm.expectRevert("epoch length must not be zero"); factory.deployContracts( - authorityOwner, 0, appOwner, templateHash, dataAvailability, salt + authorityOwner, + 0, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt ); } @@ -75,6 +90,7 @@ contract SelfHostedApplicationFactoryTest is Test { uint256 epochLength, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external { vm.assume(authorityOwner != address(0)); @@ -84,7 +100,13 @@ contract SelfHostedApplicationFactoryTest is Test { abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) ); factory.deployContracts( - authorityOwner, epochLength, address(0), templateHash, dataAvailability, salt + authorityOwner, + epochLength, + address(0), + templateHash, + dataAvailability, + withdrawalConfig, + salt ); } @@ -94,6 +116,7 @@ contract SelfHostedApplicationFactoryTest is Test { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external { vm.assume(appOwner != address(0)); @@ -104,14 +127,26 @@ contract SelfHostedApplicationFactoryTest is Test { address authorityAddr; (appAddr, authorityAddr) = factory.calculateAddresses( - authorityOwner, epochLength, appOwner, templateHash, dataAvailability, salt + authorityOwner, + epochLength, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt ); IApplication application; IAuthority authority; (application, authority) = factory.deployContracts( - authorityOwner, epochLength, appOwner, templateHash, dataAvailability, salt + authorityOwner, + epochLength, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt ); assertEq(appAddr, address(application)); From a3ce6188f4b942b9070d6aadbad975fdb067a0f0 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 21 Feb 2026 22:21:29 -0300 Subject: [PATCH 05/48] Add `WithdrawalConfig` parameter to app deployment event --- src/dapp/ApplicationFactory.sol | 2 ++ src/dapp/IApplicationFactory.sol | 1 + test/dapp/ApplicationFactory.t.sol | 9 ++++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/dapp/ApplicationFactory.sol b/src/dapp/ApplicationFactory.sol index ebf6e572..bbd54e04 100644 --- a/src/dapp/ApplicationFactory.sol +++ b/src/dapp/ApplicationFactory.sol @@ -34,6 +34,7 @@ contract ApplicationFactory is IApplicationFactory { appOwner, templateHash, dataAvailability, + withdrawalConfig, appContract ); @@ -61,6 +62,7 @@ contract ApplicationFactory is IApplicationFactory { appOwner, templateHash, dataAvailability, + withdrawalConfig, appContract ); diff --git a/src/dapp/IApplicationFactory.sol b/src/dapp/IApplicationFactory.sol index 57da3b05..edb0811d 100644 --- a/src/dapp/IApplicationFactory.sol +++ b/src/dapp/IApplicationFactory.sol @@ -23,6 +23,7 @@ interface IApplicationFactory { address appOwner, bytes32 templateHash, bytes dataAvailability, + WithdrawalConfig withdrawalConfig, IApplication appContract ); diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index f09606c3..327f7ca2 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -141,6 +141,7 @@ contract ApplicationFactoryTest is Test { appOwner, templateHash, dataAvailability, + withdrawalConfig, appContract ); } @@ -171,6 +172,7 @@ contract ApplicationFactoryTest is Test { appOwner, templateHash, dataAvailability, + withdrawalConfig, appContract ); } @@ -180,6 +182,7 @@ contract ApplicationFactoryTest is Test { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, + WithdrawalConfig calldata withdrawalConfig, IApplication appContract ) internal { Vm.Log[] memory entries = vm.getRecordedLogs(); @@ -204,12 +207,16 @@ contract ApplicationFactoryTest is Test { address appOwner_, bytes32 templateHash_, bytes memory dataAvailability_, + WithdrawalConfig memory withdrawalConfig_, IApplication app_ - ) = abi.decode(entry.data, (address, bytes32, bytes, IApplication)); + ) = abi.decode( + entry.data, (address, bytes32, bytes, WithdrawalConfig, IApplication) + ); assertEq(appOwner, appOwner_); assertEq(templateHash, templateHash_); assertEq(dataAvailability, dataAvailability_); + assertEq(abi.encode(withdrawalConfig), abi.encode(withdrawalConfig_)); assertEq(address(appContract), address(app_)); } } From cde2e4bcdc83f7e70ff20814643543b05d2b234d Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 07:54:23 -0300 Subject: [PATCH 06/48] Add `WithdrawalConfig` getters to app interface --- src/dapp/Application.sol | 48 +++++++++++++++++++++++++++- src/dapp/IApplication.sol | 30 ++++++++++++++++++ test/dapp/Application.t.sol | 25 +++++++++++++++ test/dapp/ApplicationFactory.t.sol | 50 ++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index b2e5361a..bfb9d076 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -10,6 +10,7 @@ import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; import {LibAddress} from "../library/LibAddress.sol"; import {LibOutputValidityProof} from "../library/LibOutputValidityProof.sol"; +import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; import {IApplication} from "./IApplication.sol"; import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; @@ -40,6 +41,26 @@ contract Application is /// @dev See the `getTemplateHash` function. bytes32 immutable TEMPLATE_HASH; + /// @notice The base-2 log of leaves per account. + /// @dev See the `getLog2LeavesPerAccount` function. + uint8 immutable LOG2_LEAVES_PER_ACCOUNT; + + /// @notice The base-2 log of max. num. of accounts. + /// @dev See the `getLog2MaxNumOfAccounts` function. + uint8 immutable LOG2_MAX_NUM_OF_ACCOUNTS; + + /// @notice The offset of the accounts drive. + /// @dev See the `getAccountsDriveStartIndex` function. + uint64 immutable ACCOUNTS_DRIVE_START_INDEX; + + /// @notice The guardian address. + /// @dev See the `getGuardian` function. + address immutable GUARDIAN; + + /// @notice The withdrawer contract. + /// @dev See the `getWithdrawer` function. + IWithdrawer immutable WITHDRAWER; + /// @notice Keeps track of which outputs have been executed. /// @dev See the `wasOutputExecuted` function. BitMaps.BitMap internal _executed; @@ -66,9 +87,14 @@ contract Application is address initialOwner, bytes32 templateHash, bytes memory dataAvailability, - WithdrawalConfig memory + WithdrawalConfig memory withdrawawlConfig ) Ownable(initialOwner) { TEMPLATE_HASH = templateHash; + LOG2_LEAVES_PER_ACCOUNT = withdrawawlConfig.log2LeavesPerAccount; + LOG2_MAX_NUM_OF_ACCOUNTS = withdrawawlConfig.log2MaxNumOfAccounts; + ACCOUNTS_DRIVE_START_INDEX = withdrawawlConfig.accountsDriveStartIndex; + GUARDIAN = withdrawawlConfig.guardian; + WITHDRAWER = withdrawawlConfig.withdrawer; _outputsMerkleRootValidator = outputsMerkleRootValidator; _dataAvailability = dataAvailability; } @@ -190,6 +216,26 @@ contract Application is return _numOfExecutedOutputs; } + function getLog2LeavesPerAccount() external view override returns (uint8) { + return LOG2_LEAVES_PER_ACCOUNT; + } + + function getLog2MaxNumOfAccounts() external view override returns (uint8) { + return LOG2_MAX_NUM_OF_ACCOUNTS; + } + + function getAccountsDriveStartIndex() external view override returns (uint64) { + return ACCOUNTS_DRIVE_START_INDEX; + } + + function getGuardian() external view override returns (address) { + return GUARDIAN; + } + + function getWithdrawer() external view override returns (IWithdrawer) { + return WITHDRAWER; + } + /// @inheritdoc Ownable function owner() public view override(IOwnable, Ownable) returns (address) { return super.owner(); diff --git a/src/dapp/IApplication.sol b/src/dapp/IApplication.sol index efda08a1..1c151356 100644 --- a/src/dapp/IApplication.sol +++ b/src/dapp/IApplication.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.8; import {IOwnable} from "../access/IOwnable.sol"; import {OutputValidityProof} from "../common/OutputValidityProof.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; +import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; /// @notice The base layer incarnation of an application running on the execution layer. /// @notice The state of the application advances through inputs sent to an `IInputBox` contract. @@ -123,4 +124,33 @@ interface IApplication is IOwnable { /// @notice Get number of outputs executed by the application. function getNumberOfExecutedOutputs() external view returns (uint256); + + /// @notice Get the log (base 2) of the number of leaves + /// in the machine state tree that are reserved for + /// each account in the accounts drive. + function getLog2LeavesPerAccount() external view returns (uint8); + + /// @notice Get the log (base 2) of the maximum number + /// of accounts that can be stored in the accounts drive. + /// @notice This is equivalent to the depth of the accounts + /// drive tree whose leaves are the account roots. + function getLog2MaxNumOfAccounts() external view returns (uint8); + + /// @notice Get the factor that, when multiplied by the + /// size of the accounts drive, yields the start memory address + /// of the accounts drive. + /// @dev If `a = getLog2LeavesPerAccount()` + /// `b = getLog2MaxNumOfAccounts()`, + /// and `c = getAccountsDriveStartIndex()`, + /// then the accounts drive starts at `c*2^{a+b+5}` + /// and has size `2^{a+b+5}`. + function getAccountsDriveStartIndex() external view returns (uint64); + + /// @notice Get the address of the guardian, + /// which has the power to foreclose the application. + function getGuardian() external view returns (address); + + /// @notice Get the withdrawer contract, + /// which gets delegate-called to withdraw funds from accounts. + function getWithdrawer() external view returns (IWithdrawer); } diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index d7311182..0feab72b 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -130,6 +130,31 @@ contract ApplicationTest is Test, OwnableTest { ); assertEq(appContract.owner(), owner); assertEq(appContract.getTemplateHash(), templateHash); + assertEq( + appContract.getLog2LeavesPerAccount(), + withdrawalConfig.log2LeavesPerAccount, + "getLog2LeavesPerAccount() != withdrawalConfig.log2LeavesPerAccount" + ); + assertEq( + appContract.getLog2MaxNumOfAccounts(), + withdrawalConfig.log2MaxNumOfAccounts, + "getLog2MaxNumOfAccounts() != withdrawalConfig.log2MaxNumOfAccounts" + ); + assertEq( + appContract.getAccountsDriveStartIndex(), + withdrawalConfig.accountsDriveStartIndex, + "getAccountsDriveStartIndex() != withdrawalConfig.accountsDriveStartIndex" + ); + assertEq( + appContract.getGuardian(), + withdrawalConfig.guardian, + "getGuardian() != withdrawalConfig.guardian" + ); + assertEq( + address(appContract.getWithdrawer()), + address(withdrawalConfig.withdrawer), + "getWithdrawer() != withdrawalConfig.withdrawer" + ); assertEq(appContract.getDataAvailability(), dataAvailability); assertEq(appContract.getDeploymentBlockNumber(), blockNumber); } diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index 327f7ca2..f0319a75 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -46,6 +46,31 @@ contract ApplicationFactoryTest is Test { ); assertEq(appContract.owner(), appOwner); assertEq(appContract.getTemplateHash(), templateHash); + assertEq( + appContract.getLog2LeavesPerAccount(), + withdrawalConfig.log2LeavesPerAccount, + "getLog2LeavesPerAccount() != withdrawalConfig.log2LeavesPerAccount" + ); + assertEq( + appContract.getLog2MaxNumOfAccounts(), + withdrawalConfig.log2MaxNumOfAccounts, + "getLog2MaxNumOfAccounts() != withdrawalConfig.log2MaxNumOfAccounts" + ); + assertEq( + appContract.getAccountsDriveStartIndex(), + withdrawalConfig.accountsDriveStartIndex, + "getAccountsDriveStartIndex() != withdrawalConfig.accountsDriveStartIndex" + ); + assertEq( + appContract.getGuardian(), + withdrawalConfig.guardian, + "getGuardian() != withdrawalConfig.guardian" + ); + assertEq( + address(appContract.getWithdrawer()), + address(withdrawalConfig.withdrawer), + "getWithdrawer() != withdrawalConfig.withdrawer" + ); assertEq(appContract.getDataAvailability(), dataAvailability); assertEq(appContract.getDeploymentBlockNumber(), blockNumber); } @@ -90,6 +115,31 @@ contract ApplicationFactoryTest is Test { ); assertEq(appContract.owner(), appOwner); assertEq(appContract.getTemplateHash(), templateHash); + assertEq( + appContract.getLog2LeavesPerAccount(), + withdrawalConfig.log2LeavesPerAccount, + "getLog2LeavesPerAccount() != withdrawalConfig.log2LeavesPerAccount" + ); + assertEq( + appContract.getLog2MaxNumOfAccounts(), + withdrawalConfig.log2MaxNumOfAccounts, + "getLog2MaxNumOfAccounts() != withdrawalConfig.log2MaxNumOfAccounts" + ); + assertEq( + appContract.getAccountsDriveStartIndex(), + withdrawalConfig.accountsDriveStartIndex, + "getAccountsDriveStartIndex() != withdrawalConfig.accountsDriveStartIndex" + ); + assertEq( + appContract.getGuardian(), + withdrawalConfig.guardian, + "getGuardian() != withdrawalConfig.guardian" + ); + assertEq( + address(appContract.getWithdrawer()), + address(withdrawalConfig.withdrawer), + "getWithdrawer() != withdrawalConfig.withdrawer" + ); assertEq(appContract.getDataAvailability(), dataAvailability); assertEq(appContract.getDeploymentBlockNumber(), blockNumber); From 943f21d39b7c45541c9588b2b8bade78179261e1 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 09:19:19 -0300 Subject: [PATCH 07/48] Improve code quality of app deployment tests --- test/dapp/Application.t.sol | 72 ---- test/dapp/ApplicationFactory.t.sol | 348 ++++++++++--------- test/dapp/SelfHostedApplicationFactory.t.sol | 190 +++++----- 3 files changed, 275 insertions(+), 335 deletions(-) diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index 0feab72b..5bb772d7 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -87,78 +87,6 @@ contract ApplicationTest is Test, OwnableTest { return _appContract; } - // ----------- - // constructor - // ----------- - - function testConstructorRevertsInvalidOwner() external { - WithdrawalConfig memory withdrawalConfig; - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) - ); - new Application( - _authority, address(0), TEMPLATE_HASH, new bytes(0), withdrawalConfig - ); - } - - function testConstructor( - uint256 blockNumber, - IOutputsMerkleRootValidator outputsMerkleRootValidator, - address owner, - bytes32 templateHash, - bytes calldata dataAvailability, - WithdrawalConfig calldata withdrawalConfig - ) external { - vm.assume(owner != address(0)); - - vm.roll(blockNumber); - - vm.expectEmit(true, true, false, false); - emit Ownable.OwnershipTransferred(address(0), owner); - - IApplication appContract = new Application( - outputsMerkleRootValidator, - owner, - templateHash, - dataAvailability, - withdrawalConfig - ); - - assertEq( - address(appContract.getOutputsMerkleRootValidator()), - address(outputsMerkleRootValidator) - ); - assertEq(appContract.owner(), owner); - assertEq(appContract.getTemplateHash(), templateHash); - assertEq( - appContract.getLog2LeavesPerAccount(), - withdrawalConfig.log2LeavesPerAccount, - "getLog2LeavesPerAccount() != withdrawalConfig.log2LeavesPerAccount" - ); - assertEq( - appContract.getLog2MaxNumOfAccounts(), - withdrawalConfig.log2MaxNumOfAccounts, - "getLog2MaxNumOfAccounts() != withdrawalConfig.log2MaxNumOfAccounts" - ); - assertEq( - appContract.getAccountsDriveStartIndex(), - withdrawalConfig.accountsDriveStartIndex, - "getAccountsDriveStartIndex() != withdrawalConfig.accountsDriveStartIndex" - ); - assertEq( - appContract.getGuardian(), - withdrawalConfig.guardian, - "getGuardian() != withdrawalConfig.guardian" - ); - assertEq( - address(appContract.getWithdrawer()), - address(withdrawalConfig.withdrawer), - "getWithdrawer() != withdrawalConfig.withdrawer" - ); - assertEq(appContract.getDataAvailability(), dataAvailability); - assertEq(appContract.getDeploymentBlockNumber(), blockNumber); - } - // --------------------------------------- // outputs Merkle root validator migration // --------------------------------------- diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index f0319a75..9189541f 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -10,13 +10,15 @@ import {ApplicationFactory} from "src/dapp/ApplicationFactory.sol"; import {IApplication} from "src/dapp/IApplication.sol"; import {IApplicationFactory} from "src/dapp/IApplicationFactory.sol"; +import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; + import {Test} from "forge-std-1.9.6/src/Test.sol"; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; contract ApplicationFactoryTest is Test { ApplicationFactory _factory; - function setUp() public { + function setUp() external { _factory = new ApplicationFactory(); } @@ -27,52 +29,36 @@ contract ApplicationFactoryTest is Test { bytes32 templateHash, bytes calldata dataAvailability, WithdrawalConfig calldata withdrawalConfig - ) public { - vm.assume(appOwner != address(0)); - + ) external { vm.roll(blockNumber); - IApplication appContract = _factory.newApplication( + vm.recordLogs(); + + try _factory.newApplication( outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig - ); - - assertEq( - address(appContract.getOutputsMerkleRootValidator()), - address(outputsMerkleRootValidator) - ); - assertEq(appContract.owner(), appOwner); - assertEq(appContract.getTemplateHash(), templateHash); - assertEq( - appContract.getLog2LeavesPerAccount(), - withdrawalConfig.log2LeavesPerAccount, - "getLog2LeavesPerAccount() != withdrawalConfig.log2LeavesPerAccount" - ); - assertEq( - appContract.getLog2MaxNumOfAccounts(), - withdrawalConfig.log2MaxNumOfAccounts, - "getLog2MaxNumOfAccounts() != withdrawalConfig.log2MaxNumOfAccounts" - ); - assertEq( - appContract.getAccountsDriveStartIndex(), - withdrawalConfig.accountsDriveStartIndex, - "getAccountsDriveStartIndex() != withdrawalConfig.accountsDriveStartIndex" - ); - assertEq( - appContract.getGuardian(), - withdrawalConfig.guardian, - "getGuardian() != withdrawalConfig.guardian" - ); - assertEq( - address(appContract.getWithdrawer()), - address(withdrawalConfig.withdrawer), - "getWithdrawer() != withdrawalConfig.withdrawer" - ); - assertEq(appContract.getDataAvailability(), dataAvailability); - assertEq(appContract.getDeploymentBlockNumber(), blockNumber); + ) returns ( + IApplication appContract + ) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + + _testNewApplicationSuccess( + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + appContract, + blockNumber, + logs + ); + } catch (bytes memory error) { + _testNewApplicationFailure(appOwner, error); + return; + } } function testNewApplicationDeterministic( @@ -83,9 +69,7 @@ contract ApplicationFactoryTest is Test { bytes calldata dataAvailability, WithdrawalConfig calldata withdrawalConfig, bytes32 salt - ) public { - vm.assume(appOwner != address(0)); - + ) external { vm.roll(blockNumber); address precalculatedAddress = _factory.calculateApplicationAddress( @@ -97,159 +81,96 @@ contract ApplicationFactoryTest is Test { salt ); - IApplication appContract = _factory.newApplication( + vm.recordLogs(); + + try _factory.newApplication( outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt - ); - - // Precalculated address must match actual address - assertEq(precalculatedAddress, address(appContract)); + ) returns ( + IApplication appContract + ) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + + assertEq( + precalculatedAddress, + address(appContract), + "calculateApplicationAddress(...) != newApplication(...)" + ); + + _testNewApplicationSuccess( + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + appContract, + blockNumber, + logs + ); + } catch (bytes memory error) { + _testNewApplicationFailure(appOwner, error); + return; + } assertEq( - address(appContract.getOutputsMerkleRootValidator()), - address(outputsMerkleRootValidator) - ); - assertEq(appContract.owner(), appOwner); - assertEq(appContract.getTemplateHash(), templateHash); - assertEq( - appContract.getLog2LeavesPerAccount(), - withdrawalConfig.log2LeavesPerAccount, - "getLog2LeavesPerAccount() != withdrawalConfig.log2LeavesPerAccount" - ); - assertEq( - appContract.getLog2MaxNumOfAccounts(), - withdrawalConfig.log2MaxNumOfAccounts, - "getLog2MaxNumOfAccounts() != withdrawalConfig.log2MaxNumOfAccounts" - ); - assertEq( - appContract.getAccountsDriveStartIndex(), - withdrawalConfig.accountsDriveStartIndex, - "getAccountsDriveStartIndex() != withdrawalConfig.accountsDriveStartIndex" + _factory.calculateApplicationAddress( + outputsMerkleRootValidator, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt + ), + precalculatedAddress, + "calculateApplicationAddress(...) is not a pure function" ); - assertEq( - appContract.getGuardian(), - withdrawalConfig.guardian, - "getGuardian() != withdrawalConfig.guardian" - ); - assertEq( - address(appContract.getWithdrawer()), - address(withdrawalConfig.withdrawer), - "getWithdrawer() != withdrawalConfig.withdrawer" - ); - assertEq(appContract.getDataAvailability(), dataAvailability); - assertEq(appContract.getDeploymentBlockNumber(), blockNumber); - - precalculatedAddress = _factory.calculateApplicationAddress( - outputsMerkleRootValidator, - appOwner, - templateHash, - dataAvailability, - withdrawalConfig, - salt - ); - - // Precalculated address must STILL match actual address - assertEq(precalculatedAddress, address(appContract)); // Cannot deploy an application with the same salt twice - vm.expectRevert(bytes("")); - _factory.newApplication( + try _factory.newApplication( outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt - ); - } - - function testApplicationCreatedEvent( - IOutputsMerkleRootValidator outputsMerkleRootValidator, - address appOwner, - bytes32 templateHash, - bytes calldata dataAvailability, - WithdrawalConfig calldata withdrawalConfig - ) public { - vm.assume(appOwner != address(0)); - - vm.recordLogs(); - - IApplication appContract = _factory.newApplication( - outputsMerkleRootValidator, - appOwner, - templateHash, - dataAvailability, - withdrawalConfig - ); - - _testApplicationCreatedEventAux( - outputsMerkleRootValidator, - appOwner, - templateHash, - dataAvailability, - withdrawalConfig, - appContract - ); - } - - function testApplicationCreatedEventDeterministic( - IOutputsMerkleRootValidator outputsMerkleRootValidator, - address appOwner, - bytes32 templateHash, - bytes calldata dataAvailability, - WithdrawalConfig calldata withdrawalConfig, - bytes32 salt - ) public { - vm.assume(appOwner != address(0)); - - vm.recordLogs(); - - IApplication appContract = _factory.newApplication( - outputsMerkleRootValidator, - appOwner, - templateHash, - dataAvailability, - withdrawalConfig, - salt - ); - - _testApplicationCreatedEventAux( - outputsMerkleRootValidator, - appOwner, - templateHash, - dataAvailability, - withdrawalConfig, - appContract - ); + ) { + revert("second deterministic deployment did not revert"); + } catch (bytes memory error) { + assertEq( + error, + new bytes(0), + "second deterministic deployment did not revert with empty error data" + ); + } } - function _testApplicationCreatedEventAux( + function _testNewApplicationSuccess( IOutputsMerkleRootValidator outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes calldata dataAvailability, WithdrawalConfig calldata withdrawalConfig, - IApplication appContract - ) internal { - Vm.Log[] memory entries = vm.getRecordedLogs(); - + IApplication appContract, + uint256 blockNumber, + Vm.Log[] memory logs + ) internal view { uint256 numOfApplicationsCreated; - for (uint256 i; i < entries.length; ++i) { - Vm.Log memory entry = entries[i]; + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory log = logs[i]; if ( - entry.emitter == address(_factory) - && entry.topics[0] == IApplicationFactory.ApplicationCreated.selector + log.emitter == address(_factory) + && log.topics[0] == IApplicationFactory.ApplicationCreated.selector ) { ++numOfApplicationsCreated; assertEq( - entry.topics[1], + log.topics[1], bytes32(uint256(uint160(address(outputsMerkleRootValidator)))) ); @@ -260,17 +181,102 @@ contract ApplicationFactoryTest is Test { WithdrawalConfig memory withdrawalConfig_, IApplication app_ ) = abi.decode( - entry.data, (address, bytes32, bytes, WithdrawalConfig, IApplication) + log.data, (address, bytes32, bytes, WithdrawalConfig, IApplication) ); - assertEq(appOwner, appOwner_); - assertEq(templateHash, templateHash_); - assertEq(dataAvailability, dataAvailability_); - assertEq(abi.encode(withdrawalConfig), abi.encode(withdrawalConfig_)); - assertEq(address(appContract), address(app_)); + assertEq(appOwner, appOwner_, "ApplicationCreated.owner != owner"); + assertEq( + templateHash, + templateHash_, + "ApplicationCreated.templateHash != templateHash" + ); + assertEq( + dataAvailability, + dataAvailability_, + "ApplicationCreated.dataAvailability != dataAvailability" + ); + assertEq( + abi.encode(withdrawalConfig), + abi.encode(withdrawalConfig_), + "ApplicationCreated.withdrawalConfig != withdrawalConfig" + ); + assertEq( + address(appContract), + address(app_), + "ApplicationCreated.appContract != appContract" + ); } } - assertEq(numOfApplicationsCreated, 1); + assertEq(numOfApplicationsCreated, 1, "number of ApplicationCreated events"); + assertEq( + address(appContract.getOutputsMerkleRootValidator()), + address(outputsMerkleRootValidator), + "getOutputsMerkleRootValidator() != outputsMerkleRootValidator" + ); + assertEq(appContract.owner(), appOwner, "owner() != owner"); + assertEq( + appContract.getTemplateHash(), + templateHash, + "getTemplateHash() != templateHash" + ); + assertEq( + appContract.getLog2LeavesPerAccount(), + withdrawalConfig.log2LeavesPerAccount, + "getLog2LeavesPerAccount() != withdrawalConfig.log2LeavesPerAccount" + ); + assertEq( + appContract.getLog2MaxNumOfAccounts(), + withdrawalConfig.log2MaxNumOfAccounts, + "getLog2MaxNumOfAccounts() != withdrawalConfig.log2MaxNumOfAccounts" + ); + assertEq( + appContract.getAccountsDriveStartIndex(), + withdrawalConfig.accountsDriveStartIndex, + "getAccountsDriveStartIndex() != withdrawalConfig.accountsDriveStartIndex" + ); + assertEq( + appContract.getGuardian(), + withdrawalConfig.guardian, + "getGuardian() != withdrawalConfig.guardian" + ); + assertEq( + address(appContract.getWithdrawer()), + address(withdrawalConfig.withdrawer), + "getWithdrawer() != withdrawalConfig.withdrawer" + ); + assertEq( + appContract.getDataAvailability(), + dataAvailability, + "getDataAvailability() != dataAvailability" + ); + assertEq( + appContract.getDeploymentBlockNumber(), + blockNumber, + "getDeploymentBlockNumber() != blockNumber" + ); + } + + function _testNewApplicationFailure(address appOwner, bytes memory error) + internal + pure + { + assertGe(error.length, 4, "Error data too short (no 4-byte selector)"); + + // forge-lint: disable-next-line(unsafe-typecast) + bytes4 errorSelector = bytes4(error); + bytes memory errorArgs = new bytes(error.length - 4); + + for (uint256 i; i < errorArgs.length; ++i) { + errorArgs[i] = error[i + 4]; + } + + if (errorSelector == Ownable.OwnableInvalidOwner.selector) { + address owner = abi.decode(errorArgs, (address)); + assertEq(owner, appOwner, "OwnableInvalidOwner.owner != owner"); + assertEq(owner, address(0), "OwnableInvalidOwner.owner != address(0)"); + } else { + revert("Unexpected error"); + } } } diff --git a/test/dapp/SelfHostedApplicationFactory.t.sol b/test/dapp/SelfHostedApplicationFactory.t.sol index 569fc9c8..e1f9963b 100644 --- a/test/dapp/SelfHostedApplicationFactory.t.sol +++ b/test/dapp/SelfHostedApplicationFactory.t.sol @@ -37,80 +37,8 @@ contract SelfHostedApplicationFactoryTest is Test { assertEq(address(factory.getAuthorityFactory()), address(authorityFactory)); } - function testRevertsAuthorityOwnerAddressZero( - uint256 epochLength, - address appOwner, - bytes32 templateHash, - bytes calldata dataAvailability, - WithdrawalConfig calldata withdrawalConfig, - bytes32 salt - ) external { - vm.assume(appOwner != address(0)); - vm.assume(epochLength > 0); - - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) - ); - factory.deployContracts( - address(0), - epochLength, - appOwner, - templateHash, - dataAvailability, - withdrawalConfig, - salt - ); - } - - function testRevertsEpochLengthZero( - address authorityOwner, - address appOwner, - bytes32 templateHash, - bytes calldata dataAvailability, - WithdrawalConfig calldata withdrawalConfig, - bytes32 salt - ) external { - vm.assume(appOwner != address(0)); - vm.assume(authorityOwner != address(0)); - - vm.expectRevert("epoch length must not be zero"); - factory.deployContracts( - authorityOwner, - 0, - appOwner, - templateHash, - dataAvailability, - withdrawalConfig, - salt - ); - } - - function testRevertsApplicationOwnerAddressZero( - address authorityOwner, - uint256 epochLength, - bytes32 templateHash, - bytes calldata dataAvailability, - WithdrawalConfig calldata withdrawalConfig, - bytes32 salt - ) external { - vm.assume(authorityOwner != address(0)); - vm.assume(epochLength > 0); - - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) - ); - factory.deployContracts( - authorityOwner, - epochLength, - address(0), - templateHash, - dataAvailability, - withdrawalConfig, - salt - ); - } - function testDeployContracts( + uint256 blockNumber, address authorityOwner, uint256 epochLength, address appOwner, @@ -119,9 +47,7 @@ contract SelfHostedApplicationFactoryTest is Test { WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external { - vm.assume(appOwner != address(0)); - vm.assume(authorityOwner != address(0)); - vm.assume(epochLength > 0); + vm.roll(blockNumber); address appAddr; address authorityAddr; @@ -136,10 +62,7 @@ contract SelfHostedApplicationFactoryTest is Test { salt ); - IApplication application; - IAuthority authority; - - (application, authority) = factory.deployContracts( + try factory.deployContracts( authorityOwner, epochLength, appOwner, @@ -147,17 +70,100 @@ contract SelfHostedApplicationFactoryTest is Test { dataAvailability, withdrawalConfig, salt - ); - - assertEq(appAddr, address(application)); - assertEq(authorityAddr, address(authority)); - - assertEq(authority.owner(), authorityOwner); - assertEq(authority.getEpochLength(), epochLength); - - assertEq(address(application.getOutputsMerkleRootValidator()), authorityAddr); - assertEq(application.owner(), appOwner); - assertEq(application.getTemplateHash(), templateHash); - assertEq(application.getDataAvailability(), dataAvailability); + ) returns ( + IApplication application, IAuthority authority + ) { + assertEq( + appAddr, + address(application), + "calculateAddresses(...)[0] != deployContracts(...)[0]" + ); + assertEq( + authorityAddr, + address(authority), + "calculateAddresses(...)[1] != deployContracts(...)[1]" + ); + + assertEq( + authority.owner(), authorityOwner, "authority.owner() != authorityOwner" + ); + assertEq( + authority.getEpochLength(), + epochLength, + "authority.getEpochLength() != epochLength" + ); + + assertEq( + address(application.getOutputsMerkleRootValidator()), + authorityAddr, + "app.getOutputsMerkleRootValidator() != authority" + ); + assertEq(application.owner(), appOwner, "app.owner() != appOwner"); + assertEq( + application.getTemplateHash(), + templateHash, + "app.getTemplateHash() != templateHash" + ); + assertEq( + application.getDataAvailability(), + dataAvailability, + "app.getDataAvailability() != dataAvailability" + ); + assertEq( + application.getDeploymentBlockNumber(), + blockNumber, + "getDeploymentBlockNumber() != blockNumber" + ); + + (appAddr, authorityAddr) = factory.calculateAddresses( + authorityOwner, + epochLength, + appOwner, + templateHash, + dataAvailability, + withdrawalConfig, + salt + ); + + assertEq( + appAddr, + address(application), + "calculateAddresses(...) is not a pure function" + ); + assertEq( + authorityAddr, + address(authority), + "calculateAddresses(...) is not a pure function" + ); + } catch (bytes memory error) { + assertGe(error.length, 4, "Error data too short (no 4-byte selector)"); + + // forge-lint: disable-next-line(unsafe-typecast) + bytes4 errorSelector = bytes4(error); + bytes memory errorArgs = new bytes(error.length - 4); + + for (uint256 i; i < errorArgs.length; ++i) { + errorArgs[i] = error[i + 4]; + } + + if (errorSelector == Ownable.OwnableInvalidOwner.selector) { + address owner = abi.decode(errorArgs, (address)); + assertEq(owner, address(0), "OwnableInvalidOwner.owner != address(0)"); + assertTrue( + appOwner == address(0) || authorityOwner == address(0), + "Expected either app or authority owner to be zero" + ); + } else if (errorSelector == bytes4(keccak256("Error(string)"))) { + string memory message = abi.decode(errorArgs, (string)); + bytes32 messageHash = keccak256(bytes(message)); + if (messageHash == keccak256("epoch length must not be zero")) { + assertEq(epochLength, 0, "Expected epoch length to be zero"); + } else { + revert("Unexpected error message"); + } + } else { + revert("Unexpected error"); + } + } } } From 44a1184d414973258cbacc98bd02a7634fb42707 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 10:12:57 -0300 Subject: [PATCH 08/48] Downcast canonical machine constants --- src/common/CanonicalMachine.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/CanonicalMachine.sol b/src/common/CanonicalMachine.sol index 12a2cfcd..039e5aa1 100644 --- a/src/common/CanonicalMachine.sol +++ b/src/common/CanonicalMachine.sol @@ -9,8 +9,8 @@ pragma solidity ^0.8.8; /// of the RISC-V machine that runs Linux, also known as the "Cartesi Machine". library CanonicalMachine { /// @notice Maximum input size (64 kilobytes). - uint256 constant INPUT_MAX_SIZE = 1 << 16; + uint64 constant INPUT_MAX_SIZE = 1 << 16; /// @notice Log2 of maximum number of outputs. - uint256 constant LOG2_MAX_OUTPUTS = 63; + uint8 constant LOG2_MAX_OUTPUTS = 63; } From d8cdbfc0c921e9c7fecdb1fa65dc1774a1f14a3b Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 12:13:06 -0300 Subject: [PATCH 09/48] Add `LibWithdrawalConfig` library and tests --- src/common/CanonicalMachine.sol | 6 ++ src/library/LibWithdrawalConfig.sol | 40 +++++++++ test/library/LibWithdrawalConfig.t.sol | 109 +++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/library/LibWithdrawalConfig.sol create mode 100644 test/library/LibWithdrawalConfig.t.sol diff --git a/src/common/CanonicalMachine.sol b/src/common/CanonicalMachine.sol index 039e5aa1..46970f17 100644 --- a/src/common/CanonicalMachine.sol +++ b/src/common/CanonicalMachine.sol @@ -11,6 +11,12 @@ library CanonicalMachine { /// @notice Maximum input size (64 kilobytes). uint64 constant INPUT_MAX_SIZE = 1 << 16; + /// @notice Log2 of memory size. + uint8 constant LOG2_MEMORY_SIZE = 64; + /// @notice Log2 of maximum number of outputs. uint8 constant LOG2_MAX_OUTPUTS = 63; + + /// @notice Log2 of data block size. + uint8 constant LOG2_DATA_BLOCK_SIZE = 5; } diff --git a/src/library/LibWithdrawalConfig.sol b/src/library/LibWithdrawalConfig.sol new file mode 100644 index 00000000..89512a38 --- /dev/null +++ b/src/library/LibWithdrawalConfig.sol @@ -0,0 +1,40 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {CanonicalMachine} from "../common/CanonicalMachine.sol"; +import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; + +library LibWithdrawalConfig { + function isValid(WithdrawalConfig memory withdrawalConfig) + internal + pure + returns (bool) + { + // The addition below cannot overflow because `3 * type(uint8).max <= type(uint256).max`. + uint256 log2AccountsDriveSize = uint256(CanonicalMachine.LOG2_DATA_BLOCK_SIZE) + + uint256(withdrawalConfig.log2MaxNumOfAccounts) + + uint256(withdrawalConfig.log2LeavesPerAccount); + + // The addition below cannot overflow because `type(uint8).max + 1 <= type(uint256).max`. + uint256 accountsDriveEndIndex = + uint256(withdrawalConfig.accountsDriveStartIndex) + 1; + + // The left-shift below can overflow, so we need to check for overflow afterwards. + uint256 accountsDriveEnd = accountsDriveEndIndex << log2AccountsDriveSize; + if ((accountsDriveEnd >> log2AccountsDriveSize) != accountsDriveEndIndex) { + return false; + } + + // forge-lint: disable-next-line(incorrect-shift) + uint256 memorySize = 1 << CanonicalMachine.LOG2_MEMORY_SIZE; + + // Check if the accounts drive would end past the machine memory boundaries. + if (accountsDriveEnd > memorySize) { + return false; + } + + return true; + } +} diff --git a/test/library/LibWithdrawalConfig.t.sol b/test/library/LibWithdrawalConfig.t.sol new file mode 100644 index 00000000..074bb71b --- /dev/null +++ b/test/library/LibWithdrawalConfig.t.sol @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; +import {WithdrawalConfig} from "src/common/WithdrawalConfig.sol"; +import {LibWithdrawalConfig} from "src/library/LibWithdrawalConfig.sol"; + +contract LibWithdrawalConfigTest is Test { + /// @notice This test ensures that `isValid` never reverts, + /// regardless of the input withdrawal configuration. + function testIsValidCompleteness(WithdrawalConfig memory withdrawalConfig) + external + pure + returns (bool) + { + return LibWithdrawalConfig.isValid(withdrawalConfig); + } + + /// @notice This test ensures that `isValid` returns true + /// for some withdrawal configurations. + function testIsValidTrue(WithdrawalConfig memory withdrawalConfig) external pure { + withdrawalConfig.log2LeavesPerAccount = uint8( + bound( + withdrawalConfig.log2LeavesPerAccount, + 0, + CanonicalMachine.LOG2_MEMORY_SIZE - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + ) + ); + + withdrawalConfig.log2MaxNumOfAccounts = uint8( + bound( + withdrawalConfig.log2MaxNumOfAccounts, + 0, + CanonicalMachine.LOG2_MEMORY_SIZE - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + - withdrawalConfig.log2LeavesPerAccount + ) + ); + + uint8 log2AccountsDriveSize = CanonicalMachine.LOG2_DATA_BLOCK_SIZE + + withdrawalConfig.log2LeavesPerAccount + + withdrawalConfig.log2MaxNumOfAccounts; + + // forge-lint: disable-start(incorrect-shift) + withdrawalConfig.accountsDriveStartIndex = uint64( + bound( + withdrawalConfig.accountsDriveStartIndex, + 0, + (1 << (CanonicalMachine.LOG2_MEMORY_SIZE - log2AccountsDriveSize)) - 1 + ) + ); + // forge-lint: disable-end(incorrect-shift) + + assertTrue(LibWithdrawalConfig.isValid(withdrawalConfig)); + } + + /// @notice This test ensures that `isValid` returns false + /// for withdrawal configurations in which the accounts drive is too big. + function testIsValidFalseAccountsDriveTooBig(WithdrawalConfig memory withdrawalConfig) + external + pure + { + if ( + withdrawalConfig.log2MaxNumOfAccounts + <= CanonicalMachine.LOG2_MEMORY_SIZE + - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + ) { + withdrawalConfig.log2LeavesPerAccount = uint8( + bound( + withdrawalConfig.log2LeavesPerAccount, + CanonicalMachine.LOG2_MEMORY_SIZE + - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + - withdrawalConfig.log2MaxNumOfAccounts + 1, + type(uint8).max + ) + ); + } + + assertFalse(LibWithdrawalConfig.isValid(withdrawalConfig)); + } + + /// @notice This test ensures that `isValid` returns false + /// for withdrawal configurations in which the accounts drive is outside memory bounds. + function testIsValidFalseAccountsDriveOutsideBounds(WithdrawalConfig memory withdrawalConfig) + external + pure + { + uint256 log2AccountsDriveSize = uint256(CanonicalMachine.LOG2_DATA_BLOCK_SIZE) + + uint256(withdrawalConfig.log2LeavesPerAccount) + + uint256(withdrawalConfig.log2MaxNumOfAccounts); + + // forge-lint: disable-start(incorrect-shift) + if (log2AccountsDriveSize <= CanonicalMachine.LOG2_MEMORY_SIZE) { + withdrawalConfig.accountsDriveStartIndex = uint64( + bound( + withdrawalConfig.accountsDriveStartIndex, + 1 << (CanonicalMachine.LOG2_MEMORY_SIZE - log2AccountsDriveSize), + type(uint64).max + ) + ); + } + // forge-lint: disable-end(incorrect-shift) + + assertFalse(LibWithdrawalConfig.isValid(withdrawalConfig)); + } +} From c3dbbd142990f6b02f858f39a4818b036f8b0f36 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 13:20:56 -0300 Subject: [PATCH 10/48] Validate withdrawal config on app deployment --- src/dapp/Application.sol | 3 ++ test/dapp/ApplicationFactory.t.sol | 32 +++++++++++++++----- test/dapp/SelfHostedApplicationFactory.t.sol | 12 ++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index bfb9d076..80d220eb 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -10,6 +10,7 @@ import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; import {LibAddress} from "../library/LibAddress.sol"; import {LibOutputValidityProof} from "../library/LibOutputValidityProof.sol"; +import {LibWithdrawalConfig} from "../library/LibWithdrawalConfig.sol"; import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; import {IApplication} from "./IApplication.sol"; @@ -33,6 +34,7 @@ contract Application is using BitMaps for BitMaps.BitMap; using LibAddress for address; using LibOutputValidityProof for OutputValidityProof; + using LibWithdrawalConfig for WithdrawalConfig; /// @notice Deployment block number uint256 immutable DEPLOYMENT_BLOCK_NUMBER = block.number; @@ -89,6 +91,7 @@ contract Application is bytes memory dataAvailability, WithdrawalConfig memory withdrawawlConfig ) Ownable(initialOwner) { + require(withdrawawlConfig.isValid(), "Invalid withdrawal config"); TEMPLATE_HASH = templateHash; LOG2_LEAVES_PER_ACCOUNT = withdrawawlConfig.log2LeavesPerAccount; LOG2_MAX_NUM_OF_ACCOUNTS = withdrawawlConfig.log2MaxNumOfAccounts; diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index 9189541f..f3a49dfc 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -9,6 +9,7 @@ import {IOutputsMerkleRootValidator} from "src/consensus/IOutputsMerkleRootValid import {ApplicationFactory} from "src/dapp/ApplicationFactory.sol"; import {IApplication} from "src/dapp/IApplication.sol"; import {IApplicationFactory} from "src/dapp/IApplicationFactory.sol"; +import {LibWithdrawalConfig} from "src/library/LibWithdrawalConfig.sol"; import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; @@ -16,6 +17,8 @@ import {Test} from "forge-std-1.9.6/src/Test.sol"; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; contract ApplicationFactoryTest is Test { + using LibWithdrawalConfig for WithdrawalConfig; + ApplicationFactory _factory; function setUp() external { @@ -56,7 +59,7 @@ contract ApplicationFactoryTest is Test { logs ); } catch (bytes memory error) { - _testNewApplicationFailure(appOwner, error); + _testNewApplicationFailure(appOwner, withdrawalConfig, error); return; } } @@ -112,7 +115,7 @@ contract ApplicationFactoryTest is Test { logs ); } catch (bytes memory error) { - _testNewApplicationFailure(appOwner, error); + _testNewApplicationFailure(appOwner, withdrawalConfig, error); return; } @@ -153,7 +156,7 @@ contract ApplicationFactoryTest is Test { address appOwner, bytes32 templateHash, bytes calldata dataAvailability, - WithdrawalConfig calldata withdrawalConfig, + WithdrawalConfig memory withdrawalConfig, IApplication appContract, uint256 blockNumber, Vm.Log[] memory logs @@ -255,12 +258,16 @@ contract ApplicationFactoryTest is Test { blockNumber, "getDeploymentBlockNumber() != blockNumber" ); + assertEq( + withdrawalConfig.isValid(), true, "Expected withdrawal config to be valid" + ); } - function _testNewApplicationFailure(address appOwner, bytes memory error) - internal - pure - { + function _testNewApplicationFailure( + address appOwner, + WithdrawalConfig memory withdrawalConfig, + bytes memory error + ) internal pure { assertGe(error.length, 4, "Error data too short (no 4-byte selector)"); // forge-lint: disable-next-line(unsafe-typecast) @@ -275,6 +282,17 @@ contract ApplicationFactoryTest is Test { address owner = abi.decode(errorArgs, (address)); assertEq(owner, appOwner, "OwnableInvalidOwner.owner != owner"); assertEq(owner, address(0), "OwnableInvalidOwner.owner != address(0)"); + } else if (errorSelector == bytes4(keccak256("Error(string)"))) { + string memory message = abi.decode(errorArgs, (string)); + if (keccak256(bytes(message)) == keccak256("Invalid withdrawal config")) { + assertEq( + withdrawalConfig.isValid(), + false, + "expected withdrawal config to be invalid" + ); + } else { + revert("Unexpected error message"); + } } else { revert("Unexpected error"); } diff --git a/test/dapp/SelfHostedApplicationFactory.t.sol b/test/dapp/SelfHostedApplicationFactory.t.sol index e1f9963b..f98599f7 100644 --- a/test/dapp/SelfHostedApplicationFactory.t.sol +++ b/test/dapp/SelfHostedApplicationFactory.t.sol @@ -15,10 +15,13 @@ import {IApplication} from "src/dapp/IApplication.sol"; import {IApplicationFactory} from "src/dapp/IApplicationFactory.sol"; import {ISelfHostedApplicationFactory} from "src/dapp/ISelfHostedApplicationFactory.sol"; import {SelfHostedApplicationFactory} from "src/dapp/SelfHostedApplicationFactory.sol"; +import {LibWithdrawalConfig} from "src/library/LibWithdrawalConfig.sol"; import {Test} from "forge-std-1.9.6/src/Test.sol"; contract SelfHostedApplicationFactoryTest is Test { + using LibWithdrawalConfig for WithdrawalConfig; + IAuthorityFactory authorityFactory; IApplicationFactory applicationFactory; ISelfHostedApplicationFactory factory; @@ -114,6 +117,9 @@ contract SelfHostedApplicationFactoryTest is Test { blockNumber, "getDeploymentBlockNumber() != blockNumber" ); + assertEq( + withdrawalConfig.isValid(), true, "Expected withdrawal config to be valid" + ); (appAddr, authorityAddr) = factory.calculateAddresses( authorityOwner, @@ -158,6 +164,12 @@ contract SelfHostedApplicationFactoryTest is Test { bytes32 messageHash = keccak256(bytes(message)); if (messageHash == keccak256("epoch length must not be zero")) { assertEq(epochLength, 0, "Expected epoch length to be zero"); + } else if (messageHash == keccak256("Invalid withdrawal config")) { + assertEq( + withdrawalConfig.isValid(), + false, + "expected withdrawal config to be invalid" + ); } else { revert("Unexpected error message"); } From 2d05bf0a97fa2438dea825c053d5e45ba6c2624e Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 15:59:28 -0300 Subject: [PATCH 11/48] Add `foreclose` and `isForeclosed` functions to app interface --- src/dapp/Application.sol | 25 ++++++++++++++++- src/dapp/IApplication.sol | 16 +++++++++++ test/dapp/Application.t.sol | 28 +++++++++++++++++--- test/dapp/ApplicationFactory.t.sol | 1 + test/dapp/SelfHostedApplicationFactory.t.sol | 1 + 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index 80d220eb..66a19214 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -75,6 +75,10 @@ contract Application is /// @dev See the `getDataAvailability` function. bytes internal _dataAvailability; + /// @notice Whether the application has been foreclosed by the guardian. + /// @dev See the `isForeclosed` function. + bool internal _isForeclosed; + /// @notice The number of outputs executed by the application. /// @dev See the `numberOfOutputsExecuted` function. uint256 _numOfExecutedOutputs; @@ -153,6 +157,11 @@ contract Application is emit OutputsMerkleRootValidatorChanged(newOutputsMerkleRootValidator); } + function foreclose() external override onlyGuardian { + _isForeclosed = true; + emit Foreclosure(); + } + /// @inheritdoc IApplication function wasOutputExecuted(uint256 outputIndex) external @@ -231,7 +240,7 @@ contract Application is return ACCOUNTS_DRIVE_START_INDEX; } - function getGuardian() external view override returns (address) { + function getGuardian() public view override returns (address) { return GUARDIAN; } @@ -239,6 +248,10 @@ contract Application is return WITHDRAWER; } + function isForeclosed() external view override returns (bool) { + return _isForeclosed; + } + /// @inheritdoc Ownable function owner() public view override(IOwnable, Ownable) returns (address) { return super.owner(); @@ -254,6 +267,11 @@ contract Application is super.transferOwnership(newOwner); } + modifier onlyGuardian() { + _ensureMsgSenderIsGuardian(); + _; + } + /// @notice Check if an outputs Merkle root is valid, /// according to the current outputs Merkle root validator. /// @param outputsMerkleRoot The output Merkle root @@ -296,4 +314,9 @@ contract Application is destination.safeDelegateCall(payload); } + + /// @notice Ensures the message sender is the guardian. + function _ensureMsgSenderIsGuardian() internal view { + require(msg.sender == getGuardian(), NotGuardian()); + } } diff --git a/src/dapp/IApplication.sol b/src/dapp/IApplication.sol index 1c151356..3a4bbf4b 100644 --- a/src/dapp/IApplication.sol +++ b/src/dapp/IApplication.sol @@ -37,6 +37,9 @@ interface IApplication is IOwnable { /// @param output The output event OutputExecuted(uint64 outputIndex, bytes output); + /// @notice MUST trigger when the application is foreclosed. + event Foreclosure(); + // Errors /// @notice Could not execute an output, because the application contract doesn't know how to. @@ -59,6 +62,10 @@ interface IApplication is IOwnable { /// @notice Raised when the computed outputs Merkle root is invalid, according to the current outputs Merkle root validator. error InvalidOutputsMerkleRoot(bytes32 outputsMerkleRoot); + /// @notice Raised when a function that can only be called by + /// the application guardian is called by some other account. + error NotGuardian(); + // Permissioned functions /// @notice Migrate the application to a new outputs Merkle root validator. @@ -67,6 +74,11 @@ interface IApplication is IOwnable { function migrateToOutputsMerkleRootValidator(IOutputsMerkleRootValidator newOutputsMerkleRootValidator) external; + /// @notice Forecloses the application, allowing users to withdraw their funds + /// by providing Merkle proofs of their in-app accounts. + /// @dev Can only be called by the application guardian. + function foreclose() external; + // Permissionless functions /// @notice Execute an output. @@ -153,4 +165,8 @@ interface IApplication is IOwnable { /// @notice Get the withdrawer contract, /// which gets delegate-called to withdraw funds from accounts. function getWithdrawer() external view returns (IWithdrawer); + + /// @notice Check whether the application has been foreclosed. + /// An application that has been foreclosed will remain so. + function isForeclosed() external view returns (bool); } diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index 5bb772d7..4354fa79 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -65,6 +65,7 @@ contract ApplicationTest is Test, OwnableTest { uint256[] _initialSupplies; uint256[] _transferAmounts; mapping(string => LibEmulator.OutputIndex) _outputIndexByName; + WithdrawalConfig _withdrawalConfig; uint256 constant EPOCH_LENGTH = 1; bytes32 constant TEMPLATE_HASH = keccak256("templateHash"); @@ -116,6 +117,27 @@ contract ApplicationTest is Test, OwnableTest { ); } + // ----------- + // foreclosure + // ----------- + + function testForecloseRevertsNotGuardian(address caller) external { + vm.assume(caller != _appContract.getGuardian()); + assertFalse(_appContract.isForeclosed()); + vm.expectRevert(IApplication.NotGuardian.selector); + vm.prank(caller); + _appContract.foreclose(); + } + + function testForeclose() external { + assertFalse(_appContract.isForeclosed()); + vm.expectEmit(true, true, true, true, address(_appContract)); + emit IApplication.Foreclosure(); + vm.prank(_appContract.getGuardian()); + _appContract.foreclose(); + assertTrue(_appContract.isForeclosed()); + } + // ----------------- // output validation // ----------------- @@ -279,11 +301,12 @@ contract ApplicationTest is Test, OwnableTest { // ------------------ function _initVariables() internal { - address[] memory addresses = vm.addrs(4); + address[] memory addresses = vm.addrs(5); _authorityOwner = addresses[0]; _appOwner = addresses[1]; _recipient = addresses[2]; _tokenOwner = addresses[3]; + _withdrawalConfig.guardian = addresses[4]; for (uint256 i; i < 7; ++i) { _tokenIds.push(i); _initialSupplies.push(INITIAL_SUPPLY); @@ -304,9 +327,8 @@ contract ApplicationTest is Test, OwnableTest { _inputBox = new InputBox(); _authority = new Authority(_authorityOwner, EPOCH_LENGTH); _dataAvailability = abi.encodeCall(DataAvailability.InputBox, (_inputBox)); - WithdrawalConfig memory withdrawalConfig; _appContract = new Application( - _authority, _appOwner, TEMPLATE_HASH, _dataAvailability, withdrawalConfig + _authority, _appOwner, TEMPLATE_HASH, _dataAvailability, _withdrawalConfig ); _safeErc20Transfer = new SafeERC20Transfer(); } diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index f3a49dfc..9ab9355d 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -261,6 +261,7 @@ contract ApplicationFactoryTest is Test { assertEq( withdrawalConfig.isValid(), true, "Expected withdrawal config to be valid" ); + assertEq(appContract.isForeclosed(), false, "isForeclosed() != false"); } function _testNewApplicationFailure( diff --git a/test/dapp/SelfHostedApplicationFactory.t.sol b/test/dapp/SelfHostedApplicationFactory.t.sol index f98599f7..c092e2ff 100644 --- a/test/dapp/SelfHostedApplicationFactory.t.sol +++ b/test/dapp/SelfHostedApplicationFactory.t.sol @@ -120,6 +120,7 @@ contract SelfHostedApplicationFactoryTest is Test { assertEq( withdrawalConfig.isValid(), true, "Expected withdrawal config to be valid" ); + assertEq(application.isForeclosed(), false, "isForeclosed() != false"); (appAddr, authorityAddr) = factory.calculateAddresses( authorityOwner, From 1631f10f5aa96068478823e63f8e228d655a5902 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 21:40:35 -0300 Subject: [PATCH 12/48] Add `IApplicationForeclosure` interface --- src/dapp/IApplication.sol | 23 ++----------------- src/dapp/IApplicationForeclosure.sol | 34 ++++++++++++++++++++++++++++ test/dapp/Application.t.sol | 5 ++-- 3 files changed, 39 insertions(+), 23 deletions(-) create mode 100644 src/dapp/IApplicationForeclosure.sol diff --git a/src/dapp/IApplication.sol b/src/dapp/IApplication.sol index 3a4bbf4b..1dc678ca 100644 --- a/src/dapp/IApplication.sol +++ b/src/dapp/IApplication.sol @@ -7,6 +7,7 @@ import {IOwnable} from "../access/IOwnable.sol"; import {OutputValidityProof} from "../common/OutputValidityProof.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; +import {IApplicationForeclosure} from "./IApplicationForeclosure.sol"; /// @notice The base layer incarnation of an application running on the execution layer. /// @notice The state of the application advances through inputs sent to an `IInputBox` contract. @@ -25,7 +26,7 @@ import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; /// - multiple signers (multi-sig) /// - DAO (decentralized autonomous organization) /// - self-owned application (off-chain governance logic) -interface IApplication is IOwnable { +interface IApplication is IOwnable, IApplicationForeclosure { // Events /// @notice MUST trigger when a new outputs Merkle root validator is chosen. @@ -37,9 +38,6 @@ interface IApplication is IOwnable { /// @param output The output event OutputExecuted(uint64 outputIndex, bytes output); - /// @notice MUST trigger when the application is foreclosed. - event Foreclosure(); - // Errors /// @notice Could not execute an output, because the application contract doesn't know how to. @@ -62,10 +60,6 @@ interface IApplication is IOwnable { /// @notice Raised when the computed outputs Merkle root is invalid, according to the current outputs Merkle root validator. error InvalidOutputsMerkleRoot(bytes32 outputsMerkleRoot); - /// @notice Raised when a function that can only be called by - /// the application guardian is called by some other account. - error NotGuardian(); - // Permissioned functions /// @notice Migrate the application to a new outputs Merkle root validator. @@ -74,11 +68,6 @@ interface IApplication is IOwnable { function migrateToOutputsMerkleRootValidator(IOutputsMerkleRootValidator newOutputsMerkleRootValidator) external; - /// @notice Forecloses the application, allowing users to withdraw their funds - /// by providing Merkle proofs of their in-app accounts. - /// @dev Can only be called by the application guardian. - function foreclose() external; - // Permissionless functions /// @notice Execute an output. @@ -158,15 +147,7 @@ interface IApplication is IOwnable { /// and has size `2^{a+b+5}`. function getAccountsDriveStartIndex() external view returns (uint64); - /// @notice Get the address of the guardian, - /// which has the power to foreclose the application. - function getGuardian() external view returns (address); - /// @notice Get the withdrawer contract, /// which gets delegate-called to withdraw funds from accounts. function getWithdrawer() external view returns (IWithdrawer); - - /// @notice Check whether the application has been foreclosed. - /// An application that has been foreclosed will remain so. - function isForeclosed() external view returns (bool); } diff --git a/src/dapp/IApplicationForeclosure.sol b/src/dapp/IApplicationForeclosure.sol new file mode 100644 index 00000000..dbc5874f --- /dev/null +++ b/src/dapp/IApplicationForeclosure.sol @@ -0,0 +1,34 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +interface IApplicationForeclosure { + // Events + + /// @notice MUST trigger when the application is foreclosed. + event Foreclosure(); + + // Errors + + /// @notice Raised when a function that can only be called by + /// the application guardian is called by some other account. + error NotGuardian(); + + // Permissioned functions + + /// @notice Forecloses the application, allowing users to withdraw their funds + /// by providing Merkle proofs of their in-app accounts. + /// @dev Can only be called by the application guardian. + function foreclose() external; + + // Permissionless functions + + /// @notice Get the address of the guardian, + /// which has the power to foreclose the application. + function getGuardian() external view returns (address); + + /// @notice Check whether the application has been foreclosed. + /// An application that has been foreclosed will remain so. + function isForeclosed() external view returns (bool); +} diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index 4354fa79..650af76f 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -12,6 +12,7 @@ import {IOutputsMerkleRootValidator} from "src/consensus/IOutputsMerkleRootValid import {Authority} from "src/consensus/authority/Authority.sol"; import {Application} from "src/dapp/Application.sol"; import {IApplication} from "src/dapp/IApplication.sol"; +import {IApplicationForeclosure} from "src/dapp/IApplicationForeclosure.sol"; import {SafeERC20Transfer} from "src/delegatecall/SafeERC20Transfer.sol"; import {IInputBox} from "src/inputs/IInputBox.sol"; import {InputBox} from "src/inputs/InputBox.sol"; @@ -124,7 +125,7 @@ contract ApplicationTest is Test, OwnableTest { function testForecloseRevertsNotGuardian(address caller) external { vm.assume(caller != _appContract.getGuardian()); assertFalse(_appContract.isForeclosed()); - vm.expectRevert(IApplication.NotGuardian.selector); + vm.expectRevert(IApplicationForeclosure.NotGuardian.selector); vm.prank(caller); _appContract.foreclose(); } @@ -132,7 +133,7 @@ contract ApplicationTest is Test, OwnableTest { function testForeclose() external { assertFalse(_appContract.isForeclosed()); vm.expectEmit(true, true, true, true, address(_appContract)); - emit IApplication.Foreclosure(); + emit IApplicationForeclosure.Foreclosure(); vm.prank(_appContract.getGuardian()); _appContract.foreclose(); assertTrue(_appContract.isForeclosed()); From 4357ff6132cbd325e80910f57745f8b1bfc18d75 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 23:18:00 -0300 Subject: [PATCH 13/48] Add `IApplicationChecker` interface --- src/dapp/IApplicationChecker.sol | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/dapp/IApplicationChecker.sol diff --git a/src/dapp/IApplicationChecker.sol b/src/dapp/IApplicationChecker.sol new file mode 100644 index 00000000..67a9c7f1 --- /dev/null +++ b/src/dapp/IApplicationChecker.sol @@ -0,0 +1,24 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +interface IApplicationChecker { + /// @notice The application contract address contains no code. + /// @param appContract The application contract address + error ApplicationNotDeployed(address appContract); + + /// @notice The call to the application contract reverted with an error. + /// @param appContract The application contract address + /// @param error The error raised by the application contract + error ApplicationReverted(address appContract, bytes error); + + /// @notice The call to the application contract returned ill-formed data. + /// @param appContract The application contract address + /// @param data The data returned by the application contract + error IllformedApplicationReturnData(address appContract, bytes data); + + /// @notice Application was foreclosed. + /// @param appContract The application contract address + error ApplicationForeclosed(address appContract); +} From e671be1841d8cf8892673ae578726eede9d49c6f Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 23:18:23 -0300 Subject: [PATCH 14/48] Add `ApplicationChecker` abstract contract --- src/dapp/ApplicationChecker.sol | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/dapp/ApplicationChecker.sol diff --git a/src/dapp/ApplicationChecker.sol b/src/dapp/ApplicationChecker.sol new file mode 100644 index 00000000..ac0f0997 --- /dev/null +++ b/src/dapp/ApplicationChecker.sol @@ -0,0 +1,67 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {IApplicationChecker} from "./IApplicationChecker.sol"; +import {IApplicationForeclosure} from "./IApplicationForeclosure.sol"; + +abstract contract ApplicationChecker is IApplicationChecker { + /// @notice Ensure that a given application is not foreclosed. + /// @param appContract The application contract address + function _ensureIsNotForeclosed(address appContract) internal view { + // We detect whether the application contract address has any + // code as this can be a common scenario faced by users and devs, + // which allows us to raise the clearer `ApplicationNotDeployed` error, + // rather than raising an `IllformedApplicationReturnData` error. + + if (appContract.code.length == 0) { + revert ApplicationNotDeployed(appContract); + } + + // We perform a low-level call to the application contract address + // so that we can decode the return data in a more fault-tolerant way. + + (bool success, bytes memory returndata) = appContract.staticcall( + abi.encodeCall(IApplicationForeclosure.isForeclosed, ()) + ); + + // If the call reverts, we wrap the error data in our `ApplicationReverted` + // error so that malicious application cannot inject arbitrary errors. + + if (!success) { + revert ApplicationReverted(appContract, returndata); + } + + // If the call succeeds, we check whether the return data length + // is 32 bytes. If not, we raise a `IllformedApplicationReturnData` error. + + if (returndata.length != 32) { + revert IllformedApplicationReturnData(appContract, returndata); + } + + // We decode the return data as a `uint256` value because decoding + // it as a boolean could raise a low-level EVM code (if the encoded + // value is neither a 0 or a 1). + + uint256 returncode = abi.decode(returndata, (uint256)); + + // We check whether the call returns 0 (false), 1 (true), or something else. + // If it returns 0 (the app is NOT foreclosed), we accept the input. + // If it returns 1 (app IS foreclosed), we raise an `ApplicationForeclosed` error. + // If it returns something else, we raise a `IllformedApplicationReturnData` error. + + if (returncode == 1) { + revert ApplicationForeclosed(appContract); + } else if (returncode != 0) { + revert IllformedApplicationReturnData(appContract, returndata); + } + } + + /// @notice A modifier that ensures an application is not foreclosed. + /// @param appContract The application contract address + modifier notForeclosed(address appContract) { + _ensureIsNotForeclosed(appContract); + _; + } +} From 974fbc79f5446a9a8dce56864acdfd45973b577a Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 22 Feb 2026 23:18:54 -0300 Subject: [PATCH 15/48] Make `InputBox` check for app foreclosure --- src/inputs/IInputBox.sol | 4 +- src/inputs/InputBox.sol | 4 +- test/inputs/InputBox.t.sol | 101 +++++++++++++++++++-- test/util/SimpleApplicationForeclosure.sol | 25 +++++ 4 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 test/util/SimpleApplicationForeclosure.sol diff --git a/src/inputs/IInputBox.sol b/src/inputs/IInputBox.sol index a3c534fb..ca26943b 100644 --- a/src/inputs/IInputBox.sol +++ b/src/inputs/IInputBox.sol @@ -3,12 +3,14 @@ pragma solidity ^0.8.8; +import {IApplicationChecker} from "../dapp/IApplicationChecker.sol"; + /// @notice Provides data availability of inputs for applications. /// @notice Each application has its own append-only list of inputs. /// @notice Off-chain, inputs can be retrieved via events. /// @notice On-chain, only the input hashes are stored. /// @notice See `LibInput` for more details on how such hashes are computed. -interface IInputBox { +interface IInputBox is IApplicationChecker { /// @notice MUST trigger when an input is added. /// @param appContract The application contract address /// @param index The input index diff --git a/src/inputs/InputBox.sol b/src/inputs/InputBox.sol index b9947938..2b462766 100644 --- a/src/inputs/InputBox.sol +++ b/src/inputs/InputBox.sol @@ -5,9 +5,10 @@ pragma solidity ^0.8.18; import {CanonicalMachine} from "../common/CanonicalMachine.sol"; import {Inputs} from "../common/Inputs.sol"; +import {ApplicationChecker} from "../dapp/ApplicationChecker.sol"; import {IInputBox} from "./IInputBox.sol"; -contract InputBox is IInputBox { +contract InputBox is IInputBox, ApplicationChecker { /// @notice Deployment block number uint256 immutable DEPLOYMENT_BLOCK_NUMBER = block.number; @@ -18,6 +19,7 @@ contract InputBox is IInputBox { function addInput(address appContract, bytes calldata payload) external override + notForeclosed(appContract) returns (bytes32) { bytes32[] storage inputBox = _inputBoxes[appContract]; diff --git a/test/inputs/InputBox.t.sol b/test/inputs/InputBox.t.sol index c15c2bd0..396fbdce 100644 --- a/test/inputs/InputBox.t.sol +++ b/test/inputs/InputBox.t.sol @@ -4,32 +4,78 @@ pragma solidity ^0.8.22; import {Test} from "forge-std-1.9.6/src/Test.sol"; + import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; import {Inputs} from "src/common/Inputs.sol"; +import {IApplicationChecker} from "src/dapp/IApplicationChecker.sol"; +import {IApplicationForeclosure} from "src/dapp/IApplicationForeclosure.sol"; import {IInputBox} from "src/inputs/IInputBox.sol"; import {InputBox} from "src/inputs/InputBox.sol"; import {EvmAdvanceEncoder} from "../util/EvmAdvanceEncoder.sol"; +import {SimpleApplicationForeclosure} from "../util/SimpleApplicationForeclosure.sol"; contract InputBoxTest is Test { InputBox _inputBox; + SimpleApplicationForeclosure _forecloser; - function setUp() public { + function setUp() external { _inputBox = new InputBox(); + _forecloser = new SimpleApplicationForeclosure(vm.addr(1)); } - function testDeploymentBlockNumber(uint256 blockNumber) public { + function testDeploymentBlockNumber(uint256 blockNumber) external { vm.roll(blockNumber); _inputBox = new InputBox(); assertEq(_inputBox.getDeploymentBlockNumber(), blockNumber); } - function testNoInputs(address appContract) public view { + function testNoInputs(address appContract) external view { assertEq(_inputBox.getNumberOfInputs(appContract), 0); } - function testAddLargeInput() public { - address appContract = vm.addr(1); + function testAddInputToZeroAddress(bytes calldata payload) external { + vm.expectRevert(_encodeApplicationNotDeployed(address(0))); + _inputBox.addInput(address(0), payload); + } + + function testAddInputToEoa(uint256 pkSeed, bytes calldata payload) external { + address eoa = vm.addr(boundPrivateKey(pkSeed)); + assertEq(eoa.code.length, 0, "EOA code length"); + vm.expectRevert(_encodeApplicationNotDeployed(eoa)); + _inputBox.addInput(eoa, payload); + } + + function testAddInputToReverter(bytes calldata error, bytes calldata payload) + external + { + address addr = vm.addr(2); + vm.mockCallRevert( + addr, abi.encodeCall(IApplicationForeclosure.isForeclosed, ()), error + ); + vm.expectRevert(_encodeApplicationReverted(addr, error)); + _inputBox.addInput(addr, payload); + } + + function testAddInputToIllReturner(bytes calldata data, bytes calldata payload) + external + { + vm.assume(data.length != 32); + address addr = vm.addr(2); + vm.mockCall(addr, abi.encodeCall(IApplicationForeclosure.isForeclosed, ()), data); + vm.expectRevert(_encodeIllformedApplicationReturnData(addr, data)); + _inputBox.addInput(addr, payload); + } + + function testAddInputForeclosedApp(bytes calldata payload) external { + vm.prank(_forecloser.getGuardian()); + _forecloser.foreclose(); + vm.expectRevert(_encodeApplicationForeclosed(address(_forecloser))); + _inputBox.addInput(address(_forecloser), payload); + } + + function testAddLargeInput() external { + address appContract = address(_forecloser); uint256 max = _getMaxInputPayloadLength(); _inputBox.addInput(appContract, new bytes(max)); @@ -49,9 +95,9 @@ contract InputBoxTest is Test { _inputBox.addInput(appContract, largePayload); } - function testAddInput(uint64 chainId, address appContract, bytes[] calldata payloads) - public - { + function testAddInput(uint64 chainId, bytes[] calldata payloads) external { + address appContract = address(_forecloser); + vm.chainId(chainId); // foundry limits chain id to be less than 2^64 - 1 uint256 numPayloads = payloads.length; @@ -119,4 +165,43 @@ contract InputBoxTest is Test { /// forge-lint: disable-next-line(divide-before-multiply) return ((CanonicalMachine.INPUT_MAX_SIZE - extraBytes) / 32) * 32; } + + function _encodeApplicationNotDeployed(address appContract) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + IApplicationChecker.ApplicationNotDeployed.selector, appContract + ); + } + + function _encodeApplicationReverted(address appContract, bytes memory error) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + IApplicationChecker.ApplicationReverted.selector, appContract, error + ); + } + + function _encodeIllformedApplicationReturnData( + address appContract, + bytes memory data + ) internal pure returns (bytes memory) { + return abi.encodeWithSelector( + IApplicationChecker.IllformedApplicationReturnData.selector, appContract, data + ); + } + + function _encodeApplicationForeclosed(address appContract) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + IApplicationChecker.ApplicationForeclosed.selector, appContract + ); + } } diff --git a/test/util/SimpleApplicationForeclosure.sol b/test/util/SimpleApplicationForeclosure.sol new file mode 100644 index 00000000..ee731483 --- /dev/null +++ b/test/util/SimpleApplicationForeclosure.sol @@ -0,0 +1,25 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {IApplicationForeclosure} from "src/dapp/IApplicationForeclosure.sol"; + +contract SimpleApplicationForeclosure is IApplicationForeclosure { + address immutable GUARDIAN; + bool public isForeclosed; + + constructor(address guardian) { + GUARDIAN = guardian; + } + + function foreclose() external override { + require(msg.sender == getGuardian(), NotGuardian()); + isForeclosed = true; + emit Foreclosure(); + } + + function getGuardian() public view override returns (address) { + return GUARDIAN; + } +} From b23fbffc52f0fc57f6f1d98fba0a4446af7f1618 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 28 Feb 2026 07:52:16 -0300 Subject: [PATCH 16/48] Add `LibMath` and tests --- test/util/LibMath.sol | 14 ++++++++++++++ test/util/LibMath.t.sol | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 test/util/LibMath.sol create mode 100644 test/util/LibMath.t.sol diff --git a/test/util/LibMath.sol b/test/util/LibMath.sol new file mode 100644 index 00000000..149c46f1 --- /dev/null +++ b/test/util/LibMath.sol @@ -0,0 +1,14 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +library LibMath { + /// @notice Get the smallest of two numbers. + /// @param a The first number + /// @param b The second number + /// @return The smallest of the two numbers + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a <= b ? a : b; + } +} diff --git a/test/util/LibMath.t.sol b/test/util/LibMath.t.sol new file mode 100644 index 00000000..3094c2b2 --- /dev/null +++ b/test/util/LibMath.t.sol @@ -0,0 +1,17 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {LibMath} from "./LibMath.sol"; + +contract LibMathTest is Test { + function testMin(uint256 a, uint256 b) external pure { + uint256 c = LibMath.min(a, b); + assertTrue(c == a || c == b, "min(a, b) \\in {a, b}"); + assertLe(c, a); + assertLe(c, b); + } +} From 3f8bbef922fde19f254d295340307e50c75a2ef3 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 28 Feb 2026 07:52:38 -0300 Subject: [PATCH 17/48] Add `LibUint256Array` and tests --- test/util/LibUint256Array.sol | 58 +++++++++++++++++++++ test/util/LibUint256Array.t.sol | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 test/util/LibUint256Array.sol create mode 100644 test/util/LibUint256Array.t.sol diff --git a/test/util/LibUint256Array.sol b/test/util/LibUint256Array.sol new file mode 100644 index 00000000..38bc2d76 --- /dev/null +++ b/test/util/LibUint256Array.sol @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; + +library LibUint256Array { + function shuffleInPlace(Vm vm, uint256[] memory array) internal { + // Nothing to be done. + if (array.length == 0) { + return; + } + + // Fisher-Yates shuffle + for (uint256 i = array.length - 1; i > 0; --i) { + uint256 j = vm.randomUint(0, i); + (array[i], array[j]) = (array[j], array[i]); + } + } + + function sequence(uint256 start, uint256 n) + internal + pure + returns (uint256[] memory array) + { + require(n == 0 || (n - 1) <= type(uint256).max - start, "sequence would overflow"); + array = new uint256[](n); + for (uint256 index; index < array.length; ++index) { + array[index] = start + index; + } + } + + function split(uint256[] memory array, uint256 firstLength) + internal + pure + returns (uint256[] memory first, uint256[] memory second) + { + require(firstLength <= array.length, "Invalid index"); + first = new uint256[](firstLength); + for (uint256 j; j < first.length; ++j) { + first[j] = array[j]; + } + second = new uint256[](array.length - firstLength); + for (uint256 j; j < second.length; ++j) { + second[j] = array[firstLength + j]; + } + } + + function contains(uint256[] memory array, uint256 elem) internal pure returns (bool) { + for (uint256 i; i < array.length; ++i) { + if (array[i] == elem) { + return true; + } + } + return false; + } +} diff --git a/test/util/LibUint256Array.t.sol b/test/util/LibUint256Array.t.sol new file mode 100644 index 00000000..c18d6f5d --- /dev/null +++ b/test/util/LibUint256Array.t.sol @@ -0,0 +1,90 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; + +import {LibMath} from "./LibMath.sol"; +import {LibUint256Array} from "./LibUint256Array.sol"; + +contract LibUint256ArrayTest is Test { + using LibUint256Array for Vm; + + mapping(uint256 => uint256) _histogram; + uint256[] _uniqueElements; + + function testShuffleInPlace(uint256[] memory array) external { + uint256 lengthBefore = array.length; + + for (uint256 i; i < array.length; ++i) { + uint256 element = array[i]; + if (_histogram[element] == 0) { + _uniqueElements.push(element); + } + ++_histogram[element]; + } + + vm.shuffleInPlace(array); + + assertEq(array.length, lengthBefore, "Length should be the same"); + + for (uint256 i; i < _uniqueElements.length; ++i) { + uint256 element = _uniqueElements[i]; + uint256 count; + for (uint256 j; j < array.length; ++j) { + if (array[j] == element) { + ++count; + } + } + assertEq(count, _histogram[element]); + } + } + + function testSequence(uint256 start) external { + uint256 maxN = LibMath.min(200, type(uint256).max - start); + uint256 n = vm.randomUint(0, maxN); + uint256[] memory array = LibUint256Array.sequence(start, n); + assertEq(array.length, n); + for (uint256 i; i < n; ++i) { + assertEq(array[i], start + i); + } + } + + function testSplit(uint256[] memory array) external { + uint256 firstLength = vm.randomUint(0, array.length); + (uint256[] memory first, uint256[] memory second) = + LibUint256Array.split(array, firstLength); + assertEq(first.length, firstLength); + assertEq(first.length + second.length, array.length); + for (uint256 i; i < first.length; ++i) { + assertEq(first[i], array[i]); + } + for (uint256 i; i < second.length; ++i) { + assertEq(second[i], array[i + firstLength]); + } + } + + function testContains(uint256[] memory array) external { + for (uint256 i; i < array.length; ++i) { + uint256 elem = array[i]; + assertTrue(LibUint256Array.contains(array, elem)); + } + uint256 notElem; + while (true) { + bool isElem = false; + notElem = vm.randomUint(); + for (uint256 i; i < array.length; ++i) { + if (notElem == array[i]) { + isElem = true; + break; + } + } + if (!isElem) { + break; + } + } + assertFalse(LibUint256Array.contains(array, notElem)); + } +} From ea1ed2e27cdbadf65ebba96544c5ffba560d0af2 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 28 Feb 2026 09:30:25 -0300 Subject: [PATCH 18/48] Add `AddressGenerator` and use in app tests --- test/dapp/Application.t.sol | 17 +++++++---------- test/util/AddressGenerator.sol | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 test/util/AddressGenerator.sol diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index 650af76f..acf03aaa 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -29,21 +29,19 @@ import {SafeERC20} from "@openzeppelin-contracts-5.2.0/token/ERC20/utils/SafeERC import {IERC721} from "@openzeppelin-contracts-5.2.0/token/ERC721/IERC721.sol"; import {Test} from "forge-std-1.9.6/src/Test.sol"; -import {Vm} from "forge-std-1.9.6/src/Vm.sol"; import {ExternalLibMerkle32} from "../library/LibMerkle32.t.sol"; +import {AddressGenerator} from "../util/AddressGenerator.sol"; import {EtherReceiver} from "../util/EtherReceiver.sol"; -import {LibAddressArray} from "../util/LibAddressArray.sol"; import {LibEmulator} from "../util/LibEmulator.sol"; import {OwnableTest} from "../util/OwnableTest.sol"; import {SimpleBatchERC1155, SimpleSingleERC1155} from "../util/SimpleERC1155.sol"; import {SimpleERC20} from "../util/SimpleERC20.sol"; import {SimpleERC721} from "../util/SimpleERC721.sol"; -contract ApplicationTest is Test, OwnableTest { +contract ApplicationTest is Test, OwnableTest, AddressGenerator { using LibEmulator for LibEmulator.State; using ExternalLibMerkle32 for bytes32[]; - using LibAddressArray for Vm; IApplication _appContract; EtherReceiver _etherReceiver; @@ -302,12 +300,11 @@ contract ApplicationTest is Test, OwnableTest { // ------------------ function _initVariables() internal { - address[] memory addresses = vm.addrs(5); - _authorityOwner = addresses[0]; - _appOwner = addresses[1]; - _recipient = addresses[2]; - _tokenOwner = addresses[3]; - _withdrawalConfig.guardian = addresses[4]; + _authorityOwner = _nextAddress(); + _appOwner = _nextAddress(); + _recipient = _nextAddress(); + _tokenOwner = _nextAddress(); + _withdrawalConfig.guardian = _nextAddress(); for (uint256 i; i < 7; ++i) { _tokenIds.push(i); _initialSupplies.push(INITIAL_SUPPLY); diff --git a/test/util/AddressGenerator.sol b/test/util/AddressGenerator.sol new file mode 100644 index 00000000..9d21ffd1 --- /dev/null +++ b/test/util/AddressGenerator.sol @@ -0,0 +1,15 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +contract AddressGenerator is Test { + uint256 private _addressCount; + + function _nextAddress() internal returns (address) { + bytes memory seed = abi.encode(type(AddressGenerator).name, ++_addressCount); + return vm.addr(boundPrivateKey(uint256(keccak256(seed)))); + } +} From 6a82ae96557b6f4b3f48e9450ec306b7491e3dae Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 28 Feb 2026 09:30:53 -0300 Subject: [PATCH 19/48] Add functions to `LibAddressArray` and tests - Remove `addrs` function --- test/util/LibAddressArray.sol | 27 +++++++--- test/util/LibAddressArray.t.sol | 92 +++++++++++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/test/util/LibAddressArray.sol b/test/util/LibAddressArray.sol index 92439ebc..1d379050 100644 --- a/test/util/LibAddressArray.sol +++ b/test/util/LibAddressArray.sol @@ -6,6 +6,26 @@ pragma solidity ^0.8.22; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; library LibAddressArray { + function randomAddressIn(Vm vm, address[] memory array) + internal + returns (address addr) + { + require(array.length > 0, "Cannot sample random element from empty array"); + addr = array[vm.randomUint(0, array.length - 1)]; + } + + function randomAddressNotIn(Vm vm, address[] memory array) + internal + returns (address addr) + { + while (true) { + addr = vm.randomAddress(); + if (!contains(array, addr)) { + break; + } + } + } + function contains(address[] memory array, address elem) internal pure returns (bool) { for (uint256 i; i < array.length; ++i) { if (array[i] == elem) { @@ -14,11 +34,4 @@ library LibAddressArray { } return false; } - - function addrs(Vm vm, uint256 n) internal pure returns (address[] memory array) { - array = new address[](n); - for (uint256 i; i < n; ++i) { - array[i] = vm.addr(i + 1); - } - } } diff --git a/test/util/LibAddressArray.t.sol b/test/util/LibAddressArray.t.sol index 1b479b94..bfadab22 100644 --- a/test/util/LibAddressArray.t.sol +++ b/test/util/LibAddressArray.t.sol @@ -4,19 +4,101 @@ pragma solidity ^0.8.22; import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; import {LibAddressArray} from "./LibAddressArray.sol"; +library ExternalLibAddressArray { + function randomAddressIn(Vm vm, address[] memory array) + external + returns (address addr) + { + addr = LibAddressArray.randomAddressIn(vm, array); + } +} + contract LibAddressArrayTest is Test { using LibAddressArray for address[]; + using LibAddressArray for Vm; + + function testRandomAddressIn(address[] memory array) external { + vm.assume(array.length > 0); + assertTrue(array.contains(vm.randomAddressIn(array))); + } + + function testRandomAddressInEmptyArray() external { + address[] memory emptyArray = new address[](0); + vm.expectRevert("Cannot sample random element from empty array"); + ExternalLibAddressArray.randomAddressIn(vm, emptyArray); + } + + function testRandomAddressInSingleton(address elem) external { + address[] memory singleton = new address[](1); + singleton[0] = elem; + assertEq(vm.randomAddressIn(singleton), elem); + } + + function testRandomAddressNotIn(address[] memory array) external { + assertFalse(array.contains(vm.randomAddressNotIn(array))); + } - function testContains(address[] memory array, bytes32 salt) external pure { + function testContains(address[] memory array) external { for (uint256 i; i < array.length; ++i) { assertTrue(array.contains(array[i])); } - // By the properties of keccak256, this should yield a random address - // that is not contained in the array - address elem = address(bytes20(keccak256(abi.encode(array, salt)))); - assertFalse(array.contains(elem)); + address notElement; + while (true) { + bool isElement; + notElement = vm.randomAddress(); + for (uint256 i; i < array.length; ++i) { + if (notElement == array[i]) { + isElement = true; + break; + } + } + if (!isElement) { + break; + } + } + assertFalse(array.contains(notElement)); + } + + function testContains(address elem) external pure { + address[] memory emptyArray = new address[](0); + assertFalse(emptyArray.contains(elem)); + + address[] memory singleton = new address[](1); + singleton[0] = elem; + assertTrue(singleton.contains(elem)); + } + + function testContains(address a, address b) external pure { + vm.assume(a != b); + + address[] memory emptyArray = new address[](0); + assertFalse(emptyArray.contains(a)); + assertFalse(emptyArray.contains(b)); + + address[] memory singletonA = new address[](1); + singletonA[0] = a; + assertTrue(singletonA.contains(a)); + assertFalse(singletonA.contains(b)); + + address[] memory singletonB = new address[](1); + singletonB[0] = b; + assertFalse(singletonB.contains(a)); + assertTrue(singletonB.contains(b)); + + address[] memory ab = new address[](2); + ab[0] = a; + ab[1] = b; + assertTrue(ab.contains(a)); + assertTrue(ab.contains(b)); + + address[] memory ba = new address[](2); + ba[0] = b; + ba[1] = a; + assertTrue(ba.contains(a)); + assertTrue(ba.contains(b)); } } From 78c75ccf5b60db24cddfc634604286bc5768010b Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 28 Feb 2026 10:01:48 -0300 Subject: [PATCH 20/48] Add `ApplicationCheckerTestUtils` --- test/util/ApplicationCheckerTestUtils.sol | 82 +++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/util/ApplicationCheckerTestUtils.sol diff --git a/test/util/ApplicationCheckerTestUtils.sol b/test/util/ApplicationCheckerTestUtils.sol new file mode 100644 index 00000000..db2e8c9d --- /dev/null +++ b/test/util/ApplicationCheckerTestUtils.sol @@ -0,0 +1,82 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {IApplicationChecker} from "src/dapp/IApplicationChecker.sol"; +import {IApplicationForeclosure} from "src/dapp/IApplicationForeclosure.sol"; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +contract ApplicationCheckerTestUtils is Test { + function _encodeApplicationNotDeployed(address appContract) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + IApplicationChecker.ApplicationNotDeployed.selector, appContract + ); + } + + function _encodeApplicationReverted(address appContract, bytes memory error) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + IApplicationChecker.ApplicationReverted.selector, appContract, error + ); + } + + function _encodeIllformedApplicationReturnData( + address appContract, + bytes memory data + ) internal pure returns (bytes memory) { + return abi.encodeWithSelector( + IApplicationChecker.IllformedApplicationReturnData.selector, appContract, data + ); + } + + function _encodeApplicationForeclosed(address appContract) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + IApplicationChecker.ApplicationForeclosed.selector, appContract + ); + } + + function _encodeIsForeclosed() internal pure returns (bytes memory) { + return abi.encodeCall(IApplicationForeclosure.isForeclosed, ()); + } + + function _randomAccountWithNoCode() internal returns (address) { + address account = vm.addr(boundPrivateKey(vm.randomUint())); + vm.assume(account.code.length == 0); + return account; + } + + function _newAppMockReturns(bytes memory data) internal returns (address) { + address appContract = _randomAccountWithNoCode(); + vm.mockCall(appContract, _encodeIsForeclosed(), data); + assertGt(appContract.code.length, 0); + return appContract; + } + + function _newAppMockReverts(bytes memory error) internal returns (address) { + address appContract = _randomAccountWithNoCode(); + vm.mockCallRevert(appContract, _encodeIsForeclosed(), error); + assertGt(appContract.code.length, 0); + return appContract; + } + + function _newForeclosedAppMock() internal returns (address) { + return _newAppMockReturns(abi.encode(true)); + } + + function _newActiveAppMock() internal returns (address) { + return _newAppMockReturns(abi.encode(false)); + } +} From 3ff5073310dd1487e978d611baa804e8322c6db9 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 28 Feb 2026 10:02:00 -0300 Subject: [PATCH 21/48] Refactor `InputBox` tests --- test/inputs/InputBox.t.sol | 107 ++++++++++++------------------------- 1 file changed, 35 insertions(+), 72 deletions(-) diff --git a/test/inputs/InputBox.t.sol b/test/inputs/InputBox.t.sol index 396fbdce..6db532fe 100644 --- a/test/inputs/InputBox.t.sol +++ b/test/inputs/InputBox.t.sol @@ -7,21 +7,17 @@ import {Test} from "forge-std-1.9.6/src/Test.sol"; import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; import {Inputs} from "src/common/Inputs.sol"; -import {IApplicationChecker} from "src/dapp/IApplicationChecker.sol"; -import {IApplicationForeclosure} from "src/dapp/IApplicationForeclosure.sol"; import {IInputBox} from "src/inputs/IInputBox.sol"; import {InputBox} from "src/inputs/InputBox.sol"; +import {ApplicationCheckerTestUtils} from "../util/ApplicationCheckerTestUtils.sol"; import {EvmAdvanceEncoder} from "../util/EvmAdvanceEncoder.sol"; -import {SimpleApplicationForeclosure} from "../util/SimpleApplicationForeclosure.sol"; -contract InputBoxTest is Test { +contract InputBoxTest is Test, ApplicationCheckerTestUtils { InputBox _inputBox; - SimpleApplicationForeclosure _forecloser; function setUp() external { _inputBox = new InputBox(); - _forecloser = new SimpleApplicationForeclosure(vm.addr(1)); } function testDeploymentBlockNumber(uint256 blockNumber) external { @@ -34,48 +30,54 @@ contract InputBoxTest is Test { assertEq(_inputBox.getNumberOfInputs(appContract), 0); } - function testAddInputToZeroAddress(bytes calldata payload) external { - vm.expectRevert(_encodeApplicationNotDeployed(address(0))); - _inputBox.addInput(address(0), payload); + function testAddInputRevertsZeroAddress(bytes calldata payload) external { + address appContract = address(0); + vm.expectRevert(_encodeApplicationNotDeployed(appContract)); + _inputBox.addInput(appContract, payload); } - function testAddInputToEoa(uint256 pkSeed, bytes calldata payload) external { - address eoa = vm.addr(boundPrivateKey(pkSeed)); - assertEq(eoa.code.length, 0, "EOA code length"); - vm.expectRevert(_encodeApplicationNotDeployed(eoa)); - _inputBox.addInput(eoa, payload); + function testAddInputRevertsApplicationNotDeployed(bytes calldata payload) external { + address appContract = _randomAccountWithNoCode(); + vm.expectRevert(_encodeApplicationNotDeployed(appContract)); + _inputBox.addInput(appContract, payload); } - function testAddInputToReverter(bytes calldata error, bytes calldata payload) + function testAddInputRevertsApplicationReverted( + bytes calldata error, + bytes calldata payload + ) external { + address appContract = _newAppMockReverts(error); + vm.expectRevert(_encodeApplicationReverted(appContract, error)); + _inputBox.addInput(appContract, payload); + } + + function testAddInputRevertsIllSize(bytes calldata data, bytes calldata payload) external { - address addr = vm.addr(2); - vm.mockCallRevert( - addr, abi.encodeCall(IApplicationForeclosure.isForeclosed, ()), error - ); - vm.expectRevert(_encodeApplicationReverted(addr, error)); - _inputBox.addInput(addr, payload); + vm.assume(data.length != 32); + address appContract = _newAppMockReturns(data); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, data)); + _inputBox.addInput(appContract, payload); } - function testAddInputToIllReturner(bytes calldata data, bytes calldata payload) + function testAddInputRevertsIllForm(uint256 returnValue, bytes calldata payload) external { - vm.assume(data.length != 32); - address addr = vm.addr(2); - vm.mockCall(addr, abi.encodeCall(IApplicationForeclosure.isForeclosed, ()), data); - vm.expectRevert(_encodeIllformedApplicationReturnData(addr, data)); - _inputBox.addInput(addr, payload); + vm.assume(returnValue > 1); + bytes memory data = abi.encode(returnValue); + address appContract = _newAppMockReturns(data); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, data)); + _inputBox.addInput(appContract, payload); } function testAddInputForeclosedApp(bytes calldata payload) external { - vm.prank(_forecloser.getGuardian()); - _forecloser.foreclose(); - vm.expectRevert(_encodeApplicationForeclosed(address(_forecloser))); - _inputBox.addInput(address(_forecloser), payload); + address appContract = _newForeclosedAppMock(); + vm.expectRevert(_encodeApplicationForeclosed(appContract)); + _inputBox.addInput(appContract, payload); } function testAddLargeInput() external { - address appContract = address(_forecloser); + address appContract = _newActiveAppMock(); uint256 max = _getMaxInputPayloadLength(); _inputBox.addInput(appContract, new bytes(max)); @@ -96,7 +98,7 @@ contract InputBoxTest is Test { } function testAddInput(uint64 chainId, bytes[] calldata payloads) external { - address appContract = address(_forecloser); + address appContract = _newActiveAppMock(); vm.chainId(chainId); // foundry limits chain id to be less than 2^64 - 1 @@ -165,43 +167,4 @@ contract InputBoxTest is Test { /// forge-lint: disable-next-line(divide-before-multiply) return ((CanonicalMachine.INPUT_MAX_SIZE - extraBytes) / 32) * 32; } - - function _encodeApplicationNotDeployed(address appContract) - internal - pure - returns (bytes memory) - { - return abi.encodeWithSelector( - IApplicationChecker.ApplicationNotDeployed.selector, appContract - ); - } - - function _encodeApplicationReverted(address appContract, bytes memory error) - internal - pure - returns (bytes memory) - { - return abi.encodeWithSelector( - IApplicationChecker.ApplicationReverted.selector, appContract, error - ); - } - - function _encodeIllformedApplicationReturnData( - address appContract, - bytes memory data - ) internal pure returns (bytes memory) { - return abi.encodeWithSelector( - IApplicationChecker.IllformedApplicationReturnData.selector, appContract, data - ); - } - - function _encodeApplicationForeclosed(address appContract) - internal - pure - returns (bytes memory) - { - return abi.encodeWithSelector( - IApplicationChecker.ApplicationForeclosed.selector, appContract - ); - } } From a744b78fec9d11912f95aaeb1874bd098978bfb5 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sat, 28 Feb 2026 22:38:23 -0300 Subject: [PATCH 22/48] Make `Authority` and `Quorum` check for app foreclosure --- src/consensus/AbstractConsensus.sol | 9 +- src/consensus/IConsensus.sol | 3 +- src/consensus/quorum/IQuorum.sol | 6 +- src/consensus/quorum/Quorum.sol | 4 + test/consensus/authority/Authority.t.sol | 274 ------- .../authority/AuthorityFactory.t.sol | 474 +++++++++-- test/consensus/quorum/Quorum.t.sol | 639 --------------- test/consensus/quorum/QuorumFactory.t.sol | 761 ++++++++++++++++-- test/dapp/Application.t.sol | 22 +- test/util/Claim.sol | 10 + test/util/ConsensusTestUtils.sol | 93 +++ test/util/ERC165Test.sol | 47 +- test/util/LibConsensus.sol | 26 + test/util/LibQuorum.sol | 50 ++ test/util/OwnableTest.sol | 109 +-- test/util/SimpleApplicationForeclosure.sol | 25 - 16 files changed, 1412 insertions(+), 1140 deletions(-) delete mode 100644 test/consensus/authority/Authority.t.sol delete mode 100644 test/consensus/quorum/Quorum.t.sol create mode 100644 test/util/Claim.sol create mode 100644 test/util/ConsensusTestUtils.sol create mode 100644 test/util/LibConsensus.sol create mode 100644 test/util/LibQuorum.sol delete mode 100644 test/util/SimpleApplicationForeclosure.sol diff --git a/src/consensus/AbstractConsensus.sol b/src/consensus/AbstractConsensus.sol index 9b528a55..8d4fe083 100644 --- a/src/consensus/AbstractConsensus.sol +++ b/src/consensus/AbstractConsensus.sol @@ -6,11 +6,12 @@ pragma solidity ^0.8.26; import {ERC165} from "@openzeppelin-contracts-5.2.0/utils/introspection/ERC165.sol"; import {IERC165} from "@openzeppelin-contracts-5.2.0/utils/introspection/IERC165.sol"; +import {ApplicationChecker} from "../dapp/ApplicationChecker.sol"; import {IConsensus} from "./IConsensus.sol"; import {IOutputsMerkleRootValidator} from "./IOutputsMerkleRootValidator.sol"; /// @notice Abstract implementation of IConsensus -abstract contract AbstractConsensus is IConsensus, ERC165 { +abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { /// @notice The epoch length uint256 immutable EPOCH_LENGTH; @@ -91,13 +92,14 @@ abstract contract AbstractConsensus is IConsensus, ERC165 { /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block /// @param outputsMerkleRoot The output Merkle root hash + /// @dev Checks whether the app is foreclosed. /// @dev Emits a `ClaimSubmitted` event. function _submitClaim( address submitter, address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot - ) internal { + ) internal notForeclosed(appContract) { emit ClaimSubmitted( submitter, appContract, lastProcessedBlockNumber, outputsMerkleRoot ); @@ -108,13 +110,14 @@ abstract contract AbstractConsensus is IConsensus, ERC165 { /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block /// @param outputsMerkleRoot The output Merkle root hash + /// @dev Checks whether the app is foreclosed. /// @dev Marks the outputsMerkleRoot as valid. /// @dev Emits a `ClaimAccepted` event. function _acceptClaim( address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot - ) internal { + ) internal notForeclosed(appContract) { _validOutputsMerkleRoots[appContract][outputsMerkleRoot] = true; emit ClaimAccepted(appContract, lastProcessedBlockNumber, outputsMerkleRoot); ++_numOfAcceptedClaims; diff --git a/src/consensus/IConsensus.sol b/src/consensus/IConsensus.sol index c91973b8..7ad67933 100644 --- a/src/consensus/IConsensus.sol +++ b/src/consensus/IConsensus.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.8; +import {IApplicationChecker} from "../dapp/IApplicationChecker.sol"; import {IOutputsMerkleRootValidator} from "./IOutputsMerkleRootValidator.sol"; /// @notice Each application has its own stream of inputs. @@ -21,7 +22,7 @@ import {IOutputsMerkleRootValidator} from "./IOutputsMerkleRootValidator.sol"; /// - submitted by the majority of a quorum or; /// - submitted and not proven wrong after some period of time or; /// - submitted and proven correct through an on-chain tournament. -interface IConsensus is IOutputsMerkleRootValidator { +interface IConsensus is IOutputsMerkleRootValidator, IApplicationChecker { /// @notice MUST trigger when a claim is submitted. /// @param submitter The submitter address /// @param appContract The application contract address diff --git a/src/consensus/quorum/IQuorum.sol b/src/consensus/quorum/IQuorum.sol index d0492b12..094b12f4 100644 --- a/src/consensus/quorum/IQuorum.sol +++ b/src/consensus/quorum/IQuorum.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.8; import {IConsensus} from "../IConsensus.sol"; -/// @notice A consensus model controlled by a small, immutable set of `n` validators. +/// @notice A consensus model controlled by a small, immutable set of `n` (>= 1) validators. /// @notice You can know the value of `n` by calling the `numOfValidators` function. /// @notice Upon construction, each validator is assigned a unique number between 1 and `n`. /// These numbers are used internally instead of addresses for gas optimization reasons. @@ -13,17 +13,19 @@ import {IConsensus} from "../IConsensus.sol"; /// function for each ID from 1 to `n`. interface IQuorum is IConsensus { /// @notice Get the number of validators. + /// @dev The number of validators is greater than or equal to 1. function numOfValidators() external view returns (uint256); /// @notice Get the ID of a validator. /// @param validator The validator address - /// @dev Validators have IDs greater than zero. + /// @dev Validators are assigned IDs between 1 and `N`, the total number of validators. /// @dev Non-validators are assigned to ID zero. function validatorId(address validator) external view returns (uint256); /// @notice Get the address of a validator by its ID. /// @param id The validator ID /// @dev Validator IDs range from 1 to `N`, the total number of validators. + /// @dev Valid IDs do not map to address zero. /// @dev Invalid IDs map to address zero. function validatorById(uint256 id) external view returns (address); diff --git a/src/consensus/quorum/Quorum.sol b/src/consensus/quorum/Quorum.sol index 406d0d05..06437ec9 100644 --- a/src/consensus/quorum/Quorum.sol +++ b/src/consensus/quorum/Quorum.sol @@ -50,19 +50,23 @@ contract Quorum is IQuorum, AbstractConsensus { /// @param validators The array of validator addresses /// @param epochLength The epoch length /// @dev Duplicates in the `validators` array are ignored. + /// @dev Zero addresses in the `validators` array are prohibited. /// @dev Reverts if the epoch length is zero. + /// @dev Reverts if the quorum would contain zero validators. constructor(address[] memory validators, uint256 epochLength) AbstractConsensus(epochLength) { uint256 n; for (uint256 i; i < validators.length; ++i) { address validator = validators[i]; + require(validator != address(0), "Quorum can't contain address(0)"); if (_validatorId[validator] == 0) { uint256 id = ++n; _validatorId[validator] = id; _validatorById[id] = validator; } } + require(n >= 1, "Quorum can't be empty"); NUM_OF_VALIDATORS = n; } diff --git a/test/consensus/authority/Authority.t.sol b/test/consensus/authority/Authority.t.sol deleted file mode 100644 index f53fbece..00000000 --- a/test/consensus/authority/Authority.t.sol +++ /dev/null @@ -1,274 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -/// @title Authority Test -pragma solidity ^0.8.22; - -import {Test} from "forge-std-1.9.6/src/Test.sol"; -import {Vm} from "forge-std-1.9.6/src/Vm.sol"; - -import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; -import {IERC165} from "@openzeppelin-contracts-5.2.0/utils/introspection/IERC165.sol"; - -import {IOwnable} from "src/access/IOwnable.sol"; -import {IConsensus} from "src/consensus/IConsensus.sol"; -import {Authority} from "src/consensus/authority/Authority.sol"; -import {IAuthority} from "src/consensus/authority/IAuthority.sol"; - -import {ERC165Test} from "../../util/ERC165Test.sol"; -import {LibTopic} from "../../util/LibTopic.sol"; -import {OwnableTest} from "../../util/OwnableTest.sol"; - -contract AuthorityTest is Test, ERC165Test, OwnableTest { - using LibTopic for address; - - IAuthority _authority; - - function setUp() external { - _authority = new Authority(vm.addr(1), 1); - } - - /// @inheritdoc ERC165Test - function _getErc165Contract() internal view override returns (IERC165) { - return _authority; - } - - /// @inheritdoc ERC165Test - function _getSupportedInterfaces() internal pure override returns (bytes4[] memory) { - bytes4[] memory ifaces = new bytes4[](3); - ifaces[0] = type(IERC165).interfaceId; - ifaces[1] = type(IConsensus).interfaceId; - ifaces[2] = type(IAuthority).interfaceId; - return ifaces; - } - - /// @inheritdoc OwnableTest - function _getOwnableContract() internal view override returns (IOwnable) { - return _authority; - } - - function testConstructor(address owner, uint256 epochLength) public { - vm.assume(owner != address(0)); - vm.assume(epochLength > 0); - - vm.recordLogs(); - - IAuthority authority = new Authority(owner, epochLength); - - Vm.Log[] memory entries = vm.getRecordedLogs(); - - uint256 numOfOwnershipTransferred; - - for (uint256 i; i < entries.length; ++i) { - Vm.Log memory entry = entries[i]; - - if ( - entry.emitter == address(authority) - && entry.topics[0] == Ownable.OwnershipTransferred.selector - ) { - ++numOfOwnershipTransferred; - - if (numOfOwnershipTransferred == 1) { - assertEq(entry.topics[1], address(0).asTopic()); - assertEq(entry.topics[2], owner.asTopic()); - } - } - } - - assertEq(numOfOwnershipTransferred, 1); - assertEq(authority.owner(), owner); - assertEq(authority.getEpochLength(), epochLength); - } - - function testRevertsOwnerAddressZero(uint256 epochLength) public { - vm.assume(epochLength > 0); - - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) - ); - new Authority(address(0), epochLength); - } - - function testRevertsEpochLengthZero(address owner) public { - vm.assume(owner != address(0)); - - vm.expectRevert("epoch length must not be zero"); - new Authority(owner, 0); - } - - function testSubmitClaimRevertsCallerNotOwner( - address owner, - address notOwner, - uint256 epochLength, - address appContract, - uint256 lastProcessedBlockNumber, - bytes32 claim - ) public { - vm.assume(owner != address(0)); - vm.assume(owner != notOwner); - vm.assume(epochLength > 0); - - IAuthority authority = new Authority(owner, epochLength); - - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, notOwner) - ); - - vm.prank(notOwner); - authority.submitClaim(appContract, lastProcessedBlockNumber, claim); - } - - function testSubmitMultipleValidClaims(bytes32[] calldata claims) public { - address owner = vm.addr(1); - address app = vm.addr(2); - uint256 epochLength = 5; - - IAuthority authority = new Authority(owner, epochLength); - - for (uint256 i; i < claims.length; ++i) { - uint256 blockNum = (i + 1) * epochLength; - vm.roll(blockNum); - - vm.prank(owner); - authority.submitClaim(app, blockNum - 1, claims[i]); - - assertEq(authority.getNumberOfAcceptedClaims(), i + 1); - assertEq(authority.getNumberOfSubmittedClaims(), i + 1); - } - } - - function testSubmitClaimNotFirstClaim( - address owner, - uint256 epochLength, - address appContract, - uint256 epochNumber, - uint256 blockNumber, - bytes32 claim, - bytes32 claim2 - ) public { - vm.assume(owner != address(0)); - vm.assume(epochLength >= 1); - - IAuthority authority = new Authority(owner, epochLength); - - blockNumber = bound(blockNumber, epochLength, type(uint256).max); - epochNumber = bound(epochNumber, 0, (blockNumber / epochLength) - 1); - uint256 lastProcessedBlockNumber = epochNumber * epochLength + (epochLength - 1); - - vm.roll(blockNumber); - - _expectClaimEvents(authority, owner, appContract, lastProcessedBlockNumber, claim); - - vm.prank(owner); - authority.submitClaim(appContract, lastProcessedBlockNumber, claim); - - assertTrue(authority.isOutputsMerkleRootValid(appContract, claim)); - - vm.expectRevert( - abi.encodeWithSelector( - IConsensus.NotFirstClaim.selector, appContract, lastProcessedBlockNumber - ) - ); - vm.prank(owner); - authority.submitClaim(appContract, lastProcessedBlockNumber, claim2); - - assertEq(authority.getNumberOfAcceptedClaims(), 1); - assertEq(authority.getNumberOfSubmittedClaims(), 1); - } - - function testSubmitClaimNotEpochFinalBlock( - address owner, - uint256 epochLength, - address appContract, - uint256 epochNumber, - uint256 blockNumber, - uint256 blocksAfterEpochStart, - bytes32 claim - ) public { - vm.assume(owner != address(0)); - vm.assume(epochLength >= 2); - - IAuthority authority = new Authority(owner, epochLength); - - blocksAfterEpochStart = bound(blocksAfterEpochStart, 0, epochLength - 2); - blockNumber = bound(blockNumber, epochLength, type(uint256).max); - epochNumber = bound(epochNumber, 0, (blockNumber / epochLength) - 1); - uint256 lastProcessedBlockNumber = - epochNumber * epochLength + blocksAfterEpochStart; - - vm.roll(blockNumber); - - vm.expectRevert( - abi.encodeWithSelector( - IConsensus.NotEpochFinalBlock.selector, - lastProcessedBlockNumber, - epochLength - ) - ); - vm.prank(owner); - authority.submitClaim(appContract, lastProcessedBlockNumber, claim); - assertEq(authority.getNumberOfAcceptedClaims(), 0); - assertEq(authority.getNumberOfSubmittedClaims(), 0); - } - - function testSubmitClaimNotPastBlock( - address owner, - uint256 epochLength, - address appContract, - uint256 epochNumber, - uint256 blockNumber, - bytes32 claim - ) public { - vm.assume(owner != address(0)); - vm.assume(epochLength >= 1); - - IAuthority authority = new Authority(owner, epochLength); - - uint256 maxEpochNumber = (type(uint256).max - (epochLength - 1)) / epochLength; - epochNumber = bound(epochNumber, 0, maxEpochNumber); - uint256 lastProcessedBlockNumber = epochNumber * epochLength + (epochLength - 1); - blockNumber = bound(blockNumber, 0, lastProcessedBlockNumber); - - vm.roll(blockNumber); - - vm.expectRevert( - abi.encodeWithSelector( - IConsensus.NotPastBlock.selector, lastProcessedBlockNumber, blockNumber - ) - ); - vm.prank(owner); - authority.submitClaim(appContract, lastProcessedBlockNumber, claim); - assertEq(authority.getNumberOfAcceptedClaims(), 0); - assertEq(authority.getNumberOfSubmittedClaims(), 0); - } - - function testIsOutputsMerkleRootValid( - address owner, - uint256 epochLength, - address appContract, - bytes32 claim - ) public { - vm.assume(owner != address(0)); - vm.assume(epochLength > 0); - - IAuthority authority = new Authority(owner, epochLength); - - assertFalse(authority.isOutputsMerkleRootValid(appContract, claim)); - } - - function _expectClaimEvents( - IAuthority authority, - address owner, - address appContract, - uint256 lastProcessedBlockNumber, - bytes32 claim - ) internal { - vm.expectEmit(true, true, false, true, address(authority)); - emit IConsensus.ClaimSubmitted( - owner, appContract, lastProcessedBlockNumber, claim - ); - - vm.expectEmit(true, false, false, true, address(authority)); - emit IConsensus.ClaimAccepted(appContract, lastProcessedBlockNumber, claim); - } -} diff --git a/test/consensus/authority/AuthorityFactory.t.sol b/test/consensus/authority/AuthorityFactory.t.sol index 69e108f5..cc12ba3b 100644 --- a/test/consensus/authority/AuthorityFactory.t.sol +++ b/test/consensus/authority/AuthorityFactory.t.sol @@ -8,109 +8,471 @@ import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; import {Test} from "forge-std-1.9.6/src/Test.sol"; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; +import {IConsensus} from "src/consensus/IConsensus.sol"; import {AuthorityFactory} from "src/consensus/authority/AuthorityFactory.sol"; import {IAuthority} from "src/consensus/authority/IAuthority.sol"; import {IAuthorityFactory} from "src/consensus/authority/IAuthorityFactory.sol"; -contract AuthorityFactoryTest is Test { +import {Claim} from "../../util/Claim.sol"; +import {ConsensusTestUtils} from "../../util/ConsensusTestUtils.sol"; +import {ERC165Test} from "../../util/ERC165Test.sol"; +import {LibConsensus} from "../../util/LibConsensus.sol"; +import {LibTopic} from "../../util/LibTopic.sol"; +import {OwnableTest} from "../../util/OwnableTest.sol"; + +contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUtils { + using LibConsensus for IAuthority; + using LibTopic for address; + AuthorityFactory _factory; + bytes4[] _supportedInterfaces; function setUp() public { _factory = new AuthorityFactory(); + _supportedInterfaces.push(type(IConsensus).interfaceId); + _supportedInterfaces.push(type(IAuthority).interfaceId); + _registerSupportedInterfaces(_supportedInterfaces); } - function testRevertsOwnerAddressZero(uint256 epochLength, bytes32 salt) public { - vm.assume(epochLength > 0); + function testNewAuthority( + address authorityOwner, + uint256 epochLength, + bytes4 interfaceId + ) public { + vm.recordLogs(); - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) - ); - _factory.newAuthority(address(0), epochLength); + try _factory.newAuthority(authorityOwner, epochLength) returns ( + IAuthority authority + ) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + _testNewAuthoritySuccess( + authorityOwner, epochLength, interfaceId, authority, logs + ); + } catch (bytes memory error) { + _testNewAuthorityFailure(authorityOwner, epochLength, error); + return; + } + } - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) + function testNewAuthorityDeterministic( + address authorityOwner, + uint256 epochLength, + bytes4 interfaceId, + bytes32 salt + ) public { + address precalculatedAddress = + _factory.calculateAuthorityAddress(authorityOwner, epochLength, salt); + + vm.recordLogs(); + + try _factory.newAuthority(authorityOwner, epochLength, salt) returns ( + IAuthority authority + ) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + + assertEq( + precalculatedAddress, + address(authority), + "calculateAuthorityAddress(...) != newAuthority(...)" + ); + + _testNewAuthoritySuccess( + authorityOwner, epochLength, interfaceId, authority, logs + ); + } catch (bytes memory error) { + _testNewAuthorityFailure(authorityOwner, epochLength, error); + return; + } + + assertEq( + _factory.calculateAuthorityAddress(authorityOwner, epochLength, salt), + precalculatedAddress, + "calculateAuthorityAddress(...) is not a pure function" ); - _factory.newAuthority(address(0), epochLength, salt); + + // Cannot deploy an application with the same salt twice + try _factory.newAuthority(authorityOwner, epochLength, salt) { + revert("second deterministic deployment did not revert"); + } catch (bytes memory error) { + assertEq( + error, + new bytes(0), + "second deterministic deployment did not revert with empty error data" + ); + } } - function testRevertsEpochLengthZero(address authorityOwner, bytes32 salt) public { - vm.assume(authorityOwner != address(0)); + function testRenounceOwnership(address authorityOwner, uint256 epochLength) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + _testRenounceOwnership(authority); + } - vm.expectRevert("epoch length must not be zero"); - _factory.newAuthority(authorityOwner, 0); + function testUnauthorizedAccount(address authorityOwner, uint256 epochLength) + external + { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + _testUnauthorizedAccount(authority); + } - vm.expectRevert("epoch length must not be zero"); - _factory.newAuthority(authorityOwner, 0, salt); + function testInvalidOwner(address authorityOwner, uint256 epochLength) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + _testInvalidOwner(authority); } - function testNewAuthority(address authorityOwner, uint256 epochLength) public { - vm.assume(authorityOwner != address(0)); - vm.assume(epochLength > 0); + function testTransferOwnership(address authorityOwner, uint256 epochLength) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + _testTransferOwnership(authority); + } - vm.recordLogs(); + function testSubmitClaimRevertsOwnableUnauthorizedAccount( + address authorityOwner, + uint256 epochLength, + Claim memory claim + ) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + claim.appContract = _newActiveAppMock(); - IAuthority authority = _factory.newAuthority(authorityOwner, epochLength); + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); - _testNewAuthorityAux(authorityOwner, epochLength, authority); + address nonAuthorityOwner = _randomAddressDifferentFromZeroAnd(authorityOwner); + + vm.expectRevert(_encodeOwnableUnauthorizedAccount(nonAuthorityOwner)); + vm.prank(nonAuthorityOwner); + authority.submitClaim(claim); } - function testNewAuthorityDeterministic( + function testSubmitClaimRevertsNotEpochFinalBlock( address authorityOwner, uint256 epochLength, - bytes32 salt - ) public { - vm.assume(authorityOwner != address(0)); - vm.assume(epochLength > 0); + Claim memory claim + ) external { + uint256 lastProcessedBlockNumber = _randomNonEpochFinalBlock(epochLength); - address precalculatedAddress = - _factory.calculateAuthorityAddress(authorityOwner, epochLength, salt); + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + claim.appContract = _newActiveAppMock(); + + claim.lastProcessedBlockNumber = lastProcessedBlockNumber; + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeNotEpochFinalBlock(lastProcessedBlockNumber, epochLength)); + vm.prank(authorityOwner); + authority.submitClaim(claim); + } + + function testSubmitClaimRevertNotPastBlock( + address authorityOwner, + uint256 epochLength, + Claim memory claim + ) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + claim.appContract = _newActiveAppMock(); + + // Adjust the lastProcessedBlockNumber but do not roll past it. + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + + vm.expectRevert(_encodeNotPastBlock(claim.lastProcessedBlockNumber)); + vm.prank(authorityOwner); + authority.submitClaim(claim); + } + + function testSubmitClaimRevertApplicationNotDeployed( + address authorityOwner, + uint256 epochLength, + Claim memory claim + ) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + // We use a random account with no code as app contract + claim.appContract = _randomAccountWithNoCode(); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeApplicationNotDeployed(claim.appContract)); + vm.prank(authorityOwner); + authority.submitClaim(claim); + } + + function testSubmitClaimRevertApplicationReverted( + address authorityOwner, + uint256 epochLength, + Claim memory claim, + bytes memory error + ) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + // We make isForeclosed() revert with an error + claim.appContract = _newAppMockReverts(error); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeApplicationReverted(claim.appContract, error)); + vm.prank(authorityOwner); + authority.submitClaim(claim); + } + + function testSubmitClaimRevertApplicationReturnIllSizedReturnData( + address authorityOwner, + uint256 epochLength, + Claim memory claim, + bytes memory data + ) external { + // We make isForeclosed() return ill-sized data + vm.assume(data.length != 32); + + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + claim.appContract = _newAppMockReturns(data); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeIllformedApplicationReturnData(claim.appContract, data)); + vm.prank(authorityOwner); + authority.submitClaim(claim); + } + + function testSubmitClaimRevertApplicationReturnIllFormedReturnData( + address authorityOwner, + uint256 epochLength, + Claim memory claim, + uint256 returnValue + ) external { + // We make isForeclosed() return an invalid boolean (neither 0 or 1) + vm.assume(returnValue > 1); + + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + bytes memory data = abi.encode(returnValue); + claim.appContract = _newAppMockReturns(data); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeIllformedApplicationReturnData(claim.appContract, data)); + vm.prank(authorityOwner); + authority.submitClaim(claim); + } + + function testSubmitClaimRevertApplicationForeclosed( + address authorityOwner, + uint256 epochLength, + Claim memory claim + ) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + // We make isForeclosed() return true + claim.appContract = _newForeclosedAppMock(); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeApplicationForeclosed(claim.appContract)); + vm.prank(authorityOwner); + authority.submitClaim(claim); + } + + function testSubmitClaim( + address authorityOwner, + uint256 epochLength, + Claim memory claim + ) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + claim.appContract = _newActiveAppMock(); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + uint256 totalNumOfSubmittedClaimsBefore = authority.getNumberOfSubmittedClaims(); + uint256 totalNumOfAcceptedClaimsBefore = authority.getNumberOfAcceptedClaims(); vm.recordLogs(); - IAuthority authority = _factory.newAuthority(authorityOwner, epochLength, salt); + vm.prank(authorityOwner); + authority.submitClaim(claim); - _testNewAuthorityAux(authorityOwner, epochLength, authority); + Vm.Log[] memory logs = vm.getRecordedLogs(); - // Precalculated address must match actual address - assertEq(precalculatedAddress, address(authority)); + uint256 numOfClaimSubmittedEvents; + uint256 numOfClaimAcceptedEvents; - precalculatedAddress = - _factory.calculateAuthorityAddress(authorityOwner, epochLength, salt); + for (uint256 j; j < logs.length; ++j) { + Vm.Log memory log = logs[j]; + if (log.emitter == address(authority)) { + assertGe(log.topics.length, 1, "unexpected annonymous event"); + bytes32 topic0 = log.topics[0]; + if (topic0 == IConsensus.ClaimSubmitted.selector) { + (uint256 arg0, bytes32 arg1) = + abi.decode(log.data, (uint256, bytes32)); + assertEq(log.topics[1], authorityOwner.asTopic()); + assertEq(log.topics[2], claim.appContract.asTopic()); + assertEq(arg0, claim.lastProcessedBlockNumber); + assertEq(arg1, claim.outputsMerkleRoot); + ++numOfClaimSubmittedEvents; + } else if (topic0 == IConsensus.ClaimAccepted.selector) { + (uint256 arg0, bytes32 arg1) = + abi.decode(log.data, (uint256, bytes32)); + assertEq(log.topics[1], claim.appContract.asTopic()); + assertEq(arg0, claim.lastProcessedBlockNumber); + assertEq(arg1, claim.outputsMerkleRoot); + ++numOfClaimAcceptedEvents; + } else { + revert("unexpected event selector"); + } + } else { + revert("unexpected log emitter"); + } + } + + assertEq(numOfClaimSubmittedEvents, 1, "expected 1 ClaimSubmitted event"); + assertEq(numOfClaimAcceptedEvents, 1, "expected 1 ClaimAccepted event"); + + assertTrue( + authority.isOutputsMerkleRootValid( + claim.appContract, claim.outputsMerkleRoot + ), + "Once a claim is accepted, the outputs Merkle root is valid" + ); + + assertEq( + authority.getNumberOfSubmittedClaims(), + totalNumOfSubmittedClaimsBefore + numOfClaimSubmittedEvents, + "Total number of submitted claims should be increased by number of events" + ); - // Precalculated address must STILL match actual address - assertEq(precalculatedAddress, address(authority)); + assertEq( + authority.getNumberOfAcceptedClaims(), + totalNumOfAcceptedClaimsBefore + numOfClaimAcceptedEvents, + "Total number of accepted claims should be increased by number of events" + ); - // Cannot deploy an authority with the same salt twice - vm.expectRevert(); - _factory.newAuthority(authorityOwner, epochLength, salt); + vm.expectRevert( + _encodeNotFirstClaim(claim.appContract, claim.lastProcessedBlockNumber) + ); + vm.prank(authorityOwner); + authority.submitClaim( + claim.appContract, claim.lastProcessedBlockNumber, bytes32(vm.randomUint()) + ); } - function _testNewAuthorityAux( + function _testNewAuthoritySuccess( address authorityOwner, uint256 epochLength, - IAuthority authority + bytes4 interfaceId, + IAuthority authority, + Vm.Log[] memory logs ) internal { - Vm.Log[] memory entries = vm.getRecordedLogs(); - uint256 numOfAuthorityCreated; + uint256 numOfOwnershipTransferred; - for (uint256 i; i < entries.length; ++i) { - Vm.Log memory entry = entries[i]; + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory log = logs[i]; + if (log.emitter == address(_factory)) { + bytes32 topic0 = log.topics[0]; + if (topic0 == IAuthorityFactory.AuthorityCreated.selector) { + ++numOfAuthorityCreated; + address authorityAddress = abi.decode(log.data, (address)); + assertEq(address(authority), authorityAddress); + } else { + revert("unexpected log topic #0"); + } + } else if (log.emitter == address(authority)) { + bytes32 topic0 = log.topics[0]; + if (topic0 == Ownable.OwnershipTransferred.selector) { + ++numOfOwnershipTransferred; + assertEq(log.topics[1], address(0).asTopic()); + assertEq(log.topics[2], authorityOwner.asTopic()); + } else { + revert("unexpected log topic #0"); + } + } else { + revert("unexpected log"); + } + } - if ( - entry.emitter == address(_factory) - && entry.topics[0] == IAuthorityFactory.AuthorityCreated.selector - ) { - ++numOfAuthorityCreated; + assertEq(numOfAuthorityCreated, 1, "number of AuthorityCreated events"); + assertEq(numOfOwnershipTransferred, 1, "number of OwnershipTransferred events"); - address authorityAddress = abi.decode(entry.data, (address)); + assertEq(authority.owner(), authorityOwner, "owner() == authorityOwner"); + assertNotEq(authorityOwner, address(0), "owner() != address(0)"); + + assertEq( + authority.getEpochLength(), epochLength, "getEpochLength() == epochLength" + ); + assertGt(epochLength, 0, "getEpochLength() > 0"); - assertEq(address(authority), authorityAddress); + // We check that initially all outputs Merkle roots are invalid. + assertFalse( + authority.isOutputsMerkleRootValid( + vm.randomAddress(), bytes32(vm.randomUint()) + ), + "initially, isOutputsMerkleRootValid(...) == false" + ); + + // Also, initially, no `ClaimSubmitted` or `ClaimAccepted` were emitted. + assertEq( + authority.getNumberOfSubmittedClaims(), + 0, + "initially, getNumberOfSubmittedClaims() == 0" + ); + assertEq( + authority.getNumberOfAcceptedClaims(), + 0, + "initially, getNumberOfAcceptedClaims() == 0" + ); + + // Test ERC-165 interface + _testSupportsInterface(authority, interfaceId); + } + + function _testNewAuthorityFailure( + address authorityOwner, + uint256 epochLength, + bytes memory error + ) internal pure { + assertGe(error.length, 4, "Error data too short (no 4-byte selector)"); + + // forge-lint: disable-next-line(unsafe-typecast) + bytes4 errorSelector = bytes4(error); + bytes memory errorArgs = new bytes(error.length - 4); + + for (uint256 i; i < errorArgs.length; ++i) { + errorArgs[i] = error[i + 4]; + } + + if (errorSelector == Ownable.OwnableInvalidOwner.selector) { + address owner = abi.decode(errorArgs, (address)); + assertEq(owner, authorityOwner, "OwnableInvalidOwner.owner != owner"); + assertEq(owner, address(0), "OwnableInvalidOwner.owner != address(0)"); + } else if (errorSelector == bytes4(keccak256("Error(string)"))) { + string memory message = abi.decode(errorArgs, (string)); + if (keccak256(bytes(message)) == keccak256("epoch length must not be zero")) { + assertEq(epochLength, 0, "expected epoch length to be zero"); + } else { + revert("Unexpected error message"); } + } else { + revert("Unexpected error"); } + } - assertEq(numOfAuthorityCreated, 1); - assertEq(authority.owner(), authorityOwner); - assertEq(authority.getEpochLength(), epochLength); + function _newAuthority(address authorityOwner, uint256 epochLength) + internal + returns (IAuthority) + { + if (vm.randomBool()) { + vm.assumeNoRevert(); + return _factory.newAuthority(authorityOwner, epochLength); + } else { + bytes32 salt = bytes32(vm.randomUint()); + vm.assumeNoRevert(); + return _factory.newAuthority(authorityOwner, epochLength, salt); + } } } diff --git a/test/consensus/quorum/Quorum.t.sol b/test/consensus/quorum/Quorum.t.sol deleted file mode 100644 index fc555703..00000000 --- a/test/consensus/quorum/Quorum.t.sol +++ /dev/null @@ -1,639 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pragma solidity ^0.8.22; - -import {IERC165} from "@openzeppelin-contracts-5.2.0/utils/introspection/IERC165.sol"; - -import {IConsensus} from "src/consensus/IConsensus.sol"; -import {IQuorum} from "src/consensus/quorum/IQuorum.sol"; -import {Quorum} from "src/consensus/quorum/Quorum.sol"; - -import {ERC165Test} from "../../util/ERC165Test.sol"; -import {LibAddressArray} from "../../util/LibAddressArray.sol"; -import {LibTopic} from "../../util/LibTopic.sol"; - -import {Test} from "forge-std-1.9.6/src/Test.sol"; -import {Vm} from "forge-std-1.9.6/src/Vm.sol"; - -struct Claim { - address appContract; - uint256 lastProcessedBlockNumber; - bytes32 outputsMerkleRoot; -} - -library LibQuorum { - function numOfValidatorsInFavorOfAnyClaimInEpoch(IQuorum quorum, Claim memory claim) - internal - view - returns (uint256) - { - return quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( - claim.appContract, claim.lastProcessedBlockNumber - ); - } - - function isValidatorInFavorOfAnyClaimInEpoch( - IQuorum quorum, - Claim memory claim, - uint256 id - ) internal view returns (bool) { - return quorum.isValidatorInFavorOfAnyClaimInEpoch( - claim.appContract, claim.lastProcessedBlockNumber, id - ); - } - - function numOfValidatorsInFavorOf(IQuorum quorum, Claim memory claim) - internal - view - returns (uint256) - { - return quorum.numOfValidatorsInFavorOf( - claim.appContract, claim.lastProcessedBlockNumber, claim.outputsMerkleRoot - ); - } - - function isValidatorInFavorOf(IQuorum quorum, Claim memory claim, uint256 id) - internal - view - returns (bool) - { - return quorum.isValidatorInFavorOf( - claim.appContract, claim.lastProcessedBlockNumber, claim.outputsMerkleRoot, id - ); - } - - function submitClaim(IQuorum quorum, Claim memory claim) internal { - quorum.submitClaim( - claim.appContract, claim.lastProcessedBlockNumber, claim.outputsMerkleRoot - ); - } - - function isOutputsMerkleRootValid(IQuorum quorum, Claim memory claim) - internal - view - returns (bool) - { - return quorum.isOutputsMerkleRootValid(claim.appContract, claim.outputsMerkleRoot); - } -} - -contract QuorumTest is Test, ERC165Test { - using LibQuorum for IQuorum; - using LibAddressArray for address[]; - using LibAddressArray for Vm; - using LibTopic for address; - - IQuorum _quorum; - - function setUp() external { - _quorum = new Quorum(vm.addrs(3), 1); - } - - /// @inheritdoc ERC165Test - function _getErc165Contract() internal view override returns (IERC165) { - return _quorum; - } - - /// @inheritdoc ERC165Test - function _getSupportedInterfaces() internal pure override returns (bytes4[] memory) { - bytes4[] memory ifaces = new bytes4[](3); - ifaces[0] = type(IERC165).interfaceId; - ifaces[1] = type(IConsensus).interfaceId; - ifaces[2] = type(IQuorum).interfaceId; - return ifaces; - } - - function testConstructor(uint8 numOfValidators, uint256 epochLength) external { - vm.assume(epochLength > 0); - - address[] memory validators = vm.addrs(numOfValidators); - - IQuorum quorum = new Quorum(validators, epochLength); - - assertEq(quorum.numOfValidators(), numOfValidators); - assertEq(quorum.getEpochLength(), epochLength); - - for (uint256 i; i < numOfValidators; ++i) { - address validator = validators[i]; - uint256 id = quorum.validatorId(validator); - assertEq(quorum.validatorById(id), validator); - assertEq(id, i + 1); - } - } - - function testRevertsEpochLengthZero(uint8 numOfValidators) external { - vm.expectRevert("epoch length must not be zero"); - new Quorum(vm.addrs(numOfValidators), 0); - } - - function testConstructorIgnoresDuplicates(uint256 epochLength) external { - vm.assume(epochLength > 0); - - address[] memory validators = new address[](7); - - validators[0] = vm.addr(1); - validators[1] = vm.addr(2); - validators[2] = vm.addr(1); - validators[3] = vm.addr(3); - validators[4] = vm.addr(2); - validators[5] = vm.addr(1); - validators[6] = vm.addr(3); - - IQuorum quorum = new Quorum(validators, epochLength); - - assertEq(quorum.numOfValidators(), 3); - - for (uint256 i = 1; i <= 3; ++i) { - assertEq(quorum.validatorId(vm.addr(i)), i); - assertEq(quorum.validatorById(i), vm.addr(i)); - } - } - - function testValidatorId(uint8 numOfValidators, address addr, uint256 epochLength) - external - { - vm.assume(epochLength > 0); - - address[] memory validators = vm.addrs(numOfValidators); - - IQuorum quorum = new Quorum(validators, epochLength); - - uint256 id = quorum.validatorId(addr); - - if (validators.contains(addr)) { - assertLe(1, id); - assertLe(id, numOfValidators); - } else { - assertEq(id, 0); - } - } - - function testValidatorByIdZero(uint8 numOfValidators, uint256 epochLength) external { - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - assertEq(quorum.validatorById(0), address(0)); - } - - function testValidatorByIdValid( - uint8 numOfValidators, - uint256 id, - uint256 epochLength - ) external { - numOfValidators = uint8(bound(numOfValidators, 1, type(uint8).max)); - id = bound(id, 1, numOfValidators); - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - address validator = quorum.validatorById(id); - assertEq(quorum.validatorId(validator), id); - } - - function testValidatorByIdTooLarge( - uint8 numOfValidators, - uint256 id, - uint256 epochLength - ) external { - id = bound(id, uint256(numOfValidators) + 1, type(uint256).max); - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - assertEq(quorum.validatorById(id), address(0)); - } - - function testSubmitClaimRevertsNotValidator( - uint8 numOfValidators, - uint256 epochLength, - address caller, - Claim calldata claim - ) external { - vm.assume(epochLength > 0); - - address[] memory validators = vm.addrs(numOfValidators); - - IQuorum quorum = new Quorum(validators, epochLength); - - vm.assume(!validators.contains(caller)); - - vm.expectRevert("Quorum: caller is not validator"); - - vm.prank(caller); - quorum.submitClaim(claim); - } - - function testIsOutputsMerkleRootValid( - uint8 numOfValidators, - uint256 epochLength, - Claim calldata claim - ) external { - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - assertFalse(quorum.isOutputsMerkleRootValid(claim)); - } - - function testNumOfValidatorsInFavorOfAnyClaimInEpoch( - uint8 numOfValidators, - uint256 epochLength, - Claim calldata claim - ) external { - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - assertEq(quorum.numOfValidatorsInFavorOfAnyClaimInEpoch(claim), 0); - } - - function testIsValidatorInFavorOfAnyClaimInEpoch( - uint8 numOfValidators, - uint256 epochLength, - Claim calldata claim, - uint256 id - ) external { - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - assertFalse(quorum.isValidatorInFavorOfAnyClaimInEpoch(claim, id)); - } - - function testNumOfValidatorsInFavorOf( - uint8 numOfValidators, - uint256 epochLength, - Claim calldata claim - ) external { - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - assertEq(quorum.numOfValidatorsInFavorOf(claim), 0); - } - - function testIsValidatorInFavorOf( - uint8 numOfValidators, - uint256 epochLength, - Claim calldata claim, - uint256 id - ) external { - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - assertFalse(quorum.isValidatorInFavorOf(claim, id)); - } - - function testSubmitClaim( - uint8 numOfValidators, - uint256 epochLength, - uint256 epochNumber, - uint256 blockNumber, - Claim memory claim - ) external { - numOfValidators = uint8(bound(numOfValidators, 1, 7)); - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - - blockNumber = _boundBlockNumber(blockNumber, epochLength); - epochNumber = _boundEpochNumber(epochNumber, blockNumber, epochLength); - _boundClaim(claim, epochNumber, epochLength); - - vm.roll(blockNumber); - - bool[] memory inFavorOf = new bool[](numOfValidators + 1); - for (uint256 id = 1; id <= numOfValidators; ++id) { - _submitClaimAs(quorum, claim, id); - inFavorOf[id] = true; - _checkSubmitted(quorum, claim, inFavorOf); - } - } - - /// @notice Tests the storage of votes in bitmap format - /// @dev Each slot has 256 bits, one for each validator ID. - /// The first bit is skipped because validator IDs start from 1. - /// Therefore, validator ID 256 is the first to use a new slot. - function testSubmitClaim256( - uint256 epochLength, - uint256 epochNumber, - uint256 blockNumber, - Claim memory claim - ) external { - uint256 numOfValidators = 256; - - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - - blockNumber = _boundBlockNumber(blockNumber, epochLength); - epochNumber = _boundEpochNumber(epochNumber, blockNumber, epochLength); - _boundClaim(claim, epochNumber, epochLength); - - vm.roll(blockNumber); - - uint256 id = numOfValidators; - - _submitClaimAs(quorum, claim, id); - - assertTrue(quorum.isValidatorInFavorOf(claim, id)); - assertEq(quorum.numOfValidatorsInFavorOf(claim), 1); - } - - function testSubmitClaimNotFirstClaim( - uint8 numOfValidators, - uint256 epochLength, - uint256 epochNumber, - uint256 blockNumber, - Claim memory claim, - bytes32 outputsMerkleRoot2, - uint256 id - ) external { - vm.assume(epochLength >= 2); - vm.assume(claim.outputsMerkleRoot != outputsMerkleRoot2); - - numOfValidators = uint8(bound(numOfValidators, 1, type(uint8).max)); - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - id = bound(id, 1, numOfValidators); - - blockNumber = _boundBlockNumber(blockNumber, epochLength); - epochNumber = _boundEpochNumber(epochNumber, blockNumber, epochLength); - _boundClaim(claim, epochNumber, epochLength); - - vm.roll(blockNumber); - - vm.startPrank(quorum.validatorById(id)); - quorum.submitClaim(claim); - - // Re-submitting the same claim is OK - quorum.submitClaim(claim); - - assertEq(quorum.numOfValidatorsInFavorOfAnyClaimInEpoch(claim), 1); - assertEq(quorum.numOfValidatorsInFavorOf(claim), 1); - assertTrue(quorum.isValidatorInFavorOfAnyClaimInEpoch(claim, id)); - assertTrue(quorum.isValidatorInFavorOf(claim, id)); - - // Submitting a different claim for the same epoch isn't OK - claim.outputsMerkleRoot = outputsMerkleRoot2; - vm.expectRevert( - abi.encodeWithSelector( - IConsensus.NotFirstClaim.selector, - claim.appContract, - claim.lastProcessedBlockNumber - ) - ); - quorum.submitClaim(claim); - } - - function testSubmitClaimNotEpochFinalBlock( - uint8 numOfValidators, - uint256 epochLength, - uint256 epochNumber, - uint256 blockNumber, - uint256 blocksAfterEpochStart, - Claim memory claim, - uint256 id - ) external { - vm.assume(epochLength >= 2); - - numOfValidators = uint8(bound(numOfValidators, 1, type(uint8).max)); - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - id = bound(id, 1, numOfValidators); - - blocksAfterEpochStart = bound(blocksAfterEpochStart, 0, epochLength - 2); - blockNumber = bound(blockNumber, epochLength, type(uint256).max); - epochNumber = bound(epochNumber, 0, (blockNumber / epochLength) - 1); - claim.lastProcessedBlockNumber = epochNumber * epochLength + blocksAfterEpochStart; - - vm.roll(blockNumber); - - vm.prank(quorum.validatorById(id)); - - vm.expectRevert( - abi.encodeWithSelector( - IConsensus.NotEpochFinalBlock.selector, - claim.lastProcessedBlockNumber, - epochLength - ) - ); - - quorum.submitClaim(claim); - } - - function testSubmitClaimNotPastBlock( - uint8 numOfValidators, - uint256 epochLength, - uint256 epochNumber, - uint256 blockNumber, - Claim memory claim, - uint256 id - ) external { - vm.assume(epochLength >= 2); - - numOfValidators = uint8(bound(numOfValidators, 1, type(uint8).max)); - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - id = bound(id, 1, numOfValidators); - - uint256 maxEpochNumber = (type(uint256).max - (epochLength - 1)) / epochLength; - epochNumber = bound(epochNumber, 0, maxEpochNumber); - claim.lastProcessedBlockNumber = epochNumber * epochLength + (epochLength - 1); - blockNumber = bound(blockNumber, 0, claim.lastProcessedBlockNumber); - - vm.roll(blockNumber); - - vm.prank(quorum.validatorById(id)); - - vm.expectRevert( - abi.encodeWithSelector( - IConsensus.NotPastBlock.selector, - claim.lastProcessedBlockNumber, - blockNumber - ) - ); - - quorum.submitClaim(claim); - } - - function testMultipleClaimsCounters(bytes32[] calldata claims) external { - uint256 epochLength = 5; - uint256 numOfValidators = 3; - - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - - assertEq(quorum.getNumberOfSubmittedClaims(), 0); - - Claim memory claim; - claim.appContract = vm.addr(1); - - uint256 blockNum = epochLength; - vm.roll(blockNum); - - uint256 totalSubmissions; - - for (uint256 i = 0; i < claims.length; ++i) { - claim.lastProcessedBlockNumber = blockNum - 1; - claim.outputsMerkleRoot = claims[i]; - - // submit claim with majority validators - for (uint256 id = 1; id <= numOfValidators / 2 + 1; ++id) { - vm.prank(quorum.validatorById(id)); - quorum.submitClaim(claim); - ++totalSubmissions; - assertEq(quorum.getNumberOfSubmittedClaims(), totalSubmissions); - } - - assertTrue(quorum.isOutputsMerkleRootValid(claim)); - assertEq(quorum.getNumberOfAcceptedClaims(), i + 1); - - blockNum += epochLength; - vm.roll(blockNum); - } - } - - function testSubmittedClaimsCounterIgnoresSameValidatorResubmission( - uint8 numOfValidators, - uint256 epochLength, - uint256 epochNumber, - uint256 blockNumber, - Claim memory claim, - uint256 id - ) external { - numOfValidators = uint8(bound(numOfValidators, 1, type(uint8).max)); - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - id = bound(id, 1, numOfValidators); - - blockNumber = _boundBlockNumber(blockNumber, epochLength); - epochNumber = _boundEpochNumber(epochNumber, blockNumber, epochLength); - _boundClaim(claim, epochNumber, epochLength); - - vm.roll(blockNumber); - - assertEq(quorum.getNumberOfSubmittedClaims(), 0); - - vm.startPrank(quorum.validatorById(id)); - - // First submission - quorum.submitClaim(claim); - assertEq(quorum.getNumberOfSubmittedClaims(), 1); - - // Re-submitting the same claim by same validator is silently ignored - quorum.submitClaim(claim); - assertEq(quorum.getNumberOfSubmittedClaims(), 1); - - vm.stopPrank(); - } - - function testSubmittedClaimsCounterMaxIsNumOfValidators( - uint256 epochLength, - uint256 epochNumber, - uint256 blockNumber, - Claim memory claim - ) external { - uint256 numOfValidators = 5; - IQuorum quorum = _deployQuorum(numOfValidators, epochLength); - - blockNumber = _boundBlockNumber(blockNumber, epochLength); - epochNumber = _boundEpochNumber(epochNumber, blockNumber, epochLength); - _boundClaim(claim, epochNumber, epochLength); - - vm.roll(blockNumber); - - // Each different validator submitting increments the counter - for (uint256 id = 1; id <= numOfValidators; ++id) { - vm.prank(quorum.validatorById(id)); - quorum.submitClaim(claim); - assertEq(quorum.getNumberOfSubmittedClaims(), id); - } - - // Counter equals number of validators (max per epoch) - assertEq(quorum.getNumberOfSubmittedClaims(), numOfValidators); - } - - // Internal functions - // ------------------ - - function _deployQuorum(uint256 numOfValidators, uint256 epochLength) - internal - returns (IQuorum) - { - vm.assume(epochLength > 0); - return new Quorum(vm.addrs(numOfValidators), epochLength); - } - - function _checkSubmitted(IQuorum quorum, Claim memory claim, bool[] memory inFavorOf) - internal - view - { - uint256 inFavorCount; - uint256 numOfValidators = quorum.numOfValidators(); - - for (uint256 id = 1; id <= numOfValidators; ++id) { - assertEq(quorum.isValidatorInFavorOf(claim, id), inFavorOf[id]); - assertEq(quorum.isValidatorInFavorOfAnyClaimInEpoch(claim, id), inFavorOf[id]); - if (inFavorOf[id]) ++inFavorCount; - } - - assertEq(quorum.numOfValidatorsInFavorOf(claim), inFavorCount); - assertEq(quorum.numOfValidatorsInFavorOfAnyClaimInEpoch(claim), inFavorCount); - } - - function _submitClaimAs(IQuorum quorum, Claim memory claim, uint256 id) internal { - address validator = quorum.validatorById(id); - - vm.recordLogs(); - - vm.prank(validator); - quorum.submitClaim(claim); - - Vm.Log[] memory entries = vm.getRecordedLogs(); - - uint256 numOfSubmissions; - uint256 numOfAcceptances; - - for (uint256 i; i < entries.length; ++i) { - Vm.Log memory entry = entries[i]; - - if ( - entry.emitter == address(quorum) - && entry.topics[0] == IConsensus.ClaimSubmitted.selector - ) { - (uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) = - abi.decode(entry.data, (uint256, bytes32)); - - assertEq(entry.topics[1], validator.asTopic()); - assertEq(entry.topics[2], claim.appContract.asTopic()); - assertEq(lastProcessedBlockNumber, claim.lastProcessedBlockNumber); - assertEq(outputsMerkleRoot, claim.outputsMerkleRoot); - - ++numOfSubmissions; - } - - if ( - entry.emitter == address(quorum) - && entry.topics[0] == IConsensus.ClaimAccepted.selector - ) { - (uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) = - abi.decode(entry.data, (uint256, bytes32)); - - assertEq(entry.topics[1], claim.appContract.asTopic()); - assertEq(lastProcessedBlockNumber, claim.lastProcessedBlockNumber); - assertEq(outputsMerkleRoot, claim.outputsMerkleRoot); - - ++numOfAcceptances; - } - } - - assertEq(numOfSubmissions, 1); - - uint256 inFavorCount = quorum.numOfValidatorsInFavorOf(claim); - uint256 numOfValidators = quorum.numOfValidators(); - - if (inFavorCount == 1 + (numOfValidators / 2)) { - assertEq(numOfAcceptances, 1); - } else { - assertEq(numOfAcceptances, 0); - } - - assertEq( - quorum.isOutputsMerkleRootValid(claim), inFavorCount > (numOfValidators / 2) - ); - } - - function _boundBlockNumber(uint256 blockNumber, uint256 epochLength) - internal - pure - returns (uint256) - { - return bound(blockNumber, epochLength, type(uint256).max); - } - - function _boundEpochNumber( - uint256 epochNumber, - uint256 blockNumber, - uint256 epochLength - ) internal pure returns (uint256) { - return bound(epochNumber, 0, (blockNumber / epochLength) - 1); - } - - function _boundClaim(Claim memory claim, uint256 epochNumber, uint256 epochLength) - internal - pure - { - claim.lastProcessedBlockNumber = epochNumber * epochLength + (epochLength - 1); - } -} diff --git a/test/consensus/quorum/QuorumFactory.t.sol b/test/consensus/quorum/QuorumFactory.t.sol index da17622e..b937ee5f 100644 --- a/test/consensus/quorum/QuorumFactory.t.sol +++ b/test/consensus/quorum/QuorumFactory.t.sol @@ -1,111 +1,750 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 (see LICENSE) -/// @title Quorum Factory Test pragma solidity ^0.8.22; +import {IConsensus} from "src/consensus/IConsensus.sol"; import {IQuorum} from "src/consensus/quorum/IQuorum.sol"; import {IQuorumFactory} from "src/consensus/quorum/IQuorumFactory.sol"; import {QuorumFactory} from "src/consensus/quorum/QuorumFactory.sol"; +import {Claim} from "../../util/Claim.sol"; +import {ConsensusTestUtils} from "../../util/ConsensusTestUtils.sol"; +import {ERC165Test} from "../../util/ERC165Test.sol"; +import {LibAddressArray} from "../../util/LibAddressArray.sol"; +import {LibConsensus} from "../../util/LibConsensus.sol"; +import {LibMath} from "../../util/LibMath.sol"; +import {LibQuorum} from "../../util/LibQuorum.sol"; +import {LibTopic} from "../../util/LibTopic.sol"; +import {LibUint256Array} from "../../util/LibUint256Array.sol"; + import {Test} from "forge-std-1.9.6/src/Test.sol"; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; -import {LibAddressArray} from "../../util/LibAddressArray.sol"; - -contract QuorumFactoryTest is Test { +contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { + using LibAddressArray for address[]; using LibAddressArray for Vm; + using LibUint256Array for uint256[]; + using LibUint256Array for Vm; + using LibConsensus for IQuorum; + using LibQuorum for IQuorum; + using LibTopic for address; + using LibMath for uint256; - uint256 constant QUORUM_MAX_SIZE = 50; - QuorumFactory _factory; + IQuorumFactory _factory; + bytes4[] _supportedInterfaces; function setUp() public { _factory = new QuorumFactory(); + _supportedInterfaces.push(type(IConsensus).interfaceId); + _supportedInterfaces.push(type(IQuorum).interfaceId); + _registerSupportedInterfaces(_supportedInterfaces); + } + + function testNewQuorum( + address[] memory validators, + uint256 epochLength, + bytes4 interfaceId + ) public { + vm.recordLogs(); + + try _factory.newQuorum(validators, epochLength) returns (IQuorum quorum) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + _testNewQuorumSuccess(validators, epochLength, interfaceId, quorum, logs); + } catch (bytes memory error) { + _testNewQuorumFailure(validators, epochLength, error); + return; + } } - function testRevertsEpochLengthZero(uint256 seed, bytes32 salt) public { - uint256 numOfValidators = bound(seed, 1, QUORUM_MAX_SIZE); - address[] memory validators = vm.addrs(numOfValidators); + function testNewQuorumDeterministic( + address[] memory validators, + uint256 epochLength, + bytes4 interfaceId, + bytes32 salt + ) public { + address precalculatedAddress = _factory.calculateQuorumAddress( + validators, epochLength, salt + ); + + vm.recordLogs(); + + try _factory.newQuorum(validators, epochLength, salt) returns (IQuorum quorum) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + + assertEq( + precalculatedAddress, + address(quorum), + "calculateQuorumAddress(...) != newQuorum(...)" + ); - vm.expectRevert("epoch length must not be zero"); - _factory.newQuorum(validators, 0); + _testNewQuorumSuccess(validators, epochLength, interfaceId, quorum, logs); + } catch (bytes memory error) { + _testNewQuorumFailure(validators, epochLength, error); + return; + } - vm.expectRevert("epoch length must not be zero"); - _factory.newQuorum(validators, 0, salt); + assertEq( + _factory.calculateQuorumAddress(validators, epochLength, salt), + precalculatedAddress, + "calculateQuorumAddress(...) is not a pure function" + ); + + // Cannot deploy an application with the same salt twice + try _factory.newQuorum(validators, epochLength, salt) { + revert("second deterministic deployment did not revert"); + } catch (bytes memory error) { + assertEq( + error, + new bytes(0), + "second deterministic deployment did not revert with empty error data" + ); + } } - function testNewQuorum(uint256 seed, uint256 epochLength) public { - vm.assume(epochLength > 0); + function testSubmitClaimRevertsCallerIsNotValidator( + address[] memory validators, + uint256 epochLength, + Claim memory claim + ) external { + IQuorum quorum = _newQuorum(validators, epochLength); - uint256 numOfValidators = bound(seed, 1, QUORUM_MAX_SIZE); - address[] memory validators = vm.addrs(numOfValidators); + claim.appContract = _newActiveAppMock(); - vm.recordLogs(); + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert("Quorum: caller is not validator"); + vm.prank(vm.randomAddressNotIn(validators)); // non-validator address + quorum.submitClaim(claim); + } + + function testSubmitClaimRevertsNotEpochFinalBlock( + address[] memory validators, + uint256 epochLength, + Claim memory claim + ) external { + uint256 lastProcessedBlockNumber = _randomNonEpochFinalBlock(epochLength); + + IQuorum quorum = _newQuorum(validators, epochLength); + + claim.appContract = _newActiveAppMock(); - IQuorum quorum = _factory.newQuorum(validators, epochLength); + claim.lastProcessedBlockNumber = lastProcessedBlockNumber; + vm.roll(_randomUintGt(lastProcessedBlockNumber)); - _testNewQuorumAux(validators, epochLength, quorum); + vm.expectRevert(_encodeNotEpochFinalBlock(lastProcessedBlockNumber, epochLength)); + vm.prank(vm.randomAddressIn(validators)); + quorum.submitClaim(claim); } - function testNewQuorumDeterministic(uint256 seed, uint256 epochLength, bytes32 salt) - public - { - vm.assume(epochLength > 0); + function testSubmitClaimRevertNotPastBlock( + address[] memory validators, + uint256 epochLength, + Claim memory claim + ) external { + IQuorum quorum = _newQuorum(validators, epochLength); - uint256 numOfValidators = bound(seed, 1, QUORUM_MAX_SIZE); - address[] memory validators = vm.addrs(numOfValidators); + claim.appContract = _newActiveAppMock(); - address precalculatedAddress = - _factory.calculateQuorumAddress(validators, epochLength, salt); + // Adjust the lastProcessedBlockNumber but do not roll past it. + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); - vm.recordLogs(); + vm.expectRevert(_encodeNotPastBlock(claim.lastProcessedBlockNumber)); + vm.prank(vm.randomAddressIn(validators)); + quorum.submitClaim(claim); + } + + function testSubmitClaimRevertApplicationNotDeployed( + address[] memory validators, + uint256 epochLength, + Claim memory claim + ) external { + IQuorum quorum = _newQuorum(validators, epochLength); + + // We use a random account with no code as app contract + claim.appContract = _randomAccountWithNoCode(); - IQuorum quorum = _factory.newQuorum(validators, epochLength, salt); + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); - _testNewQuorumAux(validators, epochLength, quorum); + vm.expectRevert(_encodeApplicationNotDeployed(claim.appContract)); + vm.prank(vm.randomAddressIn(validators)); + quorum.submitClaim(claim); + } + + function testSubmitClaimRevertApplicationReverted( + address[] memory validators, + uint256 epochLength, + Claim memory claim, + bytes memory error + ) external { + IQuorum quorum = _newQuorum(validators, epochLength); + + // We make isForeclosed() revert with an error + claim.appContract = _newAppMockReverts(error); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeApplicationReverted(claim.appContract, error)); + vm.prank(vm.randomAddressIn(validators)); + quorum.submitClaim(claim); + } + + function testSubmitClaimRevertApplicationReturnIllSizedReturnData( + address[] memory validators, + uint256 epochLength, + Claim memory claim, + bytes memory data + ) external { + // We make isForeclosed() return ill-sized data + vm.assume(data.length != 32); + + IQuorum quorum = _newQuorum(validators, epochLength); + + claim.appContract = _newAppMockReturns(data); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeIllformedApplicationReturnData(claim.appContract, data)); + vm.prank(vm.randomAddressIn(validators)); + quorum.submitClaim(claim); + } - // Precalculated address must match actual address - assertEq(precalculatedAddress, address(quorum)); + function testSubmitClaimRevertApplicationReturnIllFormedReturnData( + address[] memory validators, + uint256 epochLength, + Claim memory claim, + uint256 returnValue + ) external { + // We make isForeclosed() return an invalid boolean (neither 0 or 1) + vm.assume(returnValue > 1); - precalculatedAddress = - _factory.calculateQuorumAddress(validators, epochLength, salt); + IQuorum quorum = _newQuorum(validators, epochLength); - // Precalculated address must STILL match actual address - assertEq(precalculatedAddress, address(quorum)); + bytes memory data = abi.encode(returnValue); + claim.appContract = _newAppMockReturns(data); - // Cannot deploy a quorum with the same salt twice - vm.expectRevert(); - _factory.newQuorum(validators, epochLength, salt); + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeIllformedApplicationReturnData(claim.appContract, data)); + vm.prank(vm.randomAddressIn(validators)); + quorum.submitClaim(claim); } - function _testNewQuorumAux( + function testSubmitClaimRevertApplicationForeclosed( address[] memory validators, uint256 epochLength, - IQuorum quorum + Claim memory claim + ) external { + IQuorum quorum = _newQuorum(validators, epochLength); + + // We make isForeclosed() return true + claim.appContract = _newForeclosedAppMock(); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + vm.expectRevert(_encodeApplicationForeclosed(claim.appContract)); + vm.prank(vm.randomAddressIn(validators)); + quorum.submitClaim(claim); + } + + function testSubmitClaim( + address[] memory validators, + uint256 epochLength, + bytes32 winningOutputsMerkleRoot + ) external { + IQuorum quorum = _newQuorum(validators, epochLength); + + address appContract = _newActiveAppMock(); + + uint256 lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(lastProcessedBlockNumber)); + + // Divide validators into three categories: + // - winners: they form a majority and vote on the same claim + // - losers: they form a minority and vote on other claims + // - non-voters: they also form a minority, but do not vote + uint256 numOfValidators = quorum.numOfValidators(); + uint256 majority = 1 + (numOfValidators / 2); + uint256 numOfWinners = vm.randomUint(majority, numOfValidators); + uint256 numOfNonWinners = numOfValidators - numOfWinners; + uint256 numOfLosers = vm.randomUint(0, numOfNonWinners); + uint256 numOfNonVoters = numOfNonWinners - numOfLosers; + + // Check relations between categories + assertEq(numOfValidators, numOfWinners + numOfLosers + numOfNonVoters); + assertEq(numOfNonWinners, numOfLosers + numOfNonVoters); + assertGt(numOfWinners, numOfNonWinners); + + // List validator IDs and shuffle them + uint256[] memory ids = LibUint256Array.sequence(1, numOfValidators); + vm.shuffleInPlace(ids); + assertEq(ids.length, numOfValidators); + + // Distribute validators between categories + uint256[] memory winnerIds; + uint256[] memory loserIds; + uint256[] memory nonVoterIds; + + { + uint256[] memory nonWinnerIds; + + (winnerIds, nonWinnerIds) = ids.split(numOfWinners); + (loserIds, nonVoterIds) = nonWinnerIds.split(numOfLosers); + + // Check lengths of ID arrays + // and number of validators in each category + assertEq(winnerIds.length, numOfWinners); + assertEq(nonWinnerIds.length, numOfNonWinners); + assertEq(loserIds.length, numOfLosers); + assertEq(nonVoterIds.length, numOfNonVoters); + } + + assertEq( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ), + 0, + "Expected no validator to be in favor of any claim in epoch" + ); + + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, winningOutputsMerkleRoot + ), + 0, + "Expected no validator to be in favor of the winning claim in epoch" + ); + + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, bytes32(vm.randomUint()) + ), + 0, + "Expected no validator to be in favor of any random claim in epoch" + ); + + uint256 numOfWinningVotes; + uint256 numOfLosingVotes; + bool wasClaimAccepted; + + for (uint256 i; i < ids.length; ++i) { + uint256 id = ids[i]; + + assertFalse( + quorum.isValidatorInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber, id + ), + "Expected validator to not be in favor of any claim in epoch" + ); + + assertFalse( + quorum.isValidatorInFavorOf( + appContract, lastProcessedBlockNumber, bytes32(vm.randomUint()), id + ), + "Expected validator to not be in favor of any random claim in epoch" + ); + + if (nonVoterIds.contains(id)) { + continue; // skip voting + } + + bytes32 outputsMerkleRoot; + + if (winnerIds.contains(id)) { + outputsMerkleRoot = winningOutputsMerkleRoot; + ++numOfWinningVotes; + } else if (loserIds.contains(id)) { + outputsMerkleRoot = _randomBytes32DifferentFrom(winningOutputsMerkleRoot); + ++numOfLosingVotes; + } else { + revert("unexpected validator category"); + } + + assertFalse( + quorum.isValidatorInFavorOf( + appContract, lastProcessedBlockNumber, outputsMerkleRoot, id + ), + "Expected validator to not be in favor of claim" + ); + + uint256 totalNumOfSubmittedClaimsBefore = quorum.getNumberOfSubmittedClaims(); + uint256 totalNumOfAcceptedClaimsBefore = quorum.getNumberOfAcceptedClaims(); + + uint256 numOfValidatorsInFavorOfAnyClaimInEpochBefore = + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ); + + uint256 numOfValidatorsInFavorOfClaimBefore = quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, outputsMerkleRoot + ); + + address validator = quorum.validatorById(id); + assertTrue(validators.contains(validator), "voter is not validator"); + + vm.recordLogs(); + + vm.prank(validator); + quorum.submitClaim(appContract, lastProcessedBlockNumber, outputsMerkleRoot); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + + uint256 numOfClaimSubmittedEvents; + uint256 numOfClaimAcceptedEvents; + + for (uint256 j; j < logs.length; ++j) { + Vm.Log memory log = logs[j]; + if (log.emitter == address(quorum)) { + assertGe(log.topics.length, 1, "unexpected annonymous event"); + bytes32 topic0 = log.topics[0]; + if (topic0 == IConsensus.ClaimSubmitted.selector) { + (uint256 arg0, bytes32 arg1) = + abi.decode(log.data, (uint256, bytes32)); + assertEq(log.topics[1], validator.asTopic()); + assertEq(log.topics[2], appContract.asTopic()); + assertEq(arg0, lastProcessedBlockNumber); + assertEq(arg1, outputsMerkleRoot); + ++numOfClaimSubmittedEvents; + } else if (topic0 == IConsensus.ClaimAccepted.selector) { + (uint256 arg0, bytes32 arg1) = + abi.decode(log.data, (uint256, bytes32)); + assertEq(log.topics[1], appContract.asTopic()); + assertEq(arg0, lastProcessedBlockNumber); + assertEq(arg1, outputsMerkleRoot); + ++numOfClaimAcceptedEvents; + } else { + revert("unexpected event selector"); + } + } else { + revert("unexpected log emitter"); + } + } + + assertEq(numOfClaimSubmittedEvents, 1, "expected 1 ClaimSubmitted event"); + + if (numOfWinningVotes == majority && !wasClaimAccepted) { + assertEq(numOfClaimAcceptedEvents, 1, "expected 1 ClaimAccepted event"); + wasClaimAccepted = true; + } else { + assertEq(numOfClaimAcceptedEvents, 0, "expected 0 ClaimAccepted events"); + } + + assertEq( + quorum.isOutputsMerkleRootValid(appContract, winningOutputsMerkleRoot), + numOfWinningVotes >= majority, + "Once a claim is accepted, the outputs Merkle root is valid" + ); + + assertEq( + quorum.getNumberOfSubmittedClaims(), + totalNumOfSubmittedClaimsBefore + numOfClaimSubmittedEvents, + "Total number of submitted claims should be increased by number of events" + ); + + assertEq( + quorum.getNumberOfAcceptedClaims(), + totalNumOfAcceptedClaimsBefore + numOfClaimAcceptedEvents, + "Total number of accepted claims should be increased by number of events" + ); + + assertEq( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ), + numOfValidatorsInFavorOfAnyClaimInEpochBefore + 1, + "Number of validators in favor of any claim in epoch should be incremented" + ); + + assertTrue( + quorum.isValidatorInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber, id + ), + "Expected validator to be in favor of any claim in epoch" + ); + + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, outputsMerkleRoot + ), + numOfValidatorsInFavorOfClaimBefore + 1, + "Number of validators in favor of claim should be incremented" + ); + + assertTrue( + quorum.isValidatorInFavorOf( + appContract, lastProcessedBlockNumber, outputsMerkleRoot, id + ), + "Expected validator to be in favor of claim" + ); + + vm.recordLogs(); + + vm.prank(validator); + quorum.submitClaim(appContract, lastProcessedBlockNumber, outputsMerkleRoot); + + assertEq( + vm.getRecordedLogs().length, + 0, + "submitClaim() expected to emit 0 events on subsequent call" + ); + + assertEq( + quorum.isOutputsMerkleRootValid(appContract, winningOutputsMerkleRoot), + numOfWinningVotes >= majority, + "Once a claim is accepted, the outputs Merkle root is valid" + ); + + assertEq( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ), + numOfValidatorsInFavorOfAnyClaimInEpochBefore + 1, + "Number of validators in favor of any claim in epoch should be incremented" + ); + + assertTrue( + quorum.isValidatorInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber, id + ), + "Expected validator to be in favor of any claim in epoch" + ); + + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, outputsMerkleRoot + ), + numOfValidatorsInFavorOfClaimBefore + 1, + "Number of validators in favor of claim should be incremented" + ); + + assertTrue( + quorum.isValidatorInFavorOf( + appContract, lastProcessedBlockNumber, outputsMerkleRoot, id + ), + "Expected validator to be in favor of claim" + ); + + vm.expectRevert(_encodeNotFirstClaim(appContract, lastProcessedBlockNumber)); + vm.prank(validator); + quorum.submitClaim( + appContract, + lastProcessedBlockNumber, + _randomBytes32DifferentFrom(outputsMerkleRoot) + ); + } + + assertEq(numOfWinningVotes, numOfWinners, "# winning votes == # winner voters"); + assertEq(numOfLosingVotes, numOfLosers, "# losing votes == # loser voters"); + + assertTrue(wasClaimAccepted, "unexpected ClaimAccepted event"); + + assertTrue( + quorum.isOutputsMerkleRootValid(appContract, winningOutputsMerkleRoot), + "The outputs Merkle root should be valid" + ); + + assertEq( + quorum.getNumberOfSubmittedClaims(), + numOfWinners + numOfLosers, + "# votes == # voters" + ); + + assertEq( + quorum.getNumberOfAcceptedClaims(), + 1, + "Expected only 1 claim to be accepted (the winning claim)" + ); + } + + function _testNewQuorumSuccess( + address[] memory validators, + uint256 epochLength, + bytes4 interfaceId, + IQuorum quorum, + Vm.Log[] memory logs ) internal { - Vm.Log[] memory entries = vm.getRecordedLogs(); - - uint256 numQuorumCreated; - for (uint256 i; i < entries.length; ++i) { - Vm.Log memory entry = entries[i]; - - if ( - entry.emitter == address(_factory) - && entry.topics[0] == IQuorumFactory.QuorumCreated.selector - ) { - ++numQuorumCreated; - IQuorum eventQuorum = abi.decode(entry.data, (IQuorum)); - assertEq(address(eventQuorum), address(quorum)); + uint256 numOfQuorumCreated; + + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory log = logs[i]; + if (log.emitter == address(_factory)) { + bytes32 topic0 = log.topics[0]; + if (topic0 == IQuorumFactory.QuorumCreated.selector) { + ++numOfQuorumCreated; + address quorumAddress = abi.decode(log.data, (address)); + assertEq(quorumAddress, address(quorum)); + } else { + revert("unexpected log topic #0"); + } + } else { + revert("unexpected log"); + } + } + + assertEq(numOfQuorumCreated, 1, "number of QuorumCreated events"); + + uint256 numOfValidators = quorum.numOfValidators(); + assertGt(numOfValidators, 0, "numOfValidators() > 0"); + + assertEq(quorum.getEpochLength(), epochLength, "getEpochLength() == epochLength"); + assertGt(epochLength, 0, "getEpochLength() > 0"); + + assertLe( + numOfValidators, + validators.length, + "Number of unique validators <= number of validators" + ); + + // We first check that every validator in the validators array + // has a unique ID and that this ID is assigned to them. + for (uint256 i; i < validators.length; ++i) { + address validator = validators[i]; + assertNotEq(validator, address(0), "Validators should be != address(0)"); + uint256 id = quorum.validatorId(validator); + assertGe(id, 1, "Validator ID should be >= 1"); + assertLe(id, numOfValidators, "Validator ID should be <= numOfValidators"); + assertEq(quorum.validatorById(id), validator, "Validator by ID should match"); + } + + // Then we check that every ID is assigned to a validator in the array. + // By the pidgenhole principle, this can already be assumed if the + // number of unique validators is less than or equal to the length + // of the original array. Nevertheless, we test this for redundancy. + for (uint256 id = 1; id <= numOfValidators; ++id) { + address validator = quorum.validatorById(id); + bool isValidatorInArray = false; + for (uint256 i; i < validators.length; ++i) { + if (validator == validators[i]) { + isValidatorInArray = true; + break; + } } + assertTrue(isValidatorInArray, "Validator not in array"); } - assertEq(numQuorumCreated, 1); - uint256 numOfValidators = validators.length; - assertEq(numOfValidators, quorum.numOfValidators()); - for (uint256 i; i < numOfValidators; ++i) { - assertEq(validators[i], quorum.validatorById(i + 1)); + // We check that zero address and zero ID map to each other. + assertEq(quorum.validatorId(address(0)), 0, "validatorId(address(0)) == 0"); + assertEq(quorum.validatorById(0), address(0), "validatorById(0) == address(0)"); + + // We check that non-validators are assigned ID zero. + assertEq( + quorum.validatorId(vm.randomAddressNotIn(validators)), + 0, + "for any non-validator addr, validatorId(addr) == 0" + ); + + // We check that invalid IDs map to the zero address. + assertEq( + quorum.validatorById(vm.randomUint(numOfValidators + 1, type(uint256).max)), + address(0), + "for any id > numOfValidators(), validatorById(id) == address(0)" + ); + + // We check that initially all outputs Merkle roots are invalid. + assertFalse( + quorum.isOutputsMerkleRootValid(vm.randomAddress(), bytes32(vm.randomUint())), + "initially, isOutputsMerkleRootValid(...) == false" + ); + + // We check that initially no validator is in favor of any claim in an epoch. + assertEq( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + vm.randomAddress(), vm.randomUint() + ), + 0, + "initially, numOfValidatorsInFavorOfAnyClaimInEpoch(...) == 0" + ); + assertEq( + quorum.numOfValidatorsInFavorOf( + vm.randomAddress(), vm.randomUint(), bytes32(vm.randomUint()) + ), + 0, + "initially, numOfValidatorsInFavorOfAnyClaimInEpoch(...) == 0" + ); + assertFalse( + quorum.isValidatorInFavorOfAnyClaimInEpoch( + vm.randomAddress(), vm.randomUint(), vm.randomUint() + ), + "initially, isValidatorInFavorOfAnyClaimInEpoch(...) == false" + ); + assertFalse( + quorum.isValidatorInFavorOf( + vm.randomAddress(), + vm.randomUint(), + bytes32(vm.randomUint()), + vm.randomUint() + ), + "initially, isValidatorInFavorOf(...) == false" + ); + + // Also, initially, no `ClaimSubmitted` or `ClaimAccepted` were emitted. + assertEq( + quorum.getNumberOfSubmittedClaims(), + 0, + "initially, getNumberOfSubmittedClaims() == 0" + ); + assertEq( + quorum.getNumberOfAcceptedClaims(), + 0, + "initially, getNumberOfAcceptedClaims() == 0" + ); + + // Test ERC-165 interface + _testSupportsInterface(quorum, interfaceId); + } + + function _testNewQuorumFailure( + address[] memory validators, + uint256 epochLength, + bytes memory error + ) internal pure { + assertGe(error.length, 4, "Error data too short (no 4-byte selector)"); + + // forge-lint: disable-next-line(unsafe-typecast) + bytes4 errorSelector = bytes4(error); + bytes memory errorArgs = new bytes(error.length - 4); + + for (uint256 i; i < errorArgs.length; ++i) { + errorArgs[i] = error[i + 4]; } - assertEq(epochLength, quorum.getEpochLength()); + if (errorSelector == bytes4(keccak256("Error(string)"))) { + string memory message = abi.decode(errorArgs, (string)); + bytes32 messageHash = keccak256(bytes(message)); + if (messageHash == keccak256("Quorum can't contain address(0)")) { + assertTrue( + validators.contains(address(0)), + "expected validators to contain address(0)" + ); + } else if (messageHash == keccak256("Quorum can't be empty")) { + assertEq(validators.length, 0, "expected validators to be empty"); + } else if (messageHash == keccak256("epoch length must not be zero")) { + assertEq(epochLength, 0, "expected epoch length to be zero"); + } else { + revert("Unexpected error message"); + } + } else { + revert("Unexpected error"); + } + } + + function _newQuorum(address[] memory validators, uint256 epochLength) + internal + returns (IQuorum) + { + if (vm.randomBool()) { + vm.assumeNoRevert(); + return _factory.newQuorum(validators, epochLength); + } else { + bytes32 salt = bytes32(vm.randomUint()); + vm.assumeNoRevert(); + return _factory.newQuorum(validators, epochLength, salt); + } } } diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index acf03aaa..8db0a07d 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.22; -import {IOwnable} from "src/access/IOwnable.sol"; import {DataAvailability} from "src/common/DataAvailability.sol"; import {OutputValidityProof} from "src/common/OutputValidityProof.sol"; import {Outputs} from "src/common/Outputs.sol"; @@ -17,7 +16,6 @@ import {SafeERC20Transfer} from "src/delegatecall/SafeERC20Transfer.sol"; import {IInputBox} from "src/inputs/IInputBox.sol"; import {InputBox} from "src/inputs/InputBox.sol"; -import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; import { IERC1155Errors, IERC20Errors, @@ -83,8 +81,20 @@ contract ApplicationTest is Test, OwnableTest, AddressGenerator { // ownable test // ------------ - function _getOwnableContract() internal view override returns (IOwnable) { - return _appContract; + function testRenounceOwnership(uint256) external { + _testRenounceOwnership(_appContract); + } + + function testUnauthorizedAccount(uint256) external { + _testUnauthorizedAccount(_appContract); + } + + function testInvalidOwner(uint256) external { + _testInvalidOwner(_appContract); + } + + function testTransferOwnership(uint256) external { + _testTransferOwnership(_appContract); } // --------------------------------------- @@ -97,9 +107,7 @@ contract ApplicationTest is Test, OwnableTest, AddressGenerator { ) external { vm.assume(caller != _appOwner); vm.startPrank(caller); - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller) - ); + vm.expectRevert(_encodeOwnableUnauthorizedAccount(caller)); _appContract.migrateToOutputsMerkleRootValidator(newOutputsMerkleRootValidator); } diff --git a/test/util/Claim.sol b/test/util/Claim.sol new file mode 100644 index 00000000..18e9350e --- /dev/null +++ b/test/util/Claim.sol @@ -0,0 +1,10 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +struct Claim { + address appContract; + uint256 lastProcessedBlockNumber; + bytes32 outputsMerkleRoot; +} diff --git a/test/util/ConsensusTestUtils.sol b/test/util/ConsensusTestUtils.sol new file mode 100644 index 00000000..4eb9bce3 --- /dev/null +++ b/test/util/ConsensusTestUtils.sol @@ -0,0 +1,93 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {IConsensus} from "src/consensus/IConsensus.sol"; + +import {ApplicationCheckerTestUtils} from "./ApplicationCheckerTestUtils.sol"; + +contract ConsensusTestUtils is ApplicationCheckerTestUtils { + function _encodeNotPastBlock(uint256 lastProcessedBlockNumber) + internal + view + returns (bytes memory) + { + return abi.encodeWithSelector( + IConsensus.NotPastBlock.selector, + lastProcessedBlockNumber, + vm.getBlockNumber() + ); + } + + function _encodeNotFirstClaim(address appContract, uint256 lastProcessedBlockNumber) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + IConsensus.NotFirstClaim.selector, appContract, lastProcessedBlockNumber + ); + } + + function _encodeNotEpochFinalBlock( + uint256 lastProcessedBlockNumber, + uint256 epochLength + ) internal pure returns (bytes memory) { + return abi.encodeWithSelector( + IConsensus.NotEpochFinalBlock.selector, lastProcessedBlockNumber, epochLength + ); + } + + function _maxEpochIndex(uint256 epochLength) internal pure returns (uint256) { + return (type(uint256).max - (epochLength - 1)) / epochLength; + } + + function _minFutureEpochIndex(uint256 epochLength) internal view returns (uint256) { + return (vm.getBlockNumber() + 1) / epochLength; + } + + function _randomFutureEpochIndex(uint256 epochLength) internal returns (uint256) { + return + vm.randomUint(_minFutureEpochIndex(epochLength), _maxEpochIndex(epochLength)); + } + + function _randomFutureEpochFinalBlockNumber(uint256 epochLength) + internal + returns (uint256) + { + return _randomFutureEpochIndex(epochLength) * epochLength + (epochLength - 1); + } + + function _randomUintGt(uint256 n) internal returns (uint256) { + vm.assume(n <= type(uint256).max - 1); + return vm.randomUint(n + 1, type(uint256).max); + } + + function _randomNonEpochFinalBlock(uint256 epochLength) internal returns (uint256) { + // If epochLength == 1, then forall x, (x % epochLength) == (epochLength - 1). + // That is, every block is an epoch final block, so we cannot sample a random + // non-epoch-final block. + vm.assume(epochLength >= 2); + + // Pick a random blockNumber that satisfies both + // - blockNumber % epochLength != (epochLength - 1) + // - blockNumber > currentBlockNumber + uint256 blockNumber = _randomUintGt(vm.getBlockNumber()); + vm.assume(blockNumber % epochLength != (epochLength - 1)); + + return blockNumber; + } + + function _randomBytes32DifferentFrom(bytes32 value) + internal + returns (bytes32 otherValue) + { + while (true) { + otherValue = bytes32(vm.randomUint()); + if (otherValue != value) { + break; + } + } + } +} diff --git a/test/util/ERC165Test.sol b/test/util/ERC165Test.sol index 61115805..da40ee9c 100644 --- a/test/util/ERC165Test.sol +++ b/test/util/ERC165Test.sol @@ -9,32 +9,41 @@ import {Test} from "forge-std-1.9.6/src/Test.sol"; /// @notice Tests contracts that implement ERC-165 abstract contract ERC165Test is Test { - /// @notice Get ERC-165 contract to be tested - function _getErc165Contract() internal virtual returns (IERC165); + /// @notice Whether the supported interfaces were registered. + bool private _supportedInterfacesRegistered; - /// @notice Get array of IDs of supported interfaces - function _getSupportedInterfaces() internal virtual returns (bytes4[] memory); + /// @notice Mapping between interface id and whether the interface is supported. + mapping(bytes4 => bool) private _isInterfaceSupported; - function testSupportsInterface() external { - IERC165 erc165 = _getErc165Contract(); - assertTrue(erc165.supportsInterface(type(IERC165).interfaceId)); - assertFalse(erc165.supportsInterface(0xffffffff)); + function _registerSupportedInterfaces(bytes4[] memory supportedInterfaces) internal { + require( + _supportedInterfacesRegistered == false, + "Supported interfaces already registered" + ); + + _isInterfaceSupported[type(IERC165).interfaceId] = true; - bytes4[] memory supportedInterfaces = _getSupportedInterfaces(); for (uint256 i; i < supportedInterfaces.length; ++i) { - assertTrue(erc165.supportsInterface(supportedInterfaces[i])); + _isInterfaceSupported[supportedInterfaces[i]] = true; } - } - function testSupportsInterface(bytes4 interfaceId) external { - vm.assume(interfaceId != type(IERC165).interfaceId); + assertFalse( + _isInterfaceSupported[0xffffffff], + "Interface ID 0xffffffff should not be supported under ERC-165" + ); - bytes4[] memory supportedInterfaces = _getSupportedInterfaces(); - for (uint256 i; i < supportedInterfaces.length; ++i) { - vm.assume(interfaceId != supportedInterfaces[i]); - } + _supportedInterfacesRegistered = true; + } - IERC165 erc165 = _getErc165Contract(); - assertFalse(erc165.supportsInterface(interfaceId)); + function _testSupportsInterface(IERC165 erc165, bytes4 interfaceId) internal view { + require( + _supportedInterfacesRegistered == true, + "Supported interfaces were not registered yet" + ); + assertEq( + erc165.supportsInterface(interfaceId), + _isInterfaceSupported[interfaceId], + "Interface ID support mismatch" + ); } } diff --git a/test/util/LibConsensus.sol b/test/util/LibConsensus.sol new file mode 100644 index 00000000..b0e15e4f --- /dev/null +++ b/test/util/LibConsensus.sol @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {IConsensus} from "src/consensus/IConsensus.sol"; + +import {Claim} from "./Claim.sol"; + +library LibConsensus { + function submitClaim(IConsensus consensus, Claim memory claim) internal { + consensus.submitClaim( + claim.appContract, claim.lastProcessedBlockNumber, claim.outputsMerkleRoot + ); + } + + function isOutputsMerkleRootValid(IConsensus consensus, Claim memory claim) + internal + view + returns (bool) + { + return consensus.isOutputsMerkleRootValid( + claim.appContract, claim.outputsMerkleRoot + ); + } +} diff --git a/test/util/LibQuorum.sol b/test/util/LibQuorum.sol new file mode 100644 index 00000000..446b213d --- /dev/null +++ b/test/util/LibQuorum.sol @@ -0,0 +1,50 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {IQuorum} from "src/consensus/quorum/IQuorum.sol"; + +import {Claim} from "./Claim.sol"; + +library LibQuorum { + function numOfValidatorsInFavorOfAnyClaimInEpoch(IQuorum quorum, Claim memory claim) + internal + view + returns (uint256) + { + return quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + claim.appContract, claim.lastProcessedBlockNumber + ); + } + + function isValidatorInFavorOfAnyClaimInEpoch( + IQuorum quorum, + Claim memory claim, + uint256 id + ) internal view returns (bool) { + return quorum.isValidatorInFavorOfAnyClaimInEpoch( + claim.appContract, claim.lastProcessedBlockNumber, id + ); + } + + function numOfValidatorsInFavorOf(IQuorum quorum, Claim memory claim) + internal + view + returns (uint256) + { + return quorum.numOfValidatorsInFavorOf( + claim.appContract, claim.lastProcessedBlockNumber, claim.outputsMerkleRoot + ); + } + + function isValidatorInFavorOf(IQuorum quorum, Claim memory claim, uint256 id) + internal + view + returns (bool) + { + return quorum.isValidatorInFavorOf( + claim.appContract, claim.lastProcessedBlockNumber, claim.outputsMerkleRoot, id + ); + } +} diff --git a/test/util/OwnableTest.sol b/test/util/OwnableTest.sol index 59f1f2f1..024154bc 100644 --- a/test/util/OwnableTest.sol +++ b/test/util/OwnableTest.sol @@ -3,92 +3,95 @@ pragma solidity ^0.8.22; -import {IOwnable} from "src/access/IOwnable.sol"; +import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; -import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {IOwnable} from "src/access/IOwnable.sol"; -abstract contract OwnableTest is Test { - /// @notice Get ownable contract to be tested - function _getOwnableContract() internal view virtual returns (IOwnable); +import {LibAddressArray} from "./LibAddressArray.sol"; - function testRenounceOwnership(address caller, address randomOwner) external { - vm.assume(caller != address(0)); - vm.assume(randomOwner != address(0)); +abstract contract OwnableTest is Test { + using LibAddressArray for Vm; - IOwnable ownable = _getOwnableContract(); + function _testRenounceOwnership(IOwnable ownable) internal { address owner = ownable.owner(); + address newOwner = _randomAddressDifferentFromZero(); + address caller = _randomAddressDifferentFromZero(); - vm.expectEmit(true, true, false, false); + vm.expectEmit(true, true, true, true, address(ownable), 1); emit Ownable.OwnershipTransferred(owner, address(0)); - vm.startPrank(owner); + vm.prank(owner); ownable.renounceOwnership(); - vm.stopPrank(); assertEq(ownable.owner(), address(0)); - vm.startPrank(caller); - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller) - ); - ownable.transferOwnership(randomOwner); - vm.stopPrank(); + vm.expectRevert(_encodeOwnableUnauthorizedAccount(caller)); + vm.prank(caller); + ownable.transferOwnership(newOwner); } - function testUnauthorizedAccount(address caller, address randomOwner) external { - vm.assume(randomOwner != address(0)); - - IOwnable ownable = _getOwnableContract(); + function _testUnauthorizedAccount(IOwnable ownable) internal { address owner = ownable.owner(); + address newOwner = _randomAddressDifferentFromZero(); + address caller = _randomAddressDifferentFromZeroAnd(owner); - vm.assume(caller != owner); - - vm.startPrank(caller); - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller) - ); - ownable.transferOwnership(randomOwner); - vm.stopPrank(); + vm.expectRevert(_encodeOwnableUnauthorizedAccount(caller)); + vm.prank(caller); + ownable.transferOwnership(newOwner); } - function testInvalidOwner() external { - IOwnable ownable = _getOwnableContract(); + function _testInvalidOwner(IOwnable ownable) internal { address owner = ownable.owner(); - vm.startPrank(owner); - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)) - ); + vm.expectRevert(_encodeOwnableInvalidOwner()); + vm.prank(owner); ownable.transferOwnership(address(0)); - vm.stopPrank(); } - function testTransferOwnership(address newOwner, address caller, address randomOwner) - external - { - vm.assume(newOwner != address(0)); - vm.assume(caller != newOwner); - vm.assume(randomOwner != address(0)); - - IOwnable ownable = _getOwnableContract(); + function _testTransferOwnership(IOwnable ownable) internal { address owner = ownable.owner(); + address newOwner = _randomAddressDifferentFromZero(); + address anotherNewOwner = _randomAddressDifferentFromZero(); + address caller = _randomAddressDifferentFromZeroAnd(newOwner); vm.expectEmit(true, true, false, false); emit Ownable.OwnershipTransferred(owner, newOwner); - vm.startPrank(owner); + vm.prank(owner); ownable.transferOwnership(newOwner); - vm.stopPrank(); assertEq(ownable.owner(), newOwner); - vm.startPrank(caller); - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller) - ); - ownable.transferOwnership(randomOwner); - vm.stopPrank(); + vm.expectRevert(_encodeOwnableUnauthorizedAccount(caller)); + vm.prank(caller); + ownable.transferOwnership(anotherNewOwner); + } + + function _randomAddressDifferentFromZero() internal returns (address) { + address[] memory disallowList = new address[](1); + disallowList[0] = address(0); + return vm.randomAddressNotIn(disallowList); + } + + function _randomAddressDifferentFromZeroAnd(address addr) internal returns (address) { + address[] memory disallowList = new address[](2); + disallowList[0] = address(0); + disallowList[1] = addr; + return vm.randomAddressNotIn(disallowList); + } + + function _encodeOwnableInvalidOwner() internal pure returns (bytes memory) { + return abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)); + } + + function _encodeOwnableUnauthorizedAccount(address caller) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller); } } diff --git a/test/util/SimpleApplicationForeclosure.sol b/test/util/SimpleApplicationForeclosure.sol deleted file mode 100644 index ee731483..00000000 --- a/test/util/SimpleApplicationForeclosure.sol +++ /dev/null @@ -1,25 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pragma solidity ^0.8.22; - -import {IApplicationForeclosure} from "src/dapp/IApplicationForeclosure.sol"; - -contract SimpleApplicationForeclosure is IApplicationForeclosure { - address immutable GUARDIAN; - bool public isForeclosed; - - constructor(address guardian) { - GUARDIAN = guardian; - } - - function foreclose() external override { - require(msg.sender == getGuardian(), NotGuardian()); - isForeclosed = true; - emit Foreclosure(); - } - - function getGuardian() public view override returns (address) { - return GUARDIAN; - } -} From 6e6d0deadeaf60c611cf758e4c612154b9c31505 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 1 Mar 2026 07:27:18 -0300 Subject: [PATCH 23/48] Add `cartesi/machine-solidity-step` as dependency --- foundry.toml | 2 ++ soldeer.lock | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/foundry.toml b/foundry.toml index 9989b3c2..ae8686f0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,6 +7,7 @@ optimizer = true evm_version = "prague" remappings = [ "@openzeppelin-contracts-5.2.0/=dependencies/@openzeppelin-contracts-5.2.0/", + "cartesi-machine-solidity-step-0.13.0/=dependencies/cartesi-machine-solidity-step-0.13.0/", "forge-std-1.9.6/=dependencies/forge-std-1.9.6/", ] fs_permissions = [{ access = "read-write", path = "deployments" }] @@ -29,5 +30,6 @@ remappings_location = "config" [dependencies] "@openzeppelin-contracts" = "5.2.0" forge-std = "1.9.6" +cartesi-machine-solidity-step = { version = "0.13.0", git = "https://github.com/cartesi/machine-solidity-step.git", tag = "v0.13.0" } # See more config options https://book.getfoundry.sh/reference/config/ diff --git a/soldeer.lock b/soldeer.lock index 58c4a609..9436919a 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -5,6 +5,12 @@ url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/5_2_0_ checksum = "6dbd0440446b2ed16ca25e9f1af08fc0c5c1e73e71fee86ae8a00daa774e3817" integrity = "4cb7f3777f67fdf4b7d0e2f94d2f93f198b2e5dce718b7062ac7c2c83e1183bd" +[[dependencies]] +name = "cartesi-machine-solidity-step" +version = "0.13.0" +git = "https://github.com/cartesi/machine-solidity-step.git" +rev = "97fbc294dc5f816f0349fc1291456c9a06091669" + [[dependencies]] name = "forge-std" version = "1.9.6" From 424f36223aaf69d5d35b853b54aef80773ffb5cf Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Sun, 1 Mar 2026 10:56:41 -0300 Subject: [PATCH 24/48] Add proof parameter to `submitClaim` function --- src/consensus/AbstractConsensus.sol | 62 +++++++- src/consensus/IConsensus.sol | 19 ++- src/consensus/authority/Authority.sol | 17 ++- src/consensus/quorum/IQuorum.sol | 8 +- src/consensus/quorum/Quorum.sol | 36 +++-- .../authority/AuthorityFactory.t.sol | 63 ++++++-- test/consensus/quorum/QuorumFactory.t.sol | 136 +++++++++++++----- test/dapp/Application.t.sol | 7 +- test/util/Claim.sol | 1 + test/util/ConsensusTestUtils.sol | 54 ++++++- test/util/LibClaim.sol | 29 ++++ test/util/LibConsensus.sol | 5 +- test/util/LibQuorum.sol | 50 ------- 13 files changed, 354 insertions(+), 133 deletions(-) create mode 100644 test/util/LibClaim.sol delete mode 100644 test/util/LibQuorum.sol diff --git a/src/consensus/AbstractConsensus.sol b/src/consensus/AbstractConsensus.sol index 8d4fe083..c77ddcaf 100644 --- a/src/consensus/AbstractConsensus.sol +++ b/src/consensus/AbstractConsensus.sol @@ -6,12 +6,20 @@ pragma solidity ^0.8.26; import {ERC165} from "@openzeppelin-contracts-5.2.0/utils/introspection/ERC165.sol"; import {IERC165} from "@openzeppelin-contracts-5.2.0/utils/introspection/IERC165.sol"; +import { + EmulatorConstants +} from "cartesi-machine-solidity-step-0.13.0/src/EmulatorConstants.sol"; +import {Memory} from "cartesi-machine-solidity-step-0.13.0/src/Memory.sol"; + import {ApplicationChecker} from "../dapp/ApplicationChecker.sol"; +import {LibMerkle32} from "../library/LibMerkle32.sol"; import {IConsensus} from "./IConsensus.sol"; import {IOutputsMerkleRootValidator} from "./IOutputsMerkleRootValidator.sol"; /// @notice Abstract implementation of IConsensus abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { + using LibMerkle32 for bytes32[]; + /// @notice The epoch length uint256 immutable EPOCH_LENGTH; @@ -91,17 +99,24 @@ abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { /// @param submitter The submitter address /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block - /// @param outputsMerkleRoot The output Merkle root hash + /// @param outputsMerkleRoot The output Merkle root + /// @param machineMerkleRoot The machine Merkle root + /// @dev Assumes outputs Merkle root is proven to be at the start of the machine TX buffer. /// @dev Checks whether the app is foreclosed. /// @dev Emits a `ClaimSubmitted` event. function _submitClaim( address submitter, address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 outputsMerkleRoot, + bytes32 machineMerkleRoot ) internal notForeclosed(appContract) { emit ClaimSubmitted( - submitter, appContract, lastProcessedBlockNumber, outputsMerkleRoot + submitter, + appContract, + lastProcessedBlockNumber, + outputsMerkleRoot, + machineMerkleRoot ); ++_numOfSubmittedClaims; } @@ -109,17 +124,52 @@ abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { /// @notice Accept a claim. /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block - /// @param outputsMerkleRoot The output Merkle root hash + /// @param outputsMerkleRoot The output Merkle root + /// @param machineMerkleRoot The machine Merkle root + /// @dev Assumes outputs Merkle root is proven to be at the start of the machine TX buffer. /// @dev Checks whether the app is foreclosed. /// @dev Marks the outputsMerkleRoot as valid. /// @dev Emits a `ClaimAccepted` event. function _acceptClaim( address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 outputsMerkleRoot, + bytes32 machineMerkleRoot ) internal notForeclosed(appContract) { _validOutputsMerkleRoots[appContract][outputsMerkleRoot] = true; - emit ClaimAccepted(appContract, lastProcessedBlockNumber, outputsMerkleRoot); + emit ClaimAccepted( + appContract, lastProcessedBlockNumber, outputsMerkleRoot, machineMerkleRoot + ); ++_numOfAcceptedClaims; } + + /// @notice Compute the machine Merkle root given an outputs Merkle root and a proof. + /// @param outputsMerkleRoot The outputs Merkle root + /// @param proof The bottom-up Merkle proof of the outputs Merkle root at the start of the machine TX buffer + /// @return machineMerkleRoot The machine Merkle root + function _computeMachineMerkleRoot( + bytes32 outputsMerkleRoot, + bytes32[] calldata proof + ) internal pure returns (bytes32 machineMerkleRoot) { + _checkProofSize(proof.length, Memory.LOG2_MAX_SIZE); + machineMerkleRoot = proof.merkleRootAfterReplacement( + EmulatorConstants.PMA_CMIO_TX_BUFFER_START + >> EmulatorConstants.TREE_LOG2_WORD_SIZE, + keccak256(abi.encode(outputsMerkleRoot)) + ); + } + + /// @notice Check the size of a supplied proof against the expected proof size. + /// @param suppliedProofSize Supplied proof size + /// @param expectedProofSize Expected proof size + /// @dev Raises an `InvalidOutputsMerkleRootProofSize` error if sizes differ. + function _checkProofSize(uint256 suppliedProofSize, uint256 expectedProofSize) + internal + pure + { + require( + suppliedProofSize == expectedProofSize, + InvalidOutputsMerkleRootProofSize(suppliedProofSize, expectedProofSize) + ); + } } diff --git a/src/consensus/IConsensus.sol b/src/consensus/IConsensus.sol index 7ad67933..ff37f566 100644 --- a/src/consensus/IConsensus.sol +++ b/src/consensus/IConsensus.sol @@ -28,23 +28,27 @@ interface IConsensus is IOutputsMerkleRootValidator, IApplicationChecker { /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block /// @param outputsMerkleRoot The outputs Merkle root + /// @param machineStateRoot The machine state root event ClaimSubmitted( address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 outputsMerkleRoot, + bytes32 machineStateRoot ); /// @notice MUST trigger when a claim is accepted. /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block /// @param outputsMerkleRoot The outputs Merkle root + /// @param machineStateRoot The machine state root /// @dev For each application and lastProcessedBlockNumber, /// there can be at most one accepted claim. event ClaimAccepted( address indexed appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 outputsMerkleRoot, + bytes32 machineStateRoot ); /// @notice The claim contains the number of a block that is not @@ -64,16 +68,25 @@ interface IConsensus is IOutputsMerkleRootValidator, IApplicationChecker { /// @param lastProcessedBlockNumber The number of the last processed block error NotFirstClaim(address appContract, uint256 lastProcessedBlockNumber); + /// @notice Supplied output tree proof size is incorrect + /// @param suppliedProofSize Supplied proof size + /// @param expectedProofSize Expected proof size + error InvalidOutputsMerkleRootProofSize( + uint256 suppliedProofSize, uint256 expectedProofSize + ); + /// @notice Submit a claim to the consensus. /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block /// @param outputsMerkleRoot The outputs Merkle root + /// @param proof The bottom-up Merkle proof of the outputs Merkle root at the start of the machine TX buffer /// @dev MUST fire a `ClaimSubmitted` event. /// @dev MAY fire a `ClaimAccepted` event, if the acceptance criteria is met. function submitClaim( address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 outputsMerkleRoot, + bytes32[] calldata proof ) external; /// @notice Get the epoch length, in number of base layer blocks. diff --git a/src/consensus/authority/Authority.sol b/src/consensus/authority/Authority.sol index 0b8c3bc7..af220200 100644 --- a/src/consensus/authority/Authority.sol +++ b/src/consensus/authority/Authority.sol @@ -34,7 +34,8 @@ contract Authority is IAuthority, AbstractConsensus, Ownable { function submitClaim( address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 outputsMerkleRoot, + bytes32[] calldata proof ) external override onlyOwner { _validateLastProcessedBlockNumber(lastProcessedBlockNumber); @@ -46,9 +47,19 @@ contract Authority is IAuthority, AbstractConsensus, Ownable { !bitmap.get(epochNumber), NotFirstClaim(appContract, lastProcessedBlockNumber) ); - _submitClaim(msg.sender, appContract, lastProcessedBlockNumber, outputsMerkleRoot); + bytes32 machineMerkleRoot = _computeMachineMerkleRoot(outputsMerkleRoot, proof); - _acceptClaim(appContract, lastProcessedBlockNumber, outputsMerkleRoot); + _submitClaim( + msg.sender, + appContract, + lastProcessedBlockNumber, + outputsMerkleRoot, + machineMerkleRoot + ); + + _acceptClaim( + appContract, lastProcessedBlockNumber, outputsMerkleRoot, machineMerkleRoot + ); bitmap.set(epochNumber); } diff --git a/src/consensus/quorum/IQuorum.sol b/src/consensus/quorum/IQuorum.sol index 094b12f4..c6f9dc32 100644 --- a/src/consensus/quorum/IQuorum.sol +++ b/src/consensus/quorum/IQuorum.sol @@ -53,25 +53,25 @@ interface IQuorum is IConsensus { /// @notice Get the number of validators in favor of a claim. /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block - /// @param outputsMerkleRoot The outputs Merkle root + /// @param machineMerkleRoot The machine Merkle root /// @return Number of validators in favor of claim function numOfValidatorsInFavorOf( address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 machineMerkleRoot ) external view returns (uint256); /// @notice Check whether a validator is in favor of a claim. /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block - /// @param outputsMerkleRoot The outputs Merkle root + /// @param machineMerkleRoot The machine Merkle root /// @param id The ID of the validator /// @return Whether validator is in favor of claim /// @dev Assumes the provided ID is valid. function isValidatorInFavorOf( address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot, + bytes32 machineMerkleRoot, uint256 id ) external view returns (bool); } diff --git a/src/consensus/quorum/Quorum.sol b/src/consensus/quorum/Quorum.sol index 06437ec9..69f39476 100644 --- a/src/consensus/quorum/Quorum.sol +++ b/src/consensus/quorum/Quorum.sol @@ -43,7 +43,7 @@ contract Quorum is IQuorum, AbstractConsensus { mapping(address => mapping(uint256 => Votes)) private _allVotes; /// @notice Votes indexed by application contract address, - /// last processed block number and outputs Merkle root. + /// last processed block number and machine Merkle root. /// @dev See the `numOfValidatorsInFavorOf` and `isValidatorInFavorOf` functions. mapping(address => mapping(uint256 => mapping(bytes32 => Votes))) private _votes; @@ -73,15 +73,18 @@ contract Quorum is IQuorum, AbstractConsensus { function submitClaim( address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 outputsMerkleRoot, + bytes32[] calldata proof ) external override { uint256 id = _validatorId[msg.sender]; require(id > 0, "Quorum: caller is not validator"); _validateLastProcessedBlockNumber(lastProcessedBlockNumber); + bytes32 machineMerkleRoot = _computeMachineMerkleRoot(outputsMerkleRoot, proof); + Votes storage votes = - _getVotes(appContract, lastProcessedBlockNumber, outputsMerkleRoot); + _getVotes(appContract, lastProcessedBlockNumber, machineMerkleRoot); Votes storage allVotes = _getAllVotes(appContract, lastProcessedBlockNumber); @@ -94,7 +97,11 @@ contract Quorum is IQuorum, AbstractConsensus { ); _submitClaim( - msg.sender, appContract, lastProcessedBlockNumber, outputsMerkleRoot + msg.sender, + appContract, + lastProcessedBlockNumber, + outputsMerkleRoot, + machineMerkleRoot ); // Register vote (for any claim in the epoch) @@ -105,7 +112,12 @@ contract Quorum is IQuorum, AbstractConsensus { // and accept the claim if a majority has been reached votes.inFavorById.set(id); if (++votes.inFavorCount == 1 + NUM_OF_VALIDATORS / 2) { - _acceptClaim(appContract, lastProcessedBlockNumber, outputsMerkleRoot); + _acceptClaim( + appContract, + lastProcessedBlockNumber, + outputsMerkleRoot, + machineMerkleRoot + ); } } } @@ -140,19 +152,19 @@ contract Quorum is IQuorum, AbstractConsensus { function numOfValidatorsInFavorOf( address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 machineMerkleRoot ) external view override returns (uint256) { - return _getVotes(appContract, lastProcessedBlockNumber, outputsMerkleRoot) + return _getVotes(appContract, lastProcessedBlockNumber, machineMerkleRoot) .inFavorCount; } function isValidatorInFavorOf( address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot, + bytes32 machineMerkleRoot, uint256 id ) external view override returns (bool) { - return _getVotes(appContract, lastProcessedBlockNumber, outputsMerkleRoot) + return _getVotes(appContract, lastProcessedBlockNumber, machineMerkleRoot) .inFavorById.get(id); } @@ -171,14 +183,14 @@ contract Quorum is IQuorum, AbstractConsensus { /// @notice Get a `Votes` structure from storage from a given claim. /// @param appContract The application contract address /// @param lastProcessedBlockNumber The number of the last processed block - /// @param outputsMerkleRoot The outputs Merkle root + /// @param machineMerkleRoot The machine Merkle root /// @return The `Votes` structure related to a given claim function _getVotes( address appContract, uint256 lastProcessedBlockNumber, - bytes32 outputsMerkleRoot + bytes32 machineMerkleRoot ) internal view returns (Votes storage) { - return _votes[appContract][lastProcessedBlockNumber][outputsMerkleRoot]; + return _votes[appContract][lastProcessedBlockNumber][machineMerkleRoot]; } /// @inheritdoc AbstractConsensus diff --git a/test/consensus/authority/AuthorityFactory.t.sol b/test/consensus/authority/AuthorityFactory.t.sol index cc12ba3b..0098fabe 100644 --- a/test/consensus/authority/AuthorityFactory.t.sol +++ b/test/consensus/authority/AuthorityFactory.t.sol @@ -16,6 +16,7 @@ import {IAuthorityFactory} from "src/consensus/authority/IAuthorityFactory.sol"; import {Claim} from "../../util/Claim.sol"; import {ConsensusTestUtils} from "../../util/ConsensusTestUtils.sol"; import {ERC165Test} from "../../util/ERC165Test.sol"; +import {LibClaim} from "../../util/LibClaim.sol"; import {LibConsensus} from "../../util/LibConsensus.sol"; import {LibTopic} from "../../util/LibTopic.sol"; import {OwnableTest} from "../../util/OwnableTest.sol"; @@ -23,6 +24,7 @@ import {OwnableTest} from "../../util/OwnableTest.sol"; contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUtils { using LibConsensus for IAuthority; using LibTopic for address; + using LibClaim for Claim; AuthorityFactory _factory; bytes4[] _supportedInterfaces; @@ -136,6 +138,8 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + address nonAuthorityOwner = _randomAddressDifferentFromZeroAnd(authorityOwner); vm.expectRevert(_encodeOwnableUnauthorizedAccount(nonAuthorityOwner)); @@ -157,6 +161,8 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.lastProcessedBlockNumber = lastProcessedBlockNumber; vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeNotEpochFinalBlock(lastProcessedBlockNumber, epochLength)); vm.prank(authorityOwner); authority.submitClaim(claim); @@ -174,6 +180,8 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti // Adjust the lastProcessedBlockNumber but do not roll past it. claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeNotPastBlock(claim.lastProcessedBlockNumber)); vm.prank(authorityOwner); authority.submitClaim(claim); @@ -192,6 +200,8 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeApplicationNotDeployed(claim.appContract)); vm.prank(authorityOwner); authority.submitClaim(claim); @@ -211,6 +221,8 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeApplicationReverted(claim.appContract, error)); vm.prank(authorityOwner); authority.submitClaim(claim); @@ -232,6 +244,8 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeIllformedApplicationReturnData(claim.appContract, data)); vm.prank(authorityOwner); authority.submitClaim(claim); @@ -254,6 +268,8 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeIllformedApplicationReturnData(claim.appContract, data)); vm.prank(authorityOwner); authority.submitClaim(claim); @@ -272,11 +288,32 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeApplicationForeclosed(claim.appContract)); vm.prank(authorityOwner); authority.submitClaim(claim); } + function testSubmitClaimRevertInvalidOutputsMerkleRootProofSize( + address authorityOwner, + uint256 epochLength, + Claim memory claim + ) external { + IAuthority authority = _newAuthority(authorityOwner, epochLength); + + claim.appContract = _newActiveAppMock(); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + claim.proof = _randomProof(_randomInvalidLeafProofSize()); + + vm.expectRevert(_encodeInvalidOutputsMerkleRootProofSize(claim.proof.length)); + vm.prank(authorityOwner); + authority.submitClaim(claim); + } + function testSubmitClaim( address authorityOwner, uint256 epochLength, @@ -289,6 +326,10 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + + bytes32 machineMerkleRoot = claim.computeMachineMerkleRoot(); + uint256 totalNumOfSubmittedClaimsBefore = authority.getNumberOfSubmittedClaims(); uint256 totalNumOfAcceptedClaimsBefore = authority.getNumberOfAcceptedClaims(); @@ -308,19 +349,21 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti assertGe(log.topics.length, 1, "unexpected annonymous event"); bytes32 topic0 = log.topics[0]; if (topic0 == IConsensus.ClaimSubmitted.selector) { - (uint256 arg0, bytes32 arg1) = - abi.decode(log.data, (uint256, bytes32)); + (uint256 arg0, bytes32 arg1, bytes32 arg2) = + abi.decode(log.data, (uint256, bytes32, bytes32)); assertEq(log.topics[1], authorityOwner.asTopic()); assertEq(log.topics[2], claim.appContract.asTopic()); assertEq(arg0, claim.lastProcessedBlockNumber); assertEq(arg1, claim.outputsMerkleRoot); + assertEq(arg2, machineMerkleRoot); ++numOfClaimSubmittedEvents; } else if (topic0 == IConsensus.ClaimAccepted.selector) { - (uint256 arg0, bytes32 arg1) = - abi.decode(log.data, (uint256, bytes32)); + (uint256 arg0, bytes32 arg1, bytes32 arg2) = + abi.decode(log.data, (uint256, bytes32, bytes32)); assertEq(log.topics[1], claim.appContract.asTopic()); assertEq(arg0, claim.lastProcessedBlockNumber); assertEq(arg1, claim.outputsMerkleRoot); + assertEq(arg2, machineMerkleRoot); ++numOfClaimAcceptedEvents; } else { revert("unexpected event selector"); @@ -352,13 +395,13 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti "Total number of accepted claims should be increased by number of events" ); + (Claim memory otherClaim,) = _randomClaimDifferentFrom(claim, machineMerkleRoot); + vm.expectRevert( _encodeNotFirstClaim(claim.appContract, claim.lastProcessedBlockNumber) ); vm.prank(authorityOwner); - authority.submitClaim( - claim.appContract, claim.lastProcessedBlockNumber, bytes32(vm.randomUint()) - ); + authority.submitClaim(otherClaim); } function _testNewAuthoritySuccess( @@ -409,9 +452,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti // We check that initially all outputs Merkle roots are invalid. assertFalse( - authority.isOutputsMerkleRootValid( - vm.randomAddress(), bytes32(vm.randomUint()) - ), + authority.isOutputsMerkleRootValid(vm.randomAddress(), _randomBytes32()), "initially, isOutputsMerkleRootValid(...) == false" ); @@ -470,7 +511,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti vm.assumeNoRevert(); return _factory.newAuthority(authorityOwner, epochLength); } else { - bytes32 salt = bytes32(vm.randomUint()); + bytes32 salt = _randomBytes32(); vm.assumeNoRevert(); return _factory.newAuthority(authorityOwner, epochLength, salt); } diff --git a/test/consensus/quorum/QuorumFactory.t.sol b/test/consensus/quorum/QuorumFactory.t.sol index b937ee5f..601f848c 100644 --- a/test/consensus/quorum/QuorumFactory.t.sol +++ b/test/consensus/quorum/QuorumFactory.t.sol @@ -12,9 +12,9 @@ import {Claim} from "../../util/Claim.sol"; import {ConsensusTestUtils} from "../../util/ConsensusTestUtils.sol"; import {ERC165Test} from "../../util/ERC165Test.sol"; import {LibAddressArray} from "../../util/LibAddressArray.sol"; +import {LibClaim} from "../../util/LibClaim.sol"; import {LibConsensus} from "../../util/LibConsensus.sol"; import {LibMath} from "../../util/LibMath.sol"; -import {LibQuorum} from "../../util/LibQuorum.sol"; import {LibTopic} from "../../util/LibTopic.sol"; import {LibUint256Array} from "../../util/LibUint256Array.sol"; @@ -27,9 +27,9 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { using LibUint256Array for uint256[]; using LibUint256Array for Vm; using LibConsensus for IQuorum; - using LibQuorum for IQuorum; using LibTopic for address; using LibMath for uint256; + using LibClaim for Claim; IQuorumFactory _factory; bytes4[] _supportedInterfaces; @@ -114,6 +114,8 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert("Quorum: caller is not validator"); vm.prank(vm.randomAddressNotIn(validators)); // non-validator address quorum.submitClaim(claim); @@ -133,6 +135,8 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.lastProcessedBlockNumber = lastProcessedBlockNumber; vm.roll(_randomUintGt(lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeNotEpochFinalBlock(lastProcessedBlockNumber, epochLength)); vm.prank(vm.randomAddressIn(validators)); quorum.submitClaim(claim); @@ -150,6 +154,8 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { // Adjust the lastProcessedBlockNumber but do not roll past it. claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeNotPastBlock(claim.lastProcessedBlockNumber)); vm.prank(vm.randomAddressIn(validators)); quorum.submitClaim(claim); @@ -168,6 +174,8 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeApplicationNotDeployed(claim.appContract)); vm.prank(vm.randomAddressIn(validators)); quorum.submitClaim(claim); @@ -187,6 +195,8 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeApplicationReverted(claim.appContract, error)); vm.prank(vm.randomAddressIn(validators)); quorum.submitClaim(claim); @@ -208,6 +218,8 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeIllformedApplicationReturnData(claim.appContract, data)); vm.prank(vm.randomAddressIn(validators)); quorum.submitClaim(claim); @@ -230,6 +242,8 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeIllformedApplicationReturnData(claim.appContract, data)); vm.prank(vm.randomAddressIn(validators)); quorum.submitClaim(claim); @@ -248,11 +262,32 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + claim.proof = _randomLeafProof(); + vm.expectRevert(_encodeApplicationForeclosed(claim.appContract)); vm.prank(vm.randomAddressIn(validators)); quorum.submitClaim(claim); } + function testSubmitClaimRevertInvalidOutputsMerkleRootProofSize( + address[] memory validators, + uint256 epochLength, + Claim memory claim + ) external { + IQuorum quorum = _newQuorum(validators, epochLength); + + claim.appContract = _newActiveAppMock(); + + claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); + + claim.proof = _randomProof(_randomInvalidLeafProofSize()); + + vm.expectRevert(_encodeInvalidOutputsMerkleRootProofSize(claim.proof.length)); + vm.prank(vm.randomAddressIn(validators)); + quorum.submitClaim(claim); + } + function testSubmitClaim( address[] memory validators, uint256 epochLength, @@ -265,6 +300,17 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { uint256 lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(lastProcessedBlockNumber)); + bytes32[] memory winningProof = _randomLeafProof(); + + Claim memory winningClaim = Claim({ + appContract: appContract, + lastProcessedBlockNumber: lastProcessedBlockNumber, + outputsMerkleRoot: winningOutputsMerkleRoot, + proof: winningProof + }); + + bytes32 winningMachineMerkleRoot = winningClaim.computeMachineMerkleRoot(); + // Divide validators into three categories: // - winners: they form a majority and vote on the same claim // - losers: they form a minority and vote on other claims @@ -315,7 +361,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertEq( quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, winningOutputsMerkleRoot + appContract, lastProcessedBlockNumber, winningMachineMerkleRoot ), 0, "Expected no validator to be in favor of the winning claim in epoch" @@ -323,7 +369,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertEq( quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, bytes32(vm.randomUint()) + appContract, lastProcessedBlockNumber, _randomBytes32() ), 0, "Expected no validator to be in favor of any random claim in epoch" @@ -345,7 +391,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertFalse( quorum.isValidatorInFavorOf( - appContract, lastProcessedBlockNumber, bytes32(vm.randomUint()), id + appContract, lastProcessedBlockNumber, _randomBytes32(), id ), "Expected validator to not be in favor of any random claim in epoch" ); @@ -354,13 +400,15 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { continue; // skip voting } - bytes32 outputsMerkleRoot; + Claim memory claim; + bytes32 machineMerkleRoot; if (winnerIds.contains(id)) { - outputsMerkleRoot = winningOutputsMerkleRoot; + (claim, machineMerkleRoot) = (winningClaim, winningMachineMerkleRoot); ++numOfWinningVotes; } else if (loserIds.contains(id)) { - outputsMerkleRoot = _randomBytes32DifferentFrom(winningOutputsMerkleRoot); + (claim, machineMerkleRoot) = + _randomClaimDifferentFrom(winningClaim, winningMachineMerkleRoot); ++numOfLosingVotes; } else { revert("unexpected validator category"); @@ -368,7 +416,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertFalse( quorum.isValidatorInFavorOf( - appContract, lastProcessedBlockNumber, outputsMerkleRoot, id + appContract, lastProcessedBlockNumber, machineMerkleRoot, id ), "Expected validator to not be in favor of claim" ); @@ -382,7 +430,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { ); uint256 numOfValidatorsInFavorOfClaimBefore = quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, outputsMerkleRoot + appContract, lastProcessedBlockNumber, machineMerkleRoot ); address validator = quorum.validatorById(id); @@ -391,7 +439,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { vm.recordLogs(); vm.prank(validator); - quorum.submitClaim(appContract, lastProcessedBlockNumber, outputsMerkleRoot); + quorum.submitClaim(claim); Vm.Log[] memory logs = vm.getRecordedLogs(); @@ -404,19 +452,21 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertGe(log.topics.length, 1, "unexpected annonymous event"); bytes32 topic0 = log.topics[0]; if (topic0 == IConsensus.ClaimSubmitted.selector) { - (uint256 arg0, bytes32 arg1) = - abi.decode(log.data, (uint256, bytes32)); + (uint256 arg0, bytes32 arg1, bytes32 arg2) = + abi.decode(log.data, (uint256, bytes32, bytes32)); assertEq(log.topics[1], validator.asTopic()); assertEq(log.topics[2], appContract.asTopic()); assertEq(arg0, lastProcessedBlockNumber); - assertEq(arg1, outputsMerkleRoot); + assertEq(arg1, claim.outputsMerkleRoot); + assertEq(arg2, machineMerkleRoot); ++numOfClaimSubmittedEvents; } else if (topic0 == IConsensus.ClaimAccepted.selector) { - (uint256 arg0, bytes32 arg1) = - abi.decode(log.data, (uint256, bytes32)); + (uint256 arg0, bytes32 arg1, bytes32 arg2) = + abi.decode(log.data, (uint256, bytes32, bytes32)); assertEq(log.topics[1], appContract.asTopic()); assertEq(arg0, lastProcessedBlockNumber); - assertEq(arg1, outputsMerkleRoot); + assertEq(arg1, claim.outputsMerkleRoot); + assertEq(arg2, machineMerkleRoot); ++numOfClaimAcceptedEvents; } else { revert("unexpected event selector"); @@ -436,7 +486,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { } assertEq( - quorum.isOutputsMerkleRootValid(appContract, winningOutputsMerkleRoot), + quorum.isOutputsMerkleRootValid(winningClaim), numOfWinningVotes >= majority, "Once a claim is accepted, the outputs Merkle root is valid" ); @@ -470,7 +520,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertEq( quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, outputsMerkleRoot + appContract, lastProcessedBlockNumber, machineMerkleRoot ), numOfValidatorsInFavorOfClaimBefore + 1, "Number of validators in favor of claim should be incremented" @@ -478,7 +528,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertTrue( quorum.isValidatorInFavorOf( - appContract, lastProcessedBlockNumber, outputsMerkleRoot, id + appContract, lastProcessedBlockNumber, machineMerkleRoot, id ), "Expected validator to be in favor of claim" ); @@ -486,7 +536,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { vm.recordLogs(); vm.prank(validator); - quorum.submitClaim(appContract, lastProcessedBlockNumber, outputsMerkleRoot); + quorum.submitClaim(claim); assertEq( vm.getRecordedLogs().length, @@ -495,7 +545,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { ); assertEq( - quorum.isOutputsMerkleRootValid(appContract, winningOutputsMerkleRoot), + quorum.isOutputsMerkleRootValid(winningClaim), numOfWinningVotes >= majority, "Once a claim is accepted, the outputs Merkle root is valid" ); @@ -517,7 +567,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertEq( quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, outputsMerkleRoot + appContract, lastProcessedBlockNumber, machineMerkleRoot ), numOfValidatorsInFavorOfClaimBefore + 1, "Number of validators in favor of claim should be incremented" @@ -525,18 +575,17 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertTrue( quorum.isValidatorInFavorOf( - appContract, lastProcessedBlockNumber, outputsMerkleRoot, id + appContract, lastProcessedBlockNumber, machineMerkleRoot, id ), "Expected validator to be in favor of claim" ); + (Claim memory otherClaim,) = + _randomClaimDifferentFrom(claim, machineMerkleRoot); + vm.expectRevert(_encodeNotFirstClaim(appContract, lastProcessedBlockNumber)); vm.prank(validator); - quorum.submitClaim( - appContract, - lastProcessedBlockNumber, - _randomBytes32DifferentFrom(outputsMerkleRoot) - ); + quorum.submitClaim(otherClaim); } assertEq(numOfWinningVotes, numOfWinners, "# winning votes == # winner voters"); @@ -545,7 +594,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { assertTrue(wasClaimAccepted, "unexpected ClaimAccepted event"); assertTrue( - quorum.isOutputsMerkleRootValid(appContract, winningOutputsMerkleRoot), + quorum.isOutputsMerkleRootValid(winningClaim), "The outputs Merkle root should be valid" ); @@ -560,6 +609,22 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { 1, "Expected only 1 claim to be accepted (the winning claim)" ); + + assertEq( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ), + numOfWinningVotes + numOfLosingVotes, + "numOfValidatorsInFavorOfAnyClaimInEpoch(...) == # winning votes + # losing votes" + ); + + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, winningMachineMerkleRoot + ), + numOfWinningVotes, + "numOfValidatorsInFavorOf(winningClaim...) = # winning votes" + ); } function _testNewQuorumSuccess( @@ -648,7 +713,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { // We check that initially all outputs Merkle roots are invalid. assertFalse( - quorum.isOutputsMerkleRootValid(vm.randomAddress(), bytes32(vm.randomUint())), + quorum.isOutputsMerkleRootValid(vm.randomAddress(), _randomBytes32()), "initially, isOutputsMerkleRootValid(...) == false" ); @@ -662,7 +727,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { ); assertEq( quorum.numOfValidatorsInFavorOf( - vm.randomAddress(), vm.randomUint(), bytes32(vm.randomUint()) + vm.randomAddress(), vm.randomUint(), _randomBytes32() ), 0, "initially, numOfValidatorsInFavorOfAnyClaimInEpoch(...) == 0" @@ -675,10 +740,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { ); assertFalse( quorum.isValidatorInFavorOf( - vm.randomAddress(), - vm.randomUint(), - bytes32(vm.randomUint()), - vm.randomUint() + vm.randomAddress(), vm.randomUint(), _randomBytes32(), vm.randomUint() ), "initially, isValidatorInFavorOf(...) == false" ); @@ -742,7 +804,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { vm.assumeNoRevert(); return _factory.newQuorum(validators, epochLength); } else { - bytes32 salt = bytes32(vm.randomUint()); + bytes32 salt = _randomBytes32(); vm.assumeNoRevert(); return _factory.newQuorum(validators, epochLength, salt); } diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index 8db0a07d..529c924c 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -30,6 +30,7 @@ import {Test} from "forge-std-1.9.6/src/Test.sol"; import {ExternalLibMerkle32} from "../library/LibMerkle32.t.sol"; import {AddressGenerator} from "../util/AddressGenerator.sol"; +import {ConsensusTestUtils} from "../util/ConsensusTestUtils.sol"; import {EtherReceiver} from "../util/EtherReceiver.sol"; import {LibEmulator} from "../util/LibEmulator.sol"; import {OwnableTest} from "../util/OwnableTest.sol"; @@ -37,7 +38,7 @@ import {SimpleBatchERC1155, SimpleSingleERC1155} from "../util/SimpleERC1155.sol import {SimpleERC20} from "../util/SimpleERC20.sol"; import {SimpleERC721} from "../util/SimpleERC721.sol"; -contract ApplicationTest is Test, OwnableTest, AddressGenerator { +contract ApplicationTest is Test, OwnableTest, AddressGenerator, ConsensusTestUtils { using LibEmulator for LibEmulator.State; using ExternalLibMerkle32 for bytes32[]; @@ -474,7 +475,9 @@ contract ApplicationTest is Test, OwnableTest, AddressGenerator { function _submitClaim() internal { bytes32 outputsMerkleRoot = _emulator.getOutputsMerkleRoot(); vm.prank(_authorityOwner); - _authority.submitClaim(address(_appContract), 0, outputsMerkleRoot); + _authority.submitClaim( + address(_appContract), 0, outputsMerkleRoot, _randomLeafProof() + ); } function _expectEmitOutputExecuted( diff --git a/test/util/Claim.sol b/test/util/Claim.sol index 18e9350e..c49ddeaa 100644 --- a/test/util/Claim.sol +++ b/test/util/Claim.sol @@ -7,4 +7,5 @@ struct Claim { address appContract; uint256 lastProcessedBlockNumber; bytes32 outputsMerkleRoot; + bytes32[] proof; } diff --git a/test/util/ConsensusTestUtils.sol b/test/util/ConsensusTestUtils.sol index 4eb9bce3..6dd47965 100644 --- a/test/util/ConsensusTestUtils.sol +++ b/test/util/ConsensusTestUtils.sol @@ -3,11 +3,17 @@ pragma solidity ^0.8.22; +import {Memory} from "cartesi-machine-solidity-step-0.13.0/src/Memory.sol"; + import {IConsensus} from "src/consensus/IConsensus.sol"; import {ApplicationCheckerTestUtils} from "./ApplicationCheckerTestUtils.sol"; +import {Claim} from "./Claim.sol"; +import {LibClaim} from "./LibClaim.sol"; contract ConsensusTestUtils is ApplicationCheckerTestUtils { + using LibClaim for Claim; + function _encodeNotPastBlock(uint256 lastProcessedBlockNumber) internal view @@ -39,6 +45,18 @@ contract ConsensusTestUtils is ApplicationCheckerTestUtils { ); } + function _encodeInvalidOutputsMerkleRootProofSize(uint256 proofSize) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + IConsensus.InvalidOutputsMerkleRootProofSize.selector, + proofSize, + Memory.LOG2_MAX_SIZE + ); + } + function _maxEpochIndex(uint256 epochLength) internal pure returns (uint256) { return (type(uint256).max - (epochLength - 1)) / epochLength; } @@ -79,13 +97,41 @@ contract ConsensusTestUtils is ApplicationCheckerTestUtils { return blockNumber; } - function _randomBytes32DifferentFrom(bytes32 value) + function _randomBytes32() internal returns (bytes32) { + return bytes32(vm.randomUint()); + } + + function _randomProof(uint256 length) internal returns (bytes32[] memory proof) { + proof = new bytes32[](length); + for (uint256 i; i < proof.length; ++i) { + proof[i] = _randomBytes32(); + } + } + + function _randomLeafProof() internal returns (bytes32[] memory proof) { + return _randomProof(Memory.LOG2_MAX_SIZE); + } + + function _randomClaimDifferentFrom(Claim memory claim, bytes32 machineMerkleRoot) internal - returns (bytes32 otherValue) + returns (Claim memory otherClaim, bytes32 otherMachineMerkleRoot) { + otherClaim.appContract = claim.appContract; + otherClaim.lastProcessedBlockNumber = claim.lastProcessedBlockNumber; + while (true) { + otherClaim.outputsMerkleRoot = _randomBytes32(); + otherClaim.proof = _randomProof(claim.proof.length); + otherMachineMerkleRoot = otherClaim.computeMachineMerkleRoot(); + if (machineMerkleRoot != otherMachineMerkleRoot) { + break; + } + } + } + + function _randomInvalidLeafProofSize() internal returns (uint256 proofSize) { while (true) { - otherValue = bytes32(vm.randomUint()); - if (otherValue != value) { + proofSize = vm.randomUint(0, 2 * Memory.LOG2_MAX_SIZE + 1); + if (proofSize != Memory.LOG2_MAX_SIZE) { break; } } diff --git a/test/util/LibClaim.sol b/test/util/LibClaim.sol new file mode 100644 index 00000000..ccb0f3c5 --- /dev/null +++ b/test/util/LibClaim.sol @@ -0,0 +1,29 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import { + EmulatorConstants +} from "cartesi-machine-solidity-step-0.13.0/src/EmulatorConstants.sol"; + +import {LibMerkle32} from "src/library/LibMerkle32.sol"; + +import {Claim} from "./Claim.sol"; + +library LibClaim { + using LibMerkle32 for bytes32[]; + + function computeMachineMerkleRoot(Claim calldata claim) + external + pure + returns (bytes32 machineMerkleRoot) + { + machineMerkleRoot = claim.proof + .merkleRootAfterReplacement( + EmulatorConstants.PMA_CMIO_TX_BUFFER_START + >> EmulatorConstants.TREE_LOG2_WORD_SIZE, + keccak256(abi.encode(claim.outputsMerkleRoot)) + ); + } +} diff --git a/test/util/LibConsensus.sol b/test/util/LibConsensus.sol index b0e15e4f..0ab698cb 100644 --- a/test/util/LibConsensus.sol +++ b/test/util/LibConsensus.sol @@ -10,7 +10,10 @@ import {Claim} from "./Claim.sol"; library LibConsensus { function submitClaim(IConsensus consensus, Claim memory claim) internal { consensus.submitClaim( - claim.appContract, claim.lastProcessedBlockNumber, claim.outputsMerkleRoot + claim.appContract, + claim.lastProcessedBlockNumber, + claim.outputsMerkleRoot, + claim.proof ); } diff --git a/test/util/LibQuorum.sol b/test/util/LibQuorum.sol deleted file mode 100644 index 446b213d..00000000 --- a/test/util/LibQuorum.sol +++ /dev/null @@ -1,50 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pragma solidity ^0.8.22; - -import {IQuorum} from "src/consensus/quorum/IQuorum.sol"; - -import {Claim} from "./Claim.sol"; - -library LibQuorum { - function numOfValidatorsInFavorOfAnyClaimInEpoch(IQuorum quorum, Claim memory claim) - internal - view - returns (uint256) - { - return quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( - claim.appContract, claim.lastProcessedBlockNumber - ); - } - - function isValidatorInFavorOfAnyClaimInEpoch( - IQuorum quorum, - Claim memory claim, - uint256 id - ) internal view returns (bool) { - return quorum.isValidatorInFavorOfAnyClaimInEpoch( - claim.appContract, claim.lastProcessedBlockNumber, id - ); - } - - function numOfValidatorsInFavorOf(IQuorum quorum, Claim memory claim) - internal - view - returns (uint256) - { - return quorum.numOfValidatorsInFavorOf( - claim.appContract, claim.lastProcessedBlockNumber, claim.outputsMerkleRoot - ); - } - - function isValidatorInFavorOf(IQuorum quorum, Claim memory claim, uint256 id) - internal - view - returns (bool) - { - return quorum.isValidatorInFavorOf( - claim.appContract, claim.lastProcessedBlockNumber, claim.outputsMerkleRoot, id - ); - } -} From 39fda5e0838c7effbbfc39f139fb9c8af029754f Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Tue, 3 Mar 2026 09:26:30 -0300 Subject: [PATCH 25/48] Improve tests and coverage --- .../authority/AuthorityFactory.t.sol | 18 +- test/consensus/quorum/QuorumFactory.t.sol | 18 +- test/dapp/ApplicationFactory.t.sol | 14 +- test/dapp/SelfHostedApplicationFactory.t.sol | 14 +- test/inputs/InputBox.t.sol | 139 +++---- test/portals/ERC1155BatchPortal.t.sol | 341 ++++++++++++------ test/portals/ERC1155SinglePortal.t.sol | 270 +++++++++----- test/portals/ERC20Portal.t.sol | 250 ++++++++----- test/portals/ERC721Portal.t.sol | 260 +++++++++---- test/portals/EtherPortal.t.sol | 178 ++++++--- test/util/EvmAdvanceEncoder.sol | 31 -- test/util/InputBoxTestUtils.sol | 79 ++++ test/util/LibAddressArray.sol | 11 + test/util/LibAddressArray.t.sol | 9 + test/util/LibBytes.sol | 47 +++ test/util/LibBytes.t.sol | 52 +++ test/util/LibUint256Array.sol | 52 ++- test/util/LibUint256Array.t.sol | 56 +++ 18 files changed, 1279 insertions(+), 560 deletions(-) delete mode 100644 test/util/EvmAdvanceEncoder.sol create mode 100644 test/util/InputBoxTestUtils.sol create mode 100644 test/util/LibBytes.sol create mode 100644 test/util/LibBytes.t.sol diff --git a/test/consensus/authority/AuthorityFactory.t.sol b/test/consensus/authority/AuthorityFactory.t.sol index 0098fabe..a5d70f5f 100644 --- a/test/consensus/authority/AuthorityFactory.t.sol +++ b/test/consensus/authority/AuthorityFactory.t.sol @@ -16,6 +16,7 @@ import {IAuthorityFactory} from "src/consensus/authority/IAuthorityFactory.sol"; import {Claim} from "../../util/Claim.sol"; import {ConsensusTestUtils} from "../../util/ConsensusTestUtils.sol"; import {ERC165Test} from "../../util/ERC165Test.sol"; +import {LibBytes} from "../../util/LibBytes.sol"; import {LibClaim} from "../../util/LibClaim.sol"; import {LibConsensus} from "../../util/LibConsensus.sol"; import {LibTopic} from "../../util/LibTopic.sol"; @@ -25,6 +26,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti using LibConsensus for IAuthority; using LibTopic for address; using LibClaim for Claim; + using LibBytes for bytes; AuthorityFactory _factory; bytes4[] _supportedInterfaces; @@ -254,11 +256,10 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaimRevertApplicationReturnIllFormedReturnData( address authorityOwner, uint256 epochLength, - Claim memory claim, - uint256 returnValue + Claim memory claim ) external { // We make isForeclosed() return an invalid boolean (neither 0 or 1) - vm.assume(returnValue > 1); + uint256 returnValue = vm.randomUint(2, type(uint256).max); IAuthority authority = _newAuthority(authorityOwner, epochLength); @@ -477,16 +478,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti uint256 epochLength, bytes memory error ) internal pure { - assertGe(error.length, 4, "Error data too short (no 4-byte selector)"); - - // forge-lint: disable-next-line(unsafe-typecast) - bytes4 errorSelector = bytes4(error); - bytes memory errorArgs = new bytes(error.length - 4); - - for (uint256 i; i < errorArgs.length; ++i) { - errorArgs[i] = error[i + 4]; - } - + (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); if (errorSelector == Ownable.OwnableInvalidOwner.selector) { address owner = abi.decode(errorArgs, (address)); assertEq(owner, authorityOwner, "OwnableInvalidOwner.owner != owner"); diff --git a/test/consensus/quorum/QuorumFactory.t.sol b/test/consensus/quorum/QuorumFactory.t.sol index 601f848c..2052eca6 100644 --- a/test/consensus/quorum/QuorumFactory.t.sol +++ b/test/consensus/quorum/QuorumFactory.t.sol @@ -12,6 +12,7 @@ import {Claim} from "../../util/Claim.sol"; import {ConsensusTestUtils} from "../../util/ConsensusTestUtils.sol"; import {ERC165Test} from "../../util/ERC165Test.sol"; import {LibAddressArray} from "../../util/LibAddressArray.sol"; +import {LibBytes} from "../../util/LibBytes.sol"; import {LibClaim} from "../../util/LibClaim.sol"; import {LibConsensus} from "../../util/LibConsensus.sol"; import {LibMath} from "../../util/LibMath.sol"; @@ -29,6 +30,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { using LibConsensus for IQuorum; using LibTopic for address; using LibMath for uint256; + using LibBytes for bytes; using LibClaim for Claim; IQuorumFactory _factory; @@ -228,11 +230,10 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertApplicationReturnIllFormedReturnData( address[] memory validators, uint256 epochLength, - Claim memory claim, - uint256 returnValue + Claim memory claim ) external { // We make isForeclosed() return an invalid boolean (neither 0 or 1) - vm.assume(returnValue > 1); + uint256 returnValue = vm.randomUint(2, type(uint256).max); IQuorum quorum = _newQuorum(validators, epochLength); @@ -766,16 +767,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { uint256 epochLength, bytes memory error ) internal pure { - assertGe(error.length, 4, "Error data too short (no 4-byte selector)"); - - // forge-lint: disable-next-line(unsafe-typecast) - bytes4 errorSelector = bytes4(error); - bytes memory errorArgs = new bytes(error.length - 4); - - for (uint256 i; i < errorArgs.length; ++i) { - errorArgs[i] = error[i + 4]; - } - + (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); if (errorSelector == bytes4(keccak256("Error(string)"))) { string memory message = abi.decode(errorArgs, (string)); bytes32 messageHash = keccak256(bytes(message)); diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index 9ab9355d..2c4d2ab8 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -16,8 +16,11 @@ import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; import {Test} from "forge-std-1.9.6/src/Test.sol"; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; +import {LibBytes} from "../util/LibBytes.sol"; + contract ApplicationFactoryTest is Test { using LibWithdrawalConfig for WithdrawalConfig; + using LibBytes for bytes; ApplicationFactory _factory; @@ -269,16 +272,7 @@ contract ApplicationFactoryTest is Test { WithdrawalConfig memory withdrawalConfig, bytes memory error ) internal pure { - assertGe(error.length, 4, "Error data too short (no 4-byte selector)"); - - // forge-lint: disable-next-line(unsafe-typecast) - bytes4 errorSelector = bytes4(error); - bytes memory errorArgs = new bytes(error.length - 4); - - for (uint256 i; i < errorArgs.length; ++i) { - errorArgs[i] = error[i + 4]; - } - + (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); if (errorSelector == Ownable.OwnableInvalidOwner.selector) { address owner = abi.decode(errorArgs, (address)); assertEq(owner, appOwner, "OwnableInvalidOwner.owner != owner"); diff --git a/test/dapp/SelfHostedApplicationFactory.t.sol b/test/dapp/SelfHostedApplicationFactory.t.sol index c092e2ff..891411e6 100644 --- a/test/dapp/SelfHostedApplicationFactory.t.sol +++ b/test/dapp/SelfHostedApplicationFactory.t.sol @@ -19,8 +19,11 @@ import {LibWithdrawalConfig} from "src/library/LibWithdrawalConfig.sol"; import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {LibBytes} from "../util/LibBytes.sol"; + contract SelfHostedApplicationFactoryTest is Test { using LibWithdrawalConfig for WithdrawalConfig; + using LibBytes for bytes; IAuthorityFactory authorityFactory; IApplicationFactory applicationFactory; @@ -143,16 +146,7 @@ contract SelfHostedApplicationFactoryTest is Test { "calculateAddresses(...) is not a pure function" ); } catch (bytes memory error) { - assertGe(error.length, 4, "Error data too short (no 4-byte selector)"); - - // forge-lint: disable-next-line(unsafe-typecast) - bytes4 errorSelector = bytes4(error); - bytes memory errorArgs = new bytes(error.length - 4); - - for (uint256 i; i < errorArgs.length; ++i) { - errorArgs[i] = error[i + 4]; - } - + (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); if (errorSelector == Ownable.OwnableInvalidOwner.selector) { address owner = abi.decode(errorArgs, (address)); assertEq(owner, address(0), "OwnableInvalidOwner.owner != address(0)"); diff --git a/test/inputs/InputBox.t.sol b/test/inputs/InputBox.t.sol index 6db532fe..5de0354f 100644 --- a/test/inputs/InputBox.t.sol +++ b/test/inputs/InputBox.t.sol @@ -4,16 +4,16 @@ pragma solidity ^0.8.22; import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; import {Inputs} from "src/common/Inputs.sol"; import {IInputBox} from "src/inputs/IInputBox.sol"; import {InputBox} from "src/inputs/InputBox.sol"; -import {ApplicationCheckerTestUtils} from "../util/ApplicationCheckerTestUtils.sol"; -import {EvmAdvanceEncoder} from "../util/EvmAdvanceEncoder.sol"; +import {InputBoxTestUtils} from "../util/InputBoxTestUtils.sol"; -contract InputBoxTest is Test, ApplicationCheckerTestUtils { +contract InputBoxTest is Test, InputBoxTestUtils { InputBox _inputBox; function setUp() external { @@ -60,10 +60,8 @@ contract InputBoxTest is Test, ApplicationCheckerTestUtils { _inputBox.addInput(appContract, payload); } - function testAddInputRevertsIllForm(uint256 returnValue, bytes calldata payload) - external - { - vm.assume(returnValue > 1); + function testAddInputRevertsIllForm(bytes calldata payload) external { + uint256 returnValue = vm.randomUint(2, type(uint256).max); bytes memory data = abi.encode(returnValue); address appContract = _newAppMockReturns(data); vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, data)); @@ -78,93 +76,68 @@ contract InputBoxTest is Test, ApplicationCheckerTestUtils { function testAddLargeInput() external { address appContract = _newActiveAppMock(); - uint256 max = _getMaxInputPayloadLength(); - _inputBox.addInput(appContract, new bytes(max)); + bytes memory inputWithEmptyPayload = abi.encodeCall( + Inputs.EvmAdvance, + ( + block.chainid, + appContract, + address(this), + vm.getBlockNumber(), + vm.getBlockTimestamp(), + block.prevrandao, + 0, + new bytes(0) + ) + ); + + uint256 maxPayloadLength = + (CanonicalMachine.INPUT_MAX_SIZE - inputWithEmptyPayload.length) + & ~uint256(0x1f); + + _inputBox.addInput(appContract, new bytes(maxPayloadLength)); - bytes memory largePayload = new bytes(max + 1); - bytes memory largeInput = - EvmAdvanceEncoder.encode(1, appContract, address(this), 1, largePayload); - uint256 largeLength = largeInput.length; vm.expectRevert( abi.encodeWithSelector( IInputBox.InputTooLarge.selector, appContract, - largeLength, + inputWithEmptyPayload.length + maxPayloadLength + 32, CanonicalMachine.INPUT_MAX_SIZE ) ); - _inputBox.addInput(appContract, largePayload); + _inputBox.addInput(appContract, new bytes(maxPayloadLength + 1)); } - function testAddInput(uint64 chainId, bytes[] calldata payloads) external { - address appContract = _newActiveAppMock(); - - vm.chainId(chainId); // foundry limits chain id to be less than 2^64 - 1 - - uint256 numPayloads = payloads.length; - bytes32[] memory returnedValues = new bytes32[](numPayloads); - uint256 year2022 = 1641070800; // Unix Timestamp for 2022 - - // assume #bytes for each payload is within bounds - for (uint256 i; i < numPayloads; ++i) { - vm.assume(payloads[i].length <= _getMaxInputPayloadLength()); + function testAddInputs(bytes[] calldata payloads) external { + vm.chainId(vm.randomUint(64)); + for (uint256 i; i < payloads.length; ++i) { + vm.roll(vm.randomUint(vm.getBlockNumber(), type(uint256).max)); + vm.warp(vm.randomUint(vm.getBlockTimestamp(), type(uint256).max)); + vm.prevrandao(vm.randomUint()); + address appContract = _newActiveAppMock(); + address sender = vm.randomAddress(); + uint256 index = _inputBox.getNumberOfInputs(appContract); + bytes calldata payload = payloads[i]; + vm.recordLogs(); + vm.prank(sender); + bytes32 inputHash = _inputBox.addInput(appContract, payload); + Vm.Log[] memory logs = vm.getRecordedLogs(); + uint256 numOfInputAdded; + for (uint256 j; j < logs.length; ++j) { + Vm.Log memory log = logs[j]; + if (log.emitter == address(_inputBox)) { + (bytes memory decodedInput, bytes memory decodedPayload) = + _decodeInputAdded(log, appContract, sender, index); + assertEq(decodedPayload, payload); + assertEq(keccak256(decodedInput), inputHash); + ++numOfInputAdded; + } else { + revert("unexpected log emitter"); + } + } + assertEq(numOfInputAdded, 1); + assertEq(_inputBox.getInputHash(appContract, index), inputHash); + assertEq(_inputBox.getNumberOfInputs(appContract), index + 1); } - - // adding inputs - for (uint256 i; i < numPayloads; ++i) { - // test for different block number and timestamp - vm.roll(i); - vm.warp(i + year2022); - vm.prevrandao(bytes32(_prevrandao(i))); - - vm.expectEmit(true, true, false, true, address(_inputBox)); - bytes memory input = EvmAdvanceEncoder.encode( - chainId, appContract, address(this), i, payloads[i] - ); - emit IInputBox.InputAdded(appContract, i, input); - - returnedValues[i] = _inputBox.addInput(appContract, payloads[i]); - - assertEq(i + 1, _inputBox.getNumberOfInputs(appContract)); - } - - // testing added inputs - for (uint256 i; i < numPayloads; ++i) { - bytes32 inputHash = keccak256( - abi.encodeCall( - Inputs.EvmAdvance, - ( - chainId, - appContract, - address(this), - i, // block.number - i + year2022, // block.timestamp - _prevrandao(i), // block.prevrandao - i, // inputBox.length - payloads[i] - ) - ) - ); - // test if input hash is the same as in InputBox - assertEq(inputHash, _inputBox.getInputHash(appContract, i)); - // test if input hash is the same as returned from calling addInput() function - assertEq(inputHash, returnedValues[i]); - } - } - - function _prevrandao(uint256 blockNumber) internal pure returns (uint256) { - return uint256(keccak256(abi.encode("prevrandao", blockNumber))); - } - - function _getMaxInputPayloadLength() internal pure returns (uint256) { - bytes memory blob = abi.encodeCall( - Inputs.EvmAdvance, (0, address(0), address(0), 0, 0, 0, 0, new bytes(32)) - ); - // number of bytes in input blob excluding input payload - uint256 extraBytes = blob.length - 32; - // because it's abi encoded, input payloads are stored as multiples of 32 bytes - /// forge-lint: disable-next-line(divide-before-multiply) - return ((CanonicalMachine.INPUT_MAX_SIZE - extraBytes) / 32) * 32; } } diff --git a/test/portals/ERC1155BatchPortal.t.sol b/test/portals/ERC1155BatchPortal.t.sol index e1d4ef0f..5f9e3c13 100644 --- a/test/portals/ERC1155BatchPortal.t.sol +++ b/test/portals/ERC1155BatchPortal.t.sol @@ -4,169 +4,302 @@ pragma solidity ^0.8.22; import {IERC1155} from "@openzeppelin-contracts-5.2.0/token/ERC1155/IERC1155.sol"; +import { + IERC1155Receiver +} from "@openzeppelin-contracts-5.2.0/token/ERC1155/IERC1155Receiver.sol"; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; -import {InputEncoding} from "src/common/InputEncoding.sol"; import {IInputBox} from "src/inputs/IInputBox.sol"; +import {InputBox} from "src/inputs/InputBox.sol"; import {ERC1155BatchPortal} from "src/portals/ERC1155BatchPortal.sol"; import {IERC1155BatchPortal} from "src/portals/IERC1155BatchPortal.sol"; -import {Test} from "forge-std-1.9.6/src/Test.sol"; - +import {InputBoxTestUtils} from "../util/InputBoxTestUtils.sol"; +import {LibAddressArray} from "../util/LibAddressArray.sol"; +import {LibBytes} from "../util/LibBytes.sol"; +import {LibTopic} from "../util/LibTopic.sol"; +import {LibUint256Array} from "../util/LibUint256Array.sol"; import {SimpleBatchERC1155} from "../util/SimpleERC1155.sol"; -contract ERC1155BatchPortalTest is Test { - address _alice; - address _appContract; - IERC1155 _token; +contract ERC1155BatchPortalTest is Test, InputBoxTestUtils { + using LibUint256Array for uint256[]; + using LibAddressArray for address; + using LibUint256Array for Vm; + using LibTopic for address; + using LibBytes for bytes; + IInputBox _inputBox; IERC1155BatchPortal _portal; function setUp() public { - _alice = vm.addr(1); - _appContract = vm.addr(2); - _token = IERC1155(vm.addr(3)); - _inputBox = IInputBox(vm.addr(4)); + _inputBox = new InputBox(); _portal = new ERC1155BatchPortal(_inputBox); } - function testDeposit( - uint256[] calldata tokenIds, + function testGetInputBox() public view { + assertEq(address(_portal.getInputBox()), address(_inputBox)); + } + + function testDepositRevertApplicationNotDeployed( uint256[] calldata values, bytes calldata baseLayerData, bytes calldata execLayerData - ) public { - bytes memory safeBatchTransferFrom = - _encodeSafeBatchTransferFrom(tokenIds, values, baseLayerData); + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _randomAccountWithNoCode(); + + (IERC1155 token, uint256[] memory tokenIds) = _randomSetup(sender, values); - vm.mockCall(address(_token), safeBatchTransferFrom, abi.encode()); - vm.expectCall(address(_token), safeBatchTransferFrom, 1); + vm.prank(sender); + vm.expectRevert(_encodeApplicationNotDeployed(appContract)); + _portal.depositBatchERC1155Token( + token, appContract, tokenIds, values, baseLayerData, execLayerData + ); + } - bytes memory payload = - encodePayload(tokenIds, values, baseLayerData, execLayerData); + function testDepositRevertApplicationReverted( + uint256[] calldata values, + bytes calldata baseLayerData, + bytes calldata execLayerData, + bytes calldata error + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReverts(error); - bytes memory addInput = _encodeAddInput(payload); + (IERC1155 token, uint256[] memory tokenIds) = _randomSetup(sender, values); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); - vm.expectCall(address(_inputBox), addInput, 1); + _mockOnErc1155BatchReceived(appContract, sender, tokenIds, values, baseLayerData); - vm.prank(_alice); + vm.prank(sender); + vm.expectRevert(_encodeApplicationReverted(appContract, error)); _portal.depositBatchERC1155Token( - _token, _appContract, tokenIds, values, baseLayerData, execLayerData + token, appContract, tokenIds, values, baseLayerData, execLayerData ); } - function testTokenReverts( - uint256[] calldata tokenIds, + function testDepositRevertIllformedApplicationReturnDataSize( uint256[] calldata values, bytes calldata baseLayerData, bytes calldata execLayerData, - bytes memory errorData - ) public { - bytes memory safeBatchTransferFrom = - _encodeSafeBatchTransferFrom(tokenIds, values, baseLayerData); + bytes calldata returnData + ) external { + vm.assume(returnData.length != 32); - vm.mockCall(address(_token), safeBatchTransferFrom, abi.encode()); - vm.mockCallRevert(address(_token), safeBatchTransferFrom, errorData); + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); - bytes memory payload = - encodePayload(tokenIds, values, baseLayerData, execLayerData); + (IERC1155 token, uint256[] memory tokenIds) = _randomSetup(sender, values); - bytes memory addInput = _encodeAddInput(payload); + _mockOnErc1155BatchReceived(appContract, sender, tokenIds, values, baseLayerData); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); + _portal.depositBatchERC1155Token( + token, appContract, tokenIds, values, baseLayerData, execLayerData + ); + } - vm.expectRevert(errorData); + function testDepositRevertIllformedApplicationReturnDataInvalidBool( + uint256[] calldata values, + bytes calldata baseLayerData, + bytes calldata execLayerData + ) external { + uint256 returnValue = vm.randomUint(2, type(uint256).max); + bytes memory returnData = abi.encode(returnValue); + + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); + + (IERC1155 token, uint256[] memory tokenIds) = _randomSetup(sender, values); - vm.prank(_alice); + _mockOnErc1155BatchReceived(appContract, sender, tokenIds, values, baseLayerData); + + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); _portal.depositBatchERC1155Token( - _token, _appContract, tokenIds, values, baseLayerData, execLayerData + token, appContract, tokenIds, values, baseLayerData, execLayerData ); } - function testSimpleBatchERC1155( - uint256[] calldata supplies, + function testDepositRevertApplicationForeclosed( + uint256[] calldata values, bytes calldata baseLayerData, bytes calldata execLayerData - ) public { - // construct arrays of tokenIds and values - uint256 numOfTokenIds = supplies.length; - vm.assume(numOfTokenIds > 1); - uint256[] memory tokenIds = new uint256[](numOfTokenIds); - uint256[] memory values = new uint256[](numOfTokenIds); - for (uint256 i; i < numOfTokenIds; ++i) { - tokenIds[i] = i; - values[i] = bound(i, 0, supplies[i]); - } + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newForeclosedAppMock(); - _token = new SimpleBatchERC1155(_alice, tokenIds, supplies); + (IERC1155 token, uint256[] memory tokenIds) = _randomSetup(sender, values); - vm.startPrank(_alice); + _mockOnErc1155BatchReceived(appContract, sender, tokenIds, values, baseLayerData); - // Allow the portal to withdraw tokens from Alice - _token.setApprovalForAll(address(_portal), true); + vm.prank(sender); + vm.expectRevert(_encodeApplicationForeclosed(appContract)); + _portal.depositBatchERC1155Token( + token, appContract, tokenIds, values, baseLayerData, execLayerData + ); + } - bytes memory payload = - this.encodePayload(tokenIds, values, baseLayerData, execLayerData); + function testDeposit( + uint256[] calldata values, + bytes calldata baseLayerData, + bytes calldata execLayerData, + bytes[] calldata payloads + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newActiveAppMock(); - bytes memory addInput = _encodeAddInput(payload); + (IERC1155 token, uint256[] memory tokenIds) = _randomSetup(sender, values); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + _mockOnErc1155BatchReceived(appContract, sender, tokenIds, values, baseLayerData); - // balances before - for (uint256 i; i < numOfTokenIds; ++i) { - uint256 tokenId = tokenIds[i]; - uint256 supply = supplies[i]; - assertEq(_token.balanceOf(_alice, tokenId), supply); - assertEq(_token.balanceOf(_appContract, tokenId), 0); - assertEq(_token.balanceOf(address(_portal), tokenId), 0); - } + _addInputs(_inputBox, appContract, payloads); - vm.expectCall(address(_inputBox), addInput, 1); + uint256[] memory senderBalances = + token.balanceOfBatch(sender.repeat(tokenIds.length), tokenIds); + uint256[] memory appContractBalances = + token.balanceOfBatch(appContract.repeat(tokenIds.length), tokenIds); - vm.expectEmit(true, true, true, true, address(_token)); - emit IERC1155.TransferBatch( - address(_portal), _alice, _appContract, tokenIds, values - ); + uint256 numOfInputs = _inputBox.getNumberOfInputs(appContract); + + vm.recordLogs(); + vm.prank(sender); _portal.depositBatchERC1155Token( - _token, _appContract, tokenIds, values, baseLayerData, execLayerData + token, appContract, tokenIds, values, baseLayerData, execLayerData ); - vm.stopPrank(); - - // balances after - for (uint256 i; i < numOfTokenIds; ++i) { - uint256 tokenId = tokenIds[i]; - uint256 value = values[i]; - uint256 supply = supplies[i]; - assertEq(_token.balanceOf(_alice, tokenId), supply - value); - assertEq(_token.balanceOf(_appContract, tokenId), value); - assertEq(_token.balanceOf(address(_portal), tokenId), 0); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes memory input; + bytes memory payload; + uint256 numOfInputAdded; + uint256 numOfTransferSingle; + uint256 numOfTransferBatch; + + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory log = logs[i]; + if (log.emitter == address(_inputBox)) { + (input, payload) = + _decodeInputAdded(log, appContract, address(_portal), numOfInputs); + ++numOfInputAdded; + } else if (log.emitter == address(token)) { + bytes32 topic0 = log.topics[0]; + if (topic0 == IERC1155.TransferSingle.selector) { + (uint256 arg1, uint256 arg2) = + abi.decode(log.data, (uint256, uint256)); + assertEq(log.topics[1], address(_portal).asTopic()); + assertEq(log.topics[2], sender.asTopic()); + assertEq(log.topics[3], appContract.asTopic()); + assertEq(tokenIds.length, 1); + assertEq(arg1, tokenIds[0]); + assertEq(values.length, 1); + assertEq(arg2, values[0]); + ++numOfTransferSingle; + } else if (topic0 == IERC1155.TransferBatch.selector) { + (uint256[] memory arg1, uint256[] memory arg2) = + abi.decode(log.data, (uint256[], uint256[])); + assertEq(log.topics[1], address(_portal).asTopic()); + assertEq(log.topics[2], sender.asTopic()); + assertEq(log.topics[3], appContract.asTopic()); + assertEq(arg1, tokenIds); + assertEq(arg2, values); + ++numOfTransferBatch; + } else { + revert("unexpected token contract topic #0"); + } + } else { + revert("unexpected log emitter"); + } } - } - function encodePayload( - uint256[] calldata tokenIds, - uint256[] calldata values, - bytes calldata baseLayerData, - bytes calldata execLayerData - ) public view returns (bytes memory) { - return InputEncoding.encodeBatchERC1155Deposit( - _token, _alice, tokenIds, values, baseLayerData, execLayerData + assertEq(numOfInputAdded, 1); + + if (tokenIds.length == 1) { + assertEq(numOfTransferSingle, 1); + assertEq(numOfTransferBatch, 0); + } else { + assertEq(numOfTransferSingle, 0); + assertEq(numOfTransferBatch, 1); + } + + assertEq( + token.balanceOfBatch(sender.repeat(tokenIds.length), tokenIds), + senderBalances.sub(values) + ); + assertEq( + token.balanceOfBatch(appContract.repeat(tokenIds.length), tokenIds), + appContractBalances.add(values) ); + + assertEq(_inputBox.getNumberOfInputs(appContract), numOfInputs + 1); + assertEq(keccak256(input), _inputBox.getInputHash(appContract, numOfInputs)); + + bytes memory buffer = payload; + address tokenArg; + address senderArg; + uint256[] memory tokenIdsArg; + uint256[] memory valuesArg; + bytes memory baseLayerDataArg; + bytes memory execLayerDataArg; + + (tokenArg, buffer) = buffer.consumeAddress(); + (senderArg, buffer) = buffer.consumeAddress(); + (tokenIdsArg, valuesArg, baseLayerDataArg, execLayerDataArg) = + abi.decode(buffer, (uint256[], uint256[], bytes, bytes)); + + assertEq(tokenArg, address(token)); + assertEq(senderArg, sender); + assertEq(tokenIdsArg, tokenIds); + assertEq(valuesArg, values); + assertEq(baseLayerDataArg, baseLayerData); + assertEq(execLayerDataArg, execLayerData); } - function _encodeAddInput(bytes memory payload) internal view returns (bytes memory) { - return abi.encodeCall(IInputBox.addInput, (_appContract, payload)); + function _randomSetup(address sender, uint256[] calldata values) + internal + returns (IERC1155 token, uint256[] memory tokenIds) + { + // Generate an array of unique uint256 values with the same size as `values`. + tokenIds = vm.randomUniqueUint256Array(values.length); + + // Deploy the ERC-1155 token contract with the sender's tokens pre-minted + token = new SimpleBatchERC1155(sender, tokenIds, values); + + // Mine a random number of blocks + vm.roll(vm.randomUint(vm.getBlockNumber(), type(uint256).max)); + + // Make the sender give approval to the portal + vm.prank(sender); + token.setApprovalForAll(address(_portal), true); } - function _encodeSafeBatchTransferFrom( - uint256[] calldata tokenIds, - uint256[] calldata values, - bytes calldata baseLayerData - ) internal view returns (bytes memory) { - return abi.encodeCall( - IERC1155.safeBatchTransferFrom, - (_alice, _appContract, tokenIds, values, baseLayerData) - ); + function _mockOnErc1155BatchReceived( + address appContract, + address sender, + uint256[] memory tokenIds, + uint256[] memory values, + bytes memory baseLayerData + ) internal { + if (tokenIds.length == 1) { + vm.mockCall( + appContract, + abi.encodeCall( + IERC1155Receiver.onERC1155Received, + (address(_portal), sender, tokenIds[0], values[0], baseLayerData) + ), + abi.encode(IERC1155Receiver.onERC1155Received.selector) + ); + } else { + vm.mockCall( + appContract, + abi.encodeCall( + IERC1155Receiver.onERC1155BatchReceived, + (address(_portal), sender, tokenIds, values, baseLayerData) + ), + abi.encode(IERC1155Receiver.onERC1155BatchReceived.selector) + ); + } } } diff --git a/test/portals/ERC1155SinglePortal.t.sol b/test/portals/ERC1155SinglePortal.t.sol index c766e2d8..8813b5bd 100644 --- a/test/portals/ERC1155SinglePortal.t.sol +++ b/test/portals/ERC1155SinglePortal.t.sol @@ -4,28 +4,32 @@ pragma solidity ^0.8.22; import {IERC1155} from "@openzeppelin-contracts-5.2.0/token/ERC1155/IERC1155.sol"; +import { + IERC1155Receiver +} from "@openzeppelin-contracts-5.2.0/token/ERC1155/IERC1155Receiver.sol"; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; -import {InputEncoding} from "src/common/InputEncoding.sol"; import {IInputBox} from "src/inputs/IInputBox.sol"; +import {InputBox} from "src/inputs/InputBox.sol"; import {ERC1155SinglePortal} from "src/portals/ERC1155SinglePortal.sol"; import {IERC1155SinglePortal} from "src/portals/IERC1155SinglePortal.sol"; -import {Test} from "forge-std-1.9.6/src/Test.sol"; - +import {InputBoxTestUtils} from "../util/InputBoxTestUtils.sol"; +import {LibBytes} from "../util/LibBytes.sol"; +import {LibTopic} from "../util/LibTopic.sol"; import {SimpleSingleERC1155} from "../util/SimpleERC1155.sol"; -contract ERC1155SinglePortalTest is Test { - address _alice; - address _appContract; - IERC1155 _token; +contract ERC1155SinglePortalTest is Test, InputBoxTestUtils { + using LibTopic for address; + using LibBytes for bytes; + IInputBox _inputBox; IERC1155SinglePortal _portal; function setUp() public { - _alice = vm.addr(1); - _appContract = vm.addr(2); - _token = IERC1155(vm.addr(3)); - _inputBox = IInputBox(vm.addr(4)); + _inputBox = new InputBox(); _portal = new ERC1155SinglePortal(_inputBox); } @@ -33,128 +37,230 @@ contract ERC1155SinglePortalTest is Test { assertEq(address(_portal.getInputBox()), address(_inputBox)); } - function testDeposit( + function testDepositRevertApplicationNotDeployed( uint256 tokenId, uint256 value, bytes calldata baseLayerData, bytes calldata execLayerData - ) public { - bytes memory safeTransferFrom = - _encodeSafeTransferFrom(tokenId, value, baseLayerData); + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _randomAccountWithNoCode(); + + IERC1155 token = _randomSetup(sender, tokenId, value); - vm.mockCall(address(_token), safeTransferFrom, abi.encode()); - vm.expectCall(address(_token), safeTransferFrom, 1); + vm.prank(sender); + vm.expectRevert(_encodeApplicationNotDeployed(appContract)); + _portal.depositSingleERC1155Token( + token, appContract, tokenId, value, baseLayerData, execLayerData + ); + } - bytes memory payload = - _encodePayload(tokenId, value, baseLayerData, execLayerData); + function testDepositRevertApplicationReverted( + uint256 tokenId, + uint256 value, + bytes calldata baseLayerData, + bytes calldata execLayerData, + bytes calldata error + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReverts(error); - bytes memory addInput = _encodeAddInput(payload); + IERC1155 token = _randomSetup(sender, tokenId, value); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); - vm.expectCall(address(_inputBox), addInput, 1); + _mockOnErc1155Received(appContract, sender, tokenId, value, baseLayerData); - vm.prank(_alice); + vm.prank(sender); + vm.expectRevert(_encodeApplicationReverted(appContract, error)); _portal.depositSingleERC1155Token( - _token, _appContract, tokenId, value, baseLayerData, execLayerData + token, appContract, tokenId, value, baseLayerData, execLayerData ); } - function testTokenReverts( + function testDepositRevertIllformedApplicationReturnDataSize( uint256 tokenId, uint256 value, bytes calldata baseLayerData, bytes calldata execLayerData, - bytes memory errorData - ) public { - bytes memory safeTransferFrom = - _encodeSafeTransferFrom(tokenId, value, baseLayerData); - - vm.mockCall(address(_token), safeTransferFrom, abi.encode()); - vm.mockCallRevert(address(_token), safeTransferFrom, errorData); + bytes calldata returnData + ) external { + vm.assume(returnData.length != 32); - bytes memory payload = - _encodePayload(tokenId, value, baseLayerData, execLayerData); + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); - bytes memory addInput = _encodeAddInput(payload); + IERC1155 token = _randomSetup(sender, tokenId, value); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + _mockOnErc1155Received(appContract, sender, tokenId, value, baseLayerData); - vm.expectRevert(errorData); - - vm.prank(_alice); + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); _portal.depositSingleERC1155Token( - _token, _appContract, tokenId, value, baseLayerData, execLayerData + token, appContract, tokenId, value, baseLayerData, execLayerData ); } - function testSimpleSingleERC1155( + function testDepositRevertIllformedApplicationReturnDataInvalidBool( uint256 tokenId, - uint256 supply, uint256 value, bytes calldata baseLayerData, bytes calldata execLayerData - ) public { - value = bound(value, 0, supply); - _token = new SimpleSingleERC1155(_alice, tokenId, supply); - - vm.startPrank(_alice); + ) external { + uint256 returnValue = vm.randomUint(2, type(uint256).max); + bytes memory returnData = abi.encode(returnValue); - // Allow the portal to withdraw tokens from Alice - _token.setApprovalForAll(address(_portal), true); + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); - bytes memory payload = - _encodePayload(tokenId, value, baseLayerData, execLayerData); + IERC1155 token = _randomSetup(sender, tokenId, value); - bytes memory addInput = _encodeAddInput(payload); + _mockOnErc1155Received(appContract, sender, tokenId, value, baseLayerData); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); + _portal.depositSingleERC1155Token( + token, appContract, tokenId, value, baseLayerData, execLayerData + ); + } - // balances before - assertEq(_token.balanceOf(_alice, tokenId), supply); - assertEq(_token.balanceOf(_appContract, tokenId), 0); - assertEq(_token.balanceOf(address(_portal), tokenId), 0); + function testDepositRevertApplicationForeclosed( + uint256 tokenId, + uint256 value, + bytes calldata baseLayerData, + bytes calldata execLayerData + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newForeclosedAppMock(); - vm.expectCall(address(_inputBox), addInput, 1); + IERC1155 token = _randomSetup(sender, tokenId, value); - vm.expectEmit(true, true, true, true, address(_token)); - emit IERC1155.TransferSingle( - address(_portal), _alice, _appContract, tokenId, value - ); + _mockOnErc1155Received(appContract, sender, tokenId, value, baseLayerData); + vm.prank(sender); + vm.expectRevert(_encodeApplicationForeclosed(appContract)); _portal.depositSingleERC1155Token( - _token, _appContract, tokenId, value, baseLayerData, execLayerData + token, appContract, tokenId, value, baseLayerData, execLayerData ); - vm.stopPrank(); - - // balances after - assertEq(_token.balanceOf(_alice, tokenId), supply - value); - assertEq(_token.balanceOf(_appContract, tokenId), value); - assertEq(_token.balanceOf(address(_portal), tokenId), 0); } - function _encodePayload( + function testDeposit( uint256 tokenId, uint256 value, bytes calldata baseLayerData, - bytes calldata execLayerData - ) internal view returns (bytes memory) { - return InputEncoding.encodeSingleERC1155Deposit( - _token, _alice, tokenId, value, baseLayerData, execLayerData + bytes calldata execLayerData, + bytes[] calldata payloads + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newActiveAppMock(); + + IERC1155 token = _randomSetup(sender, tokenId, value); + + _mockOnErc1155Received(appContract, sender, tokenId, value, baseLayerData); + + _addInputs(_inputBox, appContract, payloads); + + uint256 senderBalance = token.balanceOf(sender, tokenId); + uint256 appContractBalance = token.balanceOf(appContract, tokenId); + + uint256 numOfInputs = _inputBox.getNumberOfInputs(appContract); + + vm.recordLogs(); + + vm.prank(sender); + _portal.depositSingleERC1155Token( + token, appContract, tokenId, value, baseLayerData, execLayerData ); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes memory input; + bytes memory payload; + uint256 numOfInputAdded; + uint256 numOfTransferSingle; + + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory log = logs[i]; + if (log.emitter == address(_inputBox)) { + (input, payload) = + _decodeInputAdded(log, appContract, address(_portal), numOfInputs); + ++numOfInputAdded; + } else if (log.emitter == address(token)) { + bytes32 topic0 = log.topics[0]; + if (topic0 == IERC1155.TransferSingle.selector) { + (uint256 arg1, uint256 arg2) = + abi.decode(log.data, (uint256, uint256)); + assertEq(log.topics[1], address(_portal).asTopic()); + assertEq(log.topics[2], sender.asTopic()); + assertEq(log.topics[3], appContract.asTopic()); + assertEq(arg1, tokenId); + assertEq(arg2, value); + ++numOfTransferSingle; + } else { + revert("unexpected token contract topic #0"); + } + } else { + revert("unexpected log emitter"); + } + } + + assertEq(numOfInputAdded, 1); + assertEq(numOfTransferSingle, 1); + + assertEq(token.balanceOf(sender, tokenId), senderBalance - value); + assertEq(token.balanceOf(appContract, tokenId), appContractBalance + value); + + assertEq(_inputBox.getNumberOfInputs(appContract), numOfInputs + 1); + assertEq(keccak256(input), _inputBox.getInputHash(appContract, numOfInputs)); + + bytes memory buffer = payload; + address tokenArg; + address senderArg; + uint256 tokenIdArg; + uint256 valueArg; + bytes memory baseLayerDataArg; + bytes memory execLayerDataArg; + + (tokenArg, buffer) = buffer.consumeAddress(); + (senderArg, buffer) = buffer.consumeAddress(); + (tokenIdArg, buffer) = buffer.consumeUint256(); + (valueArg, buffer) = buffer.consumeUint256(); + (baseLayerDataArg, execLayerDataArg) = abi.decode(buffer, (bytes, bytes)); + + assertEq(tokenArg, address(token)); + assertEq(senderArg, sender); + assertEq(tokenIdArg, tokenId); + assertEq(valueArg, value); + assertEq(baseLayerDataArg, baseLayerData); + assertEq(execLayerDataArg, execLayerData); } - function _encodeAddInput(bytes memory payload) internal view returns (bytes memory) { - return abi.encodeCall(IInputBox.addInput, (_appContract, payload)); + function _randomSetup(address sender, uint256 tokenId, uint256 value) + internal + returns (IERC1155 token) + { + // Deploy the ERC-1155 token contract with the sender's tokens pre-minted + token = new SimpleSingleERC1155(sender, tokenId, value); + + // Mine a random number of blocks + vm.roll(vm.randomUint(vm.getBlockNumber(), type(uint256).max)); + + // Make the sender give approval to the portal + vm.prank(sender); + token.setApprovalForAll(address(_portal), true); } - function _encodeSafeTransferFrom( + function _mockOnErc1155Received( + address appContract, + address sender, uint256 tokenId, uint256 value, - bytes calldata baseLayerData - ) internal view returns (bytes memory) { - return abi.encodeCall( - IERC1155.safeTransferFrom, - (_alice, _appContract, tokenId, value, baseLayerData) + bytes memory baseLayerData + ) internal { + vm.mockCall( + appContract, + abi.encodeCall( + IERC1155Receiver.onERC1155Received, + (address(_portal), sender, tokenId, value, baseLayerData) + ), + abi.encode(IERC1155Receiver.onERC1155Received.selector) ); } } diff --git a/test/portals/ERC20Portal.t.sol b/test/portals/ERC20Portal.t.sol index e7929a69..d0ffcbe1 100644 --- a/test/portals/ERC20Portal.t.sol +++ b/test/portals/ERC20Portal.t.sol @@ -5,138 +5,208 @@ pragma solidity ^0.8.22; import {IERC20} from "@openzeppelin-contracts-5.2.0/token/ERC20/IERC20.sol"; -import {InputEncoding} from "src/common/InputEncoding.sol"; +import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; + import {IInputBox} from "src/inputs/IInputBox.sol"; +import {InputBox} from "src/inputs/InputBox.sol"; import {ERC20Portal} from "src/portals/ERC20Portal.sol"; import {IERC20Portal} from "src/portals/IERC20Portal.sol"; -import {Test} from "forge-std-1.9.6/src/Test.sol"; - +import {InputBoxTestUtils} from "../util/InputBoxTestUtils.sol"; +import {LibBytes} from "../util/LibBytes.sol"; +import {LibTopic} from "../util/LibTopic.sol"; import {SimpleERC20} from "../util/SimpleERC20.sol"; -contract ERC20PortalTest is Test { - address _alice; - address _appContract; +contract ERC20PortalTest is Test, InputBoxTestUtils { + using LibTopic for address; + using LibBytes for bytes; + IInputBox _inputBox; - IERC20 _token; IERC20Portal _portal; + IERC20 _token; + + address immutable TOKEN_OWNER = vm.addr(1); + uint256 immutable TOTAL_SUPPLY = type(uint256).max; function setUp() public { - _alice = vm.addr(1); - _appContract = vm.addr(2); - _inputBox = IInputBox(vm.addr(3)); - _token = IERC20(vm.addr(4)); + _inputBox = new InputBox(); _portal = new ERC20Portal(_inputBox); + _token = new SimpleERC20(TOKEN_OWNER, TOTAL_SUPPLY); } function testGetInputBox() public view { assertEq(address(_portal.getInputBox()), address(_inputBox)); } - function testTokenReturnsTrue(uint256 value, bytes calldata data) public { - bytes memory transferFrom = _encodeTransferFrom(value); + function testDepositRevertApplicationNotDeployed( + uint256 value, + bytes calldata execLayerData + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _randomAccountWithNoCode(); - vm.mockCall(address(_token), transferFrom, abi.encode(true)); + _randomSetup(sender, appContract, value); - bytes memory payload = _encodePayload(_token, value, data); - - bytes memory addInput = _encodeAddInput(payload); - - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); - - vm.expectCall(address(_token), transferFrom, 1); - - vm.expectCall(address(_inputBox), addInput, 1); - - vm.prank(_alice); - _portal.depositERC20Tokens(_token, _appContract, value, data); + vm.prank(sender); + vm.expectRevert(_encodeApplicationNotDeployed(appContract)); + _portal.depositERC20Tokens(_token, appContract, value, execLayerData); } - function testTokenReturnsFalse(uint256 value, bytes calldata data) public { - bytes memory transferFrom = _encodeTransferFrom(value); - - vm.mockCall(address(_token), transferFrom, abi.encode(false)); - - bytes memory payload = _encodePayload(_token, value, data); + function testDepositRevertApplicationReverted( + uint256 value, + bytes calldata execLayerData, + bytes calldata error + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReverts(error); - bytes memory addInput = _encodeAddInput(payload); + _randomSetup(sender, appContract, value); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); - - vm.expectRevert(IERC20Portal.ERC20TransferFailed.selector); - - vm.prank(_alice); - _portal.depositERC20Tokens(_token, _appContract, value, data); + vm.prank(sender); + vm.expectRevert(_encodeApplicationReverted(appContract, error)); + _portal.depositERC20Tokens(_token, appContract, value, execLayerData); } - function testTokenReverts(uint256 value, bytes calldata data, bytes memory errorData) - public - { - bytes memory transferFrom = _encodeTransferFrom(value); - - vm.mockCallRevert(address(_token), transferFrom, errorData); - - bytes memory payload = _encodePayload(_token, value, data); + function testDepositRevertIllformedApplicationReturnDataSize( + uint256 value, + bytes calldata execLayerData, + bytes calldata returnData + ) external { + vm.assume(returnData.length != 32); - bytes memory addInput = _encodeAddInput(payload); + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + _randomSetup(sender, appContract, value); - vm.expectRevert(errorData); - - vm.prank(_alice); - _portal.depositERC20Tokens(_token, _appContract, value, data); + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); + _portal.depositERC20Tokens(_token, appContract, value, execLayerData); } - function testSimpleERC20(uint256 supply, uint256 value, bytes calldata data) public { - value = bound(value, 0, supply); - - SimpleERC20 token = new SimpleERC20(_alice, supply); - - bytes memory payload = _encodePayload(token, value, data); + function testDepositRevertIllformedApplicationReturnDataInvalidBool( + uint256 value, + bytes calldata execLayerData + ) external { + uint256 returnValue = vm.randomUint(2, type(uint256).max); + bytes memory returnData = abi.encode(returnValue); - bytes memory addInput = _encodeAddInput(payload); + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); - vm.startPrank(_alice); + _randomSetup(sender, appContract, value); - token.approve(address(_portal), value); + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); + _portal.depositERC20Tokens(_token, appContract, value, execLayerData); + } - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + function testDepositRevertApplicationForeclosed( + uint256 value, + bytes calldata execLayerData + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newForeclosedAppMock(); - // balances before - assertEq(token.balanceOf(_alice), supply); - assertEq(token.balanceOf(_appContract), 0); - assertEq(token.balanceOf(address(_portal)), 0); + _randomSetup(sender, appContract, value); - vm.expectCall(address(_inputBox), addInput, 1); + vm.prank(sender); + vm.expectRevert(_encodeApplicationForeclosed(appContract)); + _portal.depositERC20Tokens(_token, appContract, value, execLayerData); + } - vm.expectEmit(true, true, false, true, address(token)); - emit IERC20.Transfer(_alice, _appContract, value); + function testDeposit( + uint256 value, + bytes calldata execLayerData, + bytes[] calldata payloads + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newActiveAppMock(); + + _randomSetup(sender, appContract, value); + _addInputs(_inputBox, appContract, payloads); + + uint256 senderBalance = _token.balanceOf(sender); + uint256 appContractBalance = _token.balanceOf(appContract); + + uint256 numOfInputs = _inputBox.getNumberOfInputs(appContract); + + vm.recordLogs(); + + vm.prank(sender); + _portal.depositERC20Tokens(_token, appContract, value, execLayerData); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes memory input; + bytes memory payload; + uint256 numOfInputAdded; + uint256 numOfTransfer; + + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory log = logs[i]; + if (log.emitter == address(_inputBox)) { + (input, payload) = + _decodeInputAdded(log, appContract, address(_portal), numOfInputs); + ++numOfInputAdded; + } else if (log.emitter == address(_token)) { + bytes32 topic0 = log.topics[0]; + if (topic0 == IERC20.Transfer.selector) { + uint256 arg1 = abi.decode(log.data, (uint256)); + assertEq(log.topics[1], sender.asTopic()); + assertEq(log.topics[2], appContract.asTopic()); + assertEq(arg1, value); + ++numOfTransfer; + } else { + revert("unexpected token contract topic #0"); + } + } else { + revert("unexpected log emitter"); + } + } + + assertEq(numOfInputAdded, 1); + assertEq(numOfTransfer, 1); + + assertEq(_token.balanceOf(sender), senderBalance - value); + assertEq(_token.balanceOf(appContract), appContractBalance + value); + + assertEq(_inputBox.getNumberOfInputs(appContract), numOfInputs + 1); + assertEq(keccak256(input), _inputBox.getInputHash(appContract, numOfInputs)); + + bytes memory buffer = payload; + address tokenArg; + address senderArg; + uint256 valueArg; + bytes memory execLayerDataArg; + + (tokenArg, buffer) = buffer.consumeAddress(); + (senderArg, buffer) = buffer.consumeAddress(); + (valueArg, execLayerDataArg) = buffer.consumeUint256(); + + assertEq(tokenArg, address(_token)); + assertEq(senderArg, sender); + assertEq(valueArg, value); + assertEq(execLayerDataArg, execLayerData); + } - // deposit tokens - _portal.depositERC20Tokens(token, _appContract, value, data); + function _randomSetup(address sender, address appContract, uint256 value) internal { + // Mine a random number of blocks + vm.roll(vm.randomUint(vm.getBlockNumber(), type(uint256).max)); + // Transfer a random amount of tokens to each participant + vm.startPrank(TOKEN_OWNER); + assertTrue(_token.transfer(sender, _randomAmountGe(value))); + assertTrue(_token.transfer(address(_portal), _randomAmountGe(0))); + assertTrue(_token.transfer(appContract, _randomAmountGe(0))); vm.stopPrank(); - // balances after - assertEq(token.balanceOf(_alice), supply - value); - assertEq(token.balanceOf(_appContract), value); - assertEq(token.balanceOf(address(_portal)), 0); - } - - function _encodePayload(IERC20 token, uint256 value, bytes calldata data) - internal - view - returns (bytes memory) - { - return InputEncoding.encodeERC20Deposit(token, _alice, value, data); - } - - function _encodeTransferFrom(uint256 value) internal view returns (bytes memory) { - return abi.encodeCall(IERC20.transferFrom, (_alice, _appContract, value)); + // Make the sender give enough allowance to the portal + vm.prank(sender); + _token.approve(address(_portal), vm.randomUint(value, type(uint256).max)); } - function _encodeAddInput(bytes memory payload) internal view returns (bytes memory) { - return abi.encodeCall(IInputBox.addInput, (_appContract, payload)); + function _randomAmountGe(uint256 min) internal returns (uint256) { + return vm.randomUint(min, _token.balanceOf(TOKEN_OWNER)); } } diff --git a/test/portals/ERC721Portal.t.sol b/test/portals/ERC721Portal.t.sol index 958ce339..f3d7b4ce 100644 --- a/test/portals/ERC721Portal.t.sol +++ b/test/portals/ERC721Portal.t.sol @@ -4,28 +4,32 @@ pragma solidity ^0.8.22; import {IERC721} from "@openzeppelin-contracts-5.2.0/token/ERC721/IERC721.sol"; +import { + IERC721Receiver +} from "@openzeppelin-contracts-5.2.0/token/ERC721/IERC721Receiver.sol"; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; -import {InputEncoding} from "src/common/InputEncoding.sol"; import {IInputBox} from "src/inputs/IInputBox.sol"; +import {InputBox} from "src/inputs/InputBox.sol"; import {ERC721Portal} from "src/portals/ERC721Portal.sol"; import {IERC721Portal} from "src/portals/IERC721Portal.sol"; -import {Test} from "forge-std-1.9.6/src/Test.sol"; - +import {InputBoxTestUtils} from "../util/InputBoxTestUtils.sol"; +import {LibBytes} from "../util/LibBytes.sol"; +import {LibTopic} from "../util/LibTopic.sol"; import {SimpleERC721} from "../util/SimpleERC721.sol"; -contract ERC721PortalTest is Test { - address _alice; - address _appContract; - IERC721 _token; +contract ERC721PortalTest is Test, InputBoxTestUtils { + using LibTopic for address; + using LibBytes for bytes; + IInputBox _inputBox; IERC721Portal _portal; function setUp() public { - _alice = vm.addr(1); - _appContract = vm.addr(2); - _token = IERC721(vm.addr(3)); - _inputBox = IInputBox(vm.addr(4)); + _inputBox = new InputBox(); _portal = new ERC721Portal(_inputBox); } @@ -33,118 +37,220 @@ contract ERC721PortalTest is Test { assertEq(address(_portal.getInputBox()), address(_inputBox)); } - function testDeposit( + function testDepositRevertApplicationNotDeployed( uint256 tokenId, bytes calldata baseLayerData, bytes calldata execLayerData - ) public { - bytes memory safeTransferFrom = _encodeSafeTransferFrom(tokenId, baseLayerData); + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _randomAccountWithNoCode(); - vm.mockCall(address(_token), safeTransferFrom, abi.encode()); - vm.expectCall(address(_token), safeTransferFrom, 1); + IERC721 token = _randomSetup(sender, tokenId); - bytes memory payload = - _encodePayload(_token, tokenId, baseLayerData, execLayerData); + vm.prank(sender); + vm.expectRevert(_encodeApplicationNotDeployed(appContract)); + _portal.depositERC721Token( + token, appContract, tokenId, baseLayerData, execLayerData + ); + } + + function testDepositRevertApplicationReverted( + uint256 tokenId, + bytes calldata baseLayerData, + bytes calldata execLayerData, + bytes calldata error + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReverts(error); - bytes memory addInput = _encodeAddInput(payload); + IERC721 token = _randomSetup(sender, tokenId); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); - vm.expectCall(address(_inputBox), addInput, 1); + _mockOnErc721Received(appContract, sender, tokenId, baseLayerData); - vm.prank(_alice); + vm.prank(sender); + vm.expectRevert(_encodeApplicationReverted(appContract, error)); _portal.depositERC721Token( - _token, _appContract, tokenId, baseLayerData, execLayerData + token, appContract, tokenId, baseLayerData, execLayerData ); } - function testTokenReverts( + function testDepositRevertIllformedApplicationReturnDataSize( uint256 tokenId, bytes calldata baseLayerData, bytes calldata execLayerData, - bytes memory errorData - ) public { - bytes memory safeTransferFrom = _encodeSafeTransferFrom(tokenId, baseLayerData); - - vm.mockCall(address(_token), safeTransferFrom, abi.encode()); - vm.mockCallRevert(address(_token), safeTransferFrom, errorData); + bytes calldata returnData + ) external { + vm.assume(returnData.length != 32); - bytes memory payload = - _encodePayload(_token, tokenId, baseLayerData, execLayerData); + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); - bytes memory addInput = _encodeAddInput(payload); + IERC721 token = _randomSetup(sender, tokenId); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + _mockOnErc721Received(appContract, sender, tokenId, baseLayerData); - vm.expectRevert(errorData); - - vm.prank(_alice); + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); _portal.depositERC721Token( - _token, _appContract, tokenId, baseLayerData, execLayerData + token, appContract, tokenId, baseLayerData, execLayerData ); } - function testSimpleERC721( + function testDepositRevertIllformedApplicationReturnDataInvalidBool( uint256 tokenId, bytes calldata baseLayerData, bytes calldata execLayerData - ) public { - SimpleERC721 token = new SimpleERC721(_alice, tokenId); - - vm.startPrank(_alice); + ) external { + uint256 returnValue = vm.randomUint(2, type(uint256).max); + bytes memory returnData = abi.encode(returnValue); - token.approve(address(_portal), tokenId); + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); - // token owner before - assertEq(token.ownerOf(tokenId), _alice); + IERC721 token = _randomSetup(sender, tokenId); - bytes memory payload = - _encodePayload(token, tokenId, baseLayerData, execLayerData); + _mockOnErc721Received(appContract, sender, tokenId, baseLayerData); - bytes memory addInput = _encodeAddInput(payload); + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); + _portal.depositERC721Token( + token, appContract, tokenId, baseLayerData, execLayerData + ); + } - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + function testDepositRevertApplicationForeclosed( + uint256 tokenId, + bytes calldata baseLayerData, + bytes calldata execLayerData + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newForeclosedAppMock(); - vm.expectCall(address(_inputBox), addInput, 1); + IERC721 token = _randomSetup(sender, tokenId); - vm.expectEmit(true, true, true, false, address(token)); - emit IERC721.Transfer(_alice, _appContract, tokenId); + _mockOnErc721Received(appContract, sender, tokenId, baseLayerData); + vm.prank(sender); + vm.expectRevert(_encodeApplicationForeclosed(appContract)); _portal.depositERC721Token( - token, _appContract, tokenId, baseLayerData, execLayerData + token, appContract, tokenId, baseLayerData, execLayerData ); - - vm.stopPrank(); - - // token owner after - assertEq(token.ownerOf(tokenId), _appContract); } - function _encodePayload( - IERC721 token, + function testDeposit( uint256 tokenId, bytes calldata baseLayerData, - bytes calldata execLayerData - ) internal view returns (bytes memory) { - return InputEncoding.encodeERC721Deposit( - token, _alice, tokenId, baseLayerData, execLayerData + bytes calldata execLayerData, + bytes[] calldata payloads + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newActiveAppMock(); + + IERC721 token = _randomSetup(sender, tokenId); + + _mockOnErc721Received(appContract, sender, tokenId, baseLayerData); + + _addInputs(_inputBox, appContract, payloads); + + assertEq(token.ownerOf(tokenId), sender); + + uint256 senderBalance = token.balanceOf(sender); + uint256 appContractBalance = token.balanceOf(appContract); + + uint256 numOfInputs = _inputBox.getNumberOfInputs(appContract); + + vm.recordLogs(); + + vm.prank(sender); + _portal.depositERC721Token( + token, appContract, tokenId, baseLayerData, execLayerData ); - } - function _encodeAddInput(bytes memory payload) internal view returns (bytes memory) { - return abi.encodeCall(IInputBox.addInput, (_appContract, payload)); + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes memory input; + bytes memory payload; + uint256 numOfInputAdded; + uint256 numOfTransfer; + + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory log = logs[i]; + if (log.emitter == address(_inputBox)) { + (input, payload) = + _decodeInputAdded(log, appContract, address(_portal), numOfInputs); + ++numOfInputAdded; + } else if (log.emitter == address(token)) { + bytes32 topic0 = log.topics[0]; + if (topic0 == IERC721.Transfer.selector) { + assertEq(log.topics[1], sender.asTopic()); + assertEq(log.topics[2], appContract.asTopic()); + assertEq(log.topics[3], bytes32(tokenId)); + assertEq(log.data.length, 0); + ++numOfTransfer; + } else { + revert("unexpected token contract topic #0"); + } + } else { + revert("unexpected log emitter"); + } + } + + assertEq(numOfInputAdded, 1); + assertEq(numOfTransfer, 1); + + assertEq(token.balanceOf(sender), senderBalance - 1); + assertEq(token.balanceOf(appContract), appContractBalance + 1); + assertEq(token.ownerOf(tokenId), appContract); + + assertEq(_inputBox.getNumberOfInputs(appContract), numOfInputs + 1); + assertEq(keccak256(input), _inputBox.getInputHash(appContract, numOfInputs)); + + bytes memory buffer = payload; + address tokenArg; + address senderArg; + uint256 tokenIdArg; + bytes memory baseLayerDataArg; + bytes memory execLayerDataArg; + + (tokenArg, buffer) = buffer.consumeAddress(); + (senderArg, buffer) = buffer.consumeAddress(); + (tokenIdArg, buffer) = buffer.consumeUint256(); + (baseLayerDataArg, execLayerDataArg) = abi.decode(buffer, (bytes, bytes)); + + assertEq(tokenArg, address(token)); + assertEq(senderArg, sender); + assertEq(tokenIdArg, tokenId); + assertEq(baseLayerDataArg, baseLayerData); + assertEq(execLayerDataArg, execLayerData); } - function _encodeSafeTransferFrom(uint256 tokenId, bytes calldata baseLayerData) + function _randomSetup(address sender, uint256 tokenId) internal - view - returns (bytes memory) + returns (IERC721 token) { - return abi.encodeWithSignature( - "safeTransferFrom(address,address,uint256,bytes)", - _alice, - _appContract, - tokenId, - baseLayerData + // Deploy the ERC-721 token contract with the sender's NFT pre-minted + token = new SimpleERC721(sender, tokenId); + + // Mine a random number of blocks + vm.roll(vm.randomUint(vm.getBlockNumber(), type(uint256).max)); + + // Make the sender give approval to the portal + vm.prank(sender); + token.approve(address(_portal), tokenId); + } + + function _mockOnErc721Received( + address appContract, + address sender, + uint256 tokenId, + bytes memory baseLayerData + ) internal { + vm.mockCall( + appContract, + abi.encodeCall( + IERC721Receiver.onERC721Received, + (address(_portal), sender, tokenId, baseLayerData) + ), + abi.encode(IERC721Receiver.onERC721Received.selector) ); } } diff --git a/test/portals/EtherPortal.t.sol b/test/portals/EtherPortal.t.sol index 0e7bf617..8f090c65 100644 --- a/test/portals/EtherPortal.t.sol +++ b/test/portals/EtherPortal.t.sol @@ -3,87 +3,173 @@ pragma solidity ^0.8.22; -import {InputEncoding} from "src/common/InputEncoding.sol"; +import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; + import {IInputBox} from "src/inputs/IInputBox.sol"; +import {InputBox} from "src/inputs/InputBox.sol"; import {EtherPortal} from "src/portals/EtherPortal.sol"; import {IEtherPortal} from "src/portals/IEtherPortal.sol"; -import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {InputBoxTestUtils} from "../util/InputBoxTestUtils.sol"; +import {LibBytes} from "../util/LibBytes.sol"; + +contract EtherPortalTest is Test, InputBoxTestUtils { + using LibBytes for bytes; -contract EtherPortalTest is Test { - address _alice; - address _appContract; IInputBox _inputBox; IEtherPortal _portal; - function setUp() public { - _alice = vm.addr(1); - _appContract = vm.addr(2); - _inputBox = IInputBox(vm.addr(3)); + function setUp() external { + _inputBox = new InputBox(); _portal = new EtherPortal(_inputBox); } - function testGetInputBox() public view { + function testGetInputBox() external view { assertEq(address(_portal.getInputBox()), address(_inputBox)); } - function testDeposit(uint256 value, bytes calldata data) public { - value = _boundValue(value); + function testDepositRevertApplicationNotDeployed( + uint256 value, + bytes calldata execLayerData + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _randomAccountWithNoCode(); + + _randomSetup(sender, appContract, value); - bytes memory payload = _encodePayload(value, data); + vm.prank(sender); + vm.expectRevert(_encodeApplicationNotDeployed(appContract)); + _portal.depositEther{value: value}(appContract, execLayerData); + } - bytes memory addInput = _encodeAddInput(payload); + function testDepositRevertApplicationReverted( + uint256 value, + bytes calldata execLayerData, + bytes calldata error + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReverts(error); - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + _randomSetup(sender, appContract, value); - vm.expectCall(_appContract, value, abi.encode(), 1); + vm.prank(sender); + vm.expectRevert(_encodeApplicationReverted(appContract, error)); + _portal.depositEther{value: value}(appContract, execLayerData); + } - vm.expectCall(address(_inputBox), addInput, 1); + function testDepositRevertIllformedApplicationReturnDataSize( + uint256 value, + bytes calldata execLayerData, + bytes calldata returnData + ) external { + vm.assume(returnData.length != 32); - uint256 balance = _appContract.balance; + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); - vm.deal(_alice, value); - vm.prank(_alice); - _portal.depositEther{value: value}(_appContract, data); + _randomSetup(sender, appContract, value); - assertEq(_appContract.balance, balance + value); + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); + _portal.depositEther{value: value}(appContract, execLayerData); } - function testDepositReverts( + function testDepositRevertIllformedApplicationReturnDataInvalidBool( uint256 value, - bytes calldata data, - bytes calldata errorData - ) public { - value = _boundValue(value); + bytes calldata execLayerData + ) external { + uint256 returnValue = vm.randomUint(2, type(uint256).max); + bytes memory returnData = abi.encode(returnValue); - vm.mockCallRevert(_appContract, value, abi.encode(), errorData); + address sender = _randomAccountWithNoCode(); + address appContract = _newAppMockReturns(returnData); - bytes memory payload = _encodePayload(value, data); + _randomSetup(sender, appContract, value); - bytes memory addInput = _encodeAddInput(payload); + vm.prank(sender); + vm.expectRevert(_encodeIllformedApplicationReturnData(appContract, returnData)); + _portal.depositEther{value: value}(appContract, execLayerData); + } - vm.mockCall(address(_inputBox), addInput, abi.encode(bytes32(0))); + function testDepositRevertApplicationForeclosed( + uint256 value, + bytes calldata execLayerData + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newForeclosedAppMock(); - vm.expectRevert(IEtherPortal.EtherTransferFailed.selector); + _randomSetup(sender, appContract, value); - vm.deal(_alice, value); - vm.prank(_alice); - _portal.depositEther{value: value}(_appContract, data); + vm.prank(sender); + vm.expectRevert(_encodeApplicationForeclosed(appContract)); + _portal.depositEther{value: value}(appContract, execLayerData); } - function _encodePayload(uint256 value, bytes calldata data) - internal - view - returns (bytes memory) - { - return InputEncoding.encodeEtherDeposit(_alice, value, data); + function testDeposit( + uint256 value, + bytes calldata execLayerData, + bytes[] calldata payloads + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newActiveAppMock(); + + _randomSetup(sender, appContract, value); + _addInputs(_inputBox, appContract, payloads); + + uint256 senderBalance = sender.balance; + uint256 appContractBalance = appContract.balance; + + uint256 numOfInputs = _inputBox.getNumberOfInputs(appContract); + + vm.recordLogs(); + + vm.prank(sender); + _portal.depositEther{value: value}(appContract, execLayerData); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes memory input; + bytes memory payload; + uint256 numOfInputAdded; + + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory log = logs[i]; + if (log.emitter == address(_inputBox)) { + (input, payload) = + _decodeInputAdded(log, appContract, address(_portal), numOfInputs); + ++numOfInputAdded; + } else { + revert("unexpected log emitter"); + } + } + + assertEq(numOfInputAdded, 1); + assertEq(sender.balance, senderBalance - value); + assertEq(appContract.balance, appContractBalance + value); + + assertEq(_inputBox.getNumberOfInputs(appContract), numOfInputs + 1); + assertEq(keccak256(input), _inputBox.getInputHash(appContract, numOfInputs)); + + bytes memory buffer = payload; + address senderArg; + uint256 valueArg; + bytes memory execLayerDataArg; + + (senderArg, buffer) = buffer.consumeAddress(); + (valueArg, execLayerDataArg) = buffer.consumeUint256(); + + assertEq(senderArg, sender); + assertEq(valueArg, value); + assertEq(execLayerDataArg, execLayerData); } - function _encodeAddInput(bytes memory payload) internal view returns (bytes memory) { - return abi.encodeCall(IInputBox.addInput, (_appContract, payload)); - } + function _randomSetup(address sender, address appContract, uint256 value) internal { + // Mine a random number of blocks + vm.roll(vm.randomUint(vm.getBlockNumber(), type(uint256).max)); - function _boundValue(uint256 value) internal view returns (uint256) { - return bound(value, 0, address(this).balance); + // Transfer a random amount of Ether to each participant + vm.deal(sender, vm.randomUint(value, type(uint256).max)); + vm.deal(address(_portal), vm.randomUint(0, type(uint256).max - value)); + vm.deal(appContract, vm.randomUint(0, type(uint256).max - value)); } } diff --git a/test/util/EvmAdvanceEncoder.sol b/test/util/EvmAdvanceEncoder.sol deleted file mode 100644 index 86652e3a..00000000 --- a/test/util/EvmAdvanceEncoder.sol +++ /dev/null @@ -1,31 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -/// @title EVM Advance Encoder -pragma solidity ^0.8.22; - -import {Inputs} from "src/common/Inputs.sol"; - -library EvmAdvanceEncoder { - function encode( - uint256 chainId, - address appContract, - address sender, - uint256 index, - bytes memory payload - ) internal view returns (bytes memory) { - return abi.encodeCall( - Inputs.EvmAdvance, - ( - chainId, - appContract, - sender, - block.number, - block.timestamp, - block.prevrandao, - index, - payload - ) - ); - } -} diff --git a/test/util/InputBoxTestUtils.sol b/test/util/InputBoxTestUtils.sol new file mode 100644 index 00000000..3c028d33 --- /dev/null +++ b/test/util/InputBoxTestUtils.sol @@ -0,0 +1,79 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Vm} from "forge-std-1.9.6/src/Vm.sol"; + +import {Inputs} from "src/common/Inputs.sol"; +import {IInputBox} from "src/inputs/IInputBox.sol"; + +import {ApplicationCheckerTestUtils} from "./ApplicationCheckerTestUtils.sol"; +import {LibBytes} from "./LibBytes.sol"; +import {LibTopic} from "./LibTopic.sol"; + +contract InputBoxTestUtils is ApplicationCheckerTestUtils { + using LibTopic for address; + using LibBytes for bytes; + + function _addInputs( + IInputBox inputBox, + address appContract, + bytes[] calldata payloads + ) internal { + for (uint256 i; i < payloads.length; ++i) { + inputBox.addInput(appContract, payloads[i]); + } + } + + function _decodeInput( + bytes memory input, + address appContract, + address sender, + uint256 index + ) internal view returns (bytes memory) { + (bytes4 inputSelector, bytes memory inputArgs) = input.consumeBytes4(); + assertEq(inputSelector, Inputs.EvmAdvance.selector); + + ( + uint256 chainIdArg, + address appContractArg, + address msgSenderArg, + uint256 blockNumberArg, + uint256 blockTimestampArg, + uint256 prevRandaoArg, + uint256 indexArg, + bytes memory payloadArg + ) = abi.decode( + inputArgs, + (uint256, address, address, uint256, uint256, uint256, uint256, bytes) + ); + + assertEq(chainIdArg, block.chainid); + assertEq(appContractArg, appContract); + assertEq(msgSenderArg, sender); + assertEq(blockNumberArg, vm.getBlockNumber()); + assertEq(blockTimestampArg, vm.getBlockTimestamp()); + assertEq(prevRandaoArg, block.prevrandao); + assertEq(indexArg, index); + + return payloadArg; + } + + function _decodeInputAdded( + Vm.Log memory log, + address appContract, + address sender, + uint256 index + ) internal view returns (bytes memory input, bytes memory payload) { + require(log.topics.length >= 1, "unexpected InputBox annonymous event"); + require( + log.topics[0] == IInputBox.InputAdded.selector, + "unexpected selector of InputBox event" + ); + assertEq(log.topics[1], appContract.asTopic()); + assertEq(log.topics[2], bytes32(index)); + (input) = abi.decode(log.data, (bytes)); + payload = _decodeInput(input, appContract, sender, index); + } +} diff --git a/test/util/LibAddressArray.sol b/test/util/LibAddressArray.sol index 1d379050..248e1017 100644 --- a/test/util/LibAddressArray.sol +++ b/test/util/LibAddressArray.sol @@ -34,4 +34,15 @@ library LibAddressArray { } return false; } + + function repeat(address addr, uint256 n) + internal + pure + returns (address[] memory array) + { + array = new address[](n); + for (uint256 i; i < array.length; ++i) { + array[i] = addr; + } + } } diff --git a/test/util/LibAddressArray.t.sol b/test/util/LibAddressArray.t.sol index bfadab22..efdd649d 100644 --- a/test/util/LibAddressArray.t.sol +++ b/test/util/LibAddressArray.t.sol @@ -19,6 +19,7 @@ library ExternalLibAddressArray { contract LibAddressArrayTest is Test { using LibAddressArray for address[]; + using LibAddressArray for address; using LibAddressArray for Vm; function testRandomAddressIn(address[] memory array) external { @@ -101,4 +102,12 @@ contract LibAddressArrayTest is Test { assertTrue(ba.contains(a)); assertTrue(ba.contains(b)); } + + function testRepeat(address addr, uint8 n) external pure { + address[] memory array = addr.repeat(n); + assertEq(array.length, n); + for (uint256 i; i < array.length; ++i) { + assertEq(array[i], addr); + } + } } diff --git a/test/util/LibBytes.sol b/test/util/LibBytes.sol new file mode 100644 index 00000000..e46e5f74 --- /dev/null +++ b/test/util/LibBytes.sol @@ -0,0 +1,47 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +library LibBytes { + error BufferTooSmall(bytes buffer, uint256 minSize); + + function consumeBytes4(bytes calldata buffer) + external + pure + onlyBufferLengthGe(buffer, 4) + returns (bytes4, bytes memory) + { + return (bytes4(buffer[:4]), buffer[4:]); + } + + function consumeAddress(bytes calldata buffer) + external + pure + onlyBufferLengthGe(buffer, 20) + returns (address, bytes memory) + { + return (address(uint160(bytes20(buffer[:20]))), buffer[20:]); + } + + function consumeUint256(bytes calldata buffer) + external + pure + onlyBufferLengthGe(buffer, 32) + returns (uint256, bytes memory) + { + return (uint256(bytes32(buffer[:32])), buffer[32:]); + } + + modifier onlyBufferLengthGe(bytes calldata buffer, uint256 minSize) { + checkBufferLengthAgainstMinSize(buffer, minSize); + _; + } + + function checkBufferLengthAgainstMinSize(bytes calldata buffer, uint256 minSize) + internal + pure + { + require(buffer.length >= minSize, BufferTooSmall(buffer, minSize)); + } +} diff --git a/test/util/LibBytes.t.sol b/test/util/LibBytes.t.sol new file mode 100644 index 00000000..c4f7c3df --- /dev/null +++ b/test/util/LibBytes.t.sol @@ -0,0 +1,52 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {LibBytes} from "./LibBytes.sol"; + +contract LibBytesTest is Test { + using LibBytes for bytes; + + function testConsumeBytes4(bytes calldata buffer) external pure { + try buffer.consumeBytes4() returns (bytes4 value, bytes memory suffix) { + assertEq(buffer, abi.encodePacked(value, suffix)); + } catch (bytes memory error) { + _testConsumeError(error, buffer, 4); + } + } + + function testConsumeAddress(bytes calldata buffer) external pure { + try buffer.consumeAddress() returns (address value, bytes memory suffix) { + assertEq(buffer, abi.encodePacked(value, suffix)); + } catch (bytes memory error) { + _testConsumeError(error, buffer, 20); + } + } + + function testConsumeUint256(bytes calldata buffer) external pure { + try buffer.consumeUint256() returns (uint256 value, bytes memory suffix) { + assertEq(buffer, abi.encodePacked(value, suffix)); + } catch (bytes memory error) { + _testConsumeError(error, buffer, 32); + } + } + + function _testConsumeError(bytes memory error, bytes memory buffer, uint256 minSize) + internal + pure + { + assertLt(buffer.length, minSize); + assertEq(error, _encodeBufferTooSmall(buffer, minSize)); + } + + function _encodeBufferTooSmall(bytes memory buffer, uint256 minSize) + internal + pure + returns (bytes memory encodedError) + { + return abi.encodeWithSelector(LibBytes.BufferTooSmall.selector, buffer, minSize); + } +} diff --git a/test/util/LibUint256Array.sol b/test/util/LibUint256Array.sol index 38bc2d76..ef2cbe95 100644 --- a/test/util/LibUint256Array.sol +++ b/test/util/LibUint256Array.sol @@ -19,6 +19,23 @@ library LibUint256Array { } } + function randomUniqueUint256Array(Vm vm, uint256 n) + internal + returns (uint256[] memory array) + { + array = new uint256[](n); + for (uint256 i; i < array.length; ++i) { + uint256 elem; + while (true) { + elem = vm.randomUint(); + if (!containsBefore(array, elem, i)) { + break; + } + } + array[i] = elem; + } + } + function sequence(uint256 start, uint256 n) internal pure @@ -48,11 +65,44 @@ library LibUint256Array { } function contains(uint256[] memory array, uint256 elem) internal pure returns (bool) { - for (uint256 i; i < array.length; ++i) { + return containsBefore(array, elem, array.length); + } + + function containsBefore(uint256[] memory array, uint256 elem, uint256 n) + internal + pure + returns (bool) + { + require(n <= array.length, "cannot check past array length"); + for (uint256 i; i < n; ++i) { if (array[i] == elem) { return true; } } return false; } + + function sub(uint256[] memory a, uint256[] memory b) + internal + pure + returns (uint256[] memory c) + { + require(a.length == b.length, "vector subtraction terms have inequal lengths"); + c = new uint256[](a.length); + for (uint256 i; i < a.length; ++i) { + c[i] = a[i] - b[i]; + } + } + + function add(uint256[] memory a, uint256[] memory b) + internal + pure + returns (uint256[] memory c) + { + require(a.length == b.length, "vector addition terms have inequal lengths"); + c = new uint256[](a.length); + for (uint256 i; i < a.length; ++i) { + c[i] = a[i] + b[i]; + } + } } diff --git a/test/util/LibUint256Array.t.sol b/test/util/LibUint256Array.t.sol index c18d6f5d..7ee4f529 100644 --- a/test/util/LibUint256Array.t.sol +++ b/test/util/LibUint256Array.t.sol @@ -87,4 +87,60 @@ contract LibUint256ArrayTest is Test { } assertFalse(LibUint256Array.contains(array, notElem)); } + + function testContainsBefore(uint256[] memory array) external { + for (uint256 i; i < array.length; ++i) { + uint256 elem = array[i]; + uint256 j = vm.randomUint(i + 1, array.length); + assertTrue( + LibUint256Array.containsBefore(array, elem, j), + "element in array should be contained before an index greater its own" + ); + } + { + uint256 notElem; + uint256 i = vm.randomUint(0, array.length); + while (true) { + bool isElem = false; + notElem = vm.randomUint(); + for (uint256 j; j < i; ++j) { + if (notElem == array[j]) { + isElem = true; + break; + } + } + if (!isElem) { + break; + } + } + assertFalse( + LibUint256Array.containsBefore(array, notElem, i), + "element not in subarray should not be contained before any of its indices" + ); + } + } + + function testRandomUniqueUint256Array(uint8 n) external { + uint256[] memory array = vm.randomUniqueUint256Array(n); + assertEq(array.length, n); + for (uint256 i; i < array.length; ++i) { + uint256 elem = array[i]; + uint256 count = ++_histogram[elem]; + assertEq(count, 1, "array element not unique"); + } + } + + function testAddAndSub(uint8 n) external { + uint256[] memory a = new uint256[](n); + uint256[] memory b = new uint256[](n); + uint256[] memory c = new uint256[](n); + for (uint256 i; i < a.length; ++i) { + c[i] = vm.randomUint(); + a[i] = vm.randomUint(0, c[i]); + b[i] = c[i] - a[i]; + } + assertEq(LibUint256Array.add(a, b), c); + assertEq(LibUint256Array.sub(c, b), a); + assertEq(LibUint256Array.sub(c, a), b); + } } From 50b9626472311187535026c401bb6669ac3575d6 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Thu, 5 Mar 2026 09:02:25 -0300 Subject: [PATCH 26/48] Add `getLastFinalizedMachineMerkleRoot` function --- src/consensus/AbstractConsensus.sol | 27 +- src/consensus/IOutputsMerkleRootValidator.sol | 10 + .../authority/AuthorityFactory.t.sol | 238 +++--- test/consensus/quorum/QuorumFactory.t.sol | 739 ++++++++++-------- test/dapp/ApplicationFactory.t.sol | 40 +- test/dapp/SelfHostedApplicationFactory.t.sol | 27 +- test/util/ConsensusTestUtils.sol | 52 +- test/util/LibUint256Array.sol | 33 + test/util/LibUint256Array.t.sol | 91 ++- 9 files changed, 793 insertions(+), 464 deletions(-) diff --git a/src/consensus/AbstractConsensus.sol b/src/consensus/AbstractConsensus.sol index c77ddcaf..3d3f17a7 100644 --- a/src/consensus/AbstractConsensus.sol +++ b/src/consensus/AbstractConsensus.sol @@ -25,12 +25,22 @@ abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { /// @notice Indexes accepted claims by application contract address. mapping(address => mapping(bytes32 => bool)) private _validOutputsMerkleRoots; + + /// @notice Indexes number of the first unprocessed block + /// by application contract address. + mapping(address => uint256) private _firstUnprocessedBlockNumbers; + + /// @notice Indexes machine merkle root of the most recently accepted claim + /// by application contract address. + mapping(address => bytes32) private _lastFinalizedMachineMerkleRoots; + /// @notice Number of claims accepted by the consensus. /// @dev Must be monotonically non-decreasing in time - uint256 _numOfAcceptedClaims; + uint256 private _numOfAcceptedClaims; + /// @notice Number of claims submitted to the consensus. /// @dev Must be monotonically non-decreasing in time - uint256 _numOfSubmittedClaims; + uint256 private _numOfSubmittedClaims; /// @param epochLength The epoch length /// @dev Reverts if the epoch length is zero. @@ -49,6 +59,15 @@ abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { return _validOutputsMerkleRoots[appContract][outputsMerkleRoot]; } + function getLastFinalizedMachineMerkleRoot(address appContract) + public + view + override + returns (bytes32) + { + return _lastFinalizedMachineMerkleRoots[appContract]; + } + /// @inheritdoc IConsensus function getEpochLength() public view override returns (uint256) { return EPOCH_LENGTH; @@ -137,6 +156,10 @@ abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { bytes32 machineMerkleRoot ) internal notForeclosed(appContract) { _validOutputsMerkleRoots[appContract][outputsMerkleRoot] = true; + if (lastProcessedBlockNumber >= _firstUnprocessedBlockNumbers[appContract]) { + _lastFinalizedMachineMerkleRoots[appContract] = machineMerkleRoot; + _firstUnprocessedBlockNumbers[appContract] = lastProcessedBlockNumber + 1; + } emit ClaimAccepted( appContract, lastProcessedBlockNumber, outputsMerkleRoot, machineMerkleRoot ); diff --git a/src/consensus/IOutputsMerkleRootValidator.sol b/src/consensus/IOutputsMerkleRootValidator.sol index e4df4059..7e6deca9 100644 --- a/src/consensus/IOutputsMerkleRootValidator.sol +++ b/src/consensus/IOutputsMerkleRootValidator.sol @@ -16,4 +16,14 @@ interface IOutputsMerkleRootValidator is IERC165 { external view returns (bool); + + /// @notice Get the last finalized machine Merkle root. + /// @param appContract The application contract address + /// @dev Returns zero if no machine merkle root has been finalized yet + /// for that particular application. This should not be a problem given + /// that the pre-image Keccak-256 hash of zero is unknown. + function getLastFinalizedMachineMerkleRoot(address appContract) + external + view + returns (bytes32); } diff --git a/test/consensus/authority/AuthorityFactory.t.sol b/test/consensus/authority/AuthorityFactory.t.sol index a5d70f5f..a879ed1f 100644 --- a/test/consensus/authority/AuthorityFactory.t.sol +++ b/test/consensus/authority/AuthorityFactory.t.sol @@ -20,9 +20,11 @@ import {LibBytes} from "../../util/LibBytes.sol"; import {LibClaim} from "../../util/LibClaim.sol"; import {LibConsensus} from "../../util/LibConsensus.sol"; import {LibTopic} from "../../util/LibTopic.sol"; +import {LibUint256Array} from "../../util/LibUint256Array.sol"; import {OwnableTest} from "../../util/OwnableTest.sol"; contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUtils { + using LibUint256Array for uint256[]; using LibConsensus for IAuthority; using LibTopic for address; using LibClaim for Claim; @@ -52,8 +54,11 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti _testNewAuthoritySuccess( authorityOwner, epochLength, interfaceId, authority, logs ); + } catch Error(string memory message) { + _testNewAuthorityFailure(epochLength, message); + return; } catch (bytes memory error) { - _testNewAuthorityFailure(authorityOwner, epochLength, error); + _testNewAuthorityFailure(authorityOwner, error); return; } } @@ -83,8 +88,11 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti _testNewAuthoritySuccess( authorityOwner, epochLength, interfaceId, authority, logs ); + } catch Error(string memory message) { + _testNewAuthorityFailure(epochLength, message); + return; } catch (bytes memory error) { - _testNewAuthorityFailure(authorityOwner, epochLength, error); + _testNewAuthorityFailure(authorityOwner, error); return; } @@ -137,7 +145,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.appContract = _newActiveAppMock(); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -180,7 +188,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.appContract = _newActiveAppMock(); // Adjust the lastProcessedBlockNumber but do not roll past it. - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); claim.proof = _randomLeafProof(); @@ -199,7 +207,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti // We use a random account with no code as app contract claim.appContract = _randomAccountWithNoCode(); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -220,7 +228,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti // We make isForeclosed() revert with an error claim.appContract = _newAppMockReverts(error); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -243,7 +251,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.appContract = _newAppMockReturns(data); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -266,7 +274,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti bytes memory data = abi.encode(returnValue); claim.appContract = _newAppMockReturns(data); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -286,7 +294,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti // We make isForeclosed() return true claim.appContract = _newForeclosedAppMock(); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -305,7 +313,7 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.appContract = _newActiveAppMock(); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomProof(_randomInvalidLeafProofSize()); @@ -324,85 +332,126 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti claim.appContract = _newActiveAppMock(); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); - vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); - - claim.proof = _randomLeafProof(); - - bytes32 machineMerkleRoot = claim.computeMachineMerkleRoot(); - - uint256 totalNumOfSubmittedClaimsBefore = authority.getNumberOfSubmittedClaims(); - uint256 totalNumOfAcceptedClaimsBefore = authority.getNumberOfAcceptedClaims(); + uint256[] memory blockNumbers = _randomEpochFinalBlockNumbers(epochLength); - vm.recordLogs(); - - vm.prank(authorityOwner); - authority.submitClaim(claim); + { + (bool isEmpty, uint256 max) = blockNumbers.max(); + assertFalse(isEmpty, "unexpected empty array of epoch final block numbers"); + vm.roll(_randomUintGt(max)); + } - Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 lastFinalizedMachineMerkleRoot; + + for (uint256 claimIndex; claimIndex < blockNumbers.length; ++claimIndex) { + claim.lastProcessedBlockNumber = blockNumbers[claimIndex]; + claim.outputsMerkleRoot = _randomBytes32(); + claim.proof = _randomLeafProof(); + + bytes32 machineMerkleRoot = claim.computeMachineMerkleRoot(); + + uint256 totalNumOfSubmittedClaims = authority.getNumberOfSubmittedClaims(); + uint256 totalNumOfAcceptedClaims = authority.getNumberOfAcceptedClaims(); + + vm.recordLogs(); + + vm.prank(authority.owner()); + try authority.submitClaim( + claim.appContract, + claim.lastProcessedBlockNumber, + claim.outputsMerkleRoot, + claim.proof + ) {} + catch (bytes memory error) { + (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); + if (errorSelector == IConsensus.NotFirstClaim.selector) { + (address arg1, uint256 arg2) = + abi.decode(errorArgs, (address, uint256)); + assertEq(arg1, claim.appContract); + assertEq(arg2, claim.lastProcessedBlockNumber); + assertTrue(blockNumbers.containsBefore(arg2, claimIndex)); + } else { + revert("Unexpected error"); + } - uint256 numOfClaimSubmittedEvents; - uint256 numOfClaimAcceptedEvents; + // Proceed to the next claim. + continue; + } - for (uint256 j; j < logs.length; ++j) { - Vm.Log memory log = logs[j]; - if (log.emitter == address(authority)) { - assertGe(log.topics.length, 1, "unexpected annonymous event"); - bytes32 topic0 = log.topics[0]; - if (topic0 == IConsensus.ClaimSubmitted.selector) { - (uint256 arg0, bytes32 arg1, bytes32 arg2) = - abi.decode(log.data, (uint256, bytes32, bytes32)); - assertEq(log.topics[1], authorityOwner.asTopic()); - assertEq(log.topics[2], claim.appContract.asTopic()); - assertEq(arg0, claim.lastProcessedBlockNumber); - assertEq(arg1, claim.outputsMerkleRoot); - assertEq(arg2, machineMerkleRoot); - ++numOfClaimSubmittedEvents; - } else if (topic0 == IConsensus.ClaimAccepted.selector) { - (uint256 arg0, bytes32 arg1, bytes32 arg2) = - abi.decode(log.data, (uint256, bytes32, bytes32)); - assertEq(log.topics[1], claim.appContract.asTopic()); - assertEq(arg0, claim.lastProcessedBlockNumber); - assertEq(arg1, claim.outputsMerkleRoot); - assertEq(arg2, machineMerkleRoot); - ++numOfClaimAcceptedEvents; - } else { - revert("unexpected event selector"); + { + Vm.Log[] memory logs = vm.getRecordedLogs(); + + uint256 numOfClaimSubmittedEvents; + uint256 numOfClaimAcceptedEvents; + + for (uint256 i; i < logs.length; ++i) { + Vm.Log memory log = logs[i]; + if (log.emitter == address(authority)) { + assertGe(log.topics.length, 1, "unexpected annonymous event"); + bytes32 topic0 = log.topics[0]; + if (topic0 == IConsensus.ClaimSubmitted.selector) { + (uint256 arg0, bytes32 arg1, bytes32 arg2) = + abi.decode(log.data, (uint256, bytes32, bytes32)); + assertEq(log.topics[1], authority.owner().asTopic()); + assertEq(log.topics[2], claim.appContract.asTopic()); + assertEq(arg0, claim.lastProcessedBlockNumber); + assertEq(arg1, claim.outputsMerkleRoot); + assertEq(arg2, machineMerkleRoot); + ++numOfClaimSubmittedEvents; + } else if (topic0 == IConsensus.ClaimAccepted.selector) { + (uint256 arg0, bytes32 arg1, bytes32 arg2) = + abi.decode(log.data, (uint256, bytes32, bytes32)); + assertEq(log.topics[1], claim.appContract.asTopic()); + assertEq(arg0, claim.lastProcessedBlockNumber); + assertEq(arg1, claim.outputsMerkleRoot); + assertEq(arg2, machineMerkleRoot); + ++numOfClaimAcceptedEvents; + } else { + revert("unexpected event selector"); + } + } else { + revert("unexpected log emitter"); + } } - } else { - revert("unexpected log emitter"); + + assertEq(numOfClaimSubmittedEvents, 1, "expected 1 ClaimSubmitted event"); + assertEq(numOfClaimAcceptedEvents, 1, "expected 1 ClaimAccepted event"); } - } - assertEq(numOfClaimSubmittedEvents, 1, "expected 1 ClaimSubmitted event"); - assertEq(numOfClaimAcceptedEvents, 1, "expected 1 ClaimAccepted event"); + assertEq( + authority.getNumberOfSubmittedClaims(), + totalNumOfSubmittedClaims + 1, + "Total number of submitted claims should be increased by number of events" + ); - assertTrue( - authority.isOutputsMerkleRootValid( - claim.appContract, claim.outputsMerkleRoot - ), - "Once a claim is accepted, the outputs Merkle root is valid" - ); + assertEq( + authority.getNumberOfAcceptedClaims(), + totalNumOfAcceptedClaims + 1, + "Total number of accepted claims should be increased by number of events" + ); - assertEq( - authority.getNumberOfSubmittedClaims(), - totalNumOfSubmittedClaimsBefore + numOfClaimSubmittedEvents, - "Total number of submitted claims should be increased by number of events" - ); + { + (bool isEmpty, uint256 max) = blockNumbers.maxBefore(claimIndex); - assertEq( - authority.getNumberOfAcceptedClaims(), - totalNumOfAcceptedClaimsBefore + numOfClaimAcceptedEvents, - "Total number of accepted claims should be increased by number of events" - ); + // If the claim was successful submitted, then its last processed + // block number cannot be equal to any past successful claim. + if (isEmpty || claim.lastProcessedBlockNumber > max) { + lastFinalizedMachineMerkleRoot = machineMerkleRoot; + } + } - (Claim memory otherClaim,) = _randomClaimDifferentFrom(claim, machineMerkleRoot); + assertTrue( + authority.isOutputsMerkleRootValid( + claim.appContract, claim.outputsMerkleRoot + ), + "Once a claim is accepted, the outputs Merkle root is valid" + ); - vm.expectRevert( - _encodeNotFirstClaim(claim.appContract, claim.lastProcessedBlockNumber) - ); - vm.prank(authorityOwner); - authority.submitClaim(otherClaim); + assertEq( + authority.getLastFinalizedMachineMerkleRoot(claim.appContract), + lastFinalizedMachineMerkleRoot, + "Check last finalized machine Merkle root" + ); + } } function _testNewAuthoritySuccess( @@ -457,6 +506,13 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti "initially, isOutputsMerkleRootValid(...) == false" ); + // We check that initially no machine Merkle root has been finalized. + assertEq( + authority.getLastFinalizedMachineMerkleRoot(vm.randomAddress()), + bytes32(0), + "initially, getLastFinalizedMachineMerkleRoot(...) == bytes32(0)" + ); + // Also, initially, no `ClaimSubmitted` or `ClaimAccepted` were emitted. assertEq( authority.getNumberOfSubmittedClaims(), @@ -473,23 +529,27 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti _testSupportsInterface(authority, interfaceId); } - function _testNewAuthorityFailure( - address authorityOwner, - uint256 epochLength, - bytes memory error - ) internal pure { + function _testNewAuthorityFailure(uint256 epochLength, string memory message) + internal + pure + { + bytes32 messageHash = keccak256(bytes(message)); + if (messageHash == keccak256("epoch length must not be zero")) { + assertEq(epochLength, 0, "expected epoch length to be zero"); + } else { + revert("Unexpected error message"); + } + } + + function _testNewAuthorityFailure(address authorityOwner, bytes memory error) + internal + pure + { (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); if (errorSelector == Ownable.OwnableInvalidOwner.selector) { address owner = abi.decode(errorArgs, (address)); assertEq(owner, authorityOwner, "OwnableInvalidOwner.owner != owner"); assertEq(owner, address(0), "OwnableInvalidOwner.owner != address(0)"); - } else if (errorSelector == bytes4(keccak256("Error(string)"))) { - string memory message = abi.decode(errorArgs, (string)); - if (keccak256(bytes(message)) == keccak256("epoch length must not be zero")) { - assertEq(epochLength, 0, "expected epoch length to be zero"); - } else { - revert("Unexpected error message"); - } } else { revert("Unexpected error"); } diff --git a/test/consensus/quorum/QuorumFactory.t.sol b/test/consensus/quorum/QuorumFactory.t.sol index 2052eca6..4cf1d0f9 100644 --- a/test/consensus/quorum/QuorumFactory.t.sol +++ b/test/consensus/quorum/QuorumFactory.t.sol @@ -53,9 +53,11 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { try _factory.newQuorum(validators, epochLength) returns (IQuorum quorum) { Vm.Log[] memory logs = vm.getRecordedLogs(); _testNewQuorumSuccess(validators, epochLength, interfaceId, quorum, logs); - } catch (bytes memory error) { - _testNewQuorumFailure(validators, epochLength, error); + } catch Error(string memory message) { + _testNewQuorumFailure(validators, epochLength, message); return; + } catch { + revert("Unexpected error"); } } @@ -81,9 +83,11 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { ); _testNewQuorumSuccess(validators, epochLength, interfaceId, quorum, logs); - } catch (bytes memory error) { - _testNewQuorumFailure(validators, epochLength, error); + } catch Error(string memory message) { + _testNewQuorumFailure(validators, epochLength, message); return; + } catch { + revert("Unexpected error"); } assertEq( @@ -113,7 +117,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.appContract = _newActiveAppMock(); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -154,7 +158,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.appContract = _newActiveAppMock(); // Adjust the lastProcessedBlockNumber but do not roll past it. - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); claim.proof = _randomLeafProof(); @@ -173,7 +177,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { // We use a random account with no code as app contract claim.appContract = _randomAccountWithNoCode(); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -194,7 +198,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { // We make isForeclosed() revert with an error claim.appContract = _newAppMockReverts(error); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -217,7 +221,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.appContract = _newAppMockReturns(data); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -240,7 +244,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { bytes memory data = abi.encode(returnValue); claim.appContract = _newAppMockReturns(data); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -260,7 +264,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { // We make isForeclosed() return true claim.appContract = _newForeclosedAppMock(); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomLeafProof(); @@ -279,7 +283,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { claim.appContract = _newActiveAppMock(); - claim.lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); + claim.lastProcessedBlockNumber = _randomEpochFinalBlockNumber(epochLength); vm.roll(_randomUintGt(claim.lastProcessedBlockNumber)); claim.proof = _randomProof(_randomInvalidLeafProofSize()); @@ -289,343 +293,427 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { quorum.submitClaim(claim); } - function testSubmitClaim( - address[] memory validators, - uint256 epochLength, - bytes32 winningOutputsMerkleRoot - ) external { + function testSubmitClaim(address[] memory validators, uint256 epochLength) external { IQuorum quorum = _newQuorum(validators, epochLength); address appContract = _newActiveAppMock(); - uint256 lastProcessedBlockNumber = _randomFutureEpochFinalBlockNumber(epochLength); - vm.roll(_randomUintGt(lastProcessedBlockNumber)); - - bytes32[] memory winningProof = _randomLeafProof(); - - Claim memory winningClaim = Claim({ - appContract: appContract, - lastProcessedBlockNumber: lastProcessedBlockNumber, - outputsMerkleRoot: winningOutputsMerkleRoot, - proof: winningProof - }); - - bytes32 winningMachineMerkleRoot = winningClaim.computeMachineMerkleRoot(); - - // Divide validators into three categories: - // - winners: they form a majority and vote on the same claim - // - losers: they form a minority and vote on other claims - // - non-voters: they also form a minority, but do not vote - uint256 numOfValidators = quorum.numOfValidators(); - uint256 majority = 1 + (numOfValidators / 2); - uint256 numOfWinners = vm.randomUint(majority, numOfValidators); - uint256 numOfNonWinners = numOfValidators - numOfWinners; - uint256 numOfLosers = vm.randomUint(0, numOfNonWinners); - uint256 numOfNonVoters = numOfNonWinners - numOfLosers; - - // Check relations between categories - assertEq(numOfValidators, numOfWinners + numOfLosers + numOfNonVoters); - assertEq(numOfNonWinners, numOfLosers + numOfNonVoters); - assertGt(numOfWinners, numOfNonWinners); - - // List validator IDs and shuffle them - uint256[] memory ids = LibUint256Array.sequence(1, numOfValidators); - vm.shuffleInPlace(ids); - assertEq(ids.length, numOfValidators); - - // Distribute validators between categories - uint256[] memory winnerIds; - uint256[] memory loserIds; - uint256[] memory nonVoterIds; + uint256[] memory blockNumbers = _randomEpochFinalBlockNumbers(epochLength); { - uint256[] memory nonWinnerIds; - - (winnerIds, nonWinnerIds) = ids.split(numOfWinners); - (loserIds, nonVoterIds) = nonWinnerIds.split(numOfLosers); - - // Check lengths of ID arrays - // and number of validators in each category - assertEq(winnerIds.length, numOfWinners); - assertEq(nonWinnerIds.length, numOfNonWinners); - assertEq(loserIds.length, numOfLosers); - assertEq(nonVoterIds.length, numOfNonVoters); + (bool isEmpty, uint256 max) = blockNumbers.max(); + assertFalse(isEmpty, "unexpected empty array of epoch final block numbers"); + vm.roll(_randomUintGt(max)); } - assertEq( - quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( - appContract, lastProcessedBlockNumber - ), - 0, - "Expected no validator to be in favor of any claim in epoch" - ); - - assertEq( - quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, winningMachineMerkleRoot - ), - 0, - "Expected no validator to be in favor of the winning claim in epoch" - ); - - assertEq( - quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, _randomBytes32() - ), - 0, - "Expected no validator to be in favor of any random claim in epoch" - ); - - uint256 numOfWinningVotes; - uint256 numOfLosingVotes; - bool wasClaimAccepted; - - for (uint256 i; i < ids.length; ++i) { - uint256 id = ids[i]; - - assertFalse( - quorum.isValidatorInFavorOfAnyClaimInEpoch( - appContract, lastProcessedBlockNumber, id - ), - "Expected validator to not be in favor of any claim in epoch" - ); - - assertFalse( - quorum.isValidatorInFavorOf( - appContract, lastProcessedBlockNumber, _randomBytes32(), id - ), - "Expected validator to not be in favor of any random claim in epoch" - ); - - if (nonVoterIds.contains(id)) { - continue; // skip voting + bytes32 lastFinalizedMachineMerkleRoot; + + for (uint256 claimIndex; claimIndex < blockNumbers.length; ++claimIndex) { + uint256 lastProcessedBlockNumber = blockNumbers[claimIndex]; + bool wasEpochFinalized = + blockNumbers.containsBefore(lastProcessedBlockNumber, claimIndex); + + Claim memory winningClaim = Claim({ + appContract: appContract, + lastProcessedBlockNumber: lastProcessedBlockNumber, + outputsMerkleRoot: _randomBytes32(), + proof: _randomLeafProof() + }); + + bytes32 winningMachineMerkleRoot = winningClaim.computeMachineMerkleRoot(); + + // Divide validators into three categories: + // - winners: they form a majority and vote on the same claim + // - losers: they form a minority and vote on other claims + // - non-voters: they also form a minority, but do not vote + uint256 numOfValidators = quorum.numOfValidators(); + uint256 majority = 1 + (numOfValidators / 2); + uint256 numOfWinners = vm.randomUint(majority, numOfValidators); + uint256 numOfNonWinners = numOfValidators - numOfWinners; + uint256 numOfLosers = vm.randomUint(0, numOfNonWinners); + uint256 numOfNonVoters = numOfNonWinners - numOfLosers; + + // Check relations between categories + assertEq(numOfValidators, numOfWinners + numOfLosers + numOfNonVoters); + assertEq(numOfNonWinners, numOfLosers + numOfNonVoters); + assertGt(numOfWinners, numOfNonWinners); + + // List validator IDs and shuffle them + uint256[] memory ids = LibUint256Array.sequence(1, numOfValidators); + vm.shuffleInPlace(ids); + assertEq(ids.length, numOfValidators); + + // Distribute validators between categories + uint256[] memory winnerIds; + uint256[] memory loserIds; + uint256[] memory nonVoterIds; + + { + uint256[] memory nonWinnerIds; + + (winnerIds, nonWinnerIds) = ids.split(numOfWinners); + (loserIds, nonVoterIds) = nonWinnerIds.split(numOfLosers); + + // Check lengths of ID arrays + // and number of validators in each category + assertEq(winnerIds.length, numOfWinners); + assertEq(nonWinnerIds.length, numOfNonWinners); + assertEq(loserIds.length, numOfLosers); + assertEq(nonVoterIds.length, numOfNonVoters); } - Claim memory claim; - bytes32 machineMerkleRoot; - - if (winnerIds.contains(id)) { - (claim, machineMerkleRoot) = (winningClaim, winningMachineMerkleRoot); - ++numOfWinningVotes; - } else if (loserIds.contains(id)) { - (claim, machineMerkleRoot) = - _randomClaimDifferentFrom(winningClaim, winningMachineMerkleRoot); - ++numOfLosingVotes; + if (wasEpochFinalized) { + assertGe( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ), + majority, + "Expected a majority of validators to be in favor of any claim in epoch" + ); } else { - revert("unexpected validator category"); + assertEq( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ), + 0, + "Expected no validator to be in favor of any claim in epoch" + ); + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, winningMachineMerkleRoot + ), + 0, + "Expected no validator to be in favor of the winning claim in epoch" + ); + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, _randomBytes32() + ), + 0, + "Expected no validator to be in favor of any random claim in epoch" + ); } - assertFalse( - quorum.isValidatorInFavorOf( - appContract, lastProcessedBlockNumber, machineMerkleRoot, id - ), - "Expected validator to not be in favor of claim" - ); - - uint256 totalNumOfSubmittedClaimsBefore = quorum.getNumberOfSubmittedClaims(); - uint256 totalNumOfAcceptedClaimsBefore = quorum.getNumberOfAcceptedClaims(); + uint256 numOfWinningVotes; + uint256 numOfLosingVotes; + bool wasClaimAccepted; + + for (uint256 i; i < ids.length; ++i) { + uint256 id = ids[i]; + + if (!wasEpochFinalized) { + assertFalse( + quorum.isValidatorInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber, id + ), + "Expected validator to not be in favor of any claim in epoch" + ); + assertFalse( + quorum.isValidatorInFavorOf( + appContract, lastProcessedBlockNumber, _randomBytes32(), id + ), + "Expected validator to not be in favor of any random claim in epoch" + ); + } - uint256 numOfValidatorsInFavorOfAnyClaimInEpochBefore = - quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( - appContract, lastProcessedBlockNumber - ); + if (nonVoterIds.contains(id)) { + continue; // skip voting + } - uint256 numOfValidatorsInFavorOfClaimBefore = quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, machineMerkleRoot - ); + Claim memory claim; + bytes32 machineMerkleRoot; - address validator = quorum.validatorById(id); - assertTrue(validators.contains(validator), "voter is not validator"); + if (winnerIds.contains(id)) { + (claim, machineMerkleRoot) = (winningClaim, winningMachineMerkleRoot); + ++numOfWinningVotes; + } else if (loserIds.contains(id)) { + (claim, machineMerkleRoot) = + _randomClaimDifferentFrom(winningClaim, winningMachineMerkleRoot); + ++numOfLosingVotes; + } else { + revert("unexpected validator category"); + } - vm.recordLogs(); + if (!wasEpochFinalized) { + assertFalse( + quorum.isValidatorInFavorOf( + appContract, lastProcessedBlockNumber, machineMerkleRoot, id + ), + "Expected validator to not be in favor of claim" + ); + } - vm.prank(validator); - quorum.submitClaim(claim); + uint256 totalNumOfSubmittedClaimsBefore = + quorum.getNumberOfSubmittedClaims(); + uint256 totalNumOfAcceptedClaimsBefore = + quorum.getNumberOfAcceptedClaims(); + + uint256 numOfValidatorsInFavorOfAnyClaimInEpochBefore = + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ); + + uint256 numOfValidatorsInFavorOfClaimBefore = + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, machineMerkleRoot + ); + + address validator = quorum.validatorById(id); + assertTrue(validators.contains(validator), "voter is not validator"); + + vm.recordLogs(); + + vm.prank(validator); + try quorum.submitClaim( + claim.appContract, + claim.lastProcessedBlockNumber, + claim.outputsMerkleRoot, + claim.proof + ) {} + catch (bytes memory error) { + (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); + if (errorSelector == IConsensus.NotFirstClaim.selector) { + (address arg1, uint256 arg2) = + abi.decode(errorArgs, (address, uint256)); + assertEq( + arg1, + claim.appContract, + "NotFirstClaim.appContract != appContract" + ); + assertEq( + arg2, + claim.lastProcessedBlockNumber, + "NotFirstClaim.lastProcessedBlockNumber != lastProcessedBlockNumber" + ); + assertTrue( + wasEpochFinalized, + "NotFirstClaim should only be raised if epoch was already finalized" + ); + assertTrue( + quorum.isValidatorInFavorOfAnyClaimInEpoch( + claim.appContract, claim.lastProcessedBlockNumber, id + ), + "Expected isValidatorInFavorOfAnyClaimInEpoch(...) to return true after NotFirstClaim" + ); + assertFalse( + quorum.isValidatorInFavorOf( + claim.appContract, + claim.lastProcessedBlockNumber, + machineMerkleRoot, + id + ), + "Expected isValidatorInFavorOf(...) to return false after NotFirstClaim" + ); + } else { + revert("Unexpected error"); + } - Vm.Log[] memory logs = vm.getRecordedLogs(); + // Proceed to the next claim. + continue; + } - uint256 numOfClaimSubmittedEvents; - uint256 numOfClaimAcceptedEvents; - - for (uint256 j; j < logs.length; ++j) { - Vm.Log memory log = logs[j]; - if (log.emitter == address(quorum)) { - assertGe(log.topics.length, 1, "unexpected annonymous event"); - bytes32 topic0 = log.topics[0]; - if (topic0 == IConsensus.ClaimSubmitted.selector) { - (uint256 arg0, bytes32 arg1, bytes32 arg2) = - abi.decode(log.data, (uint256, bytes32, bytes32)); - assertEq(log.topics[1], validator.asTopic()); - assertEq(log.topics[2], appContract.asTopic()); - assertEq(arg0, lastProcessedBlockNumber); - assertEq(arg1, claim.outputsMerkleRoot); - assertEq(arg2, machineMerkleRoot); - ++numOfClaimSubmittedEvents; - } else if (topic0 == IConsensus.ClaimAccepted.selector) { - (uint256 arg0, bytes32 arg1, bytes32 arg2) = - abi.decode(log.data, (uint256, bytes32, bytes32)); - assertEq(log.topics[1], appContract.asTopic()); - assertEq(arg0, lastProcessedBlockNumber); - assertEq(arg1, claim.outputsMerkleRoot); - assertEq(arg2, machineMerkleRoot); - ++numOfClaimAcceptedEvents; + Vm.Log[] memory logs = vm.getRecordedLogs(); + + uint256 numOfClaimSubmittedEvents; + uint256 numOfClaimAcceptedEvents; + + for (uint256 j; j < logs.length; ++j) { + Vm.Log memory log = logs[j]; + if (log.emitter == address(quorum)) { + assertGe(log.topics.length, 1, "unexpected annonymous event"); + bytes32 topic0 = log.topics[0]; + if (topic0 == IConsensus.ClaimSubmitted.selector) { + (uint256 arg0, bytes32 arg1, bytes32 arg2) = + abi.decode(log.data, (uint256, bytes32, bytes32)); + assertEq(log.topics[1], validator.asTopic()); + assertEq(log.topics[2], appContract.asTopic()); + assertEq(arg0, lastProcessedBlockNumber); + assertEq(arg1, claim.outputsMerkleRoot); + assertEq(arg2, machineMerkleRoot); + ++numOfClaimSubmittedEvents; + } else if (topic0 == IConsensus.ClaimAccepted.selector) { + (uint256 arg0, bytes32 arg1, bytes32 arg2) = + abi.decode(log.data, (uint256, bytes32, bytes32)); + assertEq(log.topics[1], appContract.asTopic()); + assertEq(arg0, lastProcessedBlockNumber); + assertEq(arg1, claim.outputsMerkleRoot); + assertEq(arg2, machineMerkleRoot); + ++numOfClaimAcceptedEvents; + } else { + revert("unexpected event selector"); + } } else { - revert("unexpected event selector"); + revert("unexpected log emitter"); } - } else { - revert("unexpected log emitter"); } - } - - assertEq(numOfClaimSubmittedEvents, 1, "expected 1 ClaimSubmitted event"); - - if (numOfWinningVotes == majority && !wasClaimAccepted) { - assertEq(numOfClaimAcceptedEvents, 1, "expected 1 ClaimAccepted event"); - wasClaimAccepted = true; - } else { - assertEq(numOfClaimAcceptedEvents, 0, "expected 0 ClaimAccepted events"); - } - - assertEq( - quorum.isOutputsMerkleRootValid(winningClaim), - numOfWinningVotes >= majority, - "Once a claim is accepted, the outputs Merkle root is valid" - ); - - assertEq( - quorum.getNumberOfSubmittedClaims(), - totalNumOfSubmittedClaimsBefore + numOfClaimSubmittedEvents, - "Total number of submitted claims should be increased by number of events" - ); - - assertEq( - quorum.getNumberOfAcceptedClaims(), - totalNumOfAcceptedClaimsBefore + numOfClaimAcceptedEvents, - "Total number of accepted claims should be increased by number of events" - ); - - assertEq( - quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( - appContract, lastProcessedBlockNumber - ), - numOfValidatorsInFavorOfAnyClaimInEpochBefore + 1, - "Number of validators in favor of any claim in epoch should be incremented" - ); - assertTrue( - quorum.isValidatorInFavorOfAnyClaimInEpoch( - appContract, lastProcessedBlockNumber, id - ), - "Expected validator to be in favor of any claim in epoch" - ); + assertEq(numOfClaimSubmittedEvents, 1, "expected 1 ClaimSubmitted event"); - assertEq( - quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, machineMerkleRoot - ), - numOfValidatorsInFavorOfClaimBefore + 1, - "Number of validators in favor of claim should be incremented" - ); + if (wasEpochFinalized) { + assertEq( + numOfClaimAcceptedEvents, + 0, + "expected no ClaimAccepted events if epoch was already finalized" + ); + } else { + assertEq( + quorum.isOutputsMerkleRootValid(winningClaim), + numOfWinningVotes >= majority, + "Once a claim is accepted, the outputs Merkle root is valid" + ); + if (numOfWinningVotes == majority && !wasClaimAccepted) { + assertEq( + numOfClaimAcceptedEvents, 1, "expected 1 ClaimAccepted event" + ); + assertFalse( + wasEpochFinalized, + "expected ClaimAccepted if epoch was not finalized yet" + ); + + wasClaimAccepted = true; + + (bool isEmpty, uint256 max) = blockNumbers.maxBefore(claimIndex); + + // If the claim was successful submitted, then its last processed + // block number cannot be equal to any past successful claim. + if (isEmpty || claim.lastProcessedBlockNumber > max) { + lastFinalizedMachineMerkleRoot = machineMerkleRoot; + } + } else { + assertEq( + numOfClaimAcceptedEvents, 0, "expected 0 ClaimAccepted events" + ); + } + } - assertTrue( - quorum.isValidatorInFavorOf( - appContract, lastProcessedBlockNumber, machineMerkleRoot, id - ), - "Expected validator to be in favor of claim" - ); + assertEq( + quorum.getLastFinalizedMachineMerkleRoot(claim.appContract), + lastFinalizedMachineMerkleRoot, + "Check last finalized machine Merkle root" + ); - vm.recordLogs(); + assertEq( + quorum.getNumberOfSubmittedClaims(), + totalNumOfSubmittedClaimsBefore + numOfClaimSubmittedEvents, + "Total number of submitted claims should be increased by number of events" + ); - vm.prank(validator); - quorum.submitClaim(claim); + assertEq( + quorum.getNumberOfAcceptedClaims(), + totalNumOfAcceptedClaimsBefore + numOfClaimAcceptedEvents, + "Total number of accepted claims should be increased by number of events" + ); - assertEq( - vm.getRecordedLogs().length, - 0, - "submitClaim() expected to emit 0 events on subsequent call" - ); + assertEq( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ), + numOfValidatorsInFavorOfAnyClaimInEpochBefore + 1, + "Number of validators in favor of any claim in epoch should be incremented" + ); - assertEq( - quorum.isOutputsMerkleRootValid(winningClaim), - numOfWinningVotes >= majority, - "Once a claim is accepted, the outputs Merkle root is valid" - ); + assertTrue( + quorum.isValidatorInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber, id + ), + "Expected validator to be in favor of any claim in epoch" + ); - assertEq( - quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( - appContract, lastProcessedBlockNumber - ), - numOfValidatorsInFavorOfAnyClaimInEpochBefore + 1, - "Number of validators in favor of any claim in epoch should be incremented" - ); + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, machineMerkleRoot + ), + numOfValidatorsInFavorOfClaimBefore + 1, + "Number of validators in favor of claim should be incremented" + ); - assertTrue( - quorum.isValidatorInFavorOfAnyClaimInEpoch( - appContract, lastProcessedBlockNumber, id - ), - "Expected validator to be in favor of any claim in epoch" - ); + assertTrue( + quorum.isValidatorInFavorOf( + appContract, lastProcessedBlockNumber, machineMerkleRoot, id + ), + "Expected validator to be in favor of claim" + ); - assertEq( - quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, machineMerkleRoot - ), - numOfValidatorsInFavorOfClaimBefore + 1, - "Number of validators in favor of claim should be incremented" - ); + vm.recordLogs(); - assertTrue( - quorum.isValidatorInFavorOf( - appContract, lastProcessedBlockNumber, machineMerkleRoot, id - ), - "Expected validator to be in favor of claim" - ); + vm.prank(validator); + quorum.submitClaim(claim); - (Claim memory otherClaim,) = - _randomClaimDifferentFrom(claim, machineMerkleRoot); + assertEq( + vm.getRecordedLogs().length, + 0, + "submitClaim() expected to emit 0 events on subsequent call" + ); - vm.expectRevert(_encodeNotFirstClaim(appContract, lastProcessedBlockNumber)); - vm.prank(validator); - quorum.submitClaim(otherClaim); - } + if (!wasEpochFinalized) { + assertEq( + quorum.isOutputsMerkleRootValid(winningClaim), + numOfWinningVotes >= majority, + "Once a claim is accepted, the outputs Merkle root is valid" + ); + } - assertEq(numOfWinningVotes, numOfWinners, "# winning votes == # winner voters"); - assertEq(numOfLosingVotes, numOfLosers, "# losing votes == # loser voters"); + assertEq( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ), + numOfValidatorsInFavorOfAnyClaimInEpochBefore + 1, + "Number of validators in favor of any claim in epoch should be incremented" + ); - assertTrue(wasClaimAccepted, "unexpected ClaimAccepted event"); + assertTrue( + quorum.isValidatorInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber, id + ), + "Expected validator to be in favor of any claim in epoch" + ); - assertTrue( - quorum.isOutputsMerkleRootValid(winningClaim), - "The outputs Merkle root should be valid" - ); + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, machineMerkleRoot + ), + numOfValidatorsInFavorOfClaimBefore + 1, + "Number of validators in favor of claim should be incremented" + ); - assertEq( - quorum.getNumberOfSubmittedClaims(), - numOfWinners + numOfLosers, - "# votes == # voters" - ); + assertTrue( + quorum.isValidatorInFavorOf( + appContract, lastProcessedBlockNumber, machineMerkleRoot, id + ), + "Expected validator to be in favor of claim" + ); + } - assertEq( - quorum.getNumberOfAcceptedClaims(), - 1, - "Expected only 1 claim to be accepted (the winning claim)" - ); + if (!wasEpochFinalized) { + assertEq( + numOfWinningVotes, numOfWinners, "# winning votes == # winner voters" + ); + assertEq( + numOfLosingVotes, numOfLosers, "# losing votes == # loser voters" + ); + assertTrue(wasClaimAccepted, "expected ClaimAccepted event"); + assertTrue( + quorum.isOutputsMerkleRootValid(winningClaim), + "The outputs Merkle root should be valid" + ); - assertEq( - quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( - appContract, lastProcessedBlockNumber - ), - numOfWinningVotes + numOfLosingVotes, - "numOfValidatorsInFavorOfAnyClaimInEpoch(...) == # winning votes + # losing votes" - ); + assertEq( + quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( + appContract, lastProcessedBlockNumber + ), + numOfWinningVotes + numOfLosingVotes, + "numOfValidatorsInFavorOfAnyClaimInEpoch(...) == # winning votes + # losing votes" + ); - assertEq( - quorum.numOfValidatorsInFavorOf( - appContract, lastProcessedBlockNumber, winningMachineMerkleRoot - ), - numOfWinningVotes, - "numOfValidatorsInFavorOf(winningClaim...) = # winning votes" - ); + assertEq( + quorum.numOfValidatorsInFavorOf( + appContract, lastProcessedBlockNumber, winningMachineMerkleRoot + ), + numOfWinningVotes, + "numOfValidatorsInFavorOf(winningClaim...) = # winning votes" + ); + } + } } function _testNewQuorumSuccess( @@ -718,6 +806,13 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { "initially, isOutputsMerkleRootValid(...) == false" ); + // We check that initially no machine Merkle root has been finalized. + assertEq( + quorum.getLastFinalizedMachineMerkleRoot(vm.randomAddress()), + bytes32(0), + "initially, getLastFinalizedMachineMerkleRoot(...) == bytes32(0)" + ); + // We check that initially no validator is in favor of any claim in an epoch. assertEq( quorum.numOfValidatorsInFavorOfAnyClaimInEpoch( @@ -731,7 +826,7 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { vm.randomAddress(), vm.randomUint(), _randomBytes32() ), 0, - "initially, numOfValidatorsInFavorOfAnyClaimInEpoch(...) == 0" + "initially, numOfValidatorsInFavorOf(...) == 0" ); assertFalse( quorum.isValidatorInFavorOfAnyClaimInEpoch( @@ -765,26 +860,20 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function _testNewQuorumFailure( address[] memory validators, uint256 epochLength, - bytes memory error + string memory message ) internal pure { - (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); - if (errorSelector == bytes4(keccak256("Error(string)"))) { - string memory message = abi.decode(errorArgs, (string)); - bytes32 messageHash = keccak256(bytes(message)); - if (messageHash == keccak256("Quorum can't contain address(0)")) { - assertTrue( - validators.contains(address(0)), - "expected validators to contain address(0)" - ); - } else if (messageHash == keccak256("Quorum can't be empty")) { - assertEq(validators.length, 0, "expected validators to be empty"); - } else if (messageHash == keccak256("epoch length must not be zero")) { - assertEq(epochLength, 0, "expected epoch length to be zero"); - } else { - revert("Unexpected error message"); - } + bytes32 messageHash = keccak256(bytes(message)); + if (messageHash == keccak256("Quorum can't contain address(0)")) { + assertTrue( + validators.contains(address(0)), + "expected validators to contain address(0)" + ); + } else if (messageHash == keccak256("Quorum can't be empty")) { + assertEq(validators.length, 0, "expected validators to be empty"); + } else if (messageHash == keccak256("epoch length must not be zero")) { + assertEq(epochLength, 0, "expected epoch length to be zero"); } else { - revert("Unexpected error"); + revert("Unexpected error message"); } } diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index 2c4d2ab8..34e074a7 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -61,8 +61,11 @@ contract ApplicationFactoryTest is Test { blockNumber, logs ); + } catch Error(string memory message) { + _testNewApplicationFailure(withdrawalConfig, message); + return; } catch (bytes memory error) { - _testNewApplicationFailure(appOwner, withdrawalConfig, error); + _testNewApplicationFailure(appOwner, error); return; } } @@ -117,8 +120,11 @@ contract ApplicationFactoryTest is Test { blockNumber, logs ); + } catch Error(string memory message) { + _testNewApplicationFailure(withdrawalConfig, message); + return; } catch (bytes memory error) { - _testNewApplicationFailure(appOwner, withdrawalConfig, error); + _testNewApplicationFailure(appOwner, error); return; } @@ -268,26 +274,30 @@ contract ApplicationFactoryTest is Test { } function _testNewApplicationFailure( - address appOwner, WithdrawalConfig memory withdrawalConfig, - bytes memory error + string memory message ) internal pure { + bytes32 messageHash = keccak256(bytes(message)); + if (messageHash == keccak256("Invalid withdrawal config")) { + assertEq( + withdrawalConfig.isValid(), + false, + "expected withdrawal config to be invalid" + ); + } else { + revert("Unexpected error message"); + } + } + + function _testNewApplicationFailure(address appOwner, bytes memory error) + internal + pure + { (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); if (errorSelector == Ownable.OwnableInvalidOwner.selector) { address owner = abi.decode(errorArgs, (address)); assertEq(owner, appOwner, "OwnableInvalidOwner.owner != owner"); assertEq(owner, address(0), "OwnableInvalidOwner.owner != address(0)"); - } else if (errorSelector == bytes4(keccak256("Error(string)"))) { - string memory message = abi.decode(errorArgs, (string)); - if (keccak256(bytes(message)) == keccak256("Invalid withdrawal config")) { - assertEq( - withdrawalConfig.isValid(), - false, - "expected withdrawal config to be invalid" - ); - } else { - revert("Unexpected error message"); - } } else { revert("Unexpected error"); } diff --git a/test/dapp/SelfHostedApplicationFactory.t.sol b/test/dapp/SelfHostedApplicationFactory.t.sol index 891411e6..0ed4d936 100644 --- a/test/dapp/SelfHostedApplicationFactory.t.sol +++ b/test/dapp/SelfHostedApplicationFactory.t.sol @@ -145,6 +145,19 @@ contract SelfHostedApplicationFactoryTest is Test { address(authority), "calculateAddresses(...) is not a pure function" ); + } catch Error(string memory message) { + bytes32 messageHash = keccak256(bytes(message)); + if (messageHash == keccak256("epoch length must not be zero")) { + assertEq(epochLength, 0, "Expected epoch length to be zero"); + } else if (messageHash == keccak256("Invalid withdrawal config")) { + assertEq( + withdrawalConfig.isValid(), + false, + "expected withdrawal config to be invalid" + ); + } else { + revert("Unexpected error message"); + } } catch (bytes memory error) { (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); if (errorSelector == Ownable.OwnableInvalidOwner.selector) { @@ -154,20 +167,6 @@ contract SelfHostedApplicationFactoryTest is Test { appOwner == address(0) || authorityOwner == address(0), "Expected either app or authority owner to be zero" ); - } else if (errorSelector == bytes4(keccak256("Error(string)"))) { - string memory message = abi.decode(errorArgs, (string)); - bytes32 messageHash = keccak256(bytes(message)); - if (messageHash == keccak256("epoch length must not be zero")) { - assertEq(epochLength, 0, "Expected epoch length to be zero"); - } else if (messageHash == keccak256("Invalid withdrawal config")) { - assertEq( - withdrawalConfig.isValid(), - false, - "expected withdrawal config to be invalid" - ); - } else { - revert("Unexpected error message"); - } } else { revert("Unexpected error"); } diff --git a/test/util/ConsensusTestUtils.sol b/test/util/ConsensusTestUtils.sol index 6dd47965..0aad9b2e 100644 --- a/test/util/ConsensusTestUtils.sol +++ b/test/util/ConsensusTestUtils.sol @@ -57,24 +57,56 @@ contract ConsensusTestUtils is ApplicationCheckerTestUtils { ); } - function _maxEpochIndex(uint256 epochLength) internal pure returns (uint256) { - return (type(uint256).max - (epochLength - 1)) / epochLength; + function _epochIndexOfLastBlock(uint256 epochLength) + internal + pure + returns (uint256 epochIndex) + { + return type(uint256).max / epochLength; } - function _minFutureEpochIndex(uint256 epochLength) internal view returns (uint256) { - return (vm.getBlockNumber() + 1) / epochLength; + function _currentEpochIndex(uint256 epochLength) + internal + view + returns (uint256 epochIndex) + { + return vm.getBlockNumber() / epochLength; } - function _randomFutureEpochIndex(uint256 epochLength) internal returns (uint256) { - return - vm.randomUint(_minFutureEpochIndex(epochLength), _maxEpochIndex(epochLength)); + function _randomEpochIndex(uint256 epochLength) + internal + returns (uint256 epochIndex) + { + uint256 currentEpochIndex = _currentEpochIndex(epochLength); + uint256 epochIndexOfLastBlock = _epochIndexOfLastBlock(epochLength); + vm.assume(epochIndexOfLastBlock >= 1); + uint256 maxEpochIndex = epochIndexOfLastBlock - 1; + vm.assume(currentEpochIndex <= maxEpochIndex); + return vm.randomUint(currentEpochIndex, maxEpochIndex); + } + + function _randomEpochFinalBlockNumber(uint256 epochLength) + internal + returns (uint256 epochFinalBlock) + { + return _randomEpochIndex(epochLength) * epochLength + (epochLength - 1); + } + + function _randomEpochFinalBlockNumbers(uint256 epochLength, uint256 n) + internal + returns (uint256[] memory epochFinalBlocks) + { + epochFinalBlocks = new uint256[](n); + for (uint256 i; i < epochFinalBlocks.length; ++i) { + epochFinalBlocks[i] = _randomEpochFinalBlockNumber(epochLength); + } } - function _randomFutureEpochFinalBlockNumber(uint256 epochLength) + function _randomEpochFinalBlockNumbers(uint256 epochLength) internal - returns (uint256) + returns (uint256[] memory epochFinalBlocks) { - return _randomFutureEpochIndex(epochLength) * epochLength + (epochLength - 1); + return _randomEpochFinalBlockNumbers(epochLength, vm.randomUint(1, 3)); } function _randomUintGt(uint256 n) internal returns (uint256) { diff --git a/test/util/LibUint256Array.sol b/test/util/LibUint256Array.sol index ef2cbe95..13659e84 100644 --- a/test/util/LibUint256Array.sol +++ b/test/util/LibUint256Array.sol @@ -105,4 +105,37 @@ library LibUint256Array { c[i] = a[i] + b[i]; } } + + function max(uint256[] memory array) + external + pure + returns (bool isEmpty, uint256 maxElem) + { + (isEmpty, maxElem) = maxBefore(array, array.length); + } + + error InvalidSubArrayLength(uint256 subArrayLength, uint256 arrayLength); + + function maxBefore(uint256[] memory array, uint256 subArrayLength) + public + pure + returns (bool isEmpty, uint256 maxElem) + { + if (subArrayLength == 0) { + isEmpty = true; + maxElem = 0; + } else { + require( + subArrayLength <= array.length, + InvalidSubArrayLength(subArrayLength, array.length) + ); + isEmpty = false; + maxElem = array[0]; + for (uint256 i = 1; i < subArrayLength; ++i) { + if (array[i] > maxElem) { + maxElem = array[i]; + } + } + } + } } diff --git a/test/util/LibUint256Array.t.sol b/test/util/LibUint256Array.t.sol index 7ee4f529..1f197d8e 100644 --- a/test/util/LibUint256Array.t.sol +++ b/test/util/LibUint256Array.t.sol @@ -6,11 +6,14 @@ pragma solidity ^0.8.22; import {Test} from "forge-std-1.9.6/src/Test.sol"; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; +import {LibBytes} from "./LibBytes.sol"; import {LibMath} from "./LibMath.sol"; import {LibUint256Array} from "./LibUint256Array.sol"; contract LibUint256ArrayTest is Test { + using LibUint256Array for uint256[]; using LibUint256Array for Vm; + using LibBytes for bytes; mapping(uint256 => uint256) _histogram; uint256[] _uniqueElements; @@ -54,8 +57,7 @@ contract LibUint256ArrayTest is Test { function testSplit(uint256[] memory array) external { uint256 firstLength = vm.randomUint(0, array.length); - (uint256[] memory first, uint256[] memory second) = - LibUint256Array.split(array, firstLength); + (uint256[] memory first, uint256[] memory second) = array.split(firstLength); assertEq(first.length, firstLength); assertEq(first.length + second.length, array.length); for (uint256 i; i < first.length; ++i) { @@ -69,7 +71,7 @@ contract LibUint256ArrayTest is Test { function testContains(uint256[] memory array) external { for (uint256 i; i < array.length; ++i) { uint256 elem = array[i]; - assertTrue(LibUint256Array.contains(array, elem)); + assertTrue(array.contains(elem)); } uint256 notElem; while (true) { @@ -85,7 +87,7 @@ contract LibUint256ArrayTest is Test { break; } } - assertFalse(LibUint256Array.contains(array, notElem)); + assertFalse(array.contains(notElem)); } function testContainsBefore(uint256[] memory array) external { @@ -93,7 +95,7 @@ contract LibUint256ArrayTest is Test { uint256 elem = array[i]; uint256 j = vm.randomUint(i + 1, array.length); assertTrue( - LibUint256Array.containsBefore(array, elem, j), + array.containsBefore(elem, j), "element in array should be contained before an index greater its own" ); } @@ -114,7 +116,7 @@ contract LibUint256ArrayTest is Test { } } assertFalse( - LibUint256Array.containsBefore(array, notElem, i), + array.containsBefore(notElem, i), "element not in subarray should not be contained before any of its indices" ); } @@ -139,8 +141,79 @@ contract LibUint256ArrayTest is Test { a[i] = vm.randomUint(0, c[i]); b[i] = c[i] - a[i]; } - assertEq(LibUint256Array.add(a, b), c); - assertEq(LibUint256Array.sub(c, b), a); - assertEq(LibUint256Array.sub(c, a), b); + assertEq(a.add(b), c); + assertEq(c.sub(b), a); + assertEq(c.sub(a), b); + } + + function testMax(uint256[] memory array) external pure { + (bool isEmpty, uint256 max) = array.max(); + + if (isEmpty) { + assertEq(array.length, 0, "Expected array to be empty"); + } else { + assertGt(array.length, 0, "Expected array to be non-empty"); + bool foundMaxInArray = false; + for (uint256 i; i < array.length; ++i) { + assertLe( + array[i], + max, + "Expected max() to return a value >= any value in array" + ); + if (array[i] == max) { + foundMaxInArray = true; + } + } + assertTrue(foundMaxInArray, "Expected to find maximum value in array"); + } + } + + function testMaxBefore(uint256[] memory array, uint256 subArrayLength) external pure { + try array.maxBefore(subArrayLength) returns (bool isEmpty, uint256 max) { + if (isEmpty) { + assertEq(subArrayLength, 0, "Expected sub-array to be empty"); + } else { + assertGt(subArrayLength, 0, "Expected sub-array to be non-empty"); + assertLe( + subArrayLength, + array.length, + "Expected sub-array length to be <= array length" + ); + bool foundMaxInSubArray = false; + for (uint256 i; i < subArrayLength; ++i) { + assertLe( + array[i], + max, + "Expected max() to return a value >= any value in array" + ); + if (array[i] == max) { + foundMaxInSubArray = true; + } + } + assertTrue(foundMaxInSubArray, "Expected to find maximum value in array"); + } + } catch (bytes memory error) { + (bytes4 errorSelector, bytes memory errorArgs) = error.consumeBytes4(); + if (errorSelector == LibUint256Array.InvalidSubArrayLength.selector) { + (uint256 arg1, uint256 arg2) = abi.decode(errorArgs, (uint256, uint256)); + assertEq( + arg1, + subArrayLength, + "Expected InvalidSubArrayLength.subArrayLength == subArrayLength" + ); + assertEq( + arg2, + array.length, + "Expected InvalidSubArrayLength.arrayLength == array.length" + ); + assertGt( + subArrayLength, + array.length, + "Expected sub-array length to be > array length" + ); + } else { + revert("Unexpected error"); + } + } } } From 58929404754a5b1b3f99e77e6305fd848b3c0e03 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 10:29:23 -0300 Subject: [PATCH 27/48] Move `guardian` up in `WithdrawalConfig` --- src/common/WithdrawalConfig.sol | 4 ++-- src/dapp/Application.sol | 10 +++++----- test/dapp/ApplicationFactory.t.sol | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/common/WithdrawalConfig.sol b/src/common/WithdrawalConfig.sol index 4004aef0..4f5c7a73 100644 --- a/src/common/WithdrawalConfig.sol +++ b/src/common/WithdrawalConfig.sol @@ -6,15 +6,15 @@ pragma solidity ^0.8.8; import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; // @notice Withdrawal configuration parameters. +// @param guardian The address of the account with guardian priviledges // @param log2LeavesPerAccount The base-2 log of leaves per account // @param log2MaxNumOfAccounts The base-2 log of max. num. of accounts // @param accountsDriveStartIndex The offset of the accounts drive -// @param guardian The address of the account with guardian priviledges // @param withdrawer The address of the withdrawer delegatecall contract struct WithdrawalConfig { + address guardian; uint8 log2LeavesPerAccount; uint8 log2MaxNumOfAccounts; uint64 accountsDriveStartIndex; - address guardian; IWithdrawer withdrawer; } diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index 66a19214..c043b08d 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -43,6 +43,10 @@ contract Application is /// @dev See the `getTemplateHash` function. bytes32 immutable TEMPLATE_HASH; + /// @notice The guardian address. + /// @dev See the `getGuardian` function. + address immutable GUARDIAN; + /// @notice The base-2 log of leaves per account. /// @dev See the `getLog2LeavesPerAccount` function. uint8 immutable LOG2_LEAVES_PER_ACCOUNT; @@ -55,10 +59,6 @@ contract Application is /// @dev See the `getAccountsDriveStartIndex` function. uint64 immutable ACCOUNTS_DRIVE_START_INDEX; - /// @notice The guardian address. - /// @dev See the `getGuardian` function. - address immutable GUARDIAN; - /// @notice The withdrawer contract. /// @dev See the `getWithdrawer` function. IWithdrawer immutable WITHDRAWER; @@ -97,10 +97,10 @@ contract Application is ) Ownable(initialOwner) { require(withdrawawlConfig.isValid(), "Invalid withdrawal config"); TEMPLATE_HASH = templateHash; + GUARDIAN = withdrawawlConfig.guardian; LOG2_LEAVES_PER_ACCOUNT = withdrawawlConfig.log2LeavesPerAccount; LOG2_MAX_NUM_OF_ACCOUNTS = withdrawawlConfig.log2MaxNumOfAccounts; ACCOUNTS_DRIVE_START_INDEX = withdrawawlConfig.accountsDriveStartIndex; - GUARDIAN = withdrawawlConfig.guardian; WITHDRAWER = withdrawawlConfig.withdrawer; _outputsMerkleRootValidator = outputsMerkleRootValidator; _dataAvailability = dataAvailability; diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index 34e074a7..917b85b5 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -232,6 +232,11 @@ contract ApplicationFactoryTest is Test { templateHash, "getTemplateHash() != templateHash" ); + assertEq( + appContract.getGuardian(), + withdrawalConfig.guardian, + "getGuardian() != withdrawalConfig.guardian" + ); assertEq( appContract.getLog2LeavesPerAccount(), withdrawalConfig.log2LeavesPerAccount, @@ -247,11 +252,6 @@ contract ApplicationFactoryTest is Test { withdrawalConfig.accountsDriveStartIndex, "getAccountsDriveStartIndex() != withdrawalConfig.accountsDriveStartIndex" ); - assertEq( - appContract.getGuardian(), - withdrawalConfig.guardian, - "getGuardian() != withdrawalConfig.guardian" - ); assertEq( address(appContract.getWithdrawer()), address(withdrawalConfig.withdrawer), From a1d4e435e9fb8e0a8aec46adac784c9ed22ae084 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 10:47:10 -0300 Subject: [PATCH 28/48] Refactor withdrawer -> withdrawal output builder --- src/common/WithdrawalConfig.sol | 6 +-- src/dapp/Application.sol | 33 +++++++++------- src/dapp/IApplication.sol | 29 +------------- src/dapp/IApplicationWithdrawal.sol | 43 +++++++++++++++++++++ src/withdrawal/IWithdrawalOutputBuilder.sol | 21 ++++++++++ src/withdrawers/IWithdrawer.sol | 14 ------- test/dapp/ApplicationFactory.t.sol | 6 +-- 7 files changed, 92 insertions(+), 60 deletions(-) create mode 100644 src/dapp/IApplicationWithdrawal.sol create mode 100644 src/withdrawal/IWithdrawalOutputBuilder.sol delete mode 100644 src/withdrawers/IWithdrawer.sol diff --git a/src/common/WithdrawalConfig.sol b/src/common/WithdrawalConfig.sol index 4f5c7a73..11b92c4f 100644 --- a/src/common/WithdrawalConfig.sol +++ b/src/common/WithdrawalConfig.sol @@ -3,18 +3,18 @@ pragma solidity ^0.8.8; -import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; +import {IWithdrawalOutputBuilder} from "../withdrawal/IWithdrawalOutputBuilder.sol"; // @notice Withdrawal configuration parameters. // @param guardian The address of the account with guardian priviledges // @param log2LeavesPerAccount The base-2 log of leaves per account // @param log2MaxNumOfAccounts The base-2 log of max. num. of accounts // @param accountsDriveStartIndex The offset of the accounts drive -// @param withdrawer The address of the withdrawer delegatecall contract +// @param withdrawalOutputBuilder The address of the withdrawal output builder struct WithdrawalConfig { address guardian; uint8 log2LeavesPerAccount; uint8 log2MaxNumOfAccounts; uint64 accountsDriveStartIndex; - IWithdrawer withdrawer; + IWithdrawalOutputBuilder withdrawalOutputBuilder; } diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index c043b08d..b539907d 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -11,7 +11,7 @@ import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValida import {LibAddress} from "../library/LibAddress.sol"; import {LibOutputValidityProof} from "../library/LibOutputValidityProof.sol"; import {LibWithdrawalConfig} from "../library/LibWithdrawalConfig.sol"; -import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; +import {IWithdrawalOutputBuilder} from "../withdrawal/IWithdrawalOutputBuilder.sol"; import {IApplication} from "./IApplication.sol"; import {Ownable} from "@openzeppelin-contracts-5.2.0/access/Ownable.sol"; @@ -59,9 +59,9 @@ contract Application is /// @dev See the `getAccountsDriveStartIndex` function. uint64 immutable ACCOUNTS_DRIVE_START_INDEX; - /// @notice The withdrawer contract. - /// @dev See the `getWithdrawer` function. - IWithdrawer immutable WITHDRAWER; + /// @notice The withdrawal output builder contract. + /// @dev See the `getWithdrawalOutputBuilder` function. + IWithdrawalOutputBuilder immutable WITHDRAWAL_OUTPUT_BUILDER; /// @notice Keeps track of which outputs have been executed. /// @dev See the `wasOutputExecuted` function. @@ -87,21 +87,23 @@ contract Application is /// @param outputsMerkleRootValidator The initial outputs Merkle root validator contract /// @param initialOwner The initial application owner /// @param templateHash The initial machine state hash + /// @param dataAvailability The data availability solution + /// @param withdrawalConfig The withdrawal configuration /// @dev Reverts if the initial application owner address is zero. constructor( IOutputsMerkleRootValidator outputsMerkleRootValidator, address initialOwner, bytes32 templateHash, bytes memory dataAvailability, - WithdrawalConfig memory withdrawawlConfig + WithdrawalConfig memory withdrawalConfig ) Ownable(initialOwner) { - require(withdrawawlConfig.isValid(), "Invalid withdrawal config"); + require(withdrawalConfig.isValid(), "Invalid withdrawal config"); TEMPLATE_HASH = templateHash; - GUARDIAN = withdrawawlConfig.guardian; - LOG2_LEAVES_PER_ACCOUNT = withdrawawlConfig.log2LeavesPerAccount; - LOG2_MAX_NUM_OF_ACCOUNTS = withdrawawlConfig.log2MaxNumOfAccounts; - ACCOUNTS_DRIVE_START_INDEX = withdrawawlConfig.accountsDriveStartIndex; - WITHDRAWER = withdrawawlConfig.withdrawer; + GUARDIAN = withdrawalConfig.guardian; + LOG2_LEAVES_PER_ACCOUNT = withdrawalConfig.log2LeavesPerAccount; + LOG2_MAX_NUM_OF_ACCOUNTS = withdrawalConfig.log2MaxNumOfAccounts; + ACCOUNTS_DRIVE_START_INDEX = withdrawalConfig.accountsDriveStartIndex; + WITHDRAWAL_OUTPUT_BUILDER = withdrawalConfig.withdrawalOutputBuilder; _outputsMerkleRootValidator = outputsMerkleRootValidator; _dataAvailability = dataAvailability; } @@ -244,8 +246,13 @@ contract Application is return GUARDIAN; } - function getWithdrawer() external view override returns (IWithdrawer) { - return WITHDRAWER; + function getWithdrawalOutputBuilder() + external + view + override + returns (IWithdrawalOutputBuilder) + { + return WITHDRAWAL_OUTPUT_BUILDER; } function isForeclosed() external view override returns (bool) { diff --git a/src/dapp/IApplication.sol b/src/dapp/IApplication.sol index 1dc678ca..05f23ed7 100644 --- a/src/dapp/IApplication.sol +++ b/src/dapp/IApplication.sol @@ -6,8 +6,8 @@ pragma solidity ^0.8.8; import {IOwnable} from "../access/IOwnable.sol"; import {OutputValidityProof} from "../common/OutputValidityProof.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; -import {IWithdrawer} from "../withdrawers/IWithdrawer.sol"; import {IApplicationForeclosure} from "./IApplicationForeclosure.sol"; +import {IApplicationWithdrawal} from "./IApplicationWithdrawal.sol"; /// @notice The base layer incarnation of an application running on the execution layer. /// @notice The state of the application advances through inputs sent to an `IInputBox` contract. @@ -26,7 +26,7 @@ import {IApplicationForeclosure} from "./IApplicationForeclosure.sol"; /// - multiple signers (multi-sig) /// - DAO (decentralized autonomous organization) /// - self-owned application (off-chain governance logic) -interface IApplication is IOwnable, IApplicationForeclosure { +interface IApplication is IOwnable, IApplicationForeclosure, IApplicationWithdrawal { // Events /// @notice MUST trigger when a new outputs Merkle root validator is chosen. @@ -125,29 +125,4 @@ interface IApplication is IOwnable, IApplicationForeclosure { /// @notice Get number of outputs executed by the application. function getNumberOfExecutedOutputs() external view returns (uint256); - - /// @notice Get the log (base 2) of the number of leaves - /// in the machine state tree that are reserved for - /// each account in the accounts drive. - function getLog2LeavesPerAccount() external view returns (uint8); - - /// @notice Get the log (base 2) of the maximum number - /// of accounts that can be stored in the accounts drive. - /// @notice This is equivalent to the depth of the accounts - /// drive tree whose leaves are the account roots. - function getLog2MaxNumOfAccounts() external view returns (uint8); - - /// @notice Get the factor that, when multiplied by the - /// size of the accounts drive, yields the start memory address - /// of the accounts drive. - /// @dev If `a = getLog2LeavesPerAccount()` - /// `b = getLog2MaxNumOfAccounts()`, - /// and `c = getAccountsDriveStartIndex()`, - /// then the accounts drive starts at `c*2^{a+b+5}` - /// and has size `2^{a+b+5}`. - function getAccountsDriveStartIndex() external view returns (uint64); - - /// @notice Get the withdrawer contract, - /// which gets delegate-called to withdraw funds from accounts. - function getWithdrawer() external view returns (IWithdrawer); } diff --git a/src/dapp/IApplicationWithdrawal.sol b/src/dapp/IApplicationWithdrawal.sol new file mode 100644 index 00000000..fe44cc12 --- /dev/null +++ b/src/dapp/IApplicationWithdrawal.sol @@ -0,0 +1,43 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {IWithdrawalOutputBuilder} from "../withdrawal/IWithdrawalOutputBuilder.sol"; + +interface IApplicationWithdrawal { + // Events + + /// @notice MUST trigger when the funds of an account are withdrawn. + /// @param accountIndex The account index in the accounts drive + /// @param account The account as encoded in the accounts drive + /// @param account The withdrawal output + event Withdrawal(uint64 accountIndex, bytes account, bytes output); + + // View Functions + + /// @notice Get the log (base 2) of the number of leaves + /// in the machine state tree that are reserved for + /// each account in the accounts drive. + function getLog2LeavesPerAccount() external view returns (uint8); + + /// @notice Get the log (base 2) of the maximum number + /// of accounts that can be stored in the accounts drive. + /// @notice This is equivalent to the depth of the accounts + /// drive tree whose leaves are the account roots. + function getLog2MaxNumOfAccounts() external view returns (uint8); + + /// @notice Get the factor that, when multiplied by the + /// size of the accounts drive, yields the start memory address + /// of the accounts drive. + /// @dev If `a = getLog2LeavesPerAccount()` + /// `b = getLog2MaxNumOfAccounts()`, + /// and `c = getAccountsDriveStartIndex()`, + /// then the accounts drive starts at `c*2^{a+b+5}` + /// and has size `2^{a+b+5}`. + function getAccountsDriveStartIndex() external view returns (uint64); + + /// @notice Get the withdrawal output builder, which gets static-called + /// whenever the funds of an account are to be withdrawn. + function getWithdrawalOutputBuilder() external view returns (IWithdrawalOutputBuilder); +} diff --git a/src/withdrawal/IWithdrawalOutputBuilder.sol b/src/withdrawal/IWithdrawalOutputBuilder.sol new file mode 100644 index 00000000..5cc52b6e --- /dev/null +++ b/src/withdrawal/IWithdrawalOutputBuilder.sol @@ -0,0 +1,21 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +interface IWithdrawalOutputBuilder { + /// @notice Build an output that, when executed by the application + /// contract, transfers the funds of an account to its owner. + /// The encoding of the account is application-specific. + /// This function will be called via the `STATICCALL` opcode, + /// so any state changes such as contract creations, + /// log emissions, storage writes, self-destructions + /// and Ether transfers will revert the call and abort the execution + /// of the withdrawal output. These state-changing constraints + /// are already checked by the Solidity compiler when implementing + /// this function as either view or pure. + function buildWithdrawalOutput(bytes calldata account) + external + view + returns (bytes memory output); +} diff --git a/src/withdrawers/IWithdrawer.sol b/src/withdrawers/IWithdrawer.sol deleted file mode 100644 index f2cdcf18..00000000 --- a/src/withdrawers/IWithdrawer.sol +++ /dev/null @@ -1,14 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pragma solidity ^0.8.8; - -interface IWithdrawer { - /// @notice Withdraw the funds of an account. - /// The encoding of accounts is application-specific. - /// This function will be called via `delegatecall`, - /// so it should not attempt to access its storage space. - /// @param account The account - /// @return accountOwner The account owner - function withdraw(bytes calldata account) external returns (address accountOwner); -} diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index 917b85b5..30dad0c0 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -253,9 +253,9 @@ contract ApplicationFactoryTest is Test { "getAccountsDriveStartIndex() != withdrawalConfig.accountsDriveStartIndex" ); assertEq( - address(appContract.getWithdrawer()), - address(withdrawalConfig.withdrawer), - "getWithdrawer() != withdrawalConfig.withdrawer" + address(appContract.getWithdrawalOutputBuilder()), + address(withdrawalConfig.withdrawalOutputBuilder), + "getWithdrawalOutputBuilder() != withdrawalConfig.withdrawalOutputBuilder" ); assertEq( appContract.getDataAvailability(), From 7254d2177602621ee996d01070f6269ca1d755b8 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 11:25:44 -0300 Subject: [PATCH 29/48] Add `getNumberOfWithdrawals` view function --- src/dapp/Application.sol | 10 +++++++++- src/dapp/IApplicationWithdrawal.sol | 4 ++++ test/dapp/ApplicationFactory.t.sol | 6 ++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index b539907d..e94ce423 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -80,9 +80,13 @@ contract Application is bool internal _isForeclosed; /// @notice The number of outputs executed by the application. - /// @dev See the `numberOfOutputsExecuted` function. + /// @dev See the `getNumberOfExecutedOutputs` function. uint256 _numOfExecutedOutputs; + /// @notice The number of withdrawals from the application. + /// @dev See the `getNumberOfWithdrawals` function. + uint256 _numOfWithdrawals; + /// @notice Creates an `Application` contract. /// @param outputsMerkleRootValidator The initial outputs Merkle root validator contract /// @param initialOwner The initial application owner @@ -230,6 +234,10 @@ contract Application is return _numOfExecutedOutputs; } + function getNumberOfWithdrawals() external view override returns (uint256) { + return _numOfWithdrawals; + } + function getLog2LeavesPerAccount() external view override returns (uint8) { return LOG2_LEAVES_PER_ACCOUNT; } diff --git a/src/dapp/IApplicationWithdrawal.sol b/src/dapp/IApplicationWithdrawal.sol index fe44cc12..b854c2b6 100644 --- a/src/dapp/IApplicationWithdrawal.sol +++ b/src/dapp/IApplicationWithdrawal.sol @@ -16,6 +16,10 @@ interface IApplicationWithdrawal { // View Functions + /// @notice Get the number of withdrawals. + /// Useful for fast-syncing `Withdrawal` events. + function getNumberOfWithdrawals() external view returns (uint256); + /// @notice Get the log (base 2) of the number of leaves /// in the machine state tree that are reserved for /// each account in the accounts drive. diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index 30dad0c0..36320815 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -267,6 +267,12 @@ contract ApplicationFactoryTest is Test { blockNumber, "getDeploymentBlockNumber() != blockNumber" ); + assertEq( + appContract.getNumberOfExecutedOutputs(), + 0, + "getNumberOfExecutedOutputs() != 0" + ); + assertEq(appContract.getNumberOfWithdrawals(), 0, "getNumberOfWithdrawals() != 0"); assertEq( withdrawalConfig.isValid(), true, "Expected withdrawal config to be valid" ); From f21255dc09d7a85773db69ee6feb2940be3cccb1 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 11:35:58 -0300 Subject: [PATCH 30/48] Add `wereAccountFundsWithdrawn` view function --- src/dapp/Application.sol | 12 ++++++++++++ src/dapp/IApplicationWithdrawal.sol | 4 ++++ test/dapp/ApplicationFactory.t.sol | 10 +++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index e94ce423..52c7c991 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -67,6 +67,10 @@ contract Application is /// @dev See the `wasOutputExecuted` function. BitMaps.BitMap internal _executed; + /// @notice Keeps track of which accounts have been withdrawn. + /// @dev See the `wereAccountFundsWithdrawn` function. + BitMaps.BitMap internal _withdrawn; + /// @notice The current outputs Merkle root validator contract. /// @dev See the `getOutputsMerkleRootValidator` and `migrateToOutputsMerkleRootValidator` functions. IOutputsMerkleRootValidator internal _outputsMerkleRootValidator; @@ -178,6 +182,14 @@ contract Application is return _executed.get(outputIndex); } + function wereAccountFundsWithdrawn(uint256 accountIndex) + external + view + returns (bool) + { + return _withdrawn.get(accountIndex); + } + /// @inheritdoc IApplication function validateOutput(bytes calldata output, OutputValidityProof calldata proof) public diff --git a/src/dapp/IApplicationWithdrawal.sol b/src/dapp/IApplicationWithdrawal.sol index b854c2b6..087d2322 100644 --- a/src/dapp/IApplicationWithdrawal.sol +++ b/src/dapp/IApplicationWithdrawal.sol @@ -20,6 +20,10 @@ interface IApplicationWithdrawal { /// Useful for fast-syncing `Withdrawal` events. function getNumberOfWithdrawals() external view returns (uint256); + /// @notice Check whether an account had its funds withdrawn. + /// @param accountIndex The index of the account in the accounts drive. + function wereAccountFundsWithdrawn(uint256 accountIndex) external view returns (bool); + /// @notice Get the log (base 2) of the number of leaves /// in the machine state tree that are reserved for /// each account in the accounts drive. diff --git a/test/dapp/ApplicationFactory.t.sol b/test/dapp/ApplicationFactory.t.sol index 36320815..d66a66f4 100644 --- a/test/dapp/ApplicationFactory.t.sol +++ b/test/dapp/ApplicationFactory.t.sol @@ -169,7 +169,7 @@ contract ApplicationFactoryTest is Test { IApplication appContract, uint256 blockNumber, Vm.Log[] memory logs - ) internal view { + ) internal { uint256 numOfApplicationsCreated; for (uint256 i; i < logs.length; ++i) { @@ -272,7 +272,15 @@ contract ApplicationFactoryTest is Test { 0, "getNumberOfExecutedOutputs() != 0" ); + assertFalse( + appContract.wasOutputExecuted(vm.randomUint()), + "initially, wasOutputExecuted(...) = false" + ); assertEq(appContract.getNumberOfWithdrawals(), 0, "getNumberOfWithdrawals() != 0"); + assertFalse( + appContract.wereAccountFundsWithdrawn(vm.randomUint()), + "initially, wereAccountFundsWithdrawn(...) = false" + ); assertEq( withdrawalConfig.isValid(), true, "Expected withdrawal config to be valid" ); From 7fb13ad5b809e94743ff3fde8456df7ec4c22544 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 14:50:53 -0300 Subject: [PATCH 31/48] Create interface for `SafeERC20Transfer` --- src/delegatecall/ISafeERC20Transfer.sol | 14 ++++++++++++++ src/delegatecall/SafeERC20Transfer.sol | 6 ++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 src/delegatecall/ISafeERC20Transfer.sol diff --git a/src/delegatecall/ISafeERC20Transfer.sol b/src/delegatecall/ISafeERC20Transfer.sol new file mode 100644 index 00000000..a2a8cb3b --- /dev/null +++ b/src/delegatecall/ISafeERC20Transfer.sol @@ -0,0 +1,14 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin-contracts-5.2.0/token/ERC20/IERC20.sol"; + +interface ISafeERC20Transfer { + /// @notice Safely transfer ERC-20 tokens. + /// @param token The ERC-20 token contract + /// @param to The token receipient address + /// @param value The amount of tokens + function safeTransfer(IERC20 token, address to, uint256 value) external; +} diff --git a/src/delegatecall/SafeERC20Transfer.sol b/src/delegatecall/SafeERC20Transfer.sol index 3e59d49f..76eddec2 100644 --- a/src/delegatecall/SafeERC20Transfer.sol +++ b/src/delegatecall/SafeERC20Transfer.sol @@ -6,10 +6,12 @@ pragma solidity ^0.8.20; import {IERC20} from "@openzeppelin-contracts-5.2.0/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin-contracts-5.2.0/token/ERC20/utils/SafeERC20.sol"; -contract SafeERC20Transfer { +import {ISafeERC20Transfer} from "./ISafeERC20Transfer.sol"; + +contract SafeERC20Transfer is ISafeERC20Transfer { using SafeERC20 for IERC20; - function safeTransfer(IERC20 token, address to, uint256 value) external { + function safeTransfer(IERC20 token, address to, uint256 value) external override { token.safeTransfer(to, value); } } From bfc76d942b124f82f22c9cb03b3a148febaa5c27 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 15:03:20 -0300 Subject: [PATCH 32/48] Add `UsdWithdrawalOutputBuilder` contract and test --- src/withdrawal/UsdWithdrawalOutputBuilder.sol | 60 ++++++++++++++ .../UsdWithdrawalOutputBuilder.t.sol | 81 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/withdrawal/UsdWithdrawalOutputBuilder.sol create mode 100644 test/withdrawal/UsdWithdrawalOutputBuilder.t.sol diff --git a/src/withdrawal/UsdWithdrawalOutputBuilder.sol b/src/withdrawal/UsdWithdrawalOutputBuilder.sol new file mode 100644 index 00000000..10c6f575 --- /dev/null +++ b/src/withdrawal/UsdWithdrawalOutputBuilder.sol @@ -0,0 +1,60 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {IERC20} from "@openzeppelin-contracts-5.2.0/token/ERC20/IERC20.sol"; + +import {Outputs} from "../common/Outputs.sol"; +import {ISafeERC20Transfer} from "../delegatecall/ISafeERC20Transfer.sol"; +import {IWithdrawalOutputBuilder} from "./IWithdrawalOutputBuilder.sol"; + +contract UsdWithdrawalOutputBuilder is IWithdrawalOutputBuilder { + ISafeERC20Transfer immutable SAFE_ERC20_TRANSFER; + IERC20 immutable USD; + + constructor(ISafeERC20Transfer safeTransfer, IERC20 usd) { + SAFE_ERC20_TRANSFER = safeTransfer; + USD = usd; + } + + function buildWithdrawalOutput(bytes calldata account) + external + view + override + returns (bytes memory output) + { + (address user, uint256 balance) = _decodeAccount(account); + address destination = address(SAFE_ERC20_TRANSFER); + bytes memory payload = _encodeSafeTransferPayload(user, balance); + return _encodeDelegateCallVoucher(destination, payload); + } + + function _decodeAccount(bytes calldata account) + internal + pure + returns (address user, uint256 balance) + { + require(account.length >= 28, "Account is too short"); + user = address(uint160(bytes20(account[8:28]))); + for (uint256 i; i < 8; ++i) { + balance |= (uint256(uint8(account[i])) << (8 * i)); + } + } + + function _encodeSafeTransferPayload(address user, uint256 value) + internal + view + returns (bytes memory payload) + { + return abi.encodeCall(ISafeERC20Transfer.safeTransfer, (USD, user, value)); + } + + function _encodeDelegateCallVoucher(address destination, bytes memory payload) + internal + pure + returns (bytes memory output) + { + return abi.encodeCall(Outputs.DelegateCallVoucher, (destination, payload)); + } +} diff --git a/test/withdrawal/UsdWithdrawalOutputBuilder.t.sol b/test/withdrawal/UsdWithdrawalOutputBuilder.t.sol new file mode 100644 index 00000000..ac53d9e8 --- /dev/null +++ b/test/withdrawal/UsdWithdrawalOutputBuilder.t.sol @@ -0,0 +1,81 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {IERC20} from "@openzeppelin-contracts-5.2.0/token/ERC20/IERC20.sol"; + +import {Outputs} from "src/common/Outputs.sol"; +import {ISafeERC20Transfer} from "src/delegatecall/ISafeERC20Transfer.sol"; +import {SafeERC20Transfer} from "src/delegatecall/SafeERC20Transfer.sol"; +import {IWithdrawalOutputBuilder} from "src/withdrawal/IWithdrawalOutputBuilder.sol"; +import {UsdWithdrawalOutputBuilder} from "src/withdrawal/UsdWithdrawalOutputBuilder.sol"; + +import {LibBytes} from "../util/LibBytes.sol"; +import {SimpleERC20} from "../util/SimpleERC20.sol"; + +contract UsdWithdrawalOutputBuilderTest is Test { + using LibBytes for bytes; + + IERC20 _usd; + ISafeERC20Transfer _safeErc20Transfer; + IWithdrawalOutputBuilder _withdrawalOutputBuilder; + + address immutable TOKEN_OWNER = vm.addr(1); + uint256 constant TOTAL_SUPPLY = type(uint64).max; + + function setUp() external { + _usd = new SimpleERC20(TOKEN_OWNER, TOTAL_SUPPLY); + _safeErc20Transfer = new SafeERC20Transfer(); + _withdrawalOutputBuilder = + new UsdWithdrawalOutputBuilder(_safeErc20Transfer, _usd); + } + + function testBuildWithdrawalOutput( + address user, + uint64 balance, + bytes calldata padding + ) external view { + bytes memory account = abi.encodePacked(_encodeAccount(user, balance), padding); + assertGe(account.length, 28); + bytes memory output = _withdrawalOutputBuilder.buildWithdrawalOutput(account); + (bytes4 outputSelector, bytes memory outputArgs) = output.consumeBytes4(); + assertEq(outputSelector, Outputs.DelegateCallVoucher.selector); + (address destination, bytes memory payload) = + abi.decode(outputArgs, (address, bytes)); + assertEq(destination, address(_safeErc20Transfer)); + (bytes4 funcSelector, bytes memory callArgs) = payload.consumeBytes4(); + assertEq(funcSelector, ISafeERC20Transfer.safeTransfer.selector); + (address token, address to, uint256 value) = + abi.decode(callArgs, (address, address, uint256)); + assertEq(token, address(_usd)); + assertEq(to, user); + assertEq(value, balance); + } + + function testBuildWithdrawalOutputReverts(bytes calldata account) external { + vm.assume(account.length < 28); + vm.expectRevert("Account is too short"); + _withdrawalOutputBuilder.buildWithdrawalOutput(account); + } + + function _encodeAccount(address user, uint64 balance) + internal + pure + returns (bytes memory account) + { + account = new bytes(28); + + // Encode balance in little-endian order + for (uint256 i; i < 8; ++i) { + account[i] = bytes1(uint8((balance >> (8 * i)) & 0xff)); + } + + // Encode user address in big-endian order + for (uint256 i; i < 20; ++i) { + account[i + 8] = bytes1((bytes20(user) << (8 * i)) & bytes1(0xff)); + } + } +} From ffa0d51a1f1af7505672ec060a80e89f7be9ccc1 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 17:25:09 -0300 Subject: [PATCH 33/48] Add `LibMath` - Adapted from `draft/4.0` --- src/library/LibMath.sol | 87 ++++++++++++++++ test/library/LibMath.t.sol | 198 +++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/library/LibMath.sol create mode 100644 test/library/LibMath.t.sol diff --git a/src/library/LibMath.sol b/src/library/LibMath.sol new file mode 100644 index 00000000..ad58034a --- /dev/null +++ b/src/library/LibMath.sol @@ -0,0 +1,87 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.0; + +/// @author Felipe Argento +library LibMath { + /// @notice Count trailing zeros. + /// @param x The number you want the ctz of + /// @dev This is a binary search implementation. + function ctz(uint256 x) internal pure returns (uint256) { + if (x == 0) return 256; + else return 256 - clz(~x & (x - 1)); + } + + /// @notice Count leading zeros. + /// @param x The number you want the clz of + /// @dev This a binary search implementation. + function clz(uint256 x) internal pure returns (uint256) { + if (x == 0) return 256; + + uint256 n = 0; + if (x & 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000000000000000000000000000 == 0) { + n = n + 128; + x = x << 128; + } + if (x & 0xFFFFFFFFFFFFFFFF000000000000000000000000000000000000000000000000 == 0) { + n = n + 64; + x = x << 64; + } + if (x & 0xFFFFFFFF00000000000000000000000000000000000000000000000000000000 == 0) { + n = n + 32; + x = x << 32; + } + if (x & 0xFFFF000000000000000000000000000000000000000000000000000000000000 == 0) { + n = n + 16; + x = x << 16; + } + if (x & 0xFF00000000000000000000000000000000000000000000000000000000000000 == 0) { + n = n + 8; + x = x << 8; + } + if (x & 0xF000000000000000000000000000000000000000000000000000000000000000 == 0) { + n = n + 4; + x = x << 4; + } + if (x & 0xC000000000000000000000000000000000000000000000000000000000000000 == 0) { + n = n + 2; + x = x << 2; + } + if (x & 0x8000000000000000000000000000000000000000000000000000000000000000 == 0) { + n = n + 1; + } + + return n; + } + + /// @notice The smallest y for which x <= 2^y. + /// @param x The number you want the ceilLog2 of + /// @dev This is a binary search implementation. + function ceilLog2(uint256 x) internal pure returns (uint256) { + if (x == 0) return 0; + else return 256 - clz(x - 1); + } + + /// @notice Tried to compute floorLog2(0), which is undefined. + error FloorLog2OfZeroIsUndefined(); + + /// @notice The biggest y for which x >= 2^y. + /// @param x The number you want the floorLog2 of + /// @dev This is a binary search implementation. + /// @dev This function reverts if x = 0 is provided. + function floorLog2(uint256 x) internal pure returns (uint256) { + if (x == 0) revert FloorLog2OfZeroIsUndefined(); + else return 255 - clz(x); + } + + /// @notice The largest of two numbers. + function max(uint256 x, uint256 y) internal pure returns (uint256) { + return (x > y) ? x : y; + } + + /// @notice The smallest of two numbers. + function min(uint256 x, uint256 y) internal pure returns (uint256) { + return (x < y) ? x : y; + } +} diff --git a/test/library/LibMath.t.sol b/test/library/LibMath.t.sol new file mode 100644 index 00000000..d9f6d21a --- /dev/null +++ b/test/library/LibMath.t.sol @@ -0,0 +1,198 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {LibMath} from "src/library/LibMath.sol"; + +/// forge-lint: disable-start(incorrect-shift) + +/// @title Alternative naive, gas-inefficient implementation of LibMath +library LibNaiveMath { + function ctz(uint256 x) internal pure returns (uint256) { + uint256 n = 256; + while (x != 0) { + --n; + x <<= 1; + } + return n; + } + + function clz(uint256 x) internal pure returns (uint256) { + uint256 n = 256; + while (x != 0) { + --n; + x >>= 1; + } + return n; + } + + function ceilLog2(uint256 x) internal pure returns (uint256) { + for (uint256 i; i < 256; ++i) { + if (x <= (1 << i)) { + return i; + } + } + return 256; + } + + function floorLog2(uint256 x) internal pure returns (uint256) { + require(x > 0, "floorLog2(0) is undefined"); + for (uint256 i; i < 256; ++i) { + if ((x >> i) == 1) { + return i; + } + } + revert("unexpected code path reached"); + } +} + +library ExternalLibMath { + function floorLog2(uint256 x) external pure returns (uint256) { + return LibMath.floorLog2(x); + } +} + +contract LibMathTest is Test { + function testCtz() external pure { + assertEq(LibMath.ctz(0), 256); + assertEq(LibMath.ctz(type(uint256).max), 0); + for (uint256 i; i < 256; ++i) { + assertEq(LibMath.ctz(1 << i), i); + for (uint256 j = i + 1; j < 256; ++j) { + assertEq(LibMath.ctz((1 << i) | (1 << j)), i); + } + } + } + + function testCtz(uint256 x) external pure { + assertEq(LibMath.ctz(x), LibNaiveMath.ctz(x)); + } + + function testClz() external pure { + assertEq(LibMath.clz(0), 256); + assertEq(LibMath.clz(type(uint256).max), 0); + for (uint256 i; i < 256; ++i) { + assertEq(LibMath.clz(1 << i), 255 - i); + for (uint256 j; j < i; ++j) { + assertEq(LibMath.clz((1 << i) | (1 << j)), 255 - i); + } + } + } + + function testClz(uint256 x) external pure { + assertEq(LibMath.clz(x), LibNaiveMath.clz(x)); + } + + function testCeilLog2() external pure { + assertEq(LibMath.ceilLog2(0), 0); + assertEq(LibMath.ceilLog2(type(uint256).max), 256); + for (uint256 i; i < 256; ++i) { + assertEq(LibMath.ceilLog2(1 << i), i); + for (uint256 j; j < i; ++j) { + assertEq(LibMath.ceilLog2((1 << i) | (1 << j)), i + 1); + } + } + } + + function testCeilLog2(uint256 x) external pure { + uint256 y = LibMath.ceilLog2(x); + assertEq(y, LibNaiveMath.ceilLog2(x)); + + // Check that x <= 2^y + if (y < 256) { + // If y < 256, then we can + // represent 2^y in an EVM word + assertLe(x, 1 << y); + } else { + // For any uint256 value x, + // it is true that x < 2^256 + assertEq(y, 256); + } + + // Check that y is the smallest + // number possible that satisfies + // x <= 2^y. That is, check that + // it doesn't hold for y-1. + if (y >= 1) { + assertGe(x, 1 << (y - 1)); + } + } + + function testFloorLog2() external pure { + assertEq(LibMath.floorLog2(1), 0); + assertEq(LibMath.floorLog2(type(uint256).max), 255); + for (uint256 i; i < 256; ++i) { + assertEq(LibMath.floorLog2(1 << i), i); + for (uint256 j; j < i; ++j) { + assertEq(LibMath.floorLog2((1 << i) | (1 << j)), i); + assertEq(LibMath.floorLog2((1 << i) - (1 << j)), i - 1); + } + } + } + + function testFloorLog2(uint256 x) external pure { + vm.assume(x > 0); + uint256 y = LibMath.floorLog2(x); + assertEq(y, LibNaiveMath.floorLog2(x)); + + // For any uint256 value x, + // it is not true that x >= 2^256 + assertLt(y, 256); + + // Check that x >= 2^y + // Because y < 256, we can + // represent 2^y in an EVM word. + assertGe(x, 1 << y); + + // Check that y is the biggest + // number possible that satisfies + // x >= 2^y. That is, check that + // it doesn't hold for y+1. + // We don't need to check + // For y = 255, we don't need + // to check, because for any + // uint256 value x, it is always + // true that x < 2^256. + if ((y + 1) < 256) { + assertLt(x, 1 << (y + 1)); + } + } + + function testFloorLog2OfZero() external { + vm.expectRevert(LibMath.FloorLog2OfZeroIsUndefined.selector); + ExternalLibMath.floorLog2(0); + } + + function testMin(uint256 x) external pure { + assertEq(LibMath.min(x, x), x); + assertEq(LibMath.min(x, 0), 0); + assertEq(LibMath.min(x, type(uint256).max), x); + } + + function testMin(uint256 x, uint256 y) external pure { + uint256 min = LibMath.min(x, y); + assertLe(min, x); + assertLe(min, y); + assertTrue(min == x || min == y); + assertEq(min, LibMath.min(y, x)); + } + + function testMax(uint256 x) external pure { + assertEq(LibMath.max(x, x), x); + assertEq(LibMath.max(x, 0), x); + assertEq(LibMath.max(x, type(uint256).max), type(uint256).max); + } + + function testMax(uint256 x, uint256 y) external pure { + uint256 max = LibMath.max(x, y); + assertGe(max, x); + assertGe(max, y); + assertTrue(max == x || max == y); + assertEq(max, LibMath.max(y, x)); + } +} + +/// forge-lint: disable-end(incorrect-shift) From 1c3ab36fb4639847561e512135b44316b5d1d0aa Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 17:25:56 -0300 Subject: [PATCH 34/48] Add `LibKeccak256` - Copied from `draft/4.0` --- src/library/LibKeccak256.sol | 66 +++++++++++++++++++++++++++++++ test/library/LibKeccak256.t.sol | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/library/LibKeccak256.sol create mode 100644 test/library/LibKeccak256.t.sol diff --git a/src/library/LibKeccak256.sol b/src/library/LibKeccak256.sol new file mode 100644 index 00000000..ab00cf76 --- /dev/null +++ b/src/library/LibKeccak256.sol @@ -0,0 +1,66 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +library LibKeccak256 { + /// @notice Hash a variable-length byte array. + /// @param b The byte array + function hashBytes(bytes memory b) internal pure returns (bytes32 result) { + /// @solidity memory-safe-assembly + assembly { + result := keccak256(add(b, 0x20), mload(b)) + } + } + + /// @notice Hash a data block at a given index and of a given size. + /// @param data The data + /// @param dataBlockIndex The data block index + /// @param dataBlockSize The data block size + /// @dev If the data block is too large, an out-of-memory error might be raised. + /// @dev If the data block index is too big, an arithmetic error might be raised. + function hashBlock(bytes memory data, uint256 dataBlockIndex, uint256 dataBlockSize) + internal + pure + returns (bytes32 result) + { + uint256 start = dataBlockIndex * dataBlockSize; + uint256 end = start + dataBlockSize; + uint256 dataLength = data.length; + if (end <= dataLength) { + // Block is completely within data and can be hashed in-place, without memory allocation + assembly { + result := keccak256(add(add(data, 0x20), start), dataBlockSize) + } + } else { + // Block is partially or completely outside data and requires memory allocation + bytes memory dataBlock = new bytes(dataBlockSize); + if (start < dataLength) { + // Block is partially within data and requires a memory-copy operation + assembly { + mcopy( + add(dataBlock, 0x20), + add(add(data, 0x20), start), + sub(dataLength, start) + ) + } + } + // Block is then hashed with a known size + assembly { + result := keccak256(add(dataBlock, 0x20), dataBlockSize) + } + } + } + + /// @notice Hash a pair of 32-byte values. + /// @dev Equivalent to keccak256(abi.encode(a, b)). + /// @dev Uses assembly to avoid memory allocation or expansion. + function hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32 result) { + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, a) + mstore(0x20, b) + result := keccak256(0x00, 0x40) + } + } +} diff --git a/test/library/LibKeccak256.t.sol b/test/library/LibKeccak256.t.sol new file mode 100644 index 00000000..20b4db5a --- /dev/null +++ b/test/library/LibKeccak256.t.sol @@ -0,0 +1,69 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {LibKeccak256} from "src/library/LibKeccak256.sol"; +import {LibMath} from "src/library/LibMath.sol"; + +/// @title Alternative naive, gas-inefficient implementation of LibKeccak256 +library LibNaiveKeccak256 { + function hashBytes(bytes memory b) internal pure returns (bytes32) { + return keccak256(b); + } + + function hashBlock(bytes memory data, uint256 dataBlockIndex, uint256 dataBlockSize) + internal + pure + returns (bytes32 result) + { + bytes memory dataBlock = new bytes(dataBlockSize); + uint256 offset = dataBlockIndex * dataBlockSize; + for (uint256 i; i < dataBlockSize; ++i) { + if (offset + i < data.length) { + dataBlock[i] = data[offset + i]; + } + } + return keccak256(dataBlock); + } + + function hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { + return keccak256(abi.encode(a, b)); + } +} + +contract LibKeccak256Test is Test { + using LibMath for uint256; + + function testHashBytes(bytes memory b) external pure { + assertEq(LibKeccak256.hashBytes(b), LibNaiveKeccak256.hashBytes(b)); + } + + function testHashBlock( + bytes memory data, + uint256 dataBlockIndex, + uint256 dataBlockSize + ) external pure { + // We need to bound the data block size because, otherwise, + // allocating a data block too large would lead to an out-of-gas error. + dataBlockSize = bound(dataBlockSize, 0, 1 << 12); + + // We also need to bound the data block index because, otherwise, + // calculating the data offset would lead to an arithmetic error. + uint256 maxDataBlockIndex = type(uint256).max / dataBlockSize.max(1) - 1; + dataBlockIndex = bound(dataBlockIndex, 0, maxDataBlockIndex); + + // Finally, we assert that our naive implementation matches the main one + // for every possible combination of inputs. + assertEq( + LibKeccak256.hashBlock(data, dataBlockIndex, dataBlockSize), + LibNaiveKeccak256.hashBlock(data, dataBlockIndex, dataBlockSize) + ); + } + + function testHashPair(bytes32 a, bytes32 b) external pure { + assertEq(LibKeccak256.hashPair(a, b), LibNaiveKeccak256.hashPair(a, b)); + } +} From 26e6e49e703aef63051d9cb87e81f343a627a3be Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 17:28:56 -0300 Subject: [PATCH 35/48] Add `LibBinaryMerkleTree` --- src/library/LibBinaryMerkleTree.sol | 158 +++++++++ test/library/LibBinaryMerkleTree.t.sol | 431 ++++++++++++++++++++++++ test/util/LibBinaryMerkleTreeHelper.sol | 135 ++++++++ 3 files changed, 724 insertions(+) create mode 100644 src/library/LibBinaryMerkleTree.sol create mode 100644 test/library/LibBinaryMerkleTree.t.sol create mode 100644 test/util/LibBinaryMerkleTreeHelper.sol diff --git a/src/library/LibBinaryMerkleTree.sol b/src/library/LibBinaryMerkleTree.sol new file mode 100644 index 00000000..75b3276d --- /dev/null +++ b/src/library/LibBinaryMerkleTree.sol @@ -0,0 +1,158 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.27; + +import {LibMath} from "./LibMath.sol"; + +/// forge-lint: disable-start(incorrect-shift) + +library LibBinaryMerkleTree { + using LibMath for uint256; + + /// @notice Log2 of the maximum drive size. + uint256 constant LOG2_MAX_DRIVE_SIZE = 64; + + /// @notice Log2 of the maximum data block size. + /// @dev The data block must still be smaller than the drive. + uint256 constant LOG2_MAX_DATA_BLOCK_SIZE = 12; + + /// @notice The provided node index is invalid. + /// @dev The index should be less than `2^height`. + error InvalidNodeIndex(); + + /// @notice A drive size smaller than the data block size was provided. + error DriveSmallerThanDataBlock(); + + /// @notice A drive too small to fit the data was provided. + error DriveSmallerThanData(); + + /// @notice A data block size too large was provided. + error DataBlockTooLarge(); + + /// @notice A drive size too large was provided. + error DriveTooLarge(); + + /// @notice An unexpected stack error occurred. + /// @dev Its final depth was not 1. + error UnexpectedStackError(); + + /// @notice Compute the root of a Merkle tree after replacing one of its nodes. + /// @param sibs The siblings of the node in bottom-up order + /// @param nodeIndex The index of the node + /// @param node The new node + /// @param nodeFromChildren The function that computes nodes from their children + /// @return The root hash of the new Merkle tree + /// @dev Level of node is deduced by the length of the siblings array. + /// @dev Raises an `InvalidNodeIndex` error if an invalid node index is provided. + function merkleRootAfterReplacement( + bytes32[] calldata sibs, + uint256 nodeIndex, + bytes32 node, + function(bytes32, bytes32) pure returns (bytes32) nodeFromChildren + ) internal pure returns (bytes32) { + uint256 height = sibs.length; + require((nodeIndex >> height) == 0, InvalidNodeIndex()); + for (uint256 i; i < height; ++i) { + bool isNodeLeftChild = ((nodeIndex >> i) & 1 == 0); + bytes32 nodeSibling = sibs[i]; + node = isNodeLeftChild + ? nodeFromChildren(node, nodeSibling) + : nodeFromChildren(nodeSibling, node); + } + return node; + } + + /// @notice Get the Merkle root of a byte array. + /// @param data The byte array + /// @param log2DriveSize The log2 of the drive size + /// @param log2DataBlockSize The log2 of the data block size + /// @param leafFromDataAt The function that computes leaves from data blocks + /// @param nodeFromChildren The function that computes nodes from their children + /// @dev Data blocks are right-padded with zeros if necessary. + /// @dev leafFromDataAt receives the data, the block index, and the block size + function merkleRoot( + bytes memory data, + uint256 log2DriveSize, + uint256 log2DataBlockSize, + function(bytes memory, uint256, uint256) pure returns (bytes32) leafFromDataAt, + function(bytes32, bytes32) pure returns (bytes32) nodeFromChildren + ) internal pure returns (bytes32) { + require(log2DriveSize <= LOG2_MAX_DRIVE_SIZE, DriveTooLarge()); + require(log2DataBlockSize <= LOG2_MAX_DATA_BLOCK_SIZE, DataBlockTooLarge()); + + uint256 driveSize = 1 << log2DriveSize; + + require(data.length <= driveSize, DriveSmallerThanData()); + require(log2DataBlockSize <= log2DriveSize, DriveSmallerThanDataBlock()); + + uint256 merkleTreeHeight = log2DriveSize - log2DataBlockSize; + uint256 numOfLeaves = 1 << merkleTreeHeight; + uint256 dataBlockSize = 1 << log2DataBlockSize; + + bytes32[] memory pristineNodes = new bytes32[](1 + merkleTreeHeight); + + // compute pristine nodes + { + bytes32 node = leafFromDataAt(new bytes(dataBlockSize), 0, dataBlockSize); + pristineNodes[0] = node; + for (uint256 i = 1; i <= merkleTreeHeight; ++i) { + node = nodeFromChildren(node, node); + pristineNodes[i] = node; + } + } + + // if data is empty, then return pristine Merkle root + if (data.length == 0) { + return pristineNodes[merkleTreeHeight]; + } + + // Note: This is a very generous stack depth. + bytes32[] memory stack = new bytes32[](2 + merkleTreeHeight); + + uint256 numOfHashes; // total number of leaves covered up until now + uint256 stackLength; // total length of stack + uint256 numOfJoins; // number of hashes of the same level on stack + uint256 topStackLevel; // level of hash on top of the stack + + while (numOfHashes < numOfLeaves) { + if ((numOfHashes << log2DataBlockSize) < data.length) { + // we still have data blocks to hash + stack[stackLength] = leafFromDataAt(data, numOfHashes, dataBlockSize); + numOfHashes++; + + numOfJoins = numOfHashes; + } else { + // since padding happens in LibBytes.getBlock, + // we only need to complete the stack with + // pristine Merkle roots + topStackLevel = numOfHashes.ctz(); + + stack[stackLength] = pristineNodes[topStackLevel]; + + //Empty Tree Hash summarizes many hashes + numOfHashes = numOfHashes + (1 << topStackLevel); + numOfJoins = numOfHashes >> topStackLevel; + } + + stackLength++; + + // while there are joins, hash top of stack together + while (numOfJoins & 1 == 0) { + bytes32 h2 = stack[stackLength - 1]; + bytes32 h1 = stack[stackLength - 2]; + + stack[stackLength - 2] = nodeFromChildren(h1, h2); + stackLength = stackLength - 1; // remove hashes from stack + + numOfJoins = numOfJoins >> 1; + } + } + + require(stackLength == 1, UnexpectedStackError()); + + return stack[0]; + } +} + +/// forge-lint: disable-end(incorrect-shift) diff --git a/test/library/LibBinaryMerkleTree.t.sol b/test/library/LibBinaryMerkleTree.t.sol new file mode 100644 index 00000000..7bab425a --- /dev/null +++ b/test/library/LibBinaryMerkleTree.t.sol @@ -0,0 +1,431 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {LibBinaryMerkleTree} from "src/library/LibBinaryMerkleTree.sol"; +import {LibKeccak256} from "src/library/LibKeccak256.sol"; +import {LibMath} from "src/library/LibMath.sol"; + +import {LibBinaryMerkleTreeHelper} from "../util/LibBinaryMerkleTreeHelper.sol"; + +/// forge-lint: disable-start(incorrect-shift) + +library ExternalLibBinaryMerkleTree { + function merkleRootAfterReplacement( + bytes32[] calldata sibs, + uint256 nodeIndex, + bytes32 node + ) external pure returns (bytes32) { + return LibBinaryMerkleTree.merkleRootAfterReplacement( + sibs, nodeIndex, node, nodeFromChildren + ); + } + + function merkleRoot( + bytes memory data, + uint256 log2DriveSize, + uint256 log2DataBlockSize + ) external pure returns (bytes32) { + return LibBinaryMerkleTree.merkleRoot( + data, log2DriveSize, log2DataBlockSize, leafFromDataAt, nodeFromChildren + ); + } + + function merkleRootFromNodes( + bytes32[] memory nodes, + bytes32 defaultNode, + uint256 height + ) external pure returns (bytes32) { + return LibBinaryMerkleTreeHelper.merkleRootFromNodes( + nodes, defaultNode, height, nodeFromChildren + ); + } + + function siblings( + bytes32[] memory nodes, + bytes32 defaultNode, + uint256 nodeIndex, + uint256 height + ) external pure returns (bytes32[] memory) { + return LibBinaryMerkleTreeHelper.siblings( + nodes, defaultNode, nodeIndex, height, nodeFromChildren + ); + } + + function parentLevel(bytes32[] memory nodes, bytes32 defaultNode) + external + pure + returns (bytes32[] memory) + { + return LibBinaryMerkleTreeHelper.parentLevel(nodes, defaultNode, nodeFromChildren); + } + + function toLeaves(bytes[] memory dataBlocks) + external + pure + returns (bytes32[] memory leaves) + { + return LibBinaryMerkleTreeHelper.toLeaves(dataBlocks, leafFromDataBlock); + } + + function splitIntoBlocks(bytes memory data, uint256 dataBlockSize) + external + pure + returns (bytes[] memory dataBlocks) + { + return LibBinaryMerkleTreeHelper.splitIntoBlocks(data, dataBlockSize); + } + + function leafFromDataBlock(bytes memory data) internal pure returns (bytes32 leaf) { + return LibKeccak256.hashBytes(data); + } + + function leafFromDataAt( + bytes memory data, + uint256 dataBlockIndex, + uint256 dataBlockSize + ) internal pure returns (bytes32 leaf) { + return LibKeccak256.hashBlock(data, dataBlockIndex, dataBlockSize); + } + + function nodeFromChildren(bytes32 leaf, bytes32 right) + internal + pure + returns (bytes32 node) + { + return LibKeccak256.hashPair(leaf, right); + } +} + +contract LibBinaryMerkleTreeTest is Test { + using ExternalLibBinaryMerkleTree for bytes32[]; + using ExternalLibBinaryMerkleTree for bytes[]; + using ExternalLibBinaryMerkleTree for bytes; + using LibMath for uint256; + + // -------------- + // test functions + // -------------- + + function testMerkleRootAfterReplacement( + bytes32[] memory nodes, + uint256 height, + uint256 nodeIndex, + bytes32 defaultNode, + bytes32 newNode + ) external pure { + // Bound height and nodeIndex parameters according to the number of nodes + height = _boundHeight(height, nodes.length); + nodeIndex = _boundBits(nodeIndex, height); + + // Get node at given index or use default node if beyond array bounds + bytes32 node = (nodeIndex < nodes.length) ? nodes[nodeIndex] : defaultNode; + + // Compute the Merkle root from the nodes + bytes32 rootFromNodes = nodes.merkleRootFromNodes(defaultNode, height); + + // Compute the siblings of the node + bytes32[] memory siblings = nodes.siblings(defaultNode, nodeIndex, height); + + // Compute the Merkle root from the siblings + bytes32 rootFromSiblings = siblings.merkleRootAfterReplacement(nodeIndex, node); + + // Ensure the two Merkle roots match + assertEq(rootFromNodes, rootFromSiblings); + + if (nodeIndex < nodes.length) { + // If node is within array bounds, replace node + nodes[nodeIndex] = newNode; + + // Compute the new Merkle root from the nodes + rootFromNodes = nodes.merkleRootFromNodes(defaultNode, height); + + // Compute the new Merkle root from the same siblings but new node + rootFromSiblings = siblings.merkleRootAfterReplacement(nodeIndex, newNode); + + // Ensure the two new Merkle roots also match + assertEq(rootFromNodes, rootFromSiblings); + } + } + + function testMerkleRootAfterReplacementRevertsInvalidNodeIndex( + bytes32[] memory siblings, + uint256 nodeIndex, + bytes32 node + ) external { + // First, make sure the tree has less than 2^256 leaves, + // otherwise every unsigned 256-bit node index would be valid. + uint256 height = siblings.length; + vm.assume(height < 256); + + // Second, bound the node index to beyond the number + // of leaves in the tree, based on the tree height. + nodeIndex = bound(nodeIndex, 1 << height, type(uint256).max); + + // Finally, provide the invalid node index to the + // merkleRootAfterReplacement function and expect an error. + vm.expectRevert(LibBinaryMerkleTree.InvalidNodeIndex.selector); + siblings.merkleRootAfterReplacement(nodeIndex, node); + } + + function testMerkleRoot( + bytes memory data, + uint256 log2DriveSize, + uint256 log2DataBlockSize, + uint256 log2LeavesToReplace, + bytes32 replacementDataSeed, + uint256 replacementNodeIndex + ) external pure { + // First, compute the smallest log2 drive size that would fit the data. + uint256 minLog2DriveSize = data.length.ceilLog2(); + + // Second, we bound the log2 drive size to between the minimum amount + // calculated in the previous step and the maximum allowed. + uint256 maxLog2DriveSize = LibBinaryMerkleTree.LOG2_MAX_DRIVE_SIZE; + log2DriveSize = bound(log2DriveSize, minLog2DriveSize, maxLog2DriveSize); + + // Third, we bound the log2 data block size between 0 and + // the minimum between log2 drive size and the maximum allowed. + uint256 maxLog2DataBlockSize = LibBinaryMerkleTree.LOG2_MAX_DATA_BLOCK_SIZE; + maxLog2DataBlockSize = maxLog2DataBlockSize.min(log2DriveSize); + log2DataBlockSize = bound(log2DataBlockSize, 0, maxLog2DataBlockSize); + + // Finally, we compute the root of the Merkle tree from the data + bytes32 rootFromData = data.merkleRoot(log2DriveSize, log2DataBlockSize); + + // Now, we take an alternative approach. + // First, we slice the data into blocks with the same size we used before. + uint256 dataBlockSize = 1 << log2DataBlockSize; + bytes[] memory dataBlocks = data.splitIntoBlocks(dataBlockSize); + + // Then, we compute the leaves from these data blocks + bytes32[] memory leaves = dataBlocks.toLeaves(); + + // Then, we compute the Merkle root from the leaves + uint256 height = log2DriveSize - log2DataBlockSize; + bytes memory pristineDataBlock = new bytes(dataBlockSize); + bytes32 pristineLeaf = pristineDataBlock.leafFromDataBlock(); + bytes32 rootFromLeaves = leaves.merkleRootFromNodes(pristineLeaf, height); + + // Ensure that Merkle roots match + assertEq(rootFromData, rootFromLeaves); + + // Now, we want to replace a part of the data and be able to reconstruct + // the new Merkle root in two ways: from the updated data buffer and + // by constructing the Merkle root bottom-up through a replacement proof. + + // First, we need to make sure that the data spans at least one full block. + // Otherwise, we won't be able to replace a full block from it. + uint256 numOfFullDataBlocks = data.length >> log2DataBlockSize; + vm.assume(numOfFullDataBlocks > 0); + + // Then we compute the floor log2 number of full data blocks. + // This will help us bound the log2 number of leaves and log2 replacement size. + uint256 maxLog2LeavesToReplace = numOfFullDataBlocks.floorLog2(); + log2LeavesToReplace = bound(log2LeavesToReplace, 0, maxLog2LeavesToReplace); + uint256 log2ReplacementSize = log2LeavesToReplace + log2DataBlockSize; + + // Calculate replacement size and allocate buffer for replacement data + uint256 replacementSize = 1 << log2ReplacementSize; + bytes memory replacementData = new bytes(replacementSize); + + // Fill the replacement data buffer with random bytes derived from the seed + for (uint256 i; i < replacementData.length; ++i) { + replacementData[i] = bytes1(keccak256(abi.encode(replacementDataSeed, i))); + } + + // Compute the replacement Merkle root from the replacement data + bytes32 replacementRootFromData = + replacementData.merkleRoot(log2ReplacementSize, log2DataBlockSize); + + // Bound the replacement node index by the number of replaceable nodes + uint256 numOfReplaceableNodes = data.length >> log2ReplacementSize; + assertGe(numOfReplaceableNodes, 1, "expected at least one replaceable node"); + replacementNodeIndex = bound(replacementNodeIndex, 0, numOfReplaceableNodes - 1); + + // Make sure replacement is completely within the boundaries of the data + assertLe(((replacementNodeIndex + 1) << log2ReplacementSize), data.length); + + // Compute the siblings of the to-be-replaced node by computing the + // siblings of the first leaf of the to-be-replaced Merkle tree. + uint256 firstReplacementLeafIndex = replacementNodeIndex << log2LeavesToReplace; + bytes32[] memory replacedLeafSiblings = + leaves.siblings(pristineLeaf, firstReplacementLeafIndex, height); + + // Allocate a siblings array for the replacement node + bytes32[] memory replacementNodeSiblings = + new bytes32[](log2DriveSize - log2ReplacementSize); + + // Copy the siblings from the replacement leaf after a given height + assertEq( + replacementNodeSiblings.length + log2LeavesToReplace, + replacedLeafSiblings.length, + "siblings array difference doesn't match expected difference" + ); + for (uint256 i; i < replacementNodeSiblings.length; ++i) { + assertLt( + i + log2LeavesToReplace, + replacedLeafSiblings.length, + "buffer overrun while copying siblings" + ); + replacementNodeSiblings[i] = replacedLeafSiblings[i + log2LeavesToReplace]; + } + + // From the siblings array, node index, and replacement root, + // we can compute the updated Merkle root + bytes32 updatedRootFromSiblings = + replacementNodeSiblings.merkleRootAfterReplacement( + replacementNodeIndex, replacementRootFromData + ); + + // Now we write the replacement onto the data buffer + for (uint256 i; i < replacementSize; ++i) { + uint256 offset = replacementNodeIndex << log2ReplacementSize; + assertLt( + offset + i, + data.length, + "buffer overrun while writing replacement over data buffer" + ); + data[offset + i] = replacementData[i]; + } + + // Compute new Merkle root from the updated data + bytes32 updatedRootFromData = data.merkleRoot(log2DriveSize, log2DataBlockSize); + + // Ensure Merkle roots match (from data and from siblings) + assertEq(updatedRootFromData, updatedRootFromSiblings); + } + + function testMerkleRootRevertsDriveTooLarge( + bytes memory data, + uint256 log2DriveSize, + uint256 log2DataBlockSize + ) external { + // First, we bound the log2 drive size to beyond the maximum. + uint256 maxLog2DriveSize = LibBinaryMerkleTree.LOG2_MAX_DRIVE_SIZE; + log2DriveSize = bound(log2DriveSize, maxLog2DriveSize + 1, type(uint256).max); + + // Then, we call the merkleRoot function and expect an error. + vm.expectRevert(LibBinaryMerkleTree.DriveTooLarge.selector); + data.merkleRoot(log2DriveSize, log2DataBlockSize); + } + + function testMerkleRootRevertsDataBlockTooLarge( + bytes memory data, + uint256 log2DriveSize, + uint256 log2DataBlockSize + ) external { + // First, compute the smallest log2 drive size that would fit the data. + uint256 minLog2DriveSize = data.length.ceilLog2(); + + // Second, we bound the log2 drive size to between the minimum amount + // calculated in the previous step and the maximum allowed. + uint256 maxLog2DriveSize = LibBinaryMerkleTree.LOG2_MAX_DRIVE_SIZE; + log2DriveSize = bound(log2DriveSize, minLog2DriveSize, maxLog2DriveSize); + + // Third, we bound the log2 data block size beyond the maximum allowed. + uint256 maxLog2DataBlockSize = LibBinaryMerkleTree.LOG2_MAX_DATA_BLOCK_SIZE; + log2DataBlockSize = + bound(log2DataBlockSize, maxLog2DataBlockSize + 1, type(uint256).max); + + // Then, we call the merkleRoot function and expect an error. + vm.expectRevert(LibBinaryMerkleTree.DataBlockTooLarge.selector); + data.merkleRoot(log2DriveSize, log2DataBlockSize); + } + + function testMerkleRootRevertsDriveSmallerThanData( + bytes memory data, + uint256 log2DriveSize, + uint256 log2DataBlockSize + ) external { + // First, we assume that the data is not empty or + // has a single byte, otherwise every drive (having + // its size described in log2) would fit it. + vm.assume(data.length > 1); + + // Second, compute the smallest log2 drive size that would + // fit the data. This will important to force the + // DriveSmallerThanData error. + uint256 minLog2DriveSize = data.length.ceilLog2(); + + // Ensure the smallest log2 drive size is greater + // than zero so that we can subtract 1 from it. + assertGt(minLog2DriveSize, 0); + + // Third, bound the log2 drive size between zero + // and the maximum invalid value. + log2DriveSize = bound(log2DriveSize, 0, minLog2DriveSize - 1); + + // Fourth, we bound the log2 data block size between 0 and + // the minimum between log2 drive size and the maximum allowed. + uint256 maxLog2DataBlockSize = LibBinaryMerkleTree.LOG2_MAX_DATA_BLOCK_SIZE; + maxLog2DataBlockSize = maxLog2DataBlockSize.min(log2DriveSize); + log2DataBlockSize = bound(log2DataBlockSize, 0, maxLog2DataBlockSize); + + // Then, we call the merkleRoot function and expect an error. + vm.expectRevert(LibBinaryMerkleTree.DriveSmallerThanData.selector); + data.merkleRoot(log2DriveSize, log2DataBlockSize); + } + + // ------------------ + // internal functions + // ------------------ + + /// @notice Compute a Merkle tree leaf from a data block. + /// @param data The data + /// @param dataBlockIndex The data block index + /// @param dataBlockSize The data block size + function _leafFromDataBlock( + bytes memory data, + uint256 dataBlockIndex, + uint256 dataBlockSize + ) internal pure returns (bytes32 leaf) { + return data.leafFromDataAt(dataBlockIndex, dataBlockSize); + } + + /// @notice Compute a Merkle tree node from its children. + /// @param left The left child + /// @param right The right child + /// @return node The node with the provided left and right children + function _nodeFromChildren(bytes32 left, bytes32 right) + internal + pure + returns (bytes32 node) + { + return ExternalLibBinaryMerkleTree.nodeFromChildren(left, right); + } + + /// @notice Bounds a value between `y` (inclusive) and 256 (inclusive), + /// where `y` is the smallest unsigned integer such that `n <= 2^y`. + /// @param height The random seed + /// @param n The value `n` in the expression + /// @return newHeight A value `y` such that `n <= 2^y` + function _boundHeight(uint256 height, uint256 n) + internal + pure + returns (uint256 newHeight) + { + return bound(height, LibMath.ceilLog2(n), 256); + } + + /// @notice Bounds a value between `0` (inclusive) and `2^{nbits}` (exclusive). + /// @param x The random seed + /// @param nbits The number of non-zero least-significant bits + /// @return newValue A value between 0 and `2^{nbits}` + function _boundBits(uint256 x, uint256 nbits) + internal + pure + returns (uint256 newValue) + { + if (nbits < 256) { + return x >> (256 - nbits); + } else { + return x; + } + } +} + +/// forge-lint: disable-end(incorrect-shift) diff --git a/test/util/LibBinaryMerkleTreeHelper.sol b/test/util/LibBinaryMerkleTreeHelper.sol new file mode 100644 index 00000000..c39cd26d --- /dev/null +++ b/test/util/LibBinaryMerkleTreeHelper.sol @@ -0,0 +1,135 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.27; + +library LibBinaryMerkleTreeHelper { + using LibBinaryMerkleTreeHelper for bytes32[]; + + /// @notice The provided node index is invalid. + error InvalidNodeIndex(); + + /// @notice The provided height is invalid. + error InvalidHeight(); + + /// @notice Compute the root of a Merkle tree from an array of nodes. + /// @param nodes The nodes of Merkle tree + /// @param defaultNode The node used to right-pad the bottom level + /// @param height The height of the Merkle tree + /// @param nodeFromChildren The function that computes nodes from their children + /// @return The root of the Merkle tree + /// @dev Raises an `InvalidHeight` error if more than `2^height` nodes are provided. + function merkleRootFromNodes( + bytes32[] memory nodes, + bytes32 defaultNode, + uint256 height, + function(bytes32, bytes32) pure returns (bytes32) nodeFromChildren + ) internal pure returns (bytes32) { + for (uint256 i; i < height; ++i) { + nodes = nodes.parentLevel(defaultNode, nodeFromChildren); + defaultNode = nodeFromChildren(defaultNode, defaultNode); + } + require(nodes.length <= 1, InvalidHeight()); + return nodes.at(0, defaultNode); + } + + /// @notice Compute the siblings of a node in a Merkle tree. + /// @param nodes The nodes of Merkle tree + /// @param defaultNode The node used to right-pad the bottom level + /// @param nodeIndex The index of the node + /// @param height The height of the Merkle tree + /// @param nodeFromChildren The function that computes nodes from their children + /// @return The siblings of the node in bottom-up order + /// @dev Raises an `InvalidNodeIndex` error if the provided index is out of bounds. + /// @dev Raises an `InvalidHeight` error if more than `2^height` nodes are provided. + function siblings( + bytes32[] memory nodes, + bytes32 defaultNode, + uint256 nodeIndex, + uint256 height, + function(bytes32, bytes32) pure returns (bytes32) nodeFromChildren + ) internal pure returns (bytes32[] memory) { + bytes32[] memory sibs = new bytes32[](height); + for (uint256 i; i < height; ++i) { + sibs[i] = nodes.at(nodeIndex ^ 1, defaultNode); + nodes = nodes.parentLevel(defaultNode, nodeFromChildren); + defaultNode = nodeFromChildren(defaultNode, defaultNode); + nodeIndex >>= 1; + } + require(nodeIndex == 0, InvalidNodeIndex()); + require(nodes.length <= 1, InvalidHeight()); + return sibs; + } + + /// @notice Compute the parent level of an array of nodes. + /// @param nodes The array of left-most nodes + /// @param defaultNode The default node after the array + /// @param nodeFromChildren The function that computes nodes from their children + /// @return The left-most nodes of the parent level + /// @dev The default node of a parent level is + /// the parent node of two default nodes. + function parentLevel( + bytes32[] memory nodes, + bytes32 defaultNode, + function(bytes32, bytes32) pure returns (bytes32) nodeFromChildren + ) internal pure returns (bytes32[] memory) { + uint256 n = (nodes.length + 1) / 2; // ceil(#nodes / 2) + bytes32[] memory level = new bytes32[](n); + for (uint256 i; i < n; ++i) { + bytes32 leftChild = nodes[2 * i]; + bytes32 rightChild = nodes.at(2 * i + 1, defaultNode); + level[i] = nodeFromChildren(leftChild, rightChild); + } + return level; + } + + /// @notice Get the node at some index + /// @param nodes The array of left-most nodes + /// @param index The index of the node + /// @param defaultNode The default node after the array + function at(bytes32[] memory nodes, uint256 index, bytes32 defaultNode) + internal + pure + returns (bytes32) + { + if (index < nodes.length) { + return nodes[index]; + } else { + return defaultNode; + } + } + + /// @notice Compute leaves from data blocks. + /// @param dataBlocks The array of data blocks + /// @param leafFromDataBlock The function that computes leaves from data blocks + function toLeaves( + bytes[] memory dataBlocks, + function(bytes memory) pure returns (bytes32) leafFromDataBlock + ) internal pure returns (bytes32[] memory leaves) { + leaves = new bytes32[](dataBlocks.length); + for (uint256 i; i < dataBlocks.length; ++i) { + leaves[i] = leafFromDataBlock(dataBlocks[i]); + } + } + + /// @notice Splits a data buffer into equally-sized blocks. + /// @param data The byte array + /// @param dataBlockSize The data block size + /// @return dataBlocks An array of data blocks. + function splitIntoBlocks(bytes memory data, uint256 dataBlockSize) + internal + pure + returns (bytes[] memory dataBlocks) + { + dataBlocks = new bytes[]((data.length + dataBlockSize - 1) / dataBlockSize); + for (uint256 i; i < dataBlocks.length; ++i) { + dataBlocks[i] = new bytes(dataBlockSize); + uint256 offset = i * dataBlockSize; + for (uint256 j; j < dataBlockSize; ++j) { + if (offset + j < data.length) { + dataBlocks[i][j] = data[offset + j]; + } + } + } + } +} From 90fef401100c8ebbe3c46fb92d8483e64afd6f8b Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 18:49:42 -0300 Subject: [PATCH 36/48] Remove `LibMerkle32` and `LibMath` (from tests) --- src/consensus/AbstractConsensus.sol | 8 +- src/library/LibMerkle32.sol | 137 ------ src/library/LibOutputValidityProof.sol | 9 +- test/consensus/quorum/QuorumFactory.t.sol | 2 - test/dapp/Application.t.sol | 4 +- test/library/LibMerkle32.t.sol | 556 ---------------------- test/util/LibClaim.sol | 8 +- test/util/LibEmulator.sol | 21 +- test/util/LibMath.sol | 14 - test/util/LibMath.t.sol | 17 - test/util/LibUint256Array.t.sol | 3 +- 11 files changed, 36 insertions(+), 743 deletions(-) delete mode 100644 src/library/LibMerkle32.sol delete mode 100644 test/library/LibMerkle32.t.sol delete mode 100644 test/util/LibMath.sol delete mode 100644 test/util/LibMath.t.sol diff --git a/src/consensus/AbstractConsensus.sol b/src/consensus/AbstractConsensus.sol index 3d3f17a7..7f55171c 100644 --- a/src/consensus/AbstractConsensus.sol +++ b/src/consensus/AbstractConsensus.sol @@ -12,13 +12,14 @@ import { import {Memory} from "cartesi-machine-solidity-step-0.13.0/src/Memory.sol"; import {ApplicationChecker} from "../dapp/ApplicationChecker.sol"; -import {LibMerkle32} from "../library/LibMerkle32.sol"; +import {LibBinaryMerkleTree} from "../library/LibBinaryMerkleTree.sol"; +import {LibKeccak256} from "../library/LibKeccak256.sol"; import {IConsensus} from "./IConsensus.sol"; import {IOutputsMerkleRootValidator} from "./IOutputsMerkleRootValidator.sol"; /// @notice Abstract implementation of IConsensus abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { - using LibMerkle32 for bytes32[]; + using LibBinaryMerkleTree for bytes32[]; /// @notice The epoch length uint256 immutable EPOCH_LENGTH; @@ -178,7 +179,8 @@ abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { machineMerkleRoot = proof.merkleRootAfterReplacement( EmulatorConstants.PMA_CMIO_TX_BUFFER_START >> EmulatorConstants.TREE_LOG2_WORD_SIZE, - keccak256(abi.encode(outputsMerkleRoot)) + keccak256(abi.encode(outputsMerkleRoot)), + LibKeccak256.hashPair ); } diff --git a/src/library/LibMerkle32.sol b/src/library/LibMerkle32.sol deleted file mode 100644 index 71f944b3..00000000 --- a/src/library/LibMerkle32.sol +++ /dev/null @@ -1,137 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pragma solidity ^0.8.22; - -/// @title Merkle library for trees of 32-byte leaves -/// @notice This library is meant for creating and verifying Merkle proofs. -/// @notice Each Merkle tree is assumed to have `2^height` leaves. -/// @notice Nodes are concatenated pairwise and hashed with `keccak256`. -/// @notice Siblings are in bottom-up order, from leaf to root. -library LibMerkle32 { - using LibMerkle32 for bytes32[]; - - /// @notice Compute the root of a Merkle tree from its leaves. - /// @param leaves The left-most leaves of the Merkle tree - /// @param height The height of the Merkle tree - /// @return The root hash of the Merkle tree - /// @dev Raises an error if more than `2^height` leaves are provided. - function merkleRoot(bytes32[] memory leaves, uint256 height) - internal - pure - returns (bytes32) - { - bytes32 defaultNode; - for (uint256 i; i < height; ++i) { - leaves = leaves.parentLevel(defaultNode); - defaultNode = parent(defaultNode, defaultNode); - } - require(leaves.length <= 1, "LibMerkle32: too many leaves"); - return leaves.at(0, defaultNode); - } - - /// @notice Compute the siblings of the ancestors of a leaf in a Merkle tree. - /// @param leaves The left-most leaves of the Merkle tree - /// @param index The index of the leaf - /// @param height The height of the Merkle tree - /// @return The siblings of the ancestors of the leaf in bottom-up order - /// @dev Raises an error if the provided index is out of bounds. - /// @dev Raises an error if more than `2^height` leaves are provided. - function siblings(bytes32[] memory leaves, uint256 index, uint256 height) - internal - pure - returns (bytes32[] memory) - { - bytes32[] memory sibs = new bytes32[](height); - bytes32 defaultNode; - for (uint256 i; i < height; ++i) { - sibs[i] = leaves.at(index ^ 1, defaultNode); - leaves = leaves.parentLevel(defaultNode); - defaultNode = parent(defaultNode, defaultNode); - index >>= 1; - } - require(index == 0, "LibMerkle32: index out of bounds"); - require(leaves.length <= 1, "LibMerkle32: too many leaves"); - return sibs; - } - - /// @notice Compute the root of a Merkle tree after replacing one of its leaves. - /// @param sibs The siblings of the ancestors of the leaf in bottom-up order - /// @param index The index of the leaf - /// @param leaf The new leaf - /// @return The root hash of the new Merkle tree - /// @dev Raises an error if the provided index is out of bounds. - function merkleRootAfterReplacement( - bytes32[] calldata sibs, - uint256 index, - bytes32 leaf - ) internal pure returns (bytes32) { - uint256 height = sibs.length; - for (uint256 i; i < height; ++i) { - bytes32 sibling = sibs[i]; - if (index & 1 == 0) { - leaf = parent(leaf, sibling); - } else { - leaf = parent(sibling, leaf); - } - index >>= 1; - } - require(index == 0, "LibMerkle32: index out of bounds"); - return leaf; - } - - /// @notice Compute the parent of two nodes. - /// @param leftNode The left node - /// @param rightNode The right node - /// @return parentNode The parent node - /// @dev Uses assembly for extra performance - function parent(bytes32 leftNode, bytes32 rightNode) - internal - pure - returns (bytes32 parentNode) - { - /// @solidity memory-safe-assembly - assembly { - mstore(0x00, leftNode) - mstore(0x20, rightNode) - parentNode := keccak256(0x00, 0x40) - } - } - - /// @notice Compute the parent level of an array of nodes. - /// @param nodes The array of left-most nodes - /// @param defaultNode The default node after the array - /// @return The left-most nodes of the parent level - /// @dev The default node of a parent level is - /// the parent node of two default nodes. - function parentLevel(bytes32[] memory nodes, bytes32 defaultNode) - internal - pure - returns (bytes32[] memory) - { - uint256 n = (nodes.length + 1) / 2; // ceil(#nodes / 2) - bytes32[] memory level = new bytes32[](n); - for (uint256 i; i < n; ++i) { - bytes32 leftLeaf = nodes[2 * i]; - bytes32 rightLeaf = nodes.at(2 * i + 1, defaultNode); - level[i] = parent(leftLeaf, rightLeaf); - } - return level; - } - - /// @notice Get the node at some index - /// @param nodes The array of left-most nodes - /// @param index The index of the node - /// @param defaultNode The default node after the array - function at(bytes32[] memory nodes, uint256 index, bytes32 defaultNode) - internal - pure - returns (bytes32) - { - if (index < nodes.length) { - return nodes[index]; - } else { - return defaultNode; - } - } -} diff --git a/src/library/LibOutputValidityProof.sol b/src/library/LibOutputValidityProof.sol index d8753fa5..c77ec190 100644 --- a/src/library/LibOutputValidityProof.sol +++ b/src/library/LibOutputValidityProof.sol @@ -6,10 +6,11 @@ pragma solidity ^0.8.8; import {CanonicalMachine} from "../common/CanonicalMachine.sol"; import {OutputValidityProof} from "../common/OutputValidityProof.sol"; -import {LibMerkle32} from "./LibMerkle32.sol"; +import {LibBinaryMerkleTree} from "./LibBinaryMerkleTree.sol"; +import {LibKeccak256} from "./LibKeccak256.sol"; library LibOutputValidityProof { - using LibMerkle32 for bytes32[]; + using LibBinaryMerkleTree for bytes32[]; function isSiblingsArrayLengthValid(OutputValidityProof calldata v) internal @@ -24,7 +25,7 @@ library LibOutputValidityProof { pure returns (bytes32) { - return - v.outputHashesSiblings.merkleRootAfterReplacement(v.outputIndex, outputHash); + return v.outputHashesSiblings + .merkleRootAfterReplacement(v.outputIndex, outputHash, LibKeccak256.hashPair); } } diff --git a/test/consensus/quorum/QuorumFactory.t.sol b/test/consensus/quorum/QuorumFactory.t.sol index 4cf1d0f9..6ce85821 100644 --- a/test/consensus/quorum/QuorumFactory.t.sol +++ b/test/consensus/quorum/QuorumFactory.t.sol @@ -15,7 +15,6 @@ import {LibAddressArray} from "../../util/LibAddressArray.sol"; import {LibBytes} from "../../util/LibBytes.sol"; import {LibClaim} from "../../util/LibClaim.sol"; import {LibConsensus} from "../../util/LibConsensus.sol"; -import {LibMath} from "../../util/LibMath.sol"; import {LibTopic} from "../../util/LibTopic.sol"; import {LibUint256Array} from "../../util/LibUint256Array.sol"; @@ -29,7 +28,6 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { using LibUint256Array for Vm; using LibConsensus for IQuorum; using LibTopic for address; - using LibMath for uint256; using LibBytes for bytes; using LibClaim for Claim; diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index 529c924c..ac46c129 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -28,7 +28,7 @@ import {IERC721} from "@openzeppelin-contracts-5.2.0/token/ERC721/IERC721.sol"; import {Test} from "forge-std-1.9.6/src/Test.sol"; -import {ExternalLibMerkle32} from "../library/LibMerkle32.t.sol"; +import {ExternalLibBinaryMerkleTree} from "../library/LibBinaryMerkleTree.t.sol"; import {AddressGenerator} from "../util/AddressGenerator.sol"; import {ConsensusTestUtils} from "../util/ConsensusTestUtils.sol"; import {EtherReceiver} from "../util/EtherReceiver.sol"; @@ -40,7 +40,7 @@ import {SimpleERC721} from "../util/SimpleERC721.sol"; contract ApplicationTest is Test, OwnableTest, AddressGenerator, ConsensusTestUtils { using LibEmulator for LibEmulator.State; - using ExternalLibMerkle32 for bytes32[]; + using ExternalLibBinaryMerkleTree for bytes32[]; IApplication _appContract; EtherReceiver _etherReceiver; diff --git a/test/library/LibMerkle32.t.sol b/test/library/LibMerkle32.t.sol deleted file mode 100644 index d21f7627..00000000 --- a/test/library/LibMerkle32.t.sol +++ /dev/null @@ -1,556 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pragma solidity ^0.8.22; - -import {Test} from "forge-std-1.9.6/src/Test.sol"; - -import {LibMerkle32} from "src/library/LibMerkle32.sol"; - -library ExternalLibMerkle32 { - using LibMerkle32 for bytes32[]; - - function merkleRoot(bytes32[] calldata leaves, uint256 height) - external - pure - returns (bytes32) - { - return leaves.merkleRoot(height); - } - - function siblings(bytes32[] calldata leaves, uint256 index, uint256 height) - external - pure - returns (bytes32[] memory) - { - return leaves.siblings(index, height); - } - - function merkleRootAfterReplacement( - bytes32[] calldata sibs, - uint256 index, - bytes32 leaf - ) external pure returns (bytes32) { - return sibs.merkleRootAfterReplacement(index, leaf); - } - - function at(bytes32[] memory nodes, uint256 index, bytes32 defaultNode) - internal - pure - returns (bytes32) - { - return nodes.at(index, defaultNode); - } -} - -contract LibMerkle32Test is Test { - using ExternalLibMerkle32 for bytes32[]; - - function testMinHeight() external pure { - assertEq(_minHeight(0), 0); - assertEq(_minHeight(1), 0); - assertEq(_minHeight(2), 1); - assertEq(_minHeight(3), 2); - assertEq(_minHeight(4), 2); - assertEq(_minHeight(5), 3); - // skip... - for (uint256 i = 3; i < 256; ++i) { - /// forge-lint: disable-start(incorrect-shift) - assertEq(_minHeight(1 << i), i); - assertEq(_minHeight((1 << i) + 1), i + 1); - /// forge-lint: disable-end(incorrect-shift) - } - // skip... - assertEq(_minHeight(type(uint256).max), 256); - } - - function testParent() external pure { - assertEq( - _parent(bytes32(0), bytes32(0)), - 0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5 - ); - assertEq( - _parent(bytes32(uint256(0xdeadbeef)), bytes32(uint256(0xfafafafa))), - 0xda8d1c323302c4549981015475e50eefb2e7df73b8ecf1bde639cee25c8ad669 - ); - } - - function testParentFuzzy(bytes32 a, bytes32 b) external pure { - assertEq(_parent(a, b), LibMerkle32.parent(a, b)); - } - - function testAt(bytes32 firstNode, bytes32 secondNode, bytes32 defaultNode) - external - pure - { - bytes32[] memory leaves = new bytes32[](0); - - assertEq(leaves.at(0, defaultNode), defaultNode); - - leaves = new bytes32[](1); - leaves[0] = firstNode; - - assertEq(leaves.at(0, defaultNode), firstNode); - assertEq(leaves.at(1, defaultNode), defaultNode); - - leaves = new bytes32[](2); - leaves[0] = firstNode; - leaves[1] = secondNode; - - assertEq(leaves.at(0, defaultNode), firstNode); - assertEq(leaves.at(1, defaultNode), secondNode); - assertEq(leaves.at(2, defaultNode), defaultNode); - } - - function testMerkleRootZeroLeavesZeroHeight(bytes32 leftLeaf, bytes32 rightLeaf) - external - { - bytes32[] memory leaves; - - leaves = new bytes32[](0); - - assertEq(leaves.merkleRoot(0), bytes32(0)); - - leaves = new bytes32[](1); - leaves[0] = leftLeaf; - - // The merkle root is the leaf itself - assertEq(leaves.merkleRoot(0), leftLeaf); - - leaves = new bytes32[](2); - leaves[0] = leftLeaf; - leaves[1] = rightLeaf; - - vm.expectRevert("LibMerkle32: too many leaves"); - leaves.merkleRoot(0); - } - - function testMerkleRootHeightOne( - bytes32 leftLeaf, - bytes32 rightLeaf, - bytes32 extraLeaf - ) external { - bytes32[] memory leaves; - - leaves = new bytes32[](0); - - assertEq(leaves.merkleRoot(1), _parent(bytes32(0), bytes32(0))); - - leaves = new bytes32[](1); - leaves[0] = leftLeaf; - - // Leaves are filled from left to right - assertEq(leaves.merkleRoot(1), _parent(leftLeaf, bytes32(0))); - - leaves = new bytes32[](2); - leaves[0] = leftLeaf; - leaves[1] = rightLeaf; - - assertEq(leaves.merkleRoot(1), _parent(leftLeaf, rightLeaf)); - - leaves = new bytes32[](3); - leaves[0] = leftLeaf; - leaves[1] = rightLeaf; - leaves[2] = extraLeaf; - - vm.expectRevert("LibMerkle32: too many leaves"); - leaves.merkleRoot(1); - } - - function testMerkleRootHeightTwo( - bytes32 firstLeaf, - bytes32 secondLeaf, - bytes32 thirdLeaf, - bytes32 fourthLeaf, - bytes32 extraLeaf - ) external { - bytes32[] memory leaves; - - leaves = new bytes32[](0); - - assertEq( - leaves.merkleRoot(2), - _parent(_parent(bytes32(0), bytes32(0)), _parent(bytes32(0), bytes32(0))) - ); - - leaves = new bytes32[](1); - leaves[0] = firstLeaf; - - assertEq( - leaves.merkleRoot(2), - _parent(_parent(firstLeaf, bytes32(0)), _parent(bytes32(0), bytes32(0))) - ); - - leaves = new bytes32[](2); - leaves[0] = firstLeaf; - leaves[1] = secondLeaf; - - assertEq( - leaves.merkleRoot(2), - _parent(_parent(firstLeaf, secondLeaf), _parent(bytes32(0), bytes32(0))) - ); - - leaves = new bytes32[](3); - leaves[0] = firstLeaf; - leaves[1] = secondLeaf; - leaves[2] = thirdLeaf; - - assertEq( - leaves.merkleRoot(2), - _parent(_parent(firstLeaf, secondLeaf), _parent(thirdLeaf, bytes32(0))) - ); - - leaves = new bytes32[](4); - leaves[0] = firstLeaf; - leaves[1] = secondLeaf; - leaves[2] = thirdLeaf; - leaves[3] = fourthLeaf; - - assertEq( - leaves.merkleRoot(2), - _parent(_parent(firstLeaf, secondLeaf), _parent(thirdLeaf, fourthLeaf)) - ); - - leaves = new bytes32[](5); - leaves[0] = firstLeaf; - leaves[1] = secondLeaf; - leaves[2] = thirdLeaf; - leaves[3] = fourthLeaf; - leaves[4] = extraLeaf; - - vm.expectRevert("LibMerkle32: too many leaves"); - leaves.merkleRoot(2); - } - - function testSiblingsHeightZero(bytes32 leftLeaf, bytes32 rightLeaf) external { - bytes32[] memory siblings; - bytes32[] memory leaves; - - leaves = new bytes32[](0); - - // Zero height yields zero siblings - siblings = leaves.siblings(0, 0); - assertEq(siblings.length, 0); - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(1, 0); - - leaves = new bytes32[](1); - leaves[0] = leftLeaf; - - siblings = leaves.siblings(0, 0); - assertEq(siblings.length, 0); - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(1, 0); - - leaves = new bytes32[](2); - leaves[0] = leftLeaf; - leaves[1] = rightLeaf; - - vm.expectRevert("LibMerkle32: too many leaves"); - leaves.siblings(0, 0); - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(1, 0); - } - - function testSiblingsHeightOne( - bytes32 leftLeaf, - bytes32 rightLeaf, - bytes32 extraLeaf - ) external { - bytes32[] memory siblings; - bytes32[] memory leaves; - - leaves = new bytes32[](0); - - siblings = leaves.siblings(0, 1); - assertEq(siblings.length, 1); - assertEq(siblings[0], bytes32(0)); - - siblings = leaves.siblings(1, 1); - assertEq(siblings.length, 1); - assertEq(siblings[0], bytes32(0)); - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(2, 1); - - leaves = new bytes32[](1); - leaves[0] = leftLeaf; - - siblings = leaves.siblings(0, 1); - assertEq(siblings.length, 1); - assertEq(siblings[0], bytes32(0)); - - siblings = leaves.siblings(1, 1); - assertEq(siblings.length, 1); - assertEq(siblings[0], leftLeaf); - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(2, 1); - - leaves = new bytes32[](2); - leaves[0] = leftLeaf; - leaves[1] = rightLeaf; - - siblings = leaves.siblings(0, 1); - assertEq(siblings.length, 1); - assertEq(siblings[0], rightLeaf); - - siblings = leaves.siblings(1, 1); - assertEq(siblings.length, 1); - assertEq(siblings[0], leftLeaf); - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(2, 1); - - leaves = new bytes32[](3); - leaves[0] = leftLeaf; - leaves[1] = rightLeaf; - leaves[2] = extraLeaf; - - vm.expectRevert("LibMerkle32: too many leaves"); - leaves.siblings(0, 1); - - vm.expectRevert("LibMerkle32: too many leaves"); - leaves.siblings(1, 1); - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(2, 1); - } - - function testSiblingsHeightTwo( - bytes32 firstLeaf, - bytes32 secondLeaf, - bytes32 thirdLeaf, - bytes32 fourthLeaf - ) external { - bytes32[] memory siblings; - bytes32[] memory leaves; - - leaves = new bytes32[](0); - - for (uint256 i; i < 4; ++i) { - siblings = leaves.siblings(i, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], bytes32(0)); - assertEq(siblings[1], _parent(bytes32(0), bytes32(0))); - } - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(4, 2); - - leaves = new bytes32[](1); - leaves[0] = firstLeaf; - - siblings = leaves.siblings(0, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], bytes32(0)); - assertEq(siblings[1], _parent(bytes32(0), bytes32(0))); - - siblings = leaves.siblings(1, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], firstLeaf); - assertEq(siblings[1], _parent(bytes32(0), bytes32(0))); - - for (uint256 i = 2; i < 4; ++i) { - siblings = leaves.siblings(i, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], bytes32(0)); - assertEq(siblings[1], _parent(firstLeaf, bytes32(0))); - } - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(4, 2); - - leaves = new bytes32[](2); - leaves[0] = firstLeaf; - leaves[1] = secondLeaf; - - siblings = leaves.siblings(0, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], secondLeaf); - assertEq(siblings[1], _parent(bytes32(0), bytes32(0))); - - siblings = leaves.siblings(1, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], firstLeaf); - assertEq(siblings[1], _parent(bytes32(0), bytes32(0))); - - for (uint256 i = 2; i < 4; ++i) { - siblings = leaves.siblings(i, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], bytes32(0)); - assertEq(siblings[1], _parent(firstLeaf, secondLeaf)); - } - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(4, 2); - - leaves = new bytes32[](3); - leaves[0] = firstLeaf; - leaves[1] = secondLeaf; - leaves[2] = thirdLeaf; - - siblings = leaves.siblings(0, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], secondLeaf); - assertEq(siblings[1], _parent(thirdLeaf, bytes32(0))); - - siblings = leaves.siblings(1, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], firstLeaf); - assertEq(siblings[1], _parent(thirdLeaf, bytes32(0))); - - siblings = leaves.siblings(2, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], bytes32(0)); - assertEq(siblings[1], _parent(firstLeaf, secondLeaf)); - - siblings = leaves.siblings(3, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], thirdLeaf); - assertEq(siblings[1], _parent(firstLeaf, secondLeaf)); - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(4, 2); - - leaves = new bytes32[](4); - leaves[0] = firstLeaf; - leaves[1] = secondLeaf; - leaves[2] = thirdLeaf; - leaves[3] = fourthLeaf; - - siblings = leaves.siblings(0, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], secondLeaf); - assertEq(siblings[1], _parent(thirdLeaf, fourthLeaf)); - - siblings = leaves.siblings(1, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], firstLeaf); - assertEq(siblings[1], _parent(thirdLeaf, fourthLeaf)); - - siblings = leaves.siblings(2, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], fourthLeaf); - assertEq(siblings[1], _parent(firstLeaf, secondLeaf)); - - siblings = leaves.siblings(3, 2); - assertEq(siblings.length, 2); - assertEq(siblings[0], thirdLeaf); - assertEq(siblings[1], _parent(firstLeaf, secondLeaf)); - - vm.expectRevert("LibMerkle32: index out of bounds"); - leaves.siblings(4, 2); - } - - function testMerkleRootAfterReplacementHeightZero(bytes32 leaf) external { - bytes32[] memory siblings; - - siblings = new bytes32[](0); - - // With height zero, the leaf is the merkle root - assertEq(siblings.merkleRootAfterReplacement(0, leaf), leaf); - - vm.expectRevert("LibMerkle32: index out of bounds"); - siblings.merkleRootAfterReplacement(1, leaf); - } - - function testMerkleRootAfterReplacementHeightOne(bytes32 sibling, bytes32 leaf) - external - { - bytes32[] memory siblings; - - siblings = new bytes32[](1); - siblings[0] = sibling; - - assertEq(siblings.merkleRootAfterReplacement(0, leaf), _parent(leaf, sibling)); - - assertEq(siblings.merkleRootAfterReplacement(1, leaf), _parent(sibling, leaf)); - - vm.expectRevert("LibMerkle32: index out of bounds"); - siblings.merkleRootAfterReplacement(2, leaf); - } - - function testMerkleRootAfterReplacementHeightTwo( - bytes32 firstSibling, - bytes32 secondSibling, - bytes32 leaf - ) external { - bytes32[] memory siblings; - - siblings = new bytes32[](2); - siblings[0] = firstSibling; - siblings[1] = secondSibling; - - assertEq( - siblings.merkleRootAfterReplacement(0, leaf), - _parent(_parent(leaf, firstSibling), secondSibling) - ); - - assertEq( - siblings.merkleRootAfterReplacement(1, leaf), - _parent(_parent(firstSibling, leaf), secondSibling) - ); - - assertEq( - siblings.merkleRootAfterReplacement(2, leaf), - _parent(secondSibling, _parent(leaf, firstSibling)) - ); - - assertEq( - siblings.merkleRootAfterReplacement(3, leaf), - _parent(secondSibling, _parent(firstSibling, leaf)) - ); - - vm.expectRevert("LibMerkle32: index out of bounds"); - siblings.merkleRootAfterReplacement(4, leaf); - } - - function testMerkleRootAfterReplacementFuzzy( - bytes32[] calldata leaves, - uint256 height, - uint256 index - ) external pure { - height = _boundHeight(height, leaves.length); - index = _boundBits(index, height); - - bytes32 leaf = leaves.at(index, bytes32(0)); - - bytes32 root = leaves.merkleRoot(height); - - bytes32[] memory siblings = leaves.siblings(index, height); - - bytes32 newRoot = siblings.merkleRootAfterReplacement(index, leaf); - - assertEq(root, newRoot); - } - - function _parent(bytes32 left, bytes32 right) internal pure returns (bytes32) { - return keccak256(abi.encode(left, right)); - } - - function _minHeight(uint256 n) internal pure returns (uint256) { - for (uint256 height; height < 256; ++height) { - /// forge-lint: disable-next-line(incorrect-shift) - if (n <= (1 << height)) { - return height; - } - } - return 256; - } - - function _boundHeight(uint256 height, uint256 n) internal pure returns (uint256) { - return bound(height, _minHeight(n), 256); - } - - function _boundBits(uint256 x, uint256 nbits) internal pure returns (uint256) { - if (nbits < 256) { - return x >> (256 - nbits); - } else { - return x; - } - } -} diff --git a/test/util/LibClaim.sol b/test/util/LibClaim.sol index ccb0f3c5..c1779368 100644 --- a/test/util/LibClaim.sol +++ b/test/util/LibClaim.sol @@ -7,12 +7,13 @@ import { EmulatorConstants } from "cartesi-machine-solidity-step-0.13.0/src/EmulatorConstants.sol"; -import {LibMerkle32} from "src/library/LibMerkle32.sol"; +import {LibBinaryMerkleTree} from "src/library/LibBinaryMerkleTree.sol"; +import {LibKeccak256} from "src/library/LibKeccak256.sol"; import {Claim} from "./Claim.sol"; library LibClaim { - using LibMerkle32 for bytes32[]; + using LibBinaryMerkleTree for bytes32[]; function computeMachineMerkleRoot(Claim calldata claim) external @@ -23,7 +24,8 @@ library LibClaim { .merkleRootAfterReplacement( EmulatorConstants.PMA_CMIO_TX_BUFFER_START >> EmulatorConstants.TREE_LOG2_WORD_SIZE, - keccak256(abi.encode(claim.outputsMerkleRoot)) + keccak256(abi.encode(claim.outputsMerkleRoot)), + LibKeccak256.hashPair ); } } diff --git a/test/util/LibEmulator.sol b/test/util/LibEmulator.sol index 348eb612..3c6a331d 100644 --- a/test/util/LibEmulator.sol +++ b/test/util/LibEmulator.sol @@ -7,11 +7,13 @@ import {SafeCast} from "@openzeppelin-contracts-5.2.0/utils/math/SafeCast.sol"; import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; import {OutputValidityProof} from "src/common/OutputValidityProof.sol"; -import {LibMerkle32} from "src/library/LibMerkle32.sol"; +import {LibKeccak256} from "src/library/LibKeccak256.sol"; + +import {LibBinaryMerkleTreeHelper} from "./LibBinaryMerkleTreeHelper.sol"; library LibEmulator { using SafeCast for uint256; - using LibMerkle32 for bytes32[]; + using LibBinaryMerkleTreeHelper for bytes32[]; struct State { bytes[] outputs; @@ -19,6 +21,8 @@ library LibEmulator { type OutputIndex is uint64; + bytes32 constant NO_OUTPUT_SENTINEL_VALUE = bytes32(0); + // ------------- // state changes // ------------- @@ -78,7 +82,11 @@ library LibEmulator { pure returns (bytes32) { - return outputHashes.merkleRoot(CanonicalMachine.LOG2_MAX_OUTPUTS); + return outputHashes.merkleRootFromNodes( + NO_OUTPUT_SENTINEL_VALUE, + CanonicalMachine.LOG2_MAX_OUTPUTS, + LibKeccak256.hashPair + ); } function getOutputSiblings(bytes32[] memory outputHashes, uint64 outputIndex) @@ -86,7 +94,12 @@ library LibEmulator { pure returns (bytes32[] memory) { - return outputHashes.siblings(outputIndex, CanonicalMachine.LOG2_MAX_OUTPUTS); + return outputHashes.siblings( + NO_OUTPUT_SENTINEL_VALUE, + outputIndex, + CanonicalMachine.LOG2_MAX_OUTPUTS, + LibKeccak256.hashPair + ); } // --------------- diff --git a/test/util/LibMath.sol b/test/util/LibMath.sol deleted file mode 100644 index 149c46f1..00000000 --- a/test/util/LibMath.sol +++ /dev/null @@ -1,14 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pragma solidity ^0.8.22; - -library LibMath { - /// @notice Get the smallest of two numbers. - /// @param a The first number - /// @param b The second number - /// @return The smallest of the two numbers - function min(uint256 a, uint256 b) internal pure returns (uint256) { - return a <= b ? a : b; - } -} diff --git a/test/util/LibMath.t.sol b/test/util/LibMath.t.sol deleted file mode 100644 index 3094c2b2..00000000 --- a/test/util/LibMath.t.sol +++ /dev/null @@ -1,17 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pragma solidity ^0.8.22; - -import {Test} from "forge-std-1.9.6/src/Test.sol"; - -import {LibMath} from "./LibMath.sol"; - -contract LibMathTest is Test { - function testMin(uint256 a, uint256 b) external pure { - uint256 c = LibMath.min(a, b); - assertTrue(c == a || c == b, "min(a, b) \\in {a, b}"); - assertLe(c, a); - assertLe(c, b); - } -} diff --git a/test/util/LibUint256Array.t.sol b/test/util/LibUint256Array.t.sol index 1f197d8e..552e718b 100644 --- a/test/util/LibUint256Array.t.sol +++ b/test/util/LibUint256Array.t.sol @@ -6,8 +6,9 @@ pragma solidity ^0.8.22; import {Test} from "forge-std-1.9.6/src/Test.sol"; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; +import {LibMath} from "src/library/LibMath.sol"; + import {LibBytes} from "./LibBytes.sol"; -import {LibMath} from "./LibMath.sol"; import {LibUint256Array} from "./LibUint256Array.sol"; contract LibUint256ArrayTest is Test { From c256e06e7dabd0a65b1996ad1e0c786402a5e14d Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 9 Mar 2026 19:04:06 -0300 Subject: [PATCH 37/48] Simplify LibBinaryMerkleTree test --- test/library/LibBinaryMerkleTree.t.sol | 75 ++------------------------ 1 file changed, 4 insertions(+), 71 deletions(-) diff --git a/test/library/LibBinaryMerkleTree.t.sol b/test/library/LibBinaryMerkleTree.t.sol index 7bab425a..9a679bc0 100644 --- a/test/library/LibBinaryMerkleTree.t.sol +++ b/test/library/LibBinaryMerkleTree.t.sol @@ -55,14 +55,6 @@ library ExternalLibBinaryMerkleTree { ); } - function parentLevel(bytes32[] memory nodes, bytes32 defaultNode) - external - pure - returns (bytes32[] memory) - { - return LibBinaryMerkleTreeHelper.parentLevel(nodes, defaultNode, nodeFromChildren); - } - function toLeaves(bytes[] memory dataBlocks) external pure @@ -112,14 +104,12 @@ contract LibBinaryMerkleTreeTest is Test { function testMerkleRootAfterReplacement( bytes32[] memory nodes, - uint256 height, - uint256 nodeIndex, bytes32 defaultNode, bytes32 newNode - ) external pure { - // Bound height and nodeIndex parameters according to the number of nodes - height = _boundHeight(height, nodes.length); - nodeIndex = _boundBits(nodeIndex, height); + ) external { + // Sample height and nodeIndex according to the number of nodes + uint256 height = vm.randomUint(LibMath.ceilLog2(nodes.length), 256); + uint256 nodeIndex = vm.randomUint(height); // Get node at given index or use default node if beyond array bounds bytes32 node = (nodeIndex < nodes.length) ? nodes[nodeIndex] : defaultNode; @@ -369,63 +359,6 @@ contract LibBinaryMerkleTreeTest is Test { vm.expectRevert(LibBinaryMerkleTree.DriveSmallerThanData.selector); data.merkleRoot(log2DriveSize, log2DataBlockSize); } - - // ------------------ - // internal functions - // ------------------ - - /// @notice Compute a Merkle tree leaf from a data block. - /// @param data The data - /// @param dataBlockIndex The data block index - /// @param dataBlockSize The data block size - function _leafFromDataBlock( - bytes memory data, - uint256 dataBlockIndex, - uint256 dataBlockSize - ) internal pure returns (bytes32 leaf) { - return data.leafFromDataAt(dataBlockIndex, dataBlockSize); - } - - /// @notice Compute a Merkle tree node from its children. - /// @param left The left child - /// @param right The right child - /// @return node The node with the provided left and right children - function _nodeFromChildren(bytes32 left, bytes32 right) - internal - pure - returns (bytes32 node) - { - return ExternalLibBinaryMerkleTree.nodeFromChildren(left, right); - } - - /// @notice Bounds a value between `y` (inclusive) and 256 (inclusive), - /// where `y` is the smallest unsigned integer such that `n <= 2^y`. - /// @param height The random seed - /// @param n The value `n` in the expression - /// @return newHeight A value `y` such that `n <= 2^y` - function _boundHeight(uint256 height, uint256 n) - internal - pure - returns (uint256 newHeight) - { - return bound(height, LibMath.ceilLog2(n), 256); - } - - /// @notice Bounds a value between `0` (inclusive) and `2^{nbits}` (exclusive). - /// @param x The random seed - /// @param nbits The number of non-zero least-significant bits - /// @return newValue A value between 0 and `2^{nbits}` - function _boundBits(uint256 x, uint256 nbits) - internal - pure - returns (uint256 newValue) - { - if (nbits < 256) { - return x >> (256 - nbits); - } else { - return x; - } - } } /// forge-lint: disable-end(incorrect-shift) From 9e9558327c471027226fbfe17a1e27e21a351d5e Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Tue, 10 Mar 2026 07:44:27 -0300 Subject: [PATCH 38/48] Add `validateAccountMerkleRoot` --- src/dapp/Application.sol | 44 ++++++++++++++++++++-- src/dapp/IApplicationWithdrawal.sol | 31 ++++++++++++++++ src/library/LibAccountValidityProof.sol | 49 +++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 src/library/LibAccountValidityProof.sol diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index 52c7c991..af8f713a 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -4,10 +4,12 @@ pragma solidity ^0.8.8; import {IOwnable} from "../access/IOwnable.sol"; +import {AccountValidityProof} from "../common/AccountValidityProof.sol"; import {OutputValidityProof} from "../common/OutputValidityProof.sol"; import {Outputs} from "../common/Outputs.sol"; import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; +import {LibAccountValidityProof} from "../library/LibAccountValidityProof.sol"; import {LibAddress} from "../library/LibAddress.sol"; import {LibOutputValidityProof} from "../library/LibOutputValidityProof.sol"; import {LibWithdrawalConfig} from "../library/LibWithdrawalConfig.sol"; @@ -32,6 +34,7 @@ contract Application is ReentrancyGuard { using BitMaps for BitMaps.BitMap; + using LibAccountValidityProof for AccountValidityProof; using LibAddress for address; using LibOutputValidityProof for OutputValidityProof; using LibWithdrawalConfig for WithdrawalConfig; @@ -216,6 +219,29 @@ contract Application is } } + function validateAccountMerkleRoot( + bytes32 accountMerkleRoot, + AccountValidityProof calldata proof + ) external view override { + if (!proof.isSiblingsArrayLengthValid(getLog2MaxNumOfAccounts())) { + revert InvalidAccountRootSiblingsArrayLength(); + } + + if (!proof.isAccountIndexValid(getLog2MaxNumOfAccounts())) { + revert InvalidAccountIndex(); + } + + bytes32 machineMerkleRoot = proof.computeMachineMerkleRoot( + accountMerkleRoot, getLog2MaxNumOfAccounts(), getAccountsDriveStartIndex() + ); + + bytes32 lastFinalizedMachineMerkleRoot = _getLastFinalizedMachineMerkleRoot(); + + if (machineMerkleRoot != lastFinalizedMachineMerkleRoot) { + revert InvalidMachineMerkleRoot(machineMerkleRoot); + } + } + /// @inheritdoc IApplication function getTemplateHash() external view override returns (bytes32) { return TEMPLATE_HASH; @@ -250,15 +276,15 @@ contract Application is return _numOfWithdrawals; } - function getLog2LeavesPerAccount() external view override returns (uint8) { + function getLog2LeavesPerAccount() public view override returns (uint8) { return LOG2_LEAVES_PER_ACCOUNT; } - function getLog2MaxNumOfAccounts() external view override returns (uint8) { + function getLog2MaxNumOfAccounts() public view override returns (uint8) { return LOG2_MAX_NUM_OF_ACCOUNTS; } - function getAccountsDriveStartIndex() external view override returns (uint64) { + function getAccountsDriveStartIndex() public view override returns (uint64) { return ACCOUNTS_DRIVE_START_INDEX; } @@ -312,6 +338,18 @@ contract Application is ); } + /// @notice Get the last finalized machine Merkle root, + /// according to the current outputs Merkle root validator. + /// @return lastFinalizedMachineMerkleRoot The last finalized machine Merkle root + function _getLastFinalizedMachineMerkleRoot() + internal + view + returns (bytes32 lastFinalizedMachineMerkleRoot) + { + return + _outputsMerkleRootValidator.getLastFinalizedMachineMerkleRoot(address(this)); + } + /// @notice Executes a voucher /// @param arguments ABI-encoded arguments function _executeVoucher(bytes calldata arguments) internal { diff --git a/src/dapp/IApplicationWithdrawal.sol b/src/dapp/IApplicationWithdrawal.sol index 087d2322..c4e0788b 100644 --- a/src/dapp/IApplicationWithdrawal.sol +++ b/src/dapp/IApplicationWithdrawal.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.8; +import {AccountValidityProof} from "../common/AccountValidityProof.sol"; import {IWithdrawalOutputBuilder} from "../withdrawal/IWithdrawalOutputBuilder.sol"; interface IApplicationWithdrawal { @@ -14,6 +15,23 @@ interface IApplicationWithdrawal { /// @param account The withdrawal output event Withdrawal(uint64 accountIndex, bytes account, bytes output); + // Errors + + /// @notice Raised when the account root siblings array has an invalid length. + /// @dev The array length should be log2 of the machine memory size - + /// log2 of the data block size - log2 of the maximum number of accounts. + /// See the `CanonicalMachine` library for machine constants + /// and the `getLog2MaxNumOfAccounts` function for accounts drive parameters. + error InvalidAccountRootSiblingsArrayLength(); + + /// @notice Raised when the account index is outside the accounts drive boundaries. + /// See the `getLog2MaxNumOfAccounts` for accounts drive parameters. + error InvalidAccountIndex(); + + /// @notice Raised when the computed machine Merkle root differs + /// from the one provided by the current outputs Merkle root validator. + error InvalidMachineMerkleRoot(bytes32 machineMerkleRoot); + // View Functions /// @notice Get the number of withdrawals. @@ -48,4 +66,17 @@ interface IApplicationWithdrawal { /// @notice Get the withdrawal output builder, which gets static-called /// whenever the funds of an account are to be withdrawn. function getWithdrawalOutputBuilder() external view returns (IWithdrawalOutputBuilder); + + /// @notice Validate the existence of an account at a given index + /// on the accounts drive given a Merkle proof of the account root, + /// according to the last finalized machine Merkle root reported by + /// the application outputs Merkle root validator. + /// @param accountMerkleRoot The account Merkle root + /// @param proof The proof used to validate the account + /// @dev May raise `InvalidAccountRootSiblingsArrayLength`, + /// `InvalidAccountIndex`, or `InvalidMachineMerkleRoot`. + function validateAccountMerkleRoot( + bytes32 accountMerkleRoot, + AccountValidityProof calldata proof + ) external view; } diff --git a/src/library/LibAccountValidityProof.sol b/src/library/LibAccountValidityProof.sol new file mode 100644 index 00000000..093f06a7 --- /dev/null +++ b/src/library/LibAccountValidityProof.sol @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {AccountValidityProof} from "../common/AccountValidityProof.sol"; +import {CanonicalMachine} from "../common/CanonicalMachine.sol"; +import {LibBinaryMerkleTree} from "./LibBinaryMerkleTree.sol"; +import {LibKeccak256} from "./LibKeccak256.sol"; + +library LibAccountValidityProof { + using LibBinaryMerkleTree for bytes32[]; + + function isSiblingsArrayLengthValid( + AccountValidityProof calldata v, + uint8 log2MaxNumOfAccounts + ) internal pure returns (bool) { + // The addition below cannot overflow because + /// `ceil(calldatasize / 32) + 2 * type(uint8).max <= type(uint256).max`. + // (Considering the cost of tx calldata size, the tx gas cost would likely + /// surpass the block gas limit.) + return (v.accountRootSiblings.length + CanonicalMachine.LOG2_DATA_BLOCK_SIZE + + log2MaxNumOfAccounts) == CanonicalMachine.LOG2_MEMORY_SIZE; + } + + function isAccountIndexValid( + AccountValidityProof calldata v, + uint8 log2MaxNumOfAccounts + ) internal pure returns (bool) { + // This is equivalent to `accountIndex < 2^{log2MaxNumOfAccounts}`, + // and works with any value of `log2MaxNumOfAccounts`, even if + // typed as `uint256`. + return (v.accountIndex >> log2MaxNumOfAccounts) == 0; + } + + function computeMachineMerkleRoot( + AccountValidityProof calldata v, + bytes32 accountMerkleRoot, + uint8 log2MaxNumOfAccounts, + uint64 accountsDriveStartIndex + ) internal pure returns (bytes32) { + return v.accountRootSiblings + .merkleRootAfterReplacement( + v.accountIndex | (accountsDriveStartIndex << log2MaxNumOfAccounts), + accountMerkleRoot, + LibKeccak256.hashPair + ); + } +} From 287b805338b8df0d42149b36c2f0e05db8e59381 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Tue, 10 Mar 2026 08:28:00 -0300 Subject: [PATCH 39/48] Move errors to new `BinaryMerkleTreeErrors` iface --- src/common/BinaryMerkleTreeErrors.sol | 37 +++++++++++++ src/library/LibBinaryMerkleTree.sol | 77 +++++++++++++------------- test/library/LibBinaryMerkleTree.t.sol | 60 +++++++++++++++++--- 3 files changed, 130 insertions(+), 44 deletions(-) create mode 100644 src/common/BinaryMerkleTreeErrors.sol diff --git a/src/common/BinaryMerkleTreeErrors.sol b/src/common/BinaryMerkleTreeErrors.sol new file mode 100644 index 00000000..e32c1c0a --- /dev/null +++ b/src/common/BinaryMerkleTreeErrors.sol @@ -0,0 +1,37 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.27; + +interface BinaryMerkleTreeErrors { + /// @notice The provided node index is invalid. + /// @param nodeIndex The node index in its level + /// @param height The binary Merkle tree height + /// @dev The node index should be less than `2^{height}`. + error InvalidNodeIndex(uint256 nodeIndex, uint256 height); + + /// @notice A drive size too large was provided. + /// @param log2DriveSize The log2 size of the drive + /// @param maxLog2DriveSize The maximum log2 size of a drive + error DriveTooLarge(uint256 log2DriveSize, uint256 maxLog2DriveSize); + + /// @notice A data block size too large was provided. + /// @param log2DataBlockSize The log2 size of the data block + /// @param maxLog2DataBlockSize The maximum log2 size of a data block + error DataBlockTooLarge(uint256 log2DataBlockSize, uint256 maxLog2DataBlockSize); + + /// @notice A drive size smaller than the data block size was provided. + /// @param log2DriveSize The log2 size of the drive + /// @param log2DataBlockSize The log2 size of the data block + error DriveSmallerThanDataBlock(uint256 log2DriveSize, uint256 log2DataBlockSize); + + /// @notice A drive too small to fit the data was provided. + /// @param driveSize The size of the drive + /// @param dataSize The size of the data + error DriveSmallerThanData(uint256 driveSize, uint256 dataSize); + + /// @notice An unexpected stack error occurred. + /// @param stackDepth The final stack depth + /// @dev Expected final stack depth to be 1. + error UnexpectedFinalStackDepth(uint256 stackDepth); +} diff --git a/src/library/LibBinaryMerkleTree.sol b/src/library/LibBinaryMerkleTree.sol index 75b3276d..7133a6a6 100644 --- a/src/library/LibBinaryMerkleTree.sol +++ b/src/library/LibBinaryMerkleTree.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.27; +import {BinaryMerkleTreeErrors} from "../common/BinaryMerkleTreeErrors.sol"; +import {CanonicalMachine} from "../common/CanonicalMachine.sol"; import {LibMath} from "./LibMath.sol"; /// forge-lint: disable-start(incorrect-shift) @@ -10,33 +12,11 @@ import {LibMath} from "./LibMath.sol"; library LibBinaryMerkleTree { using LibMath for uint256; - /// @notice Log2 of the maximum drive size. - uint256 constant LOG2_MAX_DRIVE_SIZE = 64; - /// @notice Log2 of the maximum data block size. /// @dev The data block must still be smaller than the drive. + /// We limit the size of data blocks because of the block gas limit. uint256 constant LOG2_MAX_DATA_BLOCK_SIZE = 12; - /// @notice The provided node index is invalid. - /// @dev The index should be less than `2^height`. - error InvalidNodeIndex(); - - /// @notice A drive size smaller than the data block size was provided. - error DriveSmallerThanDataBlock(); - - /// @notice A drive too small to fit the data was provided. - error DriveSmallerThanData(); - - /// @notice A data block size too large was provided. - error DataBlockTooLarge(); - - /// @notice A drive size too large was provided. - error DriveTooLarge(); - - /// @notice An unexpected stack error occurred. - /// @dev Its final depth was not 1. - error UnexpectedStackError(); - /// @notice Compute the root of a Merkle tree after replacing one of its nodes. /// @param sibs The siblings of the node in bottom-up order /// @param nodeIndex The index of the node @@ -52,7 +32,10 @@ library LibBinaryMerkleTree { function(bytes32, bytes32) pure returns (bytes32) nodeFromChildren ) internal pure returns (bytes32) { uint256 height = sibs.length; - require((nodeIndex >> height) == 0, InvalidNodeIndex()); + require( + (nodeIndex >> height) == 0, + BinaryMerkleTreeErrors.InvalidNodeIndex(nodeIndex, height) + ); for (uint256 i; i < height; ++i) { bool isNodeLeftChild = ((nodeIndex >> i) & 1 == 0); bytes32 nodeSibling = sibs[i]; @@ -78,13 +61,31 @@ library LibBinaryMerkleTree { function(bytes memory, uint256, uint256) pure returns (bytes32) leafFromDataAt, function(bytes32, bytes32) pure returns (bytes32) nodeFromChildren ) internal pure returns (bytes32) { - require(log2DriveSize <= LOG2_MAX_DRIVE_SIZE, DriveTooLarge()); - require(log2DataBlockSize <= LOG2_MAX_DATA_BLOCK_SIZE, DataBlockTooLarge()); + require( + log2DriveSize <= CanonicalMachine.LOG2_MEMORY_SIZE, + BinaryMerkleTreeErrors.DriveTooLarge( + log2DriveSize, CanonicalMachine.LOG2_MEMORY_SIZE + ) + ); + require( + log2DataBlockSize <= LOG2_MAX_DATA_BLOCK_SIZE, + BinaryMerkleTreeErrors.DataBlockTooLarge( + log2DataBlockSize, LOG2_MAX_DATA_BLOCK_SIZE + ) + ); uint256 driveSize = 1 << log2DriveSize; - require(data.length <= driveSize, DriveSmallerThanData()); - require(log2DataBlockSize <= log2DriveSize, DriveSmallerThanDataBlock()); + require( + data.length <= driveSize, + BinaryMerkleTreeErrors.DriveSmallerThanData(driveSize, data.length) + ); + require( + log2DataBlockSize <= log2DriveSize, + BinaryMerkleTreeErrors.DriveSmallerThanDataBlock( + log2DriveSize, log2DataBlockSize + ) + ); uint256 merkleTreeHeight = log2DriveSize - log2DataBlockSize; uint256 numOfLeaves = 1 << merkleTreeHeight; @@ -111,14 +112,14 @@ library LibBinaryMerkleTree { bytes32[] memory stack = new bytes32[](2 + merkleTreeHeight); uint256 numOfHashes; // total number of leaves covered up until now - uint256 stackLength; // total length of stack + uint256 stackDepth; // depth of stack (length of stack sub-array) uint256 numOfJoins; // number of hashes of the same level on stack uint256 topStackLevel; // level of hash on top of the stack while (numOfHashes < numOfLeaves) { if ((numOfHashes << log2DataBlockSize) < data.length) { // we still have data blocks to hash - stack[stackLength] = leafFromDataAt(data, numOfHashes, dataBlockSize); + stack[stackDepth] = leafFromDataAt(data, numOfHashes, dataBlockSize); numOfHashes++; numOfJoins = numOfHashes; @@ -128,28 +129,30 @@ library LibBinaryMerkleTree { // pristine Merkle roots topStackLevel = numOfHashes.ctz(); - stack[stackLength] = pristineNodes[topStackLevel]; + stack[stackDepth] = pristineNodes[topStackLevel]; //Empty Tree Hash summarizes many hashes numOfHashes = numOfHashes + (1 << topStackLevel); numOfJoins = numOfHashes >> topStackLevel; } - stackLength++; + stackDepth++; // while there are joins, hash top of stack together while (numOfJoins & 1 == 0) { - bytes32 h2 = stack[stackLength - 1]; - bytes32 h1 = stack[stackLength - 2]; + bytes32 h2 = stack[stackDepth - 1]; + bytes32 h1 = stack[stackDepth - 2]; - stack[stackLength - 2] = nodeFromChildren(h1, h2); - stackLength = stackLength - 1; // remove hashes from stack + stack[stackDepth - 2] = nodeFromChildren(h1, h2); + stackDepth = stackDepth - 1; // remove hashes from stack numOfJoins = numOfJoins >> 1; } } - require(stackLength == 1, UnexpectedStackError()); + require( + stackDepth == 1, BinaryMerkleTreeErrors.UnexpectedFinalStackDepth(stackDepth) + ); return stack[0]; } diff --git a/test/library/LibBinaryMerkleTree.t.sol b/test/library/LibBinaryMerkleTree.t.sol index 9a679bc0..34088ec8 100644 --- a/test/library/LibBinaryMerkleTree.t.sol +++ b/test/library/LibBinaryMerkleTree.t.sol @@ -5,6 +5,8 @@ pragma solidity ^0.8.22; import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {BinaryMerkleTreeErrors} from "src/common/BinaryMerkleTreeErrors.sol"; +import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; import {LibBinaryMerkleTree} from "src/library/LibBinaryMerkleTree.sol"; import {LibKeccak256} from "src/library/LibKeccak256.sol"; import {LibMath} from "src/library/LibMath.sol"; @@ -157,7 +159,7 @@ contract LibBinaryMerkleTreeTest is Test { // Finally, provide the invalid node index to the // merkleRootAfterReplacement function and expect an error. - vm.expectRevert(LibBinaryMerkleTree.InvalidNodeIndex.selector); + vm.expectRevert(_encodeInvalidNodeIndex(nodeIndex, height)); siblings.merkleRootAfterReplacement(nodeIndex, node); } @@ -174,7 +176,7 @@ contract LibBinaryMerkleTreeTest is Test { // Second, we bound the log2 drive size to between the minimum amount // calculated in the previous step and the maximum allowed. - uint256 maxLog2DriveSize = LibBinaryMerkleTree.LOG2_MAX_DRIVE_SIZE; + uint256 maxLog2DriveSize = CanonicalMachine.LOG2_MEMORY_SIZE; log2DriveSize = bound(log2DriveSize, minLog2DriveSize, maxLog2DriveSize); // Third, we bound the log2 data block size between 0 and @@ -295,11 +297,11 @@ contract LibBinaryMerkleTreeTest is Test { uint256 log2DataBlockSize ) external { // First, we bound the log2 drive size to beyond the maximum. - uint256 maxLog2DriveSize = LibBinaryMerkleTree.LOG2_MAX_DRIVE_SIZE; + uint256 maxLog2DriveSize = CanonicalMachine.LOG2_MEMORY_SIZE; log2DriveSize = bound(log2DriveSize, maxLog2DriveSize + 1, type(uint256).max); // Then, we call the merkleRoot function and expect an error. - vm.expectRevert(LibBinaryMerkleTree.DriveTooLarge.selector); + vm.expectRevert(_encodeDriveTooLarge(log2DriveSize)); data.merkleRoot(log2DriveSize, log2DataBlockSize); } @@ -313,7 +315,7 @@ contract LibBinaryMerkleTreeTest is Test { // Second, we bound the log2 drive size to between the minimum amount // calculated in the previous step and the maximum allowed. - uint256 maxLog2DriveSize = LibBinaryMerkleTree.LOG2_MAX_DRIVE_SIZE; + uint256 maxLog2DriveSize = CanonicalMachine.LOG2_MEMORY_SIZE; log2DriveSize = bound(log2DriveSize, minLog2DriveSize, maxLog2DriveSize); // Third, we bound the log2 data block size beyond the maximum allowed. @@ -322,7 +324,7 @@ contract LibBinaryMerkleTreeTest is Test { bound(log2DataBlockSize, maxLog2DataBlockSize + 1, type(uint256).max); // Then, we call the merkleRoot function and expect an error. - vm.expectRevert(LibBinaryMerkleTree.DataBlockTooLarge.selector); + vm.expectRevert(_encodeDataBlockTooLarge(log2DataBlockSize)); data.merkleRoot(log2DriveSize, log2DataBlockSize); } @@ -356,9 +358,53 @@ contract LibBinaryMerkleTreeTest is Test { log2DataBlockSize = bound(log2DataBlockSize, 0, maxLog2DataBlockSize); // Then, we call the merkleRoot function and expect an error. - vm.expectRevert(LibBinaryMerkleTree.DriveSmallerThanData.selector); + vm.expectRevert(_encodeDriveSmallerThanData(1 << log2DriveSize, data.length)); data.merkleRoot(log2DriveSize, log2DataBlockSize); } + + function _encodeInvalidNodeIndex(uint256 nodeIndex, uint256 height) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + BinaryMerkleTreeErrors.InvalidNodeIndex.selector, nodeIndex, height + ); + } + + function _encodeDriveTooLarge(uint256 log2DriveSize) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + BinaryMerkleTreeErrors.DriveTooLarge.selector, + log2DriveSize, + CanonicalMachine.LOG2_MEMORY_SIZE + ); + } + + function _encodeDataBlockTooLarge(uint256 log2DataBlockSize) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + BinaryMerkleTreeErrors.DataBlockTooLarge.selector, + log2DataBlockSize, + LibBinaryMerkleTree.LOG2_MAX_DATA_BLOCK_SIZE + ); + } + + function _encodeDriveSmallerThanData(uint256 driveSize, uint256 dataSize) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + BinaryMerkleTreeErrors.DriveSmallerThanData.selector, driveSize, dataSize + ); + } } /// forge-lint: disable-end(incorrect-shift) From a410ea4de07ba959fe16d597bc293480d74feb0b Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Tue, 10 Mar 2026 08:39:27 -0300 Subject: [PATCH 40/48] Add `validateAccount` --- src/dapp/Application.sol | 21 ++++++++++++++++++++- src/dapp/IApplicationWithdrawal.sol | 14 +++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index af8f713a..40b612ef 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -5,12 +5,15 @@ pragma solidity ^0.8.8; import {IOwnable} from "../access/IOwnable.sol"; import {AccountValidityProof} from "../common/AccountValidityProof.sol"; +import {CanonicalMachine} from "../common/CanonicalMachine.sol"; import {OutputValidityProof} from "../common/OutputValidityProof.sol"; import {Outputs} from "../common/Outputs.sol"; import {WithdrawalConfig} from "../common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValidator.sol"; import {LibAccountValidityProof} from "../library/LibAccountValidityProof.sol"; import {LibAddress} from "../library/LibAddress.sol"; +import {LibBinaryMerkleTree} from "../library/LibBinaryMerkleTree.sol"; +import {LibKeccak256} from "../library/LibKeccak256.sol"; import {LibOutputValidityProof} from "../library/LibOutputValidityProof.sol"; import {LibWithdrawalConfig} from "../library/LibWithdrawalConfig.sol"; import {IWithdrawalOutputBuilder} from "../withdrawal/IWithdrawalOutputBuilder.sol"; @@ -36,6 +39,7 @@ contract Application is using BitMaps for BitMaps.BitMap; using LibAccountValidityProof for AccountValidityProof; using LibAddress for address; + using LibBinaryMerkleTree for bytes; using LibOutputValidityProof for OutputValidityProof; using LibWithdrawalConfig for WithdrawalConfig; @@ -219,10 +223,25 @@ contract Application is } } + function validateAccount(bytes calldata account, AccountValidityProof calldata proof) + external + view + override + { + bytes32 accountMerkleRoot = account.merkleRoot( + CanonicalMachine.LOG2_DATA_BLOCK_SIZE + getLog2LeavesPerAccount(), + CanonicalMachine.LOG2_DATA_BLOCK_SIZE, + LibKeccak256.hashBlock, + LibKeccak256.hashPair + ); + + validateAccountMerkleRoot(accountMerkleRoot, proof); + } + function validateAccountMerkleRoot( bytes32 accountMerkleRoot, AccountValidityProof calldata proof - ) external view override { + ) public view override { if (!proof.isSiblingsArrayLengthValid(getLog2MaxNumOfAccounts())) { revert InvalidAccountRootSiblingsArrayLength(); } diff --git a/src/dapp/IApplicationWithdrawal.sol b/src/dapp/IApplicationWithdrawal.sol index c4e0788b..e1e7d291 100644 --- a/src/dapp/IApplicationWithdrawal.sol +++ b/src/dapp/IApplicationWithdrawal.sol @@ -4,9 +4,10 @@ pragma solidity ^0.8.8; import {AccountValidityProof} from "../common/AccountValidityProof.sol"; +import {BinaryMerkleTreeErrors} from "../common/BinaryMerkleTreeErrors.sol"; import {IWithdrawalOutputBuilder} from "../withdrawal/IWithdrawalOutputBuilder.sol"; -interface IApplicationWithdrawal { +interface IApplicationWithdrawal is BinaryMerkleTreeErrors { // Events /// @notice MUST trigger when the funds of an account are withdrawn. @@ -67,6 +68,17 @@ interface IApplicationWithdrawal { /// whenever the funds of an account are to be withdrawn. function getWithdrawalOutputBuilder() external view returns (IWithdrawalOutputBuilder); + /// @notice Validate the existence of an account at a given index + /// on the accounts drive given a Merkle proof of the account root, + /// according to the last finalized machine Merkle root reported by + /// the application outputs Merkle root validator. + /// @param account The account + /// @param proof The proof used to validate the account + /// @dev May raise any of the errors raised by `validateAccountMerkleRoot`. + function validateAccount(bytes calldata account, AccountValidityProof calldata proof) + external + view; + /// @notice Validate the existence of an account at a given index /// on the accounts drive given a Merkle proof of the account root, /// according to the last finalized machine Merkle root reported by From a818672d0c93b0df64c07b91b1514f06140dde98 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Tue, 10 Mar 2026 12:05:22 -0300 Subject: [PATCH 41/48] Add `LibBytes` --- src/library/LibBytes.sol | 25 +++++++++++++++++++++++++ test/library/LibBytes.t.sol | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/library/LibBytes.sol create mode 100644 test/library/LibBytes.t.sol diff --git a/src/library/LibBytes.sol b/src/library/LibBytes.sol new file mode 100644 index 00000000..990a9c9b --- /dev/null +++ b/src/library/LibBytes.sol @@ -0,0 +1,25 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +library LibBytes { + function consumeBytes4(bytes memory buffer) + internal + pure + returns (bool isBufferValid, bytes4 selector, bytes memory arguments) + { + if (buffer.length < 4) { + isBufferValid = false; + } else { + isBufferValid = true; + for (uint256 i; i < 4; ++i) { + selector |= (bytes4(buffer[i]) >> (8 * i)); + } + arguments = new bytes(buffer.length - 4); + for (uint256 i; i < arguments.length; ++i) { + arguments[i] = buffer[i + 4]; + } + } + } +} diff --git a/test/library/LibBytes.t.sol b/test/library/LibBytes.t.sol new file mode 100644 index 00000000..17f0c79c --- /dev/null +++ b/test/library/LibBytes.t.sol @@ -0,0 +1,37 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {LibBytes} from "src/library/LibBytes.sol"; + +contract LibBytesTest is Test { + using LibBytes for bytes; + + function testConsumeBytes4(bytes calldata buffer) external pure { + (bool isBufferValid, bytes4 selector, bytes memory arguments) = + buffer.consumeBytes4(); + if (isBufferValid) { + assertGe( + buffer.length, + 4, + "Expected buffer.length >= 4 (when isBufferValid = true)" + ); + assertEq( + abi.encodePacked(selector, arguments), + buffer, + "Expected abi.encodePacked(selector, arguments) = buffer" + ); + assertEq(selector, bytes4(buffer[:4]), "Expected selector = buffer[:4]"); + assertEq(arguments, buffer[4:], "Expected selector = buffer[:4]"); + } else { + assertLt( + buffer.length, + 4, + "Expected buffer.length < 4 (when isBufferValid = false)" + ); + } + } +} From 2b3be1074ca6abb4cca2797744e0fbc29b143f66 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Tue, 10 Mar 2026 12:05:38 -0300 Subject: [PATCH 42/48] Add `withdraw` --- src/dapp/Application.sol | 111 ++++++++++++++++++++-------- src/dapp/IApplicationWithdrawal.sol | 23 ++++++ 2 files changed, 105 insertions(+), 29 deletions(-) diff --git a/src/dapp/Application.sol b/src/dapp/Application.sol index 40b612ef..3fca70b6 100644 --- a/src/dapp/Application.sol +++ b/src/dapp/Application.sol @@ -13,6 +13,7 @@ import {IOutputsMerkleRootValidator} from "../consensus/IOutputsMerkleRootValida import {LibAccountValidityProof} from "../library/LibAccountValidityProof.sol"; import {LibAddress} from "../library/LibAddress.sol"; import {LibBinaryMerkleTree} from "../library/LibBinaryMerkleTree.sol"; +import {LibBytes} from "../library/LibBytes.sol"; import {LibKeccak256} from "../library/LibKeccak256.sol"; import {LibOutputValidityProof} from "../library/LibOutputValidityProof.sol"; import {LibWithdrawalConfig} from "../library/LibWithdrawalConfig.sol"; @@ -40,6 +41,7 @@ contract Application is using LibAccountValidityProof for AccountValidityProof; using LibAddress for address; using LibBinaryMerkleTree for bytes; + using LibBytes for bytes; using LibOutputValidityProof for OutputValidityProof; using LibWithdrawalConfig for WithdrawalConfig; @@ -138,32 +140,42 @@ contract Application is uint64 outputIndex = proof.outputIndex; - if (output.length < 4) { - revert OutputNotExecutable(output); + if (_executed.get(outputIndex)) { + revert OutputNotReexecutable(output); } - bytes4 selector = bytes4(output[:4]); - bytes calldata arguments = output[4:]; - - if (selector == Outputs.Voucher.selector) { - if (_executed.get(outputIndex)) { - revert OutputNotReexecutable(output); - } - _executeVoucher(arguments); - } else if (selector == Outputs.DelegateCallVoucher.selector) { - if (_executed.get(outputIndex)) { - revert OutputNotReexecutable(output); - } - _executeDelegateCallVoucher(arguments); - } else { - revert OutputNotExecutable(output); - } + _executeOutput(output); _executed.set(outputIndex); + ++_numOfExecutedOutputs; emit OutputExecuted(outputIndex, output); } + function withdraw(bytes calldata account, AccountValidityProof calldata proof) + external + override + nonReentrant + onlyForeclosed + { + validateAccount(account, proof); + + bytes memory output = _buildWithdrawalOutput(account); + + uint64 accountIndex = proof.accountIndex; + + if (_withdrawn.get(accountIndex)) { + revert AccountFundsAlreadyWithdrawn(accountIndex); + } + + _executeOutput(output); + + _withdrawn.set(accountIndex); + + ++_numOfWithdrawals; + emit Withdrawal(accountIndex, account, output); + } + /// @inheritdoc IApplication function migrateToOutputsMerkleRootValidator(IOutputsMerkleRootValidator newOutputsMerkleRootValidator) external @@ -224,7 +236,7 @@ contract Application is } function validateAccount(bytes calldata account, AccountValidityProof calldata proof) - external + public view override { @@ -268,7 +280,7 @@ contract Application is /// @inheritdoc IApplication function getOutputsMerkleRootValidator() - external + public view override returns (IOutputsMerkleRootValidator) @@ -312,7 +324,7 @@ contract Application is } function getWithdrawalOutputBuilder() - external + public view override returns (IWithdrawalOutputBuilder) @@ -320,7 +332,7 @@ contract Application is return WITHDRAWAL_OUTPUT_BUILDER; } - function isForeclosed() external view override returns (bool) { + function isForeclosed() public view override returns (bool) { return _isForeclosed; } @@ -344,6 +356,11 @@ contract Application is _; } + modifier onlyForeclosed() { + _ensureAppIsForeclosed(); + _; + } + /// @notice Check if an outputs Merkle root is valid, /// according to the current outputs Merkle root validator. /// @param outputsMerkleRoot The output Merkle root @@ -352,9 +369,8 @@ contract Application is view returns (bool) { - return _outputsMerkleRootValidator.isOutputsMerkleRootValid( - address(this), outputsMerkleRoot - ); + return getOutputsMerkleRootValidator() + .isOutputsMerkleRootValid(address(this), outputsMerkleRoot); } /// @notice Get the last finalized machine Merkle root, @@ -365,13 +381,45 @@ contract Application is view returns (bytes32 lastFinalizedMachineMerkleRoot) { - return - _outputsMerkleRootValidator.getLastFinalizedMachineMerkleRoot(address(this)); + return getOutputsMerkleRootValidator() + .getLastFinalizedMachineMerkleRoot(address(this)); + } + + /// @notice Build a withdrawal output from an account, + /// using the withdrawal output builder contract. + /// @param account The account + /// @return output The withdrawal output + function _buildWithdrawalOutput(bytes calldata account) + internal + view + returns (bytes memory output) + { + return getWithdrawalOutputBuilder().buildWithdrawalOutput(account); + } + + /// @notice Executes an output + /// @param output The output + function _executeOutput(bytes memory output) internal { + bool isOutputExecutable; + bytes4 selector; + bytes memory arguments; + + (isOutputExecutable, selector, arguments) = output.consumeBytes4(); + + require(isOutputExecutable, OutputNotExecutable(output)); + + if (selector == Outputs.Voucher.selector) { + _executeVoucher(arguments); + } else if (selector == Outputs.DelegateCallVoucher.selector) { + _executeDelegateCallVoucher(arguments); + } else { + revert OutputNotExecutable(output); + } } /// @notice Executes a voucher /// @param arguments ABI-encoded arguments - function _executeVoucher(bytes calldata arguments) internal { + function _executeVoucher(bytes memory arguments) internal { address destination; uint256 value; bytes memory payload; @@ -390,7 +438,7 @@ contract Application is /// @notice Executes a delegatecall voucher /// @param arguments ABI-encoded arguments - function _executeDelegateCallVoucher(bytes calldata arguments) internal { + function _executeDelegateCallVoucher(bytes memory arguments) internal { address destination; bytes memory payload; @@ -403,4 +451,9 @@ contract Application is function _ensureMsgSenderIsGuardian() internal view { require(msg.sender == getGuardian(), NotGuardian()); } + + /// @notice Ensures the application is foreclosed. + function _ensureAppIsForeclosed() internal view { + require(isForeclosed(), NotForeclosed()); + } } diff --git a/src/dapp/IApplicationWithdrawal.sol b/src/dapp/IApplicationWithdrawal.sol index e1e7d291..191bae42 100644 --- a/src/dapp/IApplicationWithdrawal.sol +++ b/src/dapp/IApplicationWithdrawal.sol @@ -18,6 +18,10 @@ interface IApplicationWithdrawal is BinaryMerkleTreeErrors { // Errors + /// @notice Raised when the application has not yet been foreclosed + /// and therefore withdrawals cannot yet be performed. + error NotForeclosed(); + /// @notice Raised when the account root siblings array has an invalid length. /// @dev The array length should be log2 of the machine memory size - /// log2 of the data block size - log2 of the maximum number of accounts. @@ -31,8 +35,27 @@ interface IApplicationWithdrawal is BinaryMerkleTreeErrors { /// @notice Raised when the computed machine Merkle root differs /// from the one provided by the current outputs Merkle root validator. + /// @param machineMerkleRoot The computed machine Merkle root error InvalidMachineMerkleRoot(bytes32 machineMerkleRoot); + /// @notice Raised when trying to withdraw funds of an account + /// whose funds have already been withdrawn. + /// @param accountIndex The account index + error AccountFundsAlreadyWithdrawn(uint64 accountIndex); + + // Write functions + + /// @notice Withdraw the funds of an account from the foreclosed application. + /// First, the account is validated against the last finalized machine Merkle root. + /// Then, a withdrawal output is built from the account, and executed. + /// @param account The account + /// @param proof The proof used to validate the account + /// @dev May raise `NotForeclosed`, `AccountFundsAlreadyWithdrawn`, + /// as well as any of the errors raised by `validateAccount`. + /// On success, marks the account funds as withdrawn, and emits a `Withdrawal` event. + function withdraw(bytes calldata account, AccountValidityProof calldata proof) + external; + // View Functions /// @notice Get the number of withdrawals. From 19fa7aed5fbed4675262f08786fa0d057ecaf21c Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Thu, 12 Mar 2026 08:09:50 -0300 Subject: [PATCH 43/48] Use interfaces in app test --- test/dapp/Application.t.sol | 24 +++++++++++++++--------- test/util/EtherReceiver.sol | 9 +++++++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/test/dapp/Application.t.sol b/test/dapp/Application.t.sol index ac46c129..570f4cbb 100644 --- a/test/dapp/Application.t.sol +++ b/test/dapp/Application.t.sol @@ -9,9 +9,11 @@ import {Outputs} from "src/common/Outputs.sol"; import {WithdrawalConfig} from "src/common/WithdrawalConfig.sol"; import {IOutputsMerkleRootValidator} from "src/consensus/IOutputsMerkleRootValidator.sol"; import {Authority} from "src/consensus/authority/Authority.sol"; +import {IAuthority} from "src/consensus/authority/IAuthority.sol"; import {Application} from "src/dapp/Application.sol"; import {IApplication} from "src/dapp/IApplication.sol"; import {IApplicationForeclosure} from "src/dapp/IApplicationForeclosure.sol"; +import {ISafeERC20Transfer} from "src/delegatecall/ISafeERC20Transfer.sol"; import {SafeERC20Transfer} from "src/delegatecall/SafeERC20Transfer.sol"; import {IInputBox} from "src/inputs/IInputBox.sol"; import {InputBox} from "src/inputs/InputBox.sol"; @@ -31,7 +33,7 @@ import {Test} from "forge-std-1.9.6/src/Test.sol"; import {ExternalLibBinaryMerkleTree} from "../library/LibBinaryMerkleTree.t.sol"; import {AddressGenerator} from "../util/AddressGenerator.sol"; import {ConsensusTestUtils} from "../util/ConsensusTestUtils.sol"; -import {EtherReceiver} from "../util/EtherReceiver.sol"; +import {EtherReceiver, IEtherReceiver} from "../util/EtherReceiver.sol"; import {LibEmulator} from "../util/LibEmulator.sol"; import {OwnableTest} from "../util/OwnableTest.sol"; import {SimpleBatchERC1155, SimpleSingleERC1155} from "../util/SimpleERC1155.sol"; @@ -43,13 +45,13 @@ contract ApplicationTest is Test, OwnableTest, AddressGenerator, ConsensusTestUt using ExternalLibBinaryMerkleTree for bytes32[]; IApplication _appContract; - EtherReceiver _etherReceiver; - Authority _authority; + IEtherReceiver _etherReceiver; + IAuthority _authority; IERC20 _erc20Token; IERC721 _erc721Token; IERC1155 _erc1155SingleToken; IERC1155 _erc1155BatchToken; - SafeERC20Transfer _safeErc20Transfer; + ISafeERC20Transfer _safeErc20Transfer; IInputBox _inputBox; LibEmulator.State _emulator; @@ -139,11 +141,15 @@ contract ApplicationTest is Test, OwnableTest, AddressGenerator, ConsensusTestUt function testForeclose() external { assertFalse(_appContract.isForeclosed()); - vm.expectEmit(true, true, true, true, address(_appContract)); - emit IApplicationForeclosure.Foreclosure(); - vm.prank(_appContract.getGuardian()); - _appContract.foreclose(); - assertTrue(_appContract.isForeclosed()); + + // check the idempotence of the `foreclose()` function. + for (uint256 i; i < 3; ++i) { + vm.expectEmit(true, true, true, true, address(_appContract)); + emit IApplicationForeclosure.Foreclosure(); + vm.prank(_appContract.getGuardian()); + _appContract.foreclose(); + assertTrue(_appContract.isForeclosed()); + } } // ----------------- diff --git a/test/util/EtherReceiver.sol b/test/util/EtherReceiver.sol index 8ddc2bbc..324df4c2 100644 --- a/test/util/EtherReceiver.sol +++ b/test/util/EtherReceiver.sol @@ -3,10 +3,15 @@ pragma solidity ^0.8.22; -contract EtherReceiver { +interface IEtherReceiver { + function balanceOf(address who) external view returns (uint256); + function mint() external payable; +} + +contract EtherReceiver is IEtherReceiver { mapping(address => uint256) public balanceOf; - function mint() external payable { + function mint() external payable override { balanceOf[msg.sender] += msg.value; } } From eb23d1eb2249a49a985237477d8dfada60f793b9 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Thu, 12 Mar 2026 09:16:10 -0300 Subject: [PATCH 44/48] Modularize withdrawal config test utilities --- test/library/LibWithdrawalConfig.t.sol | 88 ++----------------------- test/util/WithdrawalConfigTestUtils.sol | 86 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 81 deletions(-) create mode 100644 test/util/WithdrawalConfigTestUtils.sol diff --git a/test/library/LibWithdrawalConfig.t.sol b/test/library/LibWithdrawalConfig.t.sol index 074bb71b..7c3b9f04 100644 --- a/test/library/LibWithdrawalConfig.t.sol +++ b/test/library/LibWithdrawalConfig.t.sol @@ -3,13 +3,12 @@ pragma solidity ^0.8.22; -import {Test} from "forge-std-1.9.6/src/Test.sol"; - -import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; import {WithdrawalConfig} from "src/common/WithdrawalConfig.sol"; import {LibWithdrawalConfig} from "src/library/LibWithdrawalConfig.sol"; -contract LibWithdrawalConfigTest is Test { +import {WithdrawalConfigTestUtils} from "../util/WithdrawalConfigTestUtils.sol"; + +contract LibWithdrawalConfigTest is WithdrawalConfigTestUtils { /// @notice This test ensures that `isValid` never reverts, /// regardless of the input withdrawal configuration. function testIsValidCompleteness(WithdrawalConfig memory withdrawalConfig) @@ -23,87 +22,14 @@ contract LibWithdrawalConfigTest is Test { /// @notice This test ensures that `isValid` returns true /// for some withdrawal configurations. function testIsValidTrue(WithdrawalConfig memory withdrawalConfig) external pure { - withdrawalConfig.log2LeavesPerAccount = uint8( - bound( - withdrawalConfig.log2LeavesPerAccount, - 0, - CanonicalMachine.LOG2_MEMORY_SIZE - CanonicalMachine.LOG2_DATA_BLOCK_SIZE - ) - ); - - withdrawalConfig.log2MaxNumOfAccounts = uint8( - bound( - withdrawalConfig.log2MaxNumOfAccounts, - 0, - CanonicalMachine.LOG2_MEMORY_SIZE - CanonicalMachine.LOG2_DATA_BLOCK_SIZE - - withdrawalConfig.log2LeavesPerAccount - ) - ); - - uint8 log2AccountsDriveSize = CanonicalMachine.LOG2_DATA_BLOCK_SIZE - + withdrawalConfig.log2LeavesPerAccount - + withdrawalConfig.log2MaxNumOfAccounts; - - // forge-lint: disable-start(incorrect-shift) - withdrawalConfig.accountsDriveStartIndex = uint64( - bound( - withdrawalConfig.accountsDriveStartIndex, - 0, - (1 << (CanonicalMachine.LOG2_MEMORY_SIZE - log2AccountsDriveSize)) - 1 - ) - ); - // forge-lint: disable-end(incorrect-shift) - + _makeWithdrawalConfigValidInPlace(withdrawalConfig); assertTrue(LibWithdrawalConfig.isValid(withdrawalConfig)); } /// @notice This test ensures that `isValid` returns false - /// for withdrawal configurations in which the accounts drive is too big. - function testIsValidFalseAccountsDriveTooBig(WithdrawalConfig memory withdrawalConfig) - external - pure - { - if ( - withdrawalConfig.log2MaxNumOfAccounts - <= CanonicalMachine.LOG2_MEMORY_SIZE - - CanonicalMachine.LOG2_DATA_BLOCK_SIZE - ) { - withdrawalConfig.log2LeavesPerAccount = uint8( - bound( - withdrawalConfig.log2LeavesPerAccount, - CanonicalMachine.LOG2_MEMORY_SIZE - - CanonicalMachine.LOG2_DATA_BLOCK_SIZE - - withdrawalConfig.log2MaxNumOfAccounts + 1, - type(uint8).max - ) - ); - } - - assertFalse(LibWithdrawalConfig.isValid(withdrawalConfig)); - } - - /// @notice This test ensures that `isValid` returns false - /// for withdrawal configurations in which the accounts drive is outside memory bounds. - function testIsValidFalseAccountsDriveOutsideBounds(WithdrawalConfig memory withdrawalConfig) - external - pure - { - uint256 log2AccountsDriveSize = uint256(CanonicalMachine.LOG2_DATA_BLOCK_SIZE) - + uint256(withdrawalConfig.log2LeavesPerAccount) - + uint256(withdrawalConfig.log2MaxNumOfAccounts); - - // forge-lint: disable-start(incorrect-shift) - if (log2AccountsDriveSize <= CanonicalMachine.LOG2_MEMORY_SIZE) { - withdrawalConfig.accountsDriveStartIndex = uint64( - bound( - withdrawalConfig.accountsDriveStartIndex, - 1 << (CanonicalMachine.LOG2_MEMORY_SIZE - log2AccountsDriveSize), - type(uint64).max - ) - ); - } - // forge-lint: disable-end(incorrect-shift) - + /// for some withdrawal configurations. + function testIsValidFalse(WithdrawalConfig memory withdrawalConfig) external view { + _makeWithdrawalConfigInvalidInPlace(withdrawalConfig); assertFalse(LibWithdrawalConfig.isValid(withdrawalConfig)); } } diff --git a/test/util/WithdrawalConfigTestUtils.sol b/test/util/WithdrawalConfigTestUtils.sol new file mode 100644 index 00000000..ac3a1846 --- /dev/null +++ b/test/util/WithdrawalConfigTestUtils.sol @@ -0,0 +1,86 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; +import {WithdrawalConfig} from "src/common/WithdrawalConfig.sol"; + +abstract contract WithdrawalConfigTestUtils is Test { + function _makeWithdrawalConfigValidInPlace(WithdrawalConfig memory withdrawalConfig) + internal + pure + { + withdrawalConfig.log2LeavesPerAccount = uint8( + bound( + withdrawalConfig.log2LeavesPerAccount, + 0, + CanonicalMachine.LOG2_MEMORY_SIZE - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + ) + ); + + withdrawalConfig.log2MaxNumOfAccounts = uint8( + bound( + withdrawalConfig.log2MaxNumOfAccounts, + 0, + CanonicalMachine.LOG2_MEMORY_SIZE - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + - withdrawalConfig.log2LeavesPerAccount + ) + ); + + uint8 log2AccountsDriveSize = CanonicalMachine.LOG2_DATA_BLOCK_SIZE + + withdrawalConfig.log2LeavesPerAccount + + withdrawalConfig.log2MaxNumOfAccounts; + + // forge-lint: disable-start(incorrect-shift) + withdrawalConfig.accountsDriveStartIndex = uint64( + bound( + withdrawalConfig.accountsDriveStartIndex, + 0, + (1 << (CanonicalMachine.LOG2_MEMORY_SIZE - log2AccountsDriveSize)) - 1 + ) + ); + // forge-lint: disable-end(incorrect-shift) + } + + function _makeWithdrawalConfigInvalidInPlace(WithdrawalConfig memory withdrawalConfig) + internal + view + { + if (vm.randomBool()) { + if ( + withdrawalConfig.log2MaxNumOfAccounts + <= CanonicalMachine.LOG2_MEMORY_SIZE + - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + ) { + withdrawalConfig.log2LeavesPerAccount = uint8( + bound( + withdrawalConfig.log2LeavesPerAccount, + CanonicalMachine.LOG2_MEMORY_SIZE + - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + - withdrawalConfig.log2MaxNumOfAccounts + 1, + type(uint8).max + ) + ); + } + } else { + uint256 log2AccountsDriveSize = uint256(CanonicalMachine.LOG2_DATA_BLOCK_SIZE) + + uint256(withdrawalConfig.log2LeavesPerAccount) + + uint256(withdrawalConfig.log2MaxNumOfAccounts); + + // forge-lint: disable-start(incorrect-shift) + if (log2AccountsDriveSize <= CanonicalMachine.LOG2_MEMORY_SIZE) { + withdrawalConfig.accountsDriveStartIndex = uint64( + bound( + withdrawalConfig.accountsDriveStartIndex, + 1 << (CanonicalMachine.LOG2_MEMORY_SIZE - log2AccountsDriveSize), + type(uint64).max + ) + ); + } + // forge-lint: disable-end(incorrect-shift) + } + } +} From 92dba782ecd0ee934a556f268969385037903ea3 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Thu, 12 Mar 2026 09:56:00 -0300 Subject: [PATCH 45/48] Sample random invalid account --- test/withdrawal/UsdWithdrawalOutputBuilder.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/withdrawal/UsdWithdrawalOutputBuilder.t.sol b/test/withdrawal/UsdWithdrawalOutputBuilder.t.sol index ac53d9e8..8673dd7c 100644 --- a/test/withdrawal/UsdWithdrawalOutputBuilder.t.sol +++ b/test/withdrawal/UsdWithdrawalOutputBuilder.t.sol @@ -55,8 +55,8 @@ contract UsdWithdrawalOutputBuilderTest is Test { assertEq(value, balance); } - function testBuildWithdrawalOutputReverts(bytes calldata account) external { - vm.assume(account.length < 28); + function testBuildWithdrawalOutputReverts(uint256) external { + bytes memory account = vm.randomBytes(vm.randomUint(0, 27)); vm.expectRevert("Account is too short"); _withdrawalOutputBuilder.buildWithdrawalOutput(account); } From 5d599ff82491c8ec7c1d4ea7784981345b1168dd Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Thu, 12 Mar 2026 12:09:22 -0300 Subject: [PATCH 46/48] Improve code coverage --- src/consensus/authority/AuthorityFactory.sol | 12 +-- src/consensus/quorum/QuorumFactory.sol | 12 +-- src/dapp/ApplicationFactory.sol | 12 +-- src/inputs/InputBox.sol | 6 +- src/library/LibMath.sol | 6 +- src/library/LibWithdrawalConfig.sol | 6 +- .../authority/AuthorityFactory.t.sol | 100 +++++++++++++----- test/consensus/quorum/QuorumFactory.t.sol | 46 +++++--- test/library/LibBinaryMerkleTree.t.sol | 6 +- test/library/LibMath.t.sol | 16 ++- test/library/LibWithdrawalConfig.t.sol | 7 +- test/portals/ERC20Portal.t.sol | 41 +++++++ test/portals/EtherPortal.t.sol | 17 +++ test/util/ApplicationCheckerTestUtils.sol | 16 +-- test/util/ConsensusTestUtils.sol | 21 +--- test/util/InputBoxTestUtils.sol | 37 ++++--- test/util/LibBinaryMerkleTreeHelper.sol | 7 +- test/util/LibUint256Array.sol | 2 - test/util/WithdrawalConfigTestUtils.sol | 10 +- 19 files changed, 241 insertions(+), 139 deletions(-) diff --git a/src/consensus/authority/AuthorityFactory.sol b/src/consensus/authority/AuthorityFactory.sol index a7f99ee4..8ba6f5a2 100644 --- a/src/consensus/authority/AuthorityFactory.sol +++ b/src/consensus/authority/AuthorityFactory.sol @@ -15,25 +15,21 @@ contract AuthorityFactory is IAuthorityFactory { function newAuthority(address authorityOwner, uint256 epochLength) external override - returns (IAuthority) + returns (IAuthority authority) { - IAuthority authority = new Authority(authorityOwner, epochLength); + authority = new Authority(authorityOwner, epochLength); emit AuthorityCreated(authority); - - return authority; } function newAuthority(address authorityOwner, uint256 epochLength, bytes32 salt) external override - returns (IAuthority) + returns (IAuthority authority) { - IAuthority authority = new Authority{salt: salt}(authorityOwner, epochLength); + authority = new Authority{salt: salt}(authorityOwner, epochLength); emit AuthorityCreated(authority); - - return authority; } function calculateAuthorityAddress( diff --git a/src/consensus/quorum/QuorumFactory.sol b/src/consensus/quorum/QuorumFactory.sol index ab55499b..91f13747 100644 --- a/src/consensus/quorum/QuorumFactory.sol +++ b/src/consensus/quorum/QuorumFactory.sol @@ -15,25 +15,21 @@ contract QuorumFactory is IQuorumFactory { function newQuorum(address[] calldata validators, uint256 epochLength) external override - returns (IQuorum) + returns (IQuorum quorum) { - IQuorum quorum = new Quorum(validators, epochLength); + quorum = new Quorum(validators, epochLength); emit QuorumCreated(quorum); - - return quorum; } function newQuorum(address[] calldata validators, uint256 epochLength, bytes32 salt) external override - returns (IQuorum) + returns (IQuorum quorum) { - IQuorum quorum = new Quorum{salt: salt}(validators, epochLength); + quorum = new Quorum{salt: salt}(validators, epochLength); emit QuorumCreated(quorum); - - return quorum; } function calculateQuorumAddress( diff --git a/src/dapp/ApplicationFactory.sol b/src/dapp/ApplicationFactory.sol index bbd54e04..7aee93f4 100644 --- a/src/dapp/ApplicationFactory.sol +++ b/src/dapp/ApplicationFactory.sol @@ -20,8 +20,8 @@ contract ApplicationFactory is IApplicationFactory { bytes32 templateHash, bytes calldata dataAvailability, WithdrawalConfig calldata withdrawalConfig - ) external override returns (IApplication) { - IApplication appContract = new Application( + ) external override returns (IApplication appContract) { + appContract = new Application( outputsMerkleRootValidator, appOwner, templateHash, @@ -37,8 +37,6 @@ contract ApplicationFactory is IApplicationFactory { withdrawalConfig, appContract ); - - return appContract; } function newApplication( @@ -48,8 +46,8 @@ contract ApplicationFactory is IApplicationFactory { bytes calldata dataAvailability, WithdrawalConfig calldata withdrawalConfig, bytes32 salt - ) external override returns (IApplication) { - IApplication appContract = new Application{salt: salt}( + ) external override returns (IApplication appContract) { + appContract = new Application{salt: salt}( outputsMerkleRootValidator, appOwner, templateHash, @@ -65,8 +63,6 @@ contract ApplicationFactory is IApplicationFactory { withdrawalConfig, appContract ); - - return appContract; } function calculateApplicationAddress( diff --git a/src/inputs/InputBox.sol b/src/inputs/InputBox.sol index 2b462766..3464a603 100644 --- a/src/inputs/InputBox.sol +++ b/src/inputs/InputBox.sol @@ -20,7 +20,7 @@ contract InputBox is IInputBox, ApplicationChecker { external override notForeclosed(appContract) - returns (bytes32) + returns (bytes32 inputHash) { bytes32[] storage inputBox = _inputBoxes[appContract]; @@ -47,13 +47,11 @@ contract InputBox is IInputBox, ApplicationChecker { } /// forge-lint: disable-next-line(asm-keccak256) - bytes32 inputHash = keccak256(input); + inputHash = keccak256(input); inputBox.push(inputHash); emit InputAdded(appContract, index, input); - - return inputHash; } /// @inheritdoc IInputBox diff --git a/src/library/LibMath.sol b/src/library/LibMath.sol index ad58034a..720725e0 100644 --- a/src/library/LibMath.sol +++ b/src/library/LibMath.sol @@ -15,11 +15,11 @@ library LibMath { /// @notice Count leading zeros. /// @param x The number you want the clz of + /// @return n The number of leading zeros in x /// @dev This a binary search implementation. - function clz(uint256 x) internal pure returns (uint256) { + function clz(uint256 x) internal pure returns (uint256 n) { if (x == 0) return 256; - uint256 n = 0; if (x & 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000000000000000000000000000 == 0) { n = n + 128; x = x << 128; @@ -51,8 +51,6 @@ library LibMath { if (x & 0x8000000000000000000000000000000000000000000000000000000000000000 == 0) { n = n + 1; } - - return n; } /// @notice The smallest y for which x <= 2^y. diff --git a/src/library/LibWithdrawalConfig.sol b/src/library/LibWithdrawalConfig.sol index 89512a38..82e06295 100644 --- a/src/library/LibWithdrawalConfig.sol +++ b/src/library/LibWithdrawalConfig.sol @@ -31,10 +31,6 @@ library LibWithdrawalConfig { uint256 memorySize = 1 << CanonicalMachine.LOG2_MEMORY_SIZE; // Check if the accounts drive would end past the machine memory boundaries. - if (accountsDriveEnd > memorySize) { - return false; - } - - return true; + return (accountsDriveEnd <= memorySize); } } diff --git a/test/consensus/authority/AuthorityFactory.t.sol b/test/consensus/authority/AuthorityFactory.t.sol index a879ed1f..f66fd826 100644 --- a/test/consensus/authority/AuthorityFactory.t.sol +++ b/test/consensus/authority/AuthorityFactory.t.sol @@ -114,34 +114,59 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti } } - function testRenounceOwnership(address authorityOwner, uint256 epochLength) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + function testRenounceOwnership( + address authorityOwner, + uint256 epochLength, + bool nonDeterministicDeployment + ) external { + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); _testRenounceOwnership(authority); } - function testUnauthorizedAccount(address authorityOwner, uint256 epochLength) - external - { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + function testUnauthorizedAccount( + address authorityOwner, + uint256 epochLength, + bool nonDeterministicDeployment + ) external { + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); _testUnauthorizedAccount(authority); } - function testInvalidOwner(address authorityOwner, uint256 epochLength) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + function testInvalidOwner( + address authorityOwner, + uint256 epochLength, + bool nonDeterministicDeployment + ) external { + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); _testInvalidOwner(authority); } - function testTransferOwnership(address authorityOwner, uint256 epochLength) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + function testTransferOwnership( + address authorityOwner, + uint256 epochLength, + bool nonDeterministicDeployment + ) external { + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); _testTransferOwnership(authority); } function testSubmitClaimRevertsOwnableUnauthorizedAccount( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); claim.appContract = _newActiveAppMock(); @@ -160,11 +185,13 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaimRevertsNotEpochFinalBlock( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { uint256 lastProcessedBlockNumber = _randomNonEpochFinalBlock(epochLength); - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = + _newAuthority(authorityOwner, epochLength, nonDeterministicDeployment); claim.appContract = _newActiveAppMock(); @@ -181,9 +208,12 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaimRevertNotPastBlock( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); claim.appContract = _newActiveAppMock(); @@ -200,9 +230,12 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaimRevertApplicationNotDeployed( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); // We use a random account with no code as app contract claim.appContract = _randomAccountWithNoCode(); @@ -220,10 +253,13 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaimRevertApplicationReverted( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim, bytes memory error ) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); // We make isForeclosed() revert with an error claim.appContract = _newAppMockReverts(error); @@ -241,13 +277,15 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaimRevertApplicationReturnIllSizedReturnData( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim, bytes memory data ) external { // We make isForeclosed() return ill-sized data vm.assume(data.length != 32); - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = + _newAuthority(authorityOwner, epochLength, nonDeterministicDeployment); claim.appContract = _newAppMockReturns(data); @@ -264,12 +302,14 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaimRevertApplicationReturnIllFormedReturnData( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { // We make isForeclosed() return an invalid boolean (neither 0 or 1) uint256 returnValue = vm.randomUint(2, type(uint256).max); - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = + _newAuthority(authorityOwner, epochLength, nonDeterministicDeployment); bytes memory data = abi.encode(returnValue); claim.appContract = _newAppMockReturns(data); @@ -287,9 +327,12 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaimRevertApplicationForeclosed( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); // We make isForeclosed() return true claim.appContract = _newForeclosedAppMock(); @@ -307,9 +350,12 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaimRevertInvalidOutputsMerkleRootProofSize( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); claim.appContract = _newActiveAppMock(); @@ -326,9 +372,12 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti function testSubmitClaim( address authorityOwner, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IAuthority authority = _newAuthority(authorityOwner, epochLength); + IAuthority authority = _newAuthority( + authorityOwner, epochLength, nonDeterministicDeployment + ); claim.appContract = _newActiveAppMock(); @@ -555,11 +604,12 @@ contract AuthorityFactoryTest is Test, ERC165Test, OwnableTest, ConsensusTestUti } } - function _newAuthority(address authorityOwner, uint256 epochLength) - internal - returns (IAuthority) - { - if (vm.randomBool()) { + function _newAuthority( + address authorityOwner, + uint256 epochLength, + bool nonDeterministicDeployment + ) internal returns (IAuthority) { + if (nonDeterministicDeployment) { vm.assumeNoRevert(); return _factory.newAuthority(authorityOwner, epochLength); } else { diff --git a/test/consensus/quorum/QuorumFactory.t.sol b/test/consensus/quorum/QuorumFactory.t.sol index 6ce85821..530bd542 100644 --- a/test/consensus/quorum/QuorumFactory.t.sol +++ b/test/consensus/quorum/QuorumFactory.t.sol @@ -109,9 +109,10 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertsCallerIsNotValidator( address[] memory validators, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IQuorum quorum = _newQuorum(validators, epochLength); + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); claim.appContract = _newActiveAppMock(); @@ -128,11 +129,12 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertsNotEpochFinalBlock( address[] memory validators, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { uint256 lastProcessedBlockNumber = _randomNonEpochFinalBlock(epochLength); - IQuorum quorum = _newQuorum(validators, epochLength); + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); claim.appContract = _newActiveAppMock(); @@ -149,9 +151,10 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertNotPastBlock( address[] memory validators, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IQuorum quorum = _newQuorum(validators, epochLength); + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); claim.appContract = _newActiveAppMock(); @@ -168,9 +171,10 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertApplicationNotDeployed( address[] memory validators, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IQuorum quorum = _newQuorum(validators, epochLength); + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); // We use a random account with no code as app contract claim.appContract = _randomAccountWithNoCode(); @@ -188,10 +192,11 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertApplicationReverted( address[] memory validators, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim, bytes memory error ) external { - IQuorum quorum = _newQuorum(validators, epochLength); + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); // We make isForeclosed() revert with an error claim.appContract = _newAppMockReverts(error); @@ -209,13 +214,14 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertApplicationReturnIllSizedReturnData( address[] memory validators, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim, bytes memory data ) external { // We make isForeclosed() return ill-sized data vm.assume(data.length != 32); - IQuorum quorum = _newQuorum(validators, epochLength); + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); claim.appContract = _newAppMockReturns(data); @@ -232,12 +238,13 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertApplicationReturnIllFormedReturnData( address[] memory validators, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { // We make isForeclosed() return an invalid boolean (neither 0 or 1) uint256 returnValue = vm.randomUint(2, type(uint256).max); - IQuorum quorum = _newQuorum(validators, epochLength); + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); bytes memory data = abi.encode(returnValue); claim.appContract = _newAppMockReturns(data); @@ -255,9 +262,10 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertApplicationForeclosed( address[] memory validators, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IQuorum quorum = _newQuorum(validators, epochLength); + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); // We make isForeclosed() return true claim.appContract = _newForeclosedAppMock(); @@ -275,9 +283,10 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { function testSubmitClaimRevertInvalidOutputsMerkleRootProofSize( address[] memory validators, uint256 epochLength, + bool nonDeterministicDeployment, Claim memory claim ) external { - IQuorum quorum = _newQuorum(validators, epochLength); + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); claim.appContract = _newActiveAppMock(); @@ -291,8 +300,12 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { quorum.submitClaim(claim); } - function testSubmitClaim(address[] memory validators, uint256 epochLength) external { - IQuorum quorum = _newQuorum(validators, epochLength); + function testSubmitClaim( + address[] memory validators, + uint256 epochLength, + bool nonDeterministicDeployment + ) external { + IQuorum quorum = _newQuorum(validators, epochLength, nonDeterministicDeployment); address appContract = _newActiveAppMock(); @@ -875,11 +888,12 @@ contract QuorumFactoryTest is Test, ERC165Test, ConsensusTestUtils { } } - function _newQuorum(address[] memory validators, uint256 epochLength) - internal - returns (IQuorum) - { - if (vm.randomBool()) { + function _newQuorum( + address[] memory validators, + uint256 epochLength, + bool nonDeterministicDeployment + ) internal returns (IQuorum) { + if (nonDeterministicDeployment) { vm.assumeNoRevert(); return _factory.newQuorum(validators, epochLength); } else { diff --git a/test/library/LibBinaryMerkleTree.t.sol b/test/library/LibBinaryMerkleTree.t.sol index 34088ec8..f5ae97e0 100644 --- a/test/library/LibBinaryMerkleTree.t.sol +++ b/test/library/LibBinaryMerkleTree.t.sol @@ -211,8 +211,12 @@ contract LibBinaryMerkleTreeTest is Test { // First, we need to make sure that the data spans at least one full block. // Otherwise, we won't be able to replace a full block from it. + // We quit the test early but do not discard it so that we can at least + // cover the empty byte array. uint256 numOfFullDataBlocks = data.length >> log2DataBlockSize; - vm.assume(numOfFullDataBlocks > 0); + if (numOfFullDataBlocks == 0) { + return; + } // Then we compute the floor log2 number of full data blocks. // This will help us bound the log2 number of leaves and log2 replacement size. diff --git a/test/library/LibMath.t.sol b/test/library/LibMath.t.sol index d9f6d21a..a6d4ba5e 100644 --- a/test/library/LibMath.t.sol +++ b/test/library/LibMath.t.sol @@ -11,22 +11,20 @@ import {LibMath} from "src/library/LibMath.sol"; /// @title Alternative naive, gas-inefficient implementation of LibMath library LibNaiveMath { - function ctz(uint256 x) internal pure returns (uint256) { - uint256 n = 256; + function ctz(uint256 x) internal pure returns (uint256 n) { + n = 256; while (x != 0) { --n; x <<= 1; } - return n; } - function clz(uint256 x) internal pure returns (uint256) { - uint256 n = 256; + function clz(uint256 x) internal pure returns (uint256 n) { + n = 256; while (x != 0) { --n; x >>= 1; } - return n; } function ceilLog2(uint256 x) internal pure returns (uint256) { @@ -38,14 +36,12 @@ library LibNaiveMath { return 256; } - function floorLog2(uint256 x) internal pure returns (uint256) { - require(x > 0, "floorLog2(0) is undefined"); + function floorLog2(uint256 x) internal pure returns (uint256 ret) { for (uint256 i; i < 256; ++i) { if ((x >> i) == 1) { - return i; + ret = i; } } - revert("unexpected code path reached"); } } diff --git a/test/library/LibWithdrawalConfig.t.sol b/test/library/LibWithdrawalConfig.t.sol index 7c3b9f04..cd0eee18 100644 --- a/test/library/LibWithdrawalConfig.t.sol +++ b/test/library/LibWithdrawalConfig.t.sol @@ -28,8 +28,11 @@ contract LibWithdrawalConfigTest is WithdrawalConfigTestUtils { /// @notice This test ensures that `isValid` returns false /// for some withdrawal configurations. - function testIsValidFalse(WithdrawalConfig memory withdrawalConfig) external view { - _makeWithdrawalConfigInvalidInPlace(withdrawalConfig); + function testIsValidFalse(WithdrawalConfig memory withdrawalConfig, uint256 seed) + external + pure + { + _makeWithdrawalConfigInvalidInPlace(withdrawalConfig, seed); assertFalse(LibWithdrawalConfig.isValid(withdrawalConfig)); } } diff --git a/test/portals/ERC20Portal.t.sol b/test/portals/ERC20Portal.t.sol index d0ffcbe1..3c35b8e0 100644 --- a/test/portals/ERC20Portal.t.sol +++ b/test/portals/ERC20Portal.t.sol @@ -116,6 +116,47 @@ contract ERC20PortalTest is Test, InputBoxTestUtils { _portal.depositERC20Tokens(_token, appContract, value, execLayerData); } + function testDepositRevertERC20TokenReverts( + uint256 value, + bytes calldata execLayerData, + bytes calldata errorData + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newActiveAppMock(); + + _randomSetup(sender, appContract, value); + + vm.mockCallRevert( + address(_token), + abi.encodeCall(IERC20.transferFrom, (sender, appContract, value)), + errorData + ); + + vm.prank(sender); + vm.expectRevert(errorData); + _portal.depositERC20Tokens(_token, appContract, value, execLayerData); + } + + function testDepositRevertERC20TokenReturnsFalse( + uint256 value, + bytes calldata execLayerData + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newActiveAppMock(); + + _randomSetup(sender, appContract, value); + + vm.mockCall( + address(_token), + abi.encodeCall(IERC20.transferFrom, (sender, appContract, value)), + abi.encode(false) + ); + + vm.prank(sender); + vm.expectRevert(IERC20Portal.ERC20TransferFailed.selector); + _portal.depositERC20Tokens(_token, appContract, value, execLayerData); + } + function testDeposit( uint256 value, bytes calldata execLayerData, diff --git a/test/portals/EtherPortal.t.sol b/test/portals/EtherPortal.t.sol index 8f090c65..4a952f2c 100644 --- a/test/portals/EtherPortal.t.sol +++ b/test/portals/EtherPortal.t.sol @@ -106,6 +106,23 @@ contract EtherPortalTest is Test, InputBoxTestUtils { _portal.depositEther{value: value}(appContract, execLayerData); } + function testDepositRevertCallReverts( + uint256 value, + bytes calldata execLayerData, + bytes calldata errorData + ) external { + address sender = _randomAccountWithNoCode(); + address appContract = _newActiveAppMock(); + + _randomSetup(sender, appContract, value); + + vm.mockCallRevert(appContract, value, new bytes(0), errorData); + + vm.prank(sender); + vm.expectRevert(IEtherPortal.EtherTransferFailed.selector); + _portal.depositEther{value: value}(appContract, execLayerData); + } + function testDeposit( uint256 value, bytes calldata execLayerData, diff --git a/test/util/ApplicationCheckerTestUtils.sol b/test/util/ApplicationCheckerTestUtils.sol index db2e8c9d..eded3361 100644 --- a/test/util/ApplicationCheckerTestUtils.sol +++ b/test/util/ApplicationCheckerTestUtils.sol @@ -58,18 +58,22 @@ contract ApplicationCheckerTestUtils is Test { return account; } - function _newAppMockReturns(bytes memory data) internal returns (address) { - address appContract = _randomAccountWithNoCode(); + function _newAppMockReturns(bytes memory data) + internal + returns (address appContract) + { + appContract = _randomAccountWithNoCode(); vm.mockCall(appContract, _encodeIsForeclosed(), data); assertGt(appContract.code.length, 0); - return appContract; } - function _newAppMockReverts(bytes memory error) internal returns (address) { - address appContract = _randomAccountWithNoCode(); + function _newAppMockReverts(bytes memory error) + internal + returns (address appContract) + { + appContract = _randomAccountWithNoCode(); vm.mockCallRevert(appContract, _encodeIsForeclosed(), error); assertGt(appContract.code.length, 0); - return appContract; } function _newForeclosedAppMock() internal returns (address) { diff --git a/test/util/ConsensusTestUtils.sol b/test/util/ConsensusTestUtils.sol index 0aad9b2e..084d2490 100644 --- a/test/util/ConsensusTestUtils.sol +++ b/test/util/ConsensusTestUtils.sol @@ -26,16 +26,6 @@ contract ConsensusTestUtils is ApplicationCheckerTestUtils { ); } - function _encodeNotFirstClaim(address appContract, uint256 lastProcessedBlockNumber) - internal - pure - returns (bytes memory) - { - return abi.encodeWithSelector( - IConsensus.NotFirstClaim.selector, appContract, lastProcessedBlockNumber - ); - } - function _encodeNotEpochFinalBlock( uint256 lastProcessedBlockNumber, uint256 epochLength @@ -160,12 +150,11 @@ contract ConsensusTestUtils is ApplicationCheckerTestUtils { } } - function _randomInvalidLeafProofSize() internal returns (uint256 proofSize) { - while (true) { - proofSize = vm.randomUint(0, 2 * Memory.LOG2_MAX_SIZE + 1); - if (proofSize != Memory.LOG2_MAX_SIZE) { - break; - } + function _randomInvalidLeafProofSize() internal returns (uint256) { + if (vm.randomUint() % 2 == 0) { + return vm.randomUint(0, Memory.LOG2_MAX_SIZE - 1); + } else { + return vm.randomUint(Memory.LOG2_MAX_SIZE + 1, 2 * Memory.LOG2_MAX_SIZE); } } } diff --git a/test/util/InputBoxTestUtils.sol b/test/util/InputBoxTestUtils.sol index 3c028d33..34950ee9 100644 --- a/test/util/InputBoxTestUtils.sol +++ b/test/util/InputBoxTestUtils.sol @@ -31,23 +31,32 @@ contract InputBoxTestUtils is ApplicationCheckerTestUtils { address appContract, address sender, uint256 index - ) internal view returns (bytes memory) { + ) internal view returns (bytes memory payloadArg) { (bytes4 inputSelector, bytes memory inputArgs) = input.consumeBytes4(); assertEq(inputSelector, Inputs.EvmAdvance.selector); + uint256 chainIdArg; + address appContractArg; + address msgSenderArg; + uint256 blockNumberArg; + uint256 blockTimestampArg; + uint256 prevRandaoArg; + uint256 indexArg; + ( - uint256 chainIdArg, - address appContractArg, - address msgSenderArg, - uint256 blockNumberArg, - uint256 blockTimestampArg, - uint256 prevRandaoArg, - uint256 indexArg, - bytes memory payloadArg - ) = abi.decode( - inputArgs, - (uint256, address, address, uint256, uint256, uint256, uint256, bytes) - ); + chainIdArg, + appContractArg, + msgSenderArg, + blockNumberArg, + blockTimestampArg, + prevRandaoArg, + indexArg, + payloadArg + ) = + abi.decode( + inputArgs, + (uint256, address, address, uint256, uint256, uint256, uint256, bytes) + ); assertEq(chainIdArg, block.chainid); assertEq(appContractArg, appContract); @@ -56,8 +65,6 @@ contract InputBoxTestUtils is ApplicationCheckerTestUtils { assertEq(blockTimestampArg, vm.getBlockTimestamp()); assertEq(prevRandaoArg, block.prevrandao); assertEq(indexArg, index); - - return payloadArg; } function _decodeInputAdded( diff --git a/test/util/LibBinaryMerkleTreeHelper.sol b/test/util/LibBinaryMerkleTreeHelper.sol index c39cd26d..2fbaccf7 100644 --- a/test/util/LibBinaryMerkleTreeHelper.sol +++ b/test/util/LibBinaryMerkleTreeHelper.sol @@ -39,7 +39,7 @@ library LibBinaryMerkleTreeHelper { /// @param nodeIndex The index of the node /// @param height The height of the Merkle tree /// @param nodeFromChildren The function that computes nodes from their children - /// @return The siblings of the node in bottom-up order + /// @return sibs The siblings of the node in bottom-up order /// @dev Raises an `InvalidNodeIndex` error if the provided index is out of bounds. /// @dev Raises an `InvalidHeight` error if more than `2^height` nodes are provided. function siblings( @@ -48,8 +48,8 @@ library LibBinaryMerkleTreeHelper { uint256 nodeIndex, uint256 height, function(bytes32, bytes32) pure returns (bytes32) nodeFromChildren - ) internal pure returns (bytes32[] memory) { - bytes32[] memory sibs = new bytes32[](height); + ) internal pure returns (bytes32[] memory sibs) { + sibs = new bytes32[](height); for (uint256 i; i < height; ++i) { sibs[i] = nodes.at(nodeIndex ^ 1, defaultNode); nodes = nodes.parentLevel(defaultNode, nodeFromChildren); @@ -58,7 +58,6 @@ library LibBinaryMerkleTreeHelper { } require(nodeIndex == 0, InvalidNodeIndex()); require(nodes.length <= 1, InvalidHeight()); - return sibs; } /// @notice Compute the parent level of an array of nodes. diff --git a/test/util/LibUint256Array.sol b/test/util/LibUint256Array.sol index 13659e84..9b584a5b 100644 --- a/test/util/LibUint256Array.sol +++ b/test/util/LibUint256Array.sol @@ -123,13 +123,11 @@ library LibUint256Array { { if (subArrayLength == 0) { isEmpty = true; - maxElem = 0; } else { require( subArrayLength <= array.length, InvalidSubArrayLength(subArrayLength, array.length) ); - isEmpty = false; maxElem = array[0]; for (uint256 i = 1; i < subArrayLength; ++i) { if (array[i] > maxElem) { diff --git a/test/util/WithdrawalConfigTestUtils.sol b/test/util/WithdrawalConfigTestUtils.sol index ac3a1846..599848f5 100644 --- a/test/util/WithdrawalConfigTestUtils.sol +++ b/test/util/WithdrawalConfigTestUtils.sol @@ -45,11 +45,11 @@ abstract contract WithdrawalConfigTestUtils is Test { // forge-lint: disable-end(incorrect-shift) } - function _makeWithdrawalConfigInvalidInPlace(WithdrawalConfig memory withdrawalConfig) - internal - view - { - if (vm.randomBool()) { + function _makeWithdrawalConfigInvalidInPlace( + WithdrawalConfig memory withdrawalConfig, + uint256 seed + ) internal pure { + if (seed % 2 == 0) { if ( withdrawalConfig.log2MaxNumOfAccounts <= CanonicalMachine.LOG2_MEMORY_SIZE From 86108276b38b0450cf7655c0e024c90dd568a5e1 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Thu, 12 Mar 2026 17:05:56 -0300 Subject: [PATCH 47/48] Modularize account encoding/decoding to lib --- src/library/LibUsdAccount.sol | 45 ++++++++++++ src/withdrawal/UsdWithdrawalOutputBuilder.sol | 15 +--- test/library/LibUsdAccount.t.sol | 72 +++++++++++++++++++ 3 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 src/library/LibUsdAccount.sol create mode 100644 test/library/LibUsdAccount.t.sol diff --git a/src/library/LibUsdAccount.sol b/src/library/LibUsdAccount.sol new file mode 100644 index 00000000..40b36305 --- /dev/null +++ b/src/library/LibUsdAccount.sol @@ -0,0 +1,45 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +library LibUsdAccount { + /// @notice Decode an account. + /// @param account The account + /// @return user The user address + /// @return balance The user balance + /// @dev Reverts if account is less than 28 bytes long. + function decode(bytes calldata account) + internal + pure + returns (address user, uint64 balance) + { + require(account.length >= 28, "Account is too short"); + + user = address(uint160(bytes20(account[8:28]))); + + for (uint256 i; i < 8; ++i) { + balance |= uint64(uint256(uint8(account[i])) << (8 * i)); + } + } + + /// @notice Encode an account. + /// @param user The user address + /// @param balance The user balance + /// @return account The account + function encode(address user, uint64 balance) + internal + pure + returns (bytes memory account) + { + account = new bytes(28); + + for (uint256 i; i < 8; ++i) { + account[i] = bytes1(uint8((balance >> (8 * i)) & 0xff)); + } + + for (uint256 i; i < 20; ++i) { + account[i + 8] = bytes1((bytes20(user) << (8 * i)) & bytes1(0xff)); + } + } +} diff --git a/src/withdrawal/UsdWithdrawalOutputBuilder.sol b/src/withdrawal/UsdWithdrawalOutputBuilder.sol index 10c6f575..50f80d59 100644 --- a/src/withdrawal/UsdWithdrawalOutputBuilder.sol +++ b/src/withdrawal/UsdWithdrawalOutputBuilder.sol @@ -7,6 +7,7 @@ import {IERC20} from "@openzeppelin-contracts-5.2.0/token/ERC20/IERC20.sol"; import {Outputs} from "../common/Outputs.sol"; import {ISafeERC20Transfer} from "../delegatecall/ISafeERC20Transfer.sol"; +import {LibUsdAccount} from "../library/LibUsdAccount.sol"; import {IWithdrawalOutputBuilder} from "./IWithdrawalOutputBuilder.sol"; contract UsdWithdrawalOutputBuilder is IWithdrawalOutputBuilder { @@ -24,24 +25,12 @@ contract UsdWithdrawalOutputBuilder is IWithdrawalOutputBuilder { override returns (bytes memory output) { - (address user, uint256 balance) = _decodeAccount(account); + (address user, uint256 balance) = LibUsdAccount.decode(account); address destination = address(SAFE_ERC20_TRANSFER); bytes memory payload = _encodeSafeTransferPayload(user, balance); return _encodeDelegateCallVoucher(destination, payload); } - function _decodeAccount(bytes calldata account) - internal - pure - returns (address user, uint256 balance) - { - require(account.length >= 28, "Account is too short"); - user = address(uint160(bytes20(account[8:28]))); - for (uint256 i; i < 8; ++i) { - balance |= (uint256(uint8(account[i])) << (8 * i)); - } - } - function _encodeSafeTransferPayload(address user, uint256 value) internal view diff --git a/test/library/LibUsdAccount.t.sol b/test/library/LibUsdAccount.t.sol new file mode 100644 index 00000000..5b8cb1bf --- /dev/null +++ b/test/library/LibUsdAccount.t.sol @@ -0,0 +1,72 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {LibUsdAccount} from "src/library/LibUsdAccount.sol"; + +library ExternalLibUsdAccount { + /// @notice Tail-calls LibUsdAccount.decode. + /// @dev Used to test errors raised by such function. + function decode(bytes calldata account) + external + pure + returns (address user, uint64 balance) + { + (user, balance) = LibUsdAccount.decode(account); + } +} + +contract LibUsdAccountTest is Test { + function testEncodeDecode(address user, uint64 balance) external pure { + bytes memory account = LibUsdAccount.encode(user, balance); + assertEq(account.length, 28, "account length"); + (address user2, uint64 balance2) = ExternalLibUsdAccount.decode(account); + assertEq(user, user2, "account user"); + assertEq(balance, balance2, "account balance"); + } + + function testDecode(bytes28 seed, bytes calldata padding) external pure { + bytes memory account = abi.encodePacked(seed, padding); + ExternalLibUsdAccount.decode(account); + } + + function testDecodeRevertsAccountIsTooShort(uint256) external { + bytes memory account = vm.randomBytes(vm.randomUint(0, 27)); + vm.expectRevert("Account is too short"); + ExternalLibUsdAccount.decode(account); + } + + function testEncodeExample() external pure { + address user = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + uint64 balance = 0x0123456789abcdef; + assertEq( + LibUsdAccount.encode(user, balance), + hex"efcdab8967452301f39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "example account" + ); + } + + function testDecodeExample() external pure { + bytes memory account = + hex"efcdab8967452301f39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + (address user, uint64 balance) = ExternalLibUsdAccount.decode(account); + assertEq(user, 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, "user"); + assertEq(balance, 0x0123456789abcdef, "balance"); + } + + function testEncodeZero() external pure { + assertEq( + LibUsdAccount.encode(address(0), uint64(0)), new bytes(28), "zero account" + ); + } + + function testDecodeZero() external pure { + bytes memory account = new bytes(28); + (address user, uint64 balance) = ExternalLibUsdAccount.decode(account); + assertEq(user, address(0), "user"); + assertEq(balance, uint64(0), "balance"); + } +} From 0fc4fbca98479835a5cf120ba3f50be4e4039f3c Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Fri, 13 Mar 2026 13:09:48 -0300 Subject: [PATCH 48/48] Redeclare emulator constants in CanonicalMachine --- src/common/CanonicalMachine.sol | 13 ++++++++++++- src/consensus/AbstractConsensus.sol | 11 +++-------- test/util/ConsensusTestUtils.sol | 14 ++++++++------ test/util/LibClaim.sol | 8 ++------ test/util/WithdrawalConfigTestUtils.sol | 10 ++++------ 5 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/common/CanonicalMachine.sol b/src/common/CanonicalMachine.sol index 46970f17..0affd571 100644 --- a/src/common/CanonicalMachine.sol +++ b/src/common/CanonicalMachine.sol @@ -3,6 +3,11 @@ pragma solidity ^0.8.8; +import { + EmulatorConstants +} from "cartesi-machine-solidity-step-0.13.0/src/EmulatorConstants.sol"; +import {Memory} from "cartesi-machine-solidity-step-0.13.0/src/Memory.sol"; + /// @title Canonical Machine Constants Library /// /// @notice Defines several constants related to the reference implementation @@ -18,5 +23,11 @@ library CanonicalMachine { uint8 constant LOG2_MAX_OUTPUTS = 63; /// @notice Log2 of data block size. - uint8 constant LOG2_DATA_BLOCK_SIZE = 5; + uint8 constant LOG2_DATA_BLOCK_SIZE = Memory.LOG2_LEAF; + + /// @notice Log2 of memory tree height. + uint8 constant MEMORY_TREE_HEIGHT = LOG2_MEMORY_SIZE - LOG2_DATA_BLOCK_SIZE; + + /// @notice TX buffer start. + uint64 constant TX_BUFFER_START = EmulatorConstants.PMA_CMIO_TX_BUFFER_START; } diff --git a/src/consensus/AbstractConsensus.sol b/src/consensus/AbstractConsensus.sol index 7f55171c..48f00401 100644 --- a/src/consensus/AbstractConsensus.sol +++ b/src/consensus/AbstractConsensus.sol @@ -6,11 +6,7 @@ pragma solidity ^0.8.26; import {ERC165} from "@openzeppelin-contracts-5.2.0/utils/introspection/ERC165.sol"; import {IERC165} from "@openzeppelin-contracts-5.2.0/utils/introspection/IERC165.sol"; -import { - EmulatorConstants -} from "cartesi-machine-solidity-step-0.13.0/src/EmulatorConstants.sol"; -import {Memory} from "cartesi-machine-solidity-step-0.13.0/src/Memory.sol"; - +import {CanonicalMachine} from "../common/CanonicalMachine.sol"; import {ApplicationChecker} from "../dapp/ApplicationChecker.sol"; import {LibBinaryMerkleTree} from "../library/LibBinaryMerkleTree.sol"; import {LibKeccak256} from "../library/LibKeccak256.sol"; @@ -175,10 +171,9 @@ abstract contract AbstractConsensus is IConsensus, ERC165, ApplicationChecker { bytes32 outputsMerkleRoot, bytes32[] calldata proof ) internal pure returns (bytes32 machineMerkleRoot) { - _checkProofSize(proof.length, Memory.LOG2_MAX_SIZE); + _checkProofSize(proof.length, CanonicalMachine.MEMORY_TREE_HEIGHT); machineMerkleRoot = proof.merkleRootAfterReplacement( - EmulatorConstants.PMA_CMIO_TX_BUFFER_START - >> EmulatorConstants.TREE_LOG2_WORD_SIZE, + CanonicalMachine.TX_BUFFER_START >> CanonicalMachine.LOG2_DATA_BLOCK_SIZE, keccak256(abi.encode(outputsMerkleRoot)), LibKeccak256.hashPair ); diff --git a/test/util/ConsensusTestUtils.sol b/test/util/ConsensusTestUtils.sol index 084d2490..aae7460e 100644 --- a/test/util/ConsensusTestUtils.sol +++ b/test/util/ConsensusTestUtils.sol @@ -3,8 +3,7 @@ pragma solidity ^0.8.22; -import {Memory} from "cartesi-machine-solidity-step-0.13.0/src/Memory.sol"; - +import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; import {IConsensus} from "src/consensus/IConsensus.sol"; import {ApplicationCheckerTestUtils} from "./ApplicationCheckerTestUtils.sol"; @@ -43,7 +42,7 @@ contract ConsensusTestUtils is ApplicationCheckerTestUtils { return abi.encodeWithSelector( IConsensus.InvalidOutputsMerkleRootProofSize.selector, proofSize, - Memory.LOG2_MAX_SIZE + CanonicalMachine.MEMORY_TREE_HEIGHT ); } @@ -131,7 +130,7 @@ contract ConsensusTestUtils is ApplicationCheckerTestUtils { } function _randomLeafProof() internal returns (bytes32[] memory proof) { - return _randomProof(Memory.LOG2_MAX_SIZE); + return _randomProof(CanonicalMachine.MEMORY_TREE_HEIGHT); } function _randomClaimDifferentFrom(Claim memory claim, bytes32 machineMerkleRoot) @@ -152,9 +151,12 @@ contract ConsensusTestUtils is ApplicationCheckerTestUtils { function _randomInvalidLeafProofSize() internal returns (uint256) { if (vm.randomUint() % 2 == 0) { - return vm.randomUint(0, Memory.LOG2_MAX_SIZE - 1); + return vm.randomUint(0, CanonicalMachine.MEMORY_TREE_HEIGHT - 1); } else { - return vm.randomUint(Memory.LOG2_MAX_SIZE + 1, 2 * Memory.LOG2_MAX_SIZE); + return vm.randomUint( + CanonicalMachine.MEMORY_TREE_HEIGHT + 1, + 2 * CanonicalMachine.MEMORY_TREE_HEIGHT + ); } } } diff --git a/test/util/LibClaim.sol b/test/util/LibClaim.sol index c1779368..58de4f98 100644 --- a/test/util/LibClaim.sol +++ b/test/util/LibClaim.sol @@ -3,10 +3,7 @@ pragma solidity ^0.8.22; -import { - EmulatorConstants -} from "cartesi-machine-solidity-step-0.13.0/src/EmulatorConstants.sol"; - +import {CanonicalMachine} from "src/common/CanonicalMachine.sol"; import {LibBinaryMerkleTree} from "src/library/LibBinaryMerkleTree.sol"; import {LibKeccak256} from "src/library/LibKeccak256.sol"; @@ -22,8 +19,7 @@ library LibClaim { { machineMerkleRoot = claim.proof .merkleRootAfterReplacement( - EmulatorConstants.PMA_CMIO_TX_BUFFER_START - >> EmulatorConstants.TREE_LOG2_WORD_SIZE, + CanonicalMachine.TX_BUFFER_START >> CanonicalMachine.LOG2_DATA_BLOCK_SIZE, keccak256(abi.encode(claim.outputsMerkleRoot)), LibKeccak256.hashPair ); diff --git a/test/util/WithdrawalConfigTestUtils.sol b/test/util/WithdrawalConfigTestUtils.sol index 599848f5..a141aa48 100644 --- a/test/util/WithdrawalConfigTestUtils.sol +++ b/test/util/WithdrawalConfigTestUtils.sol @@ -17,7 +17,7 @@ abstract contract WithdrawalConfigTestUtils is Test { bound( withdrawalConfig.log2LeavesPerAccount, 0, - CanonicalMachine.LOG2_MEMORY_SIZE - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + CanonicalMachine.MEMORY_TREE_HEIGHT ) ); @@ -25,7 +25,7 @@ abstract contract WithdrawalConfigTestUtils is Test { bound( withdrawalConfig.log2MaxNumOfAccounts, 0, - CanonicalMachine.LOG2_MEMORY_SIZE - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + CanonicalMachine.MEMORY_TREE_HEIGHT - withdrawalConfig.log2LeavesPerAccount ) ); @@ -52,14 +52,12 @@ abstract contract WithdrawalConfigTestUtils is Test { if (seed % 2 == 0) { if ( withdrawalConfig.log2MaxNumOfAccounts - <= CanonicalMachine.LOG2_MEMORY_SIZE - - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + <= CanonicalMachine.MEMORY_TREE_HEIGHT ) { withdrawalConfig.log2LeavesPerAccount = uint8( bound( withdrawalConfig.log2LeavesPerAccount, - CanonicalMachine.LOG2_MEMORY_SIZE - - CanonicalMachine.LOG2_DATA_BLOCK_SIZE + CanonicalMachine.MEMORY_TREE_HEIGHT - withdrawalConfig.log2MaxNumOfAccounts + 1, type(uint8).max )