Skip to content

feat(cast): Sign Typed Data via CLI #4878

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

Merged
merged 9 commits into from
Jun 3, 2023
Merged
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
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,
Comment on lines +79 to +82
Copy link
Member

Choose a reason for hiding this comment

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

I think this is fine, this could try if the message argument is a file, but making this explicit is reasonable.

interpreting the message arg as file could also be added to the regular command.


#[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