From 97cc79ec5f9bffdd96f573c1b5e82f30b4679ba9 Mon Sep 17 00:00:00 2001 From: Maciej Zielinski Date: Tue, 12 May 2026 18:26:23 +0200 Subject: [PATCH 1/4] CEP2612 Permit Extension --- text/2612-permit-extension.md | 220 ++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 text/2612-permit-extension.md diff --git a/text/2612-permit-extension.md b/text/2612-permit-extension.md new file mode 100644 index 0000000..5155ef4 --- /dev/null +++ b/text/2612-permit-extension.md @@ -0,0 +1,220 @@ +# Casper Payments: Permit Extension (CEP-2612) + +## Summary +A standard interface for off-chain approval of CEP-18 token allowances via +signed messages. The standard is inspired by the ERC-2612 standard from Ethereum +and is adapted to Casper's native multi-scheme signatures (ed25519, secp256k1). +A token holder signs a typed message authorizing a `spender` to receive an +allowance, and any party may submit that signature to the contract to set the +allowance on the holder's behalf — enabling gas-less approvals for the holder. + +This CEP extends [CEP-18](0018-token-standard.md). It does not change CEP-18's +`approve` / `allowance` / `transfer_from` semantics; it adds a new entry point +that produces the same allowance state change as `approve` while being callable +by a third party. + +## Prior art +The main source of influence is [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612). + +The signed payload follows [EIP-712](https://eips.ethereum.org/EIPS/eip-712) +typed data hashing. EIP-712 is treated here as a dependency rather than +re-specified — implementations are expected to use [EIP-712 toolkit for +Casper](https://github.com/casper-ecosystem/casper-eip-712). + +## Specification + +The CEP-2612 extension is defined by: +- one new entrypoint (`permit`), +- the EIP-712 typed data definition for the `Permit` struct, +- error codes, +- storage structure for replay-protection nonces and the EIP-712 chain + identifier. + +The extension reuses CEP-18's `SetAllowance` event — `permit` produces the +same allowance state change as `approve` and emits the same event. No new +events are introduced by this CEP. + +Below definitions use Rust syntax, but they are not Rust specific. + +### Entrypoint interface + +Contracts implementing this standard must expose the following entry point +in addition to the CEP-18 interface: + +```rust +pub trait CEP2612Interface { + /// Sets `value` as the allowance of `spender` over `owner`'s tokens, + /// given `owner`'s signed approval. + /// + /// - `owner` is the token holder whose tokens are being approved. + /// - `spender` is the address that will be allowed to spend the tokens. + /// - `value` is the allowance amount. + /// - `deadline` is a block-time timestamp after which the permit is no + /// longer accepted, expressed in the same units as the host + /// environment's `get_block_time()`. The sentinel value `u64::MAX` + /// disables the expiry check. + /// - `public_key` is the signer's Casper public key. + /// - `signature` is `public_key`'s signature over the EIP-712 digest + /// of the `Permit` typed data (see below). + /// + /// On success: + /// - the allowance of `spender` over `owner` is set to `value` (i.e. + /// the same effect as `owner` calling `approve(spender, value)`), + /// - the `owner`'s permit nonce is incremented by one, + /// - a CEP-18 `SetAllowance { owner, spender, allowance: value }` + /// event is emitted. + /// + /// The call must revert: + /// - with `PermitExpired` if `deadline != u64::MAX` and the current + /// block time is strictly greater than `deadline`, + /// - with `InvalidSignature` if `signature` does not verify against + /// `public_key` over the recomputed digest, + /// - with `InvalidSignature` if `owner` is not the `Address` derived + /// from `public_key` (see "Signature scheme" below). + /// + /// The transaction caller (`env().caller()`) is irrelevant — `permit` + /// is designed to be relayed by any third party. + fn permit( + &mut self, + owner: Key, + spender: Key, + value: U256, + deadline: u64, + public_key: PublicKey, + signature: Bytes + ); +} +``` + +#### Signature scheme — divergence from EIP-2612 + +EIP-2612 passes the signature as `(v, r, s)` because Ethereum recovers the +signer's address from the signature itself via ECDSA recovery and then +asserts that the recovered address equals `owner`. Casper supports +multiple signature schemes (ed25519 and secp256k1) and does not recover +the signer from the signature, so this CEP takes an explicit +`(public_key, signature)` pair instead. + +To preserve the property that a permit signature only authorizes its own +`owner`'s allowance, implementations MUST enforce: + +1. `signature` is a valid signature of `public_key` over the EIP-712 + digest of the `Permit` typed data (described below), AND +2. `owner == Address::from(public_key)` — i.e. `owner` is the account + address corresponding to `public_key`. + +Without (2) a third party could sign a digest containing an unrelated +`owner` field with their own key and have the contract set that +`owner`'s allowance. Both checks should revert with `InvalidSignature`. + +### Signed payload (EIP-712) + +The signed payload is the EIP-712 digest of a `Permit` typed struct. + +The Permit type string is: + +``` +Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline) +``` + +Its typehash is keccak256 of the type string above: + +``` +PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9 +``` + +The encoded message data hashed alongside the typehash is the +concatenation, in order, of: + +- `owner` encoded as an EIP-712 `address` (32 bytes, big-endian, left-padded), +- `spender` encoded as an EIP-712 `address` (32 bytes, big-endian, left-padded), +- `value` encoded as an EIP-712 `uint256` (32 bytes, big-endian), +- `nonce` encoded as an EIP-712 `uint256` (32 bytes, big-endian) — equal + to the current on-chain `permit_nonces[owner]` value at the time the + signature was produced, +- `deadline` encoded as an EIP-712 `uint64` (32 bytes, big-endian, + left-padded). + +The final digest is computed using the standard EIP-712 rule +(`keccak256("\x19\x01" || domainSeparator || keccak256(typeHash || encodedData))`). + +The EIP-712 `domainSeparator` uses: +- `name` — the CEP-18 token's `name`, +- `chainId` — the contract's `chain_name` (see Storage below); Casper + does not have a numeric chain id, so the chain name string is used in + its place, +- `verifyingContract` — the contract's own address. + +The salt and version fields are not used. + +### Error codes + +The CEP-2612 extension contract should revert with the following error +codes when the appropriate conditions are met: + +```rust +pub enum CEP2612Error { + /// Either `signature` does not verify against `public_key` over the + /// recomputed digest, or `owner` is not the account address derived + /// from `public_key`. + InvalidSignature = 36_000, + /// The current block time is strictly greater than `deadline`, and + /// `deadline` is not the sentinel `u64::MAX`. + PermitExpired = 36_001, +} +``` + +These codes are disjoint from the CEP-18 error code range +(`60_001`–`60_003`). + +### Storage interface + +Querying the permit nonce externally — which a wallet must do before +producing a signature — requires direct contract storage access. This +CEP fixes the on-chain layout of the two state items introduced by the +extension. + +#### Simple values + +The chain name used in the EIP-712 domain separator is stored in the +contract's named keys: + +- The chain name is stored under the key `chain_name` with the type + `String`. + +#### Permit nonces + +Permit nonces are stored in the dictionary under the named key +`permit_nonces`. It is a key-value storage where: + +- The key is the account address of the token holder (`owner`). +- The value is the current nonce for that holder, as a `U256`. Holders + that have never used `permit` have no stored value; their nonce is + considered to be `0`. + +The key is created using the same algorithm as CEP-18 balance keys: + +- The account address is converted to bytes using standard `CLType` + encoding. +- The bytes are encoded to string using base64 encoding. + +An example permit-nonce key generation implementation: + +```rust +use base64::prelude::{Engine, BASE64_STANDARD}; + +fn permit_nonce_key(account: Key) -> String { + let preimage = account.to_bytes().unwrap(); + BASE64_STANDARD.encode(preimage) +} +``` + +Each successful `permit` call increments the stored nonce by one. The +nonce that must appear in the signed `Permit` struct is the value +present at the moment of signing (typically read off-chain immediately +before signing). + +## Existing implementations + +A reference implementation of CEP-2612 is available at: +- https://github.com/odradev/odra/blob/feature/gasless-op/modules/src/erc2612.rs (TODO: update after acceptance). From d41ed6e7301f2421f125932d629a4ad3f8205bac Mon Sep 17 00:00:00 2001 From: Maciej Zielinski Date: Sat, 16 May 2026 13:58:10 +0200 Subject: [PATCH 2/4] Updatades to David's comments --- text/2612-permit-extension.md | 53 ++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/text/2612-permit-extension.md b/text/2612-permit-extension.md index 5155ef4..08fb884 100644 --- a/text/2612-permit-extension.md +++ b/text/2612-permit-extension.md @@ -24,8 +24,8 @@ Casper](https://github.com/casper-ecosystem/casper-eip-712). ## Specification The CEP-2612 extension is defined by: -- one new entrypoint (`permit`), -- the EIP-712 typed data definition for the `Permit` struct, +- one new entry point (`permit`), +- the EIP-712 typed data definition for the `Permit` struct and its domain separator, - error codes, - storage structure for replay-protection nonces and the EIP-712 chain identifier. @@ -36,7 +36,7 @@ events are introduced by this CEP. Below definitions use Rust syntax, but they are not Rust specific. -### Entrypoint interface +### Entry point interface Contracts implementing this standard must expose the following entry point in addition to the CEP-18 interface: @@ -55,7 +55,7 @@ pub trait CEP2612Interface { /// disables the expiry check. /// - `public_key` is the signer's Casper public key. /// - `signature` is `public_key`'s signature over the EIP-712 digest - /// of the `Permit` typed data (see below). + /// of the `Permit` typed data. /// /// On success: /// - the allowance of `spender` over `owner` is set to `value` (i.e. @@ -67,10 +67,12 @@ pub trait CEP2612Interface { /// The call must revert: /// - with `PermitExpired` if `deadline != u64::MAX` and the current /// block time is strictly greater than `deadline`, + /// - with `InvalidPublicKey` if `owner` is not the `Address` derived + /// from `public_key` (see "Signature scheme" below), /// - with `InvalidSignature` if `signature` does not verify against /// `public_key` over the recomputed digest, - /// - with `InvalidSignature` if `owner` is not the `Address` derived - /// from `public_key` (see "Signature scheme" below). + /// - with `InvalidNonce` if the `nonce` is not exactly equal to + /// the one expected by the contract. /// /// The transaction caller (`env().caller()`) is irrelevant — `permit` /// is designed to be relayed by any third party. @@ -98,10 +100,10 @@ the signer from the signature, so this CEP takes an explicit To preserve the property that a permit signature only authorizes its own `owner`'s allowance, implementations MUST enforce: -1. `signature` is a valid signature of `public_key` over the EIP-712 - digest of the `Permit` typed data (described below), AND -2. `owner == Address::from(public_key)` — i.e. `owner` is the account - address corresponding to `public_key`. +1. `owner == Address::from(public_key)` — i.e. `owner` is the account + address corresponding to `public_key` AND +2. `signature` is a valid signature of `public_key` over the EIP-712 + digest of the `Permit` typed data (described below). Without (2) a third party could sign a digest containing an unrelated `owner` field with their own key and have the contract set that @@ -123,26 +125,30 @@ Its typehash is keccak256 of the type string above: PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9 ``` +`Address` encoding is defined as 33 bytes, where the first byte is a type tag: +- `0x00` for `AccountHash`, +- `0x01` for `PackageHash`. + The encoded message data hashed alongside the typehash is the -concatenation, in order, of: +concatenation of `CLValue` representations, in order, of: -- `owner` encoded as an EIP-712 `address` (32 bytes, big-endian, left-padded), -- `spender` encoded as an EIP-712 `address` (32 bytes, big-endian, left-padded), -- `value` encoded as an EIP-712 `uint256` (32 bytes, big-endian), -- `nonce` encoded as an EIP-712 `uint256` (32 bytes, big-endian) — equal +- `owner` encoded as an EIP-712 `address`, +- `spender` encoded as an EIP-712 `address`, +- `value` encoded as an EIP-712 `U256`, +- `nonce` encoded as an EIP-712 `U256` — equal to the current on-chain `permit_nonces[owner]` value at the time the signature was produced, -- `deadline` encoded as an EIP-712 `uint64` (32 bytes, big-endian, +- `deadline` encoded as an EIP-712 `U256` (32 bytes, big-endian, left-padded). The final digest is computed using the standard EIP-712 rule (`keccak256("\x19\x01" || domainSeparator || keccak256(typeHash || encodedData))`). -The EIP-712 `domainSeparator` uses: +The `domainSeparator` is defined as: - `name` — the CEP-18 token's `name`, -- `chainId` — the contract's `chain_name` (see Storage below); Casper - does not have a numeric chain id, so the chain name string is used in - its place, +- `chainId` — the contract's `chain_name` (see Storage below); It should be + in the [CAIP-2](https://github.com/ChainAgnostic/namespaces/blob/main/casper/caip2.md) + format, - `verifyingContract` — the contract's own address. The salt and version fields are not used. @@ -155,12 +161,15 @@ codes when the appropriate conditions are met: ```rust pub enum CEP2612Error { /// Either `signature` does not verify against `public_key` over the - /// recomputed digest, or `owner` is not the account address derived - /// from `public_key`. + /// recomputed digest. InvalidSignature = 36_000, /// The current block time is strictly greater than `deadline`, and /// `deadline` is not the sentinel `u64::MAX`. PermitExpired = 36_001, + /// The `nonce` is not exactly equal to the one expected by the contract. + InvalidNonce = 36_002, + /// `owner` is not the `Address` derived from `public_key`. + InvalidPublicKey = 36_003, } ``` From 1f3e94ce8210bcdbc27e9f664520b58cbb8b07e2 Mon Sep 17 00:00:00 2001 From: Maciej Zielinski Date: Sat, 23 May 2026 19:30:01 +0200 Subject: [PATCH 3/4] Updates to cep2612. --- text/2612-permit-extension.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/text/2612-permit-extension.md b/text/2612-permit-extension.md index 08fb884..8ccd210 100644 --- a/text/2612-permit-extension.md +++ b/text/2612-permit-extension.md @@ -105,9 +105,10 @@ To preserve the property that a permit signature only authorizes its own 2. `signature` is a valid signature of `public_key` over the EIP-712 digest of the `Permit` typed data (described below). -Without (2) a third party could sign a digest containing an unrelated -`owner` field with their own key and have the contract set that -`owner`'s allowance. Both checks should revert with `InvalidSignature`. +Without (2) a third party could sign a digest containing an unrelated `owner` +field with their own key and have the contract set that `owner`'s allowance. (1) +reverts with `InvalidPublicKey`, while (2) reverts with `InvalidSignature` if +not satisfied. ### Signed payload (EIP-712) @@ -127,18 +128,18 @@ PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d61 `Address` encoding is defined as 33 bytes, where the first byte is a type tag: - `0x00` for `AccountHash`, -- `0x01` for `PackageHash`. +- `0x01` for package's `Hash`. -The encoded message data hashed alongside the typehash is the -concatenation of `CLValue` representations, in order, of: +The encodeData value is the concatenation of the following EIP-712-encoded +fields, in order: - `owner` encoded as an EIP-712 `address`, - `spender` encoded as an EIP-712 `address`, -- `value` encoded as an EIP-712 `U256`, -- `nonce` encoded as an EIP-712 `U256` — equal +- `value` encoded as an EIP-712 `uint256`, +- `nonce` encoded as an EIP-712 `uint256` — equal to the current on-chain `permit_nonces[owner]` value at the time the signature was produced, -- `deadline` encoded as an EIP-712 `U256` (32 bytes, big-endian, +- `deadline` encoded as an EIP-712 `uint256` (32 bytes, big-endian, left-padded). The final digest is computed using the standard EIP-712 rule From ff6d22d16f29aa17715381ff3c7681d70a716491 Mon Sep 17 00:00:00 2001 From: Maciej Zielinski Date: Mon, 25 May 2026 09:52:01 +0200 Subject: [PATCH 4/4] Final remarks from David --- text/2612-permit-extension.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/text/2612-permit-extension.md b/text/2612-permit-extension.md index 8ccd210..55547d0 100644 --- a/text/2612-permit-extension.md +++ b/text/2612-permit-extension.md @@ -147,12 +147,17 @@ The final digest is computed using the standard EIP-712 rule The `domainSeparator` is defined as: - `name` — the CEP-18 token's `name`, -- `chainId` — the contract's `chain_name` (see Storage below); It should be +- `version` - the current major version of the signing domain, +- `chain_name` — the contract's `chain_name` (see Storage below); It should be in the [CAIP-2](https://github.com/ChainAgnostic/namespaces/blob/main/casper/caip2.md) format, -- `verifyingContract` — the contract's own address. +- `contract_package_hash` — the contract's own address. -The salt and version fields are not used. +Using `EIP-712` notation, the domain separator type string is: + +``` +EIP712Domain(string name,string version,string chain_name,bytes32 contract_package_hash) +``` ### Error codes