How Nightfall works

Privacy and scaling capabilities

Nightfall uses ZKP for privacy and an Optimistic rollup for scalability. These two mechanisms are separate to an extent and so we will describe them individually, and then cover how they interact.

Details of transaction types are in the Basic Nightfall Workflow section and details of Nightfall objects are in the Nightfall objects section. Read these first because this section assumes familiarity with these concepts.

Privacy

Nightfall's privacy is based on a zksnark using the Groth16 protocol. This is a comparatively well-trodden approach and probably the most efficient. However, it does require a trusted setup at the circuit level. Nightfall's circuits are relatively static and therefore we prefer to trade efficiency for universality in this particular use case.

The whole approached is based on using an arithmetic circuit to assert that a number of conditions are fulfilled for some input data and then generating a proof that this is the case. The proof can be verified on-chain and therefore everyone can trust that the conditions are fulfilled. Some of the input data will remain secret however; we can learn that the conditions are fulfilled, even though we don't know what the data is. That is the purpose of a zero knowledge proof (ZKP).

We will not explain the details of how the zksnark is generated nor verified. That is rather too broad a discussion for here. See for example Vitalik Buterin's three-part article for more details.

In the below, we describe a number of variables about which we make statements via a zero knowlege proof (ZKP). Values that are marked with a * will be private and only the person generating the proof will know them. Other values will be public (and included in the transaction). We make values public because either a Challenger or the one of the Nightfall smart contracts must know them for correct operation.

Deposit transaction

When a Deposit transaction is posted to the Shield.sol contract, payment will be taken by the contract, equal to the value of the commitment that is to be created. That money will be held in the State.sol contract's escrow pool. The deposit transaction must contain a proof which makes statements about the pre-image of the commitment that is in the transaction. The proof will argue that the pre-image contains:

  • A zkp public key* (which should be yours, if you wish the commitment to be usable)

  • A value, equal to the amount being paid (for ERC20 and ERC1155, 0 otherwise)

  • A tokenId, equal to the id of the token being paid in (for ERC721 and ERC1155, 0x00 otherwise)

  • The address of the ERC contract associated with the token being paid in.

Most importantly, verification of the proof would ensure that the amount that is taken from the depositor's ERC balance by Shield.sol is exactly equal to that in the commitment preimage. This ensures conservation of value.

The proof generation is initiated by the Client container, when a user makes a deposit call. It hands off the actual proof generation to the Worker container, whose sole job is proof generation.

Transfer transaction

The transfer proof is much more complex to generate and has multiple parts. The transfer must 'destroy' one or more existing commitments and create new ones to the same total value but probably with a different owner (i.e. with preimages that contain a different zkp public key). The 'destruction' happens by creating a nullifier. The commitment isn't actually touched but the existence of its associated nullifier prevents it from being spent again, which amounts to the same thing.

The proof argues that:

  1. The user knows the zkp private key* corresponding to the zkp public keys* in the input commitments*

  2. The nullifiers correctly nullify the input commitments*

  3. The nullifier key* used in the nullifiers derives from the zkp public key* of the input commitments (the owner is the one nullfying them and no one else)

  4. The input commitments* actually exist (by providing a correct Merkle path* from the input commitment* to the root of the commitment tree, which is a public value in the Layer 2 block)

  5. The total value of the input commitments* equals the total value of the output commitments

  6. The ERC address* of the input and output commitments are all equal (you can't change tokens in Layer 2, otherwise you could, for example change WMATIC to WETH).

Note the output commitments and the nullifiers are public.

Withdraw transaction

The withdraw proof is very similar to the transfer proof except that there is no output commitment created. Instead the value of the input commitments* must equal the (public) value to be withdrawn. The recipient address is included as an input to the proof, although it does nothing specific. The inclusion is just to prevent it being substituted by a front runner; if they substituted their own address as the recipient, then the proof would not verify.

Scaling

In the above discussion, without any scaling, the proofs would be verified on chain by a smart contract, which would also prevent double spending by maintaining a list (or tree) of nullifiers.

This works, and give instant finality but is rather expensive, with a transaction typically costing a ~200k Gas. To reduce the cost we take an Optimistic approach.

In this scheme, each block of transactions is posted to the blockchain with only a very minimal set of correctness checks being made (for example that the block is in the correct order). No other compute, such as verifying the correctness of proofs, takes place on-chain by default.

Instead, the Optimist container checks every Layer 2 block of transactions off-chain for correctness. If anything is found that is incorrect then it can raise a challenge, specific to the error, and the Challenges.sol contract will check the validity of the challenge on chain. For example, if the Optimist noticed that a proof did not verify, it would raise a proof-does-not-verify challenge and Challenges.sol would verify the proof on-chain.

If the challenge is upheld then the faulty block and any subsequent blocks are deleted from the blockchain record. This information is then be emitted as a blockchain event (Rollback) and the Optimists and Clients update their local databases to reflect the new on-chain reality. The Proposer of the faulty block will lose their stake and be deregistered. The Challenger will be paid the block stake value for the block where they raised a challenge and any subsequent block that was built on the bad block. If the challenge is invalid then nothing happens, other than the Challenger has paid for a very expensive but nugatory transaction.

Note that the challenge process is actually a two step process. This is to prevent front-running. Firstly, the Challenger will post a hash of their challenge transaction to Challenges.sol. Once that has been received, the transaction can be sent and will only proceed if the hashes match. If a front-runner steals the challenge then the hash will not match and the transaction will fail. The front runner could steal the hash deposition transaction but then would be unable to follow through with the actual challenge because their address would not be in the hash preimage. The sender would be able to send a new transaction hash however. For a front-runner to successfully block a challenge, they would need to block all challenge hashes from all challengers for a week.

Last updated