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 7ac6d32..2698f4b 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -23,12 +23,16 @@ 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; use drc20_types::{ error, events, + permit, Account, Allowance, ApproveCall, @@ -37,6 +41,7 @@ mod drc20 { TransferCall, TransferFromCall, ZERO_ADDRESS, + PermitCall, }; /// DRC20 contract state. @@ -45,6 +50,7 @@ mod drc20 { balances: BTreeMap, allowances: BTreeMap>, supply: u64, + permit_nonces: BTreeMap, } impl Drc20 { @@ -55,6 +61,7 @@ mod drc20 { balances: BTreeMap::new(), allowances: BTreeMap::new(), supply: 0, + permit_nonces: BTreeMap::new(), } } @@ -117,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 @@ -137,6 +154,43 @@ mod drc20 { // --- State changes --- + pub fn permit(&mut self, args: PermitCall) { + 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( + &Self::domain_separator(), + &args.owner, + &args.spender, + args.value, + nonce, + args.deadline, + ); + + 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); + + self.permit_nonces.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 +280,17 @@ mod drc20 { Account::Contract(abi::caller().expect("missing caller")) } } + + fn build_permit_digest( + domain_sep: &BlsScalar, + owner: &BlsPublicKey, + spender: &Account, + value: u64, + nonce: u64, + deadline: u64, + ) -> 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..6c10d6c 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; @@ -235,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( @@ -290,6 +311,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 +599,157 @@ 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, 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/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/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"; diff --git a/types/src/lib.rs b/types/src/lib.rs index 12b1b43..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::{ @@ -24,6 +25,7 @@ pub use calls::{ InitBalance, TransferCall, TransferFromCall, + PermitCall, }; use dusk_core::abi::ContractId; 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 +}