Skip to main content

hypercall_types/
validator_rsm.rs

1use alloy::{
2    primitives::{keccak256, Address, FixedBytes, U256},
3    sol,
4    sol_types::{Eip712Domain, SolStruct, SolValue},
5};
6
7use crate::directives::{HYPERCALL_MAINNET_CHAIN_ID, HYPERCALL_TESTNET_CHAIN_ID};
8
9/// EIP-712 domain name for validator-attested RSM metadata.
10pub const VALIDATOR_RSM_DOMAIN_NAME: &str = "HypercallValidatorRsm";
11/// EIP-712 domain version for validator-attested RSM metadata.
12pub const VALIDATOR_RSM_DOMAIN_VERSION: &str = "1";
13/// Schema version for the validator-RSM typed-data payload.
14pub const VALIDATOR_RSM_TYPE_VERSION: u16 = 1;
15
16/// Validation errors for validator-RSM typed-data construction.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ValidatorRsmError {
19    /// The chain id is not one of the Hypercall chain ids accepted for validator-RSM attestations.
20    UnsupportedChainId(u64),
21    /// The verifying contract is the zero address, which would make signatures replayable.
22    ZeroVerifyingContract,
23}
24
25impl std::fmt::Display for ValidatorRsmError {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Self::UnsupportedChainId(chain_id) => {
29                write!(f, "Unsupported RSM attestation chain id: {}", chain_id)
30            }
31            Self::ZeroVerifyingContract => {
32                write!(f, "RSM attestation verifying contract must not be zero")
33            }
34        }
35    }
36}
37
38impl std::error::Error for ValidatorRsmError {}
39
40sol! {
41    /// Batch metadata attested by the single validator before RSM directive submission.
42    #[derive(Debug, PartialEq, Eq)]
43    struct ValidatorRsmBatchMetadata {
44        uint64 batchSeq;
45        bytes32 prevBlockHash;
46        bytes32 commandsHash;
47        bytes32 batchRoot;
48        uint64 commandCount;
49        uint64 firstSeq;
50        bytes32 directiveHash;
51    }
52}
53
54/// Build the EIP-712 domain for validator-RSM batch metadata.
55pub fn rsm_attestation_domain(
56    chain_id: u64,
57    verifying_contract: Address,
58) -> Result<Eip712Domain, ValidatorRsmError> {
59    if chain_id != HYPERCALL_TESTNET_CHAIN_ID && chain_id != HYPERCALL_MAINNET_CHAIN_ID {
60        return Err(ValidatorRsmError::UnsupportedChainId(chain_id));
61    }
62    if verifying_contract == Address::ZERO {
63        return Err(ValidatorRsmError::ZeroVerifyingContract);
64    }
65
66    Ok(Eip712Domain {
67        name: Some(VALIDATOR_RSM_DOMAIN_NAME.into()),
68        version: Some(VALIDATOR_RSM_DOMAIN_VERSION.into()),
69        chain_id: Some(U256::from(chain_id)),
70        verifying_contract: Some(verifying_contract),
71        salt: None,
72    })
73}
74
75/// Hash the typed batch metadata struct using the EIP-712 struct hash.
76pub fn rsm_batch_metadata_struct_hash(metadata: &ValidatorRsmBatchMetadata) -> FixedBytes<32> {
77    metadata.eip712_hash_struct()
78}
79
80/// Hash the accepted batch metadata exactly as the contract stores accepted block continuity.
81pub fn rsm_batch_metadata_block_hash(metadata: &ValidatorRsmBatchMetadata) -> FixedBytes<32> {
82    keccak256(metadata.abi_encode())
83}
84
85/// Build the exact metadata object that `ValidatorRsm.sol` hashes and stores.
86///
87/// The directive bytes must be the final ABI-encoded directive submitted to the
88/// on-chain RSM entrypoint. This keeps the directive preimage at the Rust call
89/// site while committing only its hash in the attested metadata.
90pub fn rsm_batch_metadata_from_block_fields(
91    batch_seq: u64,
92    prev_block_hash: [u8; 32],
93    commands_hash: [u8; 32],
94    batch_root: [u8; 32],
95    command_count: u64,
96    first_seq: u64,
97    directive: &[u8],
98) -> ValidatorRsmBatchMetadata {
99    ValidatorRsmBatchMetadata {
100        batchSeq: batch_seq,
101        prevBlockHash: FixedBytes::from(prev_block_hash),
102        commandsHash: FixedBytes::from(commands_hash),
103        batchRoot: FixedBytes::from(batch_root),
104        commandCount: command_count,
105        firstSeq: first_seq,
106        directiveHash: rsm_directive_hash(directive),
107    }
108}
109
110/// Verify a stored block hash against the Solidity-compatible metadata hash.
111pub fn verify_rsm_batch_metadata_block_hash(
112    metadata: &ValidatorRsmBatchMetadata,
113    expected_block_hash: FixedBytes<32>,
114) -> bool {
115    rsm_batch_metadata_block_hash(metadata) == expected_block_hash
116}
117
118/// Hash the exact encoded RSM directive bytes that the validator attests.
119pub fn rsm_directive_hash(directive: &[u8]) -> FixedBytes<32> {
120    keccak256(directive)
121}
122
123/// Compute the EIP-712 signing digest for validator-RSM batch metadata.
124pub fn rsm_batch_metadata_signing_hash(
125    metadata: &ValidatorRsmBatchMetadata,
126    chain_id: u64,
127    verifying_contract: Address,
128) -> Result<FixedBytes<32>, ValidatorRsmError> {
129    let domain = rsm_attestation_domain(chain_id, verifying_contract)?;
130    Ok(metadata.eip712_signing_hash(&domain))
131}
132
133/// Verify a signing digest against the Solidity-compatible EIP-712 digest.
134pub fn verify_rsm_batch_metadata_signing_hash(
135    metadata: &ValidatorRsmBatchMetadata,
136    chain_id: u64,
137    verifying_contract: Address,
138    expected_signing_hash: FixedBytes<32>,
139) -> Result<bool, ValidatorRsmError> {
140    Ok(
141        rsm_batch_metadata_signing_hash(metadata, chain_id, verifying_contract)?
142            == expected_signing_hash,
143    )
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use alloy::primitives::{address, b256};
150
151    fn vector_contract() -> Address {
152        address!("0x1111111111111111111111111111111111111111")
153    }
154
155    fn vector_metadata() -> ValidatorRsmBatchMetadata {
156        ValidatorRsmBatchMetadata {
157            batchSeq: 7,
158            prevBlockHash: b256!(
159                "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
160            ),
161            commandsHash: b256!(
162                "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
163            ),
164            batchRoot: b256!("0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
165            commandCount: 3,
166            firstSeq: 42,
167            directiveHash: b256!(
168                "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
169            ),
170        }
171    }
172
173    #[test]
174    fn rsm_batch_metadata_type_hash_matches_vector() {
175        assert_eq!(
176            format!(
177                "{:#x}",
178                keccak256(ValidatorRsmBatchMetadata::eip712_encode_type().as_bytes())
179            ),
180            "0x5eab7bc9925825c6321b3737a205ae360c425df72b286c62fcb6d6461889fdec"
181        );
182    }
183
184    #[test]
185    fn rsm_attestation_domain_separator_matches_vector() {
186        let domain = rsm_attestation_domain(HYPERCALL_TESTNET_CHAIN_ID, vector_contract()).unwrap();
187        assert_eq!(
188            format!("{:#x}", domain.hash_struct()),
189            "0x1064700185b52737a64ef42071090ce253524f99cc4a31103c862c3093183917"
190        );
191    }
192
193    #[test]
194    fn rsm_batch_metadata_struct_hash_matches_vector() {
195        assert_eq!(
196            format!("{:#x}", rsm_batch_metadata_struct_hash(&vector_metadata())),
197            "0x467441eb6abda1b71c63dea883a5c908be089aa182cb2b92c8d92ecd8383a65a"
198        );
199    }
200
201    #[test]
202    fn rsm_batch_metadata_signing_hash_matches_vector() {
203        assert_eq!(
204            format!(
205                "0x{}",
206                hex::encode(
207                    rsm_batch_metadata_signing_hash(
208                        &vector_metadata(),
209                        HYPERCALL_TESTNET_CHAIN_ID,
210                        vector_contract()
211                    )
212                    .unwrap()
213                )
214            ),
215            "0x29d7c6368cb595fb0bab304268eeba68545f00fa973119f5713a4093f9d29298"
216        );
217    }
218
219    #[test]
220    fn rsm_batch_metadata_block_hash_matches_solidity_vector() {
221        assert_eq!(
222            format!("{:#x}", rsm_batch_metadata_block_hash(&vector_metadata())),
223            "0xc321d2803249bd565800c1dd0160762abe459f87efe066c1e07034100e8f7383"
224        );
225    }
226
227    #[test]
228    fn rsm_directive_hash_uses_exact_encoded_bytes() {
229        let directive = hex::decode("1234abcd00010203040506070809").unwrap();
230        let mut changed = directive.clone();
231        changed.push(0);
232
233        assert_eq!(
234            format!("{:#x}", rsm_directive_hash(&directive)),
235            "0x23c304adf8d57cbac7834a6ce5df58c8d3206ebb0eb1d92e31e5af712dff6998"
236        );
237        assert_ne!(rsm_directive_hash(&directive), rsm_directive_hash(&changed));
238    }
239
240    #[test]
241    fn rsm_batch_metadata_from_block_fields_matches_solidity_hash_path() {
242        let directive = hex::decode("1234abcd00010203040506070809").unwrap();
243        let metadata = rsm_batch_metadata_from_block_fields(
244            7, [0xaa; 32], [0xbb; 32], [0xcc; 32], 3, 42, &directive,
245        );
246
247        assert_eq!(metadata.batchSeq, 7);
248        assert_eq!(metadata.prevBlockHash, FixedBytes::from([0xaa; 32]));
249        assert_eq!(metadata.commandsHash, FixedBytes::from([0xbb; 32]));
250        assert_eq!(metadata.batchRoot, FixedBytes::from([0xcc; 32]));
251        assert_eq!(metadata.commandCount, 3);
252        assert_eq!(metadata.firstSeq, 42);
253        assert_eq!(metadata.directiveHash, rsm_directive_hash(&directive));
254        assert_eq!(
255            format!("{:#x}", metadata.directiveHash),
256            "0x23c304adf8d57cbac7834a6ce5df58c8d3206ebb0eb1d92e31e5af712dff6998"
257        );
258        assert_eq!(
259            format!("{:#x}", rsm_batch_metadata_block_hash(&metadata)),
260            "0x726924a7662fdda1b8c7034dac4030348be82c23d092feacb58ea17308583d6d"
261        );
262        assert_eq!(
263            format!(
264                "{:#x}",
265                rsm_batch_metadata_signing_hash(
266                    &metadata,
267                    HYPERCALL_TESTNET_CHAIN_ID,
268                    vector_contract()
269                )
270                .unwrap()
271            ),
272            "0xd9fd769dd0090b48ea792e9682d674f8e858cb17ddcfcd5673de81b942b64bfa"
273        );
274    }
275
276    #[test]
277    fn rsm_batch_metadata_verify_helpers_reject_tampering() {
278        let metadata = vector_metadata();
279        let block_hash = rsm_batch_metadata_block_hash(&metadata);
280        let signing_hash = rsm_batch_metadata_signing_hash(
281            &metadata,
282            HYPERCALL_TESTNET_CHAIN_ID,
283            vector_contract(),
284        )
285        .unwrap();
286
287        assert!(verify_rsm_batch_metadata_block_hash(&metadata, block_hash));
288        assert!(verify_rsm_batch_metadata_signing_hash(
289            &metadata,
290            HYPERCALL_TESTNET_CHAIN_ID,
291            vector_contract(),
292            signing_hash
293        )
294        .unwrap());
295
296        let mut tampered = metadata;
297        tampered.commandCount += 1;
298        assert!(!verify_rsm_batch_metadata_block_hash(&tampered, block_hash));
299        assert!(!verify_rsm_batch_metadata_signing_hash(
300            &tampered,
301            HYPERCALL_TESTNET_CHAIN_ID,
302            vector_contract(),
303            signing_hash
304        )
305        .unwrap());
306    }
307
308    #[test]
309    fn rsm_batch_metadata_field_mutations_change_signing_hash() {
310        let base = vector_metadata();
311        let base_hash =
312            rsm_batch_metadata_signing_hash(&base, HYPERCALL_TESTNET_CHAIN_ID, vector_contract())
313                .unwrap();
314
315        let mut cases = Vec::new();
316
317        let mut changed = base.clone();
318        changed.batchSeq += 1;
319        cases.push(changed);
320
321        let mut changed = base.clone();
322        changed.prevBlockHash = FixedBytes::from([0x11; 32]);
323        cases.push(changed);
324
325        let mut changed = base.clone();
326        changed.commandsHash = FixedBytes::from([0x22; 32]);
327        cases.push(changed);
328
329        let mut changed = base.clone();
330        changed.batchRoot = FixedBytes::from([0x33; 32]);
331        cases.push(changed);
332
333        let mut changed = base.clone();
334        changed.commandCount += 1;
335        cases.push(changed);
336
337        let mut changed = base.clone();
338        changed.firstSeq += 1;
339        cases.push(changed);
340
341        let mut changed = base;
342        changed.directiveHash = FixedBytes::from([0x44; 32]);
343        cases.push(changed);
344
345        for changed in cases {
346            let changed_hash = rsm_batch_metadata_signing_hash(
347                &changed,
348                HYPERCALL_TESTNET_CHAIN_ID,
349                vector_contract(),
350            )
351            .unwrap();
352            assert_ne!(base_hash, changed_hash);
353        }
354    }
355
356    #[test]
357    fn rsm_batch_metadata_field_mutations_change_block_hash() {
358        let base = vector_metadata();
359        let base_hash = rsm_batch_metadata_block_hash(&base);
360        let mut changed = base.clone();
361        changed.batchSeq += 1;
362        assert_ne!(base_hash, rsm_batch_metadata_block_hash(&changed));
363
364        let mut changed = base;
365        changed.directiveHash = FixedBytes::from([0x44; 32]);
366        assert_ne!(base_hash, rsm_batch_metadata_block_hash(&changed));
367    }
368
369    #[test]
370    fn rsm_batch_metadata_domain_changes_affect_signing_hash() {
371        let metadata = vector_metadata();
372        let base_hash = rsm_batch_metadata_signing_hash(
373            &metadata,
374            HYPERCALL_TESTNET_CHAIN_ID,
375            vector_contract(),
376        )
377        .unwrap();
378        let other_chain_hash = rsm_batch_metadata_signing_hash(
379            &metadata,
380            HYPERCALL_MAINNET_CHAIN_ID,
381            vector_contract(),
382        )
383        .unwrap();
384        let other_contract_hash = rsm_batch_metadata_signing_hash(
385            &metadata,
386            HYPERCALL_TESTNET_CHAIN_ID,
387            address!("0x2222222222222222222222222222222222222222"),
388        )
389        .unwrap();
390
391        assert_ne!(base_hash, other_chain_hash);
392        assert_ne!(base_hash, other_contract_hash);
393    }
394
395    #[test]
396    fn rsm_attestation_domain_rejects_invalid_inputs() {
397        assert_eq!(
398            rsm_attestation_domain(1, vector_contract()).unwrap_err(),
399            ValidatorRsmError::UnsupportedChainId(1)
400        );
401        assert_eq!(
402            rsm_attestation_domain(HYPERCALL_TESTNET_CHAIN_ID, Address::ZERO).unwrap_err(),
403            ValidatorRsmError::ZeroVerifyingContract
404        );
405    }
406}