From adc2132633b873a66d87831e503ba4d1f61d2100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Vincent?= <28714795+leovct@users.noreply.github.com> Date: Mon, 29 Jul 2024 10:34:19 +0200 Subject: [PATCH] feat(cheatcodes): implement EIP-2098 in `vm.sign` (#8538) * feat: implement EIP-2098 in vm.sign * fix: clippy * test: fix * fix: forge-fmt * chore: implement new cheatcode vm.signEIP2098 * fix: typos * fix: another typo * fix * test: update sign tests * fix: typo * chore: rename `vm.signEIP2098()` into `vm.signCompact()` * chore: update `encode_compact_signature` impl * chore: update `signCompact` tests * chore: factorize * chore: rename fns * chore: nit * fix: issue * chore: nits --- crates/cheatcodes/assets/cheatcodes.json | 80 ++++++++++++++++++++++++ crates/cheatcodes/spec/src/vm.rs | 40 ++++++++++++ crates/cheatcodes/src/evm.rs | 33 +++++++++- crates/cheatcodes/src/utils.rs | 35 ++++++++--- testdata/cheats/Vm.sol | 4 ++ testdata/default/cheats/Sign.t.sol | 24 +++++++ testdata/default/cheats/Wallet.t.sol | 26 ++++++++ 7 files changed, 231 insertions(+), 11 deletions(-) diff --git a/crates/cheatcodes/assets/cheatcodes.json b/crates/cheatcodes/assets/cheatcodes.json index 61f626477705..c66f3a5ecc84 100644 --- a/crates/cheatcodes/assets/cheatcodes.json +++ b/crates/cheatcodes/assets/cheatcodes.json @@ -7751,6 +7751,86 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "signCompact_0", + "description": "Signs `digest` with `privateKey` using the secp256k1 curve.\nReturns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the\nsignature's `s` value, and the recovery id `v` in a single bytes32.\nThis format reduces the signature size from 65 to 64 bytes.", + "declaration": "function signCompact(uint256 privateKey, bytes32 digest) external pure returns (bytes32 r, bytes32 vs);", + "visibility": "external", + "mutability": "pure", + "signature": "signCompact(uint256,bytes32)", + "selector": "0xcc2a781f", + "selectorBytes": [ + 204, + 42, + 120, + 31 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "signCompact_1", + "description": "Signs `digest` with signer provided to script using the secp256k1 curve.\nReturns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the\nsignature's `s` value, and the recovery id `v` in a single bytes32.\nThis format reduces the signature size from 65 to 64 bytes.\nIf `--sender` is provided, the signer with provided address is used, otherwise,\nif exactly one signer is provided to the script, that signer is used.\nRaises error if signer passed through `--sender` does not match any unlocked signers or\nif `--sender` is not provided and not exactly one signer is passed to the script.", + "declaration": "function signCompact(bytes32 digest) external pure returns (bytes32 r, bytes32 vs);", + "visibility": "external", + "mutability": "pure", + "signature": "signCompact(bytes32)", + "selector": "0xa282dc4b", + "selectorBytes": [ + 162, + 130, + 220, + 75 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "signCompact_2", + "description": "Signs `digest` with signer provided to script using the secp256k1 curve.\nReturns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the\nsignature's `s` value, and the recovery id `v` in a single bytes32.\nThis format reduces the signature size from 65 to 64 bytes.\nRaises error if none of the signers passed into the script have provided address.", + "declaration": "function signCompact(address signer, bytes32 digest) external pure returns (bytes32 r, bytes32 vs);", + "visibility": "external", + "mutability": "pure", + "signature": "signCompact(address,bytes32)", + "selector": "0x8e2f97bf", + "selectorBytes": [ + 142, + 47, + 151, + 191 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "signCompact_3", + "description": "Signs data with a `Wallet`.\nReturns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the\nsignature's `s` value, and the recovery id `v` in a single bytes32.\nThis format reduces the signature size from 65 to 64 bytes.", + "declaration": "function signCompact(Wallet calldata wallet, bytes32 digest) external returns (bytes32 r, bytes32 vs);", + "visibility": "external", + "mutability": "", + "signature": "signCompact((address,uint256,uint256,uint256),bytes32)", + "selector": "0x3d0e292f", + "selectorBytes": [ + 61, + 14, + 41, + 47 + ] + }, + "group": "utilities", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "signP256", diff --git a/crates/cheatcodes/spec/src/vm.rs b/crates/cheatcodes/spec/src/vm.rs index 935d44e53a4e..685a6f751ff1 100644 --- a/crates/cheatcodes/spec/src/vm.rs +++ b/crates/cheatcodes/spec/src/vm.rs @@ -286,6 +286,14 @@ interface Vm { #[cheatcode(group = Evm, safety = Safe)] function sign(uint256 privateKey, bytes32 digest) 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 + /// signature's `s` value, and the recovery id `v` in a single bytes32. + /// This format reduces the signature size from 65 to 64 bytes. + #[cheatcode(group = Evm, safety = Safe)] + function signCompact(uint256 privateKey, bytes32 digest) external pure returns (bytes32 r, bytes32 vs); + /// Signs `digest` with signer provided to script using the secp256k1 curve. /// /// If `--sender` is provided, the signer with provided address is used, otherwise, @@ -296,12 +304,36 @@ interface Vm { #[cheatcode(group = Evm, safety = Safe)] function sign(bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s); + /// Signs `digest` with signer provided to script using the secp256k1 curve. + /// + /// Returns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the + /// signature's `s` value, and the recovery id `v` in a single bytes32. + /// This format reduces the signature size from 65 to 64 bytes. + /// + /// If `--sender` is provided, the signer with provided address is used, otherwise, + /// if exactly one signer is provided to the script, that signer is used. + /// + /// Raises error if signer passed through `--sender` does not match any unlocked signers or + /// if `--sender` is not provided and not exactly one signer is passed to the script. + #[cheatcode(group = Evm, safety = Safe)] + function signCompact(bytes32 digest) external pure returns (bytes32 r, bytes32 vs); + /// Signs `digest` with signer provided to script using the secp256k1 curve. /// /// Raises error if none of the signers passed into the script have provided address. #[cheatcode(group = Evm, safety = Safe)] function sign(address signer, bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s); + /// Signs `digest` with signer provided to script using the secp256k1 curve. + /// + /// Returns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the + /// signature's `s` value, and the recovery id `v` in a single bytes32. + /// This format reduces the signature size from 65 to 64 bytes. + /// + /// Raises error if none of the signers passed into the script have provided address. + #[cheatcode(group = Evm, safety = Safe)] + function signCompact(address signer, bytes32 digest) external pure returns (bytes32 r, bytes32 vs); + /// Signs `digest` with `privateKey` using the secp256r1 curve. #[cheatcode(group = Evm, safety = Safe)] function signP256(uint256 privateKey, bytes32 digest) external pure returns (bytes32 r, bytes32 s); @@ -2153,6 +2185,14 @@ interface Vm { #[cheatcode(group = Utilities)] function sign(Wallet calldata wallet, bytes32 digest) external returns (uint8 v, bytes32 r, bytes32 s); + /// Signs data with a `Wallet`. + /// + /// Returns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the + /// signature's `s` value, and the recovery id `v` in a single bytes32. + /// This format reduces the signature size from 65 to 64 bytes. + #[cheatcode(group = Utilities)] + function signCompact(Wallet calldata wallet, bytes32 digest) external returns (bytes32 r, bytes32 vs); + /// Derive a private key from a provided mnenomic string (or mnenomic file path) /// at the derivation path `m/44'/60'/0'/0/{index}`. #[cheatcode(group = Utilities)] diff --git a/crates/cheatcodes/src/evm.rs b/crates/cheatcodes/src/evm.rs index 97923a948791..5e24070a8caa 100644 --- a/crates/cheatcodes/src/evm.rs +++ b/crates/cheatcodes/src/evm.rs @@ -162,21 +162,48 @@ impl Cheatcode for dumpStateCall { impl Cheatcode for sign_0Call { fn apply_stateful(&self, _: &mut CheatsCtxt) -> Result { let Self { privateKey, digest } = self; - super::utils::sign(privateKey, digest) + let sig = super::utils::sign(privateKey, digest)?; + Ok(super::utils::encode_full_sig(sig)) + } +} + +impl Cheatcode for signCompact_0Call { + fn apply_stateful(&self, _: &mut CheatsCtxt) -> Result { + let Self { privateKey, digest } = self; + let sig = super::utils::sign(privateKey, digest)?; + Ok(super::utils::encode_compact_sig(sig)) } } impl Cheatcode for sign_1Call { fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { let Self { digest } = self; - super::utils::sign_with_wallet(ccx, None, digest) + let sig = super::utils::sign_with_wallet(ccx, None, digest)?; + Ok(super::utils::encode_full_sig(sig)) + } +} + +impl Cheatcode for signCompact_1Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + let Self { digest } = self; + let sig = super::utils::sign_with_wallet(ccx, None, digest)?; + Ok(super::utils::encode_compact_sig(sig)) } } impl Cheatcode for sign_2Call { fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { let Self { signer, digest } = self; - super::utils::sign_with_wallet(ccx, Some(*signer), digest) + let sig = super::utils::sign_with_wallet(ccx, Some(*signer), digest)?; + Ok(super::utils::encode_full_sig(sig)) + } +} + +impl Cheatcode for signCompact_2Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + let Self { signer, digest } = self; + let sig = super::utils::sign_with_wallet(ccx, Some(*signer), digest)?; + Ok(super::utils::encode_compact_sig(sig)) } } diff --git a/crates/cheatcodes/src/utils.rs b/crates/cheatcodes/src/utils.rs index 08e255341714..67b154ff246b 100644 --- a/crates/cheatcodes/src/utils.rs +++ b/crates/cheatcodes/src/utils.rs @@ -55,7 +55,16 @@ impl Cheatcode for getNonce_1Call { impl Cheatcode for sign_3Call { fn apply_stateful(&self, _: &mut CheatsCtxt) -> Result { let Self { wallet, digest } = self; - sign(&wallet.privateKey, digest) + let sig = sign(&wallet.privateKey, digest)?; + Ok(encode_full_sig(sig)) + } +} + +impl Cheatcode for signCompact_3Call { + fn apply_stateful(&self, _: &mut CheatsCtxt) -> Result { + let Self { wallet, digest } = self; + let sig = sign(&wallet.privateKey, digest)?; + Ok(encode_compact_sig(sig)) } } @@ -201,25 +210,35 @@ fn create_wallet(private_key: &U256, label: Option<&str>, state: &mut Cheatcodes .abi_encode()) } -fn encode_vrs(sig: alloy_primitives::Signature) -> Vec { - let v = sig.v().y_parity_byte_non_eip155().unwrap_or(sig.v().y_parity_byte()); +pub(super) fn encode_full_sig(sig: alloy_primitives::Signature) -> Vec { + // Retrieve v, r and s from signature. + let v = U256::from(sig.v().y_parity_byte_non_eip155().unwrap_or(sig.v().y_parity_byte())); + let r = B256::from(sig.r()); + let s = B256::from(sig.s()); + (v, r, s).abi_encode() +} - (U256::from(v), B256::from(sig.r()), B256::from(sig.s())).abi_encode() +pub(super) fn encode_compact_sig(sig: alloy_primitives::Signature) -> Vec { + // Implement EIP-2098 compact signature. + let r = B256::from(sig.r()); + let mut vs = sig.s(); + vs.set_bit(255, sig.v().y_parity()); + (r, vs).abi_encode() } -pub(super) fn sign(private_key: &U256, digest: &B256) -> Result { +pub(super) fn sign(private_key: &U256, digest: &B256) -> Result { // The `ecrecover` precompile does not use EIP-155. No chain ID is needed. let wallet = parse_wallet(private_key)?; let sig = wallet.sign_hash_sync(digest)?; debug_assert_eq!(sig.recover_address_from_prehash(digest)?, wallet.address()); - Ok(encode_vrs(sig)) + Ok(sig) } pub(super) fn sign_with_wallet( ccx: &mut CheatsCtxt, signer: Option
, digest: &B256, -) -> Result { +) -> Result { let Some(script_wallets) = ccx.state.script_wallets() else { bail!("no wallets are available"); }; @@ -244,7 +263,7 @@ pub(super) fn sign_with_wallet( let sig = foundry_common::block_on(wallet.sign_hash(digest))?; debug_assert_eq!(sig.recover_address_from_prehash(digest)?, signer); - Ok(encode_vrs(sig)) + Ok(sig) } pub(super) fn sign_p256(private_key: &U256, digest: &B256, _state: &mut Cheatcodes) -> Result { diff --git a/testdata/cheats/Vm.sol b/testdata/cheats/Vm.sol index f42fb22983ea..4a9d9cd69a27 100644 --- a/testdata/cheats/Vm.sol +++ b/testdata/cheats/Vm.sol @@ -383,6 +383,10 @@ interface Vm { function setEnv(string calldata name, string calldata value) external; function setNonce(address account, uint64 newNonce) external; function setNonceUnsafe(address account, uint64 newNonce) external; + function signCompact(uint256 privateKey, bytes32 digest) external pure returns (bytes32 r, bytes32 vs); + function signCompact(bytes32 digest) external pure returns (bytes32 r, bytes32 vs); + function signCompact(address signer, bytes32 digest) external pure returns (bytes32 r, bytes32 vs); + function signCompact(Wallet calldata wallet, bytes32 digest) external returns (bytes32 r, bytes32 vs); function signP256(uint256 privateKey, bytes32 digest) external pure returns (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 e46439b58d16..a257d62919ef 100644 --- a/testdata/default/cheats/Sign.t.sol +++ b/testdata/default/cheats/Sign.t.sol @@ -13,11 +13,35 @@ contract SignTest is DSTest { (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); address expected = vm.addr(pk); address actual = ecrecover(digest, v, r, s); + assertEq(actual, expected, "digest signer did not match"); + } + + function testSignCompactDigest(uint248 pk, bytes32 digest) public { + vm.assume(pk != 0); + + (bytes32 r, bytes32 vs) = vm.signCompact(pk, digest); + + // Extract `s` from `vs`. + // Shift left by 1 bit to clear the leftmost bit, then shift right by 1 bit to restore the original position. + // This effectively clears the leftmost bit of `vs`, giving us `s`. + bytes32 s = bytes32((uint256(vs) << 1) >> 1); + // Extract `v` from `vs`. + // We shift `vs` right by 255 bits to isolate the leftmost bit. + // Converting this to uint8 gives us the parity bit (0 or 1). + // Adding 27 converts this parity bit to the correct `v` value (27 or 28). + uint8 v = uint8(uint256(vs) >> 255) + 27; + + address expected = vm.addr(pk); + address actual = ecrecover(digest, v, r, s); assertEq(actual, expected, "digest signer did not match"); } function testSignMessage(uint248 pk, bytes memory message) public { testSignDigest(pk, keccak256(message)); } + + function testSignCompactMessage(uint248 pk, bytes memory message) public { + testSignCompactDigest(pk, keccak256(message)); + } } diff --git a/testdata/default/cheats/Wallet.t.sol b/testdata/default/cheats/Wallet.t.sol index 8ecb707aec92..5a7035876cd0 100644 --- a/testdata/default/cheats/Wallet.t.sol +++ b/testdata/default/cheats/Wallet.t.sol @@ -104,10 +104,36 @@ contract WalletTest is DSTest { assertEq(recovered, wallet.addr); } + function testSignCompactWithWalletDigest(uint256 pkSeed, bytes32 digest) public { + uint256 pk = bound(pkSeed, 1, Q - 1); + + Vm.Wallet memory wallet = vm.createWallet(pk); + + (bytes32 r, bytes32 vs) = vm.signCompact(wallet, digest); + + // Extract `s` from `vs`. + // Shift left by 1 bit to clear the leftmost bit, then shift right by 1 bit to restore the original position. + // This effectively clears the leftmost bit of `vs`, giving us `s`. + bytes32 s = bytes32((uint256(vs) << 1) >> 1); + + // Extract `v` from `vs`. + // We shift `vs` right by 255 bits to isolate the leftmost bit. + // Converting this to uint8 gives us the parity bit (0 or 1). + // Adding 27 converts this parity bit to the correct `v` value (27 or 28). + uint8 v = uint8(uint256(vs) >> 255) + 27; + + address recovered = ecrecover(digest, v, r, s); + assertEq(recovered, wallet.addr); + } + function testSignWithWalletMessage(uint256 pkSeed, bytes memory message) public { testSignWithWalletDigest(pkSeed, keccak256(message)); } + function testSignCompactWithWalletMessage(uint256 pkSeed, bytes memory message) public { + testSignCompactWithWalletDigest(pkSeed, keccak256(message)); + } + function testGetNonceWallet(uint256 pkSeed) public { uint256 pk = bound(pkSeed, 1, Q - 1);