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