Skip to Content
Blockchain specificsMidnightMidnight as L1

Midnight as L1

The second L1 we plan to integrate after launching on Cardano is Midnight. While there are plenty of details to pin down with regards to how this will happen, here is our rough sketch to convince ourselves it is a feasible goal.

Midnight Consensus

First, we provide some background on the properties of Midnight’s consensus algorithm.

Midnight is built on the Substrate framework, and uses AURA for block production, GRANDPA for finality, and BEEFY to produce bridge optimized proofs.

Selecting from a set of either federated nodes during the bootstrap era, or registered cardano stake pools, AURA establishes a deterministic round-robin schedule of block producers, assigning one block producer to each 6 second window. If a validator is offline during their opportunity to produce a block, the network treats the slot as empty rather than trying to find an alternate.

Shortly thereafter, GRANDPA initiates a vote by other validators to finalize the block; once a block has received votes from two-thirds of the validators, it is impossible for the chain to reorg to any different chain. Critically, GRANDPA finalizes a prefix of the chain. That allows it to catch up in one round if a previous finalization step stalled.

There is, however, a small risk of such a reorganization between block production and finalization. For example, if a block doesn’t propagate quickly enough, the next validator may build on the previous tip:

A <---- B // B's point of view A <---------- C // C's point of view

Alternatively, someone may maliciously use their opportunity as leader to equivocate and produce two different blocks for the same slot:

A <---- B // C's point of view A <---- B' // D's point of view

Given the deterministic schedule, this requires a 6 second block propagation delay (Cardano has a 99 percentile delivery for blocks under 1 second), or for B to be dishonest and open themselves up to network penalties, so is very rare in practice.

After a block is finalized by GRANDPA, BEEFY produces a much more compact certificate, using secp256k1, of the validator set, the finalized block hash, and the state roots. This allows bridges and light-clients on other chains to receive strong trustless assertions about transactions that have been finalized on Midnight.

In practice, for Gummiworm’s purposes, this all means that we can simply wait ~6-12 seconds for a block to be finalized, rather than dealing with a long probabilistic finality window like on Cardano, which should dramatically simplify the counterpart for Cardano Liaison component.

Midnight programming model

A Midnight transaction consists of:

  • A map from segment ID to intent, with segment IDs being “phases” or checkpoints in the execution of the transaction
  • A guaranteed ZSwap offer (shielded ledger token transfers that happen even if the transaction fails)
  • A map from segment ID to ZSwap offers that apply if the segment in question succeeds
  • A total blinding randomness for the transaction

An intent consists of:

  • A list of actions
  • Guaranteed and fallible offers for unshielded tokens
  • Dust registration or spending actions
  • TTL
  • A binding commitment value

A contract action is either:

  • A circuit invocation
  • Deployment of a new circuit
  • Maintenance of an existing circuit (Midnight’s native ability for contract governance)

Each intent has a generated randomness value (used to cryptographically blind the amounts, for example), for which we store only a commitment to the randomness. The transaction as a whole, on the other hand, stores the sum of the pre-commitment values.

These commitments have a homomorphic property: if you sum up the commitments, you’ll get a commitment to the sum.

This means that to validate the transaction, this summation has to balance; and it prevents anyone from “pulling apart” two intents that have been merged; since they only know a commitment to the intent randomness, and the sum, you can’t compute the correct randomness for the intent on its own, or for a transaction with one of the intents replaced.

Here’s a mental model for how Midnight contracts work:

  • Every contract consists of
    • A public ledger state it maintains.
    • Callbacks for accessing private data (witnesses) which typically correspond to the methods DApps implement in TypeScript.
    • A list of circuits, which are imperative programs that compile to a zk-provable constraint system.

To invoke a circuit:

  • A user runs the code locally, providing the public ledger state and their own private inputs via callbacks
  • The virtual machine running the code records a transcript: which values were read from the ledger state, what mutations to apply, timing constraints to enforce, zswap offers to require, etc.
  • While doing that, it also builds a zero knowledge proof that the transcript was derived from executing the specific circuit faithfully
  • Onchain, validators confirm the zero knowledge proof, and then apply the transcript to the public ledger state

Here’s an example of a simple lottery contract that exhibits some important properties:

Ledger State

  • tickets: Counter
  • last_buyer: Bytes<32>
  • pot: QualifiedCoinInfo

Witnesses

  • witness secretKey(): Bytes<32>;

Circuits

  • circuit increment(payment: CoinInfo)

    • Asserts that the counter is less than 20
    • Increments the counter
    • Writes hash(secretKey()) to last_buyer
    • Adds payment to the pot
  • circuit claim()

    • Asserts that the counter is equal to 20
    • Asserts that last_buyer is equal to hash(secretKey())
    • Clears last_buyer to 0
    • Resets tickets to 0
    • sends the pot, creating an offer and allowing the user to receive it

This example is instructive since it shows that:

  • Private inputs can be incorporated into the circuit
  • The circuit can avoid concurrency by relying on commutative operations like increment
  • A circuit can hold and send funds
Note

Right now, comparisons don’t actually encode as commutative assertions; Checking that the counter is < 20 would read the current value and then compare, which would make the circuit not commute with other increments. Such a thing is representable in the VM, I’m told, so this is a potential future improvement to compact. It’s actually quite difficult to make things truly contention free, and there will likely be lots of clever design patterns that people discover.

Gummiworm on Midnight

Note

This is all very much a sketch; As mentioned above, it is meant to convince us that deploying to midnight is possible, not be the final design. As we get familiar with Midnight, we will likely identify lots of tricks and optimizations that improve the protocol.

Gummiworm on Midnight would consist of three separate contracts:

  • Vault contract
  • Deposit contract
  • Dispute contract

Vault

The vault serves the role of the multisig address on Cardano: it holds all funds backing the L2 state, and facilitates the happy-path for all operations so long as consensus holds. Incidentally, since it’s a contract and not a native multisig, we don’t need the post-dated fallback effects - the conditions for switching to the dispute and evacuation can be implemented in the smart contract.

Ledger State

  • funds: QualifiedCoinInfo
  • major_version: Uint<32>
  • commitment: Bytes<32>
  • head_key: Bytes<32> - off-chain aggregated public key for the head peers
  • coil_key: Bytes<32> - off-chain aggregated public key for the coil peers, perhaps using FROST
  • deadline: BlockHeight
  • fallback: Contract

Circuits

  • circuit absorb_deposit(deposit: CoinInfo, l2_state_hash: Bytes<32>, head_signature: Bytes<32>, coil_signature: Bytes<32>)

    • Assert head_signature is a valid signature over midnight_gummiworm_deposit || major_version || l2_state_hash
    • Assert coil_signature is a valid signature over midnight_gummiworm_deposit || major_version || l2_state_hash
    • Add 1 to nonce
    • Add deposit to funds
    • Require new_major_version() be present elsewhere in the transaction
  • circuit remit_payout(amount: CoinInfo, destination: Either<Address, Circuit>, head_signature, coil_signature)

    • Assert head_signature is a valid signature over midnight_gummiworm_payout || major_version || amount || destination
    • Assert coil_signature is a valid signature over midnight_gummiworm_payout || major_version || amount || destination
    • Remove amount from funds, send to destination
    • Require new_major_version() be present elsewhere in the transaction
  • circuit new_major_version(new_commitment: Bytes<32>, head_signature: Bytes<32>, coil_signature: Bytes<32>)

    • Assert head_signature is a valid signature over midnight_gummiworm_major_version || major_version || new_commitment
    • Assert coil_signature is a valid signature over midnight_gummiworm_major_version || major_version || new_commitment
    • Increment major_version
    • Set commitment to new_commitment
  • circuit failover()

    • Assert currentBlock > deadline
    • Send funds to fallback
    • Invoke fallback.begin(major_version, commitment, deadline)

Deposit

The deposit contract serves as a queue for pending deposits; Even though Midnight has strong finality guarantees that would allow the circuit to safely absorb a deposit immediately, we maintain a separate contract because deposits aren’t accepted unconditionally, and the L2 ledger may reject them.

Ledger State

  • pending_deposits: Map<Uint<64>, Deposit>

Deposit type:

  • owner_vk: Bytes<32>
  • funds: QualifiedCoinInfo
  • expiration: BlockHeight
  • l2_state_hash: Bytes<32>
  • refund_destination: Either<Address, Contract>
  • next_deposit: Counter
  • head_key: Bytes<32>
  • coil_key: Bytes<32>
  • vault: Contract

Witnesses

  • witness my_secret_key(): Bytes<32>

Circuits

  • circuit deposit(amount: CoinInfo, l2_state_hash: Bytes<32>, refund_destination: Either<Address, Contract>)

    • let id = next_deposit.get();
    • next_deposit.increment(1)
    • Insert { hash(my_secret_key()), receive(amount), l2_state_hash, refund_destination } into pending_deposits at key id
  • circuit absorb(keys: List<Uint<64>>)

    • Iterate over keys; for each
      • remove the deposit from pending_deposits
      • send amount to Contract;
      • note that if the contract doesn’t receive it, the transaction will fail, so we don’t need to check the signature ourselves
  • circuit reject(keys: List<Uint<64>>, head_signature: Bytes<32>, coil_signature: Bytes<32>)

    • Assert head_signature is a valid signature over midnight_gummiworm_reject || keys
    • Assert coil_signature is a valid signature over midnight_gummiworm_reject || keys
    • Iterate over keys; for each
      • remove the deposit from pending_deposits
      • send amount to refund_destination
  • circuit refund(keys: List<(Uint<64>, Either<Address, Contract>)>)

    • Iterate over keys; for each
      • Assert currentBlock > expiration
      • Assert hash(my_secret_key()) == owner
      • Remove the deposit from pending_deposits
      • send amount to destination (not the refund destination)

Dispute

The dispute resolution contract serves the role of the rules-based regime on Cardano. Its goal is to identify the latest minor version snapshot to fan-out funds from.

Note

The logic here is quite involved and specific to how we deploy midnight, so this contract is even more of a sketch than other contracts

Ledger State

  • phase: Dormant | Voting | Evacuating
  • major_version: UInt32
  • minor_version: UInt32
  • vault: Contract
  • head_key: Bytes<32>
  • coil_key: Bytes<32>
  • voting_deadline: BlockHeight
  • commitment: Bytes<32>

Circuits

  • circuit begin(major_version, commitment, consensus_deadline)

    • Assert we’re called from vault
    • Assert phase is Dormant
    • Set voting_deadline to consensus_deadline + 7 days
    • Set major_version to major_version, minor_version to 0
    • Set commitment to commitment
    • Set phase to Voting
  • circuit vote(new_minor_version, new_commitment, signature)

    • Assert phase is Voting
    • Assert minor_version < new_minor_version
    • Assert signature is valid over commitment
    • Assert currentBlock < voting_deadline
    • Set commitment to new_commitment
  • circuit close()

    • Assert phase is Voting
    • Assert currentBlock > voting_deadline
    • set phase to Evacuating
  • circuit evacuate(proof)

    • Assert phase is Evacuating
    • Validate proof against commitment
    • send amount from proof to destination

Open questions

There are a number of minor open questions that would serve as feedback to the Midnight product team, or subtleties we’d have to solve, but which we expect present no major blockers to implementation.

  • Midnight transactions currently have no notion of metadata; how do we communicate things like L2 state?
  • Can evacuation use KZG commitments, or do we need a separate mechanism?
  • Can we make voting contention free, or provide contention free “ballot boxes” to avoid a denial of service attack?
  • Exactly how should the signature scheme work to be compatible with the signatures the coil peers are already producing?
  • Should Gummiworm heads support both ledger tokens and contract tokens?
Last updated on