Self-burn authenticates the caller as the account being burned. Delegated
burn (burnFrom) authenticates the caller as the spender and enforces a
prior allowance. Both use the witness-derived keypair pattern from
Access Control .
The Contract
pragma language_version >= 0.23;
import CompactStandardLibrary;
struct UserSecretKey { bytes: Bytes<32>; }
struct UserPublicKey { bytes: Bytes<32>; }
witness getUserSecret(): UserSecretKey;
export ledger _balances: Map<UserPublicKey, Uint<128>>;
export ledger _allowances: Map<UserPublicKey, Map<UserPublicKey, Uint<128>>>;
export ledger _totalSupply: Uint<128>;
export circuit deriveUserPublicKey(sk: UserSecretKey): UserPublicKey {
return UserPublicKey {
bytes: persistentHash<Vector<2, Bytes<32>>>([
pad(32, "myapp:user:v1"),
sk.bytes
])
};
}
export circuit getTotalSupply(): Uint<128> {
return _totalSupply;
}
// Burn your own tokens — the caller proves they hold the secret behind
// the account whose balance is being burned.
export circuit burn(amount: Uint<128>): [] {
const account = disclose(deriveUserPublicKey(getUserSecret()));
_burn(account, amount);
}
// Burn tokens from another account using an allowance.
// The caller (spender) authenticates via their own witness secret;
// the allowance from `account` to that spender is decremented.
export circuit burnFrom(account: UserPublicKey, amount: Uint<128>): [] {
const spender = disclose(deriveUserPublicKey(getUserSecret()));
_spendAllowance(account, spender, amount);
_burn(account, amount);
}
// Internal: burn tokens (assumes caller authentication happened above)
circuit _burn(account: UserPublicKey, amount: Uint<128>): [] {
const currentBalance = getBalance(account);
assert(currentBalance >= amount, "Burn amount exceeds balance");
assert(_totalSupply >= amount, "Burn amount exceeds total supply");
_balances.insert(disclose(account), disclose(currentBalance - amount));
_totalSupply = disclose(_totalSupply - amount);
}
// Internal: read & decrement allowance (with infinite-approval shortcut)
circuit _spendAllowance(
owner: UserPublicKey,
spender: UserPublicKey,
amount: Uint<128>
): [] {
const current = allowance(owner, spender);
const MAX_UINT128: Uint<128> = 340282366920938463463374607431768211455 as Uint<128>;
if (current < MAX_UINT128) {
assert(current >= amount, "Insufficient allowance");
_approve(owner, spender, current - amount);
}
}
// Internal: write the allowance entry
circuit _approve(
owner: UserPublicKey,
spender: UserPublicKey,
amount: Uint<128>
): [] {
if (!_allowances.member(disclose(owner))) {
_allowances.insert(disclose(owner), default<Map<UserPublicKey, Uint<128>>>);
}
_allowances.lookup(disclose(owner)).insert(disclose(spender), disclose(amount));
}
// Read remaining allowance
export circuit allowance(owner: UserPublicKey, spender: UserPublicKey): Uint<128> {
if (!_allowances.member(disclose(owner))) { return 0; }
if (!_allowances.lookup(disclose(owner)).member(disclose(spender))) { return 0; }
return _allowances.lookup(disclose(owner)).lookup(disclose(spender));
}
// Helper
circuit getBalance(account: UserPublicKey): Uint<128> {
if (!_balances.member(disclose(account))) { return 0; }
return _balances.lookup(disclose(account));
}
How It Works
Self-burn authenticates the burner
export circuit burn(amount: Uint<128>): [] {
const account = disclose(deriveUserPublicKey(getUserSecret()));
_burn(account, amount);
}
The caller proves knowledge of the secret behind the account whose balance is
being burned. Reading the chain gives an attacker the hash of the secret,
not the secret itself.
burnFrom enforces an allowance
export circuit burnFrom(account: UserPublicKey, amount: Uint<128>): [] {
const spender = disclose(deriveUserPublicKey(getUserSecret()));
_spendAllowance(account, spender, amount);
_burn(account, amount);
}
Same idea as transferFrom but the destination is the burn (no to
parameter). The spender must have a non-zero _allowances[account][spender]
entry of at least amount.
Invariants
currentBalance >= amount — caller can’t burn more than they hold.
_totalSupply >= amount — defensive check; should always hold given the
previous invariant, but cheap to assert.
Both writes happen in sequence so sum(balances) == _totalSupply stays
true.
Privacy Note
Each burn writes account and amount to the public ledger. The
account-model design is deliberately transparent. For private deflationary
mechanisms, use shielded primitives where amounts and identities stay in
ZK-committed UTXOs.
What’s Next
Minting Tokens Learn how to create new tokens
ERC20 Token Complete token with burning