Skip to main content

espresso_contract_deployer/proposals/
safe_hash.rs

1//! EIP-712 Safe transaction hash computation (Safe v1.4.1).
2
3use alloy::primitives::{Address, B256, Bytes, U256, keccak256};
4
5const SAFE_TX_TYPEHASH_PREIMAGE: &[u8] = b"SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)";
6
7#[derive(Debug, Clone)]
8pub struct SafeTxHashes {
9    pub domain: B256,
10    pub message: B256,
11    pub safe_tx: B256,
12}
13
14/// Compute EIP-712 Safe transaction hashes for a single-tx batch (operation=0).
15///
16/// Matches the domainSeparator and message hash produced by Safe v1.4.1
17/// (`EIP712Domain(uint256 chainId,address verifyingContract)`).
18pub fn safe_tx_hashes(
19    safe: Address,
20    chain_id: u64,
21    to: Address,
22    value: U256,
23    data: &Bytes,
24    operation: u8,
25    nonce: u64,
26) -> SafeTxHashes {
27    let domain_typehash = keccak256(b"EIP712Domain(uint256 chainId,address verifyingContract)");
28    let mut domain_encoded = [0u8; 96];
29    domain_encoded[..32].copy_from_slice(domain_typehash.as_ref());
30    domain_encoded[32..64]
31        .copy_from_slice(&alloy::primitives::U256::from(chain_id).to_be_bytes::<32>());
32    domain_encoded[64 + 12..].copy_from_slice(safe.as_slice());
33    let domain_hash = keccak256(domain_encoded);
34
35    let data_hash = keccak256(data.as_ref());
36    let to_padded: [u8; 32] = {
37        let mut b = [0u8; 32];
38        b[12..].copy_from_slice(to.as_slice());
39        b
40    };
41    let safe_tx_typehash = keccak256(SAFE_TX_TYPEHASH_PREIMAGE);
42
43    let mut msg_encoded = [0u8; 32 * 11];
44    msg_encoded[0..32].copy_from_slice(safe_tx_typehash.as_ref());
45    msg_encoded[32..64].copy_from_slice(&to_padded);
46    msg_encoded[64..96].copy_from_slice(&value.to_be_bytes::<32>());
47    msg_encoded[96..128].copy_from_slice(data_hash.as_ref());
48    msg_encoded[128..160]
49        .copy_from_slice(&alloy::primitives::U256::from(operation).to_be_bytes::<32>());
50    // safeTxGas, baseGas, gasPrice = 0; gasToken, refundReceiver = zero (already zero)
51    msg_encoded[320..352]
52        .copy_from_slice(&alloy::primitives::U256::from(nonce).to_be_bytes::<32>());
53    let message_hash = keccak256(msg_encoded);
54
55    let mut final_encoded = [0u8; 66];
56    final_encoded[0] = 0x19;
57    final_encoded[1] = 0x01;
58    final_encoded[2..34].copy_from_slice(domain_hash.as_ref());
59    final_encoded[34..66].copy_from_slice(message_hash.as_ref());
60
61    SafeTxHashes {
62        domain: domain_hash,
63        message: message_hash,
64        safe_tx: keccak256(final_encoded),
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use alloy::primitives::{Address, B256, Bytes, U256};
71
72    use super::*;
73
74    // ── TEST:safe-hash-known-vector ────────────────────────────────────────────
75    //
76    // Known-vector against on-chain domainSeparator for Safe v1.4.1
77    // at 0xb76834e371b666feee48e5d7d9a97ca08b5a0620 on chain 11155111.
78    #[test]
79    fn test_safe_tx_hash_known_vector() {
80        let safe: Address = "0xb76834e371b666feee48e5d7d9a97ca08b5a0620"
81            .parse()
82            .unwrap();
83        let chain_id: u64 = 11155111;
84        let timelock: Address = "0x8e3b6563d683b87964104a2c3a4bf542bb70767f"
85            .parse()
86            .unwrap();
87
88        // Pre-computed schedule calldata from decaf fixture (nonce=24).
89        // Full calldata bytes omitted; we test domain hash which depends only on safe+chain_id.
90        let data = Bytes::new();
91
92        let hashes = safe_tx_hashes(safe, chain_id, timelock, U256::ZERO, &data, 0, 24);
93
94        let expected_domain: B256 =
95            "0x8f560c9d209e6d9320305560aee98fa1dea01510aa5451a9c0911401893835c6"
96                .parse()
97                .unwrap();
98        assert_eq!(hashes.domain, expected_domain);
99    }
100}