Every blockchain application that handles value needs to answer the same question: how do you track who owns what? There are two dominant approaches, and choosing between them shapes your entire contract architecture.
Contract-state accounting behaves like a bank ledger. A single smart contract holds a balance map, and transactions update entries in place. The UTXO model behaves like physical cash. Each unit of value exists as an independent object that gets consumed and recreated with every transfer.
Midnight supports both patterns, and each has different tradeoffs for privacy, complexity, and scalability. This tutorial explains both models, shows you how to implement them in Compact, and helps you decide which one fits your use case.
Prerequisites
Before you continue, make sure you have:
- The Compact toolchain installed by following the installation guide
- A working understanding of Compact syntax from the Hello World tutorial
- Familiarity with the
disclose()function and selective disclosure concepts
What Is Contract-State Accounting?
Contract-state accounting uses a central registry. The smart contract maintains a single data structure, typically a Map, that tracks every account balance. When Alice sends 50 tokens to Bob, the contract decreases Alice's balance by 50 and increases Bob's balance by 50. The total supply is implicit in the sum of all balances.
This model mirrors how a bank operates. The bank keeps a ledger, and every transaction is a double entry update to that ledger.
Here is a basic implementation in Compact:
pragma language_version 0.22;
import CompactStandardLibrary;
export ledger balances: Map<Bytes<32>, Uint<64>>;
export ledger totalSupply: Uint<64>;
witness localSk(): Bytes<32>;
constructor(initialSupply: Uint<64>) {
const _sk = localSk();
let creator = getDappPublicKey(_sk);
balances.insert(disclose(creator), disclose(initialSupply));
totalSupply = disclose(initialSupply);
}
export circuit transfer(
recipient: Bytes<32>,
amount: Uint<64>
): [] {
const _sk = localSk();
let sender = getDappPublicKey(_sk);
let senderBalance = balances.lookup(sender);
assert(senderBalance >= amount, "Insufficient balance");
let newSenderBalance = senderBalance - amount;
balances.insert(sender, newSenderBalance);
let recipientBalance = balances.lookup(recipient);
let newRecipientBalance = recipientBalance + amount;
balances.insert(recipient, newRecipientBalance);
}
export circuit getDappPublicKey(_sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "accounting:pk:"),
_sk
]);
}
This implementation is straightforward. Every transfer reads two balances, performs arithmetic, and writes two updated balances. The state changes are destructive. Once Alice's balance is updated, the previous balance is gone forever.
Advantages of Contract-State Accounting
Simplicity: The mental model is intuitive. Developers coming from traditional backend systems immediately understand a balance map.
Gas predictability: Each transfer performs a constant number of operations regardless of transaction history. Two map lookups, two map insertions, and some arithmetic.
Easy aggregation: If you need to know the total supply or the number of holders, the data is right there in the contract state. You can query it directly.
Familiar tooling: The pattern works well with standard wallet interfaces. Users see a balance and a send button.
Disadvantages of Contract-State Accounting
Privacy challenges: In a fully public implementation, every balance is visible to every observer. Midnight mitigates this with ZK proofs, but the pattern still requires careful selective disclosure design to protect individual balances.
State bloat: The balance map grows with every new holder and never shrinks. Even accounts with zero balance may need to remain to preserve historical data.
Concurrency issues: If two transactions try to update the same balance simultaneously, one will fail. This creates friction in high throughput applications.
What Is the UTXO Model?
UTXO stands for Unspent Transaction Output. The mental model is physical cash. When you hold a ten dollar bill, that bill is a discrete object with its own serial number and value. When you pay for something that costs seven dollars using that ten dollar bill, two things happen: the ten dollar bill is destroyed, a seven dollar bill goes to the merchant, and a three dollar bill comes back to you as change.
In a UTXO system, every unit of value is a distinct object stored on chain. Each object has an owner and an amount. A transaction consumes input UTXOs and creates output UTXOs. The sum of input amounts must equal the sum of output amounts, preserving the total supply.
Here is a Compact implementation of a basic UTXO token:
pragma language_version 0.22;
import CompactStandardLibrary;
export ledger utxos: Map<Bytes<32>, Uint<64>>;
export ledger utxoOwners: Map<Bytes<32>, Bytes<32>>;
witness localSk(): Bytes<32>;
constructor(initialSupply: Uint<64>) {
const _sk = localSk();
let creator = getDappPublicKey(_sk);
let utxoId = persistentHash<Vector<2, Bytes<32>>>([
pad(32, "utxo:genesis:"),
_sk
]);
utxos.insert(disclose(utxoId), disclose(initialSupply));
utxoOwners.insert(disclose(utxoId), disclose(creator));
}
export circuit transfer(
inputUtxoId: Opaque<Bytes<32>>,
outputAmount1: Uint<64>,
outputAmount2: Uint<64>,
recipient1: Bytes<32>,
recipient2: Bytes<32>
): [] {
// Verify ownership of the input UTXO
const _sk = localSk();
let sender = getDappPublicKey(_sk);
let utxoId = disclose(inputUtxoId);
assert(utxos.member(utxoId), "UTXO does not exist");
assert(utxoOwners.lookup(utxoId) == sender, "You do not own this UTXO");
let inputAmount = utxos.lookup(utxoId);
// Verify amount conservation
assert(
inputAmount == outputAmount1 + outputAmount2,
"Input and output amounts must match"
);
// Destroy the input UTXO
utxos.remove(utxoId);
utxoOwners.remove(utxoId);
// Create output UTXOs
if (outputAmount1 > 0) {
let newUtxoId1 = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "utxo:output:"),
utxoId,
pad(32, "1")
]);
utxos.insert(disclose(newUtxoId1), outputAmount1);
utxoOwners.insert(disclose(newUtxoId1), recipient1);
}
if (outputAmount2 > 0) {
let newUtxoId2 = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "utxo:output:"),
utxoId,
pad(32, "2")
]);
utxos.insert(disclose(newUtxoId2), outputAmount2);
utxoOwners.insert(disclose(newUtxoId2), recipient2);
}
}
export circuit getDappPublicKey(_sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "utxo:pk:"),
_sk
]);
}
This implementation captures the essence of the UTXO model. Each UTXO is a distinct entry in the utxos map with a corresponding owner. A transfer destroys one UTXO and creates up to two new ones, typically one for the recipient and one for change back to the sender.
Advantages of the UTXO Model
Natural privacy; Because UTXOs are independent objects, you can use different keys for each one. An observer cannot trivially link multiple UTXOs to the same owner without additional information.
Parallelism; Transactions that consume different UTXOs do not conflict with each other. Alice sending to Bob and Charlie sending to Dave can process simultaneously because they touch different state entries.
Auditability; The total supply is preserved by construction. Every transaction must balance, so you can verify the entire history without trusting a central authority.
Resistance to replay; Each UTXO has a unique identifier. Once consumed, a UTXO cannot be used again because it no longer exists in the state.
Disadvantages of the UTXO Model
Complexity; The model is less intuitive for developers used to account-based systems. Generating change outputs, selecting which UTXOs to consume, and managing fragmented balances adds surface area for bugs.
Fragmentation; Over time, a user may accumulate many small UTXOs. Spending a large amount requires combining multiple inputs, which increases transaction size and complexity.
State growth; Every transaction creates new UTXOs. Even as old ones are consumed, the total number of UTXOs in existence tends to grow, especially if users do not consolidate their holdings.
Comparing the Two Models
Let us examine the same scenario implemented in both models to highlight the practical differences.
Scenario: Alice sends 30 tokens to Bob
Contract-state accounting:
The contract reads Alice's balance. If she has at least 30 tokens, it subtracts 30 from her balance and adds 30 to Bob's balance. Two state entries are updated. The transaction is a single unit of work.
UTXO model:
Alice's wallet selects one or more UTXOs that sum to at least 30 tokens. Suppose she has a single UTXO worth 50 tokens. The transaction consumes that UTXO and creates two new ones: 30 tokens owned by Bob and 20 tokens owned by Alice as change. One state entry is destroyed, two are created.
The UTXO approach creates more on-chain objects, but each object reveals less about the overall distribution of funds. An observer sees a 50 token UTXO disappear and a 30 and 20 appear, but they cannot trivially tell which is the payment and which is the change without additional analysis.
When to Choose Contract-State Accounting
Choose this model when:
- Your application needs frequent balance queries from the contract itself
- You are building something familiar like a token with standard wallet integrations
- The user experience of seeing a single balance matters more than transaction level privacy
- Your total number of holders is reasonably bounded
When to Choose the UTXO Model
Choose this model when:
- Privacy is a primary design goal and you want to avoid balance correlation
- Your application benefits from parallel transaction processing
- You need strong guarantees about supply preservation that are enforced by the transaction structure itself
- You are building payment systems or exchange mechanisms
Hybrid Approaches
You are not forced to pick one model exclusively. Many applications combine elements of both patterns.
For example, a DApp might store user balances as a map for quick lookups while also issuing UTXO style receipts for individual transactions. The balance map provides convenience, and the receipts provide auditability.
Here is a sketch of how that might look:
pragma language_version 0.22;
import CompactStandardLibrary;
export ledger balanceMap: Map<Bytes<32>, Uint<64>>;
export ledger transactionReceipts: Map<Bytes<32>, Bytes<32>>;
witness localSk(): Bytes<32>;
export circuit deposit(amount: Opaque<Uint<64>>): [] {
const _sk = localSk();
let user = getDappPublicKey(_sk);
let currentBalance = balanceMap.lookup(user);
let newBalance = currentBalance + disclose(amount);
balanceMap.insert(user, newBalance);
let receiptId = persistentHash<Vector<2, Bytes<32>>>([
pad(32, "receipt:"),
_sk
]);
let receiptData = pad(64, "deposit");
transactionReceipts.insert(disclose(receiptId), receiptData);
}
export circuit getDappPublicKey(_sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "hybrid:pk:"),
_sk
]);
}
The balance map handles the operational needs of the application. The receipt map provides an immutable audit trail. Each solves a different problem, and together they offer more than either alone.
Practical Recommendations
Based on these models, here is a decision framework for your own contracts:
Start with contract-state accounting if you are building a straightforward token or a DApp where user experience and developer simplicity are priorities. The pattern is easier to get right and easier to debug.
Move to the UTXO model if you discover privacy or concurrency requirements that the account model cannot satisfy. The additional complexity is worth it when these properties matter.
Consider a hybrid approach if you need both operational efficiency and an audit trail. Just be clear about which state serves which purpose.
Regardless of which model you choose, apply domain separated hashing for all user identifiers. This prevents cross DApp tracking and preserves the privacy guarantees you are building into your contract logic.
Test your assumptions on a local Devnet before deploying to a public testnet. Both models behave differently under load, and early testing catches architecture problems before they become expensive.
A Privacy Note on Both Models
Neither model automatically guarantees privacy. In contract-state accounting, if you disclose user public keys directly in the balance map, every balance becomes linkable to every transaction that user makes. In the UTXO model, if you reuse the same owner key across multiple UTXOs, you create the same linkability problem.
Always derive DApp specific public keys using domain separation. Consider whether you can use commitments instead of raw values. And apply the selective disclosure audit checklist covered in the companion tutorial on disclosure patterns.
Next Steps
Understanding these two models gives you a foundation for designing value transfer systems on Midnight. To deepen your knowledge:
Study the Token Transfers example in the Midnight documentation to see a production oriented implementation.
Experiment with both patterns using the
create-mn-appscaffolding tool. Build the same simple token with each model and compare the developer experience.Join the Midnight Discord and share your implementation. The community is actively discussing these design patterns.
Apply the selective disclosure audit checklist from the companion tutorial to whichever model you choose. Privacy is not automatic, it requires deliberate design.
Contract-state accounting and the UTXO model are both valid paths. The right choice depends on your application requirements and your users needs. Now you have the tools to make that choice confidently.

Top comments (0)