Skip to content

Commit 08d09b6

Browse files
Oightymattsse
andauthored
feat(cast): Sign Typed Data via CLI (#4878)
* 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]>
1 parent 1e03143 commit 08d09b6

File tree

3 files changed

+173
-10
lines changed

3 files changed

+173
-10
lines changed

cli/src/cmd/cast/wallet/mod.rs

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ use clap::Parser;
1111
use ethers::{
1212
core::rand::thread_rng,
1313
signers::{LocalWallet, Signer},
14-
types::{Address, Signature},
14+
types::{transaction::eip712::TypedData, Address, Signature},
1515
};
1616
use eyre::Context;
1717

18-
/// CLI arguments for `cast send`.
18+
/// CLI arguments for `cast wallet`.
1919
#[derive(Debug, Parser)]
2020
pub enum WalletSubcommands {
2121
/// Create a new random keypair.
@@ -55,15 +55,32 @@ pub enum WalletSubcommands {
5555
wallet: Wallet,
5656
},
5757

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

75+
/// If provided, the message will be treated as typed data.
76+
#[clap(long)]
77+
data: bool,
78+
79+
/// If provided, the message will be treated as a file name containing typed data. Requires
80+
/// --data.
81+
#[clap(long, requires = "data")]
82+
from_file: bool,
83+
6784
#[clap(flatten)]
6885
wallet: Wallet,
6986
},
@@ -127,9 +144,20 @@ impl WalletSubcommands {
127144
let addr = wallet.address();
128145
println!("{}", SimpleCast::to_checksum_address(&addr));
129146
}
130-
WalletSubcommands::Sign { message, wallet } => {
147+
WalletSubcommands::Sign { message, data, from_file, wallet } => {
131148
let wallet = wallet.signer(0).await?;
132-
let sig = wallet.sign_message(Self::hex_str_to_bytes(&message)?).await?;
149+
let sig = if data {
150+
let typed_data: TypedData = if from_file {
151+
// data is a file name, read json from file
152+
foundry_common::fs::read_json_file(message.as_ref())?
153+
} else {
154+
// data is a json string
155+
serde_json::from_str(&message)?
156+
};
157+
wallet.sign_typed_data(&typed_data).await?
158+
} else {
159+
wallet.sign_message(Self::hex_str_to_bytes(&message)?).await?
160+
};
133161
println!("0x{sig}");
134162
}
135163
WalletSubcommands::Verify { message, signature, address } => {
@@ -154,3 +182,66 @@ impl WalletSubcommands {
154182
})
155183
}
156184
}
185+
186+
#[cfg(test)]
187+
mod tests {
188+
use super::*;
189+
190+
#[test]
191+
fn can_parse_wallet_sign_message() {
192+
let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "deadbeef"]);
193+
match args {
194+
WalletSubcommands::Sign { message, data, from_file, .. } => {
195+
assert_eq!(message, "deadbeef".to_string());
196+
assert_eq!(data, false);
197+
assert_eq!(from_file, false);
198+
}
199+
_ => panic!("expected WalletSubcommands::Sign"),
200+
}
201+
}
202+
203+
#[test]
204+
fn can_parse_wallet_sign_hex_message() {
205+
let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "0xdeadbeef"]);
206+
match args {
207+
WalletSubcommands::Sign { message, data, from_file, .. } => {
208+
assert_eq!(message, "0xdeadbeef".to_string());
209+
assert_eq!(data, false);
210+
assert_eq!(from_file, false);
211+
}
212+
_ => panic!("expected WalletSubcommands::Sign"),
213+
}
214+
}
215+
216+
#[test]
217+
fn can_parse_wallet_sign_data() {
218+
let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "--data", "{ ... }"]);
219+
match args {
220+
WalletSubcommands::Sign { message, data, from_file, .. } => {
221+
assert_eq!(message, "{ ... }".to_string());
222+
assert_eq!(data, true);
223+
assert_eq!(from_file, false);
224+
}
225+
_ => panic!("expected WalletSubcommands::Sign"),
226+
}
227+
}
228+
229+
#[test]
230+
fn can_parse_wallet_sign_data_file() {
231+
let args = WalletSubcommands::parse_from([
232+
"foundry-cli",
233+
"sign",
234+
"--data",
235+
"--from-file",
236+
"tests/data/typed_data.json",
237+
]);
238+
match args {
239+
WalletSubcommands::Sign { message, data, from_file, .. } => {
240+
assert_eq!(message, "tests/data/typed_data.json".to_string());
241+
assert_eq!(data, true);
242+
assert_eq!(from_file, true);
243+
}
244+
_ => panic!("expected WalletSubcommands::Sign"),
245+
}
246+
}
247+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"types": {
3+
"EIP712Domain": [
4+
{
5+
"name": "name",
6+
"type": "string"
7+
},
8+
{
9+
"name": "version",
10+
"type": "string"
11+
},
12+
{
13+
"name": "chainId",
14+
"type": "uint256"
15+
},
16+
{
17+
"name": "verifyingContract",
18+
"type": "address"
19+
}
20+
],
21+
"Message": [
22+
{
23+
"name": "data",
24+
"type": "string"
25+
}
26+
]
27+
},
28+
"primaryType": "Message",
29+
"domain": {
30+
"name": "example.metamask.io",
31+
"version": "1",
32+
"chainId": "1",
33+
"verifyingContract": "0x0000000000000000000000000000000000000000"
34+
},
35+
"message": {
36+
"data": "Hello!"
37+
}
38+
}

cli/tests/it/cast.rs

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ casttest!(wallet_address_keystore_with_password_file, |_: TestProject, mut cmd:
8585
assert!(out.contains("0xeC554aeAFE75601AaAb43Bd4621A22284dB566C2"));
8686
});
8787

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

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

114+
// tests that `cast wallet sign typed-data` outputs the expected signature, given a JSON string
115+
casttest!(cast_wallet_sign_typed_data_string, |_: TestProject, mut cmd: TestCommand| {
116+
cmd.args([
117+
"wallet",
118+
"sign",
119+
"--private-key",
120+
"0x0000000000000000000000000000000000000000000000000000000000000001",
121+
"--data",
122+
"{\"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!\"}}",
123+
]);
124+
let output = cmd.stdout_lossy();
125+
assert_eq!(output.trim(), "0x06c18bdc8163219fddc9afaf5a0550e381326474bb757c86dc32317040cf384e07a2c72ce66c1a0626b6750ca9b6c035bf6f03e7ed67ae2d1134171e9085c0b51b");
126+
});
127+
128+
// tests that `cast wallet sign typed-data` outputs the expected signature, given a JSON file
129+
casttest!(cast_wallet_sign_typed_data_file, |_: TestProject, mut cmd: TestCommand| {
130+
cmd.args([
131+
"wallet",
132+
"sign",
133+
"--private-key",
134+
"0x0000000000000000000000000000000000000000000000000000000000000001",
135+
"--data",
136+
"--from-file",
137+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
138+
.join("tests/fixtures/sign_typed_data.json")
139+
.into_os_string()
140+
.into_string()
141+
.unwrap()
142+
.as_str(),
143+
]);
144+
let output = cmd.stdout_lossy();
145+
assert_eq!(output.trim(), "0x06c18bdc8163219fddc9afaf5a0550e381326474bb757c86dc32317040cf384e07a2c72ce66c1a0626b6750ca9b6c035bf6f03e7ed67ae2d1134171e9085c0b51b");
146+
});
147+
114148
// tests that `cast estimate` is working correctly.
115149
casttest!(estimate_function_gas, |_: TestProject, mut cmd: TestCommand| {
116150
let eth_rpc_url = next_http_rpc_endpoint();

0 commit comments

Comments
 (0)