Skip to content

feat(forge): implement vm.signTypedData cheatcode #10330

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

Draft
wants to merge 21 commits into
base: master
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions crates/cheatcodes/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ tracing.workspace = true
walkdir.workspace = true
proptest.workspace = true
serde.workspace = true

40 changes: 40 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.

10 changes: 10 additions & 0 deletions crates/cheatcodes/spec/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2667,6 +2667,12 @@ interface Vm {
#[cheatcode(group = Crypto)]
function sign(uint256 privateKey, bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s);

/// Signs human-readable typed data `jsonData` with `privateKey` using the secp256k1 curve.
///
/// The typed data must follow the EIP-712 standard.
#[cheatcode(group = Crypto)]
function signTypedData(string calldata jsonData, uint256 privateKey) 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 Expand Up @@ -2888,6 +2894,10 @@ interface Vm {
/// catch (bytes memory interceptedInitcode) { initcode = interceptedInitcode; }
#[cheatcode(group = Utilities, safety = Unsafe)]
function interceptInitcode() external;

/// Generates a ready-to-sign digest of human-readable typed data following the EIP-712 standard.
#[cheatcode(group = Utilities)]
function eip712HashTypedData(string calldata jsonData) external pure returns (bytes32 digest);
}
}

Expand Down
11 changes: 11 additions & 0 deletions crates/cheatcodes/src/crypto.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Implementations of [`Crypto`](spec::Group::Crypto) Cheatcodes.

use crate::{Cheatcode, Cheatcodes, Result, Vm::*};
use alloy_dyn_abi::eip712::TypedData;
use alloy_primitives::{keccak256, Address, B256, U256};
use alloy_signer::{Signer, SignerSync};
use alloy_signer_local::{
Expand Down Expand Up @@ -51,6 +52,16 @@ impl Cheatcode for sign_0Call {
}
}

impl Cheatcode for signTypedDataCall {
fn apply(&self, _state: &mut Cheatcodes) -> Result {
let Self { jsonData, privateKey } = self;
let typed_data: TypedData = serde_json::from_str(jsonData)?;
let digest = typed_data.eip712_signing_hash()?;
let sig = sign(privateKey, &digest)?;
Ok(encode_full_sig(sig))
}
}

impl Cheatcode for signCompact_0Call {
fn apply(&self, _state: &mut Cheatcodes) -> Result {
let Self { wallet, digest } = self;
Expand Down
12 changes: 11 additions & 1 deletion crates/cheatcodes/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Implementations of [`Utilities`](spec::Group::Utilities) cheatcodes.

use crate::{Cheatcode, Cheatcodes, CheatcodesExecutor, CheatsCtxt, Result, Vm::*};
use alloy_dyn_abi::{DynSolType, DynSolValue};
use alloy_dyn_abi::{DynSolType, DynSolValue, TypedData};
use alloy_ens::namehash;
use alloy_primitives::{aliases::B32, map::HashMap, B64, U256};
use alloy_sol_types::SolValue;
Expand Down Expand Up @@ -314,3 +314,13 @@ fn random_int(state: &mut Cheatcodes, bits: Option<U256>) -> Result {
.current()
.abi_encode())
}

impl Cheatcode for eip712HashTypedDataCall {
fn apply(&self, _state: &mut Cheatcodes) -> Result {
let Self { jsonData } = self;
let typed_data: TypedData = serde_json::from_str(jsonData)?;
let digest = typed_data.eip712_signing_hash()?;

Ok(digest.to_vec())
}
}
3 changes: 2 additions & 1 deletion crates/evm/traces/src/decoder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,11 +503,12 @@ impl CallTraceDecoder {

Some(decoded.iter().map(format_token).collect())
}
"signDelegation" | "signAndAttachDelegation" => {
"signDelegation" | "signAndAttachDelegation" | "signTypedData" => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@grandizzy i noticed that signDelegation and signAndAttachDelegation are under the Scripting group, however signTypedData is under Crypto.

does it matter? should we change its group perhaps?

let mut decoded = func.abi_decode_input(&data[SELECTOR_LEN..]).ok()?;
// Redact private key and replace in trace for
// signAndAttachDelegation(address implementation, uint256 privateKey)
// signDelegation(address implementation, uint256 privateKey)
// signTypedData(string jsonData, uint256 privateKey)
decoded[1] = DynSolValue::String("<pk>".to_string());
Some(decoded.iter().map(format_token).collect())
}
Expand Down
49 changes: 49 additions & 0 deletions crates/forge/tests/cli/test_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3828,3 +3828,52 @@ Encountered a total of 1 failing tests, 0 tests succeeded

"#]]);
});

forgetest!(test_eip712_hash_typed_data, |prj, cmd| {
prj.insert_ds_test();
prj.insert_vm();
prj.insert_console();

prj.add_source(
"Eip712HashTypedData.sol",
r#"
import "./Vm.sol";
import "./test.sol";
import "./console.sol";

contract Eip712HashTypedDataTest is DSTest {
Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));

string jsonData = '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"},{"name":"salt","type":"bytes32"}]},"primaryType":"EIP712Domain","domain":{"name":"example.metamask.io","version":"1","chainId":1,"verifyingContract":"0x0000000000000000000000000000000000000000"},"message":{}}';

function testHashEIP712Message() public {
console.log(jsonData);

bytes32 cheatMsgHash = vm.eip712HashTypedData(jsonData);
console.log("EIP712Domain message hash from cheatcode:");
console.logBytes32(cheatMsgHash);

// since this cheatcode simply exposes an alloy fn, the test has been borrowed from:
// <https://github.com/alloy-rs/core/blob/e0727c2224a5a83664d4ca1fb2275090d29def8b/crates/dyn-abi/src/eip712/typed_data.rs#L256>
bytes32 expectedHash = hex"122d1c8ef94b76dad44dcb03fa772361e20855c63311a15d5afe02d1b38f6077";
assertEq(cheatMsgHash, expectedHash, "EIP712Domain struct hash mismatch");
}

function testSignTypedData(uint248 pk) public {
vm.assume(pk != 0);

(uint8 v, bytes32 r, bytes32 s) = vm.signTypedData(jsonData, pk);

address expected = vm.addr(pk);
bytes32 digest = vm.eip712HashTypedData(jsonData);

address actual = ecrecover(digest, v, r, s);
assertEq(actual, expected, "digest signer did not match");
}
}
"#,
)
.unwrap();

cmd.forge_fuse().args(["test", "--mc", "Eip712HashTypedDataTest", "-vvvv"]).assert_success();
});
2 changes: 2 additions & 0 deletions testdata/cheats/Vm.sol

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