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