DEV Community

zhaog100
zhaog100

Posted on

Solidity to Compact: A Developer's Comparison Guide for Midnight Network

Solidity to Compact: A Developer's Comparison Guide

A practical side-by-side reference for developers transitioning from Solidity to Midnight's Compact language.


Introduction

If you're a Solidity developer looking to build on Midnight Network, you'll find that Compact shares some familiar concepts but introduces privacy-first design patterns that fundamentally change how you think about smart contracts.

This guide maps 10 common Solidity patterns to their Compact equivalents, highlighting key differences along the way.


1. Contract Structure & Module Definition

Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MyToken {
    // state variables, functions, events
}
Enter fullscreen mode Exit fullscreen mode

Compact

// SPDX-License-Identifier: MIT
pragma language_version >= 0.21.0;

module MyToken {
    import CompactStandardLibrary;
    // ledger entries, circuits, types
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

  • Compact uses module instead of contract
  • pragma language_version instead of pragma solidity
  • No inheritance — use import with optional prefix for namespacing
  • Functions are called circuits (they become ZK circuits)

2. State Variables (Ledger Entries)

Solidity

contract Storage {
    uint256 public count;
    address public owner;
    mapping(address => uint256) public balances;
    bool public initialized;
}
Enter fullscreen mode Exit fullscreen mode

Compact

module Storage {
    import CompactStandardLibrary;

    // Public (unshielded) state — visible on-chain
    export ledger count: Uint<128>;
    export ledger owner: Either<ZswapCoinPublicKey, ContractAddress>;
    export ledger balances: Map<Either<ZswapCoinPublicKey, ContractAddress>, Uint<128>>;
    export sealed ledger initialized: Bool;
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

  • State variables are called ledger entries
  • export makes them readable; sealed restricts writes to the module
  • No uint256 — max is Uint<128> (circuit backend limitation)
  • Addresses use Either<ZswapCoinPublicKey, ContractAddress> instead of a simple address type
  • Map<K, V> replaces mapping

3. Constructor & Initialization

Solidity

contract Ownable {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }
}
Enter fullscreen mode Exit fullscreen mode

Compact

module Ownable {
    import CompactStandardLibrary;

    export ledger _owner: Either<ZswapCoinPublicKey, ContractAddress>;

    // Called during contract deployment
    export circuit constructor(owner: Either<ZswapCoinPublicKey, ContractAddress>): [] {
        assert(!isKeyOrAddressZero(owner), "Invalid owner");
        _owner = owner;
    }
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

  • Compact uses a constructor circuit (special naming convention)
  • Must return [] (empty tuple)
  • Use assert() for validation — no require() equivalent
  • No constructor overloading

4. Functions → Circuits

Solidity

function transfer(address to, uint256 amount) public returns (bool) {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount;
    balances[to] += amount;
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Compact

export circuit transfer(
    sender: Either<ZswapCoinPublicKey, ContractAddress>,
    recipient: Either<ZswapCoinPublicKey, ContractAddress>,
    amount: Uint<128>
): [] {
    assert(_balances[sender] >= amount, "Insufficient balance");
    _balances[sender] = _balances[sender] - amount;
    _balances[recipient] = _balances[recipient] + amount;
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

  • functioncircuit
  • No msg.sender — caller identity passed as parameter or derived from context
  • No public/private/internal visibility — use export for public
  • All circuits are ZK circuits under the hood
  • State mutations use direct assignment (=)

5. Mappings & Storage

Solidity

mapping(address => uint256) public balances;
mapping(address => mapping(address => uint256)) public allowances;

function getBalance(address account) public view returns (uint256) {
    return balances[account];
}
Enter fullscreen mode Exit fullscreen mode

Compact

export ledger balances: Map<Either<ZswapCoinPublicKey, ContractAddress>, Uint<128>>;
export ledger allowances: Map<Either<ZswapCoinPublicKey, ContractAddress>,
                              Map<Either<ZswapCoinPublicKey, ContractAddress>, Uint<128>>>;

export circuit getBalance(
    account: Either<ZswapCoinPublicKey, ContractAddress>
): Uint<128> {
    return balances[account];
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

  • Nested maps use nested Map<K, Map<K, V>> syntax
  • No .at() or overflow checks — Uint<128> is inherently bounded
  • Access with [] notation (same as Solidity)
  • No delete keyword — assign zero manually

6. Access Control

Solidity

modifier onlyOwner() {
    require(msg.sender == owner, "Not the owner");
    _;
}

function setPrice(uint256 _price) public onlyOwner {
    price = _price;
}
Enter fullscreen mode Exit fullscreen mode

Compact

// Access control is inline — no modifier syntax
export circuit setPrice(
    caller: Either<ZswapCoinPublicKey, ContractAddress>,
    newPrice: Uint<128>
): [] {
    assert(caller == _owner, "Not the owner");
    _price = newPrice;
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

  • No modifiers — use inline assert() checks
  • OpenZeppelin provides Ownable and AccessControl modules for reuse
  • ShieldedAccessControl provides privacy-preserving role management
  • No msg.sender — identity comes from circuit context or parameters

7. Events & Logging

Solidity

event Transfer(address indexed from, address indexed to, uint256 value);

function transfer(address to, uint256 amount) public {
    // ... transfer logic ...
    emit Transfer(msg.sender, to, amount);
}
Enter fullscreen mode Exit fullscreen mode

Compact

// ⚠️ Compact does NOT currently support events!
// This is a known limitation.
//
// Workaround: Use ledger entries to store an action log
export ledger _lastAction: Opaque<"string">;

export circuit transfer(/* ... */): [] {
    // ... transfer logic ...
    _lastAction = "transfer executed";
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

  • No event emission — this is a major current limitation
  • Workaround: store action logs in ledger entries or use off-chain indexing
  • Future versions plan to add event support

8. Error Handling

Solidity

function withdraw(uint256 amount) public {
    require(balances[msg.sender] >= amount, "Insufficient funds");
    require(amount > 0, "Zero amount");

    balances[msg.sender] -= amount;
    payable(msg.sender).transfer(amount);
}
Enter fullscreen mode Exit fullscreen mode

Compact

export circuit withdraw(
    account: Either<ZswapCoinPublicKey, ContractAddress>,
    amount: Uint<128>
): [] {
    assert(balances[account] >= amount, "Insufficient funds");
    assert(amount > 0u128, "Zero amount");

    balances[account] = balances[account] - amount;
    // No payable/transfer — use token operations instead
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

  • require()assert()
  • No revert() or custom error types
  • Error messages are strings (same concept)
  • No try/catch — circuits either succeed or fail entirely
  • No payable — value transfers handled through ledger operations

9. Data Types

Solidity

uint256 public totalSupply;
address public tokenHolder;
bool public active;
string public name;
struct UserInfo {
    address wallet;
    uint256 balance;
    bool registered;
}
Enter fullscreen mode Exit fullscreen mode

Compact

export ledger totalSupply: Uint<128>;
export ledger tokenHolder: Either<ZswapCoinPublicKey, ContractAddress>;
export ledger active: Bool;
export ledger name: Opaque<"string">;

// Structs as record types
type UserInfo = {
    wallet: Either<ZswapCoinPublicKey, ContractAddress>,
    balance: Uint<128>,
    registered: Bool
};
Enter fullscreen mode Exit fullscreen mode
Solidity Compact Notes
uint256 Uint<128> Max 128 bits in Compact
uint8 Uint<8> Arbitrary bit widths supported
address Either<ZswapCoinPublicKey, ContractAddress> Two address types
bool Bool Capitalized
string Opaque<"string"> Wrapped in Opaque
bytes32 Bytes<32> Fixed-size byte arrays
struct { field: Type } Record type syntax
enum Not supported Use constants or integers
array[] Limited support No dynamic arrays on ledger

10. Loops & Iteration

Solidity

function batchTransfer(address[] memory recipients, uint256 amount) public {
    for (uint i = 0; i < recipients.length; i++) {
        balances[msg.sender] -= amount;
        balances[recipients[i]] += amount;
    }
}
Enter fullscreen mode Exit fullscreen mode

Compact

// ⚠️ Loops are generally avoided in Compact circuits
// Each iteration adds to the ZK circuit size (fixed at compile time)
//
// Preferred pattern: Process a fixed number of items
export circuit batchTransfer2(
    sender: Either<ZswapCoinPublicKey, ContractAddress>,
    recipient1: Either<ZswapCoinPublicKey, ContractAddress>,
    recipient2: Either<ZswapCoinPublicKey, ContractAddress>,
    amount: Uint<128>
): [] {
    assert(_balances[sender] >= amount * 2u128, "Insufficient balance");
    _balances[sender] = _balances[sender] - amount * 2u128;
    _balances[recipient1] = _balances[recipient1] + amount;
    _balances[recipient2] = _balances[recipient2] + amount;
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

  • Dynamic loops are problematic — each iteration increases circuit size
  • Prefer fixed-size operations or unrolled patterns
  • No while loops in practice
  • Circuit size must be determinable at compile time

Bonus: Privacy Features (Compact-Specific)

Compact introduces concepts that have no direct Solidity equivalent:

Shielded State

// Data that's private by default
// Only the owner can see the actual value
// Others can verify correctness without seeing the data

// Witness circuits — provide private inputs
export circuit proveOwnership(
    token_id: Uint<128>,
    witness: OwnershipWitness  // Private input, not visible on-chain
): Bool {
    // Returns true/false without revealing who owns what
}
Enter fullscreen mode Exit fullscreen mode

Selective Disclosure

// Solidity: Everything is public
uint256 public salary;  // Anyone can read

// Compact: Prove properties without revealing values
// e.g., "My salary is > $50,000" without revealing the actual amount
Enter fullscreen mode Exit fullscreen mode

Quick Reference Table

Concept Solidity Compact
Contract/Module contract Foo {} module Foo {}
Function function foo() public export circuit foo(): []
State variable uint256 public x export ledger x: Uint<128>
Mapping mapping(K => V) Map<K, V>
Address address Either<ZswapCoinPublicKey, ContractAddress>
Error check require(cond, msg) assert(cond, msg)
Events event Transfer(...) ❌ Not supported yet
Modifiers modifier onlyOwner() Inline assert()
Loops for (uint i...) Avoid; use fixed-size ops
Max integer uint256 Uint<128>
Constructor constructor() export circuit constructor()
Import import "./Foo.sol" import "./Foo" prefix Foo_

Next Steps

  1. Try it yourself: Pick a simple Solidity contract and try converting it to Compact
  2. Explore OpenZeppelin Compact Contracts: github.com/OpenZeppelin/compact-contracts
  3. Read the Midnight docs: docs.midnight.network
  4. Join the community: Discord | Forum

Written by @zhaog100 for the Midnight Network bounty program.

Top comments (0)