UTXO Tokens vs. Ledger-State Accounting on Midnight: Choosing the Right Model
A few months ago I was building a loyalty points system on Midnight — the kind where users earn points for completing actions and can later redeem them for rewards. Straightforward concept. But about two days in I hit a wall that forced me to actually understand something I had been glossing over: Midnight has two completely separate mechanisms for tracking value, and they are not interchangeable.
I had started by reaching for receiveShielded because that felt like "the token thing to do." It is, after all, how you interact with Midnight's native shielded token layer. But I kept running into a conceptual mismatch: my points were not real transferable tokens. They were internal accounting — a number tied to a user's identity in my contract. I was fighting the UTXO model to do something ledger-state accounting handles naturally.
That experience is what this article is about. By the end, you will know exactly which model to reach for, why, and how to implement both in Compact and TypeScript.
The Two Models
Midnight gives you two ways to track value inside a smart contract:
UTXO-layer shielded tokens are Midnight's native coin mechanism. When you use receiveShielded or sendShielded in a Compact contract, you are interacting with actual cryptographic coins — unspent transaction outputs living in the shielded ledger. These tokens move between addresses. They carry real economic weight and interoperate with wallets, DeFi protocols, and other contracts. Privacy is enforced at the protocol level using zero-knowledge proofs.
Ledger-state accounting means your contract maintains its own bookkeeping using fields declared in the ledger block — typically Counter and Map types. Balances are just numbers stored in on-chain state. There are no coins being moved. There is no interoperability with other token systems. Your contract is the sole authority over these numbers.
The distinction matters because these two approaches solve different problems, and using the wrong one creates friction that no amount of clever code will eliminate.
UTXO Model Deep Dive
The UTXO model in Midnight is built around the shielded coin system. When a coin enters your contract, it gets consumed. When a coin leaves, a new one is created. Your contract acts as a participant in this flow — it can receive coins and dispatch them — but it never "holds" coins the way a bank account holds a balance. The coins exist in the shielded ledger; your contract just has the authority to move them under certain conditions.
What receiveShielded and sendShielded Do
receiveShielded is a built-in Compact operation that accepts an incoming shielded token and generates a coin on behalf of your contract. When a user calls a circuit that invokes receiveShielded, the protocol verifies that the user is spending a valid unspent coin and that the amount matches what the circuit expects. A new coin is created representing your contract's ownership of those tokens.
sendShielded(addr, amount) does the inverse. It constructs a new shielded coin and dispatches it to the specified address. That address can be a user's wallet address or another contract's address. The recipient receives a spendable coin.
Both operations generate ZK proofs automatically — you do not write the proof logic yourself. The Compact compiler and the Midnight prover handle it.
receive() and send() are the unshielded equivalents. They work the same way conceptually, but without the privacy guarantees. Token amounts and addresses are visible on-chain. Use these when your use case does not require privacy or when you are integrating with public token flows.
Compact Contract Example
Here is a simple escrow contract that accepts shielded tokens from a depositor and releases them to a designated recipient when the contract owner authorizes it:
pragma language_version >= 0.14.0;
import CompactStandardLibrary;
export ledger owner: Bytes<32>;
export ledger recipient: Bytes<32>;
export ledger deposited: Boolean;
export circuit initialize(ownerAddr: Bytes<32>, recipientAddr: Bytes<32>): [] {
owner = ownerAddr;
recipient = recipientAddr;
deposited = false;
}
export circuit deposit(): [] {
assert !deposited "Already deposited";
receiveShielded();
deposited = true;
}
export circuit release(amount: Uint<64>): [] {
assert deposited "Nothing to release";
assert own_public_key() == owner "Not authorized";
sendShielded(recipient, amount);
deposited = false;
}
The deposit circuit calls receiveShielded() with no arguments — the amount comes from the coin the caller is spending, which is validated by the protocol. The release circuit uses own_public_key() to gate access, then calls sendShielded with the recipient address and the amount to transfer.
One thing to internalize: the contract does not store the amount. The coin itself tracks value. If you need the contract to know the amount — say, to enforce a minimum deposit — you pass the amount as a witness (a private input) and the protocol verifies the coin matches.
Here is a variation with a minimum deposit enforced via a witness:
export circuit deposit(witness amount: Uint<64>): [] {
assert !deposited "Already deposited";
assert amount >= 100 "Deposit below minimum";
receiveShielded();
deposited = true;
}
The witness keyword marks amount as a private input. It is known to the prover but not revealed on-chain.
TypeScript: Calling These Circuits
On the TypeScript side, you use the Midnight SDK to deploy and interact with contracts. Here is how you would call the deposit circuit from a Node.js backend:
import { createMidnightClient } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { WalletBuilder } from '@midnight-ntwrk/wallet';
import type { ContractAddress } from '@midnight-ntwrk/midnight-js-types';
async function depositToEscrow(
contractAddress: ContractAddress,
depositAmount: bigint
): Promise<string> {
const wallet = await WalletBuilder.restore(/* seed and config */);
const client = createMidnightClient({
proverUrl: process.env.PROVER_URL!,
indexerUrl: process.env.INDEXER_URL!,
});
// The SDK handles coin selection and proof generation automatically
// when the circuit calls receiveShielded()
const tx = await client.callCircuit({
contractAddress,
circuitName: 'deposit',
args: [],
wallet,
// The SDK knows to include a shielded coin input
// because the circuit calls receiveShielded
coinAmount: depositAmount,
});
const receipt = await tx.wait();
console.log('Deposit confirmed in block:', receipt.blockHeight);
return receipt.transactionHash;
}
async function releaseEscrow(
contractAddress: ContractAddress,
amount: bigint
): Promise<string> {
const wallet = await WalletBuilder.restore(/* seed and config */);
const client = createMidnightClient({
proverUrl: process.env.PROVER_URL!,
indexerUrl: process.env.INDEXER_URL!,
});
const tx = await client.callCircuit({
contractAddress,
circuitName: 'release',
args: [amount],
wallet,
});
const receipt = await tx.wait();
return receipt.transactionHash;
}
The key point is that coinAmount in the call options tells the SDK to select an appropriate coin from the wallet and include it as a shielded input. The proof generation is handled by the proof provider — you never write ZK circuit logic in your TypeScript code.
Unshielded Variants
receive() and send() work identically from a code structure standpoint. Swap them in wherever privacy is not required. They are useful for things like public treasury contracts, fee collection systems, or any context where on-chain transparency is a feature rather than a liability.
export circuit publicDeposit(): [] {
receive();
deposited = true;
}
export circuit publicRelease(amount: Uint<64>): [] {
assert deposited "Nothing to release";
send(recipient, amount);
deposited = false;
}
Ledger-State Accounting Deep Dive
Ledger-state accounting does not touch the shielded coin system at all. You define fields in the ledger block of your Compact contract, and those fields persist between transactions. Circuits read and write those fields. That is the entire mechanism.
Counter and Map in Compact
Counter is a ledger field type for incrementable integers. It supports a small set of operations and is designed for scenarios where you need a number that goes up (and sometimes down). A Counter is appropriate for things like total deposit counts, user action tallies, or supply tracking.
Map<K, V> is a persistent key-value store. Keys and values are Compact types — typically Bytes<32> for addresses or identifiers, and Uint<64> for numeric values. A Map is the right choice when you need per-entity tracking: balances per user, scores per player, claims per address.
These fields live on-chain in your contract's state. Every transaction that modifies them produces a state transition that is committed to the ledger.
Compact Contract Example
Here is a points system where users earn points for completing actions and can redeem them:
pragma language_version >= 0.14.0;
import CompactStandardLibrary;
export ledger admin: Bytes<32>;
export ledger totalPointsIssued: Counter;
export ledger balances: Map<Bytes<32>, Uint<64>>;
export circuit initialize(adminAddr: Bytes<32>): [] {
admin = adminAddr;
totalPointsIssued = 0;
}
export circuit awardPoints(userAddr: Bytes<32>, amount: Uint<64>): [] {
assert own_public_key() == admin "Not admin";
const current: Uint<64> = balances.lookup_with_default(userAddr, 0);
balances.insert(userAddr, current + amount);
increment totalPointsIssued;
}
export circuit redeemPoints(witness amount: Uint<64>): [] {
const caller: Bytes<32> = own_public_key();
const balance: Uint<64> = balances.lookup_with_default(caller, 0);
assert balance >= amount "Insufficient points";
balances.insert(caller, balance - amount);
}
export circuit getBalance(userAddr: Bytes<32>): Uint<64> {
return balances.lookup_with_default(userAddr, 0);
}
A few things to notice. lookup_with_default handles the case where a user has no entry yet — it returns the default value (0) rather than erroring. increment totalPointsIssued bumps the counter by one; there is no explicit value assignment needed. The redeemPoints circuit uses own_public_key() to identify the caller, so users can only redeem their own points.
Notice also what this contract does not do: it never calls receiveShielded, sendShielded, receive, or send. No tokens change hands. These points exist only as numbers in this contract's state. They cannot be sent to another wallet or traded on a DEX. That is intentional — and it is often exactly what you want for internal bookkeeping.
TypeScript: Depositing and Withdrawing from Ledger-State
Interacting with ledger-state contracts from TypeScript is simpler than the UTXO case because there is no coin management:
import { createMidnightClient } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { WalletBuilder } from '@midnight-ntwrk/wallet';
import type { ContractAddress } from '@midnight-ntwrk/midnight-js-types';
async function awardPoints(
contractAddress: ContractAddress,
userAddress: Uint8Array,
amount: bigint
): Promise<void> {
const wallet = await WalletBuilder.restore(/* seed and config */);
const client = createMidnightClient({
proverUrl: process.env.PROVER_URL!,
indexerUrl: process.env.INDEXER_URL!,
});
const tx = await client.callCircuit({
contractAddress,
circuitName: 'awardPoints',
args: [userAddress, amount],
wallet,
// No coinAmount — this circuit does not touch the UTXO layer
});
await tx.wait();
}
async function redeemPoints(
contractAddress: ContractAddress,
amount: bigint
): Promise<void> {
const wallet = await WalletBuilder.restore(/* seed and config */);
const client = createMidnightClient({
proverUrl: process.env.PROVER_URL!,
indexerUrl: process.env.INDEXER_URL!,
});
// amount is a witness — it's a private input to the circuit
// Pass it via the witnesses field, not args
const tx = await client.callCircuit({
contractAddress,
circuitName: 'redeemPoints',
args: [],
witnesses: { amount },
wallet,
});
await tx.wait();
}
async function checkBalance(
contractAddress: ContractAddress,
userAddress: Uint8Array
): Promise<bigint> {
const client = createMidnightClient({
proverUrl: process.env.PROVER_URL!,
indexerUrl: process.env.INDEXER_URL!,
});
// Read-only query — no wallet needed, no proof generated
const result = await client.queryContract({
contractAddress,
circuitName: 'getBalance',
args: [userAddress],
});
return result as bigint;
}
The absence of coinAmount in awardPoints and redeemPoints is the structural signal that you are working purely in ledger-state. The SDK does not need to touch the wallet's coin set at all (beyond paying the transaction fee). The witnesses field in redeemPoints is how you pass private inputs — the Midnight prover includes them in the ZK proof without exposing them on-chain.
checkBalance is a read-only query that does not require proof generation and does not need a wallet. You are reading public contract state directly from the indexer.
The Decision Framework
After building several contracts on Midnight, here is how I think about the choice.
Use the UTXO Model When:
You need real token transfers across addresses. If users should be able to hold, send, and receive tokens independently of your contract — in their own wallets — you need the UTXO layer. Ledger-state balances are trapped inside your contract.
You need interoperability. If you want your contract to integrate with DEXes, lending protocols, or other Midnight contracts that work with native tokens, you need UTXO tokens. Ledger-state numbers have no standard interface for other contracts to consume.
You are building DeFi or NFT infrastructure. Anything that involves token ownership, transferability, or composability belongs on the UTXO layer. Trying to replicate this with ledger-state accounting will either fail entirely or require building a parallel ecosystem that no wallet natively supports.
Privacy of individual transfers matters. receiveShielded and sendShielded get ZK privacy for free. If your use case requires that individual deposits and withdrawals are unlinkable, the shielded UTXO layer is the right primitive.
Use Ledger-State Accounting When:
You are doing internal bookkeeping. Points systems, reputation scores, vote tallies, subscription status — anything where the "balance" is a property of a user's relationship with your specific contract rather than a transferable asset.
Token operations are currently blocked or constrained. During some phases of testnet and certain deployment configurations, direct token transfers have restrictions. Ledger-state accounting lets you build and test business logic without depending on the live token system.
You control distribution entirely. If your contract is the sole authority that creates and destroys balances — no user should ever be able to receive these units from anyone other than your contract — ledger-state is cleaner. You are not fighting the UTXO model's decentralized ownership assumptions.
You need readable on-chain state for indexing. Shielded UTXO balances are private by design, which means they are opaque to indexers. If you need a public leaderboard or auditable total, ledger-state values in public fields are straightforward to query.
The Hybrid Approach: Wrapping UTXO Tokens in Ledger-State
There is a legitimate middle ground. You can build a contract that accepts real UTXO tokens and then tracks internal allocations using ledger-state. This is the pattern for a points system backed by actual value.
pragma language_version >= 0.14.0;
import CompactStandardLibrary;
export ledger admin: Bytes<32>;
export ledger tokenPool: Counter;
export ledger pointBalances: Map<Bytes<32>, Uint<64>>;
// User deposits real tokens, gets internal points at a 1:10 ratio
export circuit buyPoints(witness tokenAmount: Uint<64>): [] {
receiveShielded();
const caller: Bytes<32> = own_public_key();
const current: Uint<64> = pointBalances.lookup_with_default(caller, 0);
const pointsEarned: Uint<64> = tokenAmount * 10;
pointBalances.insert(caller, current + pointsEarned);
tokenPool = tokenPool + tokenAmount;
}
// User redeems points for real tokens at 1:10 ratio
export circuit redeemForTokens(witness pointAmount: Uint<64>): [] {
const caller: Bytes<32> = own_public_key();
const balance: Uint<64> = pointBalances.lookup_with_default(caller, 0);
assert balance >= pointAmount "Insufficient points";
const tokenAmount: Uint<64> = pointAmount / 10;
assert tokenPool >= tokenAmount "Pool insufficient";
pointBalances.insert(caller, balance - pointAmount);
tokenPool = tokenPool - tokenAmount;
sendShielded(caller, tokenAmount);
}
buyPoints calls receiveShielded to pull real tokens in, then updates the caller's internal point balance. redeemForTokens reduces the internal balance and calls sendShielded to push real tokens back out. The contract acts as a conversion layer between the two worlds.
This pattern is powerful, but it comes with a responsibility: the contract now holds real tokens. Your redeemForTokens logic needs to be airtight, because any flaw in the balance accounting is a direct funds vulnerability.
Common Mistakes
Trying to transfer tokens to another contract via ledger-state. This is the mistake I almost made. Ledger-state balances in contract A cannot be spent by contract B. They are not coins. If you want contract B to control tokens that originated in contract A, you need to use sendShielded to actually move the coins, not decrement a number in a Map.
Calling receiveShielded without providing a coin input. If your TypeScript call does not include coinAmount, the SDK will not include a coin input in the transaction. The circuit will fail at the proof generation step with a cryptic error about missing inputs. Always set coinAmount when calling a circuit that uses receiveShielded.
Using ledger-state balances as if they are portable. I have seen developers build out an entire token economy with Map<Bytes<32>, Uint<64>> balances, then realize late that users cannot export their balance to another platform, cannot see it in their Midnight wallet, and cannot use it in any other contract. If transferability is in the requirements — even if you do not implement it immediately — use the UTXO layer.
Forgetting that own_public_key() in a circuit refers to the transaction signer, not the contract. When you use own_public_key() to gate admin functions, make sure your admin setup records the key at deploy time and validates against it. If you deploy with a hot key and later want to rotate to a cold key, you need a key rotation mechanism in the contract — there is no built-in ownership transfer.
Ignoring the difference between witnesses and args. Arguments (args) are public inputs to a circuit — they appear in the transaction and are visible on-chain. Witnesses are private inputs — they go into the ZK proof but are not revealed. If you accidentally pass a sensitive value as an arg instead of a witness, you have leaked it. In TypeScript, check that your circuit signature and your SDK call agree on which parameters are witnesses.
Assuming Counter can go below zero. Counter in Compact is unsigned. If you decrement a counter past zero, you will get an arithmetic underflow error. Guard all decrements with an assertion that the current value is greater than or equal to the amount you are subtracting.
Closing
The UTXO model and ledger-state accounting are both first-class tools on Midnight — they are just first-class tools for different jobs. Once you internalize the distinction, the right choice for each situation becomes obvious rather than agonizing.
If you have questions, ran into a case where neither model felt right, or found a pattern worth sharing, head over to issue #302 in the contributor-hub — that is where the discussion for this article lives and where the Midnight developer community is working through exactly these kinds of architectural questions.
Top comments (0)