-
Notifications
You must be signed in to change notification settings - Fork 8
Description
Interleaving proofs guided by communication through IS and FS
NOTE: This is a draft. I'm probably going to change multiple things (especially as
I'm still reading the Coral paper), but feel free to quote and comment.
The TLDR is basically that I think we can verify a Starstream utxo aggregation
trace with just proving memory splicings and concatenations.
Background
Let's assume that we have a zkVM that works with a memory, with a nebula-like
memory argument (e.g. zkEngine).
This means we can generate proofs of the correct computation with the following
inputs and outputs:
input:
(pc = program counter, sp = stack pointer, IS = initial memory, P = a wasm program)
output:
(pc, sp, FS = final memory)
So, let's say we want to prove that 2 + 3 = 5 (who would believe it otherwise).
We could encode the computation as a wasm program, like:
(module
(memory (export "memory") 1)
(func (export "add")
;; Load value from address 3
i32.const 3
i32.load
;; Load value from address 4
i32.const 4
i32.load
;; Add the two values
i32.add
;; Store the result at address 5
i32.const 5
i32.store
)
)Then we initialize a memory with
IS: [0, 0, 0, 2, 2, 0]
And we use the zkVM to generate the proof.
The verifier now can check that the proof was generated with the initial memory
it expected (the prover has to commit to it), and then it knows that the result
should be at FS[5].
The rest of the memory could be used for state needed in the computation (a
coroutine frame), or other private information.
Chaining
What about proving a chain of 2 different computations?
We would have:
Proof_0 := F( pc, sp, IS_0, P_0 ) == FS_0
Proof_1 := F( pc, sp, IS_1, P_1 ) == FS_1
Then to verify the chaning we can assert that:
verify(Proof_0, IS_0) && verify(Proof_1, IS_1) && IS_1 == FS_0
But of course this only works if the memory layout is the same.
What if we just want to inject the result, instead of any auxiliary memory or
state used by the computation?
We would need to assert that:
verify(Proof_0, IS_0) &&
verify(Proof_1, IS_1) &&
IS_1 == IS_state_p1[0..j] <> FS_0[i..i+n] <> IS_state_p1[j+n..]
Where IS_state_p1 is the previous state of this program (a previous FS,
for example).
Starstream
In the context of Starstream, the IS_state_p1 would be the current coroutine
frame of a utxo, or a coordination script. And the interleaving operation would
be equivalent to injecting an external slice of memory into it before resuming.
Since we need to do the verification outlined above in a circuit, we need mostly
2 things:
A way to compute FS[i..i+n].
There is a more or less straight-forward way of doing this already. Since the
zkVM needs to compute multiset hashes to IS and FS and commit to them.
Multiset hashes are associative, so we could split them into 3 parts.
H(FS) = H(FS_0) * H(FS_1) * H(FS_2).
Then in our verifier function can assert that
H(IS) = H(IS_1) * H(FS_1) * H(IS_2).
The issue with this is that it doesn't prove that the splices are done at the
right indexes. So most likely we need to compute H(FS_1) in the circuit too,
and prove that the addresses are in the right range (reminder: the IS is a
multiset of pairs (address, value, timestamp)). This shouldn't be a big issue,
since the same kind of computation needs to be done anyway for the mcc, although
whether there is a way of re-using work is still unclear to me.
There may be also another way of doing this check with some other type of
commitment.
TODO: there is something like this mentioned in this paper:
"Coral: Fast Succinct Non-Interactive Zero-Knowledge CFG Proofs"
Quoting:
Persistent memory.
In Appendix D.1 we discuss the construction of Split Witness Relaxed R1CS,
which makes it straightforward to share segmented/incremental memory between
different proofs. Essentially, one can commit to any segment of IS or FS (or
their entirety) separately, and then incrementally and verifiably access that
segment across proofs.
A way to encode a verifier for the auxiliary (wasm) VM as part of the aggregator step function.
If we want a single proof, we need to also verify the wasm VM as part of
this consistency check. This should be doable, since this is how the Nebula
implementation in zkEngine works for example, so the code can be adapted from
that.
Essentially we could just extend the aggregator snark with it's own shared
memory and add the segmentation checks.
A few potential issues:
-
If we need to do other operations, not sure how easy it is to make that
adaptable to the switchboard approach. -
If we want to use https://github.com/jkwoods/segmented_circuit_memory then it
uses a different fork of Nova that doesn't seem to have that code (because they
implemented nebula with a single layer).
NOTE: I think we probably could also just have 2 circuits if needed.
Transaction processing
To put the above together, the aggregation proof could work like this:
Both utxos and coordination scripts are just wasm programs. The only difference
is that coordination scripts are not persisted after the transaction ends, but
we also need a coroutine frame for them (that gets initialized empty).
First we just run Starstream in non zk mode.
From this we can capture
- The utxos used as inputs.
- The new utxos.
- The utxos that ended.
- The interleaving order (who yields to who).
This creates the structure of a Transaction.
From this information, we can build a trace of this shape:
utxo = utxo id | wasm program vk | pc | sp | IS | FS = (frame, next_program?) | proof
Each step basically has to run a snark verifier, by using as inputs:
- The wasm program.
- An initial memory composed of the previous FS for that utxo, plus whatever is injected as input.
Then we update the current FS for this utxo with a variable in the public
memory.
How do we know which data to inject?
We control the memory layouts of the programs. That means we can just have the
programs decide the flow. And we know it's correct because of the wasm proofs.
If we have a coordinator script as a wasm program, and it calls utxo.resume,
then it has to push the utxo id to the stack as an argument. We just need to
decompose the FS at that position to extract the data.
We can also have it communicate in the same way which memory range we need to
inject into the other program's memory.
We can then keep track of two variables in the linear memory:
current_programnext_program
In each folding step, we assert that the utxo we are verifying is
next_program. And we compose the new IS with
inject_output(memory[next_program], range_from, memory[current_program], range_to).
Then set current_program = next_program.
Since we can inject one piece of data at a time, we also don't need any control
flow in this machine, that gets decided by what the wasm programs communicate
through it's output (memory).
What about effect handlers?
Effect handlers work similar to utxo resuming. We can pass handler ids down
as inputs, and if a function defines a new handler, it just overrides that
parameter.
Then when yielding, the utxo (or coordinator script) explicitly communicates
what the next program would be, by writing to the stack who should handle the
effect, and with which data.
What about builtin effects?
I think it's the same thing. We can inject the current_program for example, if
a utxo wants to assert that is being called by a specific coordination script.
Ledger-specific data could be also in the initial memory.
What about normal yield?
If a utxo yields, it doesn't yield to a specific utxo.
In that case we set next_program = current_program to continue the
coordination script that resumed that utxo.
We also need to verify that the last witness in the trace is for a coordination
script that just returns nothing.