Skip to content

Commit

Permalink
feat(cast): Sign Typed Data via CLI (foundry-rs#4878)
Browse files Browse the repository at this point in the history
* feat: add option to sign typed data (in json format) on CLI

* fix: compile error

* test: add tests for sign typed data

* chore: run fmt

* refactor: remove breaking changes from sign CLI

* simplify read json

---------

Co-authored-by: Matthias Seitz <[email protected]>
  • Loading branch information
Oighty and mattsse authored Jun 3, 2023
1 parent 1e03143 commit 08d09b6
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 10 deletions.
103 changes: 97 additions & 6 deletions cli/src/cmd/cast/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ use clap::Parser;
use ethers::{
core::rand::thread_rng,
signers::{LocalWallet, Signer},
types::{Address, Signature},
types::{transaction::eip712::TypedData, Address, Signature},
};
use eyre::Context;

/// CLI arguments for `cast send`.
/// CLI arguments for `cast wallet`.
#[derive(Debug, Parser)]
pub enum WalletSubcommands {
/// Create a new random keypair.
Expand Down Expand Up @@ -55,15 +55,32 @@ pub enum WalletSubcommands {
wallet: Wallet,
},

/// Sign a message.
/// Sign a message or typed data.
#[clap(visible_alias = "s")]
Sign {
/// The message to sign.
/// The message or typed data to sign.
///
/// Messages starting with 0x are expected to be hex encoded,
/// which get decoded before being signed.
/// The message will be prefixed with the Ethereum Signed Message header and hashed before
/// signing.
///
/// Typed data can be provided as a json string or a file name.
/// Use --data flag to denote the message is a string of typed data.
/// Use --data --from-file to denote the message is a file name containing typed data.
/// The data will be combined and hashed using the EIP712 specification before signing.
/// The data should be formatted as JSON.
message: String,

/// If provided, the message will be treated as typed data.
#[clap(long)]
data: bool,

/// If provided, the message will be treated as a file name containing typed data. Requires
/// --data.
#[clap(long, requires = "data")]
from_file: bool,

#[clap(flatten)]
wallet: Wallet,
},
Expand Down Expand Up @@ -127,9 +144,20 @@ impl WalletSubcommands {
let addr = wallet.address();
println!("{}", SimpleCast::to_checksum_address(&addr));
}
WalletSubcommands::Sign { message, wallet } => {
WalletSubcommands::Sign { message, data, from_file, wallet } => {
let wallet = wallet.signer(0).await?;
let sig = wallet.sign_message(Self::hex_str_to_bytes(&message)?).await?;
let sig = if data {
let typed_data: TypedData = if from_file {
// data is a file name, read json from file
foundry_common::fs::read_json_file(message.as_ref())?
} else {
// data is a json string
serde_json::from_str(&message)?
};
wallet.sign_typed_data(&typed_data).await?
} else {
wallet.sign_message(Self::hex_str_to_bytes(&message)?).await?
};
println!("0x{sig}");
}
WalletSubcommands::Verify { message, signature, address } => {
Expand All @@ -154,3 +182,66 @@ impl WalletSubcommands {
})
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn can_parse_wallet_sign_message() {
let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "deadbeef"]);
match args {
WalletSubcommands::Sign { message, data, from_file, .. } => {
assert_eq!(message, "deadbeef".to_string());
assert_eq!(data, false);
assert_eq!(from_file, false);
}
_ => panic!("expected WalletSubcommands::Sign"),
}
}

#[test]
fn can_parse_wallet_sign_hex_message() {
let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "0xdeadbeef"]);
match args {
WalletSubcommands::Sign { message, data, from_file, .. } => {
assert_eq!(message, "0xdeadbeef".to_string());
assert_eq!(data, false);
assert_eq!(from_file, false);
}
_ => panic!("expected WalletSubcommands::Sign"),
}
}

#[test]
fn can_parse_wallet_sign_data() {
let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "--data", "{ ... }"]);
match args {
WalletSubcommands::Sign { message, data, from_file, .. } => {
assert_eq!(message, "{ ... }".to_string());
assert_eq!(data, true);
assert_eq!(from_file, false);
}
_ => panic!("expected WalletSubcommands::Sign"),
}
}

#[test]
fn can_parse_wallet_sign_data_file() {
let args = WalletSubcommands::parse_from([
"foundry-cli",
"sign",
"--data",
"--from-file",
"tests/data/typed_data.json",
]);
match args {
WalletSubcommands::Sign { message, data, from_file, .. } => {
assert_eq!(message, "tests/data/typed_data.json".to_string());
assert_eq!(data, true);
assert_eq!(from_file, true);
}
_ => panic!("expected WalletSubcommands::Sign"),
}
}
}
38 changes: 38 additions & 0 deletions cli/tests/fixtures/sign_typed_data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"types": {
"EIP712Domain": [
{
"name": "name",
"type": "string"
},
{
"name": "version",
"type": "string"
},
{
"name": "chainId",
"type": "uint256"
},
{
"name": "verifyingContract",
"type": "address"
}
],
"Message": [
{
"name": "data",
"type": "string"
}
]
},
"primaryType": "Message",
"domain": {
"name": "example.metamask.io",
"version": "1",
"chainId": "1",
"verifyingContract": "0x0000000000000000000000000000000000000000"
},
"message": {
"data": "Hello!"
}
}
42 changes: 38 additions & 4 deletions cli/tests/it/cast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ casttest!(wallet_address_keystore_with_password_file, |_: TestProject, mut cmd:
assert!(out.contains("0xeC554aeAFE75601AaAb43Bd4621A22284dB566C2"));
});

// tests that `cast wallet sign` outputs the expected signature
casttest!(cast_wallet_sign_utf8_data, |_: TestProject, mut cmd: TestCommand| {
// tests that `cast wallet sign message` outputs the expected signature
casttest!(cast_wallet_sign_message_utf8_data, |_: TestProject, mut cmd: TestCommand| {
cmd.args([
"wallet",
"sign",
Expand All @@ -98,8 +98,8 @@ casttest!(cast_wallet_sign_utf8_data, |_: TestProject, mut cmd: TestCommand| {
assert_eq!(output.trim(), "0xfe28833983d6faa0715c7e8c3873c725ddab6fa5bf84d40e780676e463e6bea20fc6aea97dc273a98eb26b0914e224c8dd5c615ceaab69ddddcf9b0ae3de0e371c");
});

// tests that `cast wallet sign` outputs the expected signature, given a 0x-prefixed data
casttest!(cast_wallet_sign_hex_data, |_: TestProject, mut cmd: TestCommand| {
// tests that `cast wallet sign message` outputs the expected signature, given a 0x-prefixed data
casttest!(cast_wallet_sign_message_hex_data, |_: TestProject, mut cmd: TestCommand| {
cmd.args([
"wallet",
"sign",
Expand All @@ -111,6 +111,40 @@ casttest!(cast_wallet_sign_hex_data, |_: TestProject, mut cmd: TestCommand| {
assert_eq!(output.trim(), "0x23a42ca5616ee730ff3735890c32fc7b9491a9f633faca9434797f2c845f5abf4d9ba23bd7edb8577acebaa3644dc5a4995296db420522bb40060f1693c33c9b1c");
});

// tests that `cast wallet sign typed-data` outputs the expected signature, given a JSON string
casttest!(cast_wallet_sign_typed_data_string, |_: TestProject, mut cmd: TestCommand| {
cmd.args([
"wallet",
"sign",
"--private-key",
"0x0000000000000000000000000000000000000000000000000000000000000001",
"--data",
"{\"types\": {\"EIP712Domain\": [{\"name\": \"name\",\"type\": \"string\"},{\"name\": \"version\",\"type\": \"string\"},{\"name\": \"chainId\",\"type\": \"uint256\"},{\"name\": \"verifyingContract\",\"type\": \"address\"}],\"Message\": [{\"name\": \"data\",\"type\": \"string\"}]},\"primaryType\": \"Message\",\"domain\": {\"name\": \"example.metamask.io\",\"version\": \"1\",\"chainId\": \"1\",\"verifyingContract\": \"0x0000000000000000000000000000000000000000\"},\"message\": {\"data\": \"Hello!\"}}",
]);
let output = cmd.stdout_lossy();
assert_eq!(output.trim(), "0x06c18bdc8163219fddc9afaf5a0550e381326474bb757c86dc32317040cf384e07a2c72ce66c1a0626b6750ca9b6c035bf6f03e7ed67ae2d1134171e9085c0b51b");
});

// tests that `cast wallet sign typed-data` outputs the expected signature, given a JSON file
casttest!(cast_wallet_sign_typed_data_file, |_: TestProject, mut cmd: TestCommand| {
cmd.args([
"wallet",
"sign",
"--private-key",
"0x0000000000000000000000000000000000000000000000000000000000000001",
"--data",
"--from-file",
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/sign_typed_data.json")
.into_os_string()
.into_string()
.unwrap()
.as_str(),
]);
let output = cmd.stdout_lossy();
assert_eq!(output.trim(), "0x06c18bdc8163219fddc9afaf5a0550e381326474bb757c86dc32317040cf384e07a2c72ce66c1a0626b6750ca9b6c035bf6f03e7ed67ae2d1134171e9085c0b51b");
});

// tests that `cast estimate` is working correctly.
casttest!(estimate_function_gas, |_: TestProject, mut cmd: TestCommand| {
let eth_rpc_url = next_http_rpc_endpoint();
Expand Down

0 comments on commit 08d09b6

Please sign in to comment.