Skip to content

feat: signer-safety warnings for safe-hash (assumed --safe-version, DELEGATECALL) - #10

Open
aelmanaa wants to merge 2 commits into
Cyfrin:mainfrom
aelmanaa:fix/safe-hash-signer-warnings
Open

aelmanaa wants to merge 2 commits into
Cyfrin:mainfrom
aelmanaa:fix/safe-hash-signer-warnings

Conversation

@aelmanaa

@aelmanaa aelmanaa commented Jul 10, 2026 •

Copy link
Copy Markdown

While validating clearsig safe-hash 0.3.1 against live on-chain
getTransactionHash values (Sepolia and Fuji, single-call and MultiSend batched
vectors), the hash math checked out byte-for-byte in every case. Two things a
signer-facing tool should say out loud, however, stayed silent. This PR adds both
warnings without changing a single byte of hash output.

1. --safe-version is assumed silently

safe-hash and safe-msg default --safe-version to 1.4.1 with no signal. A
Safe below 1.3.0 uses a chainId-less EIP-712 domain, and one below 1.0.0 a
different SafeTx gas field, so hashing an older Safe under the assumed default
produces a confidently wrong hash. For a tool whose whole job is catching wrong
hashes, a silent version assumption is the worst-case failure mode.

The first commit keeps the default but prints an unmissable stderr warning
whenever it is used:

warning: --safe-version not supplied; assuming 1.4.1. Hashes for other Safe versions differ (the EIP-712 domain changed in 1.3.0, the SafeTx gas field in 1.0.0).

If you would rather make --safe-version required (a breaking CLI change, but
the strictest fix), I am happy to rework the commit that way; the tests cover
both behaviors easily.

2. --operation 1 prints hashes with no delegatecall warning

An unflagged delegatecall is the highest-risk action a Safe signer can approve:
the target's code runs with the Safe's own storage, balance, and identity (the
Bybit incident vector). safe_hashes.sh warns on delegatecall; clearsig printed
the three hashes only.

The second commit flags every --operation 1 on stderr and checks the target
against the official MultiSend deployments from
safe-global/safe-deployments
(1.1.1 through 1.5.0, canonical/eip155/zksync):

warning: operation=1 (DELEGATECALL) — the target's code runs in the Safe's own context.
warning: the target is the official Safe MultiSendCallOnly 1.4.1 (canonical) batching contract. Verify the decoded inner calls before signing.

and for anything that is not a known MultiSend:

WARNING: the target 0x9467...b3a3 is NOT a known Safe MultiSend deployment. A malicious DELEGATECALL target can take over the Safe. Do not sign unless you can prove what code runs at this address.

Output compatibility

All warnings go to stderr. stdout (both the human table and --json) is
byte-identical to 0.3.1; scripts parsing the output are unaffected.

Verification

  • uv run ruff check, uv run ruff format --check, uv run ty check, and
    uv run pytest all pass; 10 new CLI tests cover warning presence, absence,
    case-insensitive target matching, and the --json path staying clean.
  • Re-ran my independent conformance harness (live-Safe ground truth: on-chain
    getTransactionHash view calls on Sepolia 11155111 and Fuji 43113, single-call
    and hand-packed 2-call MultiSend vectors, operation sensitivity, bit-flip
    negative, domain-hash cross-check) against this branch: 12/12 pass, every hash
    identical to 0.3.1.
Before/after transcript on a real Sepolia vector (copy-paste reproducible)

The vector is a live Safe 1.4.1 on Sepolia (0xf55FEba2D9c767Bf8018362d9eE4F378dcEB727E,
nonce 23); the expected safeTxHash 0xce4568d5... is the value the Safe itself returns from
an on-chain getTransactionHash view call.

$ clearsig safe-hash --chain-id 11155111 \
    --safe-address 0xf55FEba2D9c767Bf8018362d9eE4F378dcEB727E \
    --to 0x03A2e316D205c90fe412523c4F8B3Cc6DD12D6c5 \
    --data 0xdcbd41bc00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000724dcbb4ddce2355000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000056bc75e2d63100000000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000002b5e3af16b188000000000000000000000000000000000000000000000000000000b1a2bc2ec50000 \
    --nonce 23

0.3.1 (no --safe-version given, nothing said about it):

Domain Hash:                              0xd4c9881b2d1ecc4d73e9dcc8bd90b52f36f994abdcfea106e7c8398d2befd4b4
Message Hash:                             0x5cc559fd684e553e17a90da3d17dda9a702f6afd6bc945817ba78e568c43628f
Safe Transaction Hash (EIP-712 Digest):   0xce4568d561eea90aef5b1baa9a5c0f63df882f811d5b3d8183b4b9f2ca756ed9

This branch (same command, warning on stderr, hashes byte-identical):

warning: --safe-version not supplied; assuming 1.4.1. Hashes for other Safe versions differ (the EIP-712 domain changed in 1.3.0, the SafeTx gas field in 1.0.0).
Domain Hash:                              0xd4c9881b2d1ecc4d73e9dcc8bd90b52f36f994abdcfea106e7c8398d2befd4b4
Message Hash:                             0x5cc559fd684e553e17a90da3d17dda9a702f6afd6bc945817ba78e568c43628f
Safe Transaction Hash (EIP-712 Digest):   0xce4568d561eea90aef5b1baa9a5c0f63df882f811d5b3d8183b4b9f2ca756ed9

Same for the delegatecall leg: --operation 1 --to 0x9641d764fc13c8B624c04430C7356C1C7C8102e2
(MultiSendCallOnly 1.4.1) prints hashes only on 0.3.1 and the named warning on this branch,
with identical hashes.

@PatrickAlphaC

Copy link
Copy Markdown
Member

Thanks for this! I'm off at the moment, but will review when I'm back. Feel free to tag @0kage-eth as well.

A Safe below 1.3.0 uses a chainId-less EIP-712 domain and one below
1.0.0 a different SafeTx gas field, so hashing under a silently assumed
1.4.1 produces a confidently wrong hash with no signal. Print an
unmissable stderr warning whenever the default is used. Hash output is
byte-identical and the warning goes to stderr, so --json consumers are
unaffected.
An unflagged delegatecall is the highest-risk action a Safe signer can
approve: the target's code runs with the Safe's own storage and
identity. safe_hashes.sh warns on operation=1; clearsig printed hashes
only. Now safe-hash flags every --operation 1 on stderr and checks the
target against the official MultiSend deployments from
safe-global/safe-deployments: a known batching contract is named, an
unknown target gets the strongest warning. Hash output is unchanged and
--json stdout stays byte-identical.
@aelmanaa
aelmanaa force-pushed the fix/safe-hash-signer-warnings branch from 6e13540 to 9aab8a8 Compare September 9, 2026 22:45
@aelmanaa

aelmanaa commented Sep 9, 2026

Copy link
Copy Markdown
Author

Thanks @PatrickAlphaC — no rush. Rebased onto current main (post-v0.4.0) so this is mergeable again, and tagging @0kage-eth as you suggested.

The rebase was clean on the code: clearsig/cli.py and tests/cli/test_cli.py auto-merged against the includes/descriptor-hash work in #18, and clearsig/_safe_hash.py was untouched by it. The only conflict was CHANGELOG.md, where both sides had added under ## [Unreleased] — my entry now sits in [Unreleased] above the released [0.4.0] section. The diff is unchanged at +218/-6 across 4 files.

Ran the full CI sequence locally against the new base: ruff check, ruff format --check, ty check all clean, and pytest green at 336 passed (with the ERC-7730 registry cloned as CI does).

One thing you may need to do: the CI run on this PR is sitting at action_required, so the workflow needs a maintainer to approve it before it will actually run.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants