[runtime, evm]: Verify BEEFY finality with an aggregate public key proof and a header digest - #1107
[runtime, evm]: Verify BEEFY finality with an aggregate public key proof and a header digest#1107dharjeezy wants to merge 48 commits into
Conversation
…per signer merkle paths
… case can be tested without a mock chain
…ied with an aggregate public key proof
…ime and produce one from the prover
…ay the circuit reads them
… of a proof submission
…ges inside the relayer
…not by where it sits
| if (!found) { | ||
| (found, setId, apkCommitment) = HeaderImpl.apkCommitment(header); | ||
| } |
There was a problem hiding this comment.
we want to precisely extract the next set from hyperbridge header, not just any parachain
…tment computed under the old one
…g, and split the ecdsa path out of the crate root
…lay for its keys only when the set id moves
… second implementation
| if (relayProof.latestMmrLeaf.nextAuthoritySet.id > trustedState.nextAuthoritySet.id) { | ||
| trustedState.currentAuthoritySet = trustedState.nextAuthoritySet; | ||
| // The incoming set's size comes from the mmr leaf, but its APK commitment is not known | ||
| // until a header digest supplies it, so it starts empty. | ||
| trustedState.nextAuthoritySet = AuthoritySetCommitment({ | ||
| id: relayProof.latestMmrLeaf.nextAuthoritySet.id, | ||
| len: relayProof.latestMmrLeaf.nextAuthoritySet.len, | ||
| root: bytes32(0) | ||
| }); | ||
| } |
There was a problem hiding this comment.
better to do this when verifying the hyperbridge header, also the digest should contain the authority set length
|
Given we want to continue to support multi-proofs, through the struct AuthoritySet {
uint256 id;
uint256 len;
uint256 blsPoseidonHash;
bytes32 ecdsaMerkleRoot;
}The |
| /// since the commitment describes the next set. | ||
| pub set_id: u64, | ||
| /// Poseidon2 over the set's G1 keys, padded to the circuit width with the identity point. | ||
| pub commitment: [u8; 32], |
| ) -> Result<[u8; BLS_G1_SIGNATURE_LEN], anyhow::Error> { | ||
| use w3f_bls::{EngineBLS, SerializableToBytes, Signature, TinyBLS381}; | ||
|
|
||
| let mut aggregate: Option<<TinyBLS381 as EngineBLS>::SignatureGroup> = None; |
There was a problem hiding this comment.
why not just start with the identity point?
| /// Record `commitment` for `set_id` if it names a set this state knows and has no commitment | ||
| /// for yet. Called with whatever a verified header carried, so an unknown set is ignored | ||
| /// rather than treated as an error. | ||
| pub fn learn_commitment(&mut self, set_id: u64, commitment: H256) { |
There was a problem hiding this comment.
not a fan of this pattern, mutations should be done explicitly, at call site
| // Read the set id up front even though it is only needed at the end. It is cheap, and | ||
| // discovering it missing after hashing a whole set would throw that work away. | ||
| let set_id = Self::relay_beefy_set_id()?; | ||
| let set_digest = sp_io::hashing::blake2_256(&keys.encode()); |
|
|
||
| match next_step(Published::<T>::get().as_ref(), set_digest) { | ||
| // The keys have not changed, so the commitment stands. It still goes in this | ||
| // header, and under the current set id: a session can rotate without changing | ||
| // membership, and a client tracks commitments per set id, so the same commitment | ||
| // has to be published again under the new one or the client never learns it. | ||
| Step::Republish(commitment) => { | ||
| Self::deposit_digest(set_id, commitment); | ||
| Published::<T>::put((set_id, set_digest, commitment)); | ||
| Ok(false) | ||
| }, | ||
| Step::Commit => { | ||
| let commitment = | ||
| commit(&keys).map_err(|_| Error::<T>::MalformedAuthorityKey)?; | ||
| Self::deposit_digest(set_id, commitment); | ||
| Published::<T>::put((set_id, set_digest, commitment)); | ||
| Self::deposit_event(Event::CommitmentPublished { | ||
| set_id, | ||
| set_digest, | ||
| commitment, | ||
| }); | ||
| Ok(true) | ||
| }, |
There was a problem hiding this comment.
i feel as though, this is also unneccessary. Simply hash if the current set id has changed
| /// Runs the prover binary, handing it a directory to read inputs from and write the proof to. | ||
| /// | ||
| /// The binary is expected to read `apk-inputs.json` and write `apk-snark.json`, which is what | ||
| /// `gnark-apk-proofs`' `prove_from_json` does. |
There was a problem hiding this comment.
i don't know if we need to do this? whats wrong with using the ProverContext from gnark-apk-proofs?
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| //! Proving through a prover kept alive between proofs. |
There was a problem hiding this comment.
the gnark prover already does this no?
…cluster The relayer links sp1's gnark ffi, and the apk circuit brings a go runtime of its own. Two of those in one process do not work: built by different go toolchains the circuit setup spins on a single core forever, and built by the same one the process dies on the first call into either, with the go allocator corrupted. sp1's archive is pulled in by sp1-beefy/local, so a build that proves sp1 on a cluster has room for ours. That cluster feature could never take effect before, because zk-beefy took sp1-beefy with its default features and sp1-beefy defaults to local, which put the archive back whatever was selected. The default is unchanged, sp1 proves locally and the apk prover runs as its own process. Building with sp1-cluster and local instead compiles the circuit in and uses ProverContext directly. A compile error refuses the pair that crashes.
… subprocess provers
Hyperbridge computes a Poseidon2 commitment over the relay chain's next BEEFY BLS keys and publishes it in an
APKCheader digest, which a client reads straight out of a header it has already authenticated through the parachain heads root. Signers are then proven with a gnark APK proof and a single pairing instead of a merkle path per signer, so nothing in the calldata or the verification grows with the size of the validator set. The earlier per signer merkle client is removed along with its fixtures and generated bindings, so the APK route is the only BLS path here.Why the design changed
An earlier revision of this branch committed the BLS keys as one extra leaf of the BEEFY authority set tree. That was rejected on review, and it also kept the per signer cost that motivated moving to BLS in the first place, since every signer still travelled with a merkle path.
Committing the set in a Hyperbridge header digest asks nothing of the relay chain and nothing of other bridges. A client that verifies a Hyperbridge header at all already has everything it needs to trust a digest attached to it, so the commitment arrives with no extra proof and no extra gas.
How the commitment reaches a client
pallet-beefy-apk-digestreadsBeefy::NextAuthoritiesout of the relay state proof that every parachain block already carries, takes the G1 half of each paired key, and absorbs 64 slots per block into a running Poseidon2 state. A full 1024 slot set is roughly 420ms of wasm, which is why it is chunked rather than done in one block. On the block the last slot lands it depositsDigestItem::Consensus(*b"APKC", (set_id, commitment)).It commits the next set rather than the current one, and that is what makes the scheme work. A client verifying under set N learns the commitment for set N+1 and is ready before the rotation happens. Committing the current set would be circular, since you would need set N's commitment to verify the header carrying set N's commitment. A set changing part way through a chunked absorption restarts it, so a commitment can never be a mixture of two sets.
BlsApkBeefypicks the commitment up inverifyParachainHeaderProof, once those headers are proven, and stores it against the matching authority set. An update for a set whose commitment was never learned reverts withMissingApkCommitmentrather than checking a proof against zero.Following a live chain
Verifying one update is not the same as following a chain, and the ordering turns out to be forced rather than a matter of taste. The justification that rotates into a set is signed by that same set, so a client can never rotate into a set whose commitment it does not already hold. The commitment only arrives through a digest in a parachain header, and the header a rotation proof carries sits on the session boundary, where the relay has only just queued the incoming set, so it still names the set the client already knows.
A session therefore needs two proofs. The prover first proves finality inside the session it still trusts, which hands the client a header naming the incoming set, and the rotation goes through on a later tick. Choosing that block by position does not work: the digest naming a set only appears once the relay has queued it and the parachain has published it, so the prover looks for the earliest justification whose header actually carries the digest it needs. Taking the earliest rather than the last also leaves the rest of the session provable, which is what a proof for new messages needs.
The pallet had to learn the same thing. It used to turn away any proof that neither rotated nor finalized a new parachain head, on the grounds that it achieved nothing, which is exactly what a proof carrying a commitment looks like from the outside. Learning a commitment now counts as work alongside rotating.
Proving
The circuit takes about four minutes to compile and generate its keys, and a prover that starts fresh for each proof pays that every time, which is most of the wall clock.
apk-beefycan now keep one prover process alive and speak to it over its stdin and stdout, so the setup happens once at startup. A proof against a live chain takes 1m49s that way against roughly seven minutes before. The one shot behaviour is still available for a prover with no serve mode.What is here
pallet-beefy-apk-digest, with the rotation decision in a purenext_progressso the restart case is testable without a mock chain.apk-commitment, a no_std Poseidon2 commitment matching the circuit's vectors, absorbable in chunks.BlsApkBeefy.sol, a completeIConsensusV2client: authority selection, threshold, APK proof, MMR leaf inclusion, parachain headers, digest harvesting and stale proof no-op.beefy-verifier'sapkmodule, the same client for a substrate runtime, wired throughpallet-beefy-consensus-proofswith the circuit's verifying key in storage rather than baked into the runtime.BlsHashToCurve.solandBlsAggregate.sol, checked against vectors from the Rust code path the chain actually signs with.PairedAuthorityinbeefy-verifier-primitivesso the pallet and the prover read the 177 byte paired key the same way.Validation
verify()with a real parachain headerverify_apkbenchmarked at 41.21ms, charged separately from the rest of a submissionapk-commitment's, and the value the pallet published on chain all agree, and the fixture assembly asserts itForward chaining is the part worth calling out, since it is what the whole design rests on. Seeded with a set it had never seen, the client learned that set's commitment from a digest inside a parachain header it had verified for itself, and then accepted a rotation into it that it would have refused a step earlier.
The messaging case is pinned by construction rather than by reading the pallet's internals.
tesseract/consensus/beefy/tests/apk_messaging.rspicks a justification from the set the client already trusts, which rules out rotation, and checks afterwards that no commitment changed, which rules out teaching. A proof accepted under those conditions can only have been accepted for the messages it carried.The APK fixture is captured from a running relay rather than synthesised. Building it is a three step pipeline because the SNARK prover needs cgo and an 800MB SRS that should not enter this workspace: collect the live inputs, prove out of tree, then assemble.