From a8e8a695ddac5036742a104ef2ec0bfcce6faa39 Mon Sep 17 00:00:00 2001 From: makemake Date: Mon, 27 Jul 2026 13:25:46 +0200 Subject: [PATCH 1/2] test(safe): add guard compatibility matrix --- .github/workflows/solidity-test.yml | 5 +- .gitmodules | 9 + examples/README.md | 2 +- examples/safe-guard/README.md | 34 +- .../CredibleSafeGuardSafeIntegration.t.sol | 200 ---------- foundry.toml | 14 +- lib/safe-smart-account-1.3.0 | 1 + lib/safe-smart-account-1.4.0 | 1 + lib/safe-smart-account-1.5.0 | 1 + src/protection/safe/README.md | 45 +++ .../CredibleSafeGuardSafeIntegration.t.sol | 366 ++++++++++++++++++ .../CredibleSafeGuardScripts.t.sol | 17 +- .../mocks}/CredibleRegistryMock.sol | 0 13 files changed, 461 insertions(+), 234 deletions(-) delete mode 100644 examples/safe-guard/test/CredibleSafeGuardSafeIntegration.t.sol create mode 160000 lib/safe-smart-account-1.3.0 create mode 160000 lib/safe-smart-account-1.4.0 create mode 160000 lib/safe-smart-account-1.5.0 create mode 100644 test/protection/safe/integration/CredibleSafeGuardSafeIntegration.t.sol rename {examples/safe-guard/test => test/protection/safe/integration}/CredibleSafeGuardScripts.t.sol (94%) rename {examples/safe-guard/src => test/protection/safe/integration/mocks}/CredibleRegistryMock.sol (100%) diff --git a/.github/workflows/solidity-test.yml b/.github/workflows/solidity-test.yml index bdc5ef7..7900740 100644 --- a/.github/workflows/solidity-test.yml +++ b/.github/workflows/solidity-test.yml @@ -39,8 +39,9 @@ jobs: # Example assertion suites live under their own foundry profiles (examples/) and also use the # Phorge-only `cl.assertion` cheatcode, so they run under `pcl test` here. Add a profile to the # matrix when its example is ready to be gated on every PR. - # `safe-guard` is a plain-forge integration suite (real Gnosis Safe + CredibleSafeGuard) on its own - # profile because the Safe contracts need the legacy pipeline; pcl runs plain forge tests too. + # `safe-guard` is a plain-forge integration suite under test/protection/safe/integration (real + # Gnosis Safe + CredibleSafeGuard) on its own profile because the Safe contracts need the legacy + # pipeline; pcl runs plain forge tests too. examples-pcl: runs-on: ubuntu-latest strategy: diff --git a/.gitmodules b/.gitmodules index e7301a8..e8e8244 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,12 @@ [submodule "lib/safe-smart-account"] path = lib/safe-smart-account url = https://github.com/safe-global/safe-smart-account +[submodule "lib/safe-smart-account-1.3.0"] + path = lib/safe-smart-account-1.3.0 + url = https://github.com/safe-global/safe-smart-account +[submodule "lib/safe-smart-account-1.4.0"] + path = lib/safe-smart-account-1.4.0 + url = https://github.com/safe-global/safe-smart-account +[submodule "lib/safe-smart-account-1.5.0"] + path = lib/safe-smart-account-1.5.0 + url = https://github.com/safe-global/safe-smart-account diff --git a/examples/README.md b/examples/README.md index 0b4ee73..4104005 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,7 +29,7 @@ FOUNDRY_PROFILE= forge build | `nado/` | `ink/assertions` | Nado perpetual clearinghouse | | `royco/` | `royco-dawn` | Royco kernel accounting and version-neutral tranche state effects | | `safe/` | `safe-protection-suite` | Safe config lock + tx-shape assertions | -| `safe-guard/` | `safe-guard` | Real Safe integration for `CredibleSafeGuard` | +| `safe-guard/` | `safe-guard` | Deployment and Safe Transaction Builder tooling for `CredibleSafeGuard` | | `spark/` | `spark` | Spark Vault previews/take accounting and risk-increasing SparkLend oracle guard | | `symbiotic/` | `symbiotic` | Symbiotic V1 deposit/withdraw/claim/slash accounting; relay and noncausal circuit policy quarantined | | `tydro/` | `ink/assertions` | Tydro Aave-v3-like operation safety on Ink | diff --git a/examples/safe-guard/README.md b/examples/safe-guard/README.md index fdb12d6..3952018 100644 --- a/examples/safe-guard/README.md +++ b/examples/safe-guard/README.md @@ -1,23 +1,15 @@ -# Credible Safe Guard integration +# Credible Safe Guard operations -Real end-to-end tests for `src/protection/safe/CredibleSafeGuard.sol`, run against an actual Gnosis Safe (v1.4.1). +Deployment and Safe Transaction Builder tooling for +`src/protection/safe/CredibleSafeGuard.sol`. -The guard contract and its unit tests live in the root project: +The guard contract, compatibility matrix, and tests live with the Safe protection package: - `src/protection/safe/CredibleSafeGuard.sol` - `src/protection/safe/ICredibleRegistry.sol` -- `test/protection/safe/CredibleSafeGuard.t.sol` (unit tests) - -These integration tests live under their own profile because the Safe contracts compile with the legacy pipeline (`optimizer = true`, no `via_ir`), while the root profiles use `via_ir`. - -## What it covers - -- Installing the guard on a real Safe via a signed `execTransaction` → `setGuard`, which exercises Safe's real `GS300` ERC-165 check against the guard. -- A signed Safe transaction executing in a credible block. -- A signed Safe transaction reverting with `NonCredibleBlock` in a non-credible block while the builder set is live. -- The fail-open path: a signed Safe transaction executing once the builder set has been silent past the configured window. -- Registry-unavailability fail-open paths, including reverted and malformed registry responses. -- An end-to-end builder stall-then-recover sequence. +- `src/protection/safe/README.md` +- `test/protection/safe/CredibleSafeGuard.t.sol` +- `test/protection/safe/integration/` ## Deploy the guard @@ -88,7 +80,15 @@ Generated files under `safe-guard-output/` are ignored by Git. Always verify the ## Run tests ```sh -FOUNDRY_PROFILE=safe-guard forge test +FOUNDRY_PROFILE=safe-guard pcl test +``` + +For a focused compatibility-matrix run during development: + +```sh +FOUNDRY_PROFILE=safe-guard forge test \ + --match-path test/protection/safe/integration/CredibleSafeGuardSafeIntegration.t.sol ``` -The Safe contracts come from the `safe-smart-account` submodule under `lib/`, so initialize submodules first (`git submodule update --init --recursive`). +The Safe contracts come from pinned `safe-smart-account` submodules under `lib/`, so initialize +submodules first (`git submodule update --init --recursive`). diff --git a/examples/safe-guard/test/CredibleSafeGuardSafeIntegration.t.sol b/examples/safe-guard/test/CredibleSafeGuardSafeIntegration.t.sol deleted file mode 100644 index e95fd3f..0000000 --- a/examples/safe-guard/test/CredibleSafeGuardSafeIntegration.t.sol +++ /dev/null @@ -1,200 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.13; - -import {Test} from "forge-std/Test.sol"; - -import {Safe} from "../../../lib/safe-smart-account/contracts/Safe.sol"; -import {SafeProxyFactory} from "../../../lib/safe-smart-account/contracts/proxies/SafeProxyFactory.sol"; -import {Enum} from "../../../lib/safe-smart-account/contracts/common/Enum.sol"; - -import {CredibleSafeGuard} from "credible-std/protection/safe/CredibleSafeGuard.sol"; -import {CredibleRegistryMock} from "../src/CredibleRegistryMock.sol"; - -/// @notice End-to-end tests that install {CredibleSafeGuard} on a real Gnosis Safe (v1.4.1) and -/// drive real, owner-signed `execTransaction` calls through the credible, non-credible, -/// and fail-open paths. Installing the guard via `setGuard` also exercises Safe's real -/// `GS300` ERC-165 check against {CredibleSafeGuard.supportsInterface}. -contract CredibleSafeGuardSafeIntegrationTest is Test { - /// @dev Safe stores the transaction guard at keccak256("guard_manager.guard.address"). - bytes32 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - - uint256 internal constant THRESHOLD = 75; - uint256 internal constant BASE_BLOCK = 1_000_000; - address internal constant PROTOCOL_MANAGER = address(0xA11CE); - - uint256 internal ownerPk = uint256(keccak256("safe.owner")); - address internal owner; - - CredibleRegistryMock internal registry; - CredibleSafeGuard internal guard; - Safe internal safe; - - function setUp() public { - owner = vm.addr(ownerPk); - - registry = new CredibleRegistryMock(); - guard = new CredibleSafeGuard(registry, THRESHOLD, PROTOCOL_MANAGER); - - Safe singleton = new Safe(); - SafeProxyFactory factory = new SafeProxyFactory(); - - address[] memory owners = new address[](1); - owners[0] = owner; - - bytes memory initializer = - abi.encodeCall(Safe.setup, (owners, 1, address(0), "", address(0), address(0), 0, payable(address(0)))); - safe = Safe(payable(address(factory.createProxyWithNonce(address(singleton), initializer, 0)))); - - vm.roll(BASE_BLOCK); - - // `setGuard` is `authorized` (callable only by the Safe itself), so route it through a - // signed `execTransaction`. The guard is not yet active for this call, and Safe runs its - // real ERC-165 check on our guard while installing it (reverting GS300 if it were wrong). - bytes memory setGuardData = abi.encodeWithSignature("setGuard(address)", address(guard)); - bytes memory sig = _signTx(address(safe), 0, setGuardData); - safe.execTransaction( - address(safe), 0, setGuardData, Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)), sig - ); - - assertEq(_installedGuard(), address(guard), "guard not installed"); - } - - function test_realSafe_guardInstalledViaErc165Check() public view { - // setUp would have reverted with GS300 if supportsInterface were wrong. - assertEq(_installedGuard(), address(guard)); - } - - function test_realSafe_executesInCredibleBlock() public { - registry.markCurrentBlockCredible(); - - uint256 nonceBefore = safe.nonce(); - bool ok = _execSafeTx(owner, 0, ""); - - assertTrue(ok); - assertEq(safe.nonce(), nonceBefore + 1); - } - - function test_realSafe_revertsInNonCredibleBlockWithinWindow() public { - // Last credible block is one behind: builder set still considered live. - registry.setLastCredibleBlock(block.number - 1); - - uint256 nonceBefore = safe.nonce(); - bytes memory sig = _signTx(owner, 0, ""); - - vm.expectRevert(CredibleSafeGuard.NonCredibleBlock.selector); - safe.execTransaction(owner, 0, "", Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)), sig); - - // A reverted Safe transaction does not consume the nonce. - assertEq(safe.nonce(), nonceBefore); - } - - function test_realSafe_failsOpenWhenBuilderOffline() public { - // No credible block within the configured window: fail open and let the Safe transact. - registry.setLastCredibleBlock(block.number - (THRESHOLD + 1)); - - uint256 nonceBefore = safe.nonce(); - bool ok = _execSafeTx(owner, 0, ""); - - assertTrue(ok); - assertEq(safe.nonce(), nonceBefore + 1); - } - - function test_realSafe_failsOpenWhenCredibilityReadReverts() public { - vm.mockCallRevert( - address(registry), abi.encodeWithSignature("isCredibleBlock(uint256)", block.number), "registry unavailable" - ); - - uint256 nonceBefore = safe.nonce(); - assertTrue(_execSafeTx(owner, 0, "")); - assertEq(safe.nonce(), nonceBefore + 1); - } - - function test_realSafe_canRemoveGuardWhenRegistryReadReverts() public { - vm.mockCallRevert( - address(registry), abi.encodeWithSignature("isCredibleBlock(uint256)", block.number), "registry unavailable" - ); - - bytes memory setGuardData = abi.encodeWithSignature("setGuard(address)", address(0)); - bytes memory sig = _signTx(address(safe), 0, setGuardData); - uint256 nonceBefore = safe.nonce(); - - assertTrue( - safe.execTransaction( - address(safe), 0, setGuardData, Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)), sig - ) - ); - assertEq(safe.nonce(), nonceBefore + 1); - assertEq(_installedGuard(), address(0)); - } - - function test_realSafe_failsOpenWhenCredibilityReadIsMalformed() public { - vm.mockCall( - address(registry), abi.encodeWithSignature("isCredibleBlock(uint256)", block.number), abi.encode(uint256(2)) - ); - - uint256 nonceBefore = safe.nonce(); - assertTrue(_execSafeTx(owner, 0, "")); - assertEq(safe.nonce(), nonceBefore + 1); - } - - function test_realSafe_failsOpenWhenLastBlockReadReverts() public { - vm.mockCallRevert(address(registry), abi.encodeWithSignature("lastCredibleBlock()"), "registry unavailable"); - - uint256 nonceBefore = safe.nonce(); - assertTrue(_execSafeTx(owner, 0, "")); - assertEq(safe.nonce(), nonceBefore + 1); - } - - function test_realSafe_failsOpenWhenLastBlockReportedInFuture() public { - // A broken registry reporting a last credible block beyond the chain head must not brick the - // Safe: it is treated as an unreadable response and fails open. - registry.setLastCredibleBlock(type(uint256).max); - - uint256 nonceBefore = safe.nonce(); - assertTrue(_execSafeTx(owner, 0, "")); - assertEq(safe.nonce(), nonceBefore + 1); - } - - function test_realSafe_endToEnd_stallThenRecover() public { - // Builder healthy: current block is credible -> allowed. - registry.markCurrentBlockCredible(); - assertTrue(_execSafeTx(owner, 0, "")); - - // Builder misses a few blocks but is still within the live window -> blocked. - vm.roll(block.number + 10); - bytes memory sig = _signTx(owner, 0, ""); - vm.expectRevert(CredibleSafeGuard.NonCredibleBlock.selector); - safe.execTransaction(owner, 0, "", Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)), sig); - - // Builder stays silent past the window -> fail open -> allowed. - vm.roll(block.number + THRESHOLD); - assertTrue(_execSafeTx(owner, 0, "")); - - // Builder recovers and marks the new current block -> allowed again. - registry.markCurrentBlockCredible(); - assertTrue(_execSafeTx(owner, 0, "")); - } - - // --------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------- - - /// @dev Signs a single-owner Safe transaction (CALL, no gas refund) over the current nonce. - function _signTx(address to, uint256 value, bytes memory data) internal view returns (bytes memory) { - bytes32 txHash = safe.getTransactionHash( - to, value, data, Enum.Operation.Call, 0, 0, 0, address(0), address(0), safe.nonce() - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, txHash); - return abi.encodePacked(r, s, v); - } - - /// @dev Signs and submits a Safe transaction; reverts bubble up to the caller. - function _execSafeTx(address to, uint256 value, bytes memory data) internal returns (bool) { - bytes memory sig = _signTx(to, value, data); - return safe.execTransaction(to, value, data, Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)), sig); - } - - function _installedGuard() internal view returns (address) { - return address(uint160(uint256(vm.load(address(safe), GUARD_STORAGE_SLOT)))); - } -} diff --git a/foundry.toml b/foundry.toml index d75aa12..6f01503 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,6 +5,7 @@ out = "out" libs = ["lib"] remappings = ["credible-std/=src/"] via_ir = true +no_match_path = "test/protection/safe/integration/**" [profile.ci] src = "src" @@ -14,6 +15,7 @@ libs = ["lib"] remappings = ["credible-std/=src/"] via_ir = true optimizer = true +no_match_path = "test/protection/safe/integration/**" [profile.backtesting] src = "src" @@ -179,12 +181,12 @@ cache_path = "examples/safe/cache" libs = ["lib"] remappings = ["credible-std/=src/"] -# Real Gnosis Safe end-to-end tests for src/protection/safe/CredibleSafeGuard.sol. -# Separate profile because the Safe contracts compile with the legacy pipeline -# (optimizer, no via_ir), unlike the via_ir root profiles. +# Real Gnosis Safe compatibility and operational tests for CredibleSafeGuard. +# The suite lives with the protection tests but needs a separate profile because +# the Safe contracts compile with the legacy pipeline (optimizer, no via_ir). [profile.safe-guard] -src = "examples/safe-guard/src" -test = "examples/safe-guard/test" +src = "src/protection/safe" +test = "test/protection/safe/integration" script = "examples/safe-guard/script" out = "examples/safe-guard/out" cache_path = "examples/safe-guard/cache" @@ -193,6 +195,8 @@ remappings = ["credible-std/=src/"] fs_permissions = [{ access = "read-write", path = "./safe-guard-output" }] optimizer = true via_ir = false +# Override the root profiles' exclusion now that this profile targets the integration tree. +no_match_path = "test/protection/safe/integration/.never-match" [profile.credible-block] src = "examples/credible-block/src" diff --git a/lib/safe-smart-account-1.3.0 b/lib/safe-smart-account-1.3.0 new file mode 160000 index 0000000..186a21a --- /dev/null +++ b/lib/safe-smart-account-1.3.0 @@ -0,0 +1 @@ +Subproject commit 186a21a74b327f17fc41217a927dea7064f74604 diff --git a/lib/safe-smart-account-1.4.0 b/lib/safe-smart-account-1.4.0 new file mode 160000 index 0000000..e870f51 --- /dev/null +++ b/lib/safe-smart-account-1.4.0 @@ -0,0 +1 @@ +Subproject commit e870f514ad34cd9654c72174d6d4a839e3c6639f diff --git a/lib/safe-smart-account-1.5.0 b/lib/safe-smart-account-1.5.0 new file mode 160000 index 0000000..dc437e8 --- /dev/null +++ b/lib/safe-smart-account-1.5.0 @@ -0,0 +1 @@ +Subproject commit dc437e8fba8b4805d76bcbd1c668c9fd3d1e83be diff --git a/src/protection/safe/README.md b/src/protection/safe/README.md index d66946a..9b413bb 100644 --- a/src/protection/safe/README.md +++ b/src/protection/safe/README.md @@ -14,6 +14,51 @@ The two `*Assertion` contracts run inside the PhEvm. `CredibleSafeGuard` is a pl Install it through an owner-authorized Safe self-transaction whose calldata is `setGuard(address(guard))`; a direct owner call is not authorized. Safe then calls `checkTransaction` before each `execTransaction`, and the guard reverts to block the execution. The scripts and Safe Transaction Builder import workflow are documented in [`examples/safe-guard/README.md`](../../../examples/safe-guard/README.md). +### Safe compatibility + +The integration suite runs against every guard-capable Safe contract release from 1.3.0 through +1.5.0: + +| Safe release | Pinned revision | Singleton / proxy factory | Guard installation validation | +| --- | --- | --- | --- | +| 1.3.0 | `186a21a` | `GnosisSafe` / `GnosisSafeProxyFactory` | Transaction guards supported; no ERC-165 check | +| 1.4.0 | `e870f51` | `Safe` / `SafeProxyFactory` | `GS300` ERC-165 interface check | +| 1.4.1 | `21dc824` (`v1.4.1-3`) | `Safe` / `SafeProxyFactory` | `GS300` ERC-165 interface check | +| 1.5.0 | `dc437e8` | `Safe` / `SafeProxyFactory` | `GS300` ERC-165 interface check | + +Safe 1.2.0 is not included because it does not implement transaction guards. The 1.4.1 +dependency uses the `v1.4.1-3` source release, whose singleton reports contract version `1.4.1`. +The matrix follows the +[official Safe contract releases](https://github.com/safe-global/safe-smart-account/releases). + +Safe 1.3.0 supports installing and running a transaction guard, but its `setGuard` implementation +accepts the address without validating ERC-165 support. Safe 1.4.0 and later reject a nonzero guard +unless it advertises the transaction-guard interface, reverting with `GS300`. +`CredibleSafeGuard` implements that interface and installs successfully on all four versions. + +The shared lifecycle suite covers: + +- Guard installation through an owner-signed `execTransaction` → `setGuard`, including the real + `GS300` check on Safe 1.4.0 and later. +- Signed transactions executing in credible blocks and reverting with `NonCredibleBlock` in + non-credible blocks while the builder set is live. +- Regular transactions and guard removal remaining blocked at exactly `failOpenBlockThreshold`, + without changing the guard slot or consuming the Safe nonce. +- Guard removal succeeding at `failOpenBlockThreshold + 1`, and before expiry in a credible block. +- Fail-open after builder silence and when registry responses revert or are malformed, including + recovery removal of the guard. +- An end-to-end builder stall-then-recover sequence. + +The expiry comparison is strict: fail-open activates only when the gap from the latest credible +block is greater than `failOpenBlockThreshold`. Expiry is only needed for a healthy, +non-credible block; a credible block or an unavailable/malformed registry permits earlier +recovery, preserving the guard's existing policy. + +The compatibility and operational tests are under +[`test/protection/safe/integration`](../../../test/protection/safe/integration) and use the +dedicated `safe-guard` Foundry profile because the pinned Safe contracts require the legacy +compiler pipeline (`optimizer = true`, no `via_ir`). + ### What It Checks On every Safe transaction the guard reads the Credible Registry and decides: diff --git a/test/protection/safe/integration/CredibleSafeGuardSafeIntegration.t.sol b/test/protection/safe/integration/CredibleSafeGuardSafeIntegration.t.sol new file mode 100644 index 0000000..d76eb1d --- /dev/null +++ b/test/protection/safe/integration/CredibleSafeGuardSafeIntegration.t.sol @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; + +import {GnosisSafe as GnosisSafe130} from "../../../../lib/safe-smart-account-1.3.0/contracts/GnosisSafe.sol"; +import { + GnosisSafeProxyFactory as GnosisSafeProxyFactory130 +} from "../../../../lib/safe-smart-account-1.3.0/contracts/proxies/GnosisSafeProxyFactory.sol"; +import {Safe as Safe140} from "../../../../lib/safe-smart-account-1.4.0/contracts/Safe.sol"; +import { + SafeProxyFactory as SafeProxyFactory140 +} from "../../../../lib/safe-smart-account-1.4.0/contracts/proxies/SafeProxyFactory.sol"; +import {Safe as Safe141} from "../../../../lib/safe-smart-account/contracts/Safe.sol"; +import { + SafeProxyFactory as SafeProxyFactory141 +} from "../../../../lib/safe-smart-account/contracts/proxies/SafeProxyFactory.sol"; +import {Safe as Safe150} from "../../../../lib/safe-smart-account-1.5.0/contracts/Safe.sol"; +import { + SafeProxyFactory as SafeProxyFactory150 +} from "../../../../lib/safe-smart-account-1.5.0/contracts/proxies/SafeProxyFactory.sol"; + +import {CredibleSafeGuard} from "credible-std/protection/safe/CredibleSafeGuard.sol"; +import {CredibleRegistryMock} from "./mocks/CredibleRegistryMock.sol"; + +/// @dev Stable owner-transaction ABI shared by every guard-capable Safe release in the matrix. +/// Enum.Operation is represented by its ABI type (`uint8`) so the harness does not depend on +/// any one release's Enum library. +interface ISafe { + function VERSION() external view returns (string memory); + + function setup( + address[] calldata owners, + uint256 threshold, + address to, + bytes calldata data, + address fallbackHandler, + address paymentToken, + uint256 payment, + address payable paymentReceiver + ) external; + + function nonce() external view returns (uint256); + + function execTransaction( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes calldata signatures + ) external payable returns (bool success); + + function getTransactionHash( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 nonce + ) external view returns (bytes32); +} + +/// @notice Shared lifecycle suite for every guard-capable Safe release from 1.3.0 through 1.5.0. +/// @dev Concrete adapters below deploy each release's real singleton and proxy factory. All setup, +/// hashing, signing, execution, and guard-slot inspection use the stable Safe ABI above. +abstract contract CredibleSafeGuardSafeIntegrationTest is Test { + /// @dev Safe stores the transaction guard at keccak256("guard_manager.guard.address"). + bytes32 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + + uint8 internal constant CALL = 0; + uint256 internal constant FAIL_OPEN_BLOCK_THRESHOLD = 75; + uint256 internal constant BASE_BLOCK = 1_000_000; + address internal constant PROTOCOL_MANAGER = address(0xA11CE); + + uint256 internal ownerPk = uint256(keccak256("safe.owner")); + address internal owner; + + CredibleRegistryMock internal registry; + CredibleSafeGuard internal guard; + ISafe internal safe; + + function setUp() public { + owner = vm.addr(ownerPk); + + registry = new CredibleRegistryMock(); + guard = new CredibleSafeGuard(registry, FAIL_OPEN_BLOCK_THRESHOLD, PROTOCOL_MANAGER); + + address[] memory owners = new address[](1); + owners[0] = owner; + bytes memory initializer = + abi.encodeCall(ISafe.setup, (owners, 1, address(0), "", address(0), address(0), 0, payable(address(0)))); + safe = ISafe(payable(_deploySafe(initializer))); + + assertEq(safe.VERSION(), _expectedVersion(), "unexpected Safe version"); + + vm.roll(BASE_BLOCK); + + // `setGuard` is self-authorized, so install it through an owner-signed Safe transaction. + // Safe 1.4.0+ also run their real GS300 ERC-165 interface check during this call. + assertTrue(_setGuard(address(guard)), "guard installation failed"); + assertEq(_installedGuard(), address(guard), "guard not installed"); + } + + function _deploySafe(bytes memory initializer) internal virtual returns (address); + + function _expectedVersion() internal pure virtual returns (string memory); + + function test_guardInstalledThroughOwnerSignedSelfTransaction() public view { + assertEq(safe.nonce(), 1); + assertEq(_installedGuard(), address(guard)); + } + + function test_executesInCredibleBlock() public { + registry.markCurrentBlockCredible(); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_execSafeTx(owner, 0, "")); + + assertEq(safe.nonce(), nonceBefore + 1); + assertEq(_installedGuard(), address(guard)); + } + + function test_revertsInNonCredibleBlockWithinWindow() public { + registry.setLastCredibleBlock(block.number - 1); + + uint256 nonceBefore = safe.nonce(); + bytes memory sig = _signTx(owner, 0, ""); + + vm.expectRevert(CredibleSafeGuard.NonCredibleBlock.selector); + safe.execTransaction(owner, 0, "", CALL, 0, 0, 0, address(0), payable(address(0)), sig); + + assertEq(safe.nonce(), nonceBefore, "blocked transaction consumed nonce"); + assertEq(_installedGuard(), address(guard)); + } + + function test_regularTransactionBlockedAtFailOpenThresholdBoundary() public { + registry.setLastCredibleBlock(block.number - FAIL_OPEN_BLOCK_THRESHOLD); + + uint256 nonceBefore = safe.nonce(); + bytes memory sig = _signTx(owner, 0, ""); + + vm.expectRevert(CredibleSafeGuard.NonCredibleBlock.selector); + safe.execTransaction(owner, 0, "", CALL, 0, 0, 0, address(0), payable(address(0)), sig); + + assertEq(safe.nonce(), nonceBefore, "boundary transaction consumed nonce"); + assertEq(_installedGuard(), address(guard)); + } + + function test_guardRemovalBlockedInNonCredibleBlockWithinWindow() public { + registry.setLastCredibleBlock(block.number - 1); + + uint256 nonceBefore = safe.nonce(); + bytes memory data = _setGuardData(address(0)); + bytes memory sig = _signTx(address(safe), 0, data); + + vm.expectRevert(CredibleSafeGuard.NonCredibleBlock.selector); + safe.execTransaction(address(safe), 0, data, CALL, 0, 0, 0, address(0), payable(address(0)), sig); + + assertEq(safe.nonce(), nonceBefore, "blocked removal consumed nonce"); + assertEq(_installedGuard(), address(guard), "blocked removal changed guard"); + } + + function test_guardRemovalBlockedAtFailOpenThresholdBoundary() public { + registry.setLastCredibleBlock(block.number - FAIL_OPEN_BLOCK_THRESHOLD); + + uint256 nonceBefore = safe.nonce(); + bytes memory data = _setGuardData(address(0)); + bytes memory sig = _signTx(address(safe), 0, data); + + vm.expectRevert(CredibleSafeGuard.NonCredibleBlock.selector); + safe.execTransaction(address(safe), 0, data, CALL, 0, 0, 0, address(0), payable(address(0)), sig); + + assertEq(safe.nonce(), nonceBefore, "boundary removal consumed nonce"); + assertEq(_installedGuard(), address(guard), "boundary removal changed guard"); + } + + function test_ownerSignedGuardRemovalSucceedsAfterFailOpenThreshold() public { + registry.setLastCredibleBlock(block.number - (FAIL_OPEN_BLOCK_THRESHOLD + 1)); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_setGuard(address(0)), "expired guard removal failed"); + + assertEq(safe.nonce(), nonceBefore + 1); + assertEq(_installedGuard(), address(0)); + } + + function test_ownerSignedGuardRemovalBeforeExpirySucceedsInCredibleBlock() public { + registry.markCurrentBlockCredible(); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_setGuard(address(0)), "credible guard removal failed"); + + assertEq(safe.nonce(), nonceBefore + 1); + assertEq(_installedGuard(), address(0)); + } + + function test_regularTransactionSucceedsAfterFailOpenThreshold() public { + registry.setLastCredibleBlock(block.number - (FAIL_OPEN_BLOCK_THRESHOLD + 1)); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_execSafeTx(owner, 0, "")); + + assertEq(safe.nonce(), nonceBefore + 1); + assertEq(_installedGuard(), address(guard)); + } + + function test_failsOpenWhenCredibilityReadReverts() public { + vm.mockCallRevert( + address(registry), abi.encodeWithSignature("isCredibleBlock(uint256)", block.number), "registry unavailable" + ); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_execSafeTx(owner, 0, "")); + assertEq(safe.nonce(), nonceBefore + 1); + } + + function test_canRemoveGuardWhenRegistryReadReverts() public { + vm.mockCallRevert( + address(registry), abi.encodeWithSignature("isCredibleBlock(uint256)", block.number), "registry unavailable" + ); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_setGuard(address(0)), "recovery removal failed"); + + assertEq(safe.nonce(), nonceBefore + 1); + assertEq(_installedGuard(), address(0)); + } + + function test_failsOpenWhenCredibilityReadIsMalformed() public { + _mockMalformedCredibilityRead(); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_execSafeTx(owner, 0, "")); + assertEq(safe.nonce(), nonceBefore + 1); + } + + function test_canRemoveGuardWhenCredibilityReadIsMalformed() public { + _mockMalformedCredibilityRead(); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_setGuard(address(0)), "malformed-response recovery removal failed"); + + assertEq(safe.nonce(), nonceBefore + 1); + assertEq(_installedGuard(), address(0)); + } + + function test_failsOpenWhenLastBlockReadReverts() public { + vm.mockCallRevert(address(registry), abi.encodeWithSignature("lastCredibleBlock()"), "registry unavailable"); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_execSafeTx(owner, 0, "")); + assertEq(safe.nonce(), nonceBefore + 1); + } + + function test_failsOpenWhenLastBlockReportedInFuture() public { + registry.setLastCredibleBlock(type(uint256).max); + + uint256 nonceBefore = safe.nonce(); + assertTrue(_execSafeTx(owner, 0, "")); + assertEq(safe.nonce(), nonceBefore + 1); + } + + function test_endToEndStallThenRecover() public { + registry.markCurrentBlockCredible(); + assertTrue(_execSafeTx(owner, 0, "")); + + vm.roll(block.number + 10); + bytes memory sig = _signTx(owner, 0, ""); + vm.expectRevert(CredibleSafeGuard.NonCredibleBlock.selector); + safe.execTransaction(owner, 0, "", CALL, 0, 0, 0, address(0), payable(address(0)), sig); + + vm.roll(block.number + FAIL_OPEN_BLOCK_THRESHOLD); + assertTrue(_execSafeTx(owner, 0, "")); + + registry.markCurrentBlockCredible(); + assertTrue(_execSafeTx(owner, 0, "")); + } + + /// @dev Signs a single-owner Safe transaction (CALL, no gas refund) over the current nonce. + function _signTx(address to, uint256 value, bytes memory data) internal view returns (bytes memory) { + bytes32 txHash = safe.getTransactionHash(to, value, data, CALL, 0, 0, 0, address(0), address(0), safe.nonce()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, txHash); + return abi.encodePacked(r, s, v); + } + + /// @dev Signs and submits a Safe transaction; reverts bubble up to the caller. + function _execSafeTx(address to, uint256 value, bytes memory data) internal returns (bool) { + bytes memory sig = _signTx(to, value, data); + return safe.execTransaction(to, value, data, CALL, 0, 0, 0, address(0), payable(address(0)), sig); + } + + function _setGuard(address newGuard) internal returns (bool) { + return _execSafeTx(address(safe), 0, _setGuardData(newGuard)); + } + + function _setGuardData(address newGuard) internal pure returns (bytes memory) { + return abi.encodeWithSignature("setGuard(address)", newGuard); + } + + function _mockMalformedCredibilityRead() internal { + vm.mockCall( + address(registry), abi.encodeWithSignature("isCredibleBlock(uint256)", block.number), abi.encode(uint256(2)) + ); + } + + function _installedGuard() internal view returns (address) { + return address(uint160(uint256(vm.load(address(safe), GUARD_STORAGE_SLOT)))); + } +} + +contract CredibleSafeGuardSafe130IntegrationTest is CredibleSafeGuardSafeIntegrationTest { + function _deploySafe(bytes memory initializer) internal override returns (address) { + GnosisSafe130 singleton = new GnosisSafe130(); + GnosisSafeProxyFactory130 factory = new GnosisSafeProxyFactory130(); + return address(factory.createProxyWithNonce(address(singleton), initializer, 0)); + } + + function _expectedVersion() internal pure override returns (string memory) { + return "1.3.0"; + } +} + +contract CredibleSafeGuardSafe140IntegrationTest is CredibleSafeGuardSafeIntegrationTest { + function _deploySafe(bytes memory initializer) internal override returns (address) { + Safe140 singleton = new Safe140(); + SafeProxyFactory140 factory = new SafeProxyFactory140(); + return address(factory.createProxyWithNonce(address(singleton), initializer, 0)); + } + + function _expectedVersion() internal pure override returns (string memory) { + return "1.4.0"; + } +} + +contract CredibleSafeGuardSafe141IntegrationTest is CredibleSafeGuardSafeIntegrationTest { + function _deploySafe(bytes memory initializer) internal override returns (address) { + Safe141 singleton = new Safe141(); + SafeProxyFactory141 factory = new SafeProxyFactory141(); + return address(factory.createProxyWithNonce(address(singleton), initializer, 0)); + } + + function _expectedVersion() internal pure override returns (string memory) { + return "1.4.1"; + } +} + +contract CredibleSafeGuardSafe150IntegrationTest is CredibleSafeGuardSafeIntegrationTest { + function _deploySafe(bytes memory initializer) internal override returns (address) { + Safe150 singleton = new Safe150(); + SafeProxyFactory150 factory = new SafeProxyFactory150(); + return address(factory.createProxyWithNonce(address(singleton), initializer, 0)); + } + + function _expectedVersion() internal pure override returns (string memory) { + return "1.5.0"; + } +} diff --git a/examples/safe-guard/test/CredibleSafeGuardScripts.t.sol b/test/protection/safe/integration/CredibleSafeGuardScripts.t.sol similarity index 94% rename from examples/safe-guard/test/CredibleSafeGuardScripts.t.sol rename to test/protection/safe/integration/CredibleSafeGuardScripts.t.sol index 7f18326..dde06dd 100644 --- a/examples/safe-guard/test/CredibleSafeGuardScripts.t.sol +++ b/test/protection/safe/integration/CredibleSafeGuardScripts.t.sol @@ -3,17 +3,16 @@ pragma solidity ^0.8.13; import {Test} from "forge-std/Test.sol"; -import {Safe} from "../../../lib/safe-smart-account/contracts/Safe.sol"; -import {SafeProxyFactory} from "../../../lib/safe-smart-account/contracts/proxies/SafeProxyFactory.sol"; -import {Enum} from "../../../lib/safe-smart-account/contracts/common/Enum.sol"; +import {Safe} from "../../../../lib/safe-smart-account/contracts/Safe.sol"; +import {SafeProxyFactory} from "../../../../lib/safe-smart-account/contracts/proxies/SafeProxyFactory.sol"; +import {Enum} from "../../../../lib/safe-smart-account/contracts/common/Enum.sol"; import {CredibleSafeGuard} from "credible-std/protection/safe/CredibleSafeGuard.sol"; -import {InitialProtocolManager} from - "credible-std/protection/credible_block/InitialProtocolManager.sol"; -import {CredibleRegistryMock} from "../src/CredibleRegistryMock.sol"; -import {DeployCredibleSafeGuard} from "../script/DeployCredibleSafeGuard.s.sol"; -import {GenerateSafeGuardBatch} from "../script/GenerateSafeGuardBatch.s.sol"; -import {SafeGuardBatch} from "../script/SafeGuardBatch.sol"; +import {InitialProtocolManager} from "credible-std/protection/credible_block/InitialProtocolManager.sol"; +import {CredibleRegistryMock} from "./mocks/CredibleRegistryMock.sol"; +import {DeployCredibleSafeGuard} from "../../../../examples/safe-guard/script/DeployCredibleSafeGuard.s.sol"; +import {GenerateSafeGuardBatch} from "../../../../examples/safe-guard/script/GenerateSafeGuardBatch.s.sol"; +import {SafeGuardBatch} from "../../../../examples/safe-guard/script/SafeGuardBatch.sol"; contract UnsupportedGuard {} diff --git a/examples/safe-guard/src/CredibleRegistryMock.sol b/test/protection/safe/integration/mocks/CredibleRegistryMock.sol similarity index 100% rename from examples/safe-guard/src/CredibleRegistryMock.sol rename to test/protection/safe/integration/mocks/CredibleRegistryMock.sol From 0d3748b48ed5c5162b023a13c2806b9c8fc3be25 Mon Sep 17 00:00:00 2001 From: makemake Date: Mon, 27 Jul 2026 14:34:29 +0200 Subject: [PATCH 2/2] fix(ci): skip legacy Safe tests in via-ir builds --- foundry.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/foundry.toml b/foundry.toml index 6f01503..f9a0efd 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,7 +5,7 @@ out = "out" libs = ["lib"] remappings = ["credible-std/=src/"] via_ir = true -no_match_path = "test/protection/safe/integration/**" +skip = ["CredibleSafeGuardSafeIntegration", "CredibleSafeGuardScripts"] [profile.ci] src = "src" @@ -15,7 +15,7 @@ libs = ["lib"] remappings = ["credible-std/=src/"] via_ir = true optimizer = true -no_match_path = "test/protection/safe/integration/**" +skip = ["CredibleSafeGuardSafeIntegration", "CredibleSafeGuardScripts"] [profile.backtesting] src = "src" @@ -195,8 +195,8 @@ remappings = ["credible-std/=src/"] fs_permissions = [{ access = "read-write", path = "./safe-guard-output" }] optimizer = true via_ir = false -# Override the root profiles' exclusion now that this profile targets the integration tree. -no_match_path = "test/protection/safe/integration/.never-match" +# Compile the integration files skipped by the root via-IR profiles. +skip = [] [profile.credible-block] src = "examples/credible-block/src"