Skip to main content

odra_modules/
cep3009.rs

1#![allow(clippy::too_many_arguments)]
2#![allow(missing_docs)]
3//! CEP-3009 — an adaptation of [ERC-3009] for the Casper Network.
4//!
5//! ERC-3009 ("Transfer With Authorization") lets a token holder authorize a
6//! transfer of their tokens via an off-chain EIP-712 signature. A third party
7//! (a relayer, or the recipient themselves) submits the authorization
8//! on-chain, so the token holder never has to spend gas to move their tokens.
9//! Unlike ERC-2612, no allowance is granted: each authorization is a direct,
10//! single-use mandate to move a specific `amount` to a specific `to` address.
11//!
12//! The module exposes three entry points:
13//!
14//! * [`CEP3009::transfer_with_authorization`] — anyone may relay; the funds
15//!   move from `from` to `to` once the signature checks out.
16//! * [`CEP3009::receive_with_authorization`] — only `to` may submit. On
17//!   Ethereum this exists to prevent front-running of a relayed
18//!   `to == contract` authorization; Casper has no public mempool, so
19//!   front-running isn't a concern. The variant is still useful because it
20//!   guarantees `caller == to`, which lets a receiving contract atomically
21//!   accept the transfer and act on the deposit in the same call.
22//! * [`CEP3009::cancel_authorization`] — the authorizer (or anyone relaying
23//!   the authorizer's signed cancel message) can burn an unused nonce so a
24//!   leaked authorization can never be redeemed.
25//!
26//! # Differences from EVM ERC-3009
27//!
28//! The EIP-712 typed-data digest construction (domain separator + the
29//! `TransferWithAuthorization`, `ReceiveWithAuthorization`, and
30//! `CancelAuthorization` struct hashes) follows the EVM specification exactly,
31//! so signatures produced by standard EIP-712 tooling remain compatible. The
32//! verification path differs because Casper does not provide `ecrecover`:
33//!
34//! * The caller must pass the signer's [`PublicKey`] explicitly alongside the
35//!   signature. The contract checks that `Address::from(public_key) == from`
36//!   (or `authorizer`, for cancellations) before verifying the signature, so
37//!   a valid signature from a different keypair cannot be used to move
38//!   someone else's tokens or burn someone else's nonce.
39//! * Signature verification uses the host's [`verify_signature`] facility,
40//!   which supports Casper's Ed25519 and Secp256k1 account keys.
41//! * The EIP-712 `chainId` field (a `uint256` on Ethereum) is replaced by a
42//!   `chain_name` string supplied at construction time, matching Casper's
43//!   chain identification model.
44//!
45//! Replay protection follows ERC-3009: each authorization carries a 32-byte
46//! `nonce` chosen by the signer (not a monotonic counter as in ERC-2612), and
47//! the contract records `(authorizer, nonce) -> used` so the same nonce can
48//! never be redeemed twice. Cancellation marks a nonce used without moving
49//! funds.
50//!
51//! [ERC-3009]: https://eips.ethereum.org/EIPS/eip-3009
52//! [`verify_signature`]: odra::ContractEnv::verify_signature
53
54use crate::{cep18::events::Transfer, cep18_token::Cep18, eip712};
55use casper_eip_712::DomainSeparator;
56use odra::{
57    casper_types::{bytesrepr::Bytes, PublicKey, U256},
58    named_keys::{compound_key_value_storage, single_value_storage},
59    prelude::*
60};
61
62// keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
63const TRANSFER_WITH_AUTHORIZATION_TYPEHASH: [u8; 32] = [
64    0x7c, 0x7c, 0x6c, 0xdb, 0x67, 0xa1, 0x87, 0x43, 0xf4, 0x9e, 0xc6, 0xfa, 0x9b, 0x35, 0xf5, 0x0d,
65    0x52, 0xed, 0x05, 0xcb, 0xed, 0x4c, 0xc5, 0x92, 0xe1, 0x3b, 0x44, 0x50, 0x1c, 0x1a, 0x22, 0x67
66];
67
68// keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
69const RECEIVE_WITH_AUTHORIZATION_TYPEHASH: [u8; 32] = [
70    0xd0, 0x99, 0xcc, 0x98, 0xef, 0x71, 0x10, 0x7a, 0x61, 0x6c, 0x4f, 0x0f, 0x94, 0x1f, 0x04, 0xc3,
71    0x22, 0xd8, 0xe2, 0x54, 0xfe, 0x26, 0xb3, 0xc6, 0x66, 0x8d, 0xb8, 0x7a, 0xae, 0x41, 0x3d, 0xe8
72];
73
74// keccak256("CancelAuthorization(address authorizer,bytes32 nonce)")
75const CANCEL_AUTHORIZATION_TYPEHASH: [u8; 32] = [
76    0x15, 0x8b, 0x0a, 0x9e, 0xdf, 0x7a, 0x82, 0x8a, 0xad, 0x02, 0xf6, 0x3c, 0xd5, 0x15, 0xc6, 0x8e,
77    0xf2, 0xf5, 0x0b, 0xa8, 0x07, 0x39, 0x6f, 0x6d, 0x12, 0x84, 0x28, 0x33, 0xa1, 0x59, 0x74, 0x29
78];
79
80/// Emitted when an authorization is consumed by
81/// [`CEP3009::transfer_with_authorization`] or
82/// [`CEP3009::receive_with_authorization`]. The `(authorizer, nonce)` pair
83/// is now marked used and cannot be redeemed again.
84#[odra::event]
85pub struct AuthorizationUsed {
86    authorizer: Address,
87    nonce: Bytes
88}
89
90/// Emitted when an authorization nonce is burned by
91/// [`CEP3009::cancel_authorization`] before being used. The `(authorizer,
92/// nonce)` pair is marked used without any funds moving.
93#[odra::event]
94pub struct AuthorizationCanceled {
95    authorizer: Address,
96    nonce: Bytes
97}
98
99/// Errors raised by CEP-3009 authorization operations.
100#[odra::odra_error]
101pub enum Error {
102    /// The `(from, nonce)` pair has already been consumed by a transfer or
103    /// burned by a cancel. Authorizations are single-use.
104    NonceAlreadyUsed = 37_000,
105    /// The current block time is past `valid_before` — the authorization has
106    /// expired.
107    AuthorizationExpired = 37_001,
108    /// The current block time is at or before `valid_after` — the
109    /// authorization is not yet in its validity window.
110    AuthorizationNotYetValid = 37_002,
111    /// Signature verification against the supplied public key failed, or the
112    /// recomputed digest does not match what the signer signed (e.g. wrong
113    /// `amount`, `to`, or `nonce`).
114    InvalidSignature = 37_003,
115    /// The supplied `public_key` does not hash to the declared `from` /
116    /// `authorizer` address. Guards against using a valid signature from a
117    /// different keypair to move someone else's tokens or burn their nonce.
118    InvalidPublicKey = 37_004,
119    /// `receive_with_authorization` was submitted by an account other than
120    /// `to`. See the module-level docs for why this restriction exists.
121    InvalidCaller = 37_005,
122    /// Attempted to cancel an authorization whose `(authorizer, nonce)` pair
123    /// has already been consumed.
124    AuthorizationUsed = 37_006,
125    /// The supplied `nonce` is not exactly 32 bytes. ERC-3009 nonces are
126    /// `bytes32`; the EIP-712 digest only covers 32 bytes, so accepting a
127    /// shorter or longer nonce would let an attacker craft length aliases
128    /// that share a digest (and thus a signature) while occupying distinct
129    /// replay-protection storage keys.
130    InvalidNonceLength = 37_007
131}
132
133/// Storage defined as named keys.
134const CHAIN_NAME_KEY: &str = "chain_name";
135const USED_NONCES_KEY: &str = "used_nonces";
136
137/// Domain separator version.
138const DOMAIN_VERSION: &str = "1";
139
140single_value_storage!(
141    CEP3009ChainNameStorage,
142    String,
143    CHAIN_NAME_KEY,
144    ExecutionError::KeyNotFound
145);
146
147compound_key_value_storage!(
148    CEP3009UsedNoncesStorage,
149    USED_NONCES_KEY,
150    Address,
151    Bytes,
152    bool
153);
154
155/// CEP-3009 authorization module — adds off-chain signed transfers to a
156/// CEP-18 token.
157///
158/// The module is meant to be composed with a [`Cep18`] sub-module (see
159/// [`CEP3009Wrapper`] for a deployable composition used in tests).
160#[odra::module(events = [AuthorizationUsed, AuthorizationCanceled, Transfer], errors = Error)]
161pub struct CEP3009 {
162    /// Per-(authorizer, nonce) flag recording whether that authorization has
163    /// been consumed or cancelled. Provides single-use semantics.
164    used_nonces: SubModule<CEP3009UsedNoncesStorage>,
165    /// CAIP-2 Chain name used as the EIP-712 domain's `chainId` substitute.
166    chain_name: SubModule<CEP3009ChainNameStorage>,
167    /// The CEP-18 token whose balances are mutated by authorized transfers.
168    token: SubModule<Cep18>
169}
170
171#[odra::module]
172impl CEP3009 {
173    /// Initializes the module by storing the EIP-712 chain ID and the
174    /// used-nonces storage.
175    pub fn init(&mut self, chain_name: String) {
176        self.chain_name.set(chain_name);
177        self.used_nonces.init();
178    }
179
180    /// Returns `true` if `(authorizer, nonce)` has been consumed by a
181    /// previous transfer or burned by [`Self::cancel_authorization`]. Useful
182    /// for off-chain clients to check before submitting a relayed transfer.
183    pub fn authorization_state(&self, authorizer: Address, nonce: Bytes) -> bool {
184        self.used_nonces.get_or_default(&authorizer, &nonce)
185    }
186
187    /// Consumes a signed `TransferWithAuthorization` and moves `amount`
188    /// tokens from `from` to `to`. Any account may submit this call — the
189    /// signature, not the caller, is what authorizes the transfer.
190    ///
191    /// Reverts with [`Error::InvalidNonceLength`], [`Error::NonceAlreadyUsed`],
192    /// [`Error::AuthorizationNotYetValid`], [`Error::AuthorizationExpired`],
193    /// [`Error::InvalidPublicKey`], or [`Error::InvalidSignature`] if the
194    /// corresponding precondition fails.
195    /// On success, marks the nonce used, emits [`AuthorizationUsed`], and
196    /// performs the transfer via [`Cep18::raw_transfer`].
197    pub fn transfer_with_authorization(
198        &mut self,
199        from: Address,
200        to: Address,
201        amount: U256,
202        valid_after: u64,
203        valid_before: u64,
204        nonce: Bytes,
205        public_key: PublicKey,
206        signature: Bytes
207    ) {
208        self.raw_transfer_with_authorization(
209            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
210            from,
211            to,
212            amount,
213            valid_after,
214            valid_before,
215            nonce,
216            public_key,
217            signature
218        );
219    }
220
221    /// Like [`Self::transfer_with_authorization`], but requires
222    /// `caller == to`. This lets a receiving contract atomically accept a
223    /// pre-signed transfer and act on it within the same call (e.g. a
224    /// "deposit then mint LP token" flow), while preventing any other
225    /// account from triggering the transfer at a moment of their choosing.
226    ///
227    /// Reverts with [`Error::InvalidCaller`] if `caller != to`. All other
228    /// checks and effects mirror `transfer_with_authorization`.
229    pub fn receive_with_authorization(
230        &mut self,
231        from: Address,
232        to: Address,
233        amount: U256,
234        valid_after: u64,
235        valid_before: u64,
236        nonce: Bytes,
237        public_key: PublicKey,
238        signature: Bytes
239    ) {
240        let caller = self.env().caller();
241        if caller != to {
242            self.env().revert(Error::InvalidCaller);
243        }
244
245        self.raw_transfer_with_authorization(
246            RECEIVE_WITH_AUTHORIZATION_TYPEHASH,
247            from,
248            to,
249            amount,
250            valid_after,
251            valid_before,
252            nonce,
253            public_key,
254            signature
255        );
256    }
257
258    /// Burns an unused authorization nonce by consuming a signed
259    /// `CancelAuthorization` message. Once a nonce is cancelled, any
260    /// previously distributed signature for the same `(authorizer, nonce)`
261    /// pair becomes unredeemable — the canonical way for an authorizer to
262    /// revoke a leaked or otherwise unwanted authorization.
263    ///
264    /// Reverts with [`Error::InvalidNonceLength`] if `nonce` is not 32 bytes,
265    /// with [`Error::AuthorizationUsed`] if the nonce has already been consumed
266    /// (a cancelled nonce cannot be re-cancelled either), with
267    /// [`Error::InvalidPublicKey`] if `public_key` does not hash to
268    /// `authorizer`, or with [`Error::InvalidSignature`] if signature
269    /// verification fails. On success, marks the nonce used and emits
270    /// [`AuthorizationCanceled`].
271    pub fn cancel_authorization(
272        &mut self,
273        authorizer: Address,
274        nonce: Bytes,
275        public_key: PublicKey,
276        signature: Bytes
277    ) {
278        let Ok(nonce_bytes) = <[u8; 32]>::try_from(nonce.as_slice()) else {
279            self.env().revert(Error::InvalidNonceLength)
280        };
281
282        if self.authorization_state(authorizer, nonce.clone()) {
283            self.env().revert(Error::AuthorizationUsed);
284        }
285
286        if Address::from(public_key.clone()) != authorizer {
287            self.env().revert(Error::InvalidPublicKey);
288        }
289
290        let message = self.build_cancel_message(authorizer, nonce_bytes);
291        if !self
292            .env()
293            .verify_signature(&message, &signature, &public_key)
294        {
295            self.env().revert(Error::InvalidSignature);
296        }
297
298        self.used_nonces.set(&authorizer, &nonce, true);
299        self.env()
300            .emit_event(AuthorizationCanceled { authorizer, nonce });
301    }
302}
303
304impl CEP3009 {
305    /// Shared body of [`Self::transfer_with_authorization`] and
306    /// [`Self::receive_with_authorization`]. They differ only in the EIP-712
307    /// typehash used to compute the digest (and in the caller restriction
308    /// applied by the receive variant before calling this).
309    fn raw_transfer_with_authorization(
310        &mut self,
311        typehash: [u8; 32],
312        from: Address,
313        to: Address,
314        amount: U256,
315        valid_after: u64,
316        valid_before: u64,
317        nonce: Bytes,
318        public_key: PublicKey,
319        signature: Bytes
320    ) {
321        // 1. Enforce a 32-byte nonce so the replay-protection key and the
322        // signed digest cover the exact same bytes (no length aliasing).
323        let Ok(nonce_bytes) = <[u8; 32]>::try_from(nonce.as_slice()) else {
324            self.env().revert(Error::InvalidNonceLength)
325        };
326
327        // 2. Replay protection
328        if self.used_nonces.get_or_default(&from, &nonce) {
329            self.env().revert(Error::NonceAlreadyUsed);
330        }
331
332        // 3. block_time
333        let now_secs = self.env().get_block_time_secs();
334
335        // 4. Check valid_after
336        if now_secs <= valid_after {
337            self.env().revert(Error::AuthorizationNotYetValid);
338        }
339
340        // 5. Check valid_before
341        if now_secs >= valid_before {
342            self.env().revert(Error::AuthorizationExpired);
343        }
344
345        // 6. Verify that public_key matches the `from` address
346        let derived_address = Address::from(public_key.clone());
347        if derived_address != from {
348            self.env().revert(Error::InvalidPublicKey);
349        }
350
351        // 7. Build message and verify signature
352        let message = self.build_authorization_message(
353            typehash,
354            from,
355            to,
356            &amount,
357            valid_after,
358            valid_before,
359            nonce_bytes
360        );
361
362        if !self
363            .env()
364            .verify_signature(&message, &signature, &public_key)
365        {
366            self.env().revert(Error::InvalidSignature);
367        }
368
369        // 8. Mark nonce as used
370        self.used_nonces.set(&from, &nonce, true);
371
372        // 9. Emit event
373        self.env().emit_event(AuthorizationUsed {
374            authorizer: from,
375            nonce
376        });
377
378        // 10. Execute transfer (raw_transfer takes refs)
379        self.token.raw_transfer(&from, &to, &amount);
380
381        // 11. Emit event.
382        self.env().emit_event(Transfer {
383            sender: from,
384            recipient: to,
385            amount
386        });
387    }
388
389    /// Builds the EIP-712 digest for a transfer authorization. `amount` is
390    /// big-endian encoded to match the EVM `uint256` layout, and `nonce` is the
391    /// 32-byte `bytes32` value (enforced at every entry point).
392    fn build_authorization_message(
393        &self,
394        typehash: [u8; 32],
395        from: Address,
396        to: Address,
397        amount: &U256,
398        valid_after: u64,
399        valid_before: u64,
400        nonce: [u8; 32]
401    ) -> Bytes {
402        let mut value_bytes = [0u8; 32];
403        amount.to_big_endian(&mut value_bytes);
404
405        let mut encoded_data = Vec::with_capacity(6 * 32);
406        encoded_data.extend(eip712::encode_address(from));
407        encoded_data.extend(eip712::encode_address(to));
408        encoded_data.extend(casper_eip_712::encode_uint256(value_bytes));
409        encoded_data.extend(casper_eip_712::encode_uint64(valid_after));
410        encoded_data.extend(casper_eip_712::encode_uint64(valid_before));
411        encoded_data.extend(casper_eip_712::encode_bytes32(nonce));
412        let domain = self.domain_separator();
413
414        Bytes::from(crate::eip712::hash_typed_data(domain, typehash, encoded_data).to_vec())
415    }
416
417    /// Builds the EIP-712 digest for a `CancelAuthorization` message. `nonce` is
418    /// the 32-byte `bytes32` value (enforced at every entry point).
419    fn build_cancel_message(&self, authorizer: Address, nonce: [u8; 32]) -> Bytes {
420        let mut encoded_data = Vec::with_capacity(64);
421        encoded_data.extend(eip712::encode_address(authorizer));
422        encoded_data.extend(casper_eip_712::encode_bytes32(nonce));
423        let domain = self.domain_separator();
424
425        Bytes::from(
426            eip712::hash_typed_data(domain, CANCEL_AUTHORIZATION_TYPEHASH, encoded_data).to_vec()
427        )
428    }
429
430    /// Builds the EIP-712 domain separator. Bound to the token `name`, the
431    /// fixed `DOMAIN_VERSION`, the configured `chain_name`, and the
432    /// deployed contract's own address (the EIP-712 `verifyingContract`).
433    fn domain_separator(&self) -> DomainSeparator {
434        let self_address = self.env().self_address();
435        let name = self.token.name();
436        let chain_name = self.chain_name.get();
437        eip712::domain_separator(&name, DOMAIN_VERSION, chain_name, self_address)
438    }
439}
440
441/// Wrapper contract that combines ERC-3009 functionality with a CEP-18 token for testing purposes.
442#[odra::module]
443pub struct CEP3009Wrapper {
444    cep3009: SubModule<CEP3009>,
445    token: SubModule<Cep18>
446}
447
448#[odra::module]
449impl CEP3009Wrapper {
450    /// Initializes both the authorization module (storing the EIP-712 chain
451    /// id) and the underlying CEP-18 token (symbol, name, decimals, supply).
452    pub fn init(
453        &mut self,
454        chain_name: String,
455        symbol: String,
456        name: String,
457        decimals: u8,
458        initial_supply: U256
459    ) {
460        self.cep3009.init(chain_name);
461        self.token.init(symbol, name, decimals, initial_supply);
462    }
463
464    delegate! {
465        to self.cep3009 {
466            fn authorization_state(&self, authorizer: Address, nonce: Bytes) -> bool;
467            fn transfer_with_authorization(
468                &mut self,
469                from: Address,
470                to: Address,
471                amount: U256,
472                valid_after: u64,
473                valid_before: u64,
474                nonce: Bytes,
475                public_key: PublicKey,
476                signature: Bytes
477            );
478            fn receive_with_authorization(
479                &mut self,
480                from: Address,
481                to: Address,
482                amount: U256,
483                valid_after: u64,
484                valid_before: u64,
485                nonce: Bytes,
486                public_key: PublicKey,
487                signature: Bytes
488            );
489            fn cancel_authorization(
490                &mut self,
491                authorizer: Address,
492                nonce: Bytes,
493                public_key: PublicKey,
494                signature: Bytes
495            );
496        }
497
498        to self.token {
499            fn balance_of(&self, address: &Address) -> U256;
500        }
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use odra::host::{Deployer, HostEnv};
508
509    const TOKEN_NAME: &str = "Test Token";
510    const TOKEN_SYMBOL: &str = "TEST";
511    const TOKEN_DECIMALS: u8 = 8;
512    const INITIAL_SUPPLY: u64 = 1_000_000;
513    const CHAIN_NAME: &str = "casper:casper";
514
515    struct Setup {
516        env: HostEnv,
517        wrapper: CEP3009WrapperHostRef,
518        alice: Address,
519        bob: Address,
520        charlie: Address,
521        alice_pubkey: PublicKey
522    }
523
524    /// Deploys the wrapper with alice as deployer (so she gets the initial supply)
525    /// and advances block time past `valid_after = 0`.
526    fn setup() -> Setup {
527        let env = odra_test::env();
528        let alice = env.get_account(0);
529        let bob = env.get_account(1);
530        let charlie = env.get_account(2);
531        let alice_pubkey = env.public_key(&alice);
532
533        let wrapper = CEP3009Wrapper::deploy(
534            &env,
535            CEP3009WrapperInitArgs {
536                chain_name: CHAIN_NAME.to_string(),
537                symbol: TOKEN_SYMBOL.to_string(),
538                name: TOKEN_NAME.to_string(),
539                decimals: TOKEN_DECIMALS,
540                initial_supply: INITIAL_SUPPLY.into()
541            }
542        );
543
544        env.advance_block_time(1_000);
545
546        Setup {
547            env,
548            wrapper,
549            alice,
550            bob,
551            charlie,
552            alice_pubkey
553        }
554    }
555
556    fn fresh_nonce(seed: u8) -> Bytes {
557        Bytes::from(vec![seed; 32])
558    }
559
560    #[test]
561    fn transfer_with_authorization_happy_path() {
562        let Setup {
563            env,
564            mut wrapper,
565            alice,
566            bob,
567            charlie,
568            alice_pubkey
569        } = setup();
570
571        assert_eq!(wrapper.balance_of(&alice), INITIAL_SUPPLY.into());
572        assert_eq!(wrapper.balance_of(&bob), U256::zero());
573
574        let amount: U256 = 100u64.into();
575        let valid_after: u64 = 0;
576        let valid_before: u64 = u64::MAX;
577        let nonce = fresh_nonce(1);
578
579        let signature = sign_transfer_authorization(
580            &env,
581            &alice,
582            wrapper.address(),
583            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
584            alice,
585            bob,
586            amount,
587            valid_after,
588            valid_before,
589            &nonce
590        );
591
592        // Charlie relays the authorization on alice's behalf.
593        env.set_caller(charlie);
594        wrapper.transfer_with_authorization(
595            alice,
596            bob,
597            amount,
598            valid_after,
599            valid_before,
600            nonce.clone(),
601            alice_pubkey,
602            signature
603        );
604
605        assert_eq!(
606            wrapper.balance_of(&alice),
607            U256::from(INITIAL_SUPPLY) - amount
608        );
609        assert_eq!(wrapper.balance_of(&bob), amount);
610        assert!(wrapper.authorization_state(alice, nonce.clone()));
611        assert!(env.emitted_event(
612            &wrapper,
613            AuthorizationUsed {
614                authorizer: alice,
615                nonce
616            }
617        ));
618    }
619
620    #[test]
621    fn transfer_with_authorization_replay_protection() {
622        let Setup {
623            env,
624            mut wrapper,
625            alice,
626            bob,
627            charlie,
628            alice_pubkey
629        } = setup();
630
631        let amount: U256 = 100u64.into();
632        let valid_after: u64 = 0;
633        let valid_before: u64 = u64::MAX;
634        let nonce = fresh_nonce(2);
635
636        let signature = sign_transfer_authorization(
637            &env,
638            &alice,
639            wrapper.address(),
640            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
641            alice,
642            bob,
643            amount,
644            valid_after,
645            valid_before,
646            &nonce
647        );
648
649        env.set_caller(charlie);
650        wrapper.transfer_with_authorization(
651            alice,
652            bob,
653            amount,
654            valid_after,
655            valid_before,
656            nonce.clone(),
657            alice_pubkey.clone(),
658            signature.clone()
659        );
660
661        // Submitting the same authorization a second time reverts.
662        assert_eq!(
663            wrapper.try_transfer_with_authorization(
664                alice,
665                bob,
666                amount,
667                valid_after,
668                valid_before,
669                nonce,
670                alice_pubkey,
671                signature
672            ),
673            Err(Error::NonceAlreadyUsed.into())
674        );
675    }
676
677    #[test]
678    fn transfer_with_authorization_not_yet_valid() {
679        let Setup {
680            env,
681            mut wrapper,
682            alice,
683            bob,
684            charlie,
685            alice_pubkey
686        } = setup();
687
688        // Block time is at 1 second; require it to be in the future.
689        let valid_after: u64 = 60;
690        let valid_before: u64 = u64::MAX;
691        let amount: U256 = 100u64.into();
692        let nonce = fresh_nonce(3);
693
694        let signature = sign_transfer_authorization(
695            &env,
696            &alice,
697            wrapper.address(),
698            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
699            alice,
700            bob,
701            amount,
702            valid_after,
703            valid_before,
704            &nonce
705        );
706
707        env.set_caller(charlie);
708        assert_eq!(
709            wrapper.try_transfer_with_authorization(
710                alice,
711                bob,
712                amount,
713                valid_after,
714                valid_before,
715                nonce,
716                alice_pubkey,
717                signature
718            ),
719            Err(Error::AuthorizationNotYetValid.into())
720        );
721    }
722
723    #[test]
724    fn transfer_with_authorization_expired() {
725        let Setup {
726            env,
727            mut wrapper,
728            alice,
729            bob,
730            charlie,
731            alice_pubkey
732        } = setup();
733
734        // Move further into the future so that valid_before is in the past.
735        env.advance_block_time(60_000);
736
737        let valid_after: u64 = 0;
738        let valid_before: u64 = 10;
739        let amount: U256 = 100u64.into();
740        let nonce = fresh_nonce(4);
741
742        let signature = sign_transfer_authorization(
743            &env,
744            &alice,
745            wrapper.address(),
746            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
747            alice,
748            bob,
749            amount,
750            valid_after,
751            valid_before,
752            &nonce
753        );
754
755        env.set_caller(charlie);
756        assert_eq!(
757            wrapper.try_transfer_with_authorization(
758                alice,
759                bob,
760                amount,
761                valid_after,
762                valid_before,
763                nonce,
764                alice_pubkey,
765                signature
766            ),
767            Err(Error::AuthorizationExpired.into())
768        );
769    }
770
771    #[test]
772    fn transfer_with_authorization_invalid_signature() {
773        let Setup {
774            env,
775            mut wrapper,
776            alice,
777            bob,
778            charlie,
779            alice_pubkey
780        } = setup();
781
782        let valid_after: u64 = 0;
783        let valid_before: u64 = u64::MAX;
784        let nonce = fresh_nonce(5);
785
786        // Alice signs for an amount of 100 ...
787        let signature = sign_transfer_authorization(
788            &env,
789            &alice,
790            wrapper.address(),
791            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
792            alice,
793            bob,
794            U256::from(100u64),
795            valid_after,
796            valid_before,
797            &nonce
798        );
799
800        // ... but charlie submits with a different amount, so the digest the
801        // contract recomputes won't match the signature.
802        env.set_caller(charlie);
803        assert_eq!(
804            wrapper.try_transfer_with_authorization(
805                alice,
806                bob,
807                U256::from(200u64),
808                valid_after,
809                valid_before,
810                nonce,
811                alice_pubkey,
812                signature
813            ),
814            Err(Error::InvalidSignature.into())
815        );
816    }
817
818    #[test]
819    fn transfer_with_authorization_public_key_mismatch() {
820        let Setup {
821            env,
822            mut wrapper,
823            alice,
824            bob,
825            charlie,
826            ..
827        } = setup();
828
829        let valid_after: u64 = 0;
830        let valid_before: u64 = u64::MAX;
831        let amount: U256 = 100u64.into();
832        let nonce = fresh_nonce(6);
833
834        // Sign with bob's key while declaring alice as `from`.
835        let bob_pubkey = env.public_key(&bob);
836        let signature = sign_transfer_authorization(
837            &env,
838            &bob,
839            wrapper.address(),
840            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
841            alice,
842            bob,
843            amount,
844            valid_after,
845            valid_before,
846            &nonce
847        );
848
849        env.set_caller(charlie);
850        assert_eq!(
851            wrapper.try_transfer_with_authorization(
852                alice,
853                bob,
854                amount,
855                valid_after,
856                valid_before,
857                nonce,
858                bob_pubkey,
859                signature
860            ),
861            Err(Error::InvalidPublicKey.into())
862        );
863    }
864
865    #[test]
866    fn receive_with_authorization_happy_path() {
867        let Setup {
868            env,
869            mut wrapper,
870            alice,
871            bob,
872            alice_pubkey,
873            ..
874        } = setup();
875
876        let amount: U256 = 250u64.into();
877        let valid_after: u64 = 0;
878        let valid_before: u64 = u64::MAX;
879        let nonce = fresh_nonce(7);
880
881        let signature = sign_transfer_authorization(
882            &env,
883            &alice,
884            wrapper.address(),
885            RECEIVE_WITH_AUTHORIZATION_TYPEHASH,
886            alice,
887            bob,
888            amount,
889            valid_after,
890            valid_before,
891            &nonce
892        );
893
894        // Bob (the recipient) must be the caller for receive_with_authorization.
895        env.set_caller(bob);
896        wrapper.receive_with_authorization(
897            alice,
898            bob,
899            amount,
900            valid_after,
901            valid_before,
902            nonce.clone(),
903            alice_pubkey,
904            signature
905        );
906
907        assert_eq!(
908            wrapper.balance_of(&alice),
909            U256::from(INITIAL_SUPPLY) - amount
910        );
911        assert_eq!(wrapper.balance_of(&bob), amount);
912        assert!(wrapper.authorization_state(alice, nonce));
913    }
914
915    #[test]
916    fn receive_with_authorization_wrong_caller_reverts() {
917        let Setup {
918            env,
919            mut wrapper,
920            alice,
921            bob,
922            charlie,
923            alice_pubkey
924        } = setup();
925
926        let amount: U256 = 250u64.into();
927        let valid_after: u64 = 0;
928        let valid_before: u64 = u64::MAX;
929        let nonce = fresh_nonce(8);
930
931        let signature = sign_transfer_authorization(
932            &env,
933            &alice,
934            wrapper.address(),
935            RECEIVE_WITH_AUTHORIZATION_TYPEHASH,
936            alice,
937            bob,
938            amount,
939            valid_after,
940            valid_before,
941            &nonce
942        );
943
944        // Charlie tries to relay even though `to` is bob.
945        env.set_caller(charlie);
946        assert_eq!(
947            wrapper.try_receive_with_authorization(
948                alice,
949                bob,
950                amount,
951                valid_after,
952                valid_before,
953                nonce,
954                alice_pubkey,
955                signature
956            ),
957            Err(Error::InvalidCaller.into())
958        );
959    }
960
961    #[test]
962    fn cancel_authorization_happy_path() {
963        let Setup {
964            env,
965            mut wrapper,
966            alice,
967            bob,
968            charlie,
969            alice_pubkey
970        } = setup();
971
972        let nonce = fresh_nonce(9);
973        let cancel_signature =
974            sign_cancel_authorization(&env, &alice, wrapper.address(), alice, &nonce);
975
976        env.set_caller(charlie);
977        wrapper.cancel_authorization(alice, nonce.clone(), alice_pubkey.clone(), cancel_signature);
978
979        assert!(wrapper.authorization_state(alice, nonce.clone()));
980        assert!(env.emitted_event(
981            &wrapper,
982            AuthorizationCanceled {
983                authorizer: alice,
984                nonce: nonce.clone()
985            }
986        ));
987
988        // A subsequent transfer reusing the cancelled nonce is rejected.
989        let amount: U256 = 1u64.into();
990        let valid_after: u64 = 0;
991        let valid_before: u64 = u64::MAX;
992        let transfer_signature = sign_transfer_authorization(
993            &env,
994            &alice,
995            wrapper.address(),
996            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
997            alice,
998            bob,
999            amount,
1000            valid_after,
1001            valid_before,
1002            &nonce
1003        );
1004        assert_eq!(
1005            wrapper.try_transfer_with_authorization(
1006                alice,
1007                bob,
1008                amount,
1009                valid_after,
1010                valid_before,
1011                nonce,
1012                alice_pubkey,
1013                transfer_signature
1014            ),
1015            Err(Error::NonceAlreadyUsed.into())
1016        );
1017    }
1018
1019    #[test]
1020    fn cancel_authorization_already_used_reverts() {
1021        let Setup {
1022            env,
1023            mut wrapper,
1024            alice,
1025            bob,
1026            charlie,
1027            alice_pubkey
1028        } = setup();
1029
1030        let amount: U256 = 100u64.into();
1031        let valid_after: u64 = 0;
1032        let valid_before: u64 = u64::MAX;
1033        let nonce = fresh_nonce(10);
1034
1035        // Consume the nonce via a successful transfer first.
1036        let transfer_signature = sign_transfer_authorization(
1037            &env,
1038            &alice,
1039            wrapper.address(),
1040            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
1041            alice,
1042            bob,
1043            amount,
1044            valid_after,
1045            valid_before,
1046            &nonce
1047        );
1048        env.set_caller(charlie);
1049        wrapper.transfer_with_authorization(
1050            alice,
1051            bob,
1052            amount,
1053            valid_after,
1054            valid_before,
1055            nonce.clone(),
1056            alice_pubkey.clone(),
1057            transfer_signature
1058        );
1059
1060        // Cancelling an already-consumed nonce reverts.
1061        let cancel_signature =
1062            sign_cancel_authorization(&env, &alice, wrapper.address(), alice, &nonce);
1063        assert_eq!(
1064            wrapper.try_cancel_authorization(alice, nonce, alice_pubkey, cancel_signature),
1065            Err(Error::AuthorizationUsed.into())
1066        );
1067    }
1068
1069    #[test]
1070    fn cancel_authorization_with_mismatched_public_key_reverts() {
1071        let Setup {
1072            env,
1073            mut wrapper,
1074            alice,
1075            bob,
1076            charlie,
1077            ..
1078        } = setup();
1079
1080        let nonce = fresh_nonce(11);
1081
1082        // Bob signs a cancel message that names alice as the authorizer,
1083        // using his own keypair.
1084        let bob_pubkey = env.public_key(&bob);
1085        let cancel_signature =
1086            sign_cancel_authorization(&env, &bob, wrapper.address(), alice, &nonce);
1087
1088        // Submitting with authorizer=alice but public_key=bob_pubkey must fail,
1089        // otherwise an attacker could burn alice's nonces.
1090        env.set_caller(charlie);
1091        assert_eq!(
1092            wrapper.try_cancel_authorization(alice, nonce.clone(), bob_pubkey, cancel_signature),
1093            Err(Error::InvalidPublicKey.into())
1094        );
1095
1096        // The nonce must remain unused so alice's pending authorization is not bricked.
1097        assert!(!wrapper.authorization_state(alice, nonce));
1098    }
1099
1100    #[test]
1101    fn transfer_with_authorization_rejects_appended_byte_nonce_alias() {
1102        let Setup {
1103            env,
1104            mut wrapper,
1105            alice,
1106            bob,
1107            charlie,
1108            alice_pubkey
1109        } = setup();
1110
1111        let amount: U256 = 100u64.into();
1112        let valid_after: u64 = 0;
1113        let valid_before: u64 = u64::MAX;
1114        let nonce = fresh_nonce(20);
1115
1116        let signature = sign_transfer_authorization(
1117            &env,
1118            &alice,
1119            wrapper.address(),
1120            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
1121            alice,
1122            bob,
1123            amount,
1124            valid_after,
1125            valid_before,
1126            &nonce
1127        );
1128
1129        // First redemption with the canonical 32-byte nonce succeeds.
1130        env.set_caller(charlie);
1131        wrapper.transfer_with_authorization(
1132            alice,
1133            bob,
1134            amount,
1135            valid_after,
1136            valid_before,
1137            nonce.clone(),
1138            alice_pubkey.clone(),
1139            signature.clone()
1140        );
1141
1142        // The same signature with a byte appended to the nonce hashes to the
1143        // same 32-byte digest (the digest only covers 32 bytes) but produces a
1144        // distinct replay-protection storage key. It must be rejected outright
1145        // rather than allowing a second redemption.
1146        let mut aliased = nonce.to_vec();
1147        aliased.push(0);
1148        let aliased_nonce = Bytes::from(aliased);
1149
1150        assert_eq!(
1151            wrapper.try_transfer_with_authorization(
1152                alice,
1153                bob,
1154                amount,
1155                valid_after,
1156                valid_before,
1157                aliased_nonce,
1158                alice_pubkey,
1159                signature
1160            ),
1161            Err(Error::InvalidNonceLength.into())
1162        );
1163
1164        // The funds moved exactly once.
1165        assert_eq!(wrapper.balance_of(&bob), amount);
1166    }
1167
1168    #[test]
1169    fn transfer_with_authorization_rejects_short_nonce() {
1170        let Setup {
1171            env,
1172            mut wrapper,
1173            alice,
1174            bob,
1175            charlie,
1176            alice_pubkey
1177        } = setup();
1178
1179        let amount: U256 = 100u64.into();
1180        let valid_after: u64 = 0;
1181        let valid_before: u64 = u64::MAX;
1182        // A 31-byte nonce that the digest would right-pad to 32 bytes.
1183        let nonce = Bytes::from(vec![21u8; 31]);
1184
1185        let signature = sign_transfer_authorization(
1186            &env,
1187            &alice,
1188            wrapper.address(),
1189            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
1190            alice,
1191            bob,
1192            amount,
1193            valid_after,
1194            valid_before,
1195            &nonce
1196        );
1197
1198        env.set_caller(charlie);
1199        assert_eq!(
1200            wrapper.try_transfer_with_authorization(
1201                alice,
1202                bob,
1203                amount,
1204                valid_after,
1205                valid_before,
1206                nonce,
1207                alice_pubkey,
1208                signature
1209            ),
1210            Err(Error::InvalidNonceLength.into())
1211        );
1212    }
1213
1214    #[test]
1215    fn cancel_authorization_rejects_nonstandard_nonce() {
1216        let Setup {
1217            env,
1218            mut wrapper,
1219            alice,
1220            charlie,
1221            alice_pubkey,
1222            ..
1223        } = setup();
1224
1225        // An oversized (33-byte) nonce aliases a 32-byte cancel digest.
1226        let nonce = Bytes::from(vec![22u8; 33]);
1227        let cancel_signature =
1228            sign_cancel_authorization(&env, &alice, wrapper.address(), alice, &nonce);
1229
1230        env.set_caller(charlie);
1231        assert_eq!(
1232            wrapper.try_cancel_authorization(alice, nonce.clone(), alice_pubkey, cancel_signature),
1233            Err(Error::InvalidNonceLength.into())
1234        );
1235
1236        // The aliased nonce must not be recorded as used.
1237        assert!(!wrapper.authorization_state(alice, nonce));
1238    }
1239
1240    fn sign_transfer_authorization(
1241        env: &HostEnv,
1242        signer: &Address,
1243        contract_address: Address,
1244        typehash: [u8; 32],
1245        from: Address,
1246        to: Address,
1247        amount: U256,
1248        valid_after: u64,
1249        valid_before: u64,
1250        nonce: &Bytes
1251    ) -> Bytes {
1252        let mut value_bytes = [0u8; 32];
1253        amount.to_big_endian(&mut value_bytes);
1254
1255        let mut nonce_padded = [0u8; 32];
1256        let len = nonce.len().min(32);
1257        nonce_padded[..len].copy_from_slice(&nonce[..len]);
1258
1259        let mut encoded_data = Vec::with_capacity(6 * 32);
1260        encoded_data.extend(crate::eip712::encode_address(from));
1261        encoded_data.extend(crate::eip712::encode_address(to));
1262        encoded_data.extend(casper_eip_712::encode_uint256(value_bytes));
1263        encoded_data.extend(casper_eip_712::encode_uint64(valid_after));
1264        encoded_data.extend(casper_eip_712::encode_uint64(valid_before));
1265        encoded_data.extend(casper_eip_712::encode_bytes32(nonce_padded));
1266
1267        let domain = crate::eip712::domain_separator(
1268            TOKEN_NAME,
1269            DOMAIN_VERSION,
1270            CHAIN_NAME.to_string(),
1271            contract_address
1272        );
1273        let message_hash = crate::eip712::hash_typed_data(domain, typehash, encoded_data);
1274        let message = Bytes::from(message_hash.to_vec());
1275
1276        env.sign_message(&message, signer)
1277    }
1278
1279    fn sign_cancel_authorization(
1280        env: &HostEnv,
1281        signer: &Address,
1282        contract_address: Address,
1283        authorizer: Address,
1284        nonce: &Bytes
1285    ) -> Bytes {
1286        let mut nonce_padded = [0u8; 32];
1287        let len = nonce.len().min(32);
1288        nonce_padded[..len].copy_from_slice(&nonce[..len]);
1289
1290        let mut encoded_data = Vec::with_capacity(64);
1291        encoded_data.extend(crate::eip712::encode_address(authorizer));
1292        encoded_data.extend(casper_eip_712::encode_bytes32(nonce_padded));
1293
1294        let domain = crate::eip712::domain_separator(
1295            TOKEN_NAME,
1296            DOMAIN_VERSION,
1297            CHAIN_NAME.to_string(),
1298            contract_address
1299        );
1300        let message_hash =
1301            crate::eip712::hash_typed_data(domain, CANCEL_AUTHORIZATION_TYPEHASH, encoded_data);
1302        let message = Bytes::from(message_hash.to_vec());
1303
1304        env.sign_message(&message, signer)
1305    }
1306}