Skip to main content

metaflux_client/wallet/
sign.rs

1//! EIP-712 signing.
2//!
3//! Implements:
4//!
5//! ```text
6//!   digest = keccak256(0x19 || 0x01 || domain_separator || struct_hash)
7//!   sig    = ECDSA-RFC6979(secret, digest)  // 32-byte r || 32-byte s
8//!   v      = recovery_id + 27               // {27, 28}
9//! ```
10//!
11//! Per-domain types implement [`Eip712`] (one trait impl per typed message).
12//! The default [`Eip712::to_digest`] does the standard EIP-712 envelope —
13//! types only need to provide `domain_separator()` + `struct_hash()`.
14
15use k256::ecdsa::signature::hazmat::PrehashSigner;
16use k256::ecdsa::{RecoveryId, Signature as K256Sig};
17use tiny_keccak::{Hasher, Keccak};
18
19use crate::error::ClientError;
20use crate::wallet::key::Wallet;
21
22/// A 65-byte EIP-712 signature: `r (32) || s (32) || v (1)`.
23///
24/// `v ∈ {27, 28}` follows the "legacy" Ethereum-style recovery id. The MTF
25/// node's signature verifier expects this layout.
26#[derive(Clone, Copy, PartialEq, Eq)]
27pub struct Signature {
28    /// First 32 bytes of the signature.
29    pub r: [u8; 32],
30    /// Second 32 bytes of the signature.
31    pub s: [u8; 32],
32    /// Recovery id, encoded as `27 + parity` (matches Ethereum legacy txs).
33    pub v: u8,
34}
35
36impl Signature {
37    /// Encode as a 65-byte big-endian blob (`r || s || v`).
38    #[must_use]
39    pub fn to_bytes(&self) -> [u8; 65] {
40        let mut out = [0u8; 65];
41        out[..32].copy_from_slice(&self.r);
42        out[32..64].copy_from_slice(&self.s);
43        out[64] = self.v;
44        out
45    }
46
47    /// Hex-encode as a 0x-prefixed lowercase string (132 chars).
48    #[must_use]
49    pub fn to_hex(&self) -> String {
50        format!("0x{}", hex::encode(self.to_bytes()))
51    }
52}
53
54impl core::fmt::Debug for Signature {
55    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
56        f.debug_struct("Signature")
57            .field("r", &hex::encode(self.r))
58            .field("s", &hex::encode(self.s))
59            .field("v", &self.v)
60            .finish()
61    }
62}
63
64/// EIP-712 typed-data trait.
65///
66/// Domains implement this once per typed message; the SDK provides
67/// [`Eip712::to_digest`] as a default that builds the standard envelope.
68///
69/// Implementers MUST encode the domain separator and the struct hash per
70/// the EIP-712 spec — typically:
71///
72/// ```text
73///   domain_separator = keccak256(encode(EIP712Domain, domain))
74///   struct_hash      = keccak256(typeHash || encode(field_1) || encode(field_2) || ...)
75/// ```
76pub trait Eip712 {
77    /// 32-byte EIP-712 domain separator.
78    fn domain_separator(&self) -> [u8; 32];
79
80    /// 32-byte EIP-712 struct hash for this typed message.
81    fn struct_hash(&self) -> [u8; 32];
82
83    /// Produce the 32-byte EIP-712 digest for this message.
84    ///
85    /// `keccak256(0x19 || 0x01 || domain_separator || struct_hash)`.
86    fn to_digest(&self) -> [u8; 32] {
87        let domain = self.domain_separator();
88        let strukt = self.struct_hash();
89        let mut hasher = Keccak::v256();
90        hasher.update(&[0x19, 0x01]);
91        hasher.update(&domain);
92        hasher.update(&strukt);
93        let mut out = [0u8; 32];
94        hasher.finalize(&mut out);
95        out
96    }
97}
98
99impl Wallet {
100    /// Sign an EIP-712 typed message and produce a 65-byte signature.
101    ///
102    /// The signing is deterministic (RFC-6979 nonces).
103    ///
104    /// # Errors
105    /// Returns [`ClientError::Signature`] only if the k256 prehash signer
106    /// fails, which is extremely rare (essentially never in practice for
107    /// valid keys + 32-byte digests).
108    pub fn sign_eip712<T: Eip712>(&self, msg: &T) -> Result<Signature, ClientError> {
109        let digest = msg.to_digest();
110        self.sign_digest(&digest)
111    }
112
113    /// Sign an arbitrary 32-byte digest with the wallet key.
114    ///
115    /// Lower-level than [`Wallet::sign_eip712`] — use only when you already
116    /// have the EIP-712 digest computed externally.
117    ///
118    /// # Errors
119    /// See [`Wallet::sign_eip712`].
120    pub fn sign_digest(&self, digest: &[u8; 32]) -> Result<Signature, ClientError> {
121        let signing_key = self.signing_key();
122        let (sig, recid): (K256Sig, RecoveryId) = signing_key.sign_prehash(digest)?;
123        // k256 returns a normalized (low-S) signature; legacy v = 27 + parity.
124        let bytes = sig.to_bytes();
125        let mut r = [0u8; 32];
126        let mut s = [0u8; 32];
127        r.copy_from_slice(&bytes[..32]);
128        s.copy_from_slice(&bytes[32..]);
129        let v = 27 + recid.to_byte();
130        Ok(Signature { r, s, v })
131    }
132}
133
134/// Convenience: recover the signer address from a digest + signature.
135///
136/// Used in tests to assert sign/recover round-trips. Not pub in the
137/// stable API surface because production code paths verify on the server,
138/// but exposed for the integration tests in `tests/` via
139/// [`crate::wallet::sign_recover_for_test_only`].
140pub(crate) fn recover_address(
141    digest: &[u8; 32],
142    sig: &Signature,
143) -> Result<crate::wallet::key::Address, ClientError> {
144    use k256::ecdsa::VerifyingKey;
145
146    let mut sig_bytes = [0u8; 64];
147    sig_bytes[..32].copy_from_slice(&sig.r);
148    sig_bytes[32..].copy_from_slice(&sig.s);
149    let k_sig = K256Sig::from_slice(&sig_bytes)
150        .map_err(|e| ClientError::Signature(format!("sig decode: {e}")))?;
151    let recid_byte = sig
152        .v
153        .checked_sub(27)
154        .ok_or_else(|| ClientError::Signature(format!("invalid v = {}", sig.v)))?;
155    let recid = RecoveryId::from_byte(recid_byte)
156        .ok_or_else(|| ClientError::Signature(format!("invalid recovery id = {recid_byte}")))?;
157
158    let verifying = VerifyingKey::recover_from_prehash(digest, &k_sig, recid)?;
159    let point = verifying.to_encoded_point(false);
160    let pubkey_bytes = point.as_bytes();
161    let mut hasher = Keccak::v256();
162    hasher.update(&pubkey_bytes[1..]);
163    let mut h = [0u8; 32];
164    hasher.finalize(&mut h);
165    let mut addr = [0u8; 20];
166    addr.copy_from_slice(&h[12..]);
167    Ok(crate::wallet::key::Address::from_bytes(addr))
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    /// Toy `Eip712` impl whose domain + struct hash are fixed bytes —
175    /// used to exercise the digest formula.
176    struct ToyMsg {
177        domain: [u8; 32],
178        strukt: [u8; 32],
179    }
180    impl Eip712 for ToyMsg {
181        fn domain_separator(&self) -> [u8; 32] {
182            self.domain
183        }
184        fn struct_hash(&self) -> [u8; 32] {
185            self.strukt
186        }
187    }
188
189    #[test]
190    fn signature_round_trip_recovers_signer() {
191        let wallet = Wallet::random_for_testing();
192        let msg = ToyMsg {
193            domain: [0xAAu8; 32],
194            strukt: [0xBBu8; 32],
195        };
196        let sig = wallet.sign_eip712(&msg).unwrap();
197        let recovered = recover_address(&msg.to_digest(), &sig).unwrap();
198        assert_eq!(recovered, wallet.address());
199    }
200
201    #[test]
202    fn rfc6979_signing_is_deterministic() {
203        let wallet = Wallet::random_for_testing();
204        let msg = ToyMsg {
205            domain: [0x01u8; 32],
206            strukt: [0x02u8; 32],
207        };
208        let sig_a = wallet.sign_eip712(&msg).unwrap();
209        let sig_b = wallet.sign_eip712(&msg).unwrap();
210        assert_eq!(sig_a, sig_b, "RFC-6979 must produce identical signatures");
211    }
212
213    #[test]
214    fn digest_matches_keccak_envelope() {
215        let domain = [0xAAu8; 32];
216        let strukt = [0xBBu8; 32];
217        let msg = ToyMsg { domain, strukt };
218
219        // Hand-computed expected digest.
220        let mut hasher = Keccak::v256();
221        hasher.update(&[0x19, 0x01]);
222        hasher.update(&domain);
223        hasher.update(&strukt);
224        let mut expected = [0u8; 32];
225        hasher.finalize(&mut expected);
226
227        assert_eq!(msg.to_digest(), expected);
228    }
229
230    #[test]
231    fn signature_hex_is_132_chars() {
232        let wallet = Wallet::random_for_testing();
233        let msg = ToyMsg {
234            domain: [0u8; 32],
235            strukt: [0u8; 32],
236        };
237        let sig = wallet.sign_eip712(&msg).unwrap();
238        let h = sig.to_hex();
239        assert!(h.starts_with("0x"));
240        assert_eq!(h.len(), 132); // 2 + 130 (65 bytes * 2)
241    }
242}