Skip to main content

nautilus_derive/signing/
eip712.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! EIP-712 typed-data hashing and signing for Derive self-custodial actions.
17//!
18//! ```text
19//! action_hash = keccak256(abi.encode(
20//!     [bytes32, uint256, uint256, address, bytes32, uint256, address, address],
21//!     [ACTION_TYPEHASH, subaccount_id, nonce, module_address,
22//!      keccak256(module_data_abi_encoded), signature_expiry_sec, owner, signer],
23//! ))
24//! typed_data_hash = keccak256(0x1901 || DOMAIN_SEPARATOR || action_hash)
25//! signature = secp256k1_sign(typed_data_hash, signer_key)
26//! ```
27
28use alloy::{
29    signers::{SignerSync, local::PrivateKeySigner},
30    sol_types::SolValue,
31};
32use alloy_primitives::{Address, B256, U256, keccak256};
33use nautilus_core::string::secret::REDACTED;
34use thiserror::Error;
35use zeroize::{Zeroize, ZeroizeOnDrop};
36
37use crate::{
38    common::consts::MIN_SIGNATURE_TTL,
39    signing::{encoding::utc_now_ms, modules::ModuleData},
40};
41
42/// Errors raised while building or signing an EIP-712 action.
43#[derive(Debug, Error)]
44pub enum TypedDataError {
45    /// `signature_expiry_sec` is shorter than the adapter's minimum TTL
46    /// ([`MIN_SIGNATURE_TTL`]).
47    #[error(
48        "signature expiry {expiry} must be at least {min_ttl_secs}s in the future of now {now}"
49    )]
50    ExpiryTooSoon {
51        /// Caller-supplied expiry (UNIX seconds).
52        expiry: i64,
53        /// Reference `now` (UNIX seconds).
54        now: i64,
55        /// Configured minimum TTL in seconds.
56        min_ttl_secs: i64,
57    },
58    /// The system clock is before the UNIX epoch.
59    #[error("system clock is before UNIX epoch")]
60    ClockBeforeEpoch,
61    /// secp256k1 signing failed.
62    #[error("signing failed: {message}")]
63    SigningFailed {
64        /// Signer error message.
65        message: String,
66    },
67    /// The module-data ABI encoder rejected the payload (e.g. negative
68    /// `max_fee`, decimal scaling overflow).
69    #[error("module data encoding failed: {message}")]
70    ModuleEncoding {
71        /// Underlying module-encoder error message.
72        message: String,
73    },
74}
75
76/// Inputs to the EIP-712 action hash, common across all module variants.
77#[derive(Debug, Clone)]
78pub struct ActionContext {
79    /// Subaccount identifier used in both the signing payload and the request.
80    pub subaccount_id: u64,
81    /// Per-action UNIX nanosecond nonce (see [`crate::signing::nonce`]).
82    pub nonce: u64,
83    /// Per-action module contract address.
84    pub module_address: Address,
85    /// Signature expiry in UNIX seconds.
86    pub signature_expiry_sec: i64,
87    /// Owner's EOA or multisig address (`owner` slot in the EIP-712 payload).
88    pub owner: Address,
89    /// Session-key wallet address (`signer` slot in the EIP-712 payload).
90    pub signer: Address,
91}
92
93/// Computes the EIP-712 action hash for a Derive self-custodial action.
94///
95/// `module_data_hash` must be `keccak256(module_data.to_abi_encoded())` from
96/// the per-module encoder; see [`crate::signing::modules`].
97#[must_use]
98pub fn compute_action_hash(
99    ctx: &ActionContext,
100    module_data_hash: B256,
101    action_typehash: B256,
102) -> B256 {
103    let tuple = (
104        action_typehash,
105        U256::from(ctx.subaccount_id),
106        U256::from(ctx.nonce),
107        ctx.module_address,
108        module_data_hash,
109        U256::from(ctx.signature_expiry_sec),
110        ctx.owner,
111        ctx.signer,
112    );
113    keccak256(tuple.abi_encode())
114}
115
116/// Composes the final EIP-712 typed-data hash to be signed by the session key.
117///
118/// `0x19 0x01 || domain_separator || action_hash`, then keccak256.
119#[must_use]
120pub fn compute_typed_data_hash(domain_separator: B256, action_hash: B256) -> B256 {
121    let mut buf = Vec::with_capacity(2 + 32 + 32);
122    buf.push(0x19);
123    buf.push(0x01);
124    buf.extend_from_slice(domain_separator.as_slice());
125    buf.extend_from_slice(action_hash.as_slice());
126    keccak256(&buf)
127}
128
129/// A self-custodial action ready to be sent to the venue once signed.
130///
131/// The struct binds the EIP-712 action context to the module-specific payload
132/// and tracks the resulting 65-byte signature. Compose it via [`SignedAction::new`],
133/// then call [`SignedAction::sign`] with the session-key signer.
134pub struct SignedAction<'a, M: ModuleData> {
135    ctx: ActionContext,
136    module_data: &'a M,
137    domain_separator: B256,
138    action_typehash: B256,
139    signature: Option<[u8; 65]>,
140}
141
142impl<M> std::fmt::Debug for SignedAction<'_, M>
143where
144    M: ModuleData + std::fmt::Debug,
145{
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.debug_struct(stringify!(SignedAction))
148            .field("ctx", &self.ctx)
149            .field("module_data", &self.module_data)
150            .field("domain_separator", &self.domain_separator)
151            .field("action_typehash", &self.action_typehash)
152            .field("signature", &self.signature.map(|_| REDACTED))
153            .finish()
154    }
155}
156
157impl<M: ModuleData> Zeroize for SignedAction<'_, M> {
158    fn zeroize(&mut self) {
159        self.signature.zeroize();
160    }
161}
162
163impl<M: ModuleData> Drop for SignedAction<'_, M> {
164    fn drop(&mut self) {
165        self.zeroize();
166    }
167}
168
169impl<M: ModuleData> ZeroizeOnDrop for SignedAction<'_, M> {}
170
171impl<'a, M: ModuleData> SignedAction<'a, M> {
172    /// Constructs a new unsigned action.
173    #[must_use]
174    pub fn new(
175        ctx: ActionContext,
176        module_data: &'a M,
177        domain_separator: B256,
178        action_typehash: B256,
179    ) -> Self {
180        Self {
181            ctx,
182            module_data,
183            domain_separator,
184            action_typehash,
185            signature: None,
186        }
187    }
188
189    /// Signs the action using the supplied secp256k1 session-key signer.
190    ///
191    /// Validates `signature_expiry_sec` against [`MIN_SIGNATURE_TTL`] before
192    /// hashing; the adapter conservatively requires five minutes in the future.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if the configured expiry is closer to `now` than the venue minimum, if the
197    /// system clock is invalid, if the per-module ABI encoder rejects the payload, or if the
198    /// underlying secp256k1 signer errors.
199    pub fn sign(&mut self, signer: &PrivateKeySigner) -> Result<[u8; 65], TypedDataError> {
200        self.validate_expiry()?;
201
202        let module_data_bytes =
203            self.module_data
204                .to_abi_encoded()
205                .map_err(|e| TypedDataError::ModuleEncoding {
206                    message: e.to_string(),
207                })?;
208
209        let module_data_hash = keccak256(module_data_bytes);
210        let action_hash = compute_action_hash(&self.ctx, module_data_hash, self.action_typehash);
211        let typed_data_hash = compute_typed_data_hash(self.domain_separator, action_hash);
212
213        let signature =
214            signer
215                .sign_hash_sync(&typed_data_hash)
216                .map_err(|e| TypedDataError::SigningFailed {
217                    message: e.to_string(),
218                })?;
219
220        let bytes = signature.as_bytes();
221        self.signature = Some(bytes);
222        Ok(bytes)
223    }
224
225    /// Returns the signature as a `0x`-prefixed 130-character hex string.
226    /// Panics if [`SignedAction::sign`] has not yet been called.
227    ///
228    /// # Panics
229    ///
230    /// Panics if [`SignedAction::sign`] has not been called.
231    #[must_use]
232    pub fn signature_hex(&self) -> String {
233        let bytes = self.signature.expect("signature_hex called before sign");
234        format!("0x{}", alloy_primitives::hex::encode(bytes))
235    }
236
237    /// Returns the signed action's subaccount id.
238    #[must_use]
239    pub const fn subaccount_id(&self) -> u64 {
240        self.ctx.subaccount_id
241    }
242
243    /// Returns the signed action's nonce.
244    #[must_use]
245    pub const fn nonce(&self) -> u64 {
246        self.ctx.nonce
247    }
248
249    /// Returns the signed action's session-key signer address.
250    #[must_use]
251    pub const fn signer_address(&self) -> Address {
252        self.ctx.signer
253    }
254
255    /// Returns the signed action's signature expiry in UNIX seconds.
256    #[must_use]
257    pub const fn signature_expiry_sec(&self) -> i64 {
258        self.ctx.signature_expiry_sec
259    }
260
261    fn validate_expiry(&self) -> Result<(), TypedDataError> {
262        let now_ms = utc_now_ms().map_err(|_| TypedDataError::ClockBeforeEpoch)?;
263        let now_secs = (now_ms / 1000) as i64;
264        let min_ttl_secs = MIN_SIGNATURE_TTL.as_secs() as i64;
265        if self.ctx.signature_expiry_sec < now_secs.saturating_add(min_ttl_secs) {
266            return Err(TypedDataError::ExpiryTooSoon {
267                expiry: self.ctx.signature_expiry_sec,
268                now: now_secs,
269                min_ttl_secs,
270            });
271        }
272
273        Ok(())
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use std::time::{SystemTime, UNIX_EPOCH};
280
281    use alloy_primitives::{Signature, hex};
282    use rstest::rstest;
283    use rust_decimal::Decimal;
284    use rust_decimal_macros::dec;
285    use serde::Deserialize;
286
287    use super::*;
288    use crate::{
289        common::{
290            consts::{ACTION_TYPEHASH, domain_separator_for, trade_module_address_for},
291            enums::DeriveEnvironment,
292        },
293        signing::modules::trade::TradeModuleData,
294    };
295
296    const SESSION_KEY_HEX: &str =
297        "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
298
299    fn fixed_typehash() -> B256 {
300        // Arbitrary but stable test typehash. Real value comes from Protocol
301        // Constants at docs.derive.xyz.
302        "0x1111111111111111111111111111111111111111111111111111111111111111"
303            .parse()
304            .unwrap()
305    }
306
307    fn fixed_domain() -> B256 {
308        "0x2222222222222222222222222222222222222222222222222222222222222222"
309            .parse()
310            .unwrap()
311    }
312
313    fn module_addr() -> Address {
314        "0x000000000000000000000000000000000000bbbb"
315            .parse()
316            .unwrap()
317    }
318
319    fn owner() -> Address {
320        "0x000000000000000000000000000000000000aaaa"
321            .parse()
322            .unwrap()
323    }
324
325    fn fresh_expiry() -> i64 {
326        let now = SystemTime::now()
327            .duration_since(UNIX_EPOCH)
328            .unwrap()
329            .as_secs() as i64;
330        now + 3600
331    }
332
333    fn sample_trade() -> TradeModuleData {
334        TradeModuleData {
335            asset_address: "0x000000000000000000000000000000000000abcd"
336                .parse()
337                .unwrap(),
338            sub_id: U256::from(42),
339            limit_price: dec!(100),
340            amount: dec!(1),
341            max_fee: dec!(1000),
342            recipient_id: 30769,
343            is_bid: true,
344        }
345    }
346
347    fn sample_ctx(signer: Address, expiry: i64) -> ActionContext {
348        ActionContext {
349            subaccount_id: 30769,
350            nonce: 1_695_836_058_725_001,
351            module_address: module_addr(),
352            signature_expiry_sec: expiry,
353            owner: owner(),
354            signer,
355        }
356    }
357
358    #[rstest]
359    fn test_compute_action_hash_changes_with_subaccount() {
360        let module_hash = keccak256(sample_trade().to_abi_encoded().unwrap());
361        let mut ctx = sample_ctx(owner(), fresh_expiry());
362        let h1 = compute_action_hash(&ctx, module_hash, fixed_typehash());
363        ctx.subaccount_id += 1;
364        let h2 = compute_action_hash(&ctx, module_hash, fixed_typehash());
365        assert_ne!(h1, h2, "changing subaccount must change the hash");
366    }
367
368    #[rstest]
369    fn test_compute_action_hash_pins_byte_layout() {
370        // Lock the 8-field ABI tuple shape against drift. The order
371        // (typehash, subaccount, nonce, module, module_data_hash, expiry,
372        // owner, signer) is the load-bearing protocol contract for
373        // byte-equivalence with the upstream derive-py SDK; a
374        // swap, drop, or reorder would slip past relative-change tests.
375        let module_hash: B256 =
376            "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
377                .parse()
378                .unwrap();
379
380        let ctx = ActionContext {
381            subaccount_id: 30769,
382            nonce: 1_695_836_058_725_001,
383            module_address: module_addr(),
384            signature_expiry_sec: 1_700_000_000,
385            owner: owner(),
386            signer: "0x000000000000000000000000000000000000cccc"
387                .parse()
388                .unwrap(),
389        };
390
391        let hash = compute_action_hash(&ctx, module_hash, fixed_typehash());
392        let expected = "0x509b526a0413577f827d7ebaf5b3fed1eb24bb480612b4e705e1001126f04a1b";
393        assert_eq!(format!("{hash:?}"), expected, "action-hash layout drift");
394    }
395
396    #[rstest]
397    fn test_compute_typed_data_hash_pins_byte_layout() {
398        // Lock the 0x1901 || domain || action_hash composition. Reordering
399        // domain and action_hash, or dropping the prefix, would alter the
400        // exact byte value below.
401        let action_hash: B256 =
402            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
403                .parse()
404                .unwrap();
405        let hash = compute_typed_data_hash(fixed_domain(), action_hash);
406        let expected = "0x939b63f7cb4f2902be3004edd4f758ce4af26b96d12fd0992957a4cf5d287312";
407        assert_eq!(
408            format!("{hash:?}"),
409            expected,
410            "typed-data hash composition drift",
411        );
412    }
413
414    #[rstest]
415    fn test_compute_typed_data_hash_includes_19_01_prefix() {
416        // Construct a known input pair and verify the prefix participates by
417        // showing the hash differs from the bare keccak of its components.
418        let action_hash: B256 =
419            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
420                .parse()
421                .unwrap();
422        let with_prefix = compute_typed_data_hash(fixed_domain(), action_hash);
423        let mut bare = Vec::with_capacity(64);
424        bare.extend_from_slice(fixed_domain().as_slice());
425        bare.extend_from_slice(action_hash.as_slice());
426        let without_prefix = keccak256(&bare);
427        assert_ne!(
428            with_prefix, without_prefix,
429            "the 0x1901 prefix must change the digest",
430        );
431    }
432
433    #[rstest]
434    fn test_sign_rejects_expiry_that_is_too_soon() {
435        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
436        let near_expiry = SystemTime::now()
437            .duration_since(UNIX_EPOCH)
438            .unwrap()
439            .as_secs() as i64
440            + MIN_SIGNATURE_TTL.as_secs() as i64
441            - 1;
442        let ctx = sample_ctx(signer.address(), near_expiry);
443        let trade = sample_trade();
444        let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
445        let err = action.sign(&signer).expect_err("must reject near expiry");
446        assert!(
447            matches!(err, TypedDataError::ExpiryTooSoon { .. }),
448            "expected ExpiryTooSoon, was {err:?}",
449        );
450    }
451
452    #[rstest]
453    fn test_sign_produces_recoverable_signature() {
454        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
455        let ctx = sample_ctx(signer.address(), fresh_expiry());
456        let trade = sample_trade();
457
458        let module_data_hash = keccak256(trade.to_abi_encoded().unwrap());
459        let action_hash = compute_action_hash(&ctx, module_data_hash, fixed_typehash());
460        let typed_data_hash = compute_typed_data_hash(fixed_domain(), action_hash);
461
462        let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
463        let raw = action.sign(&signer).expect("sign must succeed");
464        let debug = format!("{action:?}");
465        assert_eq!(raw.len(), 65);
466        assert!(debug.contains(REDACTED));
467
468        // Recover the signer from the signature and verify it matches the
469        // session key. This is the venue's verification path inverted.
470        let signature = Signature::try_from(raw.as_slice()).expect("65-byte sig");
471        let recovered = signature
472            .recover_address_from_prehash(&typed_data_hash)
473            .expect("recover");
474        assert_eq!(recovered, signer.address());
475
476        action.zeroize();
477        assert!(action.signature.is_none());
478    }
479
480    #[rstest]
481    fn test_sign_propagates_module_encoding_error() {
482        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
483        let ctx = sample_ctx(signer.address(), fresh_expiry());
484        let mut bad_trade = sample_trade();
485        bad_trade.max_fee = dec!(-1);
486        let mut action = SignedAction::new(ctx, &bad_trade, fixed_domain(), fixed_typehash());
487        let err = action
488            .sign(&signer)
489            .expect_err("invalid trade input must surface as a typed error, not a panic");
490
491        match err {
492            TypedDataError::ModuleEncoding { message } => {
493                assert!(message.contains("max_fee"), "unexpected message: {message}");
494            }
495            other => panic!("expected ModuleEncoding, was {other:?}"),
496        }
497    }
498
499    #[rstest]
500    fn test_signed_action_accessors_expose_request_envelope_fields() {
501        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
502        let ctx = sample_ctx(signer.address(), fresh_expiry());
503        let trade = sample_trade();
504        let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
505        action.sign(&signer).unwrap();
506
507        let sig = action.signature_hex();
508        assert!(sig.starts_with("0x"));
509        assert_eq!(sig.len(), 2 + 130, "0x + 65 bytes hex = 132 chars");
510        assert_eq!(action.nonce(), 1_695_836_058_725_001_u64);
511        assert_eq!(action.subaccount_id(), 30769);
512        assert_eq!(action.signer_address(), signer.address());
513        assert!(action.signature_expiry_sec() > 0);
514        // Decoding the hex back produces 65 bytes
515        let bytes = hex::decode(sig.trim_start_matches("0x")).unwrap();
516        assert_eq!(bytes.len(), 65);
517    }
518
519    const ORACLE_JSON: &str =
520        include_str!("../../test_data/common/signing_trade_action_vectors.json");
521
522    #[derive(Debug, Deserialize)]
523    struct OracleFile {
524        metadata: OracleMetadata,
525        vectors: Vec<OracleVector>,
526    }
527
528    #[derive(Debug, Deserialize)]
529    struct OracleMetadata {
530        source: String,
531        upstream_version: String,
532        upstream_revision: String,
533        generated_by: String,
534    }
535
536    #[derive(Debug, Deserialize)]
537    struct OracleVector {
538        case: String,
539        environment: String,
540        domain_separator: String,
541        action_typehash: String,
542        module_address: String,
543        subaccount_id: u64,
544        #[serde(deserialize_with = "crate::signing::nonce::deserialize_nonce")]
545        nonce: u64,
546        signature_expiry_sec: i64,
547        owner: String,
548        session_key: String,
549        signer: String,
550        trade: OracleTrade,
551        module_data: String,
552        module_data_hash: String,
553        action_hash: String,
554        typed_data_hash: String,
555        signature: String,
556    }
557
558    #[derive(Debug, Deserialize)]
559    struct OracleTrade {
560        asset_address: String,
561        sub_id: String,
562        limit_price: String,
563        amount: String,
564        max_fee: String,
565        recipient_id: u64,
566        is_bid: bool,
567    }
568
569    fn parse_oracle() -> OracleFile {
570        serde_json::from_str(ORACLE_JSON).expect("parse signing oracle fixture")
571    }
572
573    #[rstest]
574    fn test_oracle_fixture_records_upstream_provenance() {
575        let oracle = parse_oracle();
576        let metadata = &oracle.metadata;
577        assert_eq!(metadata.source, "https://github.com/derivexyz/derive-py");
578        assert_eq!(metadata.upstream_version, "0.1.4");
579        assert_eq!(
580            metadata.upstream_revision,
581            "fad785e6c328746b5f8a8219e14009670bc97a35"
582        );
583        assert_eq!(
584            metadata.generated_by,
585            "scripts/oracle-py/derive/generate_oracle.py"
586        );
587        assert!(!oracle.vectors.is_empty(), "oracle fixture has no vectors");
588    }
589
590    #[rstest]
591    fn test_oracle_vectors_use_production_protocol_constants() {
592        // The fixture must exercise the same constants production resolves
593        // from `common::consts`; an internally consistent fixture generated
594        // from mistyped generator constants would otherwise pass the
595        // equivalence test while diverging from live configuration.
596        let oracle = parse_oracle();
597
598        for (i, v) in oracle.vectors.iter().enumerate() {
599            let environment = match v.environment.as_str() {
600                "mainnet" => DeriveEnvironment::Mainnet,
601                "testnet" => DeriveEnvironment::Testnet,
602                other => panic!("vector {i}: unknown environment `{other}`"),
603            };
604
605            assert_eq!(
606                v.domain_separator,
607                domain_separator_for(environment),
608                "vector {i} ({}): domain separator does not match consts",
609                v.case,
610            );
611            assert_eq!(
612                v.action_typehash, ACTION_TYPEHASH,
613                "vector {i} ({}): action typehash does not match consts",
614                v.case,
615            );
616            assert_eq!(
617                v.module_address.to_ascii_lowercase(),
618                trade_module_address_for(environment).to_ascii_lowercase(),
619                "vector {i} ({}): trade module address does not match consts",
620                v.case,
621            );
622        }
623    }
624
625    #[rstest]
626    fn test_signing_matches_v3_sdk_oracle_vectors() {
627        // Byte-equivalence oracle against the official v3 Python SDK: every
628        // encoded module payload, module-data hash, action hash, typed-data
629        // hash, and signature must match the upstream fixture exactly. The
630        // upstream signer uses RFC 6979 deterministic nonces, so signature
631        // equality is meaningful across implementations.
632        let oracle = parse_oracle();
633
634        for (i, v) in oracle.vectors.iter().enumerate() {
635            let trade = TradeModuleData {
636                asset_address: v.trade.asset_address.parse().unwrap(),
637                sub_id: U256::from_str_radix(&v.trade.sub_id, 10).unwrap(),
638                limit_price: v.trade.limit_price.parse::<Decimal>().unwrap(),
639                amount: v.trade.amount.parse::<Decimal>().unwrap(),
640                max_fee: v.trade.max_fee.parse::<Decimal>().unwrap(),
641                recipient_id: v.trade.recipient_id,
642                is_bid: v.trade.is_bid,
643            };
644
645            let signer: PrivateKeySigner = v.session_key.parse().unwrap();
646            assert_eq!(
647                signer.address(),
648                v.signer.parse::<Address>().unwrap(),
649                "vector {i} ({}): session key does not derive the fixture signer",
650                v.case,
651            );
652
653            let encoded = trade.to_abi_encoded().unwrap();
654            assert_eq!(
655                format!("0x{}", hex::encode(&encoded)),
656                v.module_data,
657                "vector {i} ({}): encoded module data diverged from upstream",
658                v.case,
659            );
660
661            let module_data_hash = keccak256(&encoded);
662            assert_eq!(
663                format!("{module_data_hash:?}"),
664                v.module_data_hash,
665                "vector {i} ({}): module-data hash diverged from upstream",
666                v.case,
667            );
668
669            let ctx = ActionContext {
670                subaccount_id: v.subaccount_id,
671                nonce: v.nonce,
672                module_address: v.module_address.parse().unwrap(),
673                signature_expiry_sec: v.signature_expiry_sec,
674                owner: v.owner.parse().unwrap(),
675                signer: v.signer.parse().unwrap(),
676            };
677
678            let action_typehash: B256 = v.action_typehash.parse().unwrap();
679            let action_hash = compute_action_hash(&ctx, module_data_hash, action_typehash);
680            assert_eq!(
681                format!("{action_hash:?}"),
682                v.action_hash,
683                "vector {i} ({}): action hash diverged from upstream",
684                v.case,
685            );
686
687            let domain_separator: B256 = v.domain_separator.parse().unwrap();
688            let typed_data_hash = compute_typed_data_hash(domain_separator, action_hash);
689            assert_eq!(
690                format!("{typed_data_hash:?}"),
691                v.typed_data_hash,
692                "vector {i} ({}): typed-data hash diverged from upstream",
693                v.case,
694            );
695
696            let mut action = SignedAction::new(ctx, &trade, domain_separator, action_typehash);
697            let signature = action
698                .sign(&signer)
699                .expect("oracle expiry is far future, signing must succeed");
700            assert_eq!(
701                format!("0x{}", hex::encode(signature)),
702                v.signature,
703                "vector {i} ({}): signature diverged from upstream",
704                v.case,
705            );
706        }
707    }
708}