DEV Community

Tosh
Tosh

Posted on

When Your Midnight Contract Is Too Large to Deploy

When Your Midnight Contract Is Too Large to Deploy

You built a DeFi protocol on Midnight with eight distinct operations: deposit, withdraw, swap, stake, unstake, claim, delegate, and undelegate. Each one is a circuit. You fire up Lace, submit your deployment transaction, and — nothing. The transaction sits in the pool and never confirms, or you get a BlockLimitExceeded error before it even makes it there.

You have hit one of the most concrete constraints in Midnight development: contract size limits. This post explains what those limits actually are, why they exist, and the three practical strategies for working around them.


What Is Actually Being Limited

When you write a Compact contract, the compiler translates each exported circuit into a zero-knowledge proof circuit. Each circuit has:

  • A set of constraint equations that define valid executions
  • A proving key (for proof generation) and a verification key (for on-chain verification)
  • A JavaScript implementation that runs during proof generation

Deploying a contract means committing all of that to the chain — the verification keys, the circuit structure, the initial ledger state. The more circuits a contract has, the more bytes get written to the ledger on deployment.

The Midnight ledger's cost model tracks five dimensions for every transaction: readTime, computeTime, blockUsage, bytesWritten, and bytesChurned. Contract deployment hammers bytesWritten because you are creating new ledger entries for every circuit's keys and state. The block has a hard limit on how many bytes a single transaction can write, and a large contract deployment can exceed it.

When that happens, you get one of two error codes:

Error 154 (BlockLimitExceeded) — The client-side transaction builder calculated the cost and refused to submit. You hit this before the transaction reaches the node.

Error 1010 (InvalidTransaction::ExhaustsResources) — Substrate's POOL_INVALID_TX + ExhaustsResources (offset 10). The transaction reached the pool but was rejected. This is the same underlying problem, just caught at a different point.

Neither error tells you which dimension is the bottleneck. You have to infer it.


The Lace 13-Circuit Constraint

If you are testing with the Lace Midnight Preview wallet, you will hit a more immediate problem before you even approach Substrate-level block limits: Lace imposes a 13-circuit limit per contract deployment transaction.

This limit is in Lace's transaction construction logic, not in the protocol itself. Lace builds the deployment transaction and explicitly caps how many circuit proofs it will bundle. If your contract has 14 or more exported circuits, Lace will refuse to build the deployment transaction at all — no error 1010, just a deployment that never gets constructed.

Thirteen might sound like a lot, but it fills up faster than you expect. Consider a governance contract:

// These are the exported circuits this contract needs:
// vote, voteWithDelegation, castVeto, propose, cancelProposal,
// finalizeProposal, delegate, undelegate, claimReward,
// updateQuorum, updateThreshold, pauseVoting, resumeVoting,
// addMember, removeMember

// That's 15 circuits. Two over the Lace limit.
Enter fullscreen mode Exit fullscreen mode

If you are targeting testnet with Lace as the wallet, 13 exported circuits is your hard ceiling until Lace updates its limit.


How Proof Generation Time Scales

The second reason to care about circuit complexity is proof generation latency. Proof generation time is not linear in the number of lines of code in your circuit — it scales with the number of constraints.

Every operation in a Compact circuit compiles to constraint equations. A comparison (a < b) adds constraints. An assertion adds constraints. A hash function like persistentHash adds significantly more. The more constraints, the longer the proof server takes to generate a proof.

For a simple circuit with a few arithmetic checks, proof generation takes 2–4 seconds. For a complex circuit with multiple hash operations, Merkle tree lookups, and branching logic, you can be looking at 15–30 seconds. For extremely large circuits with hundreds of constraints, proof generation can take over a minute.

This matters for user experience. When a user submits a transaction in your DApp, they are waiting for proof generation to complete before the transaction can be submitted. A 15-second wait is tolerable. A 45-second wait is not, especially on a mobile wallet with an unreliable connection.

The practical implication: beyond the hard deployment limits, keeping individual circuits small is a performance optimization. Circuits that do one thing well generate proofs faster than circuits that do many things.


Strategy 1: Use Non-Exported Helper Circuits

Before reaching for contract splitting, check whether you are exporting circuits that do not need to be public entry points.

In Compact, exported circuits are callable from TypeScript and become contract entry points. Non-exported circuits are internal helpers — they can be called from other circuits but not directly from the DApp. They still compile to ZK constraints, but they do not contribute to the deployment transaction's per-circuit overhead in the same way.

// Conceptual Compact (pseudocode — illustrative only)

// Non-exported helper — can be used by multiple circuits internally
circuit validateMerkleProof(
  root: Bytes<32>,
  leaf: Bytes<32>,
  proof: Vector<32, Bytes<32>>
): Boolean {
  // ... Merkle proof verification logic ...
  return true;
}

// Exported circuits call the helper — they do not duplicate the logic
export circuit withdraw(
  commitment: Bytes<32>,
  merkleRoot: Bytes<32>,
  merkleProof: Vector<32, Bytes<32>>,
  witness amount: Uint<64>,
  witness randomness: Bytes<32>
): [] {
  assert validateMerkleProof(merkleRoot, commitment, merkleProof)
    "Invalid Merkle proof";
  // ... rest of withdraw logic ...
}

export circuit claim(
  commitment: Bytes<32>,
  merkleRoot: Bytes<32>,
  merkleProof: Vector<32, Bytes<32>>,
  witness rewardAmount: Uint<64>,
  witness randomness: Bytes<32>
): [] {
  assert validateMerkleProof(merkleRoot, commitment, merkleProof)
    "Invalid Merkle proof";
  // ... rest of claim logic ...
}
Enter fullscreen mode Exit fullscreen mode

The validateMerkleProof helper is shared by both exported circuits. Its constraint equations are inlined during compilation — you get code reuse without adding another exported entry point. This technique reduces the number of exported circuits without losing functionality.

Before splitting a contract, audit every export circuit declaration and ask: does anything external actually need to call this directly? If a circuit is only ever called from other circuits in the same contract, remove the export keyword.


Strategy 2: Decompose Into Multiple Contracts

When the helper circuit approach does not get you under the limit, split the contract.

The key decision is where to draw the boundary. The most natural split is by domain:

// Before: one monolithic contract
const governanceContract = {
  circuits: [
    "vote", "voteWithDelegation", "castVeto",
    "propose", "cancelProposal", "finalizeProposal",
    "delegate", "undelegate", "claimReward",
    "updateQuorum", "updateThreshold", "pauseVoting",
    "resumeVoting", "addMember", "removeMember"
  ]
  // 15 circuits — over Lace's limit
};

// After: two contracts by domain
const proposalContract = {
  address: "abc123...",
  circuits: [
    "propose", "cancelProposal", "finalizeProposal",
    "updateQuorum", "updateThreshold"
  ]
  // 5 circuits
};

const membershipContract = {
  address: "def456...",
  circuits: [
    "vote", "voteWithDelegation", "castVeto",
    "delegate", "undelegate", "claimReward",
    "pauseVoting", "resumeVoting", "addMember", "removeMember"
  ]
  // 10 circuits
};
Enter fullscreen mode Exit fullscreen mode

Both are under 13, each has a logical cohesion, and you can deploy them in separate transactions.

The TypeScript changes are straightforward. Instead of one deployed instance, you manage two:

import { deployContract } from "@midnight-ntwrk/midnight-js-contracts";
import { httpClientProofProvider } from "@midnight-ntwrk/midnight-js-http-client-proof-provider";
import { proposalCircuits, membershipCircuits } from "./compiled";

const proofProvider = httpClientProofProvider("http://localhost:6300");

async function deployGovernance(providers: Providers) {
  // Deploy each contract separately — separate transactions, separate addresses
  const proposalInstance = await deployContract(providers, {
    contract: proposalCircuits,
    privateStateKey: "governance-proposals",
    initialPrivateState: () => ({}),
  });

  const membershipInstance = await deployContract(providers, {
    contract: membershipCircuits,
    privateStateKey: "governance-membership",
    initialPrivateState: () => ({}),
  });

  return {
    proposals: proposalInstance.deployTxData.public.contractAddress,
    membership: membershipInstance.deployTxData.public.contractAddress,
  };
}
Enter fullscreen mode Exit fullscreen mode

Store both addresses in your application config. From there, interactions with each contract are handled independently:

interface GovernanceAddresses {
  proposals: string;
  membership: string;
}

class GovernanceClient {
  private proposalContract: DeployedContract<typeof proposalCircuits>;
  private membershipContract: DeployedContract<typeof membershipCircuits>;

  constructor(
    addresses: GovernanceAddresses,
    providers: Providers
  ) {
    this.proposalContract = deployedContractInstance(
      addresses.proposals,
      proposalCircuits,
      providers
    );
    this.membershipContract = deployedContractInstance(
      addresses.membership,
      membershipCircuits,
      providers
    );
  }

  async submitVote(
    proposalId: Uint8Array,
    witnesses: VoteWitnesses
  ): Promise<void> {
    // Votes go to membership contract
    await this.membershipContract.callTx.vote(
      proposalId,
      voteWitnessProvider(witnesses)
    );
  }

  async finalizeProposal(proposalId: Uint8Array): Promise<void> {
    // Finalization goes to proposal contract
    await this.proposalContract.callTx.finalizeProposal(proposalId);
  }
}
Enter fullscreen mode Exit fullscreen mode

The split is transparent to the end user. From the DApp's perspective, governance is a single concept — the TypeScript client handles routing calls to the appropriate contract.


Strategy 3: Cross-Contract References

Some operations genuinely need to coordinate state across contracts. The Midnight FAQ confirms this is on the roadmap — one contract calling another — but as of the current testnet, direct cross-contract calls are not yet available.

Until they are, the coordination pattern is off-chain aggregation. Both contracts expose read-only query circuits that return ledger state as public outputs, and your TypeScript backend aggregates that data:

// Reading from both contracts to compute a combined view
async function getGovernanceSnapshot(
  addresses: GovernanceAddresses,
  providers: Providers
): Promise<GovernanceSnapshot> {
  const [proposalState, membershipState] = await Promise.all([
    queryProposalState(addresses.proposals, providers),
    queryMembershipState(addresses.membership, providers),
  ]);

  // Combine the two contract states into a unified view
  return {
    activeProposals: proposalState.active,
    quorumThreshold: proposalState.quorum,
    totalMembers: membershipState.memberCount,
    delegations: membershipState.delegationGraph,
  };
}
Enter fullscreen mode Exit fullscreen mode

For operations that need atomic cross-contract consistency — where you want both contract states to update or neither to — the current workaround is sequencing with application-level validation:

async function finalizeAndDistribute(
  proposalId: Uint8Array,
  addresses: GovernanceAddresses,
  providers: Providers
): Promise<void> {
  // Step 1: finalize the proposal (marks it as settled in proposal contract)
  const finalizeTx = await proposalContract.callTx.finalizeProposal(proposalId);
  await finalizeTx.submit();

  // Step 2: distribute rewards based on the finalized result
  // If this fails, your off-chain reconciliation job will retry it
  const finalResult = await queryProposalResult(proposalId, addresses.proposals, providers);
  await membershipContract.callTx.claimBatchRewards(
    proposalId,
    finalResult,
    rewardWitnessProvider
  );
}
Enter fullscreen mode Exit fullscreen mode

This is an optimistic pattern: you sequence two contract calls and handle failures through retry logic or compensation. It works for most governance and DeFi use cases. When Midnight adds native cross-contract calls, you will be able to consolidate these into atomic transactions — the split-contract architecture does not need to change, just the coordination mechanism.


Measuring Before You Deploy

The current tooling for pre-flight cost estimation is still maturing, but you can get rough signal before attempting a deployment.

TransactionCostModel.initialTransactionCostModel() from @midnight-ntwrk/ledger gives you the current block limits. It does not give you a per-transaction cost breakdown yet, but logging the baseline helps you understand the environment you are deploying into:

import { TransactionCostModel } from "@midnight-ntwrk/ledger";

function logCostModelLimits() {
  const costModel = TransactionCostModel.initialTransactionCostModel();
  console.log("Block cost model:");
  console.log("  Base:", costModel.baselineCost.toString());
  console.log("  Input overhead:", costModel.inputFeeOverhead.toString());
  console.log("  Output overhead:", costModel.outputFeeOverhead.toString());
}
Enter fullscreen mode Exit fullscreen mode

The more actionable approach today is wrapping your deployment in structured error handling so you can distinguish between a cost model rejection and a logic error:

import { BlockLimitExceededError, CallTxFailedError } from "@midnight-ntwrk/midnight-js-contracts";

async function deployWithDiagnostics(providers: Providers, contract: Contract) {
  try {
    return await deployContract(providers, {
      contract,
      privateStateKey: "my-contract",
      initialPrivateState: () => ({}),
    });
  } catch (e) {
    if (e instanceof BlockLimitExceededError) {
      // Error 154 — too large for a single transaction
      // Reduce circuit count or split into multiple contracts
      console.error("Deployment too large. Circuit count:", contract.circuits.length);
      throw new Error("Contract exceeds block limits — split required");
    }
    if (e instanceof CallTxFailedError && e.code === 1010) {
      // Substrate-level rejection — same root cause, different detection point
      console.error("Substrate rejected transaction: ExhaustsResources");
      throw new Error("Transaction exhausted block resources");
    }
    throw e;
  }
}
Enter fullscreen mode Exit fullscreen mode

When you catch a 154 or 1010 specifically on deployment, you know immediately that the bottleneck is contract size, not a witness type mismatch or a proof generation failure. That narrows the fix space to: reduce exported circuit count, reduce individual circuit complexity, or split the contract.


Putting It Together

When you hit a size limit on a Midnight contract, work through the options in order:

  1. Audit exports first. Remove export from any circuit that is only called internally. Non-exported circuits reuse constraint logic without adding entry points.

  2. Check the Lace 13-circuit limit. If you are deploying via Lace and have 13 or fewer exported circuits, the limit may not be your problem. Look at circuit complexity and bytesWritten instead.

  3. Split by domain. Draw a natural boundary through your circuit set based on functionality. Two contracts of 8–10 circuits each is better than one contract of 18 circuits that cannot be deployed.

  4. Use off-chain coordination for cross-contract state. Until native cross-contract calls arrive, TypeScript aggregation and sequenced transactions cover most use cases.

The constraints feel tight right now because the tooling for estimating transaction cost before deployment is still developing. Once pre-flight cost estimation stabilizes, you will be able to see exactly which dimension you are hitting before you attempt a deployment. For now, the rule of thumb is: keep exported circuit counts under 10 per contract, and keep individual circuits focused on one operation.


If you are working through this on your own project, the discussion thread for this article is open in issue #305 on the contributor-hub repo. Questions about specific circuit splits or deployment strategies are welcome there.

Top comments (0)