DEV Community

Cover image for I Hit Midnight's Block Limits Twice; And It Forced Me to Rethink Everything

I Hit Midnight's Block Limits Twice; And It Forced Me to Rethink Everything

This isn't about my prediction market. It's about the assumption that broke it, twice.

I want to be upfront about what this is and isn't.

It's not a tutorial. It's not a "look what I built" post. It's what actually happened when I tried to bring EVM thinking into Midnight, hit a wall, optimized my way into the same wall, and finally understood why the wall existed in the first place.

What "Building on Midnight" Did to My Brain (Before It Fixed It)

When you spend enough time on EVM, you internalize a model without realizing it. Contracts can compute. Contracts can store structured data. Contracts can iterate. If it compiles, it probably runs.

I didn't question any of that when I started building on Midnight. I translated the mental model directly across.

That was the first mistake.

What I Built (V1)

I built what felt like a clean, well-structured prediction market. Store every bet. Track every user. Compute rewards on-chain. Store results. Let users claim.

Here's the Market contract state from V1:

export ledger admin: Bytes<32>;
export ledger marketName: Opaque<"string">;
export ledger description: Opaque<"string">;
export ledger imageUrl: Opaque<"string">;
export ledger parameter: Opaque<"string">;
export ledger category: Opaque<"string">;
export ledger bets: Map<Bytes<32>, Bet>;
export ledger betKeys: Map<Uint<32>, Bytes<32>>;
export ledger betCount: Counter;
export ledger totalVolume: Uint<128>;
export ledger totalParticipants: Uint<64>;
Enter fullscreen mode Exit fullscreen mode

And the Bet struct itself:

export struct Bet {
  user: Bytes<32>;
  amount: Uint<128>;
  predictedValue: Uint<64>;
  rewardAmount: Uint<128>;
  claimed: Boolean;
  timestamp: Uint<64>;
}
Enter fullscreen mode Exit fullscreen mode

The Factory was doing the same thing, storing a full MarketConfig struct with name, category, contract address, fee snapshot, timestamps, status, and active flag. All on-chain. Registered and tracked in a Map<Uint<32>, MarketConfig>.

At this point, everything felt right. Clean abstractions. Proper data modelling. Structured state. Good EVM design, basically.

The First Time It Broke

It didn't fail at compile time. It failed at execution.

Some transactions took minutes. Some never completed. Some failed silently. And eventually:

BlockLimitExceeded
Enter fullscreen mode Exit fullscreen mode

My first instinct: RPC issue? Wallet issue? SDK bug?

No.

The Reality I Didn't Understand

Midnight doesn't work like EVM. It doesn't charge you more for complexity, it just refuses to execute if you cross limits. The actual constraints are approximately 1 MB transaction size, around 1 second compute time, limited state writes, and constraint-based execution throughout.

This means you're not optimizing cost. You're trying to fit inside a box. And I wasn't even close.

What Actually Went Wrong (Not the Obvious Stuff)

The problem wasn't too many lines of code or too many functions. It was deeper than that.

Structs are not cheap. In EVM, Bet memory bet = bets[user] feels like a cheap read. In Midnight, bets.lookup(userPk) pulls the entire struct into the circuit, every field, every time. So when claimReward() did this:

const userBet = bets.lookup(disclose(userPk));
assert(!userBet.claimed, "PredictionMarket: Already claimed");
Enter fullscreen mode Exit fullscreen mode

It wasn't reading one boolean. It was processing all six fields of the Bet struct inside the circuit. And then to mark it claimed, I had to reconstruct the entire struct:

const updatedBet = Bet {
  user: userBet.user,
  amount: userBet.amount,
  predictedValue: userBet.predictedValue,
  rewardAmount: userBet.rewardAmount,
  claimed: true,
  timestamp: userBet.timestamp
};
bets.insert(disclose(userPk), disclose(updatedBet));
Enter fullscreen mode Exit fullscreen mode

Read full struct. Modify one field. Write full struct back. That pattern was everywhere in V1.

I built a database on-chain. marketName, description, imageUrl, parameter, category, all stored as on-chain ledger state. That's not a contract. That's a backend disguised as a contract.

I designed for iteration. This line in placeBet:

assert(
  betCount.read() < 500 as Uint<64>,
  "PredictionMarket: Max participants reached"
);
Enter fullscreen mode Exit fullscreen mode

Seems harmless. But it implies I was thinking in datasets, bounded participant sets, total user counts, potential future iteration. That's an EVM mindset. In Midnight, you don't think in datasets. You think in constraints.

I tried to compute rewards on-chain. The V1 Bet struct stored rewardAmount per user. That implied reward computation logic would run on-chain. Even before I got there, the struct overhead alone was enough to blow the limits.

The Second Failure (More Important Than the First)

After V1 failed, I did what most developers do. I split contracts. Reduced some redundant state. Reorganized logic. Introduced better lifecycle control.

V2 got significantly more sophisticated. Oracle whitelist for resolution. Dispute window system. Three-phase reward computation: computeRewardBatch, finalizeRewardBatch, markRewardsFinalized. The inverse distance accuracy model where rewards are proportional to how close your prediction was.

The math was actually elegant. Here's what computeRewardBatch was trying to verify:

const absDiff = getAbsDiff64(userBet.predictedValue, finalValue.read());
const denominator = (absDiff + 1 as Uint<64>) as Uint<128>;
const expectedInverseDist = getQuotient128(
  1000000000000000000 as Uint<128>,  // 1e18
  denominator
);
assert(_inverseDist == expectedInverseDist, "V2: Invalid inverse distance");
Enter fullscreen mode Exit fullscreen mode

For every single bet. One circuit call per user.

The problem wasn't the math. It was that V2 was still making the same fundamental assumption: contracts should compute. I'd changed how things were written, not what the system was doing. The struct reads were still expensive. The Merkle verification I'd introduced was heavy. The state reads and writes per transaction were still high.

It failed again.

This time the realization hit properly: I wasn't solving the right problem.

The Shift (V3)

This was the turning point. I stopped asking "how do I implement this on-chain?" and started asking "what is the minimum the chain needs to enforce?"

That question changed everything.

Look at what the V3 Market contract stores:

export ledger admin: Bytes<32>;
export ledger status: Status;
export ledger totalPool: Uint<128>;
export ledger endTime: Uint<64>;
export ledger finalValue: Uint<64>;
export ledger rewardPool: Uint<128>;
export ledger feesCollected: Uint<128>;
export ledger minBet: Uint<128>;
export ledger maxBet: Uint<128>;
export ledger betCount: Counter;
export ledger bets: Map<Bytes<32>, Uint<128>>;
Enter fullscreen mode Exit fullscreen mode

That last line. bets is now Map<Bytes<32>, Uint<128>>. Not a struct. Just the amount.

The placeBet circuit in V3:

bets.insert(disclose(userPk), disclose(_betAmount));
betCount.increment(1);
totalPool.write(disclose((totalPool.read() + _betAmount) as Uint<128>));
Enter fullscreen mode Exit fullscreen mode

Three operations. That's it. No struct serialization. No rewardAmount field. No timestamp. No predicted value stored on-chain. The prediction is captured in a separate data structure, just what's needed for the Merkle tree built off-chain.

The V3 Factory went even further:

export ledger owner: Bytes<32>;
export ledger marketCount: Counter;
export ledger marketExists: Map<Bytes<32>, Boolean>;
export ledger marketById: Map<Uint<32>, Bytes<32>>;
Enter fullscreen mode Exit fullscreen mode

registerMarket in V1 took six parameters and stored a full MarketConfig struct. In V3, it takes one parameter, the market address, and stores a boolean:

export circuit registerMarket(_marketAddress: Bytes<32>): Uint<32> {
  // ...
  marketExists.insert(disclose(_marketAddress), disclose(true));
  marketById.insert(disclose(id), disclose(_marketAddress));
  marketCount.increment(1);
  return id;
}
Enter fullscreen mode Exit fullscreen mode

Name, category, timestamps, fee snapshot, gone. All of that lives off-chain now, in an indexer.

And the Distributor, which is the most interesting piece of V3:

export ledger distRoots: Map<Uint<64>, Bytes<32>>;
export ledger distPools: Map<Uint<64>, Uint<128>>;
export ledger distDistributed: Map<Uint<64>, Uint<128>>;
export ledger distDeadlines: Map<Uint<64>, Uint<64>>;
export ledger distStatus: Map<Uint<64>, DistStatus>;
export ledger claims: Map<Bytes<32>, Boolean>;
Enter fullscreen mode Exit fullscreen mode

Flat maps instead of struct maps. Each field stored independently, no struct serialization overhead. And claimReward does exactly ten things: derive user key, compute leaf hash, check not already claimed, check deadline, check pool bounds, check status, accept proof via witness, mark claimed, update distributed amount, transfer funds. Nothing else.

What the Architecture Actually Looks Like Now

The flow:

User places bet on-chain. Off-chain: fetch all bets, compute final value, run the inverse distance accuracy model, generate the full reward distribution, build a Merkle tree over it. Admin submits the Merkle root on-chain via submitDistribution. User claims with a proof, the contract verifies the leaf, checks the pool, marks claimed, transfers.

export circuit submitDistribution(
  _merkleRoot: Bytes<32>,
  _totalRewardPool: Uint<128>,
  _claimDeadline: Uint<64>
): Uint<64> {
  // flat writes — no struct overhead
  distRoots.insert(disclose(id), disclose(_merkleRoot));
  distPools.insert(disclose(id), disclose(_totalRewardPool));
  distDistributed.insert(disclose(id), disclose(0 as Uint<128>));
  distDeadlines.insert(disclose(id), disclose(_claimDeadline));
  distStatus.insert(disclose(id), disclose(DistStatus.Active));
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The chain doesn't know how rewards were computed. It doesn't know how many users participated. It doesn't know who won or by how much. It knows exactly one thing: is this leaf in the Merkle tree, and has it been claimed before?

What I Had to Delete (This Is the Important Part)

To make V3 work, I had to remove things I thought were essential:

The full Bet struct: gone. Replaced with a single Uint<128> per user.

The betKeys index map: gone. Off-chain can reconstruct this.

totalParticipants, totalVolume as tracked ledger values: gone. Computable off-chain.

rewardAmount per user: gone. That's a Merkle leaf now.

marketName, description, imageUrl, parameter, category: all gone from on-chain storage.

The entire V2 reward computation pipeline, computeRewardBatch, finalizeRewardBatch, markRewardsFinalized: gone. Replaced by off-chain computation and a Merkle root.

The oracle whitelist, dispute window, resolution proposal system: simplified into a single admin resolve() call.

Everything I deleted felt essential when I wrote it.

The Tradeoff

This is where most people get uncomfortable.

V1 mindset: maximize correctness on-chain. V3 reality: maximize feasibility on-chain.

Yes, there are fewer on-chain guarantees now. The completeness of the reward distribution isn't enforced on-chain. Fairness depends on the off-chain computation being correct. The trust model shifted.

But here's the thing: V1 and V2 had zero on-chain guarantees in practice, because they never executed. A system that doesn't run protects nothing.

V3 actually works.

The Biggest Lesson

Midnight is not EVM with privacy.

It's closer to a constraint system that happens to be programmable. And that changes every design decision from the ground up.

If you're coming from EVM, here's what to unlearn:

"Contracts should compute logic." They shouldn't, not on Midnight.

"Store structured data cleanly." Structs are expensive. Flat maps are cheap.

"Track everything." Track only what the chain needs to enforce.

"Optimize gas." Wrong problem entirely. You're not optimizing cost, you're fitting inside a box.

Replace it with: contracts enforce invariants. State must be minimal. Computation belongs off-chain. Every circuit must fit within constraint limits.

The line that changed everything for me: the chain should not know how something was computed, only whether it is valid.

Closing

I didn't hit Midnight's limits because I wrote bad code.

V1 was well-structured. V2 was genuinely sophisticated. Both failed because I was solving the wrong problem.

Once I changed the mental model, the code became simpler, the circuits became smaller, and the system actually ran.

If you're building on Midnight and want to talk through the architecture before you commit to a direction, or want to see the full repos, find me on X or GitHub.

The walls are documented. You don't have to hit them yourself.

Midnight in Practice series · midnight-wallet-kit · mn-scaffold · Midnight Club

Top comments (0)