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 viewAlternatively, 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 viewGiven 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: Counterlast_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())tolast_buyer - Adds
paymentto thepot
-
circuit claim()- Asserts that the counter is equal to 20
- Asserts that
last_buyeris equal tohash(secretKey()) - Clears
last_buyerto 0 - Resets
ticketsto 0 sends thepot, 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
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
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: QualifiedCoinInfomajor_version: Uint<32>commitment: Bytes<32>head_key: Bytes<32>- off-chain aggregated public key for the head peerscoil_key: Bytes<32>- off-chain aggregated public key for the coil peers, perhaps using FROSTdeadline: BlockHeightfallback: Contract
Circuits
-
circuit absorb_deposit(deposit: CoinInfo, l2_state_hash: Bytes<32>, head_signature: Bytes<32>, coil_signature: Bytes<32>)- Assert
head_signatureis a valid signature overmidnight_gummiworm_deposit || major_version || l2_state_hash - Assert
coil_signatureis a valid signature overmidnight_gummiworm_deposit || major_version || l2_state_hash - Add 1 to nonce
- Add
deposittofunds - Require
new_major_version()be present elsewhere in the transaction
- Assert
-
circuit remit_payout(amount: CoinInfo, destination: Either<Address, Circuit>, head_signature, coil_signature)- Assert
head_signatureis a valid signature overmidnight_gummiworm_payout || major_version || amount || destination - Assert
coil_signatureis a valid signature overmidnight_gummiworm_payout || major_version || amount || destination - Remove
amountfromfunds,sendtodestination - Require
new_major_version()be present elsewhere in the transaction
- Assert
-
circuit new_major_version(new_commitment: Bytes<32>, head_signature: Bytes<32>, coil_signature: Bytes<32>)- Assert
head_signatureis a valid signature overmidnight_gummiworm_major_version || major_version || new_commitment - Assert
coil_signatureis a valid signature overmidnight_gummiworm_major_version || major_version || new_commitment - Increment
major_version - Set
commitmenttonew_commitment
- Assert
-
circuit failover()- Assert
currentBlock > deadline - Send
fundstofallback - Invoke
fallback.begin(major_version, commitment, deadline)
- Assert
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: QualifiedCoinInfoexpiration: BlockHeightl2_state_hash: Bytes<32>refund_destination: Either<Address, Contract>next_deposit: Counterhead_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 }intopending_depositsat keyid
-
circuit absorb(keys: List<Uint<64>>)- Iterate over keys; for each
- remove the deposit from
pending_deposits sendamounttoContract;- note that if the contract doesn’t
receiveit, the transaction will fail, so we don’t need to check the signature ourselves
- remove the deposit from
- Iterate over keys; for each
-
circuit reject(keys: List<Uint<64>>, head_signature: Bytes<32>, coil_signature: Bytes<32>)- Assert
head_signatureis a valid signature overmidnight_gummiworm_reject || keys - Assert
coil_signatureis a valid signature overmidnight_gummiworm_reject || keys - Iterate over keys; for each
- remove the deposit from
pending_deposits sendamounttorefund_destination
- remove the deposit from
- Assert
-
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 sendamounttodestination(not the refund destination)
- Assert
- Iterate over keys; for each
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.
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 | Evacuatingmajor_version: UInt32minor_version: UInt32vault: Contracthead_key: Bytes<32>coil_key: Bytes<32>voting_deadline: BlockHeightcommitment: Bytes<32>
Circuits
-
circuit begin(major_version, commitment, consensus_deadline)- Assert we’re called from
vault - Assert
phaseisDormant - Set
voting_deadlinetoconsensus_deadline + 7 days - Set
major_versiontomajor_version,minor_versionto 0 - Set
commitmenttocommitment - Set
phasetoVoting
- Assert we’re called from
-
circuit vote(new_minor_version, new_commitment, signature)- Assert
phaseisVoting - Assert
minor_version < new_minor_version - Assert
signatureis valid overcommitment - Assert
currentBlock < voting_deadline - Set
commitmenttonew_commitment
- Assert
-
circuit close()- Assert
phaseisVoting - Assert
currentBlock > voting_deadline - set
phasetoEvacuating
- Assert
-
circuit evacuate(proof)- Assert phase is
Evacuating - Validate
proofagainstcommitment sendamountfrom proof todestination
- Assert phase is
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?