From 4596abfca9269b84cf3152e84937e5da3e6b3d45 Mon Sep 17 00:00:00 2001 From: Yelzhas Date: Sun, 1 Mar 2026 02:35:27 +0100 Subject: [PATCH 1/3] adds permit, erc-2612 like implementation, new types and helpers for off-chain signers; TODO: add view fns for nonces and domain separator --- contract/src/lib.rs | 63 ++++++++++++++++++++++++++++++++++++++++++++ types/src/account.rs | 25 ++++++++++++++++++ types/src/calls.rs | 19 +++++++++++++ types/src/lib.rs | 1 + 4 files changed, 108 insertions(+) diff --git a/contract/src/lib.rs b/contract/src/lib.rs index 7ac6d32..e874e20 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -23,6 +23,9 @@ extern crate alloc; mod drc20 { use alloc::collections::BTreeMap; use alloc::string::String; + use alloc::vec::Vec; + use dusk_core::BlsScalar; + use dusk_core::signatures::bls::PublicKey as BlsPublicKey; use dusk_core::abi; @@ -37,6 +40,7 @@ mod drc20 { TransferCall, TransferFromCall, ZERO_ADDRESS, + PermitCall, }; /// DRC20 contract state. @@ -45,6 +49,7 @@ mod drc20 { balances: BTreeMap, allowances: BTreeMap>, supply: u64, + permit_nonces: BTreeMap, } impl Drc20 { @@ -55,6 +60,7 @@ mod drc20 { balances: BTreeMap::new(), allowances: BTreeMap::new(), supply: 0, + permit_nonces: BTreeMap::new(), } } @@ -137,6 +143,37 @@ mod drc20 { // --- State changes --- + pub fn permit(&mut self, args: PermitCall) { + // Check expiry + // TODO: move hardcoded message to error module + assert!(abi::block_height() <= args.deadline, "permit expired"); + + let owner_account = Account::External(args.owner); + + let nonce = self.permit_nonces.get(&owner_account).copied().unwrap_or(0); + + let digest = build_permit_digest( + &domain_separator(), + &args.owner, + &args.spender, + args.value, + nonce, + args.deadline, + ); + + assert!(abi::verify_bls(digest, args.owner, args.signature), "invalid permit signature"); + + self.allowances.entry(owner_account).or_default().insert(args.spender, args.value); + + self.permit_noonces.insert(owner_account, nonce + 1); + + abi::emit(events::Approval::TOPIC, events::Approval { + owner: owner_account, + spender: args.spender, + value: args.value, + }); + } + /// Transfer from caller. pub fn transfer(&mut self, args: TransferCall) { let from = sender_account(); @@ -226,4 +263,30 @@ mod drc20 { Account::Contract(abi::caller().expect("missing caller")) } } + + fn domain_separator() -> BlsScalar { + let mut bytes = Vec::new(); + bytes.extend(abi::self_id().as_bytes()); + bytes.push(abi::chain_id()); + bytes.extend(b"DRC20Permit"); + abi::hash(bytes) + } + + fn build_permit_digest( + domain_sep: &BlsScalar, + owner: &BlsPublicKey, + spender: &Account, + value: u64, + nonce: u64, + deadline: u64, + ) -> Vec { + let mut bytes = Vec::new(); + bytes.extend(&domain_sep.to_bytes()); + bytes.extend(&owner.to_raw_bytes()); + bytes.extend(&spender.to_bytes()); + bytes.extend(&value.to_le_bytes()); + bytes.extend(&nonce.to_le_bytes()); + bytes.extend(&deadline.to_le_bytes()); + abi::hash(bytes).to_bytes().to_vec() + } } diff --git a/types/src/account.rs b/types/src/account.rs index 66234b5..23bbd47 100644 --- a/types/src/account.rs +++ b/types/src/account.rs @@ -1,5 +1,6 @@ use core::cmp::Ordering; +use alloc::vec::Vec; use bytecheck::CheckBytes; use dusk_core::abi::ContractId; use dusk_core::signatures::bls::PublicKey; @@ -52,3 +53,27 @@ impl Ord for Account { } } } + +impl Account { + /// Canonical byte serialization for digest construction. + /// + /// Format: `[discriminant: u8] ++ [inner_bytes]` + /// - External: `0x00 ++ public_key_raw_bytes (96 bytes)` + /// - Contract: `0x01 ++ contract_id_bytes (32 bytes)` + pub fn to_bytes(&self) -> Vec { + match self { + Account::External(pk) => { + let mut buf = Vec::with_capacity(1 + 96); + buf.push(0x00); + buf.extend_from_slice(&pk.to_raw_bytes()); + buf + } + Account::Contract(id) => { + let mut buf = Vec::with_capacity(1 + 32); + buf.push(0x01); + buf.extend_from_slice(id.as_bytes()); + buf + } + } + } +} diff --git a/types/src/calls.rs b/types/src/calls.rs index b362a8a..b67abc6 100644 --- a/types/src/calls.rs +++ b/types/src/calls.rs @@ -1,6 +1,7 @@ use alloc::vec::Vec; use bytecheck::CheckBytes; +use dusk_core::signatures::bls::{PublicKey as BlsPublicKey, Signature as BlsSignature}; use rkyv::{Archive, Deserialize, Serialize}; use crate::Account; @@ -83,3 +84,21 @@ pub struct TransferFromCall { #[cfg_attr(feature = "serde", serde(with = "crate::serde_u64"))] pub value: u64, } + +/// Input for `permit(PermitCall)`. +#[derive(Debug, Clone, PartialEq, Eq, Archive, Serialize, Deserialize)] +#[archive_attr(derive(CheckBytes))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PermitCall { + /// Token owner (signer). + pub owner: BlsPublicKey, + /// Spender being approved. + pub spender: Account, + /// Allowance amount. + #[cfg_attr(feature = "serde", serde(with = "crate::serde_u64"))] + pub value: u64, + /// Block height after which the permit expires. + pub deadline: u64, + /// BLS signature over the permit digest. + pub signature: BlsSignature, +} diff --git a/types/src/lib.rs b/types/src/lib.rs index 12b1b43..5bee8e8 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -24,6 +24,7 @@ pub use calls::{ InitBalance, TransferCall, TransferFromCall, + PermitCall, }; use dusk_core::abi::ContractId; From 780b55aa42ff8911b8b67fbd9e9a943b73aea5b6 Mon Sep 17 00:00:00 2001 From: Yelzhas Date: Mon, 2 Mar 2026 06:55:05 +0100 Subject: [PATCH 2/3] move permit digest helpers to drc20-types for off-chain reuse; --- contract/src/lib.rs | 20 ++++------- tests/src/lib.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++ types/src/lib.rs | 1 + types/src/permit.rs | 52 ++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 types/src/permit.rs diff --git a/contract/src/lib.rs b/contract/src/lib.rs index e874e20..fb14f53 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -32,6 +32,7 @@ mod drc20 { use drc20_types::{ error, events, + permit, Account, Allowance, ApproveCall, @@ -165,7 +166,7 @@ mod drc20 { self.allowances.entry(owner_account).or_default().insert(args.spender, args.value); - self.permit_noonces.insert(owner_account, nonce + 1); + self.permit_nonces.insert(owner_account, nonce + 1); abi::emit(events::Approval::TOPIC, events::Approval { owner: owner_account, @@ -265,11 +266,7 @@ mod drc20 { } fn domain_separator() -> BlsScalar { - let mut bytes = Vec::new(); - bytes.extend(abi::self_id().as_bytes()); - bytes.push(abi::chain_id()); - bytes.extend(b"DRC20Permit"); - abi::hash(bytes) + abi::hash(permit::domain_separator_bytes(&abi::self_id(), abi::chain_id())) } fn build_permit_digest( @@ -280,13 +277,8 @@ mod drc20 { nonce: u64, deadline: u64, ) -> Vec { - let mut bytes = Vec::new(); - bytes.extend(&domain_sep.to_bytes()); - bytes.extend(&owner.to_raw_bytes()); - bytes.extend(&spender.to_bytes()); - bytes.extend(&value.to_le_bytes()); - bytes.extend(&nonce.to_le_bytes()); - bytes.extend(&deadline.to_le_bytes()); - abi::hash(bytes).to_bytes().to_vec() + abi::hash(permit::permit_digest_bytes(domain_sep, owner, spender, value, nonce, deadline)) + .to_bytes() + .to_vec() } } diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 7e32e34..9739869 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -17,6 +17,8 @@ mod spec { use dusk_core::signatures::bls::{ PublicKey as AccountPublicKey, SecretKey as AccountSecretKey, }; + use dusk_core::BlsScalar; + use dusk_vm::host_queries::hash; use dusk_vm::ContractData; use rand::rngs::StdRng; @@ -25,12 +27,14 @@ mod spec { use drc20_types::{ error, events, + permit, Account, Allowance, ApproveCall, BalanceOf, Init, InitBalance, + PermitCall, TransferCall, TransferFromCall, ZERO_ADDRESS, @@ -43,6 +47,7 @@ mod spec { const TOKEN_ID: ContractId = ContractId::from_bytes([1; 32]); const CALLER_ID: ContractId = ContractId::from_bytes([2; 32]); + const CHAIN_ID: u8 = 0x1; const MOONLIGHT_BALANCE: u64 = dusk(1_000.0); const INITIAL_ALICE: u64 = 1_000; @@ -290,6 +295,48 @@ mod spec { .borrow_mut() .icc_transaction(sk, CALLER_ID, "call_transfer", &(token, to, value)) } + + fn permit( + &self, + sk: &AccountSecretKey, + owner: dusk_core::signatures::bls::PublicKey, + spender: Account, + value: u64, + deadline: u64, + signature: dusk_core::signatures::bls::Signature, + ) -> Result, ContractError> { + self.net + .borrow_mut() + .icc_transaction( + sk, + TOKEN_ID, + "permit", + &PermitCall { + owner, + spender, + value, + deadline, + signature, + }, + ) + } + } + + fn domain_separator() -> BlsScalar { + hash(permit::domain_separator_bytes(&TOKEN_ID, CHAIN_ID)) + } + + fn build_permit_digest( + domain_sep: &BlsScalar, + owner: &AccountPublicKey, + spender: &Account, + value: u64, + nonce: u64, + deadline: u64, + ) -> Vec { + hash(permit::permit_digest_bytes(domain_sep, owner, spender, value, nonce, deadline)) + .to_bytes() + .to_vec() } // --------------------------------------------------------------------- @@ -536,4 +583,41 @@ mod spec { } assert!(seen, "expected a transfer event from the caller contract"); } + + #[test] + fn panic_if_permit_expired() { + let ctx = TestContext::new(); + + let spender = ctx.bob(); + let value: u64 = 100; + let nonce: u64 = 0; + // block height 1 > deadline 0 -> should panic + let deadline: u64 = 0; + + let digest = build_permit_digest( + &domain_separator(), + &ctx.pk_alice, + &spender, + value, + nonce, + deadline, + ); + + let sig = ctx.sk_alice.sign(&digest); + + let receipt = ctx.permit( + &ctx.sk_alice, + ctx.pk_alice, + spender, + value, + deadline, + sig, + ); + + if let ContractError::Panic(msg) = receipt.unwrap_err() { + assert_eq!(msg, "permit expired"); + } else { + panic!("Expected a panic error"); + } + } } diff --git a/types/src/lib.rs b/types/src/lib.rs index 5bee8e8..6b52f5c 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -14,6 +14,7 @@ pub mod account; pub mod calls; pub mod error; pub mod events; +pub mod permit; pub use account::Account; pub use calls::{ diff --git a/types/src/permit.rs b/types/src/permit.rs new file mode 100644 index 0000000..bdb93da --- /dev/null +++ b/types/src/permit.rs @@ -0,0 +1,52 @@ +//! Permit digest helpers. +//! +//! These functions produce the canonical byte sequences that must be hashed +//! (via `abi::hash` on-chain, or Poseidon off-chain) to build a permit digest. +//! Keeping the layout here guarantees that the contract, data-driver, and any +//! off-chain signer always agree on the same encoding. + +use alloc::vec::Vec; +use dusk_core::abi::ContractId; +use dusk_core::signatures::bls::PublicKey as BlsPublicKey; +use dusk_core::BlsScalar; + +use crate::Account; + +/// Raw bytes whose hash is the EIP-712-style domain separator for permits. +/// +/// `hash(domain_separator_bytes(contract_id, chain_id))` produces the +/// `BlsScalar` domain separator used inside [`permit_digest_bytes`]. +pub fn domain_separator_bytes( + contract_id: &ContractId, + chain_id: u8, +) -> Vec { + let mut buf = Vec::new(); + buf.extend(contract_id.as_bytes()); + buf.push(chain_id); + buf.extend(b"DRC20Permit"); + buf +} + +/// Raw bytes whose hash is the permit digest that the owner signs. +/// +/// `domain_sep` must be the **hashed** domain separator, i.e. +/// `hash(domain_separator_bytes(...))`. +/// +/// The resulting digest is `hash(permit_digest_bytes(...)).to_bytes()`. +pub fn permit_digest_bytes( + domain_sep: &BlsScalar, + owner: &BlsPublicKey, + spender: &Account, + value: u64, + nonce: u64, + deadline: u64, +) -> Vec { + let mut buf = Vec::new(); + buf.extend(&domain_sep.to_bytes()); + buf.extend(&owner.to_raw_bytes()); + buf.extend(&spender.to_bytes()); + buf.extend(&value.to_le_bytes()); + buf.extend(&nonce.to_le_bytes()); + buf.extend(&deadline.to_le_bytes()); + buf +} From 400f095ed920b129a29f802c6990f68fb8b65253 Mon Sep 17 00:00:00 2001 From: Yelzhas Date: Tue, 3 Mar 2026 18:48:17 +0100 Subject: [PATCH 3/3] add new views matching eip-2612 like impl; add more tests; polish --- README.md | 3 + contract/src/lib.rs | 30 +++++++--- tests/src/lib.rs | 134 +++++++++++++++++++++++++++++++++++++++++++- types/src/error.rs | 6 ++ 4 files changed, 163 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4c2955f..6e9f183 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,15 @@ A minimal ERC20-like fungible token reference implementation for the DuskDS netw - `total_supply() -> u64` - `balance_of(BalanceOf) -> u64` - `allowance(Allowance) -> u64` +- `permit_nonces(Owner) -> u64` +- `domain_separator() -> BlsScalar` ### State-changing - `transfer(TransferCall)` - `approve(ApproveCall)` - `transfer_from(TransferFromCall)` +- `permit(PermitCall)` ### Events diff --git a/contract/src/lib.rs b/contract/src/lib.rs index fb14f53..2698f4b 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -124,6 +124,16 @@ mod drc20 { // --- Views --- + /// Get the nonce for a given owner. + pub fn permit_nonces(&self, owner: Account) -> u64 { + self.permit_nonces.get(&owner).copied().unwrap_or(0) + } + + /// Get the (hashed) domain separator for the contract. + pub fn domain_separator() -> BlsScalar { + abi::hash(permit::domain_separator_bytes(&abi::self_id(), abi::chain_id())) + } + /// Total supply. pub fn total_supply(&self) -> u64 { self.supply @@ -145,16 +155,18 @@ mod drc20 { // --- State changes --- pub fn permit(&mut self, args: PermitCall) { - // Check expiry - // TODO: move hardcoded message to error module - assert!(abi::block_height() <= args.deadline, "permit expired"); + assert!( + abi::block_height() <= args.deadline, + "{}", + error::PERMIT_EXPIRED + ); let owner_account = Account::External(args.owner); let nonce = self.permit_nonces.get(&owner_account).copied().unwrap_or(0); let digest = build_permit_digest( - &domain_separator(), + &Self::domain_separator(), &args.owner, &args.spender, args.value, @@ -162,7 +174,11 @@ mod drc20 { args.deadline, ); - assert!(abi::verify_bls(digest, args.owner, args.signature), "invalid permit signature"); + assert!( + abi::verify_bls(digest, args.owner, args.signature), + "{}", + error::INVALID_PERMIT_SIGNATURE + ); self.allowances.entry(owner_account).or_default().insert(args.spender, args.value); @@ -265,10 +281,6 @@ mod drc20 { } } - fn domain_separator() -> BlsScalar { - abi::hash(permit::domain_separator_bytes(&abi::self_id(), abi::chain_id())) - } - fn build_permit_digest( domain_sep: &BlsScalar, owner: &BlsPublicKey, diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 9739869..6c10d6c 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -240,6 +240,22 @@ mod spec { .data } + fn permit_nonces(&self, owner: Account) -> u64 { + self.net + .borrow_mut() + .direct_call::(TOKEN_ID, "permit_nonces", &owner) + .expect("permit_nonces() call should succeed") + .data + } + + fn contract_domain_separator(&self) -> BlsScalar { + self.net + .borrow_mut() + .direct_call::<(), BlsScalar>(TOKEN_ID, "domain_separator", &()) + .expect("domain_separator() call should succeed") + .data + } + // --- State changes --- fn transfer( @@ -615,9 +631,125 @@ mod spec { ); if let ContractError::Panic(msg) = receipt.unwrap_err() { - assert_eq!(msg, "permit expired"); + assert_eq!(msg, error::PERMIT_EXPIRED); + } else { + panic!("Expected a panic error"); + } + } + + #[test] + fn panic_if_permit_signature_is_invalid() { + let ctx = TestContext::new(); + + let spender = ctx.bob(); + let value: u64 = 100; + let nonce: u64 = 0; + let deadline: u64 = 100; + + let digest = build_permit_digest( + &domain_separator(), + &ctx.pk_alice, + &spender, + value, + nonce, + deadline, + ); + + // Original digest owner is Alice, but we sign with Bob's key. + let sig = ctx.sk_bob.sign(&digest); + + // gas payer can be anyone, we use Alice for simplicity + let receipt = ctx.permit( + &ctx.sk_alice, + ctx.pk_alice, + spender, + value, + deadline, + sig, + ); + + if let ContractError::Panic(msg) = receipt.unwrap_err() { + assert_eq!(msg, error::INVALID_PERMIT_SIGNATURE); } else { panic!("Expected a panic error"); } } + + #[test] + fn permit_works_with_valid_signature() { + let ctx = TestContext::new(); + + let spender = ctx.bob(); + let value: u64 = 100; + let nonce: u64 = 0; + let deadline: u64 = 100; + + let digest = build_permit_digest( + &domain_separator(), + &ctx.pk_alice, + &spender, + value, + nonce, + deadline, + ); + + let sig = ctx.sk_alice.sign(&digest); + + let receipt = ctx.permit( + &ctx.sk_alice, + ctx.pk_alice, + spender, + value, + deadline, + sig, + ).expect("permit should succeed"); + + assert_eq!(ctx.allowance(ctx.alice(), ctx.bob()), value); + } + + #[test] + fn permit_nonces_is_zero_for_new_owner_and_increments_after_permit() { + let ctx = TestContext::new(); + + assert_eq!(ctx.permit_nonces(ctx.alice()), 0); + assert_eq!(ctx.permit_nonces(ctx.bob()), 0); + + let spender = ctx.bob(); + let value: u64 = 50; + let nonce: u64 = 0; + let deadline: u64 = 100; + + let digest = build_permit_digest( + &domain_separator(), + &ctx.pk_alice, + &spender, + value, + nonce, + deadline, + ); + let sig = ctx.sk_alice.sign(&digest); + + ctx.permit( + &ctx.sk_alice, + ctx.pk_alice, + spender, + value, + deadline, + sig, + ) + .expect("permit should succeed"); + + assert_eq!(ctx.permit_nonces(ctx.alice()), 1); + assert_eq!(ctx.permit_nonces(ctx.bob()), 0); + } + + #[test] + fn domain_separator_view_matches_expected() { + let ctx = TestContext::new(); + + let from_contract = ctx.contract_domain_separator(); + let expected = domain_separator(); + + assert_eq!(from_contract.to_bytes(), expected.to_bytes()); + } } diff --git a/types/src/error.rs b/types/src/error.rs index f784070..1e21d18 100644 --- a/types/src/error.rs +++ b/types/src/error.rs @@ -17,3 +17,9 @@ pub const SHIELDED_NOT_SUPPORTED: &str = "DRC20: shielded transactions are not s /// The reserved `ZERO_ADDRESS` must not be used as a recipient/spender/holder. pub const ZERO_ADDRESS_NOT_ALLOWED: &str = "DRC20: zero address not allowed"; + +/// The permit has expired (current block height exceeds the permit deadline). +pub const PERMIT_EXPIRED: &str = "DRC20: permit expired"; + +/// The permit signature is invalid (digest or signer mismatch). +pub const INVALID_PERMIT_SIGNATURE: &str = "DRC20: invalid permit signature";