diff --git a/crates/cheatcodes/assets/cheatcodes.json b/crates/cheatcodes/assets/cheatcodes.json index 4481f89c2ea0b..be7bb84d197dc 100644 --- a/crates/cheatcodes/assets/cheatcodes.json +++ b/crates/cheatcodes/assets/cheatcodes.json @@ -10390,6 +10390,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "signWithNonce", + "description": "Signs `digest` with `privateKey` on the secp256k1 curve, using the given `nonce`\nas the raw ephemeral k value in ECDSA (instead of deriving it deterministically).", + "declaration": "function signWithNonce(uint256 privateKey, bytes32 digest, uint256 nonce) external pure returns (uint8 v, bytes32 r, bytes32 s);", + "visibility": "external", + "mutability": "pure", + "signature": "signWithNonce(uint256,bytes32,uint256)", + "selector": "0xe11cb8fc", + "selectorBytes": [ + 225, + 28, + 184, + 252 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "sign_0", diff --git a/crates/cheatcodes/spec/src/vm.rs b/crates/cheatcodes/spec/src/vm.rs index 3281596acac62..ba89e0a9e926e 100644 --- a/crates/cheatcodes/spec/src/vm.rs +++ b/crates/cheatcodes/spec/src/vm.rs @@ -2785,6 +2785,12 @@ interface Vm { #[cheatcode(group = Crypto)] function sign(uint256 privateKey, bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s); + /// Signs `digest` with `privateKey` on the secp256k1 curve, using the given `nonce` + /// as the raw ephemeral k value in ECDSA (instead of deriving it deterministically). + #[cheatcode(group = Crypto)] + function signWithNonce(uint256 privateKey, bytes32 digest, uint256 nonce) external pure returns (uint8 v, bytes32 r, bytes32 s); + + /// Signs `digest` with `privateKey` using the secp256k1 curve. /// /// Returns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the diff --git a/crates/cheatcodes/src/crypto.rs b/crates/cheatcodes/src/crypto.rs index 0b1f9ffdd07a6..8c73f9b683e8a 100644 --- a/crates/cheatcodes/src/crypto.rs +++ b/crates/cheatcodes/src/crypto.rs @@ -12,9 +12,11 @@ use alloy_signer_local::{ }; use alloy_sol_types::SolValue; use k256::{ - ecdsa::SigningKey, + FieldBytes, Scalar, + ecdsa::{SigningKey, hazmat}, elliptic_curve::{bigint::ArrayEncoding, sec1::ToEncodedPoint}, }; + use p256::ecdsa::{ Signature as P256Signature, SigningKey as P256SigningKey, signature::hazmat::PrehashSigner, }; @@ -51,6 +53,16 @@ impl Cheatcode for sign_0Call { } } +impl Cheatcode for signWithNonceCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let pk: U256 = self.privateKey; + let digest: B256 = self.digest; + let nonce: U256 = self.nonce; + let sig: alloy_primitives::Signature = sign_with_nonce(&pk, &digest, &nonce)?; + Ok(encode_full_sig(sig)) + } +} + impl Cheatcode for signCompact_0Call { fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { wallet, digest } = self; @@ -241,6 +253,83 @@ fn sign(private_key: &U256, digest: &B256) -> Result Result { + let d_scalar: Scalar = + ::from_repr(private_key.to_be_bytes().into()) + .into_option() + .ok_or_else(|| fmt_err!("invalid private key scalar"))?; + if bool::from(d_scalar.is_zero()) { + return Err(fmt_err!("private key cannot be 0")); + } + + let k_scalar: Scalar = + ::from_repr(nonce.to_be_bytes().into()) + .into_option() + .ok_or_else(|| fmt_err!("invalid nonce scalar"))?; + if bool::from(k_scalar.is_zero()) { + return Err(fmt_err!("nonce cannot be 0")); + } + + let mut z = [0u8; 32]; + z.copy_from_slice(digest.as_slice()); + let z_fb: FieldBytes = FieldBytes::from(z); + + // Hazmat signing using the scalar `d` (SignPrimitive is implemented for `Scalar`) + // Note: returns (Signature, Option) + let (sig_raw, recid_opt) = + >::try_sign_prehashed( + &d_scalar, k_scalar, &z_fb, + ) + .map_err(|e| fmt_err!("sign_prehashed failed: {e}"))?; + + // Enforce low-s; if mirrored, parity flips (we’ll account for it below if we use recid) + let (sig_low, flipped) = + if let Some(norm) = sig_raw.normalize_s() { (norm, true) } else { (sig_raw, false) }; + + let r_u256 = U256::from_be_bytes(sig_low.r().to_bytes().into()); + let s_u256 = U256::from_be_bytes(sig_low.s().to_bytes().into()); + + // Determine v parity in {0,1} + let v_parity = if let Some(id) = recid_opt { + let mut v = id.to_byte() & 1; + if flipped { + v ^= 1; + } + v + } else { + // Fallback: choose parity by recovery to expected address + let expected_addr = { + let sk: SigningKey = parse_private_key(private_key)?; + alloy_signer::utils::secret_key_to_address(&sk) + }; + // Try v = 0 + let cand0 = alloy_primitives::Signature::new(r_u256, s_u256, false); + if cand0.recover_address_from_prehash(digest).ok() == Some(expected_addr) { + return Ok(cand0); + } + // Try v = 1 + let cand1 = alloy_primitives::Signature::new(r_u256, s_u256, true); + if cand1.recover_address_from_prehash(digest).ok() == Some(expected_addr) { + return Ok(cand1); + } + return Err(fmt_err!("failed to determine recovery id for signature")); + }; + + let y_parity = v_parity != 0; + Ok(alloy_primitives::Signature::new(r_u256, s_u256, y_parity)) +} + fn sign_with_wallet( state: &mut Cheatcodes, signer: Option
, @@ -393,6 +482,7 @@ fn derive_wallets( mod tests { use super::*; use alloy_primitives::{FixedBytes, hex::FromHex}; + use k256::elliptic_curve::Curve; use p256::ecdsa::signature::hazmat::PrehashVerifier; #[test] @@ -438,4 +528,69 @@ mod tests { let result = sign_p256(&U256::ZERO, &digest); assert_eq!(result.err().unwrap().to_string(), "private key cannot be 0"); } + + #[test] + fn test_sign_with_nonce_varies_and_recovers() { + // Given a fixed private key and digest + let pk_u256: U256 = U256::from(1u64); + let digest = FixedBytes::from_hex( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .unwrap(); + + // Two distinct nonces + let n1: U256 = U256::from(123u64); + let n2: U256 = U256::from(456u64); + + // Sign with both nonces + let sig1 = sign_with_nonce(&pk_u256, &digest, &n1).expect("sig1"); + let sig2 = sign_with_nonce(&pk_u256, &digest, &n2).expect("sig2"); + + // (r,s) must differ when nonce differs + assert!( + sig1.r() != sig2.r() || sig1.s() != sig2.s(), + "signatures should differ with different nonces" + ); + + // ecrecover must yield the address for both signatures + let sk = parse_private_key(&pk_u256).unwrap(); + let expected = alloy_signer::utils::secret_key_to_address(&sk); + + assert_eq!(sig1.recover_address_from_prehash(&digest).unwrap(), expected); + assert_eq!(sig2.recover_address_from_prehash(&digest).unwrap(), expected); + } + + #[test] + fn test_sign_with_nonce_zero_nonce_errors() { + // nonce = 0 should be rejected + let pk_u256: U256 = U256::from(1u64); + let digest = FixedBytes::from_hex( + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + .unwrap(); + let n0: U256 = U256::ZERO; + + let err = sign_with_nonce(&pk_u256, &digest, &n0).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("nonce cannot be 0"), "unexpected error: {msg}"); + } + + #[test] + fn test_sign_with_nonce_nonce_ge_order_errors() { + // nonce >= n should be rejected + use k256::Secp256k1; + // Curve order n as U256 + let n_u256 = U256::from_be_slice(&Secp256k1::ORDER.to_be_byte_array()); + + let pk_u256: U256 = U256::from(1u64); + let digest = FixedBytes::from_hex( + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + ) + .unwrap(); + + // Try exactly n (>= n invalid) + let err = sign_with_nonce(&pk_u256, &digest, &n_u256).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("invalid nonce scalar"), "unexpected error: {msg}"); + } } diff --git a/testdata/cheats/Vm.sol b/testdata/cheats/Vm.sol index 69428698fb607..cccf914ee1282 100644 --- a/testdata/cheats/Vm.sol +++ b/testdata/cheats/Vm.sol @@ -512,6 +512,7 @@ interface Vm { function signDelegation(address implementation, uint256 privateKey, uint64 nonce) external returns (SignedDelegation memory signedDelegation); function signDelegation(address implementation, uint256 privateKey, bool crossChain) external returns (SignedDelegation memory signedDelegation); function signP256(uint256 privateKey, bytes32 digest) external pure returns (bytes32 r, bytes32 s); + function signWithNonce(uint256 privateKey, bytes32 digest, uint256 nonce) external pure returns (uint8 v, bytes32 r, bytes32 s); function sign(Wallet calldata wallet, bytes32 digest) external returns (uint8 v, bytes32 r, bytes32 s); function sign(uint256 privateKey, bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s); function sign(bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s); diff --git a/testdata/default/cheats/Sign.t.sol b/testdata/default/cheats/Sign.t.sol index 937ebc00a9222..899a203f8c323 100644 --- a/testdata/default/cheats/Sign.t.sol +++ b/testdata/default/cheats/Sign.t.sol @@ -44,4 +44,60 @@ contract SignTest is DSTest { function testSignCompactMessage(uint248 pk, bytes memory message) public { testSignCompactDigest(pk, keccak256(message)); } + + /// secp256k1 subgroup order n + function _secp256k1Order() internal pure returns (uint256) { + return 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + } + + function testSignWithNonceDigestDifferentNonces(uint248 pk, bytes32 digest) public { + vm.assume(pk != 0); + uint256 n1 = 123; + uint256 n2 = 456; + vm.assume(n1 != 0 && n2 != 0 && n1 != n2); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.signWithNonce(pk, digest, n1); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.signWithNonce(pk, digest, n2); + assertTrue(r1 != r2 || s1 != s2, "signatures should differ for different nonces"); + address expected = vm.addr(pk); + assertEq(ecrecover(digest, v1, r1, s1), expected, "recover for nonce n1 failed"); + assertEq(ecrecover(digest, v2, r2, s2), expected, "recover for nonce n2 failed"); + } + + function testSignWithNonceDigestSameNonceDeterministic(uint248 pk, bytes32 digest) public { + vm.assume(pk != 0); + uint256 n = 777; + vm.assume(n != 0); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.signWithNonce(pk, digest, n); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.signWithNonce(pk, digest, n); + assertEq(v1, v2, "v should match"); + assertEq(r1, r2, "r should match"); + assertEq(s1, s2, "s should match"); + address expected = vm.addr(pk); + assertEq(ecrecover(digest, v1, r1, s1), expected, "recover failed"); + } + + function testSignWithNonceInvalidNoncesRevert() public { + uint256 pk = 1; + bytes32 digest = 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb; + (bool ok, bytes memory data) = + HEVM_ADDRESS.call(abi.encodeWithSelector(Vm.signWithNonce.selector, pk, digest, 0)); + assertTrue(!ok, "expected revert on nonce=0"); + assertEq(_revertString(data), "vm.signWithNonce: nonce cannot be 0"); + uint256 n = _secp256k1Order(); + (ok, data) = HEVM_ADDRESS.call(abi.encodeWithSelector(Vm.signWithNonce.selector, pk, digest, n)); + assertTrue(!ok, "expected revert on nonce >= n"); + assertEq(_revertString(data), "vm.signWithNonce: invalid nonce scalar"); + } + + /// Decode revert payload + /// by stripping the 4-byte selector and ABI-decoding the tail as `string`. + function _revertString(bytes memory data) internal pure returns (string memory) { + if (data.length < 4) return ""; + // copy data[4:] into a new bytes + bytes memory tail = new bytes(data.length - 4); + for (uint256 i = 0; i < tail.length; i++) { + tail[i] = data[i + 4]; + } + return abi.decode(tail, (string)); + } }