Skip to content

feat: validate proposed transactions against local payloads - #36

Merged
aviggiano merged 2 commits into
Recon-Fuzz:mainfrom
aelmanaa:proposal-validation
Nov 4, 2026
Merged

aviggiano merged 2 commits into
Recon-Fuzz:mainfrom
aelmanaa:proposal-validation

Conversation

@aelmanaa

Copy link
Copy Markdown
Contributor

Closes #5.

A Ledger or Trezor shows only hashes when signing a Safe transaction. This PR lets a co-signer check, from the same Foundry script that built the transaction, that a proposal on the Transaction Service is the transaction they mean to sign.

Hashes

  • getSafeTxHashes(safe, chainId, to, value, data, operation, nonce) (pure) and safe.getSafeTxHashes(to, value, data, operation, nonce) (current Safe and chain) return SafeTxHashes { domainSeparator, messageHash, safeTxHash }, computed locally rather than with the Safe's getTransactionHash. This is the same triple the Cyfrin safe-hash tool and OpenZeppelin's Safe utils show.
  • logSafeTxHashes(hashes) logs them as [safe-utils] domain hash: ..., [safe-utils] message hash: ... and [safe-utils] safeTxHash: .... proposeTransaction now logs them too after a successful proposal, so the proposer can share them with co-signers.

Validation

  • safe.validateProposedTransaction(safeTxHash, to, value, data, operation, nonce) fetches GET {apiKitUrl}/v2/multisig-transactions/{safeTxHash}/, the endpoint api-kit's getTransaction uses. It compares safeTxHash, safe, to, value, data, operation, nonce, safeTxGas, baseGas, gasPrice, gasToken and refundReceiver, then recomputes the safeTxHash. On the first difference it reverts with ProposedTransactionMismatch(field, expected, actual), for example ("nonce", "4", "3"). On success it logs the three hashes.
  • safe.validateProposedTransactions(safeTxHash, targets, datas, nonce) rebuilds the MultiSend payload with getProposeTransactionsTargetAndData and expects DelegateCall, the same way proposeTransactions does.
  • safe.validateProposedTransactionJson(json, ...) validates a response that has already been fetched. getProposedTransaction and getProposedTransactionUrl expose the fetch and the URL. A non-2xx response reverts with GetProposedTransactionFailed(safeTxHash, status, response).

All new functions are additions. No existing signature changed.

Limits

  • The gas fields must be zero, as everywhere else in the library. A proposal with non-zero gas fields fails validation and names the field.
  • The domain includes chainId, which is correct for Safe v1.3.0 and later. Older Safes use a domain without chainId and are not supported.
  • Calldata is compared byte for byte, not decoded (decoding is Improve Payload Decoding and Visualization #7).

Testing

  • test/SafeValidation.t.sol adds 15 tests:
    • Hashes match a vector computed with viem 2.57.5 (hashDomain, hashStruct, hashTypedData).
    • Hashes match a real proposal's safeTxHash from the Base Transaction Service.
    • On a Base fork, hashes match the Safe v1.4.1 contract's domainSeparator() and getTransactionHash, for a single call, for a batch, and in a fuzz test with 256 runs.
    • Validation runs against test/fixtures/base-multisig-transaction.json, a real Transaction Service response saved on 2026-10-31. It covers a match, wrong data, wrong nonce, wrong to, wrong operation, wrong Safe, wrong chain, a different safeTxHash, null data, and the fetch URL.
  • The validation tests do not call the Transaction Service. foundry.toml gains read-only fs_permissions for ./test/fixtures.
  • The fetch path was checked by hand against the live Base service: the match passed, a wrong batch reverted on data, and an unknown hash reverted with a 404. That check is not in the suite. When I added it, the existing propose tests hit HTTP 429 ("Too many requests per second") in parallel runs.
  • With src/ stashed, the new tests do not compile. Changing the 0x01 EIP-712 prefix byte makes 6 of the 15 tests fail.
  • forge test passes 44/44 (29 before). forge fmt --check is clean. Checked with forge 1.8.7.

🤖 Generated with Claude Code

…zz#5)

Add getSafeTxHashes, which computes the EIP-712 domain separator, SafeTx
message hash and safeTxHash locally (the triple a hardware wallet shows),
and logSafeTxHashes, which logs them as [safe-utils] lines.

Add validateProposedTransaction and validateProposedTransactions, which
fetch a proposal from the Transaction Service
(GET /v2/multisig-transactions/{safeTxHash}/, as api-kit does), compare
safe, to, value, data, operation, nonce and the zeroed gas fields with
the locally built transaction, and recompute the safeTxHash. They revert
with ProposedTransactionMismatch(field, expected, actual) on the first
difference. validateProposedTransactionJson checks an already-fetched
response, so tests run against a fixture instead of the live service.

proposeTransaction now also logs the three hashes after a successful
proposal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aviggiano

Copy link
Copy Markdown
Collaborator

Thanks, this covers #5. The field comparisons and hash calculations look good. I’ll merge once CI passes and follow up with more malformed-response tests and README notes about the Safe v1.3+ and zero gas/refund requirements.

@aviggiano
aviggiano merged commit a4404a3 into Recon-Fuzz:main Nov 4, 2026
2 checks passed
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.

Add Proposal Validation Functionality to Safe Utils

2 participants