Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ out/
# Forge broadcast logs at repo root
/broadcast/

# Generated Safe Transaction Builder batches
/safe-guard-output/

# Docs
docs/

Expand Down
68 changes: 67 additions & 1 deletion examples/safe-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,75 @@ These integration tests live under their own profile because the Safe contracts
- 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.

## Run
## Deploy the guard

The deployment script reads the registry and fail-open threshold from the environment. Foundry handles the deployer wallet; the script does not read a private key.

```sh
FOUNDRY_PROFILE=safe-guard \
CREDIBLE_REGISTRY=0x... \
FAIL_OPEN_BLOCK_THRESHOLD=75 \
forge script examples/safe-guard/script/DeployCredibleSafeGuard.s.sol \
--rpc-url "$RPC_URL" \
--account <foundry-keystore-account> \
--broadcast
```

Foundry hardware-wallet flags such as `--ledger` or `--trezor` can be used instead of `--account`.

## Create a Safe{Wallet} installation transaction

`GenerateSafeGuardBatch.s.sol` creates an official Safe Transaction Builder JSON batch. It does not sign, broadcast, call `execTransaction`, or submit anything to the Safe Transaction Service.

```sh
FOUNDRY_PROFILE=safe-guard \
SAFE_ADDRESS=0x... \
SAFE_GUARD_ACTION=install \
CREDIBLE_SAFE_GUARD=0x... \
forge script examples/safe-guard/script/GenerateSafeGuardBatch.s.sol \
--rpc-url "$RPC_URL"
```

The output is written to:

```text
safe-guard-output/install.json
```

To install the guard:

1. Open the target Safe in Safe{Wallet} on the same chain used to generate the file.
2. Open **Apps → Transaction Builder**.
3. Import `safe-guard-output/install.json`.
4. Verify that the transaction target is the Safe itself, the value is zero, and the decoded call is `setGuard(<deployed guard>)`.
5. Create the Safe transaction and complete the Safe's normal owner confirmation and execution flow.

Safe's `setGuard` function is self-authorized. A Safe owner cannot call it directly; the call must execute through an owner-authorized Safe transaction targeting the Safe itself. The generated batch provides exactly that inner transaction and includes a Transaction Builder checksum so the UI can detect modification.

## Create a removal transaction

Removal uses the same Safe self-call with the guard set to the zero address:

```sh
FOUNDRY_PROFILE=safe-guard \
SAFE_ADDRESS=0x... \
SAFE_GUARD_ACTION=remove \
forge script examples/safe-guard/script/GenerateSafeGuardBatch.s.sol \
--rpc-url "$RPC_URL"
```

Import `safe-guard-output/remove.json` into Transaction Builder and verify that it displays `setGuard(0x0000000000000000000000000000000000000000)` before submitting it.

If a guard is already installed, that current guard checks a replacement or removal transaction before Safe executes it. `CredibleSafeGuard` therefore requires the transaction to land in a credible block while the builder set is live. Its fail-open behavior still permits recovery if the builder set is stale or a required registry read is unavailable or malformed.

Importing a batch does not automatically publish it to the Safe transaction queue. The owner creates the proposal from Transaction Builder after reviewing the imported action. Automatically inserting a transaction into the queue would require a separate Safe owner signature and Safe Transaction Service integration.

Generated files under `safe-guard-output/` are ignored by Git. Always verify the chain, Safe address, action, guard address, and decoded call in Safe{Wallet} before signing.

## Run tests

```sh
FOUNDRY_PROFILE=safe-guard forge test
Expand Down
57 changes: 57 additions & 0 deletions examples/safe-guard/script/DeployCredibleSafeGuard.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Script, console2} from "forge-std/Script.sol";

import {CredibleSafeGuard} from "credible-std/protection/safe/CredibleSafeGuard.sol";
import {ICredibleRegistry} from "credible-std/protection/safe/ICredibleRegistry.sol";

/// @notice Deploys a Credible Safe guard using Foundry's configured broadcast wallet.
contract DeployCredibleSafeGuard is Script {
/// @notice Thrown when the configured registry address has no deployed code.
/// @dev Catches the common footgun of a typo that resolves to an EOA: a codeless registry
/// makes every credibility probe fail open, so the guard would allow every transaction.
error RegistryHasNoCode(address registry);
/// @notice Thrown when a required registry read (isCredibleBlock / lastCredibleBlock) does not
/// return a single, well-formed 32-byte word.
error RegistryReadFailed(address registry, string read);

function run() external returns (CredibleSafeGuard guard) {
address registry = vm.envAddress("CREDIBLE_REGISTRY");
uint256 threshold = vm.envUint("FAIL_OPEN_BLOCK_THRESHOLD");

// Validate the registry before broadcasting so a misconfigured (codeless or
// non-responsive) registry is caught up front rather than silently deploying a
// permanently-fail-open guard.
validateRegistry(registry);

vm.startBroadcast();
guard = deploy(registry, threshold);
Comment thread
makemake-kbo marked this conversation as resolved.
vm.stopBroadcast();

console2.log("Chain ID:", block.chainid);
console2.log("Credible Safe guard:", address(guard));
console2.log("Credible Registry:", registry);
console2.log("Fail-open block threshold:", threshold);
}

function deploy(address registry, uint256 threshold) public returns (CredibleSafeGuard) {
return new CredibleSafeGuard(ICredibleRegistry(registry), threshold);
}

/// @notice Asserts the registry has code and answers both reads the guard depends on.
/// @dev Reverts with a descriptive error otherwise. Kept separate from {deploy} so the
/// constructor's own zero-address / zero-threshold validation is preserved for callers
/// that deploy against an in-memory mock.
function validateRegistry(address registry) public view {
if (registry.code.length == 0) revert RegistryHasNoCode(registry);

(bool credibleOk, bytes memory credibleData) =
registry.staticcall(abi.encodeCall(ICredibleRegistry.isCredibleBlock, (block.number)));
Comment thread
makemake-kbo marked this conversation as resolved.
if (!credibleOk || credibleData.length != 32) revert RegistryReadFailed(registry, "isCredibleBlock");
Comment thread
makemake-kbo marked this conversation as resolved.
Outdated

(bool lastOk, bytes memory lastData) =
registry.staticcall(abi.encodeCall(ICredibleRegistry.lastCredibleBlock, ()));
if (!lastOk || lastData.length != 32) revert RegistryReadFailed(registry, "lastCredibleBlock");
Comment thread
makemake-kbo marked this conversation as resolved.
}
}
35 changes: 35 additions & 0 deletions examples/safe-guard/script/GenerateSafeGuardBatch.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {console2} from "forge-std/Script.sol";

import {SafeGuardBatch} from "./SafeGuardBatch.sol";

/// @notice Generates a Safe Transaction Builder JSON file for installing or removing a guard.
/// @dev This script never broadcasts or signs. Import the generated file into Safe{Wallet}'s
/// Transaction Builder and submit it through the Safe's normal owner-confirmation flow.
contract GenerateSafeGuardBatch is SafeGuardBatch {
Comment thread
makemake-kbo marked this conversation as resolved.
function run() external returns (string memory json, string memory path) {
address safe = vm.envAddress("SAFE_ADDRESS");
Action action = parseAction(vm.envString("SAFE_GUARD_ACTION"));
address guard = vm.envOr("CREDIBLE_SAFE_GUARD", address(0));
uint256 createdAt = vm.unixTime();

if (action == Action.Install) {
json = buildInstallBatch(safe, guard, block.chainid, createdAt);
} else {
json = buildRemoveBatch(safe, block.chainid, createdAt);
}

path = outputPath(action);
vm.createDir("safe-guard-output", true);
vm.writeFile(path, json);

console2.log("Safe Transaction Builder file:", path);
console2.log("Chain ID:", block.chainid);
console2.log("Safe:", safe);
console2.log("Action:", action == Action.Install ? "install" : "remove");
console2.log("Guard:", action == Action.Install ? guard : address(0));
console2.log("Import this JSON in Safe{Wallet} Transaction Builder and verify it before signing.");
}
}
154 changes: 154 additions & 0 deletions examples/safe-guard/script/SafeGuardBatch.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Script} from "forge-std/Script.sol";

import {IERC165, ITransactionGuard} from "credible-std/protection/safe/CredibleSafeGuard.sol";

/// @notice Builds Safe Transaction Builder batches for installing and removing a Safe guard.
/// @dev The checksum implementation matches Safe Transaction Builder's canonical serializer for
/// the fixed one-transaction schema emitted by this contract.
abstract contract SafeGuardBatch is Script {
enum Action {
Install,
Remove
}

bytes4 internal constant SAFE_GUARD_INTERFACE_ID = type(ITransactionGuard).interfaceId;
string internal constant TX_BUILDER_VERSION = "2.0.1";

error ZeroSafeAddress();
error SafeHasNoCode(address safe);
error ZeroGuardAddress();
error GuardHasNoCode(address guard);
error UnsupportedGuardInterface(address guard);
error InvalidAction(string action);

function buildInstallBatch(address safe, address guard, uint256 chainId, uint256 createdAt)
public
view
returns (string memory)
{
_validateSafe(safe);
_validateGuard(guard);
return _buildBatch(safe, guard, Action.Install, chainId, createdAt);
}

function buildRemoveBatch(address safe, uint256 chainId, uint256 createdAt) public view returns (string memory) {
_validateSafe(safe);
return _buildBatch(safe, address(0), Action.Remove, chainId, createdAt);
}

function parseAction(string memory action) public pure returns (Action) {
bytes32 actionHash = keccak256(bytes(action));
if (actionHash == keccak256("install")) return Action.Install;
if (actionHash == keccak256("remove")) return Action.Remove;
revert InvalidAction(action);
}

function outputPath(Action action) public pure returns (string memory) {
return action == Action.Install ? "safe-guard-output/install.json" : "safe-guard-output/remove.json";
}

function calculateChecksum(address safe, address guard, uint256 chainId, uint256 createdAt)
public
view
returns (bytes32)
{
string memory safeString = vm.toString(safe);
string memory guardString = vm.toString(guard);
string memory chainIdString = vm.toString(chainId);
string memory createdAtString = vm.toString(createdAt);

string memory canonicalMeta = string.concat(
'{["createdFromOwnerAddress","createdFromSafeAddress","description","name","txBuilderVersion"]',
'"",',
_quote(safeString),
',"",null,"',
TX_BUILDER_VERSION,
'",}'
);

string memory canonicalInput = string.concat('{["internalType","name","type"]', '"address","guard","address",}');
string memory canonicalMethod =
string.concat('{["inputs","name","payable"]', "[", canonicalInput, "]", ',"setGuard",false,}');
string memory canonicalInputs = string.concat('{["guard"]', _quote(guardString), ",}");
string memory canonicalTransaction = string.concat(
'{["contractInputsValues","contractMethod","data","to","value"]',
canonicalInputs,
",",
canonicalMethod,
",null,",
_quote(safeString),
',"0",}'
);
string memory canonicalRoot = string.concat(
'{["chainId","createdAt","meta","transactions","version"]',
_quote(chainIdString),
",",
createdAtString,
",",
canonicalMeta,
",[",
canonicalTransaction,
'],"1.0",}'
);

return keccak256(bytes(canonicalRoot));
}

function _buildBatch(address safe, address guard, Action action, uint256 chainId, uint256 createdAt)
internal
view
returns (string memory)
{
string memory safeString = vm.toString(safe);
string memory guardString = vm.toString(guard);
string memory checksum = vm.toString(calculateChecksum(safe, guard, chainId, createdAt));
string memory name = action == Action.Install ? "Install Credible Safe Guard" : "Remove Credible Safe Guard";

return string.concat(
'{"version":"1.0","chainId":',
_quote(vm.toString(chainId)),
',"createdAt":',
vm.toString(createdAt),
',"meta":{"name":',
_quote(name),
',"description":"","txBuilderVersion":"',
TX_BUILDER_VERSION,
'","createdFromSafeAddress":',
_quote(safeString),
',"createdFromOwnerAddress":"","checksum":',
_quote(checksum),
'},"transactions":[{"to":',
_quote(safeString),
',"value":"0","data":null,"contractMethod":{"inputs":[{"internalType":"address","name":"guard","type":"address"}],"name":"setGuard","payable":false},"contractInputsValues":{"guard":',
_quote(guardString),
"}}]}"
);
}

function _validateSafe(address safe) internal view {
if (safe == address(0)) revert ZeroSafeAddress();
if (safe.code.length == 0) revert SafeHasNoCode(safe);
}

function _validateGuard(address guard) internal view {
if (guard == address(0)) revert ZeroGuardAddress();
if (guard.code.length == 0) revert GuardHasNoCode(guard);

(bool success, bytes memory result) =
guard.staticcall(abi.encodeCall(IERC165.supportsInterface, (SAFE_GUARD_INTERFACE_ID)));
uint256 supported;
if (success && result.length == 32) {
assembly ("memory-safe") {
supported := mload(add(result, 0x20))
}
}
if (!success || result.length != 32 || supported != 1) revert UnsupportedGuardInterface(guard);
}

function _quote(string memory value) internal pure returns (string memory) {
return string.concat('"', value, '"');
}
}
46 changes: 46 additions & 0 deletions examples/safe-guard/test/CredibleSafeGuardSafeIntegration.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,52 @@ contract CredibleSafeGuardSafeIntegrationTest is Test {
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_endToEnd_stallThenRecover() public {
// Builder healthy: current block is credible -> allowed.
registry.markCurrentBlockCredible();
Expand Down
Loading