zkApp composability RFC
"zkApp composability" refers to the ability to call zkApp methods from other zkApp methods. Technically, it uses the callData
field on the zkApp party to connect the result of the called zkApp to the circuit/proof of the caller zkApp.
An initial spec by @mrmr1993 laying out the ideas for how to use callData
can be found here: https://o1-labs.github.io/snapps-txns-reference-impl/target/doc/snapps_txn_reference_impl/call_data/index.html
We propose the following API for snarkyjs:
API
@method myMethod() {
let calledContract = new CalledContract(address);
let result = calledContract.calledMethod(arg);
// .. do anything with result
}
That is, we can just call another method inside a smart contract method. That's the API 😃
NEW: To enable returning a result to another zkApp, the result type must be annotated with a TS type:
class CalledContract extends SmartContract {
@method calledMethod(arg: UInt64): Bool { // <-- HERE
// ...
}
}
The return type annotation is captured by the decorator (like the argument types), and supports all circuit values.
Since it is easy to miss adding this, we take care to catch the case that
- a method is called by another method, and returns something else than undefined
- BUT no return type annotation exists
In this case, the following error is thrown:
Error: To return a result from calledMethod() inside another zkApp, you need to declare the return type.
This can be done by annotating the type at the end of the function signature. For example:
@method calledMethod(): Field {
// ...
}
Note: Only types built out of `Field` are valid return types. This includes snarkyjs primitive types and custom CircuitValues.
zkApp calls can be arbitrarily nested! So, the calledMethod
above could itself call another method.
(In theory, a method could even call itself! But we would need logical branching to make that work -- right now, it can't conditionally call itself, so calling itself would cause infinite recursion.)
How it works under the hood
At a high level, calling a zkApp does not mean that the called method is executed inside the caller's circuit. Instead, the callee should have its own party, with its own execution proof for the method that was called. This means that we have to be all the more careful to constrain the callee party in the caller's circuit, in a way that we fully prove the intended statement: "I called this method, on this zkApp, with these inputs, and got this result".
To make the caller prove that, we do the following:
- In the callee circuit, we compute the following hash, and store the result in the callee's
callData
:
this.body.callData = Poseidon.hash([...inputs, ...outputs, methodIndex, blindingValue]);
--> inputs
are the arguments of the method call, represented as an array of field elements
--> outputs
is the return value of the method call, represented as an array of field elements
--> methodIndex
identifies the method that is called, by an index starting with 0, among all the methods available on the called smart contract (the index is represented as a full Field
)
--> blindingValue
is a random Field
that is made accessible to both the caller and callee circuits at proving time (it has the same value in both proofs!). In the circuit, blindingValue
is represented as a witness with no further constraints.
- In the caller circuit, we witness the party of the callee, plus the result of hashing the callee's own children (= the
calls
field of the callee's public input), plus the return value of the called method. Then, in the caller circuit we perform the same hash as before and compare it to the callee's callData
:
// ... witness `callee` and `outputs` ...
let callData = Poseidon.hash([...inputs, ...outputs, methodIndex, blindingValue]);
callee.body.callData.assertEquals(callData);
--> this check proves that we called a method with a particular index, with particular inputs and output. To also prove that we call a particular zkApp, we have to add more checks (see next bullet point)
--> the blindingValue
is needed to keep the inputs and outputs private. Note that the callData
is sent to the network as part of the transaction, so it is public. If it would contain a hash of just the inputs and outputs, those could potentially be guessed and the guess checked against the hash, ruining user privacy. Since guessing the blindingValue
is not possible, our approach keeps user inputs private.
- In the caller's circuit, we also assert that the publicKey and tokenId on the callee are the ones provided by the user:
callee.body.publicKey.assertEquals(calleeInstance.self.body.publicKey);
callee.body.tokenId.assertEquals(calleeInstance.self.body.tokenId);
--> these checks make sure that the child party in the transaction, which belongs to the callee, has to refer to the same account which is defined by publicKey
and tokenId
. Thus, we're proving that we're calling a particular zkApp.
And that's all we do under the hood when you call another zkApp!
An important thing to note is that we add stuff to the callee circuit, although the called method didn't specify that it can be called. To make that work, we in fact add that stuff to every zkApp method. In particular, the callData
field will always be populated, regardless of whether a zkApp is called or not.
Making all zkApps callable is a deliberate design decision. The alternative would be to add a special annotation, for example @callableMethod
instead of @method
, to tell snarkyjs that this method should add the callData
computation to its circuit. That way, we would save a few constraints in methods that aren't callable. However, many zkApp developers would probably not consider the requirement of a special annotation at first, or overestimate the performance impact of adding it (it is really small, just 20-odd constraints for the extra Poseidon hash). To make the zkApp callable later, it would have to be re-deployed later. Also, we would have to ensure that a non-callable zkApp aren't called by accident, because that would leave the call inputs and output completely unconstrained, making the caller's proof spoof-able.
In my opinion, it's much better to spend a few constraints to make composability the default!
EDIT: Another decision I made is to not expose the callee's child parties to the caller's circuit. Instead, I just witness the relevant hash, leaving it up to the callee to do any checks on its children. The rough idea is that when you do a function call, you usually don't want to / shouldn't have to "look inside" that function and inspect inputs and outputs of nested function calls. I'm curious if there are considerations that I missed!
rfc