Token Systems
Proving conservation laws, mint/burn accounting, and transfer safety for token contracts.
Token systems are among the most critical smart contracts to verify. A bug that creates tokens from nothing or destroys them incorrectly can have catastrophic financial consequences. Sector9 provides powerful tools to prove these properties statically.
If you want module-owned token types and verified cross-canister calls, see Digital Asset System Overview.
For exported actor methods, put caller-controlled guards in both entry_requires
and requires: entry_requires is the runtime entry check, while requires is
the logical verifier precondition. A public method with nontrivial requires
but no entry_requires is rejected in Viper mode.
Conservation Laws
The fundamental property of any token system is conservation: tokens cannot be created or destroyed except through explicit mint and burn operations. You express this using persistent state and invariants.
Basic Conservation Pattern
Track total deposits and withdrawals with persistent variables that survive upgrades:
// Basic token conservation pattern
persistent actor {
var balance : Nat = 0;
var totalMinted : Nat = 0;
var totalBurned : Nat = 0;
// Conservation invariant: minted tokens are either live or burned
invariant balance + totalBurned == totalMinted;
public func mint(amount : Nat) : async ()
modifies balance, totalMinted
reads totalBurned
entry_requires amount > 0;
requires amount > 0;
ensures balance == old(balance) + amount;
ensures totalMinted == old(totalMinted) + amount;
{
balance += amount;
totalMinted += amount;
};
public func burn(amount : Nat) : async ()
modifies balance, totalBurned
reads totalMinted
entry_requires amount > 0;
entry_requires amount <= balance;
requires amount > 0;
requires amount <= balance;
ensures balance == old(balance) - amount;
ensures totalBurned == old(totalBurned) + amount;
{
balance -= amount;
totalBurned += amount;
};
}
The invariant balance + totalBurned == totalMinted states the conservation law without relying on a Nat subtraction in the invariant itself: every minted token is either live or burned. The verifier checks this after every public function using the same actor invariant preservation rule as any other persistent state.
Why Persistent State?
Variables like totalSupply, totalMinted, totalBurned, and balances are essential runtime data that must:
- Persist across canister upgrades
- Be queryable by users and other canisters
- Accurately reflect the actual token accounting
These should always be regular persistent variables, not ghost state. Ghost state is appropriate for verification-only quantities that don't need to exist at runtime.
Mint and Burn Operations
Minting creates new tokens; burning destroys them. Both operations update the accounting state to maintain conservation.
Mint Pattern
// Token minting pattern with supply tracking
persistent actor {
var balance : Nat = 0;
var totalSupply : Nat = 0;
invariant balance == totalSupply;
public func mint(amount : Nat) : async ()
modifies balance, totalSupply
entry_requires amount > 0;
entry_requires amount <= 1_000_000;
requires amount > 0;
requires amount <= 1_000_000; // Reasonable upper bound
ensures balance == old(balance) + amount;
ensures totalSupply == old(totalSupply) + amount;
{
balance += amount;
totalSupply += amount;
};
public func getSupply() : async Nat
reads balance
ensures result == balance;
{
balance
};
}
The postcondition ensures totalSupply == old(totalSupply) + amount proves that minting increases supply by exactly the minted amount. Because verified arithmetic runs in checked mode, production mint APIs should also bound amount when a fixed-width or business cap matters. The old(totalSupply) expression is the method-entry snapshot described in old expressions.
Burn Pattern
// Token burning pattern with supply tracking
persistent actor {
var balance : Nat = 100;
var totalSupply : Nat = 100;
invariant balance == totalSupply;
public func burn(amount : Nat) : async ()
modifies balance, totalSupply
entry_requires amount > 0;
entry_requires amount <= balance;
requires amount > 0;
requires amount <= balance; // Cannot burn more than exists
ensures balance == old(balance) - amount;
ensures totalSupply == old(totalSupply) - amount;
{
balance -= amount;
totalSupply -= amount;
};
public func getSupply() : async Nat
reads balance
ensures result == balance;
{
balance
};
}
The entry_requires amount <= balance guard is the runtime check that prevents
burning more than exists. The matching requires clause is the logical
precondition used in the proof. The postcondition proves supply decreases by
exactly the burned amount.
Sector9 may still warn that a Nat subtraction can trap; that warning is a frontend caution. In the Viper lane, the matching requires amount <= balance is the proof obligation that makes the subtraction safe. See numeric verification for bounds and underflow proofs.
Transfer Safety
Transfers move tokens between accounts without changing total supply. The minimal example below uses two runtime balances; production multi-account ledgers should use runtime collections plus proof models.
// Token transfer pattern with two runtime balances
persistent actor {
var senderBalance : Nat = 1000;
var receiverBalance : Nat = 0;
public func transfer(amount : Nat) : async ()
modifies senderBalance, receiverBalance
entry_requires amount > 0;
entry_requires amount <= senderBalance;
requires amount > 0;
requires amount <= senderBalance;
ensures senderBalance == old(senderBalance) - amount;
ensures receiverBalance == old(receiverBalance) + amount;
{
senderBalance := senderBalance - amount;
receiverBalance := receiverBalance + amount;
};
}
The postconditions prove:
- Sender balance decreases by exactly
amount - Receiver balance increases by exactly
amount - No tokens are created or destroyed in the transfer
For a real ledger, add a conservation invariant over all balances or maintain a ghost summary that mirrors the runtime ledger. The two-balance example keeps the arithmetic local so the transfer proof is visible.
Multi-Account Conservation
For systems with multiple accounts, runtime balances should live in runtime
collections. Spec collections (Map, Set, Seq, Multiset) are useful as
ghost models for conservation proofs, but they are proof-only and cannot be the
deployed account database. A common pattern is: update the runtime collection,
update the ghost model in a ghost {} block, and prove the two stay related.
See spec collections and
collection restrictions
before using a proof model for account balances.
AMM Liquidity Tokens
Automated Market Makers (AMMs) issue LP (liquidity provider) tokens when users deposit assets. The LP supply must track deposits correctly.
Minting LP Tokens
When users add liquidity, they receive LP tokens proportional to their contribution:
public func addLiquidity(user : Nat, dx : Nat, dy : Nat) : async Nat
modifies pools, lpBalances, balancesX, balancesY
entry_requires dx > 0 and dy > 0;
entry_requires hasPool(pools, pid);
entry_requires balanceX(balancesX, user) >= dx;
entry_requires balanceY(balancesY, user) >= dy;
requires dx > 0 and dy > 0;
requires hasPool(pools, pid);
requires balanceX(balancesX, user) >= dx;
requires balanceY(balancesY, user) >= dy;
ensures lpSupply(pools, pid) == old(lpSupply(pools, pid)) + result;
ensures lpBalance(lpBalances, pid, user) == old(lpBalance(lpBalances, pid, user)) + result;
{
let minted = calcMinted(p.lpSupply, p.reserveX, p.reserveY, dx, dy);
// ... update state ...
minted
}
Burning LP Tokens
When users remove liquidity, their LP tokens are burned and they receive proportional assets:
public func removeLiquidity(user : Nat, lp : Nat) : async (Nat, Nat)
modifies pools, lpBalances, balancesX, balancesY
entry_requires lp > 0;
entry_requires lpBalance(lpBalances, pid, user) >= lp;
requires lp > 0;
requires lpBalance(lpBalances, pid, user) >= lp;
ensures lpSupply(pools, pid) == old(lpSupply(pools, pid)) - lp;
ensures lpBalance(lpBalances, pid, user) == old(lpBalance(lpBalances, pid, user)) - lp;
{
let dx = p.reserveX * lp / p.lpSupply;
let dy = p.reserveY * lp / p.lpSupply;
// ... update state ...
(dx, dy)
}
Backing Invariants
For wrapped tokens or stablecoins, prove that circulating supply never exceeds backing reserves:
invariant totalReserves >= circulatingSupply;
This guarantees 1:1 (or better) backing at all times.
Common Mistakes
Missing Preconditions
Always guard against impossible operations:
// Wrong - can burn more than exists
public func burn(amount : Nat) : async () {
balance -= amount; // Underflow possible
}
// Correct - precondition prevents underflow
public func burn(amount : Nat) : async ()
entry_requires amount <= balance;
requires amount <= balance;
{
balance -= amount;
}
Incomplete Footprints
List all modified fields in the modifies clause:
// Wrong - missing field in modifies
public func mint(amount : Nat) : async () modifies balance {
balance += amount;
totalMinted += amount; // Error: totalMinted not in modifies
}
// Correct - all fields listed
public func mint(amount : Nat) : async () modifies balance, totalMinted {
balance += amount;
totalMinted += amount;
}
Summary
- Use persistent variables to track token accounting (totalMinted, totalBurned, totalSupply, balances) - these survive upgrades
- Express conservation laws as invariants:
balance + totalBurned == totalMinted - Mint operations increase supply; prove with
ensures totalSupply == old(totalSupply) + amount - Burn operations decrease supply; guard public APIs with
entry_requires amount <= balanceplus the matching logicalrequires - Transfers preserve total supply; prove sender decreases and receiver increases by the same amount
- Use runtime collection state for multi-account systems, with ghost
Map/Setmodels for proofs when useful - Link token authorization to access control and async-sensitive flows to async verification