diff --git a/Cargo.toml b/Cargo.toml index 0a078114..1ed46ff5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.19.0" +version = "0.20.0" edition = "2021" rust-version = "1.87" authors = ["init4"] @@ -34,19 +34,19 @@ debug = false incremental = false [workspace.dependencies] -signet-bundle = { version = "0.19.0", path = "crates/bundle" } -signet-constants = { version = "0.19.0", path = "crates/constants" } -signet-evm = { version = "0.19.0", path = "crates/evm" } -signet-extract = { version = "0.19.0", path = "crates/extract" } -signet-journal = { version = "0.19.0", path = "crates/journal" } -signet-node = { version = "0.19.0", path = "crates/node" } -signet-orders = { version = "0.19.0", path = "crates/orders" } -signet-sim = { version = "0.19.0", path = "crates/sim" } -signet-types = { version = "0.19.0", path = "crates/types" } -signet-tx-cache = { version = "0.19.0", path = "crates/tx-cache" } -signet-zenith = { version = "0.19.0", path = "crates/zenith" } +signet-bundle = { version = "0.20.0", path = "crates/bundle" } +signet-constants = { version = "0.20.0", path = "crates/constants" } +signet-evm = { version = "0.20.0", path = "crates/evm" } +signet-extract = { version = "0.20.0", path = "crates/extract" } +signet-journal = { version = "0.20.0", path = "crates/journal" } +signet-node = { version = "0.20.0", path = "crates/node" } +signet-orders = { version = "0.20.0", path = "crates/orders" } +signet-sim = { version = "0.20.0", path = "crates/sim" } +signet-types = { version = "0.20.0", path = "crates/types" } +signet-tx-cache = { version = "0.20.0", path = "crates/tx-cache" } +signet-zenith = { version = "0.20.0", path = "crates/zenith" } -signet-test-utils = { version = "0.19.0", path = "crates/test-utils" } +signet-test-utils = { version = "0.20.0", path = "crates/test-utils" } # trevm trevm = { version = "0.34.2", features = ["full_env_cfg", "asyncdb"] } diff --git a/crates/orders/src/stream/mod.rs b/crates/orders/src/stream/mod.rs index 2bd4710e..26e9b04a 100644 --- a/crates/orders/src/stream/mod.rs +++ b/crates/orders/src/stream/mod.rs @@ -64,7 +64,7 @@ mod tests { permitted: Vec, outputs: Vec, ) -> SignedOrder { - SignedOrder::new( + SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted, diff --git a/crates/orders/src/stream/predicates.rs b/crates/orders/src/stream/predicates.rs index 0feab311..47016658 100644 --- a/crates/orders/src/stream/predicates.rs +++ b/crates/orders/src/stream/predicates.rs @@ -100,9 +100,9 @@ mod tests { assert!(!not_expired_at(|| 101)(&order)); // Cross-check against `SignedOrder::validate` to lock in the matching boundary. - order.validate(99).unwrap(); - order.validate(100).unwrap(); - order.validate(101).unwrap_err(); + order.validate_at(99).unwrap(); + order.validate_at(100).unwrap(); + order.validate_at(101).unwrap_err(); } #[test] @@ -127,7 +127,7 @@ mod tests { // A deadline that overflows u64 must saturate to u64::MAX, so the order is always // considered alive against any u64 cutoff. This mirrors `SignedOrder::validate`, which // uses `saturating_to::()`. - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![], @@ -142,8 +142,8 @@ mod tests { assert!(not_expired_at(|| 0)(&order)); assert!(not_expired_at(|| u64::MAX)(&order)); - order.validate(0).unwrap(); - order.validate(u64::MAX).unwrap(); + order.validate_at(0).unwrap(); + order.validate_at(u64::MAX).unwrap(); } #[test] diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml index 5123c392..70fc46a3 100644 --- a/crates/test-utils/Cargo.toml +++ b/crates/test-utils/Cargo.toml @@ -31,4 +31,7 @@ uuid = { workspace = true, features = ["v4"] } [dev-dependencies] chrono.workspace = true +proptest = "1.6" +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/test-utils/tests/deserialization_fuzz.rs b/crates/test-utils/tests/deserialization_fuzz.rs new file mode 100644 index 00000000..374a514e --- /dev/null +++ b/crates/test-utils/tests/deserialization_fuzz.rs @@ -0,0 +1,148 @@ +//! Proptests covering `signet-types` deserialization paths that consume +//! untrusted JSON. +//! +//! Regression coverage for ENG-2288 (`SignedOrder::order_hash` panic on +//! malformed signatures) and a forward-looking guarantee that no +//! `serde::Deserialize` impl on a SDK-exposed type panics on arbitrary +//! input — every malformed payload must surface as a `serde_json::Error`, +//! never an unwinding panic. +use alloy::primitives::Bytes; +use proptest::prelude::*; +use serde::Deserialize; +use signet_types::SignedOrder; +use signet_zenith::serde_helpers::{deserialize_non_empty_vec, deserialize_signature_bytes}; + +#[derive(Debug, Deserialize)] +struct SigWrap { + #[serde(deserialize_with = "deserialize_signature_bytes")] + signature: Bytes, +} + +#[derive(Debug, Deserialize)] +struct VecWrap { + #[serde(deserialize_with = "deserialize_non_empty_vec")] + items: Vec, +} + +fn hex_string(bytes: &[u8]) -> String { + let mut s = String::with_capacity(2 + bytes.len() * 2); + s.push_str("0x"); + for b in bytes { + use std::fmt::Write; + write!(&mut s, "{b:02x}").unwrap(); + } + s +} + +fn signed_order_json(sig_bytes: &[u8], permitted_count: usize, outputs_count: usize) -> String { + let permitted: Vec = (0..permitted_count) + .map(|i| format!(r#"{{"token":"0x{:040x}","amount":"0x{i:x}"}}"#, i as u128)) + .collect(); + let outputs: Vec = (0..outputs_count) + .map(|i| { + format!( + r#"{{"token":"0x{:040x}","amount":"0x{i:x}","recipient":"0x{:040x}","chainId":{i}}}"#, + i as u128, i as u128 + ) + }) + .collect(); + format!( + r#"{{ + "permit": {{ + "permitted": [{}], + "nonce": "0x0", + "deadline": "0xffffffffffffffff" + }}, + "owner": "0x0000000000000000000000000000000000000000", + "signature": "{}", + "outputs": [{}] + }}"#, + permitted.join(","), + hex_string(sig_bytes), + outputs.join(",") + ) +} + +proptest! { + /// `deserialize_signature_bytes` accepts iff the decoded byte string + /// is exactly 65 bytes. + #[test] + fn signature_helper_accepts_only_65_bytes(bytes in prop::collection::vec(any::(), 0..200)) { + let json = format!(r#"{{"signature":"{}"}}"#, hex_string(&bytes)); + match serde_json::from_str::(&json) { + Ok(w) => prop_assert_eq!(w.signature.len(), 65), + Err(_) => prop_assert_ne!(bytes.len(), 65), + } + } + + /// `deserialize_non_empty_vec` accepts iff the decoded vector is + /// non-empty. + #[test] + fn non_empty_vec_helper_rejects_empty(items in prop::collection::vec(any::(), 0..16)) { + let json = format!( + r#"{{"items":[{}]}}"#, + items.iter().map(u64::to_string).collect::>().join(",") + ); + match serde_json::from_str::(&json) { + Ok(w) => prop_assert!(!w.items.is_empty()), + Err(_) => prop_assert!(items.is_empty()), + } + } + + /// `SignedOrder` deserialization is total: any combination of + /// signature length, permitted count, and outputs count either + /// produces an `Ok` value satisfying all three structural + /// invariants, or an `Err`. It never panics. + #[test] + fn signed_order_deserialize_total( + sig_bytes in prop::collection::vec(any::(), 0..130), + permitted_count in 0usize..6, + outputs_count in 0usize..6, + ) { + let json = signed_order_json(&sig_bytes, permitted_count, outputs_count); + match serde_json::from_str::(&json) { + Ok(order) => { + prop_assert_eq!(sig_bytes.len(), 65); + prop_assert!(permitted_count > 0); + prop_assert!(outputs_count > 0); + prop_assert_eq!(order.permit().signature.len(), 65); + prop_assert!(!order.permit().permit.permitted.is_empty()); + prop_assert!(!order.outputs().is_empty()); + // order_hash() must not panic on any value the + // Deserialize impl admits. + let _ = order.order_hash(); + } + Err(_) => { + prop_assert!( + sig_bytes.len() != 65 || permitted_count == 0 || outputs_count == 0 + ); + } + } + } + + /// Well-formed `SignedOrder` JSON survives a serde round-trip with a + /// stable `order_hash`. + #[test] + fn signed_order_roundtrip( + permitted_count in 1usize..6, + outputs_count in 1usize..6, + ) { + let sig = vec![0u8; 65]; + let json = signed_order_json(&sig, permitted_count, outputs_count); + let order: SignedOrder = serde_json::from_str(&json).unwrap(); + let hash = *order.order_hash(); + let reserialized = serde_json::to_string(&order).unwrap(); + let decoded: SignedOrder = serde_json::from_str(&reserialized).unwrap(); + prop_assert_eq!(decoded.order_hash(), &hash); + } +} + +// Sanity check that arbitrary garbage JSON doesn't panic the +// deserializer — complements the structured proptests above by covering +// completely malformed shapes. +proptest! { + #[test] + fn arbitrary_string_never_panics(s in ".{0,256}") { + let _ = serde_json::from_str::(&s); + } +} diff --git a/crates/types/src/agg/fill.rs b/crates/types/src/agg/fill.rs index 19c3698b..42b9c1f6 100644 --- a/crates/types/src/agg/fill.rs +++ b/crates/types/src/agg/fill.rs @@ -212,6 +212,13 @@ impl AggregateFills { ) -> Result<(), MarketError> { self.check_aggregate(aggregate)?; + // SAFETY: the `check_aggregate` call above proves, for every + // `(output_asset, recipient)` pair in `aggregate`, that the entry + // exists in `self.fills` and that `filled >= amount`. We hold + // `&mut self` for the duration, so neither the map nor the + // recipient balances can change between the check and the + // mutation. The `get_mut`/`unwrap` and `checked_sub`/`unwrap` + // below are therefore infallible by construction. for (output_asset, recipients) in aggregate.outputs.iter() { let context_recipients = self.fills.get_mut(output_asset).expect("checked in check_aggregate"); diff --git a/crates/types/src/agg/order.rs b/crates/types/src/agg/order.rs index d18c35f5..a6c0c79e 100644 --- a/crates/types/src/agg/order.rs +++ b/crates/types/src/agg/order.rs @@ -122,6 +122,12 @@ impl AggregateOrders { token: *token, amount: U256::from(*amount), recipient: *recipient, + // SAFETY: signet chain IDs are protocol-defined to + // fit in `u32`; the on-chain `Output.chainId` + // field is itself `uint32`. A future protocol + // change permitting chain IDs above `u32::MAX` + // would require revisiting this cast and the + // contract type together. chainId: ru_chain_id as u32, }); } diff --git a/crates/types/src/signing/order.rs b/crates/types/src/signing/order.rs index 9b8699ef..9c2b3b6a 100644 --- a/crates/types/src/signing/order.rs +++ b/crates/types/src/signing/order.rs @@ -7,10 +7,12 @@ use alloy::{ sol_types::{SolCall, SolValue}, }; use chrono::Utc; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use signet_constants::SignetSystemConstants; -use signet_zenith::RollupOrders::{ - initiatePermit2Call, Input, Order, Output, Permit2Batch, TokenPermissions, +use signet_zenith::{ + serde_helpers::{deserialize_non_empty_vec, deserialize_signature_bytes}, + HostOrders::PermitBatchTransferFrom, + RollupOrders::{initiatePermit2Call, Input, Order, Output, Permit2Batch, TokenPermissions}, }; use std::{borrow::Cow, sync::OnceLock}; @@ -27,7 +29,7 @@ use std::{borrow::Cow, sync::OnceLock}; /// Inputs cannot be transferred until the Order Outputs have already been filled. /// /// TODO: Link to docs. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct SignedOrder { /// The permit batch. #[serde(flatten)] @@ -41,9 +43,62 @@ pub struct SignedOrder { order_hash_pre_image: OnceLock, } +/// Wire-shape mirror of [`PermitBatchTransferFrom`] that enforces a +/// non-empty `permitted` list during deserialization. +#[derive(Deserialize)] +struct PermitBatchTransferFromRepr { + #[serde(deserialize_with = "deserialize_non_empty_vec")] + permitted: Vec, + nonce: U256, + deadline: U256, +} + +impl From for PermitBatchTransferFrom { + fn from(r: PermitBatchTransferFromRepr) -> Self { + Self { permitted: r.permitted, nonce: r.nonce, deadline: r.deadline } + } +} + +/// Wire-shape mirror of [`SignedOrder`] that enforces the structural +/// invariants required by [`SignedOrder::order_hash`] and the +/// `initiatePermit2` contract entrypoint: a 65-byte signature, a non-empty +/// `permitted` list, and a non-empty `outputs` list. +#[derive(Deserialize)] +struct SignedOrderRepr { + permit: PermitBatchTransferFromRepr, + owner: Address, + #[serde(deserialize_with = "deserialize_signature_bytes")] + signature: Bytes, + #[serde(deserialize_with = "deserialize_non_empty_vec")] + outputs: Vec, +} + +impl<'de> Deserialize<'de> for SignedOrder { + fn deserialize>(deserializer: D) -> Result { + let SignedOrderRepr { permit, owner, signature, outputs } = + SignedOrderRepr::deserialize(deserializer)?; + Ok(Self::new_unchecked(Permit2Batch { permit: permit.into(), owner, signature }, outputs)) + } +} + impl SignedOrder { - /// Creates a new signed order. - pub const fn new(permit: Permit2Batch, outputs: Vec) -> Self { + /// Construct a `SignedOrder` without validating structural invariants. + /// + /// # Invariants + /// + /// The caller must ensure all of: + /// - `permit.signature` is exactly 65 bytes (canonical secp256k1 + /// `(r, s, v)` encoding). + /// - `permit.permit.permitted` is non-empty. + /// - `outputs` is non-empty. + /// + /// These invariants are required by [`SignedOrder::order_hash`] (which + /// would otherwise panic on a non-65-byte signature) and by the + /// `initiatePermit2` contract entrypoint. Values obtained via + /// [`UnsignedOrder::sign`] or [`serde::Deserialize`] always satisfy + /// them; consumers that build a `SignedOrder` from raw parts should + /// prefer one of those construction paths. + pub const fn new_unchecked(permit: Permit2Batch, outputs: Vec) -> Self { Self { permit, outputs, order_hash: OnceLock::new(), order_hash_pre_image: OnceLock::new() } } @@ -71,11 +126,16 @@ impl SignedOrder { timestamp > self.permit.permit.deadline.saturating_to::() } - /// Check that this can be syntactically used to initiate an order. + /// Check that this order is usable at `timestamp`. + /// + /// Structural invariants (signature length, non-empty input/output + /// vectors) are enforced at construction time — see + /// [`Self::new_unchecked`]. This method covers the time/state-dependent + /// checks; presently that is only the deadline. /// /// For it to be valid: /// - Deadline must be in the future. - pub fn validate(&self, timestamp: u64) -> Result<(), SignedPermitError> { + pub fn validate_at(&self, timestamp: u64) -> Result<(), SignedPermitError> { if self.is_expired_at(timestamp) { let deadline = self.permit.permit.deadline.saturating_to::(); return Err(SignedPermitError::DeadlinePassed { current: timestamp, deadline }); @@ -84,6 +144,11 @@ impl SignedOrder { Ok(()) } + /// Returns `true` iff [`Self::validate_at`] would return `Ok`. + pub fn is_valid_at(&self, timestamp: u64) -> bool { + self.validate_at(timestamp).is_ok() + } + /// Generate a TransactionRequest to `initiate` the SignedOrder. pub fn to_initiate_tx( &self, @@ -135,7 +200,12 @@ impl SignedOrder { buf.extend_from_slice(keccak256(self.permit.owner.abi_encode()).as_slice()); buf.extend_from_slice(keccak256(self.outputs.abi_encode()).as_slice()); - // Normalize the signature. + // SAFETY: `self.permit.signature` is exactly 65 bytes by the + // invariants documented on `SignedOrder::new_unchecked`, which are + // enforced for every construction path: `UnsignedOrder::sign` + // produces a fresh 65-byte signature, and `` rejects any other length via + // `signet_zenith::serde_helpers::deserialize_signature_bytes`. let signature = alloy::primitives::Signature::from_raw(&self.permit.signature).unwrap().normalized_s(); buf.extend_from_slice(keccak256(signature.as_bytes()).as_slice()); @@ -287,7 +357,7 @@ impl<'a> UnsignedOrder<'a> { let signature = signer.sign_hash(&permit.signing_hash).await?; // return as a SignedOrder - Ok(SignedOrder::new( + Ok(SignedOrder::new_unchecked( Permit2Batch { permit: permit.permit, owner: signer.address(), @@ -306,7 +376,7 @@ mod tests { use super::*; fn basic_order() -> SignedOrder { - SignedOrder::new( + SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![TokenPermissions { token: Address::ZERO, amount: U256::ZERO }], @@ -365,7 +435,7 @@ mod tests { vec![ // 1. Minimal order - single input/output with zero values { - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![TokenPermissions { @@ -401,7 +471,7 @@ mod tests { let token_c = address!("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"); // WBTC let recipient = address!("0x1234567890123456789012345678901234567890"); - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![ @@ -448,7 +518,7 @@ mod tests { let recipient_b = address!("0x2222222222222222222222222222222222222222"); let recipient_c = address!("0x3333333333333333333333333333333333333333"); - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![TokenPermissions { @@ -497,7 +567,7 @@ mod tests { let owner = address!("0xCafeBabeCafeBabeCafeBabeCafeBabeCafeBabe"); let recipient = address!("0x4444444444444444444444444444444444444444"); - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![TokenPermissions { @@ -543,7 +613,7 @@ mod tests { // Amount larger than JS Number.MAX_SAFE_INTEGER (2^53 - 1 = 9007199254740991) let large_amount = U256::from(10_000_000_000_000_000_000_000u128); // 10,000 ETH - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![TokenPermissions { token, amount: large_amount }], @@ -572,7 +642,7 @@ mod tests { let owner = address!("0x7777777777777777777777777777777777777777"); let recipient = address!("0x8888888888888888888888888888888888888888"); - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![TokenPermissions { @@ -653,7 +723,7 @@ mod tests { /// Test that verifies the minimal order matches expected hash. #[test] fn minimal_order_hash() { - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![TokenPermissions { token: Address::ZERO, amount: U256::ZERO }], @@ -686,7 +756,7 @@ mod tests { let token_c = address!("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"); let recipient = address!("0x1234567890123456789012345678901234567890"); - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![ @@ -722,7 +792,7 @@ mod tests { let recipient = address!("0x6666666666666666666666666666666666666666"); let large_amount = U256::from(10_000_000_000_000_000_000_000u128); - let order = SignedOrder::new( + let order = SignedOrder::new_unchecked( Permit2Batch { permit: PermitBatchTransferFrom { permitted: vec![TokenPermissions { token, amount: large_amount }], @@ -741,6 +811,84 @@ mod tests { ); } + /// Regression test for ENG-2288: a `SignedOrder` JSON with a malformed + /// `signature` (any length other than 65 bytes) must be rejected at + /// deserialize time, so downstream callers of [`SignedOrder::order_hash`] + /// can rely on the type-system invariant. + #[test] + fn malformed_deserialized_signature_rejected() { + let json = r#"{ + "permit": { + "permitted": [ + { "token": "0x0000000000000000000000000000000000000000", "amount": "0x01" } + ], + "nonce": "0x00", + "deadline": "0xffffffffffffffff" + }, + "owner": "0x0000000000000000000000000000000000000000", + "signature": "0x01", + "outputs": [ + { + "token": "0x0000000000000000000000000000000000000000", + "amount": "0x01", + "recipient": "0x0000000000000000000000000000000000000000", + "chainId": 88888 + } + ] + }"#; + let err = serde_json::from_str::(json).unwrap_err(); + assert!(err.to_string().contains("65 bytes"), "{err}"); + } + + #[test] + fn empty_permitted_rejected() { + let json = r#"{ + "permit": { + "permitted": [], + "nonce": "0x00", + "deadline": "0xffffffffffffffff" + }, + "owner": "0x0000000000000000000000000000000000000000", + "signature": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "outputs": [ + { + "token": "0x0000000000000000000000000000000000000000", + "amount": "0x01", + "recipient": "0x0000000000000000000000000000000000000000", + "chainId": 88888 + } + ] + }"#; + let err = serde_json::from_str::(json).unwrap_err(); + assert!(err.to_string().contains("at least one element"), "{err}"); + } + + #[test] + fn empty_outputs_rejected() { + let json = r#"{ + "permit": { + "permitted": [ + { "token": "0x0000000000000000000000000000000000000000", "amount": "0x01" } + ], + "nonce": "0x00", + "deadline": "0xffffffffffffffff" + }, + "owner": "0x0000000000000000000000000000000000000000", + "signature": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "outputs": [] + }"#; + let err = serde_json::from_str::(json).unwrap_err(); + assert!(err.to_string().contains("at least one element"), "{err}"); + } + + #[test] + fn well_formed_roundtrip() { + let order = basic_order(); + let json = serde_json::to_string(&order).unwrap(); + let decoded: SignedOrder = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.order_hash(), order.order_hash()); + } + #[test] fn signed_order_eq_with_one_populated_hash() { let order_a = basic_order(); diff --git a/crates/zenith/src/lib.rs b/crates/zenith/src/lib.rs index 2f1a5fe6..a865dda5 100644 --- a/crates/zenith/src/lib.rs +++ b/crates/zenith/src/lib.rs @@ -20,6 +20,8 @@ pub use bindings::{ mod block; pub use block::{decode_txns, encode_txns, Alloy2718Coder, Coder, ZenithBlock, ZenithTransaction}; +pub mod serde_helpers; + mod trevm; use alloy::{ diff --git a/crates/zenith/src/serde_helpers.rs b/crates/zenith/src/serde_helpers.rs new file mode 100644 index 00000000..94470849 --- /dev/null +++ b/crates/zenith/src/serde_helpers.rs @@ -0,0 +1,117 @@ +//! Reusable [`serde::Deserialize`] helpers that enforce structural +//! invariants on sol-shaped types received at JSON trust boundaries. +//! +//! The [`alloy::sol!`] macro emits permissive [`serde::Deserialize`] impls +//! suitable for ABI-shaped JSON. When the same types are deserialized from +//! untrusted input — for instance, orders posted to a tx-cache feed — the +//! structural invariants the contract enforces on-chain (signature length, +//! non-empty input/output vectors) are not re-checked. These helpers +//! re-impose those invariants via `#[serde(deserialize_with = "…")]`. +//! +//! # Example +//! +//! ```ignore +//! use alloy::primitives::Bytes; +//! use serde::Deserialize; +//! +//! #[derive(Deserialize)] +//! struct ValidatedBlob { +//! #[serde(deserialize_with = "signet_zenith::serde_helpers::deserialize_signature_bytes")] +//! signature: Bytes, +//! } +//! ``` +use alloy::primitives::Bytes; +use serde::{ + de::{Deserializer, Error as DeError, Unexpected}, + Deserialize, +}; + +/// Length in bytes of a canonical secp256k1 signature `(r, s, v)`. +const SIGNATURE_BYTES: usize = 65; + +/// Deserialize [`Bytes`] and require the value to be exactly 65 bytes. +/// +/// Errors via [`serde::de::Error::invalid_length`] if the decoded byte +/// string is any other length. +pub fn deserialize_signature_bytes<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let bytes = Bytes::deserialize(deserializer)?; + if bytes.len() != SIGNATURE_BYTES { + return Err(D::Error::invalid_length(bytes.len(), &"65 bytes")); + } + Ok(bytes) +} + +/// Deserialize a [`Vec`] and require it to be non-empty. +/// +/// Errors via [`serde::de::Error::invalid_value`] if the decoded vector +/// has length zero. +pub fn deserialize_non_empty_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + let v = Vec::::deserialize(deserializer)?; + if v.is_empty() { + return Err(D::Error::invalid_value(Unexpected::Seq, &"at least one element")); + } + Ok(v) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize)] + struct Sig { + #[serde(deserialize_with = "deserialize_signature_bytes")] + signature: Bytes, + } + + #[derive(Debug, Deserialize)] + struct Vec64 { + #[serde(deserialize_with = "deserialize_non_empty_vec")] + items: Vec, + } + + #[test] + fn signature_exact_65_ok() { + let json = format!(r#"{{"signature":"0x{}"}}"#, "ab".repeat(65)); + let s: Sig = serde_json::from_str(&json).unwrap(); + assert_eq!(s.signature.len(), 65); + } + + #[test] + fn signature_short_rejected() { + let err = serde_json::from_str::(r#"{"signature":"0x01"}"#).unwrap_err(); + assert!(err.to_string().contains("65 bytes"), "{err}"); + } + + #[test] + fn signature_long_rejected() { + let json = format!(r#"{{"signature":"0x{}"}}"#, "ab".repeat(66)); + let err = serde_json::from_str::(&json).unwrap_err(); + assert!(err.to_string().contains("65 bytes"), "{err}"); + } + + #[test] + fn signature_empty_rejected() { + let err = serde_json::from_str::(r#"{"signature":"0x"}"#).unwrap_err(); + assert!(err.to_string().contains("65 bytes"), "{err}"); + } + + #[test] + fn non_empty_vec_ok() { + let v: Vec64 = serde_json::from_str(r#"{"items":[1,2,3]}"#).unwrap(); + assert_eq!(v.items, vec![1, 2, 3]); + } + + #[test] + fn non_empty_vec_rejected() { + let err = serde_json::from_str::(r#"{"items":[]}"#).unwrap_err(); + assert!(err.to_string().contains("at least one element"), "{err}"); + } +}