Skip to main content
This is an account-model token. Balances and transfers are visible on chain. Every transfer publishes (sender, recipient, amount). The participant graph is fully observable.Use this pattern for transparent registries (game economies, public reward programs) — not for private finance. For private payments, use Midnight’s native shielded primitives (sendShielded, mintShieldedToken, etc.) which keep balances and counterparties in ZK-committed UTXOs.
Authentication uses witness-derived keypairs, not ownPublicKey(). See Access Control for the rationale and Transfer for the building block this contract uses.

The Contract

pragma language_version >= 0.23;

import CompactStandardLibrary;

// Caller keypair (witness-derived) — used to authenticate users
struct UserSecretKey { bytes: Bytes<32>; }
struct UserPublicKey { bytes: Bytes<32>; }
witness getUserSecret(): UserSecretKey;

// Admin keypair (witness-derived) — gates mint and burn
struct AdminSecretKey { bytes: Bytes<32>; }
struct AdminPublicKey { bytes: Bytes<32>; }
witness getAdminSecret(): AdminSecretKey;

// Token metadata (sealed: settable only in the constructor)
export sealed ledger _name: Opaque<"string">;
export sealed ledger _symbol: Opaque<"string">;
export sealed ledger _decimals: Uint<8>;

// On-chain admin and ledger state
export ledger _admin: AdminPublicKey;
export ledger _balances: Map<UserPublicKey, Uint<128>>;
export ledger _allowances: Map<UserPublicKey, Map<UserPublicKey, Uint<128>>>;
export ledger _totalSupply: Uint<128>;

constructor(
    initialAdmin: AdminPublicKey,
    name_: Opaque<"string">,
    symbol_: Opaque<"string">,
    decimals_: Uint<8>
) {
    _admin = disclose(initialAdmin);
    _name = disclose(name_);
    _symbol = disclose(symbol_);
    _decimals = disclose(decimals_);
}

// Key derivation
export circuit deriveUserPublicKey(sk: UserSecretKey): UserPublicKey {
    return UserPublicKey {
        bytes: persistentHash<Vector<2, Bytes<32>>>([
            pad(32, "erc20:user:v1"),
            sk.bytes
        ])
    };
}

export circuit deriveAdminPublicKey(sk: AdminSecretKey): AdminPublicKey {
    return AdminPublicKey {
        bytes: persistentHash<Vector<2, Bytes<32>>>([
            pad(32, "erc20:admin:v1"),
            sk.bytes
        ])
    };
}

// Metadata readers
export circuit name(): Opaque<"string"> { return _name; }
export circuit symbol(): Opaque<"string"> { return _symbol; }
export circuit decimals(): Uint<8> { return _decimals; }
export circuit totalSupply(): Uint<128> { return _totalSupply; }

export circuit balanceOf(account: UserPublicKey): Uint<128> {
    if (!_balances.member(disclose(account))) { return 0; }
    return _balances.lookup(disclose(account));
}

// Transfer — caller authenticates as the sender
export circuit transfer(to: UserPublicKey, value: Uint<128>): Boolean {
    const owner = disclose(deriveUserPublicKey(getUserSecret()));
    _transfer(owner, to, value);
    return true;
}

// Approve — caller authenticates as the owner
export circuit approve(spender: UserPublicKey, value: Uint<128>): Boolean {
    const owner = disclose(deriveUserPublicKey(getUserSecret()));
    _approve(owner, spender, value);
    return true;
}

// 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));
}

// transferFrom — caller authenticates as the spender
export circuit transferFrom(
    owner: UserPublicKey,
    to: UserPublicKey,
    value: Uint<128>
): Boolean {
    const spender = disclose(deriveUserPublicKey(getUserSecret()));
    _spendAllowance(owner, spender, value);
    _transfer(owner, to, value);
    return true;
}

// Admin: mint
export circuit mint(account: UserPublicKey, value: Uint<128>): [] {
    assertAdmin();
    _mint(account, value);
}

// Admin: burn (forceful)
export circuit adminBurn(account: UserPublicKey, value: Uint<128>): [] {
    assertAdmin();
    _burn(account, value);
}

// Self-burn: caller burns their own tokens
export circuit burn(value: Uint<128>): [] {
    const account = disclose(deriveUserPublicKey(getUserSecret()));
    _burn(account, value);
}

// Internals
circuit assertAdmin(): [] {
    assert(_admin == deriveAdminPublicKey(getAdminSecret()), "Not admin");
}

circuit _transfer(
    sender: UserPublicKey,
    to: UserPublicKey,
    value: Uint<128>
): [] {
    const MAX_UINT128: Uint<128> = 340282366920938463463374607431768211455 as Uint<128>;
    const fromBal = balanceOf(sender);
    assert(fromBal >= value, "Insufficient balance");
    const toBal = balanceOf(to);
    assert(toBal <= MAX_UINT128 - value, "Balance overflow");
    _balances.insert(disclose(sender), disclose(fromBal - value));
    _balances.insert(disclose(to), disclose((toBal + value) as Uint<128>));
}

circuit _approve(
    owner: UserPublicKey,
    spender: UserPublicKey,
    value: Uint<128>
): [] {
    if (!_allowances.member(disclose(owner))) {
        _allowances.insert(disclose(owner), default<Map<UserPublicKey, Uint<128>>>);
    }
    _allowances.lookup(disclose(owner)).insert(disclose(spender), disclose(value));
}

circuit _spendAllowance(
    owner: UserPublicKey,
    spender: UserPublicKey,
    value: Uint<128>
): [] {
    const current = allowance(owner, spender);
    const MAX_UINT128: Uint<128> = 340282366920938463463374607431768211455 as Uint<128>;
    if (current < MAX_UINT128) {
        assert(current >= value, "Insufficient allowance");
        _approve(owner, spender, current - value);
    }
}

circuit _mint(account: UserPublicKey, value: Uint<128>): [] {
    const MAX_UINT128: Uint<128> = 340282366920938463463374607431768211455 as Uint<128>;
    assert(_totalSupply <= MAX_UINT128 - value, "Total supply overflow");
    _totalSupply = disclose((_totalSupply + value) as Uint<128>);
    const bal = balanceOf(account);
    assert(bal <= MAX_UINT128 - value, "Balance overflow");
    _balances.insert(disclose(account), disclose((bal + value) as Uint<128>));
}

circuit _burn(account: UserPublicKey, value: Uint<128>): [] {
    const bal = balanceOf(account);
    assert(bal >= value, "Insufficient balance");
    assert(_totalSupply >= value, "Total supply underflow");
    _balances.insert(disclose(account), disclose(bal - value));
    _totalSupply = disclose(_totalSupply - value);
}

How It Works

This ERC20 implementation combines the patterns from the basics tutorials. For detailed explanations, follow the linked sub-tutorials.

Authentication

Every state-changing circuit authenticates the caller by proving knowledge of a witness secret whose persistentHash equals a stored public key:
  • transfer, approve, burn → caller authenticates as a UserPublicKey
  • transferFrom → caller authenticates as the spender
  • mint, adminBurn → caller authenticates as the admin
See Access Control for why ownPublicKey() cannot be used here.

Initialization

constructor(
    initialAdmin: AdminPublicKey,
    name_: Opaque<"string">,
    symbol_: Opaque<"string">,
    decimals_: Uint<8>
)
The constructor seeds _admin and the sealed metadata fields (_name, _symbol, _decimals). sealed ledger fields can only be written from the constructor, so they cannot be modified after deployment — exactly the “set once, read forever” semantics ERC20 metadata wants.

Total supply invariant

_totalSupply = disclose((_totalSupply + value) as Uint<128>);
sum(all balances) == _totalSupply is preserved: mint increases both, _burn decreases both, _transfer preserves both. See Overflow Protection.

Why no isKeyOrAddressZero / burnAddress helpers?

UserPublicKey is a Bytes<32> hash of a witness secret. There is no canonical “zero address” — the burn happens by deducting from a balance without crediting it elsewhere (_burn), and admin authority is enforced via the keypair check, not by checking for the default value.

Privacy trade-off

The keypair scheme authenticates the caller but does not hide them. _balances stores UserPublicKey → amount; every write reveals which key moved and by how much. For genuinely private tokens, use Midnight’s shielded primitives — they’re built on UTXOs and ZK commitments and don’t expose the participant graph.

Try It Yourself

1. Create project structure:
mkdir my-token && cd my-token
mkdir -p contracts
2. Save the contract at contracts/my-token.compact.3. Compile:
compact compile contracts/my-token.compact contracts/managed/my-token
4. Deploy with the four constructor arguments (admin pubkey, name, symbol, decimals). The witness side is wired by your DApp:
  • getAdminSecret() — the admin’s private Bytes<32> (kept in DApp state)
  • getUserSecret() — each user’s private Bytes<32>
  • initialAdmin: AdminPublicKey — computed off chain as persistentHash([pad(32, "erc20:admin:v1"), adminSecretBytes])

What’s Next

NFT (ERC721)

Create unique, non-fungible tokens

Multi-Token (ERC1155)

Manage multiple token types in one contract