From 134745a3bada26c4e44a688f33662138702be5cb Mon Sep 17 00:00:00 2001 From: Damian Nolan Date: Tue, 27 Oct 2026 11:07:41 +0100 Subject: [PATCH 1/2] fix: canonicalize evnode sponsor signatures and strictly decode payload txs - decode_optional_signature rejects non-canonical signature encodings (e.g. v = 0/1 or EIP-155) by requiring the bytes to round-trip, so a sponsor signature has a single valid encoding and transaction hash. - recover_sponsor rejects malleable high-s signatures (EIP-2), matching the executor recovery path. - payload transactions are decoded with decode_2718_exact so trailing bytes after a transaction are rejected instead of silently ignored. Adds regression tests for each. --- crates/ev-primitives/src/tx.rs | 87 ++++++++++++++++++++++++++++++++-- crates/node/src/validator.rs | 2 +- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/ev-primitives/src/tx.rs b/crates/ev-primitives/src/tx.rs index efa9830b..6e301a0c 100644 --- a/crates/ev-primitives/src/tx.rs +++ b/crates/ev-primitives/src/tx.rs @@ -113,6 +113,11 @@ impl EvNodeTransaction { executor: Address, signature: &Signature, ) -> Result { + // Reject malleable high-s signatures (EIP-2): `s` must be in the lower + // half of the curve order. + if signature.normalize_s().is_some() { + return Err(alloy_primitives::SignatureError::FromBytes("high-s signature")); + } signature.recover_address_from_prehash(&self.sponsor_signing_hash(executor)) } @@ -599,9 +604,15 @@ fn decode_optional_signature(buf: &mut &[u8]) -> alloy_rlp::Result Vec { + let mut out = vec![0xb8, 0x41]; + out.extend_from_slice(raw); + out + } + + #[test] + fn decode_optional_signature_rejects_non_canonical_v() { + let mut raw = [0u8; 65]; + raw[31] = 1; // r = 1 + raw[63] = 1; // s = 1 + + // Canonical v (27) round-trips to the same bytes. + raw[64] = 27; + let encoded = rlp_encode_signature_bytes(&raw); + let decoded = decode_optional_signature(&mut encoded.as_slice()) + .expect("canonical signature decodes"); + assert!(decoded.is_some()); + + // Non-canonical v (0) recovers the same parity but is a second encoding + // of the same signature, so it must be rejected. + raw[64] = 0; + let encoded = rlp_encode_signature_bytes(&raw); + let err = decode_optional_signature(&mut encoded.as_slice()) + .expect_err("non-canonical v rejected"); + assert_eq!( + err, + alloy_rlp::Error::Custom("non-canonical signature encoding") + ); + } + + #[test] + fn recover_sponsor_rejects_high_s_signature() { + let tx = sample_tx(); + let executor = Address::from_slice(&[7u8; 20]); + // secp256k1 curve order n. + let n = U256::from_str_radix( + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", + 16, + ) + .unwrap(); + let high_s = Signature::new(U256::from(1u64), n - U256::from(1u64), false); + assert!(matches!( + tx.recover_sponsor(executor, &high_s), + Err(alloy_primitives::SignatureError::FromBytes("high-s signature")) + )); + } + + #[test] + fn decode_2718_exact_rejects_trailing_bytes() { + use alloy_eips::eip2718::{Decodable2718, Encodable2718}; + + let signed = alloy_consensus::Signed::new_unhashed(sample_tx(), sample_signature()); + let envelope = EvTxEnvelope::EvNode(signed); + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + // The clean encoding decodes. + assert!(EvTxEnvelope::decode_2718_exact(&encoded).is_ok()); + + // Trailing bytes would change the transaction hash while decoding to the + // same transaction, so the exact decoder must reject them. + let mut with_trailing = encoded.clone(); + with_trailing.push(0xff); + assert!(EvTxEnvelope::decode_2718_exact(&with_trailing).is_err()); + // The non-exact decoder (previously used at payload ingress) tolerates them. + assert!(EvTxEnvelope::decode_2718(&mut with_trailing.as_slice()).is_ok()); + } + #[test] fn compact_eip7702_type_roundtrip() { let mut encoded = Vec::new(); diff --git a/crates/node/src/validator.rs b/crates/node/src/validator.rs index 4d3165dc..2a78b5fd 100644 --- a/crates/node/src/validator.rs +++ b/crates/node/src/validator.rs @@ -146,7 +146,7 @@ fn parse_evolve_payload( .transactions() .iter() .map(|tx| { - EvTxEnvelope::decode_2718(&mut tx.as_ref()) + EvTxEnvelope::decode_2718_exact(tx.as_ref()) .map_err(|e| NewPayloadError::Other(Box::new(e))) }) .collect::, _>>()?; From f542601471d9f8b6b7cd5025166035c9b1360cf8 Mon Sep 17 00:00:00 2001 From: Damian Nolan Date: Tue, 27 Oct 2026 11:21:46 +0100 Subject: [PATCH 2/2] fix(primitives): drop sponsor-v canonicalization; keep low-s and strict decode The non-canonical-v check required the as_bytes() form (v = 27/28), but clients encode the fee-payer signature with v = 0/1 (matching the executor signature), so valid sponsored transactions were rejected. Enforcing a single canonical v requires aligning the encoder, decoder, and tx hash on one form, which is a protocol-level change; defer it. The high-s (EIP-2) sponsor check and strict decode_2718_exact are kept. Also applies cargo fmt. --- crates/ev-primitives/src/tx.rs | 52 ++++++---------------------------- 1 file changed, 9 insertions(+), 43 deletions(-) diff --git a/crates/ev-primitives/src/tx.rs b/crates/ev-primitives/src/tx.rs index 6e301a0c..745caf05 100644 --- a/crates/ev-primitives/src/tx.rs +++ b/crates/ev-primitives/src/tx.rs @@ -116,7 +116,9 @@ impl EvNodeTransaction { // Reject malleable high-s signatures (EIP-2): `s` must be in the lower // half of the curve order. if signature.normalize_s().is_some() { - return Err(alloy_primitives::SignatureError::FromBytes("high-s signature")); + return Err(alloy_primitives::SignatureError::FromBytes( + "high-s signature", + )); } signature.recover_address_from_prehash(&self.sponsor_signing_hash(executor)) } @@ -604,15 +606,9 @@ fn decode_optional_signature(buf: &mut &[u8]) -> alloy_rlp::Result Vec { - let mut out = vec![0xb8, 0x41]; - out.extend_from_slice(raw); - out - } - - #[test] - fn decode_optional_signature_rejects_non_canonical_v() { - let mut raw = [0u8; 65]; - raw[31] = 1; // r = 1 - raw[63] = 1; // s = 1 - - // Canonical v (27) round-trips to the same bytes. - raw[64] = 27; - let encoded = rlp_encode_signature_bytes(&raw); - let decoded = decode_optional_signature(&mut encoded.as_slice()) - .expect("canonical signature decodes"); - assert!(decoded.is_some()); - - // Non-canonical v (0) recovers the same parity but is a second encoding - // of the same signature, so it must be rejected. - raw[64] = 0; - let encoded = rlp_encode_signature_bytes(&raw); - let err = decode_optional_signature(&mut encoded.as_slice()) - .expect_err("non-canonical v rejected"); - assert_eq!( - err, - alloy_rlp::Error::Custom("non-canonical signature encoding") - ); - } - #[test] fn recover_sponsor_rejects_high_s_signature() { let tx = sample_tx(); @@ -766,7 +730,9 @@ mod tests { let high_s = Signature::new(U256::from(1u64), n - U256::from(1u64), false); assert!(matches!( tx.recover_sponsor(executor, &high_s), - Err(alloy_primitives::SignatureError::FromBytes("high-s signature")) + Err(alloy_primitives::SignatureError::FromBytes( + "high-s signature" + )) )); }