DEV Community

Guaranteed vs Fallible: How Midnight Actually Commits State and Fees

If you come from Ethereum, Solana, or almost any other smart-contract chain, you carry a quiet assumption: a transaction either lands fully or it does not land at all. Midnight breaks that assumption on purpose.

A Midnight transaction can succeed in one section and fail in another and the successful section still commits. Fees paid for the failed work are still consumed. That is not an edge case. It is the execution model.

This article walks through the three-stage pipeline documented in Midnight's transaction semantics, then maps it to what you write in Compact with kernel.checkpoint(), and finally to what your DApp client must check after submission.


Why this matters more than another “intro to privacy” post

Most Midnight content stops at “ZK + shielded tokens.” Useful, but it does not prepare you for the failure modes that actually burn builders:

  • You assert auth and debit in one circuit, credit in another mental model then a fallible conflict leaves you with fees spent and state half-updated relative to what you intended.
  • You treat “tx included” as “contract call succeeded.”
  • You put complementary mutations (debit + credit) across a checkpoint boundary and create an inconsistent public ledger when only one side applies.
  • You do not understand why the fallible Zswap offer is applied during the guaranteed phase a security property, not an implementation quirk.

The mental model: three stages, three failure meanings

Every Midnight transaction passes through three sequential stages:

Stage Access to ledger? On failure
Well-formedness No (stateless) Rejected entirely never reaches the ledger
Guaranteed phase Yes Not included in the ledger
Fallible phase Yes Partial success guaranteed effects remain; fallible effects roll back

If a transaction fails during the guaranteed phase, it is not included in the ledger. If it fails during the fallible phase, any effects of the guaranteed phase still apply, and the ledger will record the transaction as a partial success.

And on fees:

The fees for all phases of execution are collected in the guaranteed phase and are forfeited if a transaction fails in the fallible phase.

That single sentence is the product of a deliberate incentive design: you cannot grief the network with free conflict retries. Failed fallible work still costs DUST.

Client constructs tx + ZK proofs
        │
        ▼
┌───────────────────────┐
│  1. Well-formedness   │  stateless: proofs, balances, claims, ckpt shape
└───────────┬───────────┘
            │ pass
            ▼
┌───────────────────────┐
│  2. Guaranteed phase  │  stateful: verify contract proofs, collect ALL fees,
│                       │  apply Zswap (incl. fallible offer), run guaranteed
│                       │  transcripts
└───────────┬───────────┘
            │ pass
            ▼
┌───────────────────────┐
│  3. Fallible phase    │  stateful: fallible transcripts, deployments
└───────────┬───────────┘
            │
     ┌──────┴──────┐
  success       failure
     │              │
     ▼              ▼
 SucceedEntirely   Partial success
                   (guaranteed kept, fees kept, fallible rolled back)
Enter fullscreen mode Exit fullscreen mode

Stage 1 — Well-formedness (stateless integrity)

Well-formedness never touches ledger state. It answers: is this transaction internally consistent and cryptographically well-formed?

Among other checks, the protocol verifies that:

  1. Zswap ZK proofs in both the guaranteed and fallible offers verify.
  2. The Schnorr proof on the contract section verifies (binds contract actions; ensures the section does not smuggle unbalanced value).
  3. The guaranteed offer balances after:
    • subtracting fees for the entire transaction (both phases), and
    • adding mints performed in guaranteed transcripts.
  4. The fallible offer balances after adding mints performed in fallible transcripts.
  5. Contract-owned inputs/outputs are claimed exactly once, with claim fallibility matching the offer they appear in.
  6. If a call has both sections, the fallible section starts with a ckpt operation.

That last rule is your Compact surface: kernel.checkpoint() compiles to Impact’s ckpt opcode. The chain will not accept a malformed phase split.

What well-formedness cannot check (because it has no state):

  • Whether a nullifier was already spent
  • Whether a Merkle root is still in the valid past-roots set
  • Whether the contract address exists / state matches the proof’s expectations

Those are deferred to the guaranteed phase.


Stage 2 — Guaranteed phase (all-or-nothing commit)

The guaranteed phase is the first stateful stage. Failure here means the transaction is not included. Nothing sticks.

Beyond the shared per-phase work, guaranteed does two things fallible does not:

1. Lookup verifier keys and verify contract-call ZK proofs

For each call, the ledger loads the contract’s entry-point → verifier-key map and verifies the submitted SNARK against the correct key. Proof failure aborts the whole transaction.

2. Apply the fallible Zswap section during the guaranteed phase

This is the detail most summaries skip. From the official semantics:

the fallible Zswap section is also applied during the guaranteed section, to ensure that it cannot invalidate the fallible section by itself.

The footnote explains the attack: otherwise you could merge an invalid spend that specifically poisons only the fallible section, letting guaranteed succeed while fallible is doomed a way to force partial success with corrupted fallible semantics.

By checking or applying fallible coin ops early, invalidity in that offer fails the guaranteed phase instead. The transaction is rejected wholesale. Partial success remains a state-conflict / transcript-execution outcome, not a merge-attack outcome.

Important nuance: early application in the guaranteed phase is an integrity / anti-merge check. A fallible segment’s coin effects still succeed or fail with that segment if the fallible segment fails later, those segment coin operations are discarded with it; only guaranteed-phase effects (including fees) stick.

Then the phase proceeds roughly as:

  1. Apply this phase’s Zswap offer (commitments into the Merkle tree, nullifiers into the nullifier set, past-root checks).
  2. Run the additional guaranteed-only checks above (if applicable).
  3. For each contract call in sequence, apply the guaranteed transcript:
    • load contract state
    • set up context
    • execute the Impact program in verification mode against the declared gas limit
    • require effects == declared effects
    • store resulting state iff it is “strong” (not weakened by copying context/effects into state)

Calls in the same transaction see prior calls’ state updates within the phase. Composition is sequential, not magical cross-contract recursion (cross-contract calls from inside a circuit are a different capability and are not assumed here).


Stage 3 — Fallible phase (may fail; guaranteed already paid)

Fallible execution looks similar, with important differences:

Guaranteed (protocol) Fallible (protocol)
Verifies contract ZK proofs Does not re-verify (already done)
Also applies / checks fallible Zswap early Fallible segment coin + transcript effects; discarded on failure
Fee collection for all phases No second fee collection
Guaranteed transcripts Fallible transcripts
Contract deployments execute here

If fallible fails, the ledger still records the transaction as a partial success. Guaranteed Zswap effects, guaranteed transcript effects, and fees remain.

That is why deployments are fallible by design: creating new persistent contract state is inherently conflict-prone. The protocol refuses to let deployment failure undo fee collection or proof verification invariants.


The Compact control surface: kernel.checkpoint()

In Compact, you do not name “guaranteed” and “fallible” as keywords. You insert a phase boundary:

export circuit submitTask(taskId: Bytes<32>): [] {
  // --- GUARANTEED ---
  const sk = localSecretKey();
  const callerPk = publicKey(sk);
  assert(disclose(callerPk == owner), "Only the owner can submit tasks");
  taskCount.increment(1);

  kernel.checkpoint();

  // --- FALLIBLE ---
  tasks.insert(disclose(taskId), TaskStatus.pending);
}
Enter fullscreen mode Exit fullscreen mode

Rules of thumb (aligned with compiler / Impact semantics):

Rule Meaning
No kernel.checkpoint() Entire circuit body is guaranteed-only
Code before checkpoint Guaranteed transcript
Code after checkpoint Fallible transcript (must begin with ckpt on-chain)
Multiple checkpoints Only the first is semantically meaningful for phase split

What belongs before the checkpoint

Put here anything that must not “half happen”:

  • Authorization / membership asserts that gate the call
  • Monotonic counters / nullifier inserts that define “this attempt happened”
  • Fee-relevant structure (fees themselves are protocol-collected in guaranteed; your circuit should not assume you can “refund” on fallible failure)
  • Proof-dependent invariants that define validity of the call

What belongs after the checkpoint

Put here operations that:

  • Touch hot shared mutable state (map overwrite / RMW patterns)
  • May conflict under concurrent inclusion in a block
  • Are safe to retry if they fail after fees were already paid
  • Deployments (always fallible at the protocol layer)

The design landmine

Never split a logical atomic pair across the boundary:

BAD:
  guaranteed: debit Alice
  fallible:   credit Bob

On partial success: Alice debited, Bob not credited.
Enter fullscreen mode Exit fullscreen mode

Keep complementary mutations in the same phase almost always the fallible phase together or redesign with append-only / journal patterns so a partial success is still interpretable.


Zswap offers sit beside contract transcripts

A transaction is not “just a contract call.” Structurally it combines:

  • a guaranteed Zswap offer (optional not every tx must move coins there)
  • fallible Zswap offers keyed by segment / intent (map-shaped, not a single blob)
  • contract actions whose transcripts split at ckpt

Balance rules (well-formedness) treat the two offer classes differently: guaranteed must cover fees for the whole transaction; fallible balances without that fee subtraction. Excess on the fee-paying side is how the network is paid.

Homomorphic Pedersen commitments let validators check balance without learning individual amounts the same algebraic substrate that makes Zswap mergeable for atomic swaps. Merging is a Zswap-strength feature; contract-call proofs do not merge the same way because each proof is bound to a private witness only its prover holds.


Partial success is a client problem, not only a chain problem

If your UI treats “included in a block” as “my transfer finished,” you will lie to users.

After finalization you must inspect section-level outcomes. The DApp connector / midnight-js stack surfaces execution status where fallible sections can fail independently of guaranteed success (see deploy helpers’ distinction between guaranteed failure vs fallible / non-SucceedEntirely outcomes in the midnight-js contracts API).

Practical client rules:

  1. Do not advance local / private state until you confirm full success for the sections you care about.
  2. Treat partial success as a real on-chain event: fees gone, counters may have moved, maps may not have.
  3. Design Compact so observers can distinguish “attempt recorded” from “effect applied” if you intentionally use guaranteed counters + fallible maps.
  4. Retry fallible work deliberately; do not assume the chain will undo guaranteed side effects.

Worked intuition: fees, conflicts, and griefing

Imagine two users submit conflicting fallible map updates in the same block window.

  • Both pass well-formedness (proofs, balances OK).
  • Both pass guaranteed (fees collected, proofs verified, fallible Zswap already applied).
  • First fallible transcript applies; second conflicts and fails.

Outcome for the second user: partial success. They paid. Their guaranteed effects (if any) stuck. Their fallible mutation did not.

That is expensive and intentional. Without fee forfeiture on fallible failure, an attacker could spray conflict transactions cheaply. With it, conflict is priced.


Conclusion

Midnight’s privacy stack gets the headlines. The guaranteed / fallible split is how that stack stays economically honest under concurrency: proofs and fees land in a phase that cannot be undone by a later conflict, while mutable contract work can fail without rewriting history.

If you only remember one sentence for your next Compact circuit:

Everything before kernel.checkpoint() is paid for and kept if the transaction is included; everything after can die and you still paid.

Design for that. Review for that. Teach that.


References

Top comments (0)