Skip to content

Commit aed92df

Browse files
fix(levm): convert EIP-8141 recovery id to EVM ecrecover v at precompile boundary
EIP-8141 defines SECP256K1 signature[0] as the recovery id (0 or 1), matching EIP-2718 typed transactions. The ecrecover precompile expects v in {27, 28}. Ethrex was forwarding the recovery id verbatim, causing spec-conformant 0/1 signatures to be rejected (empty recovery) and breaking consensus with Nethermind. Add validation to reject v > 1 and translate 0/1 -> 27/28 only at the precompile boundary. Extend frame_sig_validation_tests with recovery-id encoding tests covering both specifications. All regression suites pass. Fixes the consensus split described in artifacts/interop-findings.md and artifacts/opus-audit.md.
1 parent cee872d commit aed92df

2 files changed

Lines changed: 156 additions & 44 deletions

File tree

crates/vm/levm/src/vm.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,16 @@ pub fn validate_frame_signatures(
748748
let v = sig.signature[0];
749749
let r = &sig.signature[1..33];
750750
let s = &sig.signature[33..65];
751+
// EIP-8141 defines `signature[0]` as the RECOVERY ID (0 or 1),
752+
// matching EIP-2718 typed transactions - not the EVM `ecrecover`
753+
// `v` (27/28). Anything above 1 is invalid, so reject it here
754+
// rather than letting the precompile's own 27/28 mapping decide:
755+
// forwarding the byte verbatim both rejected conformant 0/1
756+
// signatures and accepted non-conformant 27/28 ones, which is a
757+
// consensus split against a spec-conformant client.
758+
if v > 1 {
759+
return false;
760+
}
751761
// EIP-8141 defines verification as `signer == ecrecover(msg, v, r, s)`
752762
// and does NOT mandate EIP-2 low-s, so a high-s frame signature is
753763
// spec-valid and MUST be accepted here (this is the consensus
@@ -757,7 +767,13 @@ pub fn validate_frame_signatures(
757767
// client that accepts it.
758768
let mut calldata = vec![0u8; 128];
759769
calldata[..32].copy_from_slice(&msg);
760-
calldata[63] = v;
770+
// Translate the recovery id to the EVM `ecrecover` `v` only at
771+
// the precompile boundary. `v` is 0 or 1 (checked above), so
772+
// this mapping is total and exact.
773+
calldata[63] = match v {
774+
0 => 27,
775+
_ => 28,
776+
};
761777
calldata[64..96].copy_from_slice(r);
762778
calldata[96..128].copy_from_slice(s);
763779
let Ok(result) = crate::precompiles::ecrecover(

test/tests/levm/eip8141_tests.rs

Lines changed: 139 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2757,6 +2757,139 @@ mod frame_sig_validation_tests {
27572757
}
27582758
}
27592759

2760+
/// Sign `msg_hash` with a fixed devnet key and return
2761+
/// `(recovery_id, r || s, signer)`. `sign_prehash_recoverable` normalizes
2762+
/// `s` to low-s, so every vector produced here is canonical per EIP-8141.
2763+
#[expect(
2764+
clippy::indexing_slicing,
2765+
reason = "fixed-size buffers with well-known bounds in test code"
2766+
)]
2767+
fn secp256k1_vector(msg_hash: H256) -> (u8, [u8; 64], Address) {
2768+
use k256::ecdsa::SigningKey;
2769+
2770+
let pk_hex = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
2771+
let pk_bytes: Vec<u8> = (0..pk_hex.len())
2772+
.step_by(2)
2773+
.map(|i| u8::from_str_radix(&pk_hex[i..i + 2], 16).unwrap())
2774+
.collect();
2775+
let private_key: [u8; 32] = pk_bytes.try_into().unwrap();
2776+
let signing_key = SigningKey::from_bytes(&private_key.into()).unwrap();
2777+
2778+
let (raw_sig, recovery_id) = signing_key
2779+
.sign_prehash_recoverable(msg_hash.as_bytes())
2780+
.unwrap();
2781+
2782+
// Signer address = keccak(uncompressed pubkey without the 0x04 tag)[12..].
2783+
let uncompressed = signing_key.verifying_key().to_encoded_point(false);
2784+
let pub_hash = ethrex_crypto::keccak::keccak_hash(&uncompressed.as_bytes()[1..]);
2785+
let signer = Address::from_slice(&pub_hash[12..]);
2786+
2787+
let mut rs = [0u8; 64];
2788+
rs.copy_from_slice(&raw_sig.to_bytes());
2789+
(recovery_id.to_byte(), rs, signer)
2790+
}
2791+
2792+
/// The `[v | r | s]` frame signature for [`secp256k1_vector`] over
2793+
/// `msg_hash`, with `signature[0]` forced to `v_byte` so the encoding of
2794+
/// that single byte is the only variable under test.
2795+
#[expect(
2796+
clippy::indexing_slicing,
2797+
reason = "fixed-size buffers with well-known bounds in test code"
2798+
)]
2799+
fn secp_frame_sig(msg_hash: H256, v_byte: u8, signer: Option<Address>) -> FrameSignature {
2800+
let (_, rs, _) = secp256k1_vector(msg_hash);
2801+
let mut bytes = vec![0u8; 65];
2802+
bytes[0] = v_byte;
2803+
bytes[1..65].copy_from_slice(&rs);
2804+
FrameSignature {
2805+
scheme: FRAME_SIG_SCHEME_SECP256K1,
2806+
signer,
2807+
msg: Bytes::new(), // empty -> the signature is over `sig_hash`
2808+
signature: Bytes::from(bytes),
2809+
}
2810+
}
2811+
2812+
/// Deterministically pick a prehash whose RFC-6979 signature has the
2813+
/// requested recovery id, so both `v = 0` and `v = 1` are exercised with
2814+
/// real signatures rather than one arbitrary parity.
2815+
fn msg_hash_with_recovery_id(want: u8) -> H256 {
2816+
(1u64..64)
2817+
.map(H256::from_low_u64_be)
2818+
.find(|h| secp256k1_vector(*h).0 == want)
2819+
.expect("no prehash in range yields the requested recovery id")
2820+
}
2821+
2822+
#[test]
2823+
fn secp256k1_spec_recovery_id_is_accepted() {
2824+
// EIP-8141 (spec commit fe0940cae2) Signature Validation: `signature[0]`
2825+
// is the recovery id `0`/`1`, matching EIP-2718 typed transactions, not
2826+
// the EVM `ecrecover` `v` (`27`/`28`). A conformant, canonical low-s
2827+
// signature must authenticate on the block-validation path.
2828+
for want_v in [0u8, 1u8] {
2829+
let msg_hash = msg_hash_with_recovery_id(want_v);
2830+
let (v, _, signer) = secp256k1_vector(msg_hash);
2831+
assert_eq!(v, want_v, "vector must have the requested recovery id");
2832+
let sig = secp_frame_sig(msg_hash, want_v, Some(signer));
2833+
assert!(
2834+
frame_signatures_are_low_s(std::slice::from_ref(&sig)),
2835+
"k256 normalizes to low-s; v = {want_v} vector must be canonical"
2836+
);
2837+
assert!(
2838+
validate_frame_signatures(
2839+
&[sig],
2840+
msg_hash,
2841+
Address::zero(),
2842+
hegota(),
2843+
&ethrex_crypto::NativeCrypto
2844+
),
2845+
"spec-encoded recovery id v = {want_v} must be accepted"
2846+
);
2847+
}
2848+
}
2849+
2850+
#[test]
2851+
fn secp256k1_evm_v_encoding_is_rejected() {
2852+
// `27`/`28` is the EVM `ecrecover` encoding. EIP-8141 rejects any
2853+
// `v > 1`, so the legacy encoding must never authenticate a frame tx,
2854+
// otherwise this client accepts blocks a conformant client rejects.
2855+
for v in [27u8, 28u8] {
2856+
let msg_hash = msg_hash_with_recovery_id(v - 27);
2857+
let (_, _, signer) = secp256k1_vector(msg_hash);
2858+
let sig = secp_frame_sig(msg_hash, v, Some(signer));
2859+
assert!(
2860+
!validate_frame_signatures(
2861+
&[sig],
2862+
msg_hash,
2863+
Address::zero(),
2864+
hegota(),
2865+
&ethrex_crypto::NativeCrypto
2866+
),
2867+
"EVM ecrecover encoding v = {v} must be rejected"
2868+
);
2869+
}
2870+
}
2871+
2872+
#[test]
2873+
fn secp256k1_out_of_range_v_is_rejected() {
2874+
// Everything else above the recovery-id range is invalid too: the
2875+
// neighbours of `27`/`28`, the EIP-155 chain-id forms, and 0xFF.
2876+
let msg_hash = msg_hash_with_recovery_id(0);
2877+
let (_, _, signer) = secp256k1_vector(msg_hash);
2878+
for v in [2u8, 3, 26, 29, 35, 36, 0xFF] {
2879+
let sig = secp_frame_sig(msg_hash, v, Some(signer));
2880+
assert!(
2881+
!validate_frame_signatures(
2882+
&[sig],
2883+
msg_hash,
2884+
Address::zero(),
2885+
hegota(),
2886+
&ethrex_crypto::NativeCrypto
2887+
),
2888+
"out-of-range v = {v} must be rejected"
2889+
);
2890+
}
2891+
}
2892+
27602893
fn p256_sig_with_s(s: &[u8; 32]) -> FrameSignature {
27612894
// [r(32) | s(32) | qx(32) | qy(32)]
27622895
let mut bytes = vec![0u8; 128];
@@ -2920,46 +3053,14 @@ mod frame_sig_validation_tests {
29203053
}
29213054

29223055
#[test]
2923-
#[expect(
2924-
clippy::indexing_slicing,
2925-
reason = "fixed-size buffers with well-known bounds in test code"
2926-
)]
29273056
fn secp256k1_positive_and_tampered() {
2928-
// Build a real secp256k1 signature vector using k256.
2929-
use k256::ecdsa::SigningKey;
2930-
2931-
let pk_hex = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
2932-
let pk_bytes: Vec<u8> = (0..pk_hex.len())
2933-
.step_by(2)
2934-
.map(|i| u8::from_str_radix(&pk_hex[i..i + 2], 16).unwrap())
2935-
.collect();
2936-
let private_key: [u8; 32] = pk_bytes.try_into().unwrap();
2937-
let signing_key = SigningKey::from_bytes(&private_key.into()).unwrap();
2938-
3057+
// Build a real secp256k1 signature vector using k256. The outer
3058+
// signature is `v || r || s` (65 bytes) where `v` is the EIP-8141
3059+
// recovery id (0/1), NOT the EVM ecrecover `v` (27/28).
29393060
let msg_hash: H256 = H256::from_low_u64_be(0xDEADBEEF_CAFEBABE);
3061+
let (recovery_id, _, expected_signer) = secp256k1_vector(msg_hash);
29403062

2941-
let (raw_sig, recovery_id) = signing_key
2942-
.sign_prehash_recoverable(msg_hash.as_bytes())
2943-
.unwrap();
2944-
2945-
// Derive the expected signer address
2946-
let uncompressed = signing_key.verifying_key().to_encoded_point(false);
2947-
let pub_hash = ethrex_crypto::keccak::keccak_hash(&uncompressed.as_bytes()[1..]);
2948-
let expected_signer = Address::from_slice(&pub_hash[12..]);
2949-
2950-
// Build the outer signature: v || r || s (65 bytes).
2951-
// EVM ecrecover expects v ∈ {27, 28}, so add 27 to the raw recovery id.
2952-
let mut sig_bytes = vec![0u8; 65];
2953-
sig_bytes[0] = 27 + recovery_id.to_byte();
2954-
sig_bytes[1..33].copy_from_slice(&raw_sig.to_bytes()[..32]); // r
2955-
sig_bytes[33..65].copy_from_slice(&raw_sig.to_bytes()[32..]); // s
2956-
2957-
let valid_sig = FrameSignature {
2958-
scheme: FRAME_SIG_SCHEME_SECP256K1,
2959-
signer: Some(expected_signer),
2960-
msg: Bytes::new(), // empty → use sig_hash
2961-
signature: Bytes::from(sig_bytes.clone()),
2962-
};
3063+
let valid_sig = secp_frame_sig(msg_hash, recovery_id, Some(expected_signer));
29633064

29643065
// Positive: correct signer → valid
29653066
assert!(
@@ -2991,12 +3092,7 @@ mod frame_sig_validation_tests {
29913092
);
29923093

29933094
// Empty signer (None) resolves to tx.sender: valid iff sender == recovered.
2994-
let empty_signer_sig = FrameSignature {
2995-
scheme: FRAME_SIG_SCHEME_SECP256K1,
2996-
signer: None,
2997-
msg: Bytes::new(),
2998-
signature: Bytes::from(sig_bytes.clone()),
2999-
};
3095+
let empty_signer_sig = secp_frame_sig(msg_hash, recovery_id, None);
30003096
assert!(
30013097
validate_frame_signatures(
30023098
std::slice::from_ref(&empty_signer_sig),

0 commit comments

Comments
 (0)