Introduction
A few months ago, I pushed an smart-contract to my github account which allows users to participate on a ballot process. That contract have the following features (among others):
- Voter cannot vote twice.
- Voter can delegate its vote.
- Voter who has delegated its vote cannot vote.
- Voter who has delegated votes cannot delegate its vote.
You can check the contract code here.
To control this rules, the contract saves the votes and the delegated votes on the storage to check them later.
The new contract
Recently, I've pushed a new version of this contract but, in this case, the vote delegation control and the voting allowance is controlled using a token (a contract which acts as a token). Concretely, the voting contract use the token to control the following:
- Voting delegation.
- Voting allowance.
In the next sections we will see the token contract code and how the new voting contract differs from the old.
The token
Through this section, we will delve into the contract code and will learn what's the goal of each function.
This token does not completely implements the soroban token interface since we do not need all functions.
Let's start by the beginning:
#![no_std]
use soroban_sdk::{contract, contracttype, contractimpl, contracterror, symbol_short, Address, Env, Symbol};
pub const TOKEN_ADMIN: Symbol = symbol_short!("t_admin");
pub const DAY_IN_LEDGERS: u32 = 17280;
pub const INSTANCE_BUMP_AMOUNT: u32 = 7 * DAY_IN_LEDGERS;
pub const INSTANCE_LIFETIME_THRESHOLD: u32 = INSTANCE_BUMP_AMOUNT - DAY_IN_LEDGERS;
pub const BALANCE_BUMP_AMOUNT: u32 = 30 * DAY_IN_LEDGERS;
pub const BALANCE_LIFETIME_THRESHOLD: u32 = BALANCE_BUMP_AMOUNT - DAY_IN_LEDGERS;
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
TokenAlreadyInitialized = 1,
AddressAlreadyHoldsToken = 2,
AddressDoesNotHoldToken = 3,
AddressAlreadyHasAllowance = 4,
ExpirationLedgerLessThanCurrentLedger = 5
}
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Balance(Address),
Allowance(Address),
Blocking(Address)
}
fn has_admin(e: &Env) -> bool {
let has_admin = e.storage().instance().has(&TOKEN_ADMIN);
has_admin
}
fn get_balance(e: &Env, addr: Address)-> u32 {
let key = DataKey::Balance(addr);
if let Some(b) = e.storage().persistent().get::<DataKey, u32>(&key) {
e.storage()
.persistent()
.extend_ttl(&key, BALANCE_LIFETIME_THRESHOLD, BALANCE_BUMP_AMOUNT);
return b;
}
0
}
fn get_allowance(e: &Env, addr: Address) -> bool {
let allowance_key = DataKey::Allowance(addr);
if let Some(_a) = e.storage().temporary().get::<_, Address>(&allowance_key) {
return true;
}
false
}
fn get_blocking(e: &Env, addr: Address) -> bool {
let blocking_key = DataKey::Blocking(addr);
if let Some(_b) = e.storage().temporary().get::<_, Address>(&blocking_key) {
return true;
}
false
}
- The Error enum contains all the possible errors that the token contract can return.
- The Datakey enum contains three Address variants:
- Balance: For the balance keys
- Allowance: For the allowance keys
- Blocking: For the blocking keys
- The has_admin function checks if the contract has and admin address
- The get_balance function checks whether the address holds the token. If so, it extends the address balance key ttl and returns the balance. Otherwise returns 0.
- The get_allowance function checks whether the address has been allowed to spend other address balance.
- The get_blocking function checks whether the address has allowed to another address to spend its balance.
The token functions
Let's analyze now the contract functions:
Initialize function
pub fn initialize(e: Env, admin: Address) -> Result<bool, Error> {
if has_admin(&e) {
return Err(Error::TokenAlreadyInitialized);
}
e.storage().instance().set(&TOKEN_ADMIN, &admin);
Ok(true)
}
The initialize function checks whether the contract has an admin or not. If so, it returns a TokenAlreadyInitialized error. Otherwise it stores the address received as an admin and returns Ok.
Mint function
pub fn mint(e: Env, addr: Address) -> Result<u32, Error> {
let admin: Address = e.storage().instance().get(&TOKEN_ADMIN).unwrap();
admin.require_auth();
if get_balance(&e, addr.clone()) > 0 {
return Err(Error::AddressAlreadyHoldsToken);
}
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
let key = DataKey::Balance(addr.clone());
let amount: u32 = 1;
e.storage().persistent().set(&key, &amount);
Ok(amount)
}
The mint function mints the user with new tokens. Minting is only allowed for the contract admin. Each address can only have one token so, if the current address balance is greater than 0, it returns an AddressAlreadyHoldsToken error.
If the address does not hold the token yet, the function creates the key for the address and sets the amount to 1 for the key and stores it. Then it returns the amount.
Get balance function
pub fn balance(e: Env, addr: Address) -> u32 {
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
let b: u32 = get_balance(&e, addr);
b
}
The balance function returns the current address balance.
Transfer function
pub fn transfer(e: Env, from: Address, to: Address) -> Result<bool, Error> {
from.require_auth();
if get_balance(&e, from.clone()) == 0 {
return Err(Error::AddressDoesNotHoldToken);
}
if get_balance(&e, to.clone()) > 0 {
return Err(Error::AddressAlreadyHoldsToken);
}
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
let from_key = DataKey::Balance(from.clone());
let to_key = DataKey::Balance(to.clone());
let amount: u32 = 1;
e.storage().persistent().remove(&from_key);
e.storage().persistent().set(&to_key, &amount);
e.storage()
.persistent()
.extend_ttl(&to_key, BALANCE_LIFETIME_THRESHOLD, BALANCE_BUMP_AMOUNT);
Ok(true)
}
The transfer function transfers the token between two addresses. The function requires from address to authorize the transfer. The function also requires that "from" address holds a token and "to" address does not.
Remember that only one token per address is allowed.
If all requirements match, the function proceeds following the next steps:
- Creates the "from" and "to" balance keys.
- Removes the "from" key.
- Sets the amount for "to" key to 1.
- Extends the "to" balance key ttl.
Approve function
pub fn approve(e: Env, from: Address, spender: Address, expiration: u32) -> Result<bool, Error> {
from.require_auth();
if expiration < e.ledger().sequence(){
return Err(Error::ExpirationLedgerLessThanCurrentLedger);
}
if get_blocking(&e, from.clone()) {
return Err(Error::AddressAlreadyHasAllowance);
}
if get_allowance(&e, spender.clone()) {
return Err(Error::AddressAlreadyHasAllowance);
}
if get_balance(&e, from.clone()) < 1 {
return Err(Error::AddressDoesNotHoldToken);
}
if get_balance(&e, spender.clone()) < 1 {
return Err(Error::AddressDoesNotHoldToken);
}
let allowance_key = DataKey::Allowance(spender.clone());
let blocking_key = DataKey::Blocking(from.clone());
e.storage().temporary().set(&allowance_key, &from);
e.storage().temporary().set(&blocking_key, &spender);
let live_for = expiration
.checked_sub(e.ledger().sequence())
.unwrap()
;
e.storage().temporary().extend_ttl(&allowance_key, live_for, live_for);
e.storage().temporary().extend_ttl(&blocking_key, live_for, live_for);
Ok(true)
}
This function allows spender to use the token which belongs to from address. Before setting the allowance, the function checks the following rules:
- from must not have allowed to another spender.
- spender must no have been allowed by another address.
- Both from and spender must hold token balance.
If these rules match, the function creates the allowance key for the spender address and the blocking key for the from address. Then it saves the keys and extends ttl based on the expiration value.
As we will see later, this function will be used by an address to delegate its vote to another one.
Allowance function
pub fn allowance(e: &Env, from: Address) -> bool {
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
let allowance = get_allowance(&e, addr);
allowance
}
The allowance function simply checks if the address has an allowance.
Blocking function
pub fn blocking(e: &Env, from: Address) -> bool {
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
let blocking = get_blocking(e, addr);
blocking
}
The blocking function simply checks if the address has a blocking, that is, is has allowed to another address.
Burn function
pub fn burn(e: Env, addr: Address) {
let admin: Address = e.storage().instance().get(&TOKEN_ADMIN).unwrap();
admin.require_auth();
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
let from_key = DataKey::Balance(addr);
e.storage().persistent().remove(&from_key);
}
The burn function removes the address token by removing its balance key.
Using the token to control the ballot
Let's see now the key differences between the old contract and new one where the token is used to control some voting features.
You can see the new contract complete code here. The old contract is code is located here
Checking address can vote
In the first version of the contract there wasn't any check before checking dates and the admin had to authorize the vote:
admin.require_auth();
if !check_dates(&env) {
return Err(Error::BallotOutOfDate);
}
Now, the voter address must authorize the vote and the voter must hold enough balance to vote.
voter.require_auth();
let token = storage::get_token(&env);
let tk = token::Client::new(&env, &token);
if tk.balance(&voter) < 1 {
return Err(Error::VoterDoesNotHoldToken);
}
if !check_dates(&env) {
return Err(Error::BallotOutOfDate);
}
Checking address can delegate
Just like the case in the previous section, the old contract's delegate function did not check anything before checking dates and was the admin address who had to authorize the function.
admin.require_auth();
if !check_dates(&env) {
return Err(Error::BallotOutOfDate);
}
Now, The voter who wants to delegate its vote is who authorize the function and both addresses must hold the token.
o_voter.require_auth();
let token = storage::get_token(&env);
let tk = token::Client::new(&env, &token);
if tk.balance(&o_voter) < 1 {
return Err(Error::VoterDoesNotHoldToken);
}
if tk.balance(&d_voter) < 1 {
return Err(Error::VoterDoesNotHoldToken);
}
if !check_dates(&env) {
return Err(Error::BallotOutOfDate);
}
Delegate a vote
The old contract had to save a voter delegated votes on a Vector:
let mut d_vot_delegs: Vec<Symbol> = storage::get_voter_delegated_votes(&env, &d_voter);
d_vot_delegs.push_back(o_voter.clone());
storage::update_voter_delegated_votes(&env, d_voter, d_vot_delegs);
Now, the voter who wants to delegate its vote has to approve the another address to vote on its name.
let expiration_ledger = (((config.to - config.from) / 5) + 60) as u32; // 5 seconds for every ledger. Add 5 extra minutes
tk.approve(&o_voter, &d_voter, &expiration_ledger);
The function which checks whether an address has its vote delegated
The old contract saved on the storage an address delegated votes and then it checked the storage to check it.
fn is_delegated(&self, env: &Env) -> bool {
let dvts: Vec<Symbol> = storage::get_delegated_votes(env);
dvts.contains(self.id)
}
Now, the contract checks if the address token is blocked (token blocking function) which means that this address has approved another to vote in its name.
fn is_delegated(&self, env: &Env) -> bool {
let token = storage::get_token(&env);
let tk = token::Client::new(&env, &token);
if tk.blocking(&self.id) {
return true
}
false
}
The function which checks whether an address has delegated votes
Again, just like the case in the previous section, the old contract checked the storage to check if there were delegated votes for an address.
fn has_delegated_votes(&self, env: &Env) -> bool {
let dvotes = storage::get_voter_delegated_votes(env, self.id);
if dvotes.len() > 0 {
return true;
}
false
}
The new contract checks if another address has allowed (using the token approve function) the current address to vote for it.
fn has_delegated_vote(&self, env: &Env) -> bool {
let token = storage::get_token(&env);
let tk = token::Client::new(&env, &token);
if tk.allowance(&self.id) {
return true
}
false
}
The vote
In the old contract, the vote function gets the address delegated votes vector and saves its vote plus the total of its delegated votes.
let d_votes: Vec<Symbol> = storage::get_voter_delegated_votes(&env, v.id);
let count = 1 + d_votes.len() + storage::get_candidate_votes_count(&env, &candidate_key);
The new contract uses the has_delegated_vote function (which internally uses the token allowance function) to check whether the address has a vote delegated. If so, it adds an extra vote.
let mut d_votes = 0;
if v.has_delegated_vote(&env) {
d_votes = 1;
}
At this point, we can notice that we have lost a feature compared to the old contract. The old contract allows an address to have more than one vote delegated. The new one doesn't since the an address can only hold one token.
The token could be modified to allow addresses to hold more tokens and improve the vote delegation system.
Conclusion
In this post, we have learned that tokenization can be used in many contexts not only finance. There are standards both on soroban (token interface) and ethereum (ERC-20) but we can also create and build tokens to fit custom needs.
we have to take into account the supply process, that is, how many tokens we are going to put into circulation. In this case, it can exist one token for each community member.
Top comments (0)