Skip to main content

odra_modules/
cep2612.rs

1#![allow(missing_docs)]
2
3//! CEP-2612 — an adaptation of [ERC-2612] for the Casper Network.
4//!
5//! ERC-2612 extends ERC-20 with a `permit` entry point that lets a token holder
6//! grant an allowance via an off-chain signature instead of an on-chain
7//! `approve` transaction. A third party (a relayer) can then submit the signed
8//! permit on-chain, which makes the approval gas-less from the holder's
9//! perspective and removes the classic "approve + transferFrom" two-transaction
10//! pattern.
11//!
12//! # Differences from EVM ERC-2612
13//!
14//! The EIP-712 typed-data digest construction (domain separator + `Permit`
15//! struct hash) follows the EVM specification exactly so signatures produced
16//! by standard EIP-712 tooling remain compatible. The verification path
17//! differs because Casper does not provide `ecrecover`:
18//!
19//! * The caller must pass the signer's [`PublicKey`] explicitly alongside the
20//!   signature. The contract checks that `Address::from(public_key) == owner`
21//!   before verifying the signature, so a valid signature from a different
22//!   keypair cannot be used to approve on someone else's behalf.
23//! * Signature verification uses the host's [`verify_signature`] facility,
24//!   which supports Casper's Ed25519 and Secp256k1 account keys.
25//! * The EIP-712 `chainId` field (a `uint256` on Ethereum) is replaced by a
26//!   `chain_name` string (e.g. `"casper:casper"`) supplied at construction
27//!   time, matching Casper's chain identification model.
28//!
29//! Replay protection follows the ERC-2612 pattern: each `owner` has a
30//! monotonically increasing `nonce` that is mixed into the signed digest and
31//! incremented on every successful `permit` call.
32//!
33//! [ERC-2612]: https://eips.ethereum.org/EIPS/eip-2612
34//! [`verify_signature`]: odra::ContractEnv::verify_signature
35use casper_eip_712::DomainSeparator;
36use odra::{
37    casper_types::{bytesrepr::Bytes, PublicKey, U256},
38    named_keys::{base64_encoded_key_value_storage, single_value_storage},
39    prelude::*
40};
41
42use crate::{cep18_token::Cep18, eip712};
43
44// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
45const PERMIT_TYPEHASH: [u8; 32] = [
46    0x6e, 0x71, 0xed, 0xae, 0x12, 0xb1, 0xb9, 0x7f, 0x4d, 0x1f, 0x60, 0x37, 0x0f, 0xef, 0x10, 0x10,
47    0x5f, 0xa2, 0xfa, 0xae, 0x01, 0x26, 0x11, 0x4a, 0x16, 0x9c, 0x64, 0x84, 0x5d, 0x61, 0x26, 0xc9
48];
49
50/// Errors raised by CEP-2612 permit operations.
51#[odra::odra_error]
52pub enum Error {
53    /// Signature verification against the supplied public key failed, or the
54    /// recomputed digest does not match what the signer signed (e.g. wrong
55    /// `value`, `nonce`, `spender`, or `deadline`).
56    InvalidSignature = 36_000,
57    /// The current block time is past the `deadline` timestamp. Permits with
58    /// `deadline == u64::MAX` skip this check and never expire.
59    PermitExpired = 36_001,
60    /// The supplied `public_key` does not hash to the declared `owner`
61    /// address. Guards against using a valid signature from a different
62    /// keypair to approve on someone else's behalf.
63    InvalidPublicKey = 36_002
64}
65
66/// Storage defined as named keys.
67const CHAIN_NAME_KEY: &str = "chain_name";
68const PERMIT_NONCES_KEY: &str = "permit_nonces";
69
70/// Domain separator version.
71const DOMAIN_VERSION: &str = "1";
72
73single_value_storage!(
74    CEP2612ChainNameStorage,
75    String,
76    CHAIN_NAME_KEY,
77    ExecutionError::KeyNotFound
78);
79
80base64_encoded_key_value_storage!(CEP2612PermitNoncesStorage, PERMIT_NONCES_KEY, Address, U256);
81
82/// CEP-2612 permit module — adds off-chain signed approvals to a CEP-18 token.
83///
84/// The module is meant to be composed with a [`Cep18`] sub-module (see
85/// [`CEP2612Wrapper`] for a deployable composition used in tests).
86#[odra::module]
87pub struct CEP2612 {
88    /// Per-owner monotonic nonce mixed into the signed digest to prevent
89    /// replay of a previously consumed permit.
90    permit_nonces: SubModule<CEP2612PermitNoncesStorage>,
91    /// CAIP-2 chain name used as the EIP-712 domain's `chainId` substitute.
92    chain_name: SubModule<CEP2612ChainNameStorage>,
93    /// The CEP-18 token whose allowances are mutated by `permit`.
94    token: SubModule<Cep18>
95}
96
97#[odra::module]
98impl CEP2612 {
99    /// Initializes the module by storing the EIP-712 domain's CAIP-2 chain name
100    /// (e.g. `"casper:casper"`) and the nonce storage.
101    pub fn init(&mut self, chain_name: String) {
102        self.chain_name.set(chain_name);
103        self.permit_nonces.init();
104    }
105
106    /// Consumes an off-chain permit signature and sets `spender`'s allowance
107    /// over `owner`'s tokens to `value`.
108    ///
109    /// The caller (typically a relayer, not `owner`) supplies the signed
110    /// `Permit(owner, spender, value, nonce, deadline)` typed-data digest.
111    /// The contract:
112    ///
113    /// 1. Rejects the call if `deadline != u64::MAX` and the current block
114    ///    time is past `deadline` (`PermitExpired`).
115    /// 2. Rejects the call if `Address::from(public_key) != owner`
116    ///    (`InvalidPublicKey`).
117    /// 3. Recomputes the EIP-712 digest using the on-chain nonce for `owner`
118    ///    and verifies `signature` against `public_key`. A mismatch — wrong
119    ///    field value or a replayed signature whose nonce has already been
120    ///    consumed — yields `InvalidSignature`.
121    /// 4. Increments the owner's nonce and overwrites the allowance via
122    ///    [`Cep18::raw_approve`] (it does not add to the existing allowance).
123    pub fn permit(
124        &mut self,
125        owner: Address,
126        spender: Address,
127        value: U256,
128        deadline: u64,
129        public_key: PublicKey,
130        signature: Bytes
131    ) {
132        if deadline != u64::MAX && self.env().get_block_time() > deadline {
133            self.revert(Error::PermitExpired);
134        }
135
136        if Address::from(public_key.clone()) != owner {
137            self.revert(Error::InvalidPublicKey);
138        }
139
140        let nonce = self.permit_nonces.get(&owner).unwrap_or_default();
141        let message_hash = self.message_hash(owner, spender, value, nonce, deadline);
142        let message = Bytes::from(message_hash.to_vec());
143
144        let is_valid = self
145            .env()
146            .verify_signature(&message, &signature, &public_key);
147        if !is_valid {
148            self.revert(Error::InvalidSignature);
149        }
150
151        self.permit_nonces.set(&owner, nonce + U256::from(1));
152
153        self.token.raw_approve(&owner, &spender, &value);
154    }
155
156    /// Builds the EIP-712 domain separator. Bound to the token `name`, the
157    /// fixed `DOMAIN_VERSION`, the configured `chain_name`, and the
158    /// deployed contract's own address (the EIP-712 `verifyingContract`).
159    fn domain_separator(&self) -> DomainSeparator {
160        let self_address = self.env().self_address();
161        let name = self.token.name();
162        let chain_name = self.chain_name.get();
163        crate::eip712::domain_separator(&name, DOMAIN_VERSION, chain_name, self_address)
164    }
165
166    /// Computes the EIP-712 digest the signer must produce — the keccak256 of
167    /// the encoded `Permit` struct combined with the domain separator. `value`
168    /// and `nonce` are big-endian encoded to match the EVM `uint256` layout.
169    fn message_hash(
170        &self,
171        owner: Address,
172        spender: Address,
173        value: U256,
174        nonce: U256,
175        deadline: u64
176    ) -> [u8; 32] {
177        let mut encoded_data = Vec::with_capacity(32 * 5);
178        let mut value_bytes = [0u8; 32];
179        value.to_big_endian(&mut value_bytes);
180        let mut nonce_bytes = [0u8; 32];
181        nonce.to_big_endian(&mut nonce_bytes);
182        encoded_data.extend(eip712::encode_address(owner));
183        encoded_data.extend(eip712::encode_address(spender));
184        encoded_data.extend(casper_eip_712::encode_uint256(value_bytes));
185        encoded_data.extend(casper_eip_712::encode_uint256(nonce_bytes));
186        encoded_data.extend(casper_eip_712::encode_uint64(deadline));
187
188        let domain = self.domain_separator();
189
190        crate::eip712::hash_typed_data(domain, PERMIT_TYPEHASH, encoded_data)
191    }
192}
193
194/// Wrapper contract that combines ERC-2612 functionality with a CEP-18 token for testing purposes.
195#[odra::module]
196pub struct CEP2612Wrapper {
197    cep2612: SubModule<CEP2612>,
198    token: SubModule<Cep18>
199}
200
201/// Wrapper contract that combines ERC-2612 functionality with a CEP-18 token for testing purposes.
202/// In a real deployment, the ERC-2612 module would likely be separate and interact with an existing token contract.
203#[odra::module]
204impl CEP2612Wrapper {
205    /// Initializes the wrapper by deploying the ERC-2612 module and the CEP-18 token, and setting up the EIP-712 domain.
206    pub fn init(
207        &mut self,
208        chain_name: String,
209        symbol: String,
210        name: String,
211        decimals: u8,
212        initial_supply: U256
213    ) {
214        self.cep2612.init(chain_name);
215        self.token.init(symbol, name, decimals, initial_supply);
216    }
217
218    delegate! {
219        to self.cep2612 {
220            fn permit(
221                &mut self,
222                owner: Address,
223                spender: Address,
224                value: U256,
225                deadline: u64,
226                public_key: PublicKey,
227                signature: Bytes
228            );
229        }
230
231        to self.token {
232            fn allowance(&self, owner: &Address, spender: &Address) -> U256;
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::cep18::events::SetAllowance;
241    use odra::host::{Deployer, HostEnv};
242
243    const TOKEN_NAME: &str = "Test Token";
244    const TOKEN_SYMBOL: &str = "TEST";
245    const TOKEN_DECIMALS: u8 = 8;
246    const INITIAL_SUPPLY: u64 = 1_000_000;
247    const CHAIN_NAME: &str = "casper-test";
248
249    struct Setup {
250        env: HostEnv,
251        wrapper: CEP2612WrapperHostRef,
252        alice: Address,
253        bob: Address,
254        charlie: Address,
255        alice_pubkey: PublicKey
256    }
257
258    fn setup() -> Setup {
259        let env = odra_test::env();
260        let alice = env.get_account(0);
261        let bob = env.get_account(1);
262        let charlie = env.get_account(2);
263        let alice_pubkey = env.public_key(&alice);
264
265        let wrapper = CEP2612Wrapper::deploy(
266            &env,
267            CEP2612WrapperInitArgs {
268                chain_name: CHAIN_NAME.to_string(),
269                symbol: TOKEN_SYMBOL.to_string(),
270                name: TOKEN_NAME.to_string(),
271                decimals: TOKEN_DECIMALS,
272                initial_supply: INITIAL_SUPPLY.into()
273            }
274        );
275
276        Setup {
277            env,
278            wrapper,
279            alice,
280            bob,
281            charlie,
282            alice_pubkey
283        }
284    }
285
286    #[test]
287    fn permit_happy_path() {
288        let Setup {
289            env,
290            mut wrapper,
291            alice,
292            bob,
293            charlie,
294            alice_pubkey
295        } = setup();
296
297        assert_eq!(wrapper.allowance(&alice, &bob), U256::zero());
298
299        let value: U256 = 500u64.into();
300        let deadline: u64 = u64::MAX;
301        let nonce: U256 = U256::zero();
302
303        let signature = sign_permit(
304            &env,
305            &alice,
306            wrapper.address(),
307            alice,
308            bob,
309            value,
310            nonce,
311            deadline
312        );
313
314        // Charlie relays alice's permit (gas-less for alice).
315        env.set_caller(charlie);
316        wrapper.permit(alice, bob, value, deadline, alice_pubkey, signature);
317
318        assert_eq!(wrapper.allowance(&alice, &bob), value);
319        assert!(env.emitted_event(
320            &wrapper,
321            SetAllowance {
322                owner: alice,
323                spender: bob,
324                allowance: value
325            }
326        ));
327    }
328
329    #[test]
330    fn permit_replay_reverts() {
331        let Setup {
332            env,
333            mut wrapper,
334            alice,
335            bob,
336            charlie,
337            alice_pubkey
338        } = setup();
339
340        let value: U256 = 500u64.into();
341        let deadline: u64 = u64::MAX;
342        let nonce: U256 = U256::zero();
343
344        let signature = sign_permit(
345            &env,
346            &alice,
347            wrapper.address(),
348            alice,
349            bob,
350            value,
351            nonce,
352            deadline
353        );
354
355        env.set_caller(charlie);
356        wrapper.permit(
357            alice,
358            bob,
359            value,
360            deadline,
361            alice_pubkey.clone(),
362            signature.clone()
363        );
364
365        // Resubmitting fails: the on-chain nonce is now 1, so the contract
366        // recomputes a different digest and the old signature no longer matches.
367        assert_eq!(
368            wrapper.try_permit(alice, bob, value, deadline, alice_pubkey, signature),
369            Err(Error::InvalidSignature.into())
370        );
371    }
372
373    #[test]
374    fn permit_subsequent_with_next_nonce_succeeds() {
375        let Setup {
376            env,
377            mut wrapper,
378            alice,
379            bob,
380            charlie,
381            alice_pubkey
382        } = setup();
383
384        let deadline: u64 = u64::MAX;
385        let first_value: U256 = 500u64.into();
386        let second_value: U256 = 1_000u64.into();
387
388        let first_signature = sign_permit(
389            &env,
390            &alice,
391            wrapper.address(),
392            alice,
393            bob,
394            first_value,
395            U256::zero(),
396            deadline
397        );
398        let second_signature = sign_permit(
399            &env,
400            &alice,
401            wrapper.address(),
402            alice,
403            bob,
404            second_value,
405            U256::one(),
406            deadline
407        );
408
409        env.set_caller(charlie);
410        wrapper.permit(
411            alice,
412            bob,
413            first_value,
414            deadline,
415            alice_pubkey.clone(),
416            first_signature
417        );
418        assert_eq!(wrapper.allowance(&alice, &bob), first_value);
419
420        wrapper.permit(
421            alice,
422            bob,
423            second_value,
424            deadline,
425            alice_pubkey,
426            second_signature
427        );
428        // raw_approve overwrites (does not add) the allowance.
429        assert_eq!(wrapper.allowance(&alice, &bob), second_value);
430    }
431
432    #[test]
433    fn permit_expired() {
434        let Setup {
435            env,
436            mut wrapper,
437            alice,
438            bob,
439            charlie,
440            alice_pubkey
441        } = setup();
442
443        // Move block time past the chosen deadline.
444        env.advance_block_time(60_000);
445
446        let value: U256 = 500u64.into();
447        let deadline: u64 = 1_000;
448        let nonce: U256 = U256::zero();
449
450        let signature = sign_permit(
451            &env,
452            &alice,
453            wrapper.address(),
454            alice,
455            bob,
456            value,
457            nonce,
458            deadline
459        );
460
461        env.set_caller(charlie);
462        assert_eq!(
463            wrapper.try_permit(alice, bob, value, deadline, alice_pubkey, signature),
464            Err(Error::PermitExpired.into())
465        );
466    }
467
468    #[test]
469    fn permit_max_deadline_skips_expiry_check() {
470        let Setup {
471            env,
472            mut wrapper,
473            alice,
474            bob,
475            charlie,
476            alice_pubkey
477        } = setup();
478
479        // Even after a long time, a permit with deadline = u64::MAX is valid.
480        env.advance_block_time(1_000_000);
481
482        let value: U256 = 500u64.into();
483        let deadline: u64 = u64::MAX;
484        let nonce: U256 = U256::zero();
485
486        let signature = sign_permit(
487            &env,
488            &alice,
489            wrapper.address(),
490            alice,
491            bob,
492            value,
493            nonce,
494            deadline
495        );
496
497        env.set_caller(charlie);
498        wrapper.permit(alice, bob, value, deadline, alice_pubkey, signature);
499
500        assert_eq!(wrapper.allowance(&alice, &bob), value);
501    }
502
503    #[test]
504    fn permit_invalid_signature() {
505        let Setup {
506            env,
507            mut wrapper,
508            alice,
509            bob,
510            charlie,
511            alice_pubkey
512        } = setup();
513
514        let deadline: u64 = u64::MAX;
515        let nonce: U256 = U256::zero();
516
517        // Alice signs for value=100 ...
518        let signature = sign_permit(
519            &env,
520            &alice,
521            wrapper.address(),
522            alice,
523            bob,
524            U256::from(100u64),
525            nonce,
526            deadline
527        );
528
529        // ... but charlie submits with value=200, so the digest the contract
530        // recomputes won't match the signature.
531        env.set_caller(charlie);
532        assert_eq!(
533            wrapper.try_permit(
534                alice,
535                bob,
536                U256::from(200u64),
537                deadline,
538                alice_pubkey,
539                signature
540            ),
541            Err(Error::InvalidSignature.into())
542        );
543    }
544
545    #[test]
546    fn permit_with_mismatched_public_key_reverts() {
547        let Setup {
548            env,
549            mut wrapper,
550            alice,
551            bob,
552            charlie,
553            ..
554        } = setup();
555
556        let attacker = env.get_account(3);
557        let attacker_pubkey = env.public_key(&attacker);
558
559        let value: U256 = 500u64.into();
560        let deadline: u64 = u64::MAX;
561        let nonce: U256 = U256::zero();
562
563        // Attacker signs a permit message that names alice as the owner,
564        // using their own keypair.
565        let signature = sign_permit(
566            &env,
567            &attacker,
568            wrapper.address(),
569            alice,
570            bob,
571            value,
572            nonce,
573            deadline
574        );
575
576        // Submitting with owner=alice but public_key=attacker_pubkey must fail,
577        // even though the signature itself verifies against the attacker's key.
578        env.set_caller(charlie);
579        assert_eq!(
580            wrapper.try_permit(alice, bob, value, deadline, attacker_pubkey, signature),
581            Err(Error::InvalidPublicKey.into())
582        );
583
584        // Allowance must remain unchanged.
585        assert_eq!(wrapper.allowance(&alice, &bob), U256::zero());
586    }
587
588    #[allow(clippy::too_many_arguments)]
589    fn sign_permit(
590        env: &HostEnv,
591        signer: &Address,
592        contract_address: Address,
593        owner: Address,
594        spender: Address,
595        value: U256,
596        nonce: U256,
597        deadline: u64
598    ) -> Bytes {
599        let mut value_bytes = [0u8; 32];
600        value.to_big_endian(&mut value_bytes);
601        let mut nonce_bytes = [0u8; 32];
602        nonce.to_big_endian(&mut nonce_bytes);
603
604        let mut encoded_data = Vec::with_capacity(32 * 5);
605        encoded_data.extend(crate::eip712::encode_address(owner));
606        encoded_data.extend(crate::eip712::encode_address(spender));
607        encoded_data.extend(casper_eip_712::encode_uint256(value_bytes));
608        encoded_data.extend(casper_eip_712::encode_uint256(nonce_bytes));
609        encoded_data.extend(casper_eip_712::encode_uint64(deadline));
610
611        let domain = crate::eip712::domain_separator(
612            TOKEN_NAME,
613            DOMAIN_VERSION,
614            CHAIN_NAME.to_string(),
615            contract_address
616        );
617        let message_hash = crate::eip712::hash_typed_data(domain, PERMIT_TYPEHASH, encoded_data);
618        let message = Bytes::from(message_hash.to_vec());
619
620        env.sign_message(&message, signer)
621    }
622}