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_macros::dec;
255
256    use super::*;
257    use crate::signing::modules::trade::TradeModuleData;
258
259    const SESSION_KEY_HEX: &str =
260        "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
261
262    fn fixed_typehash() -> B256 {
263        // Arbitrary but stable test typehash. Real value comes from Protocol
264        // Constants at docs.derive.xyz.
265        "0x1111111111111111111111111111111111111111111111111111111111111111"
266            .parse()
267            .unwrap()
268    }
269
270    fn fixed_domain() -> B256 {
271        "0x2222222222222222222222222222222222222222222222222222222222222222"
272            .parse()
273            .unwrap()
274    }
275
276    fn module_addr() -> Address {
277        "0x000000000000000000000000000000000000bbbb"
278            .parse()
279            .unwrap()
280    }
281
282    fn owner() -> Address {
283        "0x000000000000000000000000000000000000aaaa"
284            .parse()
285            .unwrap()
286    }
287
288    fn fresh_expiry() -> i64 {
289        let now = SystemTime::now()
290            .duration_since(UNIX_EPOCH)
291            .unwrap()
292            .as_secs() as i64;
293        now + 3600
294    }
295
296    fn sample_trade() -> TradeModuleData {
297        TradeModuleData {
298            asset_address: "0x000000000000000000000000000000000000abcd"
299                .parse()
300                .unwrap(),
301            sub_id: U256::from(42),
302            limit_price: dec!(100),
303            amount: dec!(1),
304            max_fee: dec!(1000),
305            recipient_id: 30769,
306            is_bid: true,
307        }
308    }
309
310    fn sample_ctx(signer: Address, expiry: i64) -> ActionContext {
311        ActionContext {
312            subaccount_id: 30769,
313            nonce: 1_695_836_058_725_001,
314            module_address: module_addr(),
315            signature_expiry_sec: expiry,
316            owner: owner(),
317            signer,
318        }
319    }
320
321    #[rstest]
322    fn test_compute_action_hash_changes_with_subaccount() {
323        let module_hash = keccak256(sample_trade().to_abi_encoded().unwrap());
324        let mut ctx = sample_ctx(owner(), fresh_expiry());
325        let h1 = compute_action_hash(&ctx, module_hash, fixed_typehash());
326        ctx.subaccount_id += 1;
327        let h2 = compute_action_hash(&ctx, module_hash, fixed_typehash());
328        assert_ne!(h1, h2, "changing subaccount must change the hash");
329    }
330
331    #[rstest]
332    fn test_compute_action_hash_pins_byte_layout() {
333        // Lock the 8-field ABI tuple shape against drift. The order
334        // (typehash, subaccount, nonce, module, module_data_hash, expiry,
335        // owner, signer) is the load-bearing protocol contract for
336        // byte-equivalence with the upstream derive_action_signing SDK; a
337        // swap, drop, or reorder would slip past relative-change tests.
338        let module_hash: B256 =
339            "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
340                .parse()
341                .unwrap();
342        let ctx = ActionContext {
343            subaccount_id: 30769,
344            nonce: 1_695_836_058_725_001,
345            module_address: module_addr(),
346            signature_expiry_sec: 1_700_000_000,
347            owner: owner(),
348            signer: "0x000000000000000000000000000000000000cccc"
349                .parse()
350                .unwrap(),
351        };
352        let hash = compute_action_hash(&ctx, module_hash, fixed_typehash());
353        let expected = "0x509b526a0413577f827d7ebaf5b3fed1eb24bb480612b4e705e1001126f04a1b";
354        assert_eq!(format!("{hash:?}"), expected, "action-hash layout drift");
355    }
356
357    #[rstest]
358    fn test_compute_typed_data_hash_pins_byte_layout() {
359        // Lock the 0x1901 || domain || action_hash composition. Reordering
360        // domain and action_hash, or dropping the prefix, would alter the
361        // exact byte value below.
362        let action_hash: B256 =
363            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
364                .parse()
365                .unwrap();
366        let hash = compute_typed_data_hash(fixed_domain(), action_hash);
367        let expected = "0x939b63f7cb4f2902be3004edd4f758ce4af26b96d12fd0992957a4cf5d287312";
368        assert_eq!(
369            format!("{hash:?}"),
370            expected,
371            "typed-data hash composition drift",
372        );
373    }
374
375    #[rstest]
376    fn test_compute_typed_data_hash_includes_19_01_prefix() {
377        // Construct a known input pair and verify the prefix participates by
378        // showing the hash differs from the bare keccak of its components.
379        let action_hash: B256 =
380            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
381                .parse()
382                .unwrap();
383        let with_prefix = compute_typed_data_hash(fixed_domain(), action_hash);
384        let mut bare = Vec::with_capacity(64);
385        bare.extend_from_slice(fixed_domain().as_slice());
386        bare.extend_from_slice(action_hash.as_slice());
387        let without_prefix = keccak256(&bare);
388        assert_ne!(
389            with_prefix, without_prefix,
390            "the 0x1901 prefix must change the digest",
391        );
392    }
393
394    #[rstest]
395    fn test_sign_rejects_expiry_that_is_too_soon() {
396        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
397        let near_expiry = SystemTime::now()
398            .duration_since(UNIX_EPOCH)
399            .unwrap()
400            .as_secs() as i64
401            + 60; // only 1 minute, well under 5-minute MIN_SIGNATURE_TTL
402        let ctx = sample_ctx(signer.address(), near_expiry);
403        let trade = sample_trade();
404        let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
405        let err = action.sign(&signer).expect_err("must reject near expiry");
406        assert!(
407            matches!(err, TypedDataError::ExpiryTooSoon { .. }),
408            "expected ExpiryTooSoon, was {err:?}",
409        );
410    }
411
412    #[rstest]
413    fn test_sign_produces_recoverable_signature() {
414        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
415        let ctx = sample_ctx(signer.address(), fresh_expiry());
416        let trade = sample_trade();
417
418        let module_data_hash = keccak256(trade.to_abi_encoded().unwrap());
419        let action_hash = compute_action_hash(&ctx, module_data_hash, fixed_typehash());
420        let typed_data_hash = compute_typed_data_hash(fixed_domain(), action_hash);
421
422        let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
423        let raw = action.sign(&signer).expect("sign must succeed");
424        assert_eq!(raw.len(), 65);
425
426        // Recover the signer from the signature and verify it matches the
427        // session key. This is the venue's verification path inverted.
428        let signature = Signature::try_from(raw.as_slice()).expect("65-byte sig");
429        let recovered = signature
430            .recover_address_from_prehash(&typed_data_hash)
431            .expect("recover");
432        assert_eq!(recovered, signer.address());
433    }
434
435    #[rstest]
436    fn test_sign_propagates_module_encoding_error() {
437        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
438        let ctx = sample_ctx(signer.address(), fresh_expiry());
439        let mut bad_trade = sample_trade();
440        bad_trade.max_fee = dec!(-1);
441        let mut action = SignedAction::new(ctx, &bad_trade, fixed_domain(), fixed_typehash());
442        let err = action
443            .sign(&signer)
444            .expect_err("invalid trade input must surface as a typed error, not a panic");
445
446        match err {
447            TypedDataError::ModuleEncoding { message } => {
448                assert!(message.contains("max_fee"), "unexpected message: {message}");
449            }
450            other => panic!("expected ModuleEncoding, was {other:?}"),
451        }
452    }
453
454    #[rstest]
455    fn test_signed_action_accessors_expose_request_envelope_fields() {
456        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
457        let ctx = sample_ctx(signer.address(), fresh_expiry());
458        let trade = sample_trade();
459        let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
460        action.sign(&signer).unwrap();
461
462        let sig = action.signature_hex();
463        assert!(sig.starts_with("0x"));
464        assert_eq!(sig.len(), 2 + 130, "0x + 65 bytes hex = 132 chars");
465        assert_eq!(action.nonce(), 1_695_836_058_725_001_u64);
466        assert_eq!(action.subaccount_id(), 30769);
467        assert_eq!(action.signer_address(), signer.address());
468        assert!(action.signature_expiry_sec() > 0);
469        // Decoding the hex back produces 65 bytes
470        let bytes = hex::decode(sig.trim_start_matches("0x")).unwrap();
471        assert_eq!(bytes.len(), 65);
472    }
473}