|
| 1 | +# Optimistic Attestation Signature Validation Design Document |
| 2 | + |
| 3 | +- Owner: @spalladino |
| 4 | +- Approvers: |
| 5 | + - @LHerskind or @just-mitch or @Maddiaa0 |
| 6 | + - @aminsammara or @joeandrews |
| 7 | + - @PhilWindle |
| 8 | + |
| 9 | +## Summary |
| 10 | + |
| 11 | +In order to reduce L1 gas costs, we want to avoid verifying the validators' ECDSA signatures for block attestations on every block proposal. |
| 12 | + |
| 13 | +We propose having them posted to L1, but not verified unless strictly needed. |
| 14 | + |
| 15 | +## Background |
| 16 | + |
| 17 | +Signature verification today costs about 160k gas per L2 proposed block. This is expensive. |
| 18 | + |
| 19 | +In our benchmarks, a median `propose` call with a committee size of 48 validators costs `448,378` gas, and removing the `ValidatorSelectionLib.verify` function brings down this cost to `286,616`, saving `161762` gas (36% cheaper). |
| 20 | + |
| 21 | +## Design and Rationale |
| 22 | + |
| 23 | +Attestations provide the pending chain with the economic security of the epoch's validator committee that the txs for the block are available, and that the new state root follows correctly from executing those txs. Attestations also act as training wheels for our proving system, in case an attacker can generate a proof for an invalid public function execution. |
| 24 | + |
| 25 | +### Full Nodes |
| 26 | + |
| 27 | +To keep the same economic security for the pending chain, L2 nodes need to verify attestations on their own before accepting an L2 block as valid. This needs to be enforced in the `archiver` as blocks are being processed. Assuming the archiver is the sole source of truth for L1 data for the block, an L2 node will never serve data that does not have the security of the validator committee. |
| 28 | + |
| 29 | +### Attestors |
| 30 | + |
| 31 | +Before attesting to a proposal, validators check that the parent block hash is valid. Assuming validators only accept an L2 block if it has its attestations, as per the paragraph above, then they should not sign a block proposal unless it builds off a block that has all attestations. This means that attesting to a block is equivalent to attesting to it and to all its previous blocks in the epoch, and that these previous blocks have their attestations posted to L1. |
| 32 | + |
| 33 | +### Proposers |
| 34 | + |
| 35 | +Today the L1 rollup contract only accepts a new L2 block if it builds off from the previous one, and it's not possible for a new block to build off from an older one. This makes it easier to process the chain since it doesn't have any _forks_. This also helps with L1 reorgs: let's say the block for slot N+1 does not land, so the proposer for N+2 builds off from N; if N+1 eventually does get included via an L1 reorg, N+2 will be reverted automatically by L1. |
| 36 | + |
| 37 | +To maintain this invariant, a proposer for slot N first needs to check that all previous blocks have their corresponding attestations. This will be monitored by the `archiver` as it processes L1. Before posting a new block, a proposer will first need to _invalidate_ all previous blocks with incorrect attestations. This requires a new method in the rollup contract that, given a block number and the attestations originally posted by its proposer, it confirms that attestations are invalid and rolls back to the block immediately before. The `sequencer` will need the additional logic to construct this call, and bundle it with its proposal. |
| 38 | + |
| 39 | +### Proving |
| 40 | + |
| 41 | +Assuming our circuits and proving systems are sound, a prover can post a proof for a given epoch without having to verify any attestation, which is enough for convincing L1 of the correctness of the proven state root. However, if we were to do this, we lose the training wheels provided by the economic security of the attestation committee, in the event of a bug in proving. |
| 42 | + |
| 43 | +It follows that we want attestations to be verified. And as mentioned above, we know that verifying the attestations for the last block in an epoch is equivalent to verifying them for every block _in the epoch_, since every block in the epoch is attested by the same committee members, so the total stake is the same. So we should demand provers to verify the attestations of the last block in the epoch when they upload a proof. |
| 44 | + |
| 45 | +## Open Questions |
| 46 | + |
| 47 | +### How do provers verify the attestations from the last block in the epoch? |
| 48 | + |
| 49 | +We have two options: |
| 50 | + |
| 51 | +1. Prove the the validity of the attestations in the root rollup circuit (or in an extra circuit which gets recursively verified by the root rollup). This means no additional L1 gas costs at the expense of slightly longer rollup proving costs. This protects against any bugs on the AVM, but it does not protect against bugs in the overall proving system. |
| 52 | + |
| 53 | +2. Prove the validity of the attestations on L1, as part of the rollup contract method for submitting a proof. This means higher L1 gas costs, but protects against any bugs on the proving system. |
| 54 | + |
| 55 | +Given the tradeoffs in security, I push for the second option. |
| 56 | + |
| 57 | +### Who can invalidate blocks, and what is the incentive? |
| 58 | + |
| 59 | +One option is to restrict the rollup new `invalidate` method to the proposer in the current slot, so they can clear up previous invalid blocks before submitting, or to a prover submitting a proof, so they can clear up invalid blocks at the end of the epoch being proven (unclear if this is actually needed, given a prover can just submit a partial epoch proof without including the tail of invalid blocks). |
| 60 | + |
| 61 | +Since invalidating a block is an expensive operation, a proposer can be given a gas rebate for this action. This rebate could be taken from the proposer who posted the block with invalid attestations. However, this means enshrining a slashing mechanism in the system, which involves additional complexity. We could initially require proposers (or provers) to absorb this additional expense, assuming rewards will be enough to offset it. |
| 62 | + |
| 63 | +Alternatively, we can keep this method open for anyone to call. Assuming there is no reward for this action, we expect only block proposers to actually call it. Should we introduce a gas rebate and reward, we could end up with multiple nodes racing to claim this reward. |
| 64 | + |
| 65 | +Given the incentives, I suggest keeping the method open and with no rewards. And to minimize complexity, I suggest no gas rebates at all. |
| 66 | + |
| 67 | +### Where do attestations get posted? |
| 68 | + |
| 69 | +While the cheapest option is not to post attestations at all, and require these to be sourced from the p2p layer in order to invalidate a block, we quickly run into data availability issues, since the invalidator may not have access to the invalid attestations in order to post them to L1 to trigger the invalidation. |
| 70 | + |
| 71 | +Two options remain: posting them to calldata or to blobs. The flow in both cases is similar: proposers post attestations in either of them, and store in L1 a commitment to them (we can also modify the block hash to include a commitment to a set of attestations, to avoid an extra `SSTORE`, but this is a larger change). On block proposal, we check that the hash corresponds to the data posted. On (in)validation, the caller re-posts the attestations to L1, which get re-hashed and compared against the stored commitment, and then verified. |
| 72 | + |
| 73 | +Calldata for a 48-sized committee is `(48 * 2/3 * 65) + (48 * 1/3 * 20)` 2400 bytes, or 9600 gas. This can be saved in favor of moving the attestations to blocks. |
| 74 | + |
| 75 | +While posting on blobs is cheaper, it is more complex. As @iAmMichaelConnor points out: |
| 76 | + |
| 77 | +> If you were to put the attestations and and attested block data in a static part of the 0th blob of the tx (say, the first 500 fields of the blob), then in the event that you need to do a fraud demonstration (I call them "demonstrations" to avoid confusion with the word "proof"), you might be able to unpack that data efficiently. You would need to do some maths in typescript-land to compute a batched KZG witness for the first 500 values of the blob; `blob[0], ..., blob[499]`. When you do your fraud proof tx, you'd need to feed-in those 500 uint256 values as calldata, along with a Q, C (48 bytes each). The smart contract would already have hard-coded a commitment to the zero polynomial for the first 500 roots of unity. And with that data, you can probably call the point evaluation precompile (50k gas) to demonstrate that those 500 values do really exist within the blob. |
| 78 | +> |
| 79 | +> Cons: |
| 80 | +> |
| 81 | +> - Complex, so more surface for bugs. |
| 82 | +> - I'd need to double-check the maths, if you like the sound of it. |
| 83 | +> - Miranda will not enjoy the news that we want to modify what's inside a blob. |
| 84 | +> - It's not something that's easy to iterate on. |
| 85 | +> - It'll be hard to update the circuits to put block proposal stuff in that fist blob. |
| 86 | +
|
| 87 | +Given these cons, I'd push for posting to CALLDATA. |
| 88 | + |
| 89 | +### Do we accept a proven epoch with intermediate blocks that contain missing attestations? |
| 90 | + |
| 91 | +From the design above, it follows that the L1 rollup contract would happily accept a proof for an epoch where only its last block contains valid attestations. Since L1 is the source of truth, all L2 nodes should adjust their view of the chain to accept unattested blocks if they are part of the proven chain. |
| 92 | + |
| 93 | +Considering the above, what should an L2 node do if they see two blocks N and N+1 in L1, both from the same epoch, where N does not have its attestations? While this situation should not happen since the proposer for N+1 should refuse to build on N, there is nothing in the rollup contract that prevents it from happening. And since attestation for a block is economically equivalent to an attestation to also all its previous blocks within the same epoch, L2 nodes could happily accept both blocks N and N+1 in this example. |
| 94 | + |
| 95 | +It's unclear to me whether this may lead to situations where proposers purposefully omit attestations for a block, knowing that this gets "patched" in the following one. This doesn't seem to be the case if the attestation committee refuses to sign off N+1 given the lack of attestations on L1 for N, but I still wanted to flag it. |
| 96 | + |
| 97 | +The open question remains on whether L2 nodes should accept blocks N and N+1 in the example above, or wait until their epoch gets proven. For simplicity, I'd push for only accepting such blocks once they get proven. |
| 98 | + |
| 99 | +## Changes to L1 Rollup Contract |
| 100 | + |
| 101 | +### `propose` |
| 102 | + |
| 103 | +Changes to `propose` include computing and storing the `attestationsHash` for each block, as well as storing the `ProposePayload` digest, so both can be used for (in)validation later. This involves an extra 318 gas for hashing (assuming 48 as committee size), plus 40k gas for additional `SSTORE`s (see "Optimizations" below to bring down this number). |
| 104 | + |
| 105 | +### `invalidate` |
| 106 | + |
| 107 | +We add a new `invalidate` method that removes a given block from the pending chain (and all following ones) after showing that its attestations were invalid. Assuming attestations were pushed in CALLDATA: |
| 108 | + |
| 109 | +``` |
| 110 | +invalidate(blockNumber, attestations, committee, invalidIndex) |
| 111 | + let block = storage.blocks[blockNumber] |
| 112 | + let storedAttestationHash = block.attestationsHash |
| 113 | + require(storedAttestationHash === hash(attestations)) |
| 114 | +
|
| 115 | + let slotNumber = block.slotNumber |
| 116 | + let committeeCommitment = getCommitteeCommitmentAtSlot(slotNumber) |
| 117 | + require(committeeCommitment == hash(committee)) |
| 118 | +
|
| 119 | + let digest = block.proposalDigest |
| 120 | + require(ecrecover(attestations[invalidIndex], digest) !== committee[invalidIndex]) |
| 121 | + storage.tips = storage.tips.updatePendingBlockNumber(blockNumber - 1) |
| 122 | +``` |
| 123 | + |
| 124 | +Considering we need to do only a single `ECRECOVER`, we can estimate the gas for this operation to be `3000 * 31 = 93k` gas less than the current proposal validation, plus `4200` for the two SLOAD operations (`attestationsHash` and `committeeCommitment`), and the 9600 gas for calldata. This results in `160k - 93k + 4.2k + 9.6k = 80k` gas. Note that this function should hardly ever be called. |
| 125 | + |
| 126 | +### `submitProof` |
| 127 | + |
| 128 | +In addition to verifying the rollup validity proof, `submitProof` also needs to check the validity of attestations in the last block in the epoch. |
| 129 | + |
| 130 | +``` |
| 131 | +submitProof(currentArgs, attestations) |
| 132 | + let block = storage.blocks[currentArgs.endBlock] |
| 133 | + let storedAttestationHash = block.attestationsHash |
| 134 | + require(storedAttestationHash === hash(attestations)) |
| 135 | +
|
| 136 | + let slotNumber = block.slotNumber |
| 137 | + let committeeCommitment = getCommitteeCommitmentAtSlot(slotNumber) |
| 138 | +
|
| 139 | + let digest = block.proposalDigest |
| 140 | + let recoveredCommittee = [ecrecover(attestation, digest) if attestation is signature else attestation for attestation in attestations] |
| 141 | + require(committeeCommitment == digest(recoveredCommittee)) |
| 142 | +``` |
| 143 | + |
| 144 | +We estimate additional gas costs to be `160k` (current cost for verification), plus `4200` for the two SLOAD operations (`attestationsHash` and `committeeCommitment`), and `9600` for the extra calldata. |
| 145 | + |
| 146 | +### Optimizations |
| 147 | + |
| 148 | +The changes above involve storing two additional words in the `CompressedBlockLog` storage for the rollup contract. These could instead be stored as a single word by hashing them together, and requesting them as input arguments on any functions that use them (`invalidate` and `submitProof`). This means we'd require an additional `318 + 42 + 20k` gas for `propose` (hashing attestations, hashing the `attestationsHash` and `digest` together, and storing this last value). |
| 149 | + |
| 150 | +Note that, if we also shove the `blobCommitmentsHash` in this digest (which is only used once in `ProposeLib#propose` and once in `EpochProofLib#getEpochProofPublicInputs`), we can save the extra 20k from the `SSTORE`: |
| 151 | + |
| 152 | +```diff |
| 153 | +struct CompressedBlockLog { |
| 154 | + CompressedSlot slotNumber; |
| 155 | + bytes32 archive; |
| 156 | + bytes32 headerHash; |
| 157 | +- bytes32 blobCommitmentsHash; |
| 158 | ++ bytes32 hash(blobCommitmentsHash, attestationsHash, payloadDigest) |
| 159 | +} |
| 160 | +``` |
| 161 | + |
| 162 | +Assuming we implement the optimizations listed above, we can get to `160k` less gas per block proposal, with an extra cost for proof submission of `172k`. Amortizing this extra cost across 32 blocks in an epoch, we end up with `154k` less gas per block proposal |
| 163 | + |
| 164 | +## Disclaimer |
| 165 | + |
| 166 | +The information set out herein is for discussion purposes only and does not represent any binding indication or commitment by Aztec Labs and its employees to take any action whatsoever, including relating to the structure and/or any potential operation of the Aztec protocol or the protocol roadmap. In particular: (i) nothing in these projects, requests, or comments is intended to create any contractual or other form of legal relationship with Aztec Labs or third parties who engage with this AztecProtocol GitHub account (including, without limitation, by responding to a conversation or submitting comments) (ii) by engaging with any conversation or request, the relevant persons are consenting to Aztec Labs’ use and publication of such engagement and related information on an open-source basis (and agree that Aztec Labs will not treat such engagement and related information as confidential), and (iii) Aztec Labs is not under any duty to consider any or all engagements, and that consideration of such engagements and any decision to award grants or other rewards for any such engagement is entirely at Aztec Labs’ sole discretion. Please do not rely on any information on this account for any purpose - the development, release, and timing of any products, features, or functionality remains subject to change and is currently entirely hypothetical. Nothing on this account should be treated as an offer to sell any security or any other asset by Aztec Labs or its affiliates, and you should not rely on any content or comments for advice of any kind, including legal, investment, financial, tax, or other professional advice. |
0 commit comments