Docs · Tutorials
Build a token that passes 8 of 8
A complete ERC-20 with a fixed tax, annotated against each check, and the local test that tells you before you submit.
This is the fixture the worker's own tests use. It is deliberately plain. Every line that exists because of a check is marked.
The contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Clean {
string public name = "Clean";
string public symbol = "CLEAN";
uint8 public constant decimals = 18;
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
mapping(address => bool) public isExcludedFromFee; // 04: read only to pick a fee, never in a require
address public immutable treasury; // matching: immutables are blanked on both sides
uint256 public constant taxBps = 300; // 03: constant, no setter, no cap needed
bool public tradingOpen; // 05: one-way switch
address public owner;
constructor(address _treasury) {
treasury = _treasury;
owner = msg.sender;
_mint(msg.sender, 1_000_000_000e18); // 01: the only mint, in the constructor
isExcludedFromFee[msg.sender] = true;
}
function openTrading() external { require(msg.sender == owner); tradingOpen = true; } // 05: only ever set to true
function excludeFromFee(address a, bool v) external { require(msg.sender == owner); isExcludedFromFee[a] = v; }
function totalSupply() external view returns (uint256) { return _totalSupply; }
function balanceOf(address a) external view returns (uint256) { return _balances[a]; }
function allowance(address o, address s) external view returns (uint256) { return _allowances[o][s]; }
function approve(address s, uint256 v) external returns (bool) { _allowances[msg.sender][s] = v; return true; }
function transfer(address to, uint256 v) external returns (bool) { _transfer(msg.sender, to, v); return true; } // 02: from the caller
function transferFrom(address f, address to, uint256 v) external returns (bool) {
uint256 a = _allowances[f][msg.sender];
require(a >= v, "allowance"); // 02: allowance spent before moving
_allowances[f][msg.sender] = a - v;
_transfer(f, to, v);
return true;
}
function _transfer(address f, address to, uint256 v) internal {
require(tradingOpen || f == owner, "not open"); // 05: guard reads the switch; blocks when false
require(_balances[f] >= v, "balance");
uint256 fee = isExcludedFromFee[f] ? 0 : (v * taxBps) / 10000; // 03: denominator as a literal
_balances[f] -= v;
_balances[to] += v - fee;
if (fee > 0) _balances[treasury] += fee;
}
function _mint(address to, uint256 v) internal { _totalSupply += v; _balances[to] += v; }
}
No proxy (06), no selfdestruct (08). Deployed and held, a fresh address can receive and send back (07).
What each check sees
| Check | Verdict | Evidence |
|---|---|---|
| 01 | pass | Minting happens in the constructor only. |
| 02 | pass | Balances move only from the caller, or with an allowance. |
| 03 | pass | taxBps cannot be changed after deployment. |
| 04 | pass | No per-address switch in the transfer path. |
| 05 | pass | tradingOpen can only be switched to let transfers through. |
| 06 | pass | No DELEGATECALL in the runtime. |
| 07 | not run | Until deployed and held. |
| 08 | pass | No SELFDESTRUCT in the runtime. |
Things people add that break it
- A
mintfor "future rewards": fails 01. Mint the rewards now to a locked contract. setTax(uint256)withoutrequire(x <= 1000): fails 03.mapping(address => bool) botsin arequire: fails 04. Use a time window and a max size instead.pause()/unpause(): fails 05. If you must, accept the 7 of 8.- A
rescueTokens(address from)on the token's own balances: fails 02. Rescue other tokens sent to the contract, not your own holders.
Test before you submit
cd zkcheck/worker && pnpm install # the worker source, once you have it
cp -r /path/to/your/project test/fixtures/mine
Then in test/rules.test.ts, add a test that compiles "mine" and prints runStatic(...). pnpm test shows the eight answers in seconds, with the same evidence sentences the seal would show.