Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 67 additions & 0 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -37,6 +41,7 @@ mod drc20 {
TransferCall,
TransferFromCall,
ZERO_ADDRESS,
PermitCall,
};

/// DRC20 contract state.
Expand All @@ -45,6 +50,7 @@ mod drc20 {
balances: BTreeMap<Account, u64>,
allowances: BTreeMap<Account, BTreeMap<Account, u64>>,
supply: u64,
permit_nonces: BTreeMap<Account, u64>,
}

impl Drc20 {
Expand All @@ -55,6 +61,7 @@ mod drc20 {
balances: BTreeMap::new(),
allowances: BTreeMap::new(),
supply: 0,
permit_nonces: BTreeMap::new(),
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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<u8> {
abi::hash(permit::permit_digest_bytes(domain_sep, owner, spender, value, nonce, deadline))
.to_bytes()
.to_vec()
}
}
216 changes: 216 additions & 0 deletions tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,12 +27,14 @@ mod spec {
use drc20_types::{
error,
events,
permit,
Account,
Allowance,
ApproveCall,
BalanceOf,
Init,
InitBalance,
PermitCall,
TransferCall,
TransferFromCall,
ZERO_ADDRESS,
Expand All @@ -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;
Expand Down Expand Up @@ -235,6 +240,22 @@ mod spec {
.data
}

fn permit_nonces(&self, owner: Account) -> u64 {
self.net
.borrow_mut()
.direct_call::<Account, u64>(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(
Expand Down Expand Up @@ -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<dusk_vm::CallReceipt<()>, 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<u8> {
hash(permit::permit_digest_bytes(domain_sep, owner, spender, value, nonce, deadline))
.to_bytes()
.to_vec()
}

// ---------------------------------------------------------------------
Expand Down Expand Up @@ -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());
}
}
Loading