DEV Community

Cover image for Contract size limits on Midnight: what breaks when your dApp grows too complex
Harrie
Harrie

Posted on

Contract size limits on Midnight: what breaks when your dApp grows too complex

You write a clean contract. It works. Then you add governance. Then staking. Then rewards. Then you deploy and hit a wall.

Midnight has real limits on contract complexity. They don't all surface the same way — some kill deployment, some kill individual transactions, and some just make users wait while proofs grind. This article covers all three, with compiler-verified contracts showing what actually works.

All three contracts compile against the latest Compact compiler. Verified CI run: IamHarrie-Labs/compact-size-limits-guide


The three limits

Lace's 13-circuit deployment limit is a hard cap enforced by the wallet at deploy time. Exceed 13 exported circuits and deployment is rejected. This is wallet-level, not protocol-level, which means it could change with a wallet update — but for now, 13 is the number.

Block weight limits (error 1010) are a runtime problem. Midnight blocks measure transactions across five dimensions: readTime, computeTime, blockUsage, bytesWritten, and bytesChurned. If any of them blow past block capacity, the transaction is rejected. The contract deployed fine; this particular call just does too much.

Proof generation time is the quieter problem. ZK proofs are generated client-side before submission. Constraint count drives proof time. Expensive operations — persistent hash calls, Map reads and writes, bounded loops — each add constraints. A heavy circuit can take several seconds to prove. In a UI, that's visible.

These are three separate problems. The fixes are different.


The 13-circuit limit

Only export circuit declarations count. Non-exported helper circuits, declared with just circuit, are invisible to the limit. That's the most useful thing to understand here, because it's what makes the main optimization strategy work.

Here's a monolithic contract pushing against it:

// monolithic.compact
// Token + governance + staking in one contract.
// 12 exported circuits — one short of Lace's 13-circuit deployment limit.
//
// Non-exported helper circuits (adminKey, requireAdmin) do NOT count
// toward the 13-circuit limit. Only `export circuit` declarations count.

pragma language_version >= 0.20;
import CompactStandardLibrary;

// ─── Token state ───────────────────────────────────────────────────────────

export ledger admin: Bytes<32>;
export ledger balances: Map<Bytes<32>, Uint<64>>;
export ledger totalMinted: Counter;

// ─── Governance state ──────────────────────────────────────────────────────

export ledger proposals: Map<Bytes<32>, Boolean>;
export ledger voteCounts: Map<Bytes<32>, Uint<64>>;
export ledger hasVoted: Map<Bytes<32>, Boolean>;
export ledger proposalCount: Counter;

// ─── Staking state ─────────────────────────────────────────────────────────

export ledger stakedBalances: Map<Bytes<32>, Uint<64>>;
export ledger rewardPoints: Map<Bytes<32>, Uint<64>>;
export ledger totalStaked: Counter;

// ─── Witnesses ─────────────────────────────────────────────────────────────

witness getAdminSecret(): Bytes<32>;

// ─── Non-exported helpers (do NOT count toward the 13-circuit limit) ────────

circuit adminKey(): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>([
        pad(32, "mono:admin:v1"),
        getAdminSecret()
    ]);
}

circuit requireAdmin(): [] {
    assert(disclose(adminKey()) == admin, "Admin only");
}

// ─── Exported circuits — each one counts toward Lace's 13-circuit limit ────
//     Count shown inline. At 13, deployment via Lace wallet will be rejected.

export circuit initialize(adminPubkey: Bytes<32>): [] {          // 1
    admin = disclose(adminPubkey);
}

// Token ──────────────────────────────────────────────────────────────────────

export circuit mint(                                             // 2
    recipient: Bytes<32>,
    amount: Uint<64>,
    newBalance: Uint<64>
): [] {
    requireAdmin();
    if (!balances.member(disclose(recipient))) {
        assert(disclose(newBalance) == disclose(amount), "First mint: balance must equal amount");
    } else {
        const current = balances.lookup(disclose(recipient));
        assert(disclose(newBalance) > current, "Balance must increase");
        assert(disclose(newBalance) - current == disclose(amount), "Invalid mint amount");
    }
    balances.insert(disclose(recipient), disclose(newBalance));
    totalMinted.increment(1);
}

export circuit transfer(                                         // 3
    sender: Bytes<32>,
    recipient2: Bytes<32>,
    amount: Uint<64>,
    newFromBalance: Uint<64>,
    newToBalance: Uint<64>
): [] {
    assert(balances.member(disclose(sender)), "Sender has no balance");
    const fromCurrent = balances.lookup(disclose(sender));
    assert(fromCurrent >= disclose(amount), "Insufficient balance");
    assert(disclose(newFromBalance) == fromCurrent - disclose(amount), "Invalid sender balance");
    if (!balances.member(disclose(recipient2))) {
        assert(disclose(newToBalance) == disclose(amount), "First receive: balance must equal amount");
    } else {
        const toCurrent = balances.lookup(disclose(recipient2));
        assert(disclose(newToBalance) > toCurrent, "Recipient balance must increase");
        assert(disclose(newToBalance) - toCurrent == disclose(amount), "Invalid recipient balance");
    }
    balances.insert(disclose(sender), disclose(newFromBalance));
    balances.insert(disclose(recipient2), disclose(newToBalance));
}

export circuit burn(userKey: Bytes<32>, amount: Uint<64>): [] {  // 4
    assert(balances.member(disclose(userKey)), "No balance");
    const current = balances.lookup(disclose(userKey));
    assert(current >= disclose(amount), "Insufficient balance");
    balances.insert(disclose(userKey), current - disclose(amount));
}

export circuit balanceOf(userKey: Bytes<32>): Uint<64> {         // 5
    if (!balances.member(disclose(userKey))) {
        return 0;
    }
    return balances.lookup(disclose(userKey));
}

// Governance ─────────────────────────────────────────────────────────────────

export circuit createProposal(proposalId: Bytes<32>): [] {       // 6
    requireAdmin();
    assert(!proposals.member(disclose(proposalId)), "Proposal already exists");
    proposals.insert(disclose(proposalId), disclose(true));
    proposalCount.increment(1);
}

export circuit vote(                                             // 7
    proposalId: Bytes<32>,
    userKey: Bytes<32>,
    newVoteCount: Uint<64>
): [] {
    assert(proposals.member(disclose(proposalId)), "Proposal does not exist");
    const voteKey = persistentHash<Vector<2, Bytes<32>>>([
        disclose(proposalId),
        disclose(userKey)
    ]);
    assert(!hasVoted.member(voteKey), "Already voted");
    if (voteCounts.member(disclose(proposalId))) {
        const current = voteCounts.lookup(disclose(proposalId));
        assert(disclose(newVoteCount) > current, "Vote count must increase");
    }
    hasVoted.insert(voteKey, disclose(true));
    voteCounts.insert(disclose(proposalId), disclose(newVoteCount));
}

export circuit voteCountOf(proposalId: Bytes<32>): Uint<64> {   // 8
    if (!voteCounts.member(disclose(proposalId))) {
        return 0;
    }
    return voteCounts.lookup(disclose(proposalId));
}

// Staking ────────────────────────────────────────────────────────────────────

export circuit stake(                                            // 9
    userKey: Bytes<32>,
    amount: Uint<64>,
    newBalance: Uint<64>
): [] {
    if (!stakedBalances.member(disclose(userKey))) {
        assert(disclose(newBalance) == disclose(amount), "First stake: balance must equal amount");
    } else {
        const current = stakedBalances.lookup(disclose(userKey));
        assert(disclose(newBalance) > current, "Staked balance must increase");
        assert(disclose(newBalance) - current == disclose(amount), "Invalid stake amount");
    }
    stakedBalances.insert(disclose(userKey), disclose(newBalance));
    totalStaked.increment(1);
}

export circuit unstake(userKey: Bytes<32>, amount: Uint<64>): [] { // 10
    assert(stakedBalances.member(disclose(userKey)), "No stake");
    const current = stakedBalances.lookup(disclose(userKey));
    assert(current >= disclose(amount), "Cannot unstake more than staked");
    stakedBalances.insert(disclose(userKey), current - disclose(amount));
}

export circuit stakedOf(userKey: Bytes<32>): Uint<64> {          // 11
    if (!stakedBalances.member(disclose(userKey))) {
        return 0;
    }
    return stakedBalances.lookup(disclose(userKey));
}

export circuit awardPoints(                                      // 12
    userKey: Bytes<32>,
    amount: Uint<64>,
    newBalance: Uint<64>
): [] {
    requireAdmin();
    if (!rewardPoints.member(disclose(userKey))) {
        assert(disclose(newBalance) == disclose(amount), "First award: balance must equal amount");
    } else {
        const current = rewardPoints.lookup(disclose(userKey));
        assert(disclose(newBalance) > current, "Balance must increase");
        assert(disclose(newBalance) - current == disclose(amount), "Invalid award amount");
    }
    rewardPoints.insert(disclose(userKey), disclose(newBalance));
}

// Circuit count: 12 / 13 maximum.
// Adding one more export circuit here would push this to the Lace limit.
// Any new feature domain requires splitting into a separate contract.
Enter fullscreen mode Exit fullscreen mode

The full working contract is in the repo. The count is the point: 12 exported circuits. adminKey() and requireAdmin() are helpers and don't count. Two more feature circuits, and this contract won't deploy.


Block weight and error 1010

Error 1010 is a runtime failure, not a deployment one. The contract is on-chain; this particular call just does too much in one transaction.

Midnight measures block weight across five dimensions:

Dimension What it tracks
readTime Cost of reading ledger state
computeTime Circuit computation cost
blockUsage Overall block space consumed
bytesWritten New bytes written to ledger
bytesChurned Bytes written then immediately overwritten

A circuit that reads from several Map keys, writes multiple entries, and calls persistentHash a few times can hit this even with a completely reasonable circuit count. The fix is the same as for proof time: keep circuits focused.

Operations with the most weight: persistentHash adds significant constraint cost per call. Map.lookup() and Map.insert() each cost readTime or bytesWritten. Bounded loops scale with iteration count times body cost. Large Bytes<N> values directly increase bytesWritten.

The transfer circuit in monolithic.compact does four Map operations. Fine for one transfer. A circuit running ten transfers in a loop is a different story.


Strategy 1: non-exported helper circuits

Pulling repeated logic into non-exported circuits is the cheapest fix. It costs nothing in circuit count.

// ❌ Repeated in every admin-gated circuit — same hash computation every time
export circuit mint(...): [] {
    assert(disclose(persistentHash<Vector<2, Bytes<32>>>([
        pad(32, "token:admin:v1"),
        getAdminSecret()
    ])) == admin, "Admin only");
    // ... rest of circuit
}

export circuit createProposal(...): [] {
    assert(disclose(persistentHash<Vector<2, Bytes<32>>>([
        pad(32, "token:admin:v1"),
        getAdminSecret()
    ])) == admin, "Admin only");
    // ... rest of circuit
}
Enter fullscreen mode Exit fullscreen mode
// ✅ Extract into a non-exported helper — doesn't count toward 13-circuit limit
circuit adminKey(): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>([
        pad(32, "token:admin:v1"),
        getAdminSecret()
    ]);
}

circuit requireAdmin(): [] {
    assert(disclose(adminKey()) == admin, "Admin only");
}

export circuit mint(...): [] {
    requireAdmin();
    // ... rest of circuit
}
Enter fullscreen mode Exit fullscreen mode

adminKey() and requireAdmin() don't appear in the circuit count. They're inlined at compile time, so there's no runtime cost either. Key derivation, bounds checks, member guards — anything repeated across exported circuits belongs in a helper.


Strategy 2: split by domain

Once you've pulled out all the helpers and the circuit count is still too high, split by domain.

token.compact — 5 exported circuits:

// token.compact
// Token operations split into their own contract.
// 5 exported circuits — well under Lace's 13-circuit limit.

pragma language_version >= 0.20;
import CompactStandardLibrary;

export ledger admin: Bytes<32>;
export ledger balances: Map<Bytes<32>, Uint<64>>;
export ledger totalMinted: Counter;

witness getAdminSecret(): Bytes<32>;

circuit adminKey(): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>([
        pad(32, "token:admin:v1"),
        getAdminSecret()
    ]);
}

circuit requireAdmin(): [] {
    assert(disclose(adminKey()) == admin, "Admin only");
}

circuit requireMember(userKey: Bytes<32>): [] {
    assert(balances.member(userKey), "User has no balance");
}

export circuit initialize(adminPubkey: Bytes<32>): [] {          // 1
    admin = disclose(adminPubkey);
}

export circuit mint(                                             // 2
    recipient: Bytes<32>,
    amount: Uint<64>,
    newBalance: Uint<64>
): [] {
    requireAdmin();
    if (!balances.member(disclose(recipient))) {
        assert(disclose(newBalance) == disclose(amount), "First mint: balance must equal amount");
    } else {
        const current = balances.lookup(disclose(recipient));
        assert(disclose(newBalance) > current, "Balance must increase");
        assert(disclose(newBalance) - current == disclose(amount), "Invalid mint amount");
    }
    balances.insert(disclose(recipient), disclose(newBalance));
    totalMinted.increment(1);
}

export circuit transfer(                                         // 3
    sender: Bytes<32>,
    receiver: Bytes<32>,
    amount: Uint<64>,
    newFromBalance: Uint<64>,
    newToBalance: Uint<64>
): [] {
    assert(balances.member(disclose(sender)), "Sender has no balance");
    const fromCurrent = balances.lookup(disclose(sender));
    assert(fromCurrent >= disclose(amount), "Insufficient balance");
    assert(disclose(newFromBalance) == fromCurrent - disclose(amount), "Invalid sender balance");
    if (!balances.member(disclose(receiver))) {
        assert(disclose(newToBalance) == disclose(amount), "First receive: balance must equal amount");
    } else {
        const toCurrent = balances.lookup(disclose(receiver));
        assert(disclose(newToBalance) > toCurrent, "Recipient balance must increase");
        assert(disclose(newToBalance) - toCurrent == disclose(amount), "Invalid recipient balance");
    }
    balances.insert(disclose(sender), disclose(newFromBalance));
    balances.insert(disclose(receiver), disclose(newToBalance));
}

export circuit burn(userKey: Bytes<32>, amount: Uint<64>): [] {  // 4
    assert(balances.member(disclose(userKey)), "No balance to burn");
    const current = balances.lookup(disclose(userKey));
    assert(current >= disclose(amount), "Insufficient balance");
    balances.insert(disclose(userKey), current - disclose(amount));
}

export circuit balanceOf(userKey: Bytes<32>): Uint<64> {         // 5
    if (!balances.member(disclose(userKey))) {
        return 0;
    }
    return balances.lookup(disclose(userKey));
}
// Total: 5 / 13
Enter fullscreen mode Exit fullscreen mode

governance.compact — 4 exported circuits:

// governance.compact
// Governance operations in a separate contract from token logic.
// 4 exported circuits — well under Lace's 13-circuit limit.

pragma language_version >= 0.20;
import CompactStandardLibrary;

export ledger admin: Bytes<32>;
export ledger proposals: Map<Bytes<32>, Boolean>;
export ledger voteCounts: Map<Bytes<32>, Uint<64>>;
export ledger hasVoted: Map<Bytes<32>, Boolean>;
export ledger proposalCount: Counter;

witness getAdminSecret(): Bytes<32>;

circuit adminKey(): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>([
        pad(32, "governance:admin:v1"),
        getAdminSecret()
    ]);
}

circuit requireAdmin(): [] {
    assert(disclose(adminKey()) == admin, "Admin only");
}

// Composite key for per-user per-proposal vote tracking.
circuit voteKey(proposalId: Bytes<32>, userKey: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>([proposalId, userKey]);
}

export circuit initialize(adminPubkey: Bytes<32>): [] {          // 1
    admin = disclose(adminPubkey);
}

export circuit createProposal(proposalId: Bytes<32>): [] {       // 2
    requireAdmin();
    assert(!proposals.member(disclose(proposalId)), "Proposal already exists");
    proposals.insert(disclose(proposalId), disclose(true));
    proposalCount.increment(1);
}

export circuit vote(                                             // 3
    proposalId: Bytes<32>,
    userKey: Bytes<32>,
    newVoteCount: Uint<64>
): [] {
    assert(proposals.member(disclose(proposalId)), "Proposal does not exist");
    const key = voteKey(disclose(proposalId), disclose(userKey));
    assert(!hasVoted.member(key), "Already voted on this proposal");
    if (voteCounts.member(disclose(proposalId))) {
        const current = voteCounts.lookup(disclose(proposalId));
        assert(disclose(newVoteCount) > current, "Vote count must increase");
    }
    hasVoted.insert(key, disclose(true));
    voteCounts.insert(disclose(proposalId), disclose(newVoteCount));
}

export circuit voteCountOf(proposalId: Bytes<32>): Uint<64> {   // 4
    if (!voteCounts.member(disclose(proposalId))) {
        return 0;
    }
    return voteCounts.lookup(disclose(proposalId));
}
// Total: 4 / 13
Enter fullscreen mode Exit fullscreen mode

Each deploys independently, each well under the limit. When staking comes next, that's a third contract, not a rewrite.


Cross-contract coordination

Compact circuits don't call each other. Coordination is the TypeScript layer's job. Call each contract in sequence, feeding the output of one into the input of the next.

// TypeScript coordinates calls across both contracts
async function voteWithTokenCheck(
  proposalId: Uint8Array,
  voterKey: Uint8Array,
  tokenContractAddress: ContractAddress
) {
  // 1. Query token contract to verify the voter holds tokens
  const balance = await tokenContract.query.balanceOf(voterKey);
  if (balance === 0n) {
    throw new Error("Must hold tokens to vote");
  }

  // 2. Get current vote count from governance contract
  const currentCount = await governanceContract.query.voteCountOf(proposalId);
  const newVoteCount = currentCount + 1n;

  // 3. Submit vote
  await governanceContract.callTx.vote(proposalId, voterKey, newVoteCount);
}
Enter fullscreen mode Exit fullscreen mode

If you want an on-chain record of which token contract the governance contract is paired with, store the address in a ledger field:

export ledger tokenContractRef: Bytes<32>;

export circuit setTokenContract(ref: Bytes<32>): [] {
    requireAdmin();
    tokenContractRef = disclose(ref);
}
Enter fullscreen mode Exit fullscreen mode

The actual balance check still happens off-chain. The stored address is just an audit trail.


Strategy 3: keep individual circuits lean

A heavy circuit is a problem regardless of circuit count. Proof time and block weight both scale with what a circuit does.

persistentHash adds significant constraints per call. Factor repeated hash computations into non-exported helpers rather than duplicating them in every circuit that needs a key.

Map.lookup() and Map.insert() each cost readTime or bytesWritten. A circuit that touches five Map keys is heavier than one that touches one. If a circuit is doing a lot of independent reads, consider whether some of that work can move off-chain.

Off-chain delegation keeps constraint counts low. The newBalance pattern used throughout these contracts is an example: TypeScript computes the arithmetic, the circuit just verifies the relationship. Keeping expensive computation in TypeScript and passing the result as a parameter is consistently cheaper than recomputing in-circuit.

// ❌ In-circuit arithmetic widens the type and adds constraints
const newBalance = current + amount;  // Uint<64> + Uint<64> = Uint<1..2^65>

// ✅ Pass pre-computed value, verify in-circuit
export circuit mint(recipient: Bytes<32>, amount: Uint<64>, newBalance: Uint<64>): [] {
    const current = balances.lookup(disclose(recipient));
    assert(disclose(newBalance) - current == disclose(amount), "Invalid balance");
    balances.insert(disclose(recipient), disclose(newBalance));
}
Enter fullscreen mode Exit fullscreen mode

Pitfalls

1. Counting witnesses and non-exported circuits toward the limit

witness getAdminSecret(): Bytes<32>;  // ← does NOT count
circuit adminKey(): Bytes<32> { ... } // ← does NOT count
export circuit mint(...): [] { ... }  // ← counts: 1
Enter fullscreen mode Exit fullscreen mode

Only export circuit declarations count. witness and bare circuit are invisible to the 13-circuit limit.

2. Confusing the 13-circuit limit with error 1010

They fail differently:

  • 13-circuit limit: deployment fails. Lace rejects the contract before it reaches the chain. Fix: split the contract or remove exported circuits.
  • Error 1010: a specific transaction fails at runtime. The contract is deployed; this call is just too expensive. Fix: reduce what the circuit does per call, or split the work across multiple transactions.

3. Calling Map.lookup() without a Map.member() guard

// ❌ Panics at proof generation if the key doesn't exist
const current = voteCounts.lookup(disclose(proposalId));
Enter fullscreen mode Exit fullscreen mode
// ✅ Guard first
if (voteCounts.member(disclose(proposalId))) {
    const current = voteCounts.lookup(disclose(proposalId));
    // ...
}
Enter fullscreen mode Exit fullscreen mode

This applies inside helper circuits too. A helper that calls lookup() unsafely will panic in every exported circuit that calls it.

4. Uint<64> arithmetic widening in-circuit

// ❌ Uint<64> + Uint<64> = Uint<1..2^65> — can't store to Uint<64>
const newBalance = current + amount;
balances.insert(disclose(userKey), newBalance);
Enter fullscreen mode Exit fullscreen mode
expected right-hand side to have type Uint<64>
but received Uint<1..18446744073709551616>
Enter fullscreen mode Exit fullscreen mode
// ✅ Compute off-chain, verify in-circuit using subtraction (type-safe)
export circuit mint(recipient: Bytes<32>, amount: Uint<64>, newBalance: Uint<64>): [] {
    const current = balances.lookup(disclose(recipient));
    assert(disclose(newBalance) - current == disclose(amount), "Invalid balance");
    balances.insert(disclose(recipient), disclose(newBalance));
}
Enter fullscreen mode Exit fullscreen mode

Subtraction stays within Uint<64> range once you've asserted the operands are safe. Addition widens. This is consistent across all balance-tracking circuits in this article.

5. Missing disclose() on circuit parameters used in assertions

// ❌ Compiler error
assert(amount > 0, "Amount must be positive");
Enter fullscreen mode Exit fullscreen mode
// ✅
assert(disclose(amount) > 0, "Amount must be positive");
Enter fullscreen mode Exit fullscreen mode

Circuit parameters behave like witnesses from the type system's perspective. Any comparison, ledger write, or function call that would expose the value requires an explicit disclose().

6. Using reserved keywords as parameter names

// ❌ Parse error — 'from' is a reserved keyword
export circuit transfer(from: Bytes<32>, to: Bytes<32>, ...): [] { ... }
Enter fullscreen mode Exit fullscreen mode
parse error: found keyword "from" looking for a typed pattern or ")"
Enter fullscreen mode Exit fullscreen mode
// ✅ Use non-reserved names
export circuit transfer(sender: Bytes<32>, receiver: Bytes<32>, ...): [] { ... }
Enter fullscreen mode Exit fullscreen mode

Compact reserves words that look like valid identifiers: from, to, import, export, ledger, circuit, witness, and others. The error ("found keyword X looking for a typed pattern") means the parser hit a reserved word where it expected a variable name. sender and receiver work fine.

7. Using Counter.increment() with a Uint<64> argument

// ❌ Type error — Counter.increment expects Uint<16>
totalMinted.increment(disclose(amount));  // amount: Uint<64>
Enter fullscreen mode Exit fullscreen mode
expected first argument of increment to have type Uint<16>
but received Uint<64>
Enter fullscreen mode Exit fullscreen mode
// ✅ Pass a literal
totalMinted.increment(1);
Enter fullscreen mode Exit fullscreen mode

Counter counts operations, not arbitrary sums. For large aggregates, use a Map field.


Compiler-verified source

All three contracts — monolithic.compact, token.compact, and governance.compact — compile against the latest Compact compiler:

https://github.com/IamHarrie-Labs/compact-size-limits-guide/actions/runs/25701998943


Resources

Top comments (0)