Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a6b0410
feat: adding sign_wih_nonce in cheatcodes crates
Ectario Aug 9, 2025
5f3cc58
test: adding sign with nonce solidity unit tests
Ectario Aug 10, 2025
106e6c1
Merge remote-tracking branch 'origin/master' into gen-sig-with-nonce
Ectario Aug 10, 2025
ee0c5c0
style: formatting
Ectario Aug 10, 2025
beda0df
test: fixing solidity invalid nonce test
Ectario Aug 10, 2025
6efc47d
style: fix formatting in Sign.t.sol
Ectario Aug 10, 2025
ca28a20
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 11, 2025
d7186fc
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 12, 2025
17f8778
Merge branch 'foundry-rs:master' into gen-sig-with-nonce
Ectario Aug 12, 2025
0a9d936
ci: fix cheatcode specs test problem
Ectario Aug 12, 2025
0bacb11
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 13, 2025
7b7aa16
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 15, 2025
69ba4cd
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 18, 2025
db3170f
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 18, 2025
70eb80f
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 21, 2025
269d851
Update crates/cheatcodes/spec/src/vm.rs
Ectario Aug 21, 2025
1b24256
Update crates/cheatcodes/src/crypto.rs
Ectario Aug 21, 2025
ece9385
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 21, 2025
d7b98c7
refactor: changing cheatcode name (append Unsafe to it)
Ectario Aug 21, 2025
29b4f7b
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 21, 2025
f835935
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 21, 2025
bd43142
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 22, 2025
ee3301b
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 24, 2025
06e668e
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 25, 2025
7291eb3
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 26, 2025
5f815a1
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 26, 2025
9d91441
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 28, 2025
647f734
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 30, 2025
ef62d54
Merge branch 'master' into gen-sig-with-nonce
Ectario Aug 31, 2025
22c06ef
Merge branch 'master' into gen-sig-with-nonce
Ectario Sep 1, 2025
d06d57c
Merge branch 'master' into gen-sig-with-nonce
Ectario Sep 4, 2025
529b8df
Merge branch 'master' into gen-sig-with-nonce
Ectario Sep 4, 2025
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 @@ -2855,6 +2855,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));
}
}
Loading