DEV Community

henry messiah tmt
henry messiah tmt

Posted on

Solidity to Compact: Developer's Migration Guide

Header banner showing the title 'Solidity to Compact: A Developer's Migration Guide' with visual elements representing the transition from Ethereum's Solidity to Midnight's Compact language

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.

Architectural diagram comparing Solidity's contract-based structure on the left with Compact's ledger and circuit model on the right, illustrating the fundamental organizational differences between the two languages

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.
Comparison chart showing data type differences: Solidity's flexible runtime sizing with signed integers versus Compact's fixed compile-time bounds with unsigned-only types

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.
Diagram illustrating loop bound differences: Solidity's dynamic runtime bounds versus Compact's compile-time constant maximum iteration requirements for zero-knowledge proof circuits

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.
Side-by-side comparison showing Solidity's transparent-by-default model where all data is publicly readable versus Compact's explicit privacy model using witness functions for cryptographically private data

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    });
Enter fullscreen mode Exit fullscreen mode

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.
Comparison diagram showing Solidity's dedicated event emission system with indexed parameters versus Compact's ledger-based state tracking using counters and return values

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.
Flowchart comparing Solidity's automatic modifier injection pattern with Compact's explicit helper function approach for implementing authorization and access control

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Chart mapping Solidity's four-level visibility system (public, external, internal, private) to Compact's simplified two-level model using export keyword versus non-exported functions

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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.
Diagram comparing Solidity's special constructor syntax with automatic context variables versus Compact's regular circuit approach requiring explicit parameters from deployment scripts

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

The diagram below illustrates Solidity's three validation mechanisms (require, revert, assert) and their different behaviors.
Flowchart showing Solidity's three validation mechanisms: require for input validation, revert for complex conditions, and assert for invariant checking, with their different gas refund 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
}
Enter fullscreen mode Exit fullscreen mode

The visualization below demonstrates how Compact's single assert mechanism prevents proof generation when validation fails.
Diagram illustrating Compact's single assert mechanism that prevents zero-knowledge proof generation when validation fails, with no on-chain transaction submission for failed assertions

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.
Process flow diagram showing how witness data participates in off-chain zero-knowledge proof generation without appearing on-chain, with only the verification proof submitted to the blockchain

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;
}
Enter fullscreen mode Exit fullscreen mode

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.
Architectural comparison illustrating Solidity's transparent-by-default approach where all state is publicly readable versus Compact's explicit public/private separation using ledger state and witness functions

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.
System architecture diagram comparing Solidity's on-chain execution in the EVM where every node re-executes transactions versus Compact's off-chain proof generation with constant-time 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.
Table comparing data constraints: Solidity's support for dynamic arrays, unbounded loops, and signed integers versus Compact's requirement for compile-time bounds, fixed-size structures, and unsigned-only integers

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:

  1. From transparent to privacy-first: Default to keeping data private, explicitly disclose only when necessary.
  2. From dynamic to bounded: Determine maximum sizes for data structures upfront, or use Opaque types for dynamic data.
  3. From runtime to compile-time: Most constraints must be expressible at compile time for circuit generation.
  4. From automatic to explicit: Explicitly handle caller context, timestamps, and authorization.
  5. 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.
Visual checklist highlighting seven common migration pitfalls: forgetting type suffixes, using dynamic sizes, unbounded loops, missing library imports, using signed integers, and expecting automatic msg.sender

Pitfall 1: Incorrect return type syntax

Wrong:

export circuit increment(): Void {  // Outdated syntax
    counter.increment(1);
}
Enter fullscreen mode Exit fullscreen mode

Correct:

export circuit increment(): [] {  // Modern syntax
    counter.increment(1);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Correct:

export ledger name: Opaque<"string">;  // Dynamic string using Opaque
// OR
export ledger name: Bytes<100>;  // Fixed maximum size
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Correct:

pragma language_version >= 0.18.0;
import CompactStandardLibrary;  // Required
export ledger value: Field;
Enter fullscreen mode Exit fullscreen mode

Solution: Always include import CompactStandardLibrary; after the pragma.

Pitfall 5: Using signed integers

Wrong:

export ledger temperature: Int32;  // No signed types
Enter fullscreen mode Exit fullscreen mode

Correct:

export ledger temperature: Field;  // Use Field for general numeric values
// Handle negative logic differently using conditional checks
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Correct:

witness get_caller(): Bytes<32>;

export circuit restricted_action(): [] {
    let caller: Bytes<32> = get_caller();  // Explicit witness
    assert(caller == owner, "Not authorized");
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Correct (for regular types):

export circuit update_value(new_value: Field): [] {
    stored_value = new_value;  // Direct assignment
}
Enter fullscreen mode Exit fullscreen mode

Correct (for Opaque types):

export circuit store_message(msg: Opaque<"string">): [] {
    message = disclose(msg);  // Required for Opaque types
}
Enter fullscreen mode Exit fullscreen mode

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.
Architecture diagram of a Solidity ERC20-like token contract showing the contract structure with state variables, events, modifiers, and functions organized within a single container

// 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];
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Architecture diagram of a migrated Compact token contract showing global ledger state declarations, witness functions for private data, and separate circuit functions replacing the Solidity contract structure

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;
}
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

The diagram below summarizes the eight critical migration decisions made during the ERC20 conversion from Solidity to Compact.
Process flow diagram showing the complete lifecycle of a Compact contract: deployment with TypeScript scripts, circuit interaction with witness data, and state reading via the indexer API

You've now seen the complete migration including contract code, deployment, and interaction. Let's highlight the critical migration decisions.

Key migration points

  1. 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.

  2. No events: Use counters on the ledger and return values. Applications monitor state changes via indexer.

  3. Explicit context: No msg.sender. Use witness functions to provide caller address.

  4. Constructor parameters: Compact constructors accept parameters directly, similar to Solidity.

  5. Return types: Use [] for circuits with no return value (modern Compact syntax).

  6. Balance reading: Circuits can read state directly. External applications also read state via TypeScript using indexer queries.

  7. Modifiers: Use helper circuits with assert() instead of modifiers, called explicitly.

  8. Witness providers: TypeScript provides witness data when calling circuits.

  9. Validation: Use assert() for validation. Failed assertions abort circuit execution.

  10. 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.
Decision matrix showing use cases where Compact is superior: privacy-essential applications, regulatory compliance scenarios, confidential voting, sealed-bid auctions, and selective disclosure requirements

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.
Decision matrix showing use cases where Solidity is superior: full transparency requirements

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.
Hybrid architecture: solidity + compact integration

Additional resources

Official documentation

Tools and platforms

Community

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:

  1. Privacy Architecture: Ledger for public state, witness functions for truly private data with cryptographic guarantees
  2. Compile-Time Constraints: Fixed sizes for most structures, bounded loops, unsigned integers - all determined before execution
  3. No Direct Equivalents: Mappings, inheritance, events require alternative patterns
  4. Explicit Context: Manual handling of caller context and timestamps through witness functions
  5. Proof-Aware Design: Optimize for proof complexity and generation time rather than gas costs

Welcome to privacy-first smart contract development.

Top comments (11)

Collapse
 
alexander_ogbu_e55ffed921 profile image
Alexander Ogbu

I have always known you to be a smart chap, interesting view on how to migrate. Keep up with the good work.

Collapse
 
henry_messiahtmt_099ca84 profile image
henry messiah tmt

Thank you very much

Collapse
 
richard_ben_2ca6ff6b3c8ac profile image
Richard Ben

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.

Collapse
 
henry_messiahtmt_099ca84 profile image
henry messiah tmt

Thanks for your kind words

Collapse
 
charlesandremi_05735d565b profile image
Charlesandremi

It’s really true that one cannot get enough perspective on concepts.
Thank you Henry sharing such an interesting piece.

Collapse
 
henry_messiahtmt_099ca84 profile image
henry messiah tmt

Thank you very much

Collapse
 
ebiweni_lokoja_f8e97af1f5 profile image
EBIWENI LOKOJA

Superb work

Collapse
 
samuel_ndiomu_a0348a661c4 profile image
Samuel Ndiomu

Quite comprehensive

Collapse
 
justin_ikakah_4fe4f968621 profile image
Justin Ikakah

well done, this is great

Collapse
 
edward_tarilado_c117c0e73 profile image
Edward Tarilado

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

Collapse
 
adika_david_f4882f8904c91 profile image
ADIKA DAVID

Impressive writeup. Keep up the good work.