Skip to main content

cast/cmd/erc20/
permit.rs

1//! ERC-2612 signed approvals.
2
3use 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/// Arguments for signing and submitting an ERC-2612 permit.
61#[derive(Debug, Clone, Args)]
62pub struct PermitArgs {
63    /// The ERC-2612 token contract address.
64    #[arg(value_parser = NameOrAddress::from_str)]
65    token: NameOrAddress,
66    /// The spender authorized by the permit.
67    #[arg(value_parser = NameOrAddress::from_str)]
68    spender: NameOrAddress,
69    /// The allowance to set, in raw token units.
70    amount: U256,
71    /// Absolute Unix timestamp in seconds after which the permit cannot be submitted.
72    #[arg(long)]
73    deadline: U256,
74    /// Override the EIP-712 domain name.
75    #[arg(long)]
76    domain_name: Option<String>,
77    /// Override the EIP-712 domain version (fallback: "1").
78    #[arg(long)]
79    domain_version: Option<String>,
80    /// Submit the permit transaction using the signing wallet.
81    #[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}