Sideloaded Verification Keys
In the article on recursion, we showed how o1js can be used to
write constraint systems that verify other constraint systems. Those examples always assumed that the
prover has compiled an instance of the other ZkProgram
. We can, in fact, go even more general than that.
With sideloading, we can verify any proof, given that its shape (public inputs and outputs) are known at
compile time, and the verification key is available at runtime.
The use cases for sideloading include:
- Upgradability: Without needing to recompile the entire program (breaking compatibility with existing proofs), you can write your program with sideloading from the start, to support future use cases.
- Flexibility: You can write programs that are compatible with several proofs without enumerating them individually. (Remember that without conditional execution, it's not possible to write a big switch statement to handle different proof types. So sideloading can be an effective way to streamline an app).
Multiple Hash Example
Let's say we have an application with multiple hashing algorithms in use. We want to write a simple Zk Program to verify that a user knows the preimage of a hash, but we want to support any of our algorithms. Let's solve this by writing a specific program for each algorithm, and a wrapper program that verifies any of them.
Setup
First, let's set up a Bytes32 class and some different hash function proofs. Note that all proofs must have the same shape.
// Define our bytes input
class Bytes32 extends Bytes(32) {
static assertEquals(a: Bytes, b: Bytes) {
Poseidon.hash(a.toFields()).assertEquals(Poseidon.hash(b.toFields()));
}
}
const Sha2 = ZkProgram({
name: "SHA2_256",
publicInput: Bytes32,
methods: {
verifyPreimage: {
privateInputs: [Bytes32],
method: async (claimedHash: Bytes32, preimage: Bytes32) => {
const calculatedHash = Hash.SHA2_256.hash(preimage);
Bytes32.assertEquals(claimedHash, calculatedHash);
},
},
},
});
const Sha3 = ZkProgram({
name: "SHA3_256",
publicInput: Bytes32,
methods: {
verifyPreimage: {
privateInputs: [Bytes32],
method: async (claimedHash: Bytes32, preimage: Bytes32) => {
const calculatedHash = Hash.SHA3_256.hash(preimage);
Bytes32.assertEquals(claimedHash, calculatedHash);
},
},
},
});
Defining a Sideloaded Proof
Usually, a ZkProgram
's proof type is inferrable, but since we want to sideload one of several proofs, we
need to define the proof class explicitly.
// Define the proof shape that both hash implementations satisfy
class HashProof extends DynamicProof<Bytes32, null> {
/**
* Bytes.provable is a little trick to access the provable type of the class - Not required for simpler types like Field or UInt32
*/
static publicInputType = Bytes32.provable;
/**
* Hacky way to set the public output type to null
*/
static publicOutputType = Sha2.publicOutputType;
/**
* maxProofsVerified sets the wrapping domain for this proof.
* Essentially, it tells the compiler how many times `verify` may be called
*/
static maxProofsVerified = 0 as const;
/**
* Set all feature flags to maybe to indicate that the sideloaded proof may use any gate types
* NOTE: Failing to do this may result in a nasty error - unless you specifically want to exclude a feature, allMaybe is a safe default
*/
static featureFlags = FeatureFlags.allMaybe;
}
For more details on DynamicProof
and FeatureFlags
, check out the api reference: DynamicProof, FeatureFlags.
Using the Sideloaded Proof in a ZkProgram
Now that we've defined the proof class, we can use it in a ZkProgram
.
Pay attention to the constraints on the verification key. Without these, any valid proof can be sideloaded. We want the flexibility of sideloading, but we need to be mindful of the security implications!
// Define our generic multi-hash program
const MultiHash = ZkProgram({
name: "MultiHash",
publicInput: Bytes32,
methods: {
verifyPreimage: {
privateInputs: [HashProof, VerificationKey],
method: async (
claimedHash: Bytes32,
proof: HashProof,
verificationKey: VerificationKey
) => {
// Pass verification key into `proof.verify` in the sideloaded case
proof.verify(verificationKey);
// Assert that the verification key matches one of our known programs
// NOTE: A merkle map could be a more efficient check for larger sets
// but this example uses a simpler check for clarity
let match = Bool(false);
match = match.or(
// SHA2_256 VK
verificationKey.hash.equals(
"18946629726997484436154648354739477208964589603707310554296950898554384176434"
)
);
match = match.or(
// SHA3_256 VK
verificationKey.hash.equals(
"10594931916390393299319985652348439891528773201960631586652037259274667432468"
)
);
match.assertEquals(true, "Invalid verification key");
// Now we know that the proof is legitimate
// The user provided proof that they know the preimage of _some_ hash
// Finally, let's confirm that _some_ hash is the hash being claimed
Bytes32.assertEquals(proof.publicInput, claimedHash);
},
},
},
});
Instantiation and Verifying the Wrap Proof
Finally, here is how you can instantiate a sideloaded proof and pass it as a parameter to the wrap program.
const preimage = Bytes32.random();
const sha2Hash = Hash.SHA2_256.hash(preimage);
const sha3Hash = Hash.SHA3_256.hash(preimage);
const sha2Proof = await Sha2.verifyPreimage(sha2Hash, preimage);
const sha3Proof = await Sha3.verifyPreimage(sha3Hash, preimage);
// Wrap the original proof in the dymamic proof class for compatibility
const sha2ProofToBeSideloaded = HashProof.fromProof(sha2Proof.proof);
const sha3ProofToBeSideloaded = HashProof.fromProof(sha3Proof.proof);
await MultiHash.compile();
const genericProofSha2 = await MultiHash.verifyPreimage(
sha2Hash,
sha2ProofToBeSideloaded,
Sha2VK.verificationKey
);
const genericProofSha3 = await MultiHash.verifyPreimage(
sha3Hash,
sha3ProofToBeSideloaded,
Sha3VK.verificationKey
);