DEV Community

Cover image for Contract-state accounting vs UTXO tokens: two models for onchain value on Midnight
Harrie
Harrie

Posted on

Contract-state accounting vs UTXO tokens: two models for onchain value on Midnight

Midnight gives you two ways to represent value in a contract. Most tutorials pick one and move on. This one covers both: what the real compiler-verified API looks like, where each model breaks, and why the UTXO path has more gotchas than it appears.

Short version: UTXO tokens for privacy and real transferability. Ledger-state accounting for queryable bookkeeping. Both, when your contract needs both.

All three contracts here compile against the latest Compact compiler. Verified CI run: IamHarrie-Labs/compact-token-guide


Two separate systems

Midnight runs a public ledger and a shielded Zswap layer. Not two views of the same data. Genuinely separate systems with different guarantees.

Counter and Map fields live on the public ledger. Every insert, every increment is readable by anyone watching the chain. You can query balances, enumerate holders, build conditional logic on accumulated state. You cannot hide amounts.

Zswap is a zero-knowledge Merkle tree. Tokens committed into it have their amounts, owners, and transfer history hidden. You can't ask "how much does Alice hold?" from inside a contract. The contract has no view into individual UTXO holdings. What it can do: mint coins, verify a coin is valid, send coins to recipients.

Pick the wrong model and you'll have balances everyone can read when they shouldn't, or a contract that can't answer "how much does this user have?" That's often the entire point of the contract.


Ledger-state accounting

Ledger-state accounting uses Counter and Map fields to track values inside the contract. Think of it as an on-chain database where your contract is the only writer.

pragma language_version >= 0.20;
import CompactStandardLibrary;

export ledger totalPoints: Counter;
export ledger userPoints: Map<Bytes<32>, Uint<64>>;

witness getAdminSecret(): Bytes<32>;

circuit adminKey(): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>([
        pad(32, "accounting:admin:v1"),
        getAdminSecret()
    ]);
}

export ledger admin: Bytes<32>;

export circuit initialize(adminPubkey: Bytes<32>): [] {
    admin = disclose(adminPubkey);
}

export circuit awardPoints(
    userKey: Bytes<32>,
    amount: Uint<64>,
    newBalance: Uint<64>
): [] {
    assert(disclose(adminKey()) == admin, "Only admin can award points");

    if (!userPoints.member(disclose(userKey))) {
        assert(disclose(newBalance) == disclose(amount), "First award: balance must equal amount");
    } else {
        const current = userPoints.lookup(disclose(userKey));
        assert(disclose(newBalance) >= current, "Balance must not decrease");
        assert(disclose(newBalance) - current == disclose(amount), "Invalid balance update");
    }

    userPoints.insert(disclose(userKey), disclose(newBalance));
    totalPoints.increment(1);
}

export circuit redeemPoints(
    userKey: Bytes<32>,
    amount: Uint<64>
): [] {
    assert(userPoints.member(disclose(userKey)), "User has no points");

    const current = userPoints.lookup(disclose(userKey));
    assert(current >= disclose(amount), "Insufficient points");

    userPoints.insert(disclose(userKey), current - disclose(amount));
}

export circuit pointsOf(userKey: Bytes<32>): Uint<64> {
    if (!userPoints.member(disclose(userKey))) {
        return 0;
    }
    return userPoints.lookup(disclose(userKey));
}
Enter fullscreen mode Exit fullscreen mode

What you get

Every balance is public. userPoints["alice"] is readable by anyone watching the ledger. That's fine for loyalty points, reputation scores, game credits — anything where the value isn't sensitive. You can enumerate holders, check balances from other circuits, build vesting schedules and rate limits.

The TypeScript call for awardPoints:

// Compute new balance off-chain — arithmetic happens here, not in-circuit
const current = await contract.query.pointsOf(userKey);
const newBalance = current + amount;

await contract.callTx.awardPoints(userKey, amount, newBalance);
Enter fullscreen mode Exit fullscreen mode

The Uint<64> arithmetic constraint

The contract takes newBalance as a parameter from TypeScript rather than computing it in-circuit. This is deliberate. It trips up most developers the first time they write a balance-tracking contract.

When you add two Uint<64> values in-circuit, the result type widens. Uint<64> + Uint<64> produces Uint<1..2^65>, which is too wide to store back into a Map<Bytes<32>, Uint<64>> field:

// ❌ Type error: Uint<64> + Uint<64> = Uint<1..2^65>, not Uint<64>
const newBal = current + amount;
userPoints.insert(disclose(userKey), newBal);
Enter fullscreen mode Exit fullscreen mode
expected right-hand side to have type Uint<64>
but received Uint<1..18446744073709551616>
Enter fullscreen mode Exit fullscreen mode

The fix: compute the result in TypeScript, pass it as a circuit parameter, verify the relationship in-circuit. TypeScript does the arithmetic. The circuit proves it was done correctly.

Subtraction stays within range. Uint<64> - Uint<64> is fine as long as the operand won't go negative, which the assert(current >= amount) guarantees.

When to use it

Use it for internal state that doesn't need to leave the contract, anything you need to query or compare from other circuits, or early development when you don't have a full Midnight node. Avoid it for anything with sensitive amounts.


UTXO-layer tokens

UTXO tokens live in the Zswap Merkle tree, not in ledger fields. The contract is a policy layer: it governs who can create, receive, and send coins. Actual token custody is the shielded transaction engine's job.

The actual standard library API

Most community examples get this wrong. The mint(amount, recipient) you'll see in various tutorials isn't in the standard library. The real functions:

mintShieldedToken(
  domainSep: Bytes<32>,
  value: Uint<64>,
  nonce: Bytes<32>,
  recipient: Either<ZswapCoinPublicKey, ContractAddress>
): ShieldedCoinInfo

sendShielded(
  input: QualifiedShieldedCoinInfo,
  recipient: Either<ZswapCoinPublicKey, ContractAddress>,
  value: Uint<128>
): ShieldedSendResult

receiveShielded(coin: ShieldedCoinInfo): []

ownPublicKey(): ZswapCoinPublicKey
Enter fullscreen mode Exit fullscreen mode

The types involved:

  • ShieldedCoinInfo — a coin commitment: { nonce: Bytes<32>, color: Bytes<32>, value: Uint<128> }
  • QualifiedShieldedCoinInfo — a coin plus its Merkle tree position: { nonce: Bytes<32>, color: Bytes<32>, value: Uint<128>, mtIndex: Uint<64> }
  • ZswapCoinPublicKey — a shielded wallet address: { bytes: Bytes<32> }
  • Either<L, R> — use left<ZswapCoinPublicKey, ContractAddress>(v) for a wallet key

These complex types come from the wallet SDK through witnesses. You can't pass QualifiedShieldedCoinInfo as a direct circuit parameter. The wallet has to provide it.

pragma language_version >= 0.20;
import CompactStandardLibrary;

export ledger admin: Bytes<32>;
export ledger totalMinted: Counter;

witness getAdminSecret(): Bytes<32>;
witness getMintNonce(): Bytes<32>;
witness getRecipient(): ZswapCoinPublicKey;
witness getQualifiedCoin(): QualifiedShieldedCoinInfo;
witness getCoinToReceive(): ShieldedCoinInfo;

circuit adminKey(): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>([
        pad(32, "utxo:admin:v1"),
        getAdminSecret()
    ]);
}

export circuit initialize(adminPubkey: Bytes<32>): [] {
    admin = disclose(adminPubkey);
}

// Mint shielded tokens directly to a recipient's wallet.
// Domain separator scopes these tokens to this contract version.
export circuit mintToWallet(value: Uint<64>): [] {
    assert(disclose(adminKey()) == admin, "Only admin can mint");

    const recipient = getRecipient();
    const nonce = getMintNonce();

    mintShieldedToken(
        pad(32, "token:v1"),
        disclose(value),
        disclose(nonce),
        left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
    );

    totalMinted.increment(1);
}

// Transfer shielded tokens. Protocol handles double-spend prevention.
export circuit transferShielded(value: Uint<128>): [] {
    const coin = getQualifiedCoin();
    const recipient = getRecipient();

    sendShielded(
        disclose(coin),
        left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient)),
        disclose(value)
    );
}

// Receive shielded tokens into this contract.
export circuit receiveIntoContract(): [] {
    const coin = getCoinToReceive();
    receiveShielded(disclose(coin));
}

// Get this contract's shielded public key (use as recipient address).
export circuit contractPublicKey(): ZswapCoinPublicKey {
    return ownPublicKey();
}
Enter fullscreen mode Exit fullscreen mode

How the Zswap layer works

mintShieldedToken creates a coin commitment in the Zswap Merkle tree. The coin's owner, amount, and nonce are hidden inside it. Anyone watching the chain can see a mint happened. Nothing else.

sendShielded takes a coin the caller already owns. The QualifiedShieldedCoinInfo includes the Merkle tree index proving the coin exists and hasn't been spent. Old coin nullified, new commitment created for the recipient.

receiveShielded is for when the contract itself is the recipient. It pulls the coin into the contract's UTXO holdings so the contract can send it onward later.

Double-spend prevention is protocol-level. The Zswap nullifier set handles it. No application logic required.

When token operations get blocked

UTXO operations need the full Midnight node and a Zswap-capable wallet. mintShieldedToken fails in simulator-only environments. sendShielded requires the wallet to provide a valid Merkle inclusion proof. If the wallet doesn't support it, the witness returns nothing and proof generation fails.

Ledger-state accounting works in all environments. UTXO needs the full stack. That's the main practical reason to fall back to accounting even for something that conceptually feels like a token.

When to use it

Use UTXO tokens when value needs to move between wallets privately, when the amounts themselves are sensitive, or when you'd rather not write your own double-spend logic. The tradeoff: you need the full Midnight stack, and the witness-based coin provisioning has its own learning curve.


Combining both: staking rewards

Public participation tracking plus private reward distributions. Staking contracts are a natural fit for this split.

pragma language_version >= 0.20;
import CompactStandardLibrary;

// Public: anyone can see total staked and per-user stakes
export ledger admin: Bytes<32>;
export ledger totalStaked: Counter;
export ledger userStakes: Map<Bytes<32>, Uint<64>>;

// Public aggregate only — individual reward amounts are hidden in Zswap
export ledger totalRewarded: Counter;

witness getAdminSecret(): Bytes<32>;
witness getMintNonce(): Bytes<32>;
witness getRecipient(): ZswapCoinPublicKey;

circuit adminKey(): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>([
        pad(32, "hybrid:admin:v1"),
        getAdminSecret()
    ]);
}

export circuit initialize(adminPubkey: Bytes<32>): [] {
    admin = disclose(adminPubkey);
}

// ACCOUNTING: record a stake
export circuit recordStake(
    userKey: Bytes<32>,
    amount: Uint<64>,
    newBalance: Uint<64>
): [] {
    if (!userStakes.member(disclose(userKey))) {
        assert(disclose(newBalance) == disclose(amount), "First stake: balance must equal amount");
    } else {
        const current = userStakes.lookup(disclose(userKey));
        assert(disclose(newBalance) > current, "Balance must increase");
        assert(disclose(newBalance) - current == disclose(amount), "Invalid stake amount");
    }

    userStakes.insert(disclose(userKey), disclose(newBalance));
    totalStaked.increment(1);
}

// ACCOUNTING: remove a stake
export circuit removeStake(
    userKey: Bytes<32>,
    amount: Uint<64>
): [] {
    assert(userStakes.member(disclose(userKey)), "No stake recorded");

    const current = userStakes.lookup(disclose(userKey));
    assert(current >= disclose(amount), "Cannot remove more than staked");

    userStakes.insert(disclose(userKey), current - disclose(amount));
}

// UTXO: distribute real shielded token rewards (private amounts)
export circuit distributeReward(rewardAmount: Uint<64>): [] {
    assert(disclose(adminKey()) == admin, "Only admin can distribute rewards");

    const recipient = getRecipient();
    const nonce = getMintNonce();

    mintShieldedToken(
        pad(32, "hybrid:reward:v1"),
        disclose(rewardAmount),
        disclose(nonce),
        left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
    );

    totalRewarded.increment(1);
}

export circuit stakeOf(userKey: Bytes<32>): Uint<64> {
    if (!userStakes.member(disclose(userKey))) {
        return 0;
    }
    return userStakes.lookup(disclose(userKey));
}
Enter fullscreen mode Exit fullscreen mode

The accounting circuits give auditors a clear view of who staked what. The UTXO circuit keeps reward amounts hidden. totalRewarded increments on-chain, but who got what stays inside Zswap.


Decision table

Scenario Model
Loyalty points, credits, scores Accounting
Participation tracking, voting weight Accounting
Internal rate limits or quotas Accounting
Payment tokens, transferable assets UTXO
Amount and participant privacy required UTXO
Unit-testing without full Midnight node Accounting
Internal tracking + real payouts Hybrid

If you need to query balances inside the contract, UTXO won't work. The contract can't read Zswap holdings. If you need private transfers between wallets, ledger-state won't work. Those balances are fully public.


Pitfalls

1. Map.get() doesn't exist — use Map.lookup()

// ❌ Compile error — Map has no .get() method
const balance = userPoints.get(disclose(userKey));
Enter fullscreen mode Exit fullscreen mode
// ✅ Correct
if (userPoints.member(disclose(userKey))) {
    const balance = userPoints.lookup(disclose(userKey));
}
Enter fullscreen mode Exit fullscreen mode

2. Map.lookup() panics on a missing key

// ❌ Panics at proof generation if userKey was never inserted
const balance = userPoints.lookup(disclose(userKey));
Enter fullscreen mode Exit fullscreen mode
// ✅ Guard with .member() first
assert(userPoints.member(disclose(userKey)), "User has no balance");
const balance = userPoints.lookup(disclose(userKey));
Enter fullscreen mode Exit fullscreen mode

3. Set<T> doesn't exist — use Map<K, Boolean>

// ❌ Compile error — Compact has no Set type
export ledger spentCoins: Set<Bytes<32>>;
Enter fullscreen mode Exit fullscreen mode
// ✅ Map<K, Boolean> is the set pattern
export ledger spentCoins: Map<Bytes<32>, Boolean>;
spentCoins.insert(disclose(coinId), disclose(true));
Enter fullscreen mode Exit fullscreen mode

4. Uint<64> addition widens the type

// ❌ Uint<64> + Uint<64> = Uint<1..2^65> — can't store back to Uint<64>
const newBalance = current + amount;
userPoints.insert(disclose(key), newBalance);
Enter fullscreen mode Exit fullscreen mode
expected right-hand side to have type Uint<64>
but received Uint<1..18446744073709551616>
Enter fullscreen mode Exit fullscreen mode
// ✅ Compute off-chain, verify in-circuit
export circuit awardPoints(key: Bytes<32>, amount: Uint<64>, newBalance: Uint<64>): [] {
    const current = userPoints.lookup(disclose(key));
    assert(disclose(newBalance) - current == disclose(amount), "Invalid balance");
    userPoints.insert(disclose(key), disclose(newBalance));
}
Enter fullscreen mode Exit fullscreen mode

5. Missing disclose() on exported parameters in comparisons

// ❌ Compiler error
assert(amount > 0, "Amount must be positive");
Enter fullscreen mode Exit fullscreen mode
// ✅ Exported parameters need disclose() before comparisons
assert(disclose(amount) > 0, "Amount must be positive");
Enter fullscreen mode Exit fullscreen mode

Compact compiler error: potential witness-value disclosure must be declared but is not — performing this ledger operation might disclose the boolean value of the result of a comparison involving the witness value

6. Wrong UTXO API signatures

// ❌ Not a standard library function
mint(amount, recipient);

// ❌ Wrong signature — sendShielded doesn't take (amount, recipient)
sendShielded(amount, recipient);
Enter fullscreen mode Exit fullscreen mode

The correct forms:

// ✅ mintShieldedToken: domain separator + nonce + explicit type params on left()
mintShieldedToken(
    pad(32, "token:v1"),
    disclose(value),
    disclose(nonce),
    left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
);

// ✅ sendShielded: takes QualifiedShieldedCoinInfo from witness, not just an amount
sendShielded(
    disclose(coin),                                    // QualifiedShieldedCoinInfo
    left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient)),
    disclose(value)                                    // Uint<128>
);
Enter fullscreen mode Exit fullscreen mode

mintShieldedToken needs a domain separator to scope the token type to this contract, a per-mint nonce to prevent replay, and left() with explicit type parameters. Type inference doesn't work here.

7. Counter.increment() takes Uint<16>, not Uint<64>

// ❌ Type error
totalPoints.increment(disclose(amount));  // amount: Uint<64>
Enter fullscreen mode Exit fullscreen mode
expected first argument of increment to have type Uint<16>
but received Uint<64>
Enter fullscreen mode Exit fullscreen mode
// ✅ Pass a literal
totalPoints.increment(1);
Enter fullscreen mode Exit fullscreen mode

Counter.increment() accepts step values up to 65535. For large aggregate sums, use a Map field. Use Counter to count operations only.

8. Missing disclose() on witness values passed to UTXO functions

// ❌ Compiler error: witness values passed to UTXO functions need disclose()
mintShieldedToken(pad(32, "token:v1"), disclose(value), nonce, left<...>(recipient));
sendShielded(coin, left<...>(recipient), disclose(value));
receiveShielded(coin);
Enter fullscreen mode Exit fullscreen mode
potential witness-value disclosure must be declared but is not:
  the call to standard-library circuit mintShieldedToken might disclose a link between
  a coin spend and the coin with the commitment given by a hash of the witness value
Enter fullscreen mode Exit fullscreen mode
// ✅ Disclose witness values before passing to any UTXO standard library function
mintShieldedToken(
    pad(32, "token:v1"),
    disclose(value),
    disclose(nonce),
    left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
);
sendShielded(
    disclose(coin),
    left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient)),
    disclose(value)
);
receiveShielded(disclose(coin));
Enter fullscreen mode Exit fullscreen mode

This applies to all three: mintShieldedToken, sendShielded, and receiveShielded. Each creates or consumes a coin commitment that hashes your witness value on-chain. Compact requires explicit disclose() to acknowledge that link. The private data stays inside the ZK proof. What you're acknowledging is that a hash of your witness will appear on-chain.

9. Treating accounting balances as private

// This balance is publicly readable — NOT private
export ledger userPoints: Map<Bytes<32>, Uint<64>>;
Enter fullscreen mode Exit fullscreen mode

Ledger fields are visible to anyone reading the chain. For sensitive amounts (stake sizes, bid values, holdings), use the UTXO model or commitment patterns with persistentCommit.


Compiler-verified source

All three contracts — accounting.compact, utxo-token.compact, and hybrid.compact — compile against the latest Compact compiler:

https://github.com/IamHarrie-Labs/compact-token-guide/actions/runs/25700476519


Resources

Top comments (0)