Recursive proof aggregation

In this post I will explain a concrete technique to aggregate all the proofs of a final (balanced) transaction. It is yet to be tested in practice. Pointing out mistakes, improvements, or opinions are welcome.

Given a program \mathsf P and an output x, the computational integrity statement that a proof \pi attests to is:

``\text{for program }\mathsf P \text{ and output }x, \text{ there exists an input } w \text{ such that } \mathsf P(w) = x."

The NP relation we are dealing with is \mathcal R:=\{(\mathsf p, x);w)\ |\ \mathsf P(w) = x\}. Thus, the input w is the witness. The description of the program (in lowercase \mathsf p) and the output form the instance. Let (\mathsf P, \mathsf V) be a proof system for \mathcal R.

Since the aggregation technique leverages recursive verification, we require succinct verification. This means the runtime of the verifier \mathsf V on instance (\mathsf p, x) is polylogarithmic in the runtime of the program \mathsf P on input x. Many SNARKs provide succinct verifiers (including the STARK scheme used in RISC0).

What this post covers. I will start explaining the basic idea, and then show how aggregations can be combined. From there we will see how to generalize to ‘universal’ aggregations, and finalize sketching how to connect the RM layer with the settlement layer via aggregating transaction proofs.

Aggregating proofs – the basic idea

In the simplest case, all proofs are for the same program. Let us take as example the compliance logic (program) \mathsf P_{c}(w_c) \rightarrow x_c run over compliance units. The exact structure of the witness w_c, instance x_c , and description of \mathsf P_{c} is unimportant for the purposes of aggregation. We only care about the existence of a SNARK with succinct verification \mathbf{V}:

\mathbf{V}(\mathsf p_{c},x_c,\pi_c) =1 \text{ if } \exists\ w_c\text{ s.t. } \mathsf P_{c}(w_c) = x_c

We aggregate compliance proofs one step at a time. At each step, a fresh output x_c of the compliance program is verified using proof \pi_c, and accumulated in a chaining hash h, (the bytes of x_c are hashed). Also, the previous aggregation is verified. In pseudocode:

\mathsf P_{ac}(\mathsf p^{in},h^{in},\pi^{in},x_c,\pi_c)\rightarrow (\mathsf p^{out},h^{out}):

  1. Check 1 = \mathbf V (\mathsf p_{c},x_c,\pi_c) // the description of the compliance program \mathsf P_{c} is hard-coded in \mathsf P_{ac}
  2. If h^{in}\neq h_0, check 1 = \mathbf V (\mathsf p^{in},h^{in},\pi^{in})
  3. Output h^{out} := Hash(x_{c},h^{in}) and \mathsf p^{out}:=\mathsf p^{in}

The aggregation program takes as input its own description \mathsf p^{in} := \mathsf p_{ac}. This just means that it verifies an execution of itself for a previous chained hash h^{in}. In other words, the aggregation is an incrementally verifiable computation (IVC).

Generated transcripts are dynamic, and of virtually any length. One action with two compliance units will aggregate twice, but another action with three compliance units can performs three aggregations.

Combining aggregations

With the IVC approach outlined above, a single actor can aggregate all the individual compliance proofs, independently of whether the proofs are from the same action (scope) or not.

Another possibility is that different actors aggregate in turns. For example, users announce unbalanced transactions with actions containing a single aggregated compliance proof \pi_a. The solver compiles the final transaction, and in the process aggregates the aggregations.

How do we aggregate aggregations? The process is the same, noting that executions of \mathsf P_{ac} can be verified incrementally once again.

\mathsf P_{aa}(\mathsf p^{in},h^{in},\pi^{in},x_{ac},\pi_{ac},)\rightarrow (\mathsf p^{out},h^{out}):

  1. Check 1 = \mathbf V (\mathsf p_{ac},x_c,\pi_c) // the description of the aggregation program \mathsf P_{ac} is hard-coded in \mathsf P_{aa}
  2. If h^{in}\neq h_0, check 1 = \mathbf V (\mathsf p^{in},h^{in},\pi^{in})
  3. Output h^{out} := Hash(x_{ac},h^{in}) and \mathsf p^{out}:=\mathsf p^{in}

The two-round aggregation process is depicted below.

Non-uniform aggregation

Suppose we have a total of \ell programs \mathcal P :=\{P_1,\ldots, P_\ell\}. In practice, these are the \ell different resource logics of a given scope. Whether the scope is an action or a transaction only matters to decide if we aggregate in turns or in one go. The programs are numbered as they appear in resources (resources are assumed to be canonically ordered in the scope). In particular, they may repeat, i.e. \mathcal P is a multiset, meaning that for i\neq j, we may have P_i = P_j.

The blueprint presented for aggregation of compliance proofs is:

  • at each step, verify an output of a specific program (the incremental verification),

  • accumulate the verified output in a chained hash,

  • verify correct aggregation up to the previous step (by recursive proof verification).

The blueprint can be translated to arbitrary program verification. The difference is that now at each incremental step we need to verify a different program. The first idea that comes to mind is to hard-code the \ell programs in the aggregation circuit. But that would yield rigid aggregations. Each multiset \mathcal P of programs would necessitate their own aggregation program.

We seek a universal aggregator that works for any two \mathcal P \neq \mathcal P'. To achieve it, the program P_{j_i} verified in the i-th incremental step is passed as an input. To keep track of what program is verified, the same trick as for outputs is used: the aggregation includes the (description of the) program P_{j_i} in a chained hash.

The pseudocode of the universal aggregator is as follows:

\mathsf P_{ua}(\mathsf p^{in},h^{in},d^{in},\pi^{in},\mathsf p,x,\pi)\rightarrow (\mathsf p^{out},h^{out},d^{out}):

  1. Check 1 = \mathbf V (\mathsf p,x,\pi) // the description of the verified program \mathsf p is an input of \mathsf P_{ua}
  2. If h^{in}\neq h_0, d^{in}\neq d_0, check 1 = \mathbf V (\mathsf p^{in},h^{in},d^{in},\pi^{in})
  3. Output h^{out} := Hash(x,h^{in}), d^{out} := Hash(\mathsf p,d^{out}), and \mathsf p^{out}:=\mathsf p^{in}

Soundness

Sound aggregation relies on the knowledge soundness of the proof system (\mathbf P, \mathbf V), and on the collision resistance of the hash function. Hash.

Let a bunch of programs \mathsf P_1,\ldots, \mathsf P_\ell, and a bunch of outputs x_1,\ldots,x_\ell. If there exists a valid proof \pi_{ua}, such that for chained hashes:

  • h = Hash(x_\ell,Hash(x_{\ell-1},\cdots,Hash(x_1,x_{IV}))\cdots )

  • d = Hash(\mathsf p_\ell,Hash(\mathsf p_{\ell-1},\cdots,Hash(\mathsf p_1,x_{IV}))\cdots )

the proof is valid \mathbf V (\mathsf p_{ua},(\mathsf p_{ua},h,d),\pi_{ua}) = 1, then we can use the knowledge extractor to get an input (\mathsf p_{ext},h_{ext},d_{ext},\pi_{ext},\mathsf p_{ext},x_{ext},\pi'_{ext}) such that h = Hash(x_{ext}, h_{ext}), and d = Hash(\mathsf p_{ext}, h_{ext}). Also, \pi'_{ext} is a valid proof for program \mathsf p_{ext} and output x_{ext}.

If x_{ext} \neq x_\ell, or \mathsf p_{ext} \neq \mathsf p_{\ell}, the extractor is an algorithm that finds collisions for Hash. This process can be repeated to argue that x_i is a verified output of the program \mathsf P_i.

Parallel proving

Proof carrying data is a generalization of IVC that allows to generate any type of transcripts (not just sequential transcript). Esentially, the witness is split into input and local data. In our case the input/output of the aggregation is the running chained hash, and the local data is the instance and proof that is verified incrementally. Suppose we want aggregate validation of three outputs x_1,x_2,x_3 of three programs \mathsf P_1, \mathsf P_2, \mathsf P_3. The universal aggregator can be modified to accept two running pair of hashes (h^{in}_1,d^{in}_1), (h^{in}_2,d^{in}_2), and perform two recursive proof validations, one for each pair. Incremental aggregation is now more complex, but allows parallelizing via tree-like transcripts.

Aggregating transaction proofs: from RM to settlement layer

The universal aggregator allows to generate a single proof for all proofs appearing in a transaction. Therefore, logic and compliance proofs can be accumulated together. This can be done either way, in two rounds, (aggregate within actions, then across actions), or in a single round (aggregate all individual logic and compliance proofs).

Well, you see where this is going. There is no reason we cannot aggregate transaction proofs. In the incremental proof generation, the digest and proof of the previous transaction is used to generate the proof of the new transaction. Obviously, this aggregation must be sequential and inherently two-round. Once the new proof is generated, the old one can be deleted. At all times, there will be a single proof attesting to the correctness of the logic and compliance predicates of all resources of the RM state. We can see this last aggregated proof as attesting to the well-formedness of the RM state.

Instance generation. The instance of an aggregation will always consist of two chained hashes. The first hash (h) contains all the accumulated instances of the incremental programs being verified. In other words, all the resources of the transaction. The second hash (d) contains all the programs. That is, the compliance and logic predicates. How exactly the hashes are formed depends on the aggregation strategy: the fan-in and ordering used to form the transcript. In the picture above we can see two different aggregation strategies.

3 Likes

Thank you for the write up! Some comments after the first read:

This is not the regular succinctness property of SNARKs. Where does it come from?

Shouldn’t we also output \pi^{out} here? Or is the aggregated proof just a hash? But then in the combined aggregation you use the same interface but if there is no proof in the output, what do you pass as input when aggregating aggregated proofs?

and p^{out} := p^{in}

So it is always the same value?

What is the value of h_0?

Do you mean that we can aggregate any number of proofs or are you talking of the resulting value being arbitrary length?

Shouldn’t this hash \pi^{in} in as well? Or generally anything that identifies the circuit

General comments:

  1. Could you please define each variable in each pseudocode entry? It is really hard to remember the meaning of all inputs especially when there are ~8 of them
  2. I think it would be helpful if you also defined how verification goes for each aggregated proof type (same or different circuits)
  3. This post describes the approach using generic terms. I think it would be helpful to describe it using the terms we use so that we don’t have to keep in our head the mapping when reading (e.g., program description vs proving/verifying key)
  4. Your point is basically that we use IVC for aggregation. I think it would be good to start with that (if that is the classic IVC definition or explain how it is different if it isn’t).
    1. Do we need anything on the risc0 side to use it?
    2. Does risc0 zkvm already support it or do we need to enable it ourselves?
    3. Can you specify what we need to use on risc0 side and what to implement additionally to enable aggregated proofs, at least in broad terms?
  5. Perhaps including the relevant links to risc0 recursion (if needed), IVC definition we are referring to, and any other sources you used would be helpful

To be able to recurse by verifying proofs. Succinct verification is a well-known property, but not all the schemes have it. (For example, Bullletproofs has a linear-time verifier.)

After each aggregation step, the prover produces a proof of correct aggregation. This would be \pi^{out}, which then is fed as input to the next iteration step.

Yes.

A default, fixed value. For example, all-zero bytes.

We can aggregate any number of proofs. The number of aggregated profs is not fixed.

\pi^{in} is the proof of correct aggregation. If you mean the proof of the incremental verification (e.g. the compliance proof \pi_c), I didn’t want to hash it so it doesn’t become part of the aggregated instance (the chained hash). We don’t really care what exact proof has been verified, but rather that there exists one.

What do you mean?

Which ones are unclear to you? I included pictures with the hope things will be clearer.

Not sure I follow here, sorry. For the same program (circuit), verification goes by hard-coding the verifier in the aggregation program. For aggregating proofs of different programs, it goes by hashing the verifier description, and those become part of the aggregated instance (together with the hash for the aggregated instances)

I’d say the idea is to use PCD, which generalizes IVC, and committed relations. Thus, no need to do sequential aggregation (see parallel proving and the last diagram), and commit (hash) to keep track of what has been verified. Comitting also has the advantage that the size of the aggregated instance will be constant, so the verifier runtime will not depend on how many instances where aggregated/verified incrementally.

As for RISC0, next step is to assess its feasibility. We basically need the ability to pass the program image ID as input to the guest program. And for non-uniform aggregation, different ones at each step. I’m working on this now.

If you are curious you can read about PCDs here and here. Proof recursion via succinct verifiers was established here. But not sure how much would help to understand the post. Below there is a list of articles on proof aggregation that I still need to go through. The most relevant seems to be Snarkfold. They are also taking the IVC-approach, but they are folding (relaxed) R1CS instances of the verifier – I’m not sure we can do that in RISC0.

I understand. I’m asking for a source with a formal definition of the property.

It would be helpful to see the description of inputs right next to the function that takes the inputs so that I don’t have to look in the rest of the document to remember what letter corresponds to what value. For example,

is difficult to read because I have to remember what all these variables are. It would be helpful to have them defined right above.

Regarding verification question:

So in resource plasma it is gonna roughly work as follows:
the user and the solver create individual proofs, then the solver/controller aggregate the proofs into one RM proof. This aggregated proof is verified on the Anoma contract (if it is inside the executor proof or not doesn’t matter here).

Let’s ignore the details on how the RM proofs are generated and imagine we have a single function RM.prove(...) that outputs a transaction with all the proofs required. We would like to have aggregation interface that takes the transaction / RM proofs as inputs and outputs the transaction and/or the aggregated proofs, something like:
Aggregator.aggregate: Tx -> [AggregatedProof; n]

(Here for generality we output not a single proof but n proofs where n is constant)

On the Ethereum side, we want to be able to verify the aggregated proof, e.g.:
Aggregator.verify: [AggregatedProof; n] -> ... -> Bool

I think it would be helpful to specify:

  • what the aggregate function takes and what it outputs
  • what the verify function takes as input
  • what other functions Aggregator (the name can change) exposes in the API
  • what steps these functions perform

But I think I defined and described everything in text surrounding the pseudocode. The paragraph just above it describes the algorithm:

I am not sure if you want more clarifications for better understanding, or because you want a place where everything is explained nicely and clearer. This post was conceived to just transmit the idea – it as an sketch to quickly share the key points, don’t take it as definite. If we are happy with the approach, I will write up the proposal more precisely. I’m still unsure whether you and @xuyang are happy with it or want to explore other non-IVC based possibilities. IMO:

pros

  • Aggregation can be paused and resumed by different parties → can aggregate in scope
  • individual proofs can be removed as soon as they are aggregated
  • tree-like provers: can utilize all the nodes of the tree, not just the leaves – yields less recursion overhead in general

cons

  • recursion overhead at each node might bee expensive
  • unclear whether can unleash full advantage of risc0. Ideally, I’d like to use the recursion module for the aggregation programs because the AIR being proved is optimized for STARK verifiers, which is what aggregation mainly do.

Yes, all this need to be specified. I would distinguish at least two scopes: action and transaction, and two aggregations strategies: sequential and tree-like. I first want to integrate the PoC in the arm-risc0 codebase and see how it plays out.

The former. It’s a bit challenging to fully evaluate the proposal without the RM context. Including some sketches for aggregate and verify, along with more detailed pseudocode that explicitly defines the inputs, would help clarify how the proposed proof aggregation scheme fits in the whole model

Here there is a bit more of detail. Hope it helps.

Aggregating proofs inside actions

Let n_c be the number of compliance units in the action. Let n_r be the total number of resources (consumed and created) in the action.

There are n_c compliance proofs, and n_r logic proofs in the action.

Aggregate compliance and logic proofs:

  • Inputs:

    • cvk: The verifying key of the compliance circuit.
    • (cst_{j},\pi_{c,j})_{j\in \leq n_c}: The n_c instances and (valid) proofs of the compliance circuit.
    • (lvk_j,lst_{j},\pi_{l,j})_{j\leq n_r}: The n_r verifying keys, instances, and proofs, of the resource logic circuits.
  • Output:

    • The action proof \pi_a. // An aggregated proof.

Steps:

  1. Set the initial aggregated instance to two zero-bytes arrays h_0,d_0, and the initial aggregated proof to a dummy value \pi_{a,0}.

  2. Let avk the verifying key of the universal aggregator program/circuit \mathsf{P}_{ua}. (See first post.)

  3. For j \in 1,\ldots n_c: // First aggregate the compliance proofs

    1. Compute h_j = Hash(cst_j,h_{j-1})
    2. Compute d_j = Hash(cvk,d_{j-1})
    3. Set ast_j = (avk,h_j,d_j) // The j-th aggregated action instance
    4. Set aw_j = (cvk,h_{j-1},d_{j-1},\pi_{a,j-1},\pi_{c,j}) // The j-th aggregated action witness
    5. Compute \pi_{a,j} = prove(\mathsf{P}_{ua},ast_j,aw_j) // The j-th aggregated action proof.
  4. Reset h_0 = h_{n_{c}}, d_0 = d_{n_{c}}, \pi_{a,0}= \pi_{a,n_c}

  5. For j \in 1,\ldots, n_r: // Now aggregate the logic proofs

    1. Compute h_j = Hash(lst_j,h_{j-1})
    2. Compute d_j = Hash(lvk_j,d_{j-1})
    3. Set ast_j = (avk,h_j,d_j) // The (n_c+j)-th aggregated action instance
    4. Set aw_j = (lvk_j,h_{j-1},d_{j-1},\pi_{a,j-1},\pi_{l,j}) // The (n_c+j)-th aggregated action witness
    5. Compute \pi_{a,j} = prove(\mathsf{P}_{ua},ast_j,aw_j) // The j-th aggregated action proof.
  6. Output \pi_{a,{n_c+n_r}}

Verify aggregation of compliance and logic proofs:

  • Inputs:
    • cvk: The verifying key of the compliance circuit.
    • (cst_{j})_{j\in \leq n_c}: The n_c instances of the compliance circuit.
    • (lvk_j,lst_{j})_{j\leq n_r}: The n_r verifying keys and instances of the resource logic circuits.
    • \pi_a: The aggregated proof for the action.
  • Output: b\in\{true,false\}

Steps

  1. Derive the action instance from compliance and logic instances of the action:

    1. Set h_0,d_0,\pi_{a,0} to the same values as when aggregating.

    2. For j \in 1,\ldots n_c+n_r: // Compute same chained hashes as when aggregating

      • If j \leq n_c

        • Compute h_j = Hash(cst_j,h_{j-1})
        • Compute d_j = Hash(cvk,d_{j-1})
      • If j> n_c:

        • Compute h_j = Hash(lst_j,h_{j-1})
        • Compute d_j = Hash(lvk_j,d_{j-1})
    3. Set ast = (avk,h_{n_c+n_r},d_{n_c+n_r}) // The action instance

  2. Output verify(avk,ast,\pi_a)

Aggregate proofs in transactions (aggregate all compliance and logic proofs)

The same way as aggregating/verifying them within actions (see above). One first collects compliance and logic instances and proofs from all the actions in the transaction.

Aggregate proofs in transactions (aggregate action proofs)

This assumes actions come with their compliance and logic proofs already aggregated. Let n_a be the number of actions in transaction T. There are n_a action proofs generated as explained above.

Aggregate actions proofs:

  • Inputs:

    • avk: The verifying key of the universal aggregator program/circuit \mathsf{P}_{ua}. (See first post.) .
    • (ast_{j},\pi_{a,j})_{j\in \leq n_c}: The n_a instances and (valid) proofs of the actions of the transaction. Each action instance is generated from the compliance and logic instances of the corresponding action as explained in step 1 of ‘verify aggregation of compliance and logic proofs’
  • Output:

    • The transaction proof \pi_t. // An aggregated proof.

Steps:

  1. Set the initial aggregated instance to two zero-bytes arrays h_0,d_0, and the initial aggregated proof to a dummy value \pi_{t,0}.

  2. Let avk the verifying key of the universal aggregator program/circuit \mathsf{P}_{ua}. (See first post.)

  3. For j \in 1,\ldots n_a:

    1. Compute h_j = Hash(ast_j,h_{j-1})
    2. Compute d_j = Hash(avk,d_{j-1})
    3. Set tst_j = (avk,h_j,d_j) // The j-th aggregated transaction instance
    4. Set tw_j = (avk,h_{j-1},d_{j-1},\pi_{t,j-1},\pi_{a,j}) // The j-th aggregated transaction witness
    5. Compute \pi_{t,j} = prove(\mathsf{P}_{ua},tst_j,tw_j) // The j-th aggregated transaction proof.
  4. Output \pi_{t,{n_a}}

Verify aggregation of action proofs

  • Inputs:
    • avk: The verifying key of the compliance circuit.
    • (ast_{j})_{j\in \leq n_a}: The n_a action instances of the transaction.
    • \pi_t: The aggregated proof for the action.
  • Output: b\in\{true,false\}

Steps

  1. Derive the transaction instance:

    1. Set h_0,d_0,\pi_{a,0} to the same values as when aggregating.

    2. For j \in 1,\ldots n_a: // Compute same chained hashes as when aggregating

      1. Compute h_j = Hash(ast_j,h_{j-1})
      2. Compute d_j = Hash(avk,d_{j-1})
    3. Set tst = (avk,h_{n_a},d_{n_a}) // The transaction instance

  2. Output verify(avk,tst,\pi_t)

Parallelizing provers

The universal aggregator program \mathsf{P}_{ua} can be modified so it accepts as input 2 aggregated instances and proofs (instead of one).
To aggregate a total of N = \sum_{l=0}^d n/{2^l} proofs, the N incremental proofs produced by the prover can be arranged into d layers. In the l-th layer, n/{2^l} proofs are generated in parallel and fed as input to the next layer.

Instance generation. The chained hashes h_j,d_j are computed accordingly: use the two input pair of hashes at each incremental step.

Mixing aggregation strategies

Sequential and parallel provers via binary tree transcripts are two examples of aggregation strategies. Most likely the ones we will want to use the most (sequential for few aggregations, parallel for many aggregations).

Each transaction (or each action within a transaction) can aggregate with their own strategy. Aggregating two proofs resulting from different strategies is possible.

Thank you, this is very helpful! A couple of thoughts:

  1. This IVC-style approach is strictly sequential and processes one proof at a time, which means the only way to parallelise it, as far as I can see, is to make the sequences shorter and then aggregate the aggregated proofs sequentially. I wonder if there are approaches that can take, for example, two proofs as input and aggregate them into one, so that instead (old_aggregated_proof, incoming_proof) -> new_aggregated_proof we can get something like incoming_proof_1, incoming_proof_2 -> aggregated_proof_12
  2. Could it make sense to focus on vk-based domain separation, i.e., we aggregate proofs with the same vk first and then aggregate the resulting proofs? Do you think it can have some benefits, e.g., could be faster?
  1. The compliance verifying key doesn’t change. What is the value of recomputing d_j at each step? Is it just for the circuit generality?
  2. What are the constraints of the aggregation circuit?

Tree-like transcripts are possible. (For an expanded answer, see this part and this.)

With the PCD approach, each node of the tree verifies an instance of the transaction (see the last picture in the first post). Compare this with e.g. the way recursion is implemented in RISC-0, where the instances to verify are place in the leaves of the tree. Proofs in leaves yields trees with more nodes – more recursion overhead.

Hmm, maybe. I’m not sure. Perhaps can avoid hashing the verifying key while going through instances for the same key, and simply pass it from input to output. This adds complexity (in coding). It would be good to give it some thinking, but I will wait to first get something prototyped.

We need to keep track of the verifying keys each of the instances are verified against. Otherwise can verify against other (dummy) programs that are always satisfiable. Here I elaborate a bit more about the soundness of the aggregation.

The universal aggregation program. Specified here: it verifies input aggregations, and the incremental instance.

1 Like