tycho_execution/encoding/evm/approvals/
permit2.rs

1use std::{str::FromStr, sync::Arc};
2
3use alloy::{
4    core::sol,
5    primitives::{aliases::U48, Address, Bytes as AlloyBytes, TxKind, U160, U256},
6    providers::Provider,
7    rpc::types::{TransactionInput, TransactionRequest},
8    sol_types::SolValue,
9};
10use chrono::Utc;
11use num_bigint::BigUint;
12use tokio::{
13    runtime::{Handle, Runtime},
14    task::block_in_place,
15};
16use tycho_common::Bytes;
17
18use crate::encoding::{
19    errors::EncodingError,
20    evm::{
21        encoding_utils::encode_input,
22        utils::{biguint_to_u256, bytes_to_address, get_client, get_runtime, EVMProvider},
23    },
24    models,
25};
26
27/// Struct for managing Permit2 operations, including encoding approvals and fetching allowance
28/// data.
29#[derive(Clone)]
30pub struct Permit2 {
31    address: Address,
32    client: EVMProvider,
33    runtime_handle: Handle,
34    // Store the runtime to prevent it from being dropped before use.
35    // This is required since tycho-execution does not have a pre-existing runtime.
36    // However, if the library is used in a context where a runtime already exists, it is not
37    // necessary to store it.
38    #[allow(dead_code)]
39    runtime: Option<Arc<Runtime>>,
40}
41
42/// Type alias for representing allowance data as a tuple of (amount, expiration, nonce). Used for
43/// decoding
44type Allowance = (U160, U48, U48);
45/// Expiration period for permits, set to 30 days (in seconds).
46const PERMIT_EXPIRATION: u64 = 30 * 24 * 60 * 60;
47/// Expiration period for signatures, set to 30 minutes (in seconds).
48const PERMIT_SIG_EXPIRATION: u64 = 30 * 60;
49
50sol! {
51     #[derive(Debug)]
52    struct PermitSingle {
53        PermitDetails details;
54        address spender;
55        uint256 sigDeadline;
56    }
57
58    #[derive(Debug)]
59    struct PermitDetails {
60        address token;
61        uint160 amount;
62        uint48 expiration;
63        uint48 nonce;
64    }
65}
66
67impl TryFrom<&PermitSingle> for models::PermitSingle {
68    type Error = EncodingError;
69
70    fn try_from(sol: &PermitSingle) -> Result<Self, EncodingError> {
71        Ok(models::PermitSingle {
72            details: models::PermitDetails {
73                token: Bytes::from(sol.details.token.to_vec()),
74                amount: BigUint::from_bytes_be(&sol.details.amount.to_be_bytes::<20>()),
75                expiration: BigUint::from_bytes_be(
76                    &sol.details
77                        .expiration
78                        .to_be_bytes::<6>(),
79                ),
80                nonce: BigUint::from_bytes_be(&sol.details.nonce.to_be_bytes::<6>()),
81            },
82            spender: Bytes::from(sol.spender.to_vec()),
83            sig_deadline: BigUint::from_bytes_be(&sol.sigDeadline.to_be_bytes::<32>()),
84        })
85    }
86}
87
88impl TryFrom<&models::PermitSingle> for PermitSingle {
89    type Error = EncodingError;
90
91    fn try_from(p: &models::PermitSingle) -> Result<Self, EncodingError> {
92        Ok(PermitSingle {
93            details: PermitDetails {
94                token: bytes_to_address(&p.details.token)?,
95                amount: U160::from(biguint_to_u256(&p.details.amount)),
96                expiration: U48::from(biguint_to_u256(&p.details.expiration)),
97                nonce: U48::from(biguint_to_u256(&p.details.nonce)),
98            },
99            spender: bytes_to_address(&p.spender)?,
100            sigDeadline: biguint_to_u256(&p.sig_deadline),
101        })
102    }
103}
104
105impl Permit2 {
106    pub fn new() -> Result<Self, EncodingError> {
107        let (handle, runtime) = get_runtime()?;
108        let client = block_in_place(|| handle.block_on(get_client()))?;
109        Ok(Self {
110            address: Address::from_str("0x000000000022D473030F116dDEE9F6B43aC78BA3")
111                .map_err(|_| EncodingError::FatalError("Permit2 address not valid".to_string()))?,
112            client,
113            runtime_handle: handle,
114            runtime,
115        })
116    }
117
118    /// Fetches allowance data for a specific owner, spender, and token.
119    fn get_existing_allowance(
120        &self,
121        owner: &Bytes,
122        spender: &Bytes,
123        token: &Bytes,
124    ) -> Result<Allowance, EncodingError> {
125        let args = (bytes_to_address(owner)?, bytes_to_address(token)?, bytes_to_address(spender)?);
126        let data = encode_input("allowance(address,address,address)", args.abi_encode());
127        let tx = TransactionRequest {
128            to: Some(TxKind::from(self.address)),
129            input: TransactionInput { input: Some(AlloyBytes::from(data)), data: None },
130            ..Default::default()
131        };
132
133        let output = block_in_place(|| {
134            self.runtime_handle
135                .block_on(async { self.client.call(tx).await })
136        });
137        match output {
138            Ok(response) => {
139                let allowance: Allowance = Allowance::abi_decode(&response).map_err(|_| {
140                    EncodingError::FatalError(
141                        "Failed to decode response for permit2 allowance".to_string(),
142                    )
143                })?;
144                Ok(allowance)
145            }
146            Err(err) => Err(EncodingError::RecoverableError(format!(
147                "Call to permit2 allowance method failed with error: {err}"
148            ))),
149        }
150    }
151    /// Creates permit single
152    pub fn get_permit(
153        &self,
154        spender: &Bytes,
155        owner: &Bytes,
156        token: &Bytes,
157        amount: &BigUint,
158    ) -> Result<models::PermitSingle, EncodingError> {
159        let current_time = Utc::now()
160            .naive_utc()
161            .and_utc()
162            .timestamp() as u64;
163
164        let (_, _, nonce) = self.get_existing_allowance(owner, spender, token)?;
165        let expiration = U48::from(current_time + PERMIT_EXPIRATION);
166        let sig_deadline = U256::from(current_time + PERMIT_SIG_EXPIRATION);
167        let amount = U160::from(biguint_to_u256(amount));
168
169        let details = PermitDetails { token: bytes_to_address(token)?, amount, expiration, nonce };
170
171        let permit_single = PermitSingle {
172            details,
173            spender: bytes_to_address(spender)?,
174            sigDeadline: sig_deadline,
175        };
176
177        models::PermitSingle::try_from(&permit_single)
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use std::str::FromStr;
184
185    use alloy::{
186        primitives::{Uint, B256},
187        signers::local::PrivateKeySigner,
188    };
189    use num_bigint::BigUint;
190    use tycho_common::models::Chain;
191
192    use super::*;
193    use crate::encoding::evm::encoding_utils::sign_permit;
194
195    // These two implementations are to avoid comparing the expiration and sig_deadline fields
196    // because they are timestamps
197    impl PartialEq for PermitSingle {
198        fn eq(&self, other: &Self) -> bool {
199            if self.details != other.details {
200                return false;
201            }
202            if self.spender != other.spender {
203                return false;
204            }
205            true
206        }
207    }
208
209    impl PartialEq for PermitDetails {
210        fn eq(&self, other: &Self) -> bool {
211            if self.token != other.token {
212                return false;
213            }
214            if self.amount != other.amount {
215                return false;
216            }
217            // Compare `nonce`
218            if self.nonce != other.nonce {
219                return false;
220            }
221
222            true
223        }
224    }
225
226    fn eth_chain() -> Chain {
227        Chain::Ethereum
228    }
229
230    #[test]
231    fn test_get_existing_allowance() {
232        let manager = Permit2::new().unwrap();
233
234        let token = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
235        let owner = Bytes::from_str("0x2c6a3cd97c6283b95ac8c5a4459ebb0d5fd404f4").unwrap();
236        let spender = Bytes::from_str("0xba12222222228d8ba445958a75a0704d566bf2c8").unwrap();
237
238        let result = manager
239            .get_existing_allowance(&owner, &spender, &token)
240            .unwrap();
241        assert_eq!(
242            result,
243            (Uint::<160, 3>::from(0), Uint::<48, 1>::from(0), Uint::<48, 1>::from(0))
244        );
245    }
246
247    #[test]
248    fn test_get_permit() {
249        let permit2 = Permit2::new().expect("Failed to create Permit2");
250
251        let owner = Bytes::from_str("0x2c6a3cd97c6283b95ac8c5a4459ebb0d5fd404f4").unwrap();
252        let spender = Bytes::from_str("0xba12222222228d8ba445958a75a0704d566bf2c8").unwrap();
253        let token = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
254        let amount = BigUint::from(1000u64);
255
256        let permit = permit2
257            .get_permit(&spender, &owner, &token, &amount)
258            .unwrap();
259
260        let expected_details = models::PermitDetails {
261            token,
262            amount,
263            expiration: BigUint::from(Utc::now().timestamp() as u64 + PERMIT_EXPIRATION),
264            nonce: BigUint::from(0u64),
265        };
266        let expected_permit_single = models::PermitSingle {
267            details: expected_details,
268            spender: Bytes::from_str("0xba12222222228d8ba445958a75a0704d566bf2c8").unwrap(),
269            sig_deadline: BigUint::from(Utc::now().timestamp() as u64 + PERMIT_SIG_EXPIRATION),
270        };
271
272        assert_eq!(
273            permit, expected_permit_single,
274            "Decoded PermitSingle does not match expected values"
275        );
276    }
277
278    /// This test actually calls the permit method on the Permit2 contract to verify the encoded
279    /// data works. It requires an Anvil fork, so please run with the following command: anvil
280    /// --fork-url <RPC-URL> And set up the following env var as RPC_URL=127.0.0.1:8545
281    /// Use an account from anvil to fill the anvil_account and anvil_private_key variables
282    #[test]
283    #[cfg_attr(not(feature = "fork-tests"), ignore)]
284    fn test_permit() {
285        let anvil_account = Bytes::from_str("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap();
286        let anvil_private_key =
287            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string();
288
289        let pk = B256::from_str(&anvil_private_key)
290            .map_err(|_| {
291                EncodingError::FatalError(
292                    "Failed to convert swapper private key to B256".to_string(),
293                )
294            })
295            .unwrap();
296        let signer = PrivateKeySigner::from_bytes(&pk)
297            .map_err(|_| {
298                EncodingError::FatalError("Failed to create signer from private key".to_string())
299            })
300            .unwrap();
301        let permit2 = Permit2::new().expect("Failed to create Permit2");
302
303        let token = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
304        let amount = BigUint::from(1000u64);
305
306        // Approve token allowance for permit2 contract
307        let approve_function_signature = "approve(address,uint256)";
308        let args = (permit2.address, biguint_to_u256(&BigUint::from(1000000u64)));
309        let data = encode_input(approve_function_signature, args.abi_encode());
310
311        let tx = TransactionRequest {
312            to: Some(TxKind::from(bytes_to_address(&token).unwrap())),
313            input: TransactionInput { input: Some(AlloyBytes::from(data)), data: None },
314            ..Default::default()
315        };
316        let receipt = block_in_place(|| {
317            permit2.runtime_handle.block_on(async {
318                let pending_tx = permit2
319                    .client
320                    .send_transaction(tx)
321                    .await
322                    .unwrap();
323                // Wait for the transaction to be mined
324                pending_tx.get_receipt().await.unwrap()
325            })
326        });
327        assert!(receipt.status(), "Approve transaction failed");
328
329        let spender = Bytes::from_str("0xba12222222228d8ba445958a75a0704d566bf2c8").unwrap();
330
331        let permit = permit2
332            .get_permit(&spender, &anvil_account, &token, &amount)
333            .unwrap();
334        let sol_permit: PermitSingle =
335            PermitSingle::try_from(&permit).expect("Failed to convert to PermitSingle");
336
337        let signature = sign_permit(eth_chain().id(), &permit, signer).unwrap();
338        let encoded =
339            (bytes_to_address(&anvil_account).unwrap(), sol_permit, signature.as_bytes().to_vec())
340                .abi_encode();
341
342        let function_signature =
343            "permit(address,((address,uint160,uint48,uint48),address,uint256),bytes)";
344        let data = encode_input(function_signature, encoded.to_vec());
345
346        let tx = TransactionRequest {
347            to: Some(TxKind::from(permit2.address)),
348            input: TransactionInput { input: Some(AlloyBytes::from(data)), data: None },
349            gas: Some(10_000_000u64),
350            ..Default::default()
351        };
352
353        let result = permit2.runtime_handle.block_on(async {
354            let pending_tx = permit2
355                .client
356                .send_transaction(tx)
357                .await
358                .unwrap();
359            pending_tx.get_receipt().await.unwrap()
360        });
361        assert!(result.status(), "Permit transaction failed");
362
363        // Assert that the allowance was set correctly in the permit2 contract
364        let (allowance_amount, _, nonce) = permit2
365            .get_existing_allowance(&anvil_account, &spender, &token)
366            .unwrap();
367        assert_eq!(allowance_amount, U160::from(biguint_to_u256(&amount)));
368        assert_eq!(nonce, U48::from(1));
369    }
370}