Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
64 changes: 64 additions & 0 deletions examples/safe-guard/script/DeployCredibleSafeGuard.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// 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);

// `isCredibleBlock` is a Solidity `bool`, so a well-formed answer is a canonical boolean
// word (0 or 1). Reject any other 32-byte value (e.g. `abi.encode(uint256(2))`) here:
// it passes the length check but the guard's runtime decode treats it as unreadable
// (see CredibleSafeGuard._tryIsCredibleBlock's `value > 1` branch), which would otherwise
// let a registry silently deploy a permanently-fail-open guard.
(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 || abi.decode(credibleData, (uint256)) > 1) {
revert RegistryReadFailed(registry, "isCredibleBlock");
}

(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, '"');
}
}
Loading