Many developers entering the Midnight ecosystem bring valuable Solidity experience from Ethereum development. This guide bridges that knowledge gap by providing direct, side-by-side comparisons between Solidity and Compact code patterns.
Compact is Midnight Network's domain-specific language for building privacy-preserving smart contracts. Unlike Solidity's transparent-by-default model, Compact introduces explicit privacy controls through public ledger state and private witness functions. The language uses TypeScript-like syntax while introducing blockchain-specific concepts necessary for zero-knowledge proof generation.
This comparison guide covers ten fundamental patterns that Solidity developers use daily: contract structure, data types, loops, state management, events, access control, visibility modifiers, initialization, validation, and privacy features. Each pattern includes working code examples in both languages, highlighting key differences and migration considerations.
By mapping your existing Solidity knowledge to Compact equivalents, you will reduce the learning curve and start building privacy-preserving applications faster. The guide emphasizes not just syntax translation but conceptual differences in how privacy-first smart contracts operate.
The image below shows the architecture comparison between Solidity and Compact.
Before diving into detailed code comparisons, let's start with a high-level overview of how common Solidity patterns map to their Compact equivalents.
Quick reference table
This table provides an overview of how common Solidity patterns translate to Compact:
| Pattern | Solidity Feature | Compact Equivalent | Key Difference |
|---|---|---|---|
| Structure | contract MyContract { } |
export ledger + export circuit
|
No container; state declared globally |
| Data Types | Dynamic types, signed integers |
Field, Bytes<N>, Counter, Boolean
|
Fixed-size types required for ZK proofs |
| Loops | for (i < array.length) |
for with compile-time bounds |
Iteration limits must be known at compile time |
| Privacy | All public (even private keyword) |
witness functions for true privacy |
Cryptographic privacy guarantees |
| Events | emit Transfer(from, to, amount) |
Ledger state updates + return values | Track changes via ledger counters and return values |
| Modifiers | modifier onlyOwner() { } |
Helper circuits with assert
|
No equivalent; use helper circuits instead |
| Visibility |
public, external, internal, private
|
export vs non-exported |
File-scoped functions |
| Constructor | constructor(params) { } |
constructor(params) with direct assignment |
Similar syntax, explicit state initialization |
| Validation |
require(), revert(), assert()
|
assert() only |
Aborts circuit execution on failure |
| State Reading | function getValue() view |
Circuits read state directly | External apps query via TypeScript indexer |
Now that you've seen the high-level mapping between Solidity and Compact, let's examine each pattern in detail with working code examples.
Pattern 1: Basic contract structure
This section explores how Solidity and Compact organize code differently. You'll learn how Solidity bundles everything into contract containers, while Compact separates state declarations from transaction logic using a global ledger model.
Solidity: Contract declaration
Solidity organizes code within contract containers that hold state variables and functions together:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleStorage {
// State variables declared inside contract
uint256 public storedValue;
address public owner;
// Constructor initializes state
constructor(uint256 _initialValue) {
storedValue = _initialValue;
owner = msg.sender; // Automatic caller context
}
// State-modifying function
function setValue(uint256 _newValue) public {
storedValue = _newValue;
}
// View function for reading state
function getValue() public view returns (uint256) {
return storedValue; // Direct state access
}
}
This familiar Solidity pattern organizes everything within a contract container. Now, let's see how Compact approaches the same functionality with a fundamentally different structure.
Compact: Ledger and circuit declaration
Compact separates state declarations from transaction logic, with no container pattern:
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
// Public ledger state (declared globally, outside circuits)
export ledger stored_value: Field;
export ledger owner: Bytes<32>;
// Witness functions provide private off-chain data
witness get_caller(): Bytes<32>;
// Constructor initializes state with parameters
constructor(initial_value: Field, owner_address: Bytes<32>) {
stored_value = initial_value;
owner = owner_address;
}
// State-modifying circuit
export circuit set_value(new_value: Field): [] {
stored_value = new_value;
}
// Circuits can read ledger state directly
export circuit get_and_update(increment: Field): Field {
let current: Field = stored_value; // Read state in circuit
stored_value = current + increment; // Update state
return current; // Return old value
}
You've seen how Compact declares state and circuits differently from Solidity. Circuits can read and modify ledger state directly during execution. For external applications (like frontends) that need to query state without executing a circuit, you'll use the TypeScript runtime.
Reading ledger state in compact
Compact circuits can read ledger state directly during execution. For external queries (like from a frontend), use TypeScript runtime:
// OPTION 1: Read state within a circuit (during execution)
export circuit check_value(): Field {
let current: Field = stored_value; // Direct state access
return current;
}
// OPTION 2: Query state externally via TypeScript (for frontends)
import { indexerPublicDataProvider } from "@midnight-ntwrk/midnight-js-indexer-public-data-provider";
const state = await publicDataProvider.queryContractState(contractAddress);
if (state) {
const ledger = ContractModule.ledger(state.data);
const currentValue = ledger.stored_value; // Read Field value
const currentOwner = ledger.owner;
console.log(`Value: ${currentValue}, Owner: ${currentOwner}`);
}
Now that you've seen both implementations, let's highlight the critical differences you need to remember.
Key differences
Constructor initialization: Both Solidity and Compact constructors accept parameters directly for initialization. In Compact, the constructor is called once during deployment with the initial state values.
Privacy by default: Solidity parameters are public. Compact circuit parameters can be provided as private witness data, enabling cryptographic privacy for sensitive inputs.
Structure: Solidity uses a contract container holding all code. Compact declares ledger state globally and defines individual circuits for transactions.
Language declaration: Solidity specifies version with pragma solidity ^0.8.0. Compact requires pragma language_version with version specification and mandatory import CompactStandardLibrary.
State access: Solidity allows state reads through view functions. Compact circuits can read ledger state directly during execution. External applications query state via TypeScript runtime SDK using the indexer.
Automatic context: Solidity provides msg.sender automatically. Compact requires explicit witness functions to access caller context.
Return types: Solidity uses explicit return type syntax returns (uint256). Compact uses [] (empty tuple) to indicate no return value, or specifies the return type like Field.
With a solid understanding of how Compact structures contracts differently from Solidity, you're ready to explore another fundamental difference: the type system.
Pattern 2: Data types and type system
This section examines the fundamental differences in how each language handles data types. You'll discover why Solidity's flexible runtime sizing doesn't work in Compact's zero-knowledge proof environment, which requires all sizes to be known at compile time.
Solidity: Flexible types with runtime sizing
Solidity supports various numeric types, signed integers, and dynamic data structures:
contract DataTypes {
// Boolean (no type suffix needed)
bool public flag = true;
// Unsigned integers (various sizes)
uint256 public largeNumber = 1000000;
uint8 public smallNumber = 255;
// Signed integers supported
int256 public signedNumber = -100;
// Address type
address public userAddress;
// Fixed-size bytes
bytes32 public dataHash;
// Dynamic string (no size limit)
string public name = "Example";
// Dynamic array (grows at runtime)
uint256[] public dynamicArray;
// Fixed-size array
uint256[5] public fixedArray;
// Mapping (key-value store)
mapping(address => uint256) public balances;
// Struct with dynamic fields
struct User {
string name; // Dynamic
uint256 age;
bool active;
}
User public userData;
}
Solidity's flexible type system allows for dynamic sizing and various numeric types. Compact takes a stricter approach, requiring compile-time bounds for proof generation.
Compact: Fixed-Size, unsigned types
Compact requires compile-time sizing and primarily uses Field for general numeric operations:
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
// Numeric values - Field type for general numeric operations
export ledger number: Field;
// Specific unsigned integer types
export ledger small_number: Uint<8>;
export ledger medium_number: Uint<16>;
export ledger large_number: Uint<64>;
// Boolean values are available
export ledger flag: Boolean;
// Address type - use Bytes<32>
export ledger user_address: Bytes<32>;
// Fixed-size bytes
export ledger data_hash: Bytes<32>;
// Opaque string type (for dynamic strings)
export ledger name: Opaque<"string">;
constructor() {
number = 42;
small_number = 255;
medium_number = 1000;
large_number = 1000000;
flag = true;
user_address = pad(32, "");
data_hash = pad(32, "");
name = "";
}
export circuit set_number(value: Field): [] {
number = value;
}
export circuit set_flag(new_flag: Boolean): [] {
flag = new_flag;
}
export circuit set_address(new_address: Bytes<32>): [] {
user_address = new_address;
}
The contrast between these two approaches is stark. Let's break down what these differences mean for your migration.
Key differences
The chart below visualizes the fundamental type system differences between Solidity's flexible runtime sizing and Compact's fixed compile-time bounds.

Signed integers: Solidity supports both int and uint types. Compact only provides unsigned integers (Uint<8>, Uint<16>, Uint<32>, Uint<64>, Uint<128>, Uint<256>). No negative numbers. The Field type is commonly used for general numeric operations.
Type specification: Solidity infers types from literals (0, 255, true). Compact uses explicit type declarations in ledger fields and relies on type inference in circuit code.
Strings: Solidity allows dynamic string with unlimited length. Compact uses Opaque<"string"> for dynamic strings or fixed-size arrays for bounded strings.
Arrays: Solidity supports both dynamic arrays (uint[]) and fixed-size arrays (uint[5]). Compact primarily uses fixed-size collections with compile-time known bounds.
Mappings: Solidity provides key-value mappings with O(1) access. Compact has no direct mapping equivalent; use alternative data structures like MerkleTrees or Sets.
Structs: Both languages support structured data. Solidity uses struct keyword. Compact uses record types, and all fields must have fixed or opaque sizes.
Now that you understand Compact's strict type requirements, let's examine how these constraints extend to one of the most common programming patterns: loops and iteration.
Pattern 3: Loops and iteration
This section reveals how loop constraints differ between the two languages. You'll understand why Compact requires compile-time bounds on all loops for proof circuit generation, unlike Solidity's dynamic iteration patterns.
Solidity: Dynamic loop bounds
Solidity allows loops with runtime-determined iteration counts:
contract LoopExample {
uint256[] public numbers;
// For loop with dynamic bound (array.length)
function sumArray() public view returns (uint256) {
uint256 sum = 0;
// Length determined at runtime
for (uint256 i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
return sum;
}
// While loop with parameter-based bound
function sumRange(uint256 max) public pure returns (uint256) {
uint256 sum = 0;
uint256 i = 0;
// Max determined at runtime
while (i < max) {
sum += i;
i++; // Shorthand increment
}
return sum;
}
// Do-while loop
function processUntilCondition(uint256 target) public pure returns (uint256) {
uint256 value = 0;
do {
value += 10;
} while (value < target);
return value;
}
}
Solidity's flexibility with loops makes iteration straightforward. Compact's zero-knowledge proof requirements demand a different approach.
Compact: Compile-time bounded loops
Compact requires all loops to have fixed maximum iteration counts known at compile time:
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
export ledger sum: Field;
export ledger counter: Counter;
constructor() {
sum = 0;
}
// Loop with compile-time bound
export circuit sum_range(max: Field): [] {
let total: Field = 0;
// Compile-time maximum bound (100) with runtime condition
for (let i: Field = 0; i < max && i < 100; i = i + 1) {
total = total + i;
}
sum = total;
}
// Counter operations for tracking
export circuit increment_counter(): [] {
counter.increment(1);
}
export circuit increment_by_literal(): [] {
// Counter.increment() requires literal integers
counter.increment(5); // Literal value
}
These examples reveal fundamental differences in how the two languages handle iteration. Let's examine what this means for your code.
Key differences
The diagram below illustrates how Solidity's dynamic loop bounds differ from Compact's compile-time bounded iteration requirements.

Loop bounds: Solidity permits dynamic bounds like i < array.length or parameter-based limits. Compact requires compile-time constant upper bounds (e.g., i < 100) combined with runtime conditions using && operator.
Increment syntax: Solidity supports shorthand operators i++ and i--. Compact requires explicit assignment: i = i + 1.
Loop types: Solidity supports for, while, and do-while loops. Compact supports for and while loops only. No do-while.
Zero-knowledge requirement: Loop bounds must be compile-time constants in Compact because zero-knowledge proof circuits require fixed, deterministic complexity. The proof system needs to know the maximum circuit size upfront.
Workaround pattern: When logical iteration count varies at runtime, use a compile-time maximum with conditional early exit using &&. The loop always iterates the maximum times for the proof circuit, but can exit early with conditional logic.
Having mastered bounded iteration, you're now ready to explore Compact's most powerful feature: true cryptographic privacy. This pattern reveals how Compact fundamentally differs from Solidity in handling sensitive data.
Pattern 4: State variables (public vs private)
This section uncovers the truth about blockchain privacy. You'll learn why Solidity's "private" keyword doesn't provide real privacy, and how Compact achieves genuine cryptographic privacy through witness functions.
Solidity: Visibility modifiers (all data on-chain)
Solidity uses visibility keywords, but all state exists on-chain regardless:
contract StateExample {
// Public (auto-generates getter function)
uint256 public totalSupply;
address public owner;
// Private (no auto-getter, but still readable from blockchain)
// Anyone can read this by directly accessing blockchain state
uint256 private secretValue;
// Internal (accessible to derived contracts)
uint256 internal sharedValue;
// Constant (compile-time immutable)
uint256 public constant MAX_SUPPLY = 1000000;
// Immutable (set once in constructor)
address public immutable deployer;
constructor() {
owner = msg.sender;
deployer = msg.sender;
totalSupply = 0;
secretValue = 12345; // Still visible on-chain to anyone!
}
function updateSecret(uint256 _new) public {
require(msg.sender == owner, "Not authorized");
secretValue = _new; // Written to blockchain, publicly readable
}
}
Despite Solidity's private keyword, all blockchain state is publicly readable. Compact offers true cryptographic privacy through a completely different mechanism.
Compact: Explicit privacy with witness functions
Compact provides true cryptographic privacy through witness functions that supply off-chain data:
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
// Public ledger state (visible on-chain to everyone)
export ledger total_supply: Field;
export ledger owner: Bytes<32>;
export ledger deployer: Bytes<32>;
export ledger disclosed_secret: Field;
// Witness functions provide private off-chain data
witness get_secret_value(): Field;
witness get_caller(): Bytes<32>;
// Constants
const MAX_SUPPLY: Field = 1000000;
// Constructor with parameters for initialization
constructor(deployer_address: Bytes<32>, initial_supply: Field) {
owner = deployer_address;
deployer = deployer_address;
total_supply = initial_supply;
disclosed_secret = 0;
}
// Use private data without revealing it
export circuit use_secret_privately(): Field {
let secret: Field = get_secret_value();
let result: Field = secret + 100;
return result;
}
// Selectively disclose private data (explicit opt-in)
export circuit update_secret(): [] {
let caller: Bytes<32> = get_caller();
let secret: Field = get_secret_value();
assert(caller == owner, "Not authorized");
disclosed_secret = secret;
}
The witness function declarations in the Compact code look simple, but you need to understand how to provide this private data from your TypeScript application.
Witness function implementation (typescript side)
import { WalletBuilder } from "@midnight-ntwrk/wallet";
// When calling circuits, provide witnesses
const witnesses = {
get_secret_value: async () => {
// Return private value (never goes on-chain)
return 42n; // BigInt value for Field
},
get_caller: async () => {
// Return caller's address privately
const state = await wallet.state();
return state.address;
}
};
// Call circuit with witnesses
const result = await contract.callTx.use_secret_privately(witnesses);
Now you've seen both the Compact circuit code and the TypeScript witness implementation. Let's examine the fundamental privacy differences between these approaches.
Key differences
The comparison below highlights the critical privacy differences between Solidity's publicly readable state and Compact's cryptographically private witness functions.

Privacy model: Solidity's private keyword only affects Solidity-level visibility; anyone can read blockchain state directly using tools like Etherscan. Compact's witness functions provide cryptographically private data that never appears on-chain.
Witness functions: Declared with witness keyword, these functions retrieve private off-chain data. The data participates in zero-knowledge proof generation but remains hidden from the blockchain and all observers.
Selective disclosure: Compact allows explicit disclosure of private data to the public ledger. Without explicit disclosure (storing to a ledger field), witness data remains private forever.
State reading: Solidity generates automatic getters for public variables. Compact circuits can read ledger state directly during execution. External applications query state through the TypeScript runtime SDK via the indexer.
Constants: Both languages support constants. Solidity uses constant and immutable keywords. Compact uses const for compile-time constants.
You've learned how Compact provides genuine privacy through witness functions. Next, let's address a common challenge developers face when migrating: how to track and monitor state changes without Solidity's event system.
Pattern 5: Events and logging
This section demonstrates how to track state changes without Solidity's event system. You'll learn Compact's alternative approach using ledger counters and return values for observable state transitions.
Solidity: Event declaration and emission
Solidity provides an explicit event system for off-chain logging:
contract EventExample {
// Event declarations with indexed parameters
event Transfer(address indexed from, address indexed to, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint256 value);
event StateChanged(uint256 indexed oldValue, uint256 indexed newValue);
uint256 public value;
address public owner;
constructor() {
owner = msg.sender;
value = 0;
// Emit event in constructor
emit StateChanged(0, 0);
}
function updateValue(uint256 newValue) public {
require(msg.sender == owner, "Not authorized");
uint256 oldValue = value;
value = newValue;
// Explicitly emit event for off-chain tracking
emit StateChanged(oldValue, newValue);
}
function transfer(address to, uint256 amount) public {
// Transfer logic...
// Event includes indexed and non-indexed data
emit Transfer(msg.sender, to, amount);
}
}
Solidity's dedicated event system makes off-chain tracking straightforward. Compact achieves similar observability through a different pattern.
Compact: Ledger state changes and return values
Compact has no event system; observable changes occur through ledger updates and circuit return values:
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
export ledger value: Field;
export ledger owner: Bytes<32>;
// Counter fields for tracking changes (observable on ledger)
export ledger update_count: Counter;
export ledger transfer_count: Counter;
witness get_caller(): Bytes<32>;
constructor(initial_owner: Bytes<32>) {
owner = initial_owner;
value = 0;
}
// State changes are observable via ledger updates
export circuit update_value(new_value: Field): Field {
let old_value: Field = value; // Read current state
value = new_value; // Update state (observable)
update_count.increment(1); // Track change count
// Return value provides event-like data
return old_value;
}
export circuit transfer(to: Bytes<32>, amount: Field): [] {
let from: Bytes<32> = get_caller();
// Transfer logic...
// Increment counter (observable on ledger)
transfer_count.increment(1);
}
The ledger counters and return values provide observable data, but you need to know how to monitor these changes in your application.
Monitoring state changes (typescript side)
// TypeScript code for monitoring changes (not in .compact file)
import { indexerPublicDataProvider } from "@midnight-ntwrk/midnight-js-indexer-public-data-provider";
// Subscribe to contract state changes
const subscription = publicDataProvider
.subscribeToContractState(contractAddress)
.subscribe(async (state) => {
if (state) {
const ledger = ContractModule.ledger(state.data);
// Monitor ledger changes
console.log(`Value: ${ledger.value}`);
console.log(`Update count: ${ledger.update_count}`);
console.log(`Transfer count: ${ledger.transfer_count}`);
// Build event-like timeline from state changes
}
});
With both patterns demonstrated, let's compare how each language handles state change tracking.
Key differences
The visualization below demonstrates how Compact's ledger-based state tracking replaces Solidity's dedicated event emission system.

Event system: Solidity has dedicated event keyword and emit statement for logging. Compact has no event system; changes are tracked through ledger state updates, counter increments, and circuit return values.
Indexing: Solidity allows indexed parameters for efficient event filtering via logs. Compact applications must monitor ledger state changes via the indexer GraphQL API and filter off-chain.
Observability: Solidity explicitly emits events with emit EventName(params). Compact circuit return values and ledger updates serve as observable outputs. Applications monitor these changes through the indexer.
Off-chain tracking: Solidity events are specifically designed for off-chain indexing with bloom filters and log queries. Compact applications monitor ledger state through the Midnight indexer and build event-like timelines from state changes.
Privacy: Solidity events are always public and visible to everyone. Compact can selectively reveal information by choosing what to return from circuits or update in the ledger, keeping witness data private.
Pattern recommendation: To track state changes in Compact, maintain counter fields on the ledger (like update_count) and return relevant data from circuits. Off-chain applications query the indexer to build event-like timelines from state transitions.
With state monitoring strategies in place, let's turn our attention to a critical aspect of smart contract security: access control.
Pattern 6: Access control and modifiers
This section compares authorization patterns between the languages. You'll discover that Solidity's modifier syntax has no equivalent in Compact; instead, you use explicit helper circuit calls for authorization checks.
Solidity: Function modifiers
Solidity uses modifiers to inject reusable authorization logic:
contract AccessControl {
address public owner;
mapping(address => bool) public admins;
// Modifier for owner-only functions
modifier onlyOwner() {
require(msg.sender == owner, "Caller is not owner");
_; // Placeholder where function body executes
}
// Modifier for admin-only functions
modifier onlyAdmin() {
require(admins[msg.sender], "Caller is not admin");
_;
}
// Modifier with parameters
modifier hasMinBalance(uint256 minAmount) {
require(address(this).balance >= minAmount, "Insufficient balance");
_;
}
// Modifier for input validation
modifier validAddress(address _addr) {
require(_addr != address(0), "Invalid address");
_;
}
constructor() {
owner = msg.sender;
admins[msg.sender] = true;
}
// Using modifiers (automatic injection before function)
function transferOwnership(address newOwner)
public
onlyOwner // Check 1: caller is owner
validAddress(newOwner) // Check 2: address is valid
{
owner = newOwner;
}
function addAdmin(address newAdmin)
public
onlyOwner
validAddress(newAdmin)
{
admins[newAdmin] = true;
}
function adminAction() public onlyAdmin {
// Admin-only logic
}
}
Solidity's modifier syntax provides elegant code reuse for authorization. Compact achieves the same security with a more explicit pattern.
Compact: Helper functions and assertions
Compact has no modifier syntax; use helper functions with assert statements:
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
export ledger owner: Bytes<32>;
export ledger admin_count: Counter;
witness get_caller(): Bytes<32>;
constructor(initial_owner: Bytes<32>) {
owner = initial_owner;
}
// Helper function (replaces onlyOwner modifier)
// Call this at the start of circuits requiring owner access
circuit require_owner(caller: Bytes<32>): [] {
assert(caller == owner, "Caller is not owner");
// If assertion fails, circuit execution aborts
// Transaction is never submitted to blockchain
}
// Helper function with parameters (replaces validAddress modifier)
circuit require_non_zero_address(addr: Bytes<32>): [] {
const ZERO: Bytes<32> = pad(32, "");
assert(addr != ZERO, "Invalid address");
}
// Using helper functions (manual calls replace modifiers)
export circuit transfer_ownership(new_owner: Bytes<32>): [] {
let caller: Bytes<32> = get_caller();
// Manually apply checks (like modifiers would)
require_owner(caller); // Check 1
require_non_zero_address(new_owner); // Check 2
// Function logic executes only if all checks pass
owner = new_owner;
}
export circuit add_admin(): [] {
let caller: Bytes<32> = get_caller();
// Manual authorization checks
require_owner(caller);
// Simplified admin addition (just tracks count)
admin_count.increment(1);
}
export circuit admin_action(): [] {
let caller: Bytes<32> = get_caller();
// Check owner status
require_owner(caller);
// Admin-only logic here
}
Both approaches secure your functions, but Compact has no modifier equivalent. All authorization checks must be done explicitly through helper circuit calls.
Key differences
The diagram below compares Solidity's automatic modifier injection with Compact's explicit helper function approach for authorization.

Syntax: Solidity has dedicated modifier keyword with _ placeholder for automatic function body injection. Compact has no equivalent to modifiers; use explicit helper circuit calls at the start of circuits instead.
Reusability: Solidity modifiers automatically inject code before function execution with clean syntax. Compact has no modifier equivalent; you must manually call helper circuits in each circuit that needs authorization, giving explicit control but requiring more verbose code.
Execution order: Solidity modifiers execute in the order listed in function declaration. Compact has no modifiers; helper circuits execute in the exact order you call them, providing explicit control flow.
Caller context: Solidity provides msg.sender automatically in all functions. Compact requires witness functions like get_caller() to retrieve the caller's address.
Authorization logic: Solidity uses require() for authorization checks that revert on failure. Compact uses assert() (similar to Solidity's require) which aborts circuit execution if the condition fails, preventing the transaction from being submitted.
Authorization privacy: Solidity authorization checks are publicly visible in transaction data. Compact can verify authorization privately through witness proofs without revealing the caller on-chain (unless explicitly disclosed to ledger state).
Pattern recommendation: Create a library of reusable helper circuits for common authorization checks (owner, admin, balance, etc.) and call them consistently at the start of circuits requiring access control.
Having established secure access control patterns, let's examine how Compact handles function visibility and scope.
Pattern 7: Function visibility
This section clarifies how function visibility works in each language. You'll learn how Compact's simple export model replaces Solidity's four-level visibility system.
Solidity: Visibility modifiers
Solidity provides four visibility levels and state access modifiers:
contract VisibilityExample {
uint256 private internalState;
// Public: callable externally and internally
function publicFunction() public view returns (uint256) {
return internalState;
}
// External: only callable from outside (more gas efficient)
function externalFunction(uint256 value) external {
internalState = value;
}
// Internal: callable within contract and derived contracts
function internalFunction() internal view returns (uint256) {
return internalState * 2;
}
// Private: only callable within this contract
function privateFunction() private pure returns (uint256) {
return 42;
}
// Pure: doesn't read or modify state
function pureMath(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
// View: reads but doesn't modify state
function viewState() public view returns (uint256) {
return internalState;
}
// Payable: can receive Ether
function deposit() public payable {
internalState += msg.value;
}
}
// Inheritance example
contract DerivedExample is VisibilityExample {
function callInternal() public view returns (uint256) {
// Can call internal function from parent
return internalFunction();
}
// Cannot call privateFunction() from parent
}
Solidity offers four distinct visibility levels with specific gas and inheritance implications. Compact simplifies this with a binary exported/non-exported model.
Compact: Export keyword and circuit Scope
Compact uses export for public circuits; non-exported functions are internal:
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
export ledger internal_state: Field;
constructor() {
internal_state = 0;
}
// Exported circuit: callable from outside
// Like Solidity's public/external
export circuit public_function(): Field {
return internal_state;
}
// Exported circuit with state modification
export circuit external_function(value: Field): [] {
internal_state = value;
}
// Non-exported function: internal to this file only
// Like Solidity's internal/private (no inheritance distinction)
circuit internal_helper(): Field {
return internal_state * 2;
}
// Non-exported helper (private to file)
circuit private_helper(): Field {
return 42;
}
// Pure computation (no state access)
// Like Solidity's pure functions
circuit pure_math(a: Field, b: Field): Field {
return a + b;
}
// Exported circuit using helpers
export circuit complex_calculation(): Field {
let internal_result: Field = internal_helper();
let private_result: Field = private_helper();
// Non-exported functions can be called within file
return pure_math(internal_result, private_result);
}
// Circuit with state modification and return value
// No explicit "view" or "pure" keyword
export circuit update_and_return(new_value: Field): Field {
let old_value: Field = internal_state;
internal_state = new_value;
return old_value;
}
The visibility systems reveal different design philosophies. Here's what you need to know for migration.
Key differences
The chart below maps Solidity's four-level visibility system to Compact's simplified export-based model.
Visibility keywords: Solidity has public, external, internal, private. Compact has export for public circuits; non-exported functions are internal to the file.
External vs public: Solidity distinguishes external (more gas-efficient, calldata) from public (can call internally, memory). Compact treats all exported circuits similarly without optimization difference.
State access modifiers: Solidity has view (read-only) and pure (no state access). Compact has no explicit modifiers; circuits can both read and modify state directly. External applications also read state via TypeScript runtime SDK.
Inheritance: Solidity supports full inheritance with internal functions accessible to derived contracts and private functions not accessible. Compact has no inheritance model; use composition by importing circuits from other files.
Payable functions: Solidity uses payable for functions that receive Ether. Compact has no native token handling within circuits; token operations are managed at the protocol level by the Midnight runtime.
Understanding function visibility prepares you for the next essential pattern: contract initialization.
Pattern 8: Contract initialization (constructors)
This section explains contract initialization patterns in both languages. You'll understand how Compact's constructor works similarly to Solidity's but requires explicit parameter passing for context.
Solidity: Constructor pattern
Solidity uses the constructor keyword for one-time initialization:
// Simple constructor with automatic context
contract SimpleConstructor {
address public owner;
uint256 public createdAt;
constructor() {
owner = msg.sender; // Automatic
createdAt = block.timestamp; // Automatic
}
}
// Constructor with parameters
contract ParameterizedConstructor {
string public name;
uint256 public totalSupply;
address public admin;
constructor(string memory _name, uint256 _supply) {
name = _name;
totalSupply = _supply;
admin = msg.sender; // Still automatic
}
}
// Constructor with validation
contract ValidatedConstructor {
address public owner;
uint256 public initialSupply;
uint256 public constant MAX_SUPPLY = 1000000;
constructor(uint256 _supply) {
// Validation with require
require(_supply > 0, "Supply must be positive");
require(_supply <= MAX_SUPPLY, "Supply exceeds maximum");
owner = msg.sender;
initialSupply = _supply;
}
}
// Immutable variables (set once in constructor)
contract ImmutableExample {
address public immutable deployer;
uint256 public immutable deployTime;
constructor() {
deployer = msg.sender;
deployTime = block.timestamp;
}
}
Solidity's constructor has special syntax and automatic context variables. Compact treats constructors similarly but requires explicit parameters.
Compact: Constructor circuit
Compact uses a constructor that works similarly to Solidity, accepting parameters directly:
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
// Simple constructor with parameters
export ledger owner: Bytes<32>;
export ledger created_at: Field;
export ledger total_supply: Field;
const MAX_SUPPLY: Field = 1000000;
constructor(
initial_owner: Bytes<32>,
timestamp: Field,
supply: Field
) {
// Validation using assert
assert(supply > 0, "Supply must be positive");
assert(supply <= MAX_SUPPLY, "Supply exceeds maximum");
// Address validation
const ZERO_BYTES: Bytes<32> = pad(32, "");
assert(initial_owner != ZERO_BYTES, "Invalid owner");
// Initialize state
owner = initial_owner;
created_at = timestamp;
total_supply = supply;
}
// State-modifying circuits work with initialized state
export circuit update_supply(additional: Field): [] {
assert(total_supply + additional <= MAX_SUPPLY, "Exceeds max supply");
total_supply = total_supply + additional;
}
// Immutable fields (set once in constructor, never changed)
export ledger deployer: Bytes<32>;
export ledger deploy_time: Field;
constructor(deployer_address: Bytes<32>, timestamp: Field) {
deployer = deployer_address;
deploy_time = timestamp;
// Application logic should prevent further changes to these fields
}
The constructor looks straightforward, but you need to understand how to call it correctly during deployment.
Constructor call in deployment script
// TypeScript deployment code
import { deployContract } from "@midnight-ntwrk/midnight-js-contracts";
// Get wallet state for address
const state = await wallet.state();
// Deploy with constructor parameters
const deployed = await deployContract(providers, {
contract: contractInstance,
// Pass constructor arguments directly
initialState: {
initial_owner: state.address,
timestamp: BigInt(Date.now()),
supply: 1000000n
},
privateStateId: "contractState",
initialPrivateState: {}
});
console.log(`Deployed: ${deployed.deployTxData.public.contractAddress}`);
Now that you've seen both the contract code and deployment script, let's examine the key differences in initialization patterns.
Key differences
The illustration below shows how constructor patterns differ between Solidity's special syntax and Compact's regular circuit approach.

Constructor pattern: Both Solidity and Compact use constructors that accept parameters directly. The syntax and initialization approach are similar.
Caller Context: Solidity provides msg.sender and block.timestamp automatically in constructors. Compact requires these values as explicit constructor parameters, passed from the deployment script.
Validation: Solidity uses require() for constructor validation that reverts deployment if failed. Compact uses assert(), which aborts circuit execution if validation fails.
Inheritance: Solidity constructors can call parent constructors with special syntax. Compact has no inheritance; cannot chain constructor calls. Use composition patterns instead.
Immutability: Solidity uses immutable keyword for values set once in constructor. Compact has no built-in immutability enforcement for ledger fields; implement "set-once" patterns in your application logic if needed.
With initialization patterns covered, let's address a critical aspect of robust smart contract development: input validation and error handling.
Pattern 9: Input validation and error handling
This section compares validation mechanisms between the languages. You'll learn why Compact uses only assert() statements that prevent proof generation.
Solidity: Require, revert, and assert
Solidity provides three mechanisms for validation and error handling:
contract ValidationExample {
address public owner;
uint256 public balance;
constructor() {
owner = msg.sender;
}
// Using require for input validation (common pattern)
function deposit(uint256 amount) public {
require(amount > 0, "Amount must be positive");
require(amount <= 1000000, "Amount exceeds maximum");
balance += amount;
// If requires fail, transaction reverts and gas refunded
}
// Multiple require statements
function withdraw(uint256 amount) public {
require(msg.sender == owner, "Not authorized");
require(amount > 0, "Invalid amount");
require(balance >= amount, "Insufficient balance");
balance -= amount;
// State changes only persist if all requires pass
}
// Using revert for complex conditions
function complexValidation(address user, uint256 value) public {
if (user == address(0)) {
revert("Invalid user address");
}
if (value < 100 || value > 10000) {
revert("Value out of range");
}
// Process transaction only if validations pass
}
// Assert for invariants (should never fail in correct code)
function criticalOperation(uint256 a, uint256 b) public pure returns (uint256) {
uint256 result = a + b;
assert(result >= a); // Check overflow
return result;
}
}
The diagram below illustrates Solidity's three validation mechanisms (require, revert, assert) and their different behaviors.

Solidity provides three validation mechanisms with different behaviors. Compact simplifies this to a single assertion pattern.
Compact: Assert statements only
Compact uses assert() statements that abort circuit execution when validation fails:
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
export ledger owner: Bytes<32>;
export ledger balance: Field;
witness get_caller(): Bytes<32>;
constructor(initial_owner: Bytes<32>) {
owner = initial_owner;
balance = 0;
}
// Using assert for input validation
export circuit deposit(amount: Field): [] {
assert(amount > 0, "Amount must be positive");
assert(amount <= 1000000, "Amount exceeds maximum");
balance = balance + amount;
// If assertions fail, circuit execution aborts
}
// Multiple assert statements
export circuit withdraw(amount: Field): [] {
let caller: Bytes<32> = get_caller();
assert(caller == owner, "Not authorized");
assert(amount > 0, "Invalid amount");
assert(balance >= amount, "Insufficient balance");
balance = balance - amount;
// State changes only persist if all assertions pass
}
// Complex validation with conditional logic
export circuit complex_validation(user: Bytes<32>, value: Field): [] {
const ZERO: Bytes<32> = pad(32, "");
assert(user != ZERO, "Invalid user address");
assert(value >= 100 && value <= 10000, "Value out of range");
// Process transaction only if validations pass
}
// Return status codes for recoverable errors
// (alternative pattern when you don't want to abort)
export circuit withdraw_with_status(amount: Field): Field {
let caller: Bytes<32> = get_caller();
// Check conditions and return status codes
if (caller != owner) {
return 1; // Error code: unauthorized
}
if (balance < amount) {
return 2; // Error code: insufficient balance
}
balance = balance - amount;
return 0; // Success code
}
// Compact has no try-catch
// Design with status codes or boolean returns for error cases
export circuit safe_operation(): Boolean {
// Return true for success, false for failure
// instead of throwing errors
// Perform operation
return true; // Success
}
The visualization below demonstrates how Compact's single assert mechanism prevents proof generation when validation fails.

The single assertion mechanism requires a different approach to error handling. Here are the critical differences.
Key differences
Validation mechanism: Solidity has require(), revert(), and assert() with different gas behaviors and use cases. Compact only has assert() for validation.
Error handling: Solidity's failed validations revert transactions and return error messages to callers. Compact's failed assertions abort circuit execution, preventing the transaction from being created and submitted to the blockchain.
Transaction costs: Solidity failed validations consume gas up to the failure point, then refund remaining. Compact failed assertions prevent transaction submission entirely, so no on-chain cost occurs for validation failures.
Error messages: Solidity error messages are returned to callers and visible on explorers. Compact error messages are for developers during testing; assertions either pass or fail without revealing error details on-chain.
Recovery: Solidity exceptions can be caught with try-catch for error recovery patterns. Compact has no exception handling; design with status codes or boolean returns for scenarios where you want to handle failures gracefully.
Best practice: Use assert() for critical validations that must never fail in correct usage (invariants, authorization). For recoverable errors (like insufficient balance), return status codes or boolean success flags instead of asserting, allowing applications to handle errors gracefully.
You've now mastered nine fundamental patterns that have direct Solidity equivalents. For our final pattern, we'll explore Compact's unique privacy features.
Pattern 10: Privacy-specific features (compact-only)
This section showcases Compact's unique privacy capabilities that have no Solidity equivalent. You'll explore zero-knowledge proof patterns that enable genuinely private computation.
The diagram below illustrates the zero-knowledge proof flow that enables Compact's privacy features, showing how witness data participates in proof generation without appearing on-chain.

Important note: The examples in this pattern demonstrate privacy concepts. Specific cryptographic functions may vary based on the CompactStandardLibrary version. Always refer to the official Compact documentation for exact API details.
Private state with witness functions
pragma language_version >= 0.18.0;
import CompactStandardLibrary;
// Public ledger (visible on-chain to everyone)
export ledger public_counter: Field;
export ledger disclosed_value: Field;
export ledger computation_result: Field;
// Witness functions (private off-chain data)
// These provide private inputs that never appear on-chain
witness get_secret_value(): Field;
constructor() {
public_counter = 0;
disclosed_value = 0;
computation_result = 0;
}
// Example 1: Private computation with public result
export circuit compute_privately(): [] {
// get_secret_value() retrieves private input
let secret: Field = get_secret_value(); // Private
// Compute with private data
// The computation happens in the proof, but secret never goes on-chain
let result: Field = secret * 2 + 100;
// Store result publicly (secret remains hidden)
computation_result = result;
}
// Example 2: Selective disclosure
export circuit reveal_secret(): [] {
let secret: Field = get_secret_value(); // Private
// Explicitly choose to disclose private data
// This is the only way to make witness data public
disclosed_value = secret;
public_counter = public_counter + 1;
}
These examples showcase powerful privacy patterns. Let's summarize the key concepts that make them possible.
Key privacy concepts
Witness functions: Declared with witness keyword, these provide private off-chain data that participates in zero-knowledge proof generation but never appears on-chain. The blockchain only sees the proof, not the data.
Selective disclosure: Private data remains hidden unless explicitly written to a ledger field. You control what becomes public and what stays private, enabling granular privacy controls.
Commitments and Merkle Trees: Compact provides MerkleTree and HistoricMerkleTree types for storing commitments to private data. These allow proving membership without revealing which specific item is being proven.
Privacy patterns:
- Prove properties without revealing data: Prove you're over 18 without showing birthdate
- Commit to values without disclosure: Sealed-bid auctions where bids are hidden until reveal phase
- Selective disclosure timing: Keep data private during sensitive periods, reveal later
- Anonymous actions: Private operations where identity stays hidden
No Solidity equivalent: These patterns are impossible in Solidity because all transaction data, inputs, and state changes are public and visible on-chain. Compact's zero-knowledge proof architecture enables genuinely private computation where only the proof of correct execution is public.
Zero-knowledge proofs: When a circuit executes, it generates a zero-knowledge proof that proves "I executed this code correctly with some private inputs" without revealing what those inputs were. The blockchain verifies the proof but never sees the private data.
Key conceptual differences
This section moves beyond syntax to explore the architectural differences that drive language design choices.
Privacy model
Solidity: Everything is public by default. The private keyword only affects Solidity-level visibility; anyone can read blockchain state directly using block explorers or archive nodes.
Compact: Explicit public/private separation. Ledger state is public and visible to everyone. Witness functions provide truly private data through zero-knowledge proofs that cryptographically guarantee privacy.
The comparison below visualizes how Solidity's transparent-by-default model differs fundamentally from Compact's explicit public/private separation.

Execution model
Solidity: Code executes directly on-chain in the Ethereum Virtual Machine. Every node re-executes transactions. Gas costs are proportional to computational complexity.
Compact: Code compiles to zero-knowledge circuits. Proofs generate off-chain using private data. Only the proof is submitted on-chain. On-chain validation has constant cost regardless of computation complexity.
The diagram below illustrates the architectural difference between Solidity's on-chain execution and Compact's off-chain proof generation with on-chain verification.

State management
Solidity: State variables declared inside contracts. Functions directly read and write state using simple assignment and access.
Compact: Ledger state declared globally with export ledger. Circuits can both read and modify state through direct assignments. External applications query state via TypeScript runtime SDK using the indexer.
Data constraints
Solidity: Supports dynamic arrays that grow at runtime, unbounded loops based on array length, runtime-sized strings, and signed integers for negative numbers.
Compact: Requires compile-time bounds on most data structures. Only unsigned integers supported (no negative numbers). All sizes must be known at compile time for proof circuit generation. Uses Opaque<"string"> for dynamic strings.
The chart below summarizes the fundamental data constraint differences between Solidity's dynamic runtime flexibility and Compact's fixed compile-time requirements.

Caller context
Solidity: msg.sender automatically provides transaction sender. block.timestamp, block.number, msg.value etc. automatically available in all functions.
Compact: Caller address retrieved via witness functions for privacy. Block data passed as circuit parameters from deployment/interaction scripts. Nothing is automatic; everything is explicit.
Migration mindset
When migrating from Solidity to Compact, shift your thinking:
- From transparent to privacy-first: Default to keeping data private, explicitly disclose only when necessary.
-
From dynamic to bounded: Determine maximum sizes for data structures upfront, or use
Opaquetypes for dynamic data. - From runtime to compile-time: Most constraints must be expressible at compile time for circuit generation.
- From automatic to explicit: Explicitly handle caller context, timestamps, and authorization.
- From gas optimization to proof optimization: Minimize proof complexity rather than gas costs.
Common pitfalls and solutions
This section identifies the seven most common mistakes developers make during migration. You'll learn practical solutions to avoid these pitfalls and accelerate your Compact development.
The diagram below highlights the six most common migration pitfalls developers encounter when converting Solidity contracts to Compact.

Pitfall 1: Incorrect return type syntax
Wrong:
export circuit increment(): Void { // Outdated syntax
counter.increment(1);
}
Correct:
export circuit increment(): [] { // Modern syntax
counter.increment(1);
}
Solution: Use [] (empty tuple) for circuits that don't return values. This is the modern Compact syntax.
Pitfall 2: Using dynamic sizes without Opaque
Wrong:
export ledger name: String; // No size specified
Correct:
export ledger name: Opaque<"string">; // Dynamic string using Opaque
// OR
export ledger name: Bytes<100>; // Fixed maximum size
Solution: Use Opaque<"string"> for dynamic strings or specify fixed sizes with Bytes<N>.
Pitfall 3: Unbounded loops
Wrong:
export circuit process_all(count: Field): [] {
for (let i: Field = 0; i < count; i = i + 1) {
// Loop bound depends on runtime parameter
}
}
Correct:
export circuit process_all(count: Field): [] {
// Add compile-time maximum bound
for (let i: Field = 0; i < count && i < 1000; i = i + 1) {
// Now compiler knows maximum 1000 iterations
}
}
Solution: Always add compile-time upper bounds to loops using && operator.
Pitfall 4: Forgetting CompactStandardLibrary import
Wrong:
pragma language_version >= 0.18.0;
// Missing import
export ledger value: Field;
Correct:
pragma language_version >= 0.18.0;
import CompactStandardLibrary; // Required
export ledger value: Field;
Solution: Always include import CompactStandardLibrary; after the pragma.
Pitfall 5: Using signed integers
Wrong:
export ledger temperature: Int32; // No signed types
Correct:
export ledger temperature: Field; // Use Field for general numeric values
// Handle negative logic differently using conditional checks
Solution: Compact only has unsigned integers and Field. Redesign logic to avoid negative numbers.
Pitfall 6: Expecting automatic msg.sender
Wrong:
export circuit restricted_action(): [] {
assert(msg.sender == owner, "Not authorized"); // Doesn't exist
}
Correct:
witness get_caller(): Bytes<32>;
export circuit restricted_action(): [] {
let caller: Bytes<32> = get_caller(); // Explicit witness
assert(caller == owner, "Not authorized");
}
Solution: Use witness functions to get caller address. Provide the address from TypeScript when calling the circuit.
Pitfall 7: Using disclose() incorrectly
Wrong (for regular types):
export circuit update_value(new_value: Field): [] {
stored_value = disclose(new_value); // Not needed for regular types
}
Correct (for regular types):
export circuit update_value(new_value: Field): [] {
stored_value = new_value; // Direct assignment
}
Correct (for Opaque types):
export circuit store_message(msg: Opaque<"string">): [] {
message = disclose(msg); // Required for Opaque types
}
Solution: Regular types (Field, Bytes, Boolean, Uint) can be assigned directly to ledger fields. Opaque types like Opaque<"string"> require disclose() to make the data public on the ledger, as Compact treats user inputs as private by default.
Complete migration Example
Let's migrate a simple ERC20-like token contract from Solidity to Compact, applying all the patterns you've learned.
Solidity token contract
The diagram below provides a visual overview of the Solidity ERC20-like token contract structure before migration.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleToken {
string public name = "SimpleToken";
string public symbol = "STK";
uint256 public totalSupply;
address public owner;
mapping(address => uint256) public balances;
event Transfer(address indexed from, address indexed to, uint256 amount);
event Mint(address indexed to, uint256 amount);
constructor(uint256 _initialSupply) {
owner = msg.sender;
totalSupply = _initialSupply;
balances[owner] = _initialSupply;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function transfer(address to, uint256 amount) public returns (bool) {
require(balances[msg.sender] >= amount, "Insufficient balance");
require(to != address(0), "Invalid recipient");
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function mint(address to, uint256 amount) public onlyOwner {
require(to != address(0), "Invalid recipient");
totalSupply += amount;
balances[to] += amount;
emit Mint(to, amount);
}
function balanceOf(address account) public view returns (uint256) {
return balances[account];
}
}
This standard ERC20-like contract uses familiar Solidity patterns. Now let's see the Compact equivalent.
Compact token contract
The visualization below shows the migrated Compact token contract structure with ledger state, circuits, and witness functions.

pragma language_version >= 0.18.0;
import CompactStandardLibrary;
// Public ledger state
export ledger total_supply: Field;
export ledger owner: Bytes<32>;
export ledger owner_balance: Field;
export ledger transfer_count: Counter;
export ledger mint_count: Counter;
witness get_caller(): Bytes<32>;
constructor(initial_supply: Field, owner_address: Bytes<32>) {
total_supply = initial_supply;
owner = owner_address;
owner_balance = initial_supply;
}
// Helper function (replaces onlyOwner modifier)
circuit require_owner(caller: Bytes<32>): [] {
assert(caller == owner, "Not owner");
}
// Helper function for address validation
circuit require_valid_address(addr: Bytes<32>): [] {
const ZERO: Bytes<32> = pad(32, "");
assert(addr != ZERO, "Invalid recipient");
}
// Transfer circuit
export circuit transfer(to: Bytes<32>, amount: Field): [] {
let caller: Bytes<32> = get_caller();
// Validation
require_valid_address(to);
assert(owner_balance >= amount, "Insufficient balance");
// Update balances (simplified - only tracks owner balance)
owner_balance = owner_balance - amount;
// Increment counter (replaces event)
transfer_count.increment(1);
}
// Mint circuit
export circuit mint(amount: Field): [] {
let caller: Bytes<32> = get_caller();
// Authorization and validation
require_owner(caller);
// Update state
total_supply = total_supply + amount;
owner_balance = owner_balance + amount;
// Increment counter (replaces event)
mint_count.increment(1);
}
// Read balance through circuit
export circuit get_balance(): Field {
return owner_balance;
}
Typescript deployment script
import { deployContract } from "@midnight-ntwrk/midnight-js-contracts";
import { WalletBuilder } from "@midnight-ntwrk/wallet";
// Build wallet
const wallet = await WalletBuilder.buildFromSeed(
indexerUrl,
indexerWsUrl,
proofServerUrl,
nodeUrl,
walletSeed,
networkId,
"info"
);
wallet.start();
// Wait for sync
await firstValueFrom(
wallet.state().pipe(filter(s => s.syncProgress?.synced === true))
);
// Get wallet address
const state = await firstValueFrom(wallet.state());
// Deploy contract
const deployed = await deployContract(providers, {
contract: contractInstance,
privateStateId: "tokenState",
initialPrivateState: {},
initialState: {
initial_supply: 1000000n,
owner_address: state.address
}
});
console.log(`Deployed: ${deployed.deployTxData.public.contractAddress}`);
Typescript interaction script
import { findDeployedContract } from "@midnight-ntwrk/midnight-js-contracts";
import { indexerPublicDataProvider } from "@midnight-ntwrk/midnight-js-indexer-public-data-provider";
// Connect to deployed contract
const deployed = await findDeployedContract(providers, {
contractAddress: deploymentAddress,
contract: contractInstance,
privateStateId: "tokenState",
initialPrivateState: {}
});
// Read balance
const state = await publicDataProvider.queryContractState(contractAddress);
if (state) {
const ledger = TokenModule.ledger(state.data);
console.log(`Owner balance: ${ledger.owner_balance}`);
console.log(`Total supply: ${ledger.total_supply}`);
}
// Call transfer
const witnesses = {
get_caller: async () => walletState.address
};
const tx = await deployed.callTx.transfer(
recipientAddress,
100n,
witnesses
);
console.log(`Transfer completed: ${tx.public.txId}`);
// Call mint (owner only)
const mintTx = await deployed.callTx.mint(
500n,
{ get_caller: async () => walletState.address }
);
console.log(`Minted: ${mintTx.public.txId}`);
The diagram below summarizes the eight critical migration decisions made during the ERC20 conversion from Solidity to Compact.

You've now seen the complete migration including contract code, deployment, and interaction. Let's highlight the critical migration decisions.
Key migration points
No mapping: Compact has no mapping type. This example uses simplified single-balance tracking. Production code needs alternative patterns like MerkleTrees for managing multiple balances.
No events: Use counters on the ledger and return values. Applications monitor state changes via indexer.
Explicit context: No
msg.sender. Use witness functions to provide caller address.Constructor parameters: Compact constructors accept parameters directly, similar to Solidity.
Return types: Use
[]for circuits with no return value (modern Compact syntax).Balance reading: Circuits can read state directly. External applications also read state via TypeScript using indexer queries.
Modifiers: Use helper circuits with
assert()instead of modifiers, called explicitly.Witness providers: TypeScript provides witness data when calling circuits.
Validation: Use
assert()for validation. Failed assertions abort circuit execution.State access: Circuits can read and write ledger state directly during execution.
When to use each language
This section provides strategic guidance for choosing the right language for your project. You'll learn when Compact's privacy features are essential, when Solidity's mature ecosystem is better, and when a hybrid approach makes sense.
Understanding the strengths of each language helps you choose appropriately.
Let's start by examining scenarios where Compact's privacy features make it the superior choice.
Choose compact when
Privacy is essential: Applications requiring confidential data processing, private transactions, or selective information disclosure.
Use cases:
- Healthcare records with HIPAA compliance requirements
- Confidential voting systems
- Private financial transactions
- Sealed-bid auctions where bid amounts must stay hidden
- Identity verification without revealing personal information
- KYC compliance with data protection regulations
- Private supply chain tracking
- Confidential credit scoring
Regulatory compliance: Industries with strict data privacy regulations that prohibit public data storage or require selective disclosure controls.
Selective disclosure: Scenarios requiring proof of properties without revealing underlying data.
The chart below illustrates use cases where Compact's privacy features provide essential value that Solidity cannot match.

Choose solidity when
Full transparency required: Applications benefiting from complete auditability and public verification where privacy is not needed.
Use cases:
- Public treasuries and transparent fund management
- Open governance systems and DAO voting
- Transparent token distributions
- Decentralized exchanges with public order books
- Community-funded projects requiring full accountability
- Public records and registries
Complex DeFi composability: Applications requiring integration with existing Ethereum protocols.
Dynamic structures essential: Applications needing runtime-determined data structures or signed integers.
Mature ecosystem critical: Projects requiring extensive battle-tested libraries and established auditing processes.
The visualization below shows scenarios where Solidity's transparency and mature ecosystem make it the superior choice.

Hybrid approach
Consider using both languages in a single application architecture:
Pattern: Use Solidity for public coordination and Compact for private operations, connected via cross-chain bridges.
Example: Private NFT marketplace
- Solidity contracts manage public NFT ownership records
- Compact contracts handle private bid amounts and confidential buyer identities
- Public can verify ownership, but bid details stay private
The diagram below demonstrates how hybrid architectures can combine Solidity's public coordination with Compact's private operations for optimal results.

Additional resources
Official documentation
- Midnight Network Docs: https://docs.midnight.network
- Compact Language Reference: https://docs.midnight.network/develop/reference/compact
- Midnight API: https://docs.midnight.network/develop/reference/midnight-api
- Getting Started Tutorial: https://docs.midnight.network/develop/tutorial
Tools and platforms
- Compact Compiler: https://github.com/midnightntwrk/compact
- Lace Wallet: https://www.lace.io
- Example Counter DApp: https://github.com/midnightntwrk/example-counter
- Example Bulletin Board DApp: https://github.com/midnightntwrk/example-bboard
Community
- Discord: https://discord.com/invite/midnightnetwork
- Twitter/X: @MidnightNtwrk
- GitHub: https://github.com/midnightntwrk
Conclusion
Your Solidity expertise provides a strong foundation for Compact development. The core concepts of state management, transactions, and contract logic transfer directly. What's new is the explicit privacy model and the constraints of zero-knowledge proof systems.
With the patterns, differences, and examples outlined in this guide, you are ready to build the next generation of privacy-preserving decentralized applications on Midnight Network. Start with simple contracts, gradually add privacy features, and explore the unique capabilities that zero-knowledge proofs enable.
Privacy is not just a feature, it's a fundamental design principle. Compact gives you the tools to build applications where users control their data, sensitive information stays confidential, and trust is established through cryptographic proofs rather than transparency.
Key Takeaways:
- Privacy Architecture: Ledger for public state, witness functions for truly private data with cryptographic guarantees
- Compile-Time Constraints: Fixed sizes for most structures, bounded loops, unsigned integers - all determined before execution
- No Direct Equivalents: Mappings, inheritance, events require alternative patterns
- Explicit Context: Manual handling of caller context and timestamps through witness functions
- Proof-Aware Design: Optimize for proof complexity and generation time rather than gas costs
Welcome to privacy-first smart contract development.



Top comments (11)
I have always known you to be a smart chap, interesting view on how to migrate. Keep up with the good work.
Thank you very much
Wow such a comprehensive coverage. This really is the best education I have had on compact. It's now easy to migrate from solidity to compact. God bless you for sharing.
Thanks for your kind words
It’s really true that one cannot get enough perspective on concepts.
Thank you Henry sharing such an interesting piece.
Thank you very much
Superb work
Quite comprehensive
well done, this is great
Very Educative. I've always struggled with migration myself. This is so helpful. Keep em coming, it's clear I still have a lot to learn
Impressive writeup. Keep up the good work.