Skip to main content
All balances and transfers are transparent. Each _balances[id][owner] entry and every transfer write reveals (token id, sender, recipient, amount). Anyone reading the chain can reconstruct holdings of every token type. Use this for game items and other transparent use cases — not for private finance.
Authentication uses witness-derived keypairs. See Access Control for why ownPublicKey() cannot be used here.

The Contract

pragma language_version >= 0.23;

import CompactStandardLibrary;

// User keypair (witness-derived)
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;

export ledger _admin: AdminPublicKey;

// Balances: tokenId → (owner → amount)
export ledger _balances: Map<Uint<128>, Map<UserPublicKey, Uint<128>>>;

// Operator approvals (all-or-nothing across every token type)
export ledger _operatorApprovals: Map<UserPublicKey, Map<UserPublicKey, Boolean>>;

// Per-tokenId metadata URIs
export ledger _uris: Map<Uint<128>, Opaque<"string">>;

constructor(initialAdmin: AdminPublicKey) {
    _admin = disclose(initialAdmin);
}

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

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

// Balance of `account` for token id `id`
export circuit balanceOf(account: UserPublicKey, id: Uint<128>): Uint<128> {
    if (!_balances.member(disclose(id))) { return 0; }
    if (!_balances.lookup(disclose(id)).member(disclose(account))) { return 0; }
    return _balances.lookup(disclose(id)).lookup(disclose(account));
}

// Operator approval — caller authenticates as `owner`
export circuit setApprovalForAll(operator: UserPublicKey, approved: Boolean): [] {
    const owner = disclose(deriveUserPublicKey(getUserSecret()));
    _setApprovalForAll(owner, operator, approved);
}

export circuit isApprovedForAll(account: UserPublicKey, operator: UserPublicKey): Boolean {
    if (_operatorApprovals.member(disclose(account)) &&
        _operatorApprovals.lookup(disclose(account)).member(disclose(operator))) {
        return _operatorApprovals.lookup(disclose(account)).lookup(disclose(operator));
    }
    return false;
}

// Transfer — caller must be `fromKey` itself, or an approved operator
export circuit transfer(
    fromKey: UserPublicKey,
    to: UserPublicKey,
    id: Uint<128>,
    value: Uint<128>
): [] {
    // Disclose both sides of the comparison up front. `fromKey` will be
    // disclosed anyway by `_debit` below, so this changes no privacy
    // properties — but it lets the compiler see that the boolean branch
    // is on public values.
    const fromD = disclose(fromKey);
    const callerD = disclose(deriveUserPublicKey(getUserSecret()));
    assert(fromD == callerD || isApprovedForAll(fromKey, callerD), "Not authorized");
    _update(fromKey, to, id, value);
}

// Read token URI
export circuit uri(tokenId: Uint<128>): Opaque<"string"> {
    if (!_uris.member(disclose(tokenId))) { return default<Opaque<"string">>; }
    return _uris.lookup(disclose(tokenId));
}

// Admin: set the URI for a token id
export circuit setURI(id: Uint<128>, newuri: Opaque<"string">): [] {
    assertAdmin();
    _uris.insert(disclose(id), disclose(newuri));
}

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

// Admin: burn (forceful — admin can burn any holder's tokens)
export circuit adminBurn(fromKey: UserPublicKey, id: Uint<128>, value: Uint<128>): [] {
    assertAdmin();
    _debit(fromKey, id, value);
}

// Self-burn — caller authenticates as the holder
export circuit burn(id: Uint<128>, value: Uint<128>): [] {
    const account = disclose(deriveUserPublicKey(getUserSecret()));
    _debit(account, id, value);
}

// Internals

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

circuit _setApprovalForAll(
    owner: UserPublicKey,
    operator: UserPublicKey,
    approved: Boolean
): [] {
    assert(owner != operator, "Cannot approve self");
    if (!_operatorApprovals.member(disclose(owner))) {
        _operatorApprovals.insert(disclose(owner), default<Map<UserPublicKey, Boolean>>);
    }
    _operatorApprovals.lookup(disclose(owner)).insert(disclose(operator), disclose(approved));
}

circuit _update(
    fromKey: UserPublicKey,
    to: UserPublicKey,
    id: Uint<128>,
    value: Uint<128>
): [] {
    _debit(fromKey, id, value);
    _credit(to, id, value);
}

circuit _credit(account: UserPublicKey, id: Uint<128>, value: Uint<128>): [] {
    const MAX_UINT128: Uint<128> = 340282366920938463463374607431768211455 as Uint<128>;
    const current = balanceOf(account, id);
    assert(current <= MAX_UINT128 - value, "Balance overflow");

    if (!_balances.member(disclose(id))) {
        _balances.insert(disclose(id), default<Map<UserPublicKey, Uint<128>>>);
    }
    _balances.lookup(disclose(id)).insert(
        disclose(account),
        disclose((current + value) as Uint<128>)
    );
}

circuit _debit(account: UserPublicKey, id: Uint<128>, value: Uint<128>): [] {
    const current = balanceOf(account, id);
    assert(current >= value, "Insufficient balance");
    _balances.lookup(disclose(id)).insert(disclose(account), disclose(current - value));
}

How It Works

ERC1155 supports many token types in one contract. Each token id has its own balance Map.

Storage layout

_balances: Map<Uint<128>, Map<UserPublicKey, Uint<128>>>
  • Outer key: token id (e.g. 1 = Gold, 100 = Legendary Sword).
  • Inner map: per-holder balance for that token id.
This makes lookups O(1) and lets balances of different token types be managed independently.

Mixed token types

  • Fungible: mint(alice, 1, 1000) — 1000 copies of token id 1.
  • Non-Fungible: mint(bob, 100, 1) — single edition of token id 100.
  • Semi-Fungible: mint(carol, 50, 10) — 10 of token id 50.
A common convention is to reserve id ranges (e.g. 1..999 for currency, 1000..9999 for items, 10000+ for legendaries).

Authorization

transfer(fromKey, to, id, value):
  • Either the caller authenticates as fromKey directly, or
  • The caller authenticates as an operator that fromKey has approved via setApprovalForAll.
Note from is a Compact reserved keyword, so the parameter is named fromKey.

Admin mint / burn / URI

mint, adminBurn, and setURI are admin-only. Wrap them in your own business logic for richer policies (per-id supply caps, mint windows, delegated minter roles).

Batch operations

Compact does not yet support dynamic arrays, so ERC1155 batch operations (balanceOfBatch, safeBatchTransferFrom) are not implementable verbatim. For now, callers must invoke single-id operations sequentially.

Self-approval guard

assert(owner != operator, "Cannot approve self");
Self-approval is rejected — it’s a no-op (the owner can already move their own tokens) and usually indicates a UI bug.

Try It Yourself

1. Create project structure:
mkdir my-game-items && cd my-game-items
mkdir -p contracts
2. Save the contract at contracts/game-items.compact.3. Define your token types and mint policy in a wrapper contract or your DApp. The admin pubkey is the constructor argument.4. Compile:
compact compile contracts/game-items.compact contracts/managed/game-items

What’s Next

ERC20 Token

Create fungible tokens

ERC721 NFT

Build non-fungible tokens