DEV Community

Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on

Using tokenization to control a soroban voting smart-contract

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
}
Enter fullscreen mode Exit fullscreen mode
  • 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)

}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
 }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)