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 at or before `now`, or shorter than the
46    /// venue-required minimum TTL ([`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 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    /// Smart-contract wallet 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 venue rejects expiries less than five minutes in the
193    /// future.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`TypedDataError::ExpiryTooSoon`] when the configured expiry is
198    /// closer to `now` than the venue minimum, [`TypedDataError::ClockBeforeEpoch`]
199    /// when the system clock is invalid, [`TypedDataError::ModuleEncoding`]
200    /// when the per-module ABI encoder rejects the payload, and
201    /// [`TypedDataError::SigningFailed`] when the underlying secp256k1 signer
202    /// errors.
203    pub fn sign(&mut self, signer: &PrivateKeySigner) -> Result<[u8; 65], TypedDataError> {
204        self.validate_expiry()?;
205
206        let module_data_bytes =
207            self.module_data
208                .to_abi_encoded()
209                .map_err(|e| TypedDataError::ModuleEncoding {
210                    message: e.to_string(),
211                })?;
212        let module_data_hash = keccak256(module_data_bytes);
213        let action_hash = compute_action_hash(&self.ctx, module_data_hash, self.action_typehash);
214        let typed_data_hash = compute_typed_data_hash(self.domain_separator, action_hash);
215
216        let signature =
217            signer
218                .sign_hash_sync(&typed_data_hash)
219                .map_err(|e| TypedDataError::SigningFailed {
220                    message: e.to_string(),
221                })?;
222        let bytes = signature.as_bytes();
223        self.signature = Some(bytes);
224        Ok(bytes)
225    }
226
227    /// Returns the signature as a `0x`-prefixed 130-character hex string.
228    /// Panics if [`SignedAction::sign`] has not yet been called.
229    ///
230    /// # Panics
231    ///
232    /// Panics if [`SignedAction::sign`] has not been called.
233    #[must_use]
234    pub fn signature_hex(&self) -> String {
235        let bytes = self.signature.expect("signature_hex called before sign");
236        format!("0x{}", alloy_primitives::hex::encode(bytes))
237    }
238
239    /// Returns the signed action's subaccount id.
240    #[must_use]
241    pub const fn subaccount_id(&self) -> u64 {
242        self.ctx.subaccount_id
243    }
244
245    /// Returns the signed action's nonce.
246    #[must_use]
247    pub const fn nonce(&self) -> u64 {
248        self.ctx.nonce
249    }
250
251    /// Returns the signed action's session-key signer address.
252    #[must_use]
253    pub const fn signer_address(&self) -> Address {
254        self.ctx.signer
255    }
256
257    /// Returns the signed action's signature expiry in UNIX seconds.
258    #[must_use]
259    pub const fn signature_expiry_sec(&self) -> i64 {
260        self.ctx.signature_expiry_sec
261    }
262
263    fn validate_expiry(&self) -> Result<(), TypedDataError> {
264        let now_ms = utc_now_ms().map_err(|_| TypedDataError::ClockBeforeEpoch)?;
265        let now_secs = (now_ms / 1000) as i64;
266        let min_ttl_secs = MIN_SIGNATURE_TTL.as_secs() as i64;
267        if self.ctx.signature_expiry_sec < now_secs.saturating_add(min_ttl_secs) {
268            return Err(TypedDataError::ExpiryTooSoon {
269                expiry: self.ctx.signature_expiry_sec,
270                now: now_secs,
271                min_ttl_secs,
272            });
273        }
274        Ok(())
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use std::time::{SystemTime, UNIX_EPOCH};
281
282    use alloy_primitives::{Signature, hex};
283    use rstest::rstest;
284    use rust_decimal::Decimal;
285    use rust_decimal_macros::dec;
286    use serde::Deserialize;
287
288    use super::*;
289    use crate::{
290        common::{
291            consts::{ACTION_TYPEHASH, domain_separator_for, trade_module_address_for},
292            enums::DeriveEnvironment,
293        },
294        signing::modules::trade::TradeModuleData,
295    };
296
297    const SESSION_KEY_HEX: &str =
298        "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
299
300    fn fixed_typehash() -> B256 {
301        // Arbitrary but stable test typehash. Real value comes from Protocol
302        // Constants at docs.derive.xyz.
303        "0x1111111111111111111111111111111111111111111111111111111111111111"
304            .parse()
305            .unwrap()
306    }
307
308    fn fixed_domain() -> B256 {
309        "0x2222222222222222222222222222222222222222222222222222222222222222"
310            .parse()
311            .unwrap()
312    }
313
314    fn module_addr() -> Address {
315        "0x000000000000000000000000000000000000bbbb"
316            .parse()
317            .unwrap()
318    }
319
320    fn owner() -> Address {
321        "0x000000000000000000000000000000000000aaaa"
322            .parse()
323            .unwrap()
324    }
325
326    fn fresh_expiry() -> i64 {
327        let now = SystemTime::now()
328            .duration_since(UNIX_EPOCH)
329            .unwrap()
330            .as_secs() as i64;
331        now + 3600
332    }
333
334    fn sample_trade() -> TradeModuleData {
335        TradeModuleData {
336            asset_address: "0x000000000000000000000000000000000000abcd"
337                .parse()
338                .unwrap(),
339            sub_id: U256::from(42),
340            limit_price: dec!(100),
341            amount: dec!(1),
342            max_fee: dec!(1000),
343            recipient_id: 30769,
344            is_bid: true,
345        }
346    }
347
348    fn sample_ctx(signer: Address, expiry: i64) -> ActionContext {
349        ActionContext {
350            subaccount_id: 30769,
351            nonce: 1_695_836_058_725_001,
352            module_address: module_addr(),
353            signature_expiry_sec: expiry,
354            owner: owner(),
355            signer,
356        }
357    }
358
359    #[rstest]
360    fn test_compute_action_hash_changes_with_subaccount() {
361        let module_hash = keccak256(sample_trade().to_abi_encoded().unwrap());
362        let mut ctx = sample_ctx(owner(), fresh_expiry());
363        let h1 = compute_action_hash(&ctx, module_hash, fixed_typehash());
364        ctx.subaccount_id += 1;
365        let h2 = compute_action_hash(&ctx, module_hash, fixed_typehash());
366        assert_ne!(h1, h2, "changing subaccount must change the hash");
367    }
368
369    #[rstest]
370    fn test_compute_action_hash_pins_byte_layout() {
371        // Lock the 8-field ABI tuple shape against drift. The order
372        // (typehash, subaccount, nonce, module, module_data_hash, expiry,
373        // owner, signer) is the load-bearing protocol contract for
374        // byte-equivalence with the upstream derive_action_signing SDK; a
375        // swap, drop, or reorder would slip past relative-change tests.
376        let module_hash: B256 =
377            "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
378                .parse()
379                .unwrap();
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        let hash = compute_action_hash(&ctx, module_hash, fixed_typehash());
391        let expected = "0x509b526a0413577f827d7ebaf5b3fed1eb24bb480612b4e705e1001126f04a1b";
392        assert_eq!(format!("{hash:?}"), expected, "action-hash layout drift");
393    }
394
395    #[rstest]
396    fn test_compute_typed_data_hash_pins_byte_layout() {
397        // Lock the 0x1901 || domain || action_hash composition. Reordering
398        // domain and action_hash, or dropping the prefix, would alter the
399        // exact byte value below.
400        let action_hash: B256 =
401            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
402                .parse()
403                .unwrap();
404        let hash = compute_typed_data_hash(fixed_domain(), action_hash);
405        let expected = "0x939b63f7cb4f2902be3004edd4f758ce4af26b96d12fd0992957a4cf5d287312";
406        assert_eq!(
407            format!("{hash:?}"),
408            expected,
409            "typed-data hash composition drift",
410        );
411    }
412
413    #[rstest]
414    fn test_compute_typed_data_hash_includes_19_01_prefix() {
415        // Construct a known input pair and verify the prefix participates by
416        // showing the hash differs from the bare keccak of its components.
417        let action_hash: B256 =
418            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
419                .parse()
420                .unwrap();
421        let with_prefix = compute_typed_data_hash(fixed_domain(), action_hash);
422        let mut bare = Vec::with_capacity(64);
423        bare.extend_from_slice(fixed_domain().as_slice());
424        bare.extend_from_slice(action_hash.as_slice());
425        let without_prefix = keccak256(&bare);
426        assert_ne!(
427            with_prefix, without_prefix,
428            "the 0x1901 prefix must change the digest",
429        );
430    }
431
432    #[rstest]
433    fn test_sign_rejects_expiry_that_is_too_soon() {
434        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
435        let near_expiry = SystemTime::now()
436            .duration_since(UNIX_EPOCH)
437            .unwrap()
438            .as_secs() as i64
439            + 60; // only 1 minute, well under 5-minute MIN_SIGNATURE_TTL
440        let ctx = sample_ctx(signer.address(), near_expiry);
441        let trade = sample_trade();
442        let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
443        let err = action.sign(&signer).expect_err("must reject near expiry");
444        assert!(
445            matches!(err, TypedDataError::ExpiryTooSoon { .. }),
446            "expected ExpiryTooSoon, was {err:?}",
447        );
448    }
449
450    #[rstest]
451    fn test_sign_produces_recoverable_signature() {
452        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
453        let ctx = sample_ctx(signer.address(), fresh_expiry());
454        let trade = sample_trade();
455
456        let module_data_hash = keccak256(trade.to_abi_encoded().unwrap());
457        let action_hash = compute_action_hash(&ctx, module_data_hash, fixed_typehash());
458        let typed_data_hash = compute_typed_data_hash(fixed_domain(), action_hash);
459
460        let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
461        let raw = action.sign(&signer).expect("sign must succeed");
462        let debug = format!("{action:?}");
463        assert_eq!(raw.len(), 65);
464        assert!(debug.contains(REDACTED));
465
466        // Recover the signer from the signature and verify it matches the
467        // session key. This is the venue's verification path inverted.
468        let signature = Signature::try_from(raw.as_slice()).expect("65-byte sig");
469        let recovered = signature
470            .recover_address_from_prehash(&typed_data_hash)
471            .expect("recover");
472        assert_eq!(recovered, signer.address());
473
474        action.zeroize();
475        assert!(action.signature.is_none());
476    }
477
478    #[rstest]
479    fn test_sign_propagates_module_encoding_error() {
480        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
481        let ctx = sample_ctx(signer.address(), fresh_expiry());
482        let mut bad_trade = sample_trade();
483        bad_trade.max_fee = dec!(-1);
484        let mut action = SignedAction::new(ctx, &bad_trade, fixed_domain(), fixed_typehash());
485        let err = action
486            .sign(&signer)
487            .expect_err("invalid trade input must surface as a typed error, not a panic");
488
489        match err {
490            TypedDataError::ModuleEncoding { message } => {
491                assert!(message.contains("max_fee"), "unexpected message: {message}");
492            }
493            other => panic!("expected ModuleEncoding, was {other:?}"),
494        }
495    }
496
497    #[rstest]
498    fn test_signed_action_accessors_expose_request_envelope_fields() {
499        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
500        let ctx = sample_ctx(signer.address(), fresh_expiry());
501        let trade = sample_trade();
502        let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
503        action.sign(&signer).unwrap();
504
505        let sig = action.signature_hex();
506        assert!(sig.starts_with("0x"));
507        assert_eq!(sig.len(), 2 + 130, "0x + 65 bytes hex = 132 chars");
508        assert_eq!(action.nonce(), 1_695_836_058_725_001_u64);
509        assert_eq!(action.subaccount_id(), 30769);
510        assert_eq!(action.signer_address(), signer.address());
511        assert!(action.signature_expiry_sec() > 0);
512        // Decoding the hex back produces 65 bytes
513        let bytes = hex::decode(sig.trim_start_matches("0x")).unwrap();
514        assert_eq!(bytes.len(), 65);
515    }
516
517    const ORACLE_JSON: &str =
518        include_str!("../../test_data/common/signing_trade_action_vectors.json");
519
520    #[derive(Debug, Deserialize)]
521    struct OracleFile {
522        metadata: OracleMetadata,
523        vectors: Vec<OracleVector>,
524    }
525
526    #[derive(Debug, Deserialize)]
527    struct OracleMetadata {
528        source: String,
529        upstream_version: String,
530        upstream_revision: String,
531        generated_by: String,
532    }
533
534    #[derive(Debug, Deserialize)]
535    struct OracleVector {
536        case: String,
537        environment: String,
538        domain_separator: String,
539        action_typehash: String,
540        module_address: String,
541        subaccount_id: u64,
542        nonce: u64,
543        signature_expiry_sec: i64,
544        owner: String,
545        session_key: String,
546        signer: String,
547        trade: OracleTrade,
548        module_data: String,
549        module_data_hash: String,
550        action_hash: String,
551        typed_data_hash: String,
552        signature: String,
553    }
554
555    #[derive(Debug, Deserialize)]
556    struct OracleTrade {
557        asset_address: String,
558        sub_id: String,
559        limit_price: String,
560        amount: String,
561        max_fee: String,
562        recipient_id: u64,
563        is_bid: bool,
564    }
565
566    fn parse_oracle() -> OracleFile {
567        serde_json::from_str(ORACLE_JSON).expect("parse signing oracle fixture")
568    }
569
570    #[rstest]
571    fn test_oracle_fixture_records_upstream_provenance() {
572        // The values stay unpinned here so re-generating the fixture against a
573        // new upstream revision (e.g. a future V3 signer) never requires test
574        // changes; only the presence of provenance is enforced.
575        let oracle = parse_oracle();
576        let metadata = &oracle.metadata;
577        assert!(!metadata.source.is_empty(), "oracle source missing");
578        assert!(
579            !metadata.upstream_version.is_empty(),
580            "oracle upstream version missing",
581        );
582        assert!(
583            !metadata.upstream_revision.is_empty(),
584            "oracle upstream revision missing",
585        );
586        assert!(
587            !metadata.generated_by.is_empty(),
588            "oracle generator path missing",
589        );
590        assert!(!oracle.vectors.is_empty(), "oracle fixture has no vectors");
591    }
592
593    #[rstest]
594    fn test_oracle_vectors_use_production_protocol_constants() {
595        // The fixture must exercise the same constants production resolves
596        // from `common::consts`; an internally consistent fixture generated
597        // from mistyped generator constants would otherwise pass the
598        // equivalence test while diverging from live configuration.
599        let oracle = parse_oracle();
600
601        for (i, v) in oracle.vectors.iter().enumerate() {
602            let environment = match v.environment.as_str() {
603                "mainnet" => DeriveEnvironment::Mainnet,
604                "testnet" => DeriveEnvironment::Testnet,
605                other => panic!("vector {i}: unknown environment `{other}`"),
606            };
607            assert_eq!(
608                v.domain_separator,
609                domain_separator_for(environment),
610                "vector {i} ({}): domain separator does not match consts",
611                v.case,
612            );
613            assert_eq!(
614                v.action_typehash, ACTION_TYPEHASH,
615                "vector {i} ({}): action typehash does not match consts",
616                v.case,
617            );
618            assert_eq!(
619                v.module_address.to_ascii_lowercase(),
620                trade_module_address_for(environment).to_ascii_lowercase(),
621                "vector {i} ({}): trade module address does not match consts",
622                v.case,
623            );
624        }
625    }
626
627    #[rstest]
628    fn test_signing_matches_upstream_sdk_oracle_vectors() {
629        // Byte-equivalence oracle against the official Python SDK: every
630        // encoded module payload, module-data hash, action hash, typed-data
631        // hash, and signature must match the upstream fixture exactly. The
632        // upstream signer uses RFC 6979 deterministic nonces, so signature
633        // equality is meaningful across implementations.
634        let oracle = parse_oracle();
635
636        for (i, v) in oracle.vectors.iter().enumerate() {
637            let trade = TradeModuleData {
638                asset_address: v.trade.asset_address.parse().unwrap(),
639                sub_id: U256::from_str_radix(&v.trade.sub_id, 10).unwrap(),
640                limit_price: v.trade.limit_price.parse::<Decimal>().unwrap(),
641                amount: v.trade.amount.parse::<Decimal>().unwrap(),
642                max_fee: v.trade.max_fee.parse::<Decimal>().unwrap(),
643                recipient_id: v.trade.recipient_id,
644                is_bid: v.trade.is_bid,
645            };
646
647            let signer: PrivateKeySigner = v.session_key.parse().unwrap();
648            assert_eq!(
649                signer.address(),
650                v.signer.parse::<Address>().unwrap(),
651                "vector {i} ({}): session key does not derive the fixture signer",
652                v.case,
653            );
654
655            let encoded = trade.to_abi_encoded().unwrap();
656            assert_eq!(
657                format!("0x{}", hex::encode(&encoded)),
658                v.module_data,
659                "vector {i} ({}): encoded module data diverged from upstream",
660                v.case,
661            );
662
663            let module_data_hash = keccak256(&encoded);
664            assert_eq!(
665                format!("{module_data_hash:?}"),
666                v.module_data_hash,
667                "vector {i} ({}): module-data hash diverged from upstream",
668                v.case,
669            );
670
671            let ctx = ActionContext {
672                subaccount_id: v.subaccount_id,
673                nonce: v.nonce,
674                module_address: v.module_address.parse().unwrap(),
675                signature_expiry_sec: v.signature_expiry_sec,
676                owner: v.owner.parse().unwrap(),
677                signer: v.signer.parse().unwrap(),
678            };
679            let action_typehash: B256 = v.action_typehash.parse().unwrap();
680            let action_hash = compute_action_hash(&ctx, module_data_hash, action_typehash);
681            assert_eq!(
682                format!("{action_hash:?}"),
683                v.action_hash,
684                "vector {i} ({}): action hash diverged from upstream",
685                v.case,
686            );
687
688            let domain_separator: B256 = v.domain_separator.parse().unwrap();
689            let typed_data_hash = compute_typed_data_hash(domain_separator, action_hash);
690            assert_eq!(
691                format!("{typed_data_hash:?}"),
692                v.typed_data_hash,
693                "vector {i} ({}): typed-data hash diverged from upstream",
694                v.case,
695            );
696
697            let mut action = SignedAction::new(ctx, &trade, domain_separator, action_typehash);
698            let signature = action
699                .sign(&signer)
700                .expect("oracle expiry is far future, signing must succeed");
701            assert_eq!(
702                format!("0x{}", hex::encode(signature)),
703                v.signature,
704                "vector {i} ({}): signature diverged from upstream",
705                v.case,
706            );
707        }
708    }
709}