Skip to content

feat(cheatcodes): Add vm.signWithNonce(privateKey, digest, nonce) cheatcode (Crypto) #11267

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions crates/cheatcodes/assets/cheatcodes.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/cheatcodes/spec/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 156 additions & 1 deletion crates/cheatcodes/src/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -241,6 +253,83 @@ fn sign(private_key: &U256, digest: &B256) -> Result<alloy_primitives::Signature
Ok(sig)
}

// Signs `digest` on secp256k1 using a user-supplied ephemeral nonce `k` (no RFC6979).
// - `private_key` and `nonce` must be in (0, n)
// - `digest` is a 32-byte prehash.
// WARNING: Use sign_with_nonce with extreme caution!
// Reusing the same nonce (k) with the same private key in ECDSA will leak the private key.
// Always generate `nonce` with a cryptographically secure RNG, and never reuse it across
// signatures.
fn sign_with_nonce(
private_key: &U256,
digest: &B256,
nonce: &U256,
) -> Result<alloy_primitives::Signature> {
let d_scalar: Scalar =
<Scalar as k256::elliptic_curve::PrimeField>::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 =
<Scalar as k256::elliptic_curve::PrimeField>::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<RecoveryId>)
let (sig_raw, recid_opt) =
<Scalar as hazmat::SignPrimitive<k256::Secp256k1>>::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<Address>,
Expand Down Expand Up @@ -393,6 +482,7 @@ fn derive_wallets<W: Wordlist>(
mod tests {
use super::*;
use alloy_primitives::{FixedBytes, hex::FromHex};
use k256::elliptic_curve::Curve;
use p256::ecdsa::signature::hazmat::PrehashVerifier;

#[test]
Expand Down Expand Up @@ -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}");
}
}
1 change: 1 addition & 0 deletions testdata/cheats/Vm.sol

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 56 additions & 0 deletions testdata/default/cheats/Sign.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}