Skip to main content
This is a transparent NFT contract. Every mint, transfer, and approval publishes the involved keys, token IDs, and (where applicable) URIs to the public ledger. The full ownership graph and provenance history is observable.For NFTs where ownership must stay private, use Midnight’s shielded primitives — they’re built on UTXOs and ZK commitments and don’t leak the ownership graph.
Authentication uses witness-derived keypairs, not ownPublicKey(). See Access Control for the rationale.

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;

// Metadata (sealed: settable only in the constructor)
export sealed ledger _name: Opaque<"string">;
export sealed ledger _symbol: Opaque<"string">;

// Admin
export ledger _admin: AdminPublicKey;

// Ownership maps
//   _owners[tokenId] = owner pubkey
//   _balances[owner] = how many NFTs that owner holds
export ledger _owners: Map<Uint<128>, UserPublicKey>;
export ledger _balances: Map<UserPublicKey, Uint<128>>;

// Approvals
//   _tokenApprovals[tokenId] = pubkey approved for that one token
//   _operatorApprovals[owner][operator] = operator can manage all of owner's NFTs
export ledger _tokenApprovals: Map<Uint<128>, UserPublicKey>;
export ledger _operatorApprovals: Map<UserPublicKey, Map<UserPublicKey, Boolean>>;

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

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

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

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

// Metadata
export circuit name(): Opaque<"string"> { return _name; }
export circuit symbol(): Opaque<"string"> { return _symbol; }

export circuit ownerOf(tokenId: Uint<128>): UserPublicKey {
    return _requireOwned(tokenId);
}

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

export circuit tokenURI(tokenId: Uint<128>): Opaque<"string"> {
    _requireOwned(tokenId);
    if (!_tokenURIs.member(disclose(tokenId))) { return default<Opaque<"string">>; }
    return _tokenURIs.lookup(disclose(tokenId));
}

// Approve a single token for transfer by `to`
export circuit approve(to: UserPublicKey, tokenId: Uint<128>): [] {
    const auth = disclose(deriveUserPublicKey(getUserSecret()));
    _approve(to, tokenId, auth);
}

export circuit getApproved(tokenId: Uint<128>): UserPublicKey {
    _requireOwned(tokenId);
    return _getApproved(tokenId);
}

// Approve operator for all of caller's NFTs
export circuit setApprovalForAll(operator: UserPublicKey, approved: Boolean): [] {
    const owner = disclose(deriveUserPublicKey(getUserSecret()));
    _setApprovalForAll(owner, operator, approved);
}

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

// Transfer — caller must be owner, approved-for-token, or approved-for-all
export circuit transferFrom(
    fromKey: UserPublicKey,
    to: UserPublicKey,
    tokenId: Uint<128>
): [] {
    const caller = disclose(deriveUserPublicKey(getUserSecret()));
    const previousOwner = _update(to, tokenId, caller);
    assert(previousOwner == fromKey, "Incorrect owner");
}

// Admin: mint a new NFT
export circuit mint(to: UserPublicKey, tokenId: Uint<128>): [] {
    assertAdmin();
    assert(!_owners.member(disclose(tokenId)), "Token already minted");
    _balances.insert(disclose(to), disclose((balanceOf(to) + 1) as Uint<128>));
    _owners.insert(disclose(tokenId), disclose(to));
}

// Admin: burn an NFT
export circuit adminBurn(tokenId: Uint<128>): [] {
    assertAdmin();
    const owner = _requireOwned(tokenId);
    // clear single-token approval
    if (_tokenApprovals.member(disclose(tokenId))) {
        _tokenApprovals.remove(disclose(tokenId));
    }
    _balances.insert(disclose(owner), disclose(_balances.lookup(disclose(owner)) - 1));
    _owners.remove(disclose(tokenId));
}

// Set the URI of a token you own
export circuit setTokenURI(tokenId: Uint<128>, uri: Opaque<"string">): [] {
    const caller = disclose(deriveUserPublicKey(getUserSecret()));
    const owner = _requireOwned(tokenId);
    assert(owner == caller, "Only owner may set URI");
    _tokenURIs.insert(disclose(tokenId), disclose(uri));
}

// Internals

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

circuit _requireOwned(tokenId: Uint<128>): UserPublicKey {
    assert(_owners.member(disclose(tokenId)), "Token does not exist");
    return _owners.lookup(disclose(tokenId));
}

circuit _getApproved(tokenId: Uint<128>): UserPublicKey {
    if (!_tokenApprovals.member(disclose(tokenId))) {
        return UserPublicKey { bytes: pad(32, "") };
    }
    return _tokenApprovals.lookup(disclose(tokenId));
}

circuit _approve(to: UserPublicKey, tokenId: Uint<128>, auth: UserPublicKey): [] {
    const owner = _requireOwned(tokenId);
    assert(owner == auth || isApprovedForAll(owner, auth), "Not authorized");
    _tokenApprovals.insert(disclose(tokenId), disclose(to));
}

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

circuit _update(to: UserPublicKey, tokenId: Uint<128>, auth: UserPublicKey): UserPublicKey {
    const fromKey = _requireOwned(tokenId);
    _checkAuthorized(fromKey, auth, tokenId);
    // clear single-token approval
    if (_tokenApprovals.member(disclose(tokenId))) {
        _tokenApprovals.remove(disclose(tokenId));
    }
    // adjust balances
    _balances.insert(
        disclose(fromKey),
        disclose(_balances.lookup(disclose(fromKey)) - 1)
    );
    if (!_balances.member(disclose(to))) {
        _balances.insert(disclose(to), 0);
    }
    _balances.insert(
        disclose(to),
        disclose((_balances.lookup(disclose(to)) + 1) as Uint<128>)
    );
    _owners.insert(disclose(tokenId), disclose(to));
    return fromKey;
}

circuit _checkAuthorized(owner: UserPublicKey, spender: UserPublicKey, tokenId: Uint<128>): [] {
    assert(
        owner == spender
            || isApprovedForAll(owner, spender)
            || _getApproved(tokenId) == spender,
        "Not authorized"
    );
}

How It Works

This ERC721 implementation provides standard non-fungible token functionality with witness-derived caller authentication.

Dual ownership view

  • _owners: tokenId → owner (who owns this specific NFT)
  • _balances: owner → count (how many NFTs that owner holds)
Both maps are kept in sync inside _update, mint, and adminBurn. Keeping the count denormalized lets balanceOf answer in O(1) without iterating _owners.

Authorization layers

Three ways to be authorized to move a token:
  1. You are the owner: owner == caller.
  2. The owner has approved you for this specific token: _getApproved(tokenId) == caller.
  3. The owner has approved you as operator for all their NFTs: isApprovedForAll(owner, caller).
_checkAuthorized evaluates all three; the union is the standard ERC721 authorization rule.

Admin mint / burn

mint and adminBurn are admin-only — they wrap assertAdmin() around the underlying state mutation. To delegate mint authority (e.g. to a separate “minter” role), follow the role-based pattern from Access Control.

Token URI

setTokenURI(tokenId, uri) is callable by the current owner. The URI is typically a pointer to off-chain metadata (IPFS, Arweave, HTTPS) and is stored as Opaque<"string"> since the contract doesn’t need to read its bytes.
{
  "name": "Cool Cat #42",
  "image": "ipfs://QmX.../cat42.png",
  "attributes": [{ "trait_type": "Color", "value": "Blue" }]
}

What’s not here

  • safeTransferFrom and onERC721Received — Compact has no contract-to-contract call yet, so the unsafe-vs-safe distinction is moot.
  • ERC721 events — Compact has no event/log primitive. Off-chain indexers must read state directly.
  • Enumerable extension — Compact has no iteration over Map keys; if you need tokenOfOwnerByIndex, track an auxiliary list explicitly.

Try It Yourself

1. Create project structure:
mkdir my-nft-collection && cd my-nft-collection
mkdir -p contracts
2. Save the contract at contracts/my-nft.compact.3. Compile:
compact compile contracts/my-nft.compact contracts/managed/my-nft
4. Deploy with (adminPublicKey, name, symbol). The DApp supplies getAdminSecret() and getUserSecret() witnesses.

What’s Next

ERC20 Token

Create fungible tokens

Multi-Token (ERC1155)

Manage multiple token types