1use crate::{
4 cmd::send::SendTxArgs,
5 tempo,
6 tx::{SendTxOpts, TxParams},
7};
8use alloy_consensus::{SignableTransaction, Signed};
9use alloy_dyn_abi::TypedData;
10use alloy_ens::NameOrAddress;
11use alloy_network::{Ethereum, Network};
12use alloy_primitives::{Address, B256, U256, hex};
13use alloy_provider::Provider;
14use alloy_signer::{Signature, Signer};
15use alloy_sol_types::{Eip712Domain, SolCall, sol};
16use clap::Args;
17use eyre::{Result, WrapErr, ensure};
18use foundry_cli::{
19 json::{print_json_success, print_scalar},
20 utils::{LoadConfig, get_provider},
21};
22use foundry_common::{
23 FoundryTransactionBuilder,
24 fmt::{UIfmt, UIfmtReceiptExt},
25 provider::ProviderBuilder,
26 shell,
27};
28use foundry_config::Config;
29use foundry_wallets::WalletSigner;
30use serde::Serialize;
31use serde_json::json;
32use std::str::FromStr;
33use tempo_alloy::TempoNetwork;
34
35sol! {
36 #[sol(rpc)]
37 interface IERC2612 {
38 function name() external view returns (string);
39 function nonces(address owner) external view returns (uint256);
40 function DOMAIN_SEPARATOR() external view returns (bytes32);
41 function eip712Domain() external view returns (
42 bytes1 fields, string name, string version, uint256 chainId,
43 address verifyingContract, bytes32 salt, uint256[] extensions
44 );
45 }
46
47 function permit(address owner, address spender, uint256 value, uint256 deadline,
48 uint8 v, bytes32 r, bytes32 s) external;
49
50 #[derive(Serialize)]
51 struct Permit {
52 address owner;
53 address spender;
54 uint256 value;
55 uint256 nonce;
56 uint256 deadline;
57 }
58}
59
60#[derive(Debug, Clone, Args)]
62pub struct PermitArgs {
63 #[arg(value_parser = NameOrAddress::from_str)]
65 token: NameOrAddress,
66 #[arg(value_parser = NameOrAddress::from_str)]
68 spender: NameOrAddress,
69 amount: U256,
71 #[arg(long)]
73 deadline: U256,
74 #[arg(long)]
76 domain_name: Option<String>,
77 #[arg(long)]
79 domain_version: Option<String>,
80 #[arg(long)]
82 broadcast: bool,
83 #[command(flatten)]
84 pub(super) send_tx: SendTxOpts,
85 #[command(flatten)]
86 tx: TxParams,
87}
88
89impl PermitArgs {
90 pub(super) async fn run(self) -> Result<()> {
91 self.ensure_broadcast_options()?;
92 ensure!(
93 self.tx.tempo.session_id()?.is_none(),
94 "Tempo sessions cannot sign ERC-2612 permits"
95 );
96 let config = self.send_tx.eth.load_config()?;
97 let provider = get_provider(&config)?;
98 let rpc_chain_id = provider.get_chain_id().await?;
99 ensure!(
100 config.chain.is_none_or(|chain| chain.id() == rpc_chain_id),
101 "Configured chain does not match the RPC chain"
102 );
103 let (network, signer, access_key) =
104 tempo::resolve_transaction_network_and_signer(&self.tx.tempo, &self.send_tx.eth)
105 .await?;
106 ensure!(
107 access_key.is_none(),
108 "Tempo access keys cannot sign ERC-2612 permits; use a root account signer"
109 );
110 if network.is_tempo() {
111 self.run_generic::<TempoNetwork>(signer, config, rpc_chain_id).await
112 } else {
113 self.run_generic::<Ethereum>(signer, config, rpc_chain_id).await
114 }
115 }
116
117 async fn run_generic<N: Network>(
118 self,
119 pre_resolved_signer: Option<WalletSigner>,
120 config: Config,
121 rpc_chain_id: u64,
122 ) -> Result<()>
123 where
124 N::TxEnvelope: From<Signed<N::UnsignedTx>>,
125 N::UnsignedTx: SignableTransaction<Signature>,
126 N::TransactionRequest: FoundryTransactionBuilder<N>,
127 N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
128 {
129 let provider = ProviderBuilder::<N>::from_config(&config)?.build()?;
130 let token = self.token.resolve(&provider).await?;
131 let spender = self.spender.resolve(&provider).await?;
132 let chain_id = U256::from(rpc_chain_id);
133 let contract = IERC2612::new(token, &provider);
134 let separator = contract
135 .DOMAIN_SEPARATOR()
136 .call()
137 .await
138 .wrap_err("Could not read ERC-2612 DOMAIN_SEPARATOR()")?;
139 let mut domain = match contract.eip712Domain().call().await {
140 Ok(domain) => discovered_domain(domain)?,
141 Err(_) => Eip712Domain {
142 name: Some(
143 match &self.domain_name {
144 Some(name) => name.clone(),
145 None => contract
146 .name()
147 .call()
148 .await
149 .wrap_err("Could not read name(); supply --domain-name")?,
150 }
151 .into(),
152 ),
153 version: Some("1".into()),
154 chain_id: Some(chain_id),
155 verifying_contract: Some(token),
156 ..Default::default()
157 },
158 };
159 if let Some(name) = self.domain_name {
160 domain.name = Some(name.into());
161 }
162 if let Some(version) = self.domain_version {
163 domain.version = Some(version.into());
164 }
165 validate_domain(&domain, separator, chain_id, token)?;
166
167 let browser = self.send_tx.browser.run::<N>().await?;
168 if let Some(browser) = &browser
169 && domain.chain_id.is_some()
170 && browser.chain_id() != rpc_chain_id
171 {
172 browser.switch_chain(rpc_chain_id).await?;
173 }
174 let wallet = if browser.is_none() {
175 Some(match pre_resolved_signer {
176 Some(signer) => signer,
177 None => self.send_tx.eth.wallet.signer().await?,
178 })
179 } else {
180 None
181 };
182 let owner = browser
183 .as_ref()
184 .map(|wallet| wallet.address())
185 .unwrap_or_else(|| wallet.as_ref().expect("signer resolved").address());
186 ensure!(
187 self.send_tx.eth.wallet.from.is_none_or(|from| from == owner),
188 "--from must match the permit signing wallet"
189 );
190 let nonce = contract
191 .nonces(owner)
192 .call()
193 .await
194 .wrap_err("Could not read ERC-2612 nonces(owner)")?;
195 let permit = Permit { owner, spender, value: self.amount, nonce, deadline: self.deadline };
196 let typed_data = TypedData::from_struct(&permit, Some(domain));
197 let signature = if let Some(browser) = &browser {
198 browser.sign_dynamic_typed_data(&typed_data).await?
199 } else {
200 wallet.as_ref().expect("signer resolved").sign_dynamic_typed_data(&typed_data).await?
201 };
202 let calldata = permitCall {
203 owner,
204 spender,
205 value: self.amount,
206 deadline: self.deadline,
207 v: signature.v_byte(),
208 r: signature.r().into(),
209 s: signature.s().into(),
210 }
211 .abi_encode();
212 if self.broadcast {
213 let send = SendTxArgs::contract_call(token.into(), calldata, self.send_tx, self.tx);
214 return if let Some(browser) = browser {
215 send.run_generic_with_browser::<N>(browser).await
216 } else {
217 send.run_generic::<N>(wallet, None).await
218 };
219 }
220 if shell::is_json() {
221 print_json_success(json!({
222 "token": token, "owner": owner, "spender": spender,
223 "value": self.amount.to_string(), "nonce": nonce.to_string(),
224 "deadline": self.deadline.to_string(),
225 "signature": hex::encode_prefixed(signature.as_bytes()),
226 "calldata": hex::encode_prefixed(calldata), "typed_data": typed_data,
227 }))?;
228 } else {
229 print_scalar(hex::encode_prefixed(signature.as_bytes()))?;
230 }
231 Ok(())
232 }
233
234 fn ensure_broadcast_options(&self) -> Result<()> {
235 let send_options = self.send_tx.cast_async
236 || self.send_tx.sync
237 || self.send_tx.confirmations != 1
238 || self.send_tx.timeout.is_some()
239 || self.send_tx.poll_interval.is_some();
240 let transaction_options = self.tx.gas_limit.is_some()
241 || self.tx.gas_price.is_some()
242 || self.tx.priority_gas_price.is_some()
243 || self.tx.nonce.is_some()
244 || self.tx.tempo.is_tempo()
245 || self.tx.tempo.session_id()?.is_some()
246 || self.tx.tempo.lanes_file.is_some();
247 ensure!(
248 self.broadcast || !(send_options || transaction_options),
249 "Transaction options require --broadcast"
250 );
251 Ok(())
252 }
253}
254
255fn discovered_domain(domain: IERC2612::eip712DomainReturn) -> Result<Eip712Domain> {
256 let fields = domain.fields[0];
257 ensure!(
258 fields & !0x1f == 0 && domain.extensions.is_empty(),
259 "Unsupported EIP-712 domain fields or extensions"
260 );
261 Ok(Eip712Domain {
262 name: (fields & 1 != 0).then(|| domain.name.into()),
263 version: (fields & 2 != 0).then(|| domain.version.into()),
264 chain_id: (fields & 4 != 0).then_some(domain.chainId),
265 verifying_contract: (fields & 8 != 0).then_some(domain.verifyingContract),
266 salt: (fields & 16 != 0).then_some(domain.salt),
267 })
268}
269
270fn validate_domain(
271 domain: &Eip712Domain,
272 separator: B256,
273 chain_id: U256,
274 token: Address,
275) -> Result<()> {
276 ensure!(
277 domain.chain_id.is_none_or(|id| id == chain_id),
278 "EIP-712 domain chain ID does not match the RPC chain"
279 );
280 ensure!(
281 domain.verifying_contract.is_none_or(|address| address == token),
282 "EIP-712 domain verifying contract does not match the token"
283 );
284 ensure!(
285 domain.separator() == separator,
286 "EIP-712 domain does not match DOMAIN_SEPARATOR(); check --domain-name and --domain-version"
287 );
288 Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::cmd::erc20::Erc20Subcommand;
295 use clap::Parser;
296
297 #[test]
298 fn permit_requires_explicit_deadline_and_valid_amount() {
299 let args = [
300 "erc20",
301 "permit",
302 "0x0000000000000000000000000000000000000001",
303 "0x0000000000000000000000000000000000000002",
304 "123",
305 ];
306 assert!(Erc20Subcommand::try_parse_from(args).is_err());
307 assert!(
308 Erc20Subcommand::try_parse_from(args.into_iter().chain(["--deadline", "4000000000"]))
309 .is_ok()
310 );
311 let mut invalid = args;
312 invalid[4] = "-1";
313 assert!(
314 Erc20Subcommand::try_parse_from(
315 invalid.into_iter().chain(["--deadline", "4000000000"])
316 )
317 .is_err()
318 );
319 }
320
321 #[test]
322 fn permit_requires_broadcast_for_transaction_options() {
323 let base = [
324 "erc20",
325 "permit",
326 "0x0000000000000000000000000000000000000001",
327 "0x0000000000000000000000000000000000000002",
328 "123",
329 "--deadline",
330 "4000000000",
331 ];
332 for option in [["--async", ""], ["--nonce", "1"], ["--gas-limit", "21000"]] {
333 let args = base.into_iter().chain(option.into_iter().filter(|value| !value.is_empty()));
334 let Erc20Subcommand::Permit(args) = Erc20Subcommand::try_parse_from(args).unwrap()
335 else {
336 unreachable!()
337 };
338 assert_eq!(
339 args.ensure_broadcast_options().unwrap_err().to_string(),
340 "Transaction options require --broadcast"
341 );
342 }
343
344 let args = base.into_iter().chain(["--async", "--broadcast"]);
345 let Erc20Subcommand::Permit(args) = Erc20Subcommand::try_parse_from(args).unwrap() else {
346 unreachable!()
347 };
348 assert!(args.ensure_broadcast_options().is_ok());
349 }
350
351 #[test]
352 fn domain_discovery_respects_fields_and_salt() {
353 let domain = discovered_domain(IERC2612::eip712DomainReturn {
354 fields: [0x14].into(),
355 name: "ignored".into(),
356 version: "ignored".into(),
357 chainId: U256::from(1),
358 verifyingContract: Address::ZERO,
359 salt: B256::repeat_byte(42),
360 extensions: vec![],
361 })
362 .unwrap();
363 assert_eq!(
364 domain,
365 Eip712Domain {
366 chain_id: Some(U256::from(1)),
367 salt: Some(B256::repeat_byte(42)),
368 ..Default::default()
369 }
370 );
371 }
372
373 #[test]
374 fn domain_discovery_rejects_extensions_and_unknown_fields() {
375 for (fields, extensions) in [(0x20, vec![]), (0x0f, vec![U256::from(1)])] {
376 assert!(
377 discovered_domain(IERC2612::eip712DomainReturn {
378 fields: [fields].into(),
379 name: String::new(),
380 version: String::new(),
381 chainId: U256::ZERO,
382 verifyingContract: Address::ZERO,
383 salt: B256::ZERO,
384 extensions,
385 })
386 .is_err()
387 );
388 }
389 }
390
391 #[test]
392 fn domain_validation_rejects_mismatched_separator_chain_and_contract() {
393 let chain = U256::from(1);
394 let token = Address::repeat_byte(1);
395 let domain = Eip712Domain {
396 name: Some("Token".into()),
397 version: Some("2".into()),
398 chain_id: Some(chain),
399 verifying_contract: Some(token),
400 ..Default::default()
401 };
402 let separator = domain.separator();
403 assert!(validate_domain(&domain, separator, chain, token).is_ok());
404 assert!(validate_domain(&domain, B256::ZERO, chain, token).is_err());
405 assert!(validate_domain(&domain, separator, U256::from(2), token).is_err());
406 assert!(validate_domain(&domain, separator, chain, Address::ZERO).is_err());
407 }
408}