diff --git a/upa/README.md b/upa/README.md index 8a653da..4b09769 100644 --- a/upa/README.md +++ b/upa/README.md @@ -32,7 +32,10 @@ See [DEVELOPMENT.md]. ## Release notes -### V2.0.x +### V2.0.3 +Support for off-chain verifier services in the upa tool. + +### V2.0.2 UPA `V2` supports submission of Groth16 proofs to an off-chain submission endpoint, allowing users to save the gas cost of submitting on-chain. The `upa` tool has been updated to support this new feature. diff --git a/upa/package.json b/upa/package.json index 4f2b5f6..53558a2 100644 --- a/upa/package.json +++ b/upa/package.json @@ -1,7 +1,7 @@ { "name": "@nebrazkp/upa", "license": "MIT", - "version": "2.0.2", + "version": "2.0.3", "description": "UPA contracts, client SDK and tools", "repository": "https://github.com/nebrazkp/upa", "exports": { diff --git a/upa/src/sdk/index.ts b/upa/src/sdk/index.ts index 8921fd0..c75406d 100644 --- a/upa/src/sdk/index.ts +++ b/upa/src/sdk/index.ts @@ -24,3 +24,4 @@ export * as submissionIntervals from "./submissionIntervals"; export * as aggregatedProofParams from "./aggregatedProofParams"; export * as typechain from "../../typechain-types"; export * as offchain from "./offChainClient"; +export * as offchainVerify from "./offChainVerify"; diff --git a/upa/src/sdk/offChainClient.ts b/upa/src/sdk/offChainClient.ts index f67a22e..f07c184 100644 --- a/upa/src/sdk/offChainClient.ts +++ b/upa/src/sdk/offChainClient.ts @@ -314,17 +314,17 @@ async function getRequest(url: string): Promise { return processResponse(response, url); } -async function jsonPostRequest( +export async function jsonPostRequest( url: string, request: Request ): Promise { const requestBody = utils.JSONstringify(request); - const response = await fetch(url, { + const httpRequest = { method: "POST", body: requestBody, headers: { "Content-Type": "application/json" }, - }); - + }; + const response = await fetch(url, httpRequest); return processResponse(response, url, requestBody); } diff --git a/upa/src/sdk/offChainVerify.ts b/upa/src/sdk/offChainVerify.ts new file mode 100644 index 0000000..f05fe34 --- /dev/null +++ b/upa/src/sdk/offChainVerify.ts @@ -0,0 +1,62 @@ +import { jsonPostRequest } from "./offChainClient"; +import { AppVkProofInputs } from "./application"; +import { Signature, getAddress, recoverAddress } from "ethers"; +import { computeCircuitId, computeProofId, computeSubmissionId } from "./utils"; + +export type VerifyRequestProofType = "groth16"; +export type VerifyRequestData = AppVkProofInputs[]; + +export class VerifyRequest { + public readonly proof_type: VerifyRequestProofType; + public readonly data: VerifyRequestData; + + // For now, just assemble from AppVkProofInputs + constructor(data: AppVkProofInputs[]) { + this.proof_type = "groth16"; + this.data = data; + } +} + +export class VerifierClient { + constructor(public readonly url: string) {} + + public async getSignature(data: AppVkProofInputs[]): Promise { + const request = new VerifyRequest(data); + const response = await jsonPostRequest(this.url, request); + + if (typeof response !== "object") { + throw ( + `Unexpected response type: {typeof response}\n` + + `{JSON.stringify(response)}` + ); + } + + return Signature.from(response as Signature); + } + + public async verify( + data: AppVkProofInputs[], + verifierAddress?: string + ): Promise { + const signature = await this.getSignature(data); + + // Verify the signature and confirm it is for expectAddress + const proof_ids = data.map((vki) => { + const cid = computeCircuitId(vki.vk); + return computeProofId(cid, vki.inputs); + }); + const submission_id = computeSubmissionId(proof_ids); + const address = recoverAddress(submission_id, signature); + + // If verifierAddress is given, compare to the signer's address. + // Otherwise, the verifier is trusted and the presence of a well-formed + // signature is sufficient evidence. + + if (verifierAddress) { + const expectAddress = getAddress(verifierAddress); + return address == expectAddress; + } + + return true; + } +} diff --git a/upa/src/tool/config.ts b/upa/src/tool/config.ts index 33f7027..0627f37 100644 --- a/upa/src/tool/config.ts +++ b/upa/src/tool/config.ts @@ -217,7 +217,6 @@ export function loadGnarkProof(filename: string): GnarkProof { export function loadGnarkInputs(filename: string): GnarkInputs { const inputsJSON = JSON.parse(fs.readFileSync(filename, "ascii")); const result = inputsJSON.map(BigInt); - console.log(result); return result; } @@ -249,6 +248,28 @@ export function loadAppVkProofInputsBatchFile( ); } +export function loadAppVkProofInputsSingleOrBatchFile( + filename: string +): AppVkProofInputs[] { + const vkProofInputs: object = JSON.parse(fs.readFileSync(filename, "ascii")); + if (Array.isArray(vkProofInputs)) { + return vkProofInputs.map((o) => + AppVkProofInputs.from_json( + o, + Groth16VerifyingKey.from_json, + Groth16Proof.from_json + ) + ); + } + return [ + AppVkProofInputs.from_json( + vkProofInputs, + Groth16VerifyingKey.from_json, + Groth16Proof.from_json + ), + ]; +} + /// Converts either of the JSON objects: /// - A single object { vk, proof, inputs } /// - A single object { circuitId, proof, inputs } diff --git a/upa/src/tool/convert.ts b/upa/src/tool/convert.ts index 9b0f978..0cdcabd 100644 --- a/upa/src/tool/convert.ts +++ b/upa/src/tool/convert.ts @@ -69,7 +69,7 @@ const convertProofSnarkjs = command({ description: "The destination for output UPA proof with inputs", }), }, - description: "Convert Groth16 verifying key from SnarkJS to UPA format", + description: "Convert Groth16 proof from SnarkJS to UPA format", handler: async function ({ snarkJSProofAndInputsFile, upaProofFile, diff --git a/upa/src/tool/offChain.ts b/upa/src/tool/offChain.ts index b2845ce..e0e7680 100644 --- a/upa/src/tool/offChain.ts +++ b/upa/src/tool/offChain.ts @@ -5,12 +5,19 @@ import { option, optional, positional, + boolean, + flag, } from "cmd-ts"; import * as log from "./log"; import { loadWallet, loadAppVkProofInputsBatchFile, readAddressFromKeyfile, + loadAppVkProofInputsSingleOrBatchFile, + loadGnarkVK, + loadGnarkProof, + loadGnarkInputs, + loadSnarkjsVK, } from "./config"; import { password, @@ -20,6 +27,9 @@ import { submissionEndpoint, depositContract, chainEndpoint, + verifyEndpoint, + vkProofInputsSingleOrBatchFilePositional, + verifierAddress, } from "./options"; import { computeCircuitId, @@ -27,6 +37,12 @@ import { computeSubmissionId, JSONstringify, } from "../sdk/utils"; +import { + AppVkProofInputs, + Groth16Proof, + Groth16VerifyingKey, + offchainVerify, +} from "../sdk"; import { getSignedResponseData, OffChainClient, @@ -414,6 +430,133 @@ export const withdrawAtBlock = command({ }, }); +// The common part of offchain verify commands +export async function doOffChainVerify( + endpoint: string, + proofs: AppVkProofInputs[], + verifierAddress?: string +): Promise { + const verifier = new offchainVerify.VerifierClient(endpoint); + const result = await verifier.verify(proofs, verifierAddress).catch((e) => { + console.log(`${e}`); + process.exit(1); + }); + + console.log(result ? "valid" : "invalid"); + process.exit(result ? 0 : 1); +} + +export const verify = command({ + name: "verify", + description: "Use a verification service to verify off-chain", + args: { + verifyEndpoint: verifyEndpoint(), + vkProofInputsFile: vkProofInputsSingleOrBatchFilePositional(), + verifierAddress: verifierAddress(), + }, + handler: async function ({ + verifyEndpoint, + vkProofInputsFile, + verifierAddress, + }): Promise { + if (!verifyEndpoint) { + throw "no verify-endpoint specified"; + } + + const proofAndInputs = + loadAppVkProofInputsSingleOrBatchFile(vkProofInputsFile); + doOffChainVerify(verifyEndpoint, proofAndInputs, verifierAddress); + }, +}); + +export const verify_gnark = command({ + name: "verify-gnark", + description: "Use an off-chain verification service to verify a gnark proof", + args: { + verifyEndpoint: verifyEndpoint(), + verifierAddress: verifierAddress(), + vkFile: positional({ + type: string, + displayName: "gnark-vk-file", + description: "VK in gnark format", + }), + proofFile: positional({ + type: string, + displayName: "gnark-proof-file", + description: "Proof in gnark format", + }), + inputsFile: positional({ + type: string, + displayName: "gnark-inputs-file", + description: "Inputs in gnark format", + }), + hasCommitment: flag({ + type: boolean, + long: "has-commitment", + description: "circuit uses the gnark commitment extension", + }), + }, + handler: async function ({ + verifyEndpoint, + verifierAddress, + vkFile, + proofFile, + inputsFile, + hasCommitment, + }): Promise { + if (!verifyEndpoint) { + throw "no verify-endpoint specified"; + } + + const vk = Groth16VerifyingKey.from_gnark( + loadGnarkVK(vkFile), + hasCommitment + ); + const proof = Groth16Proof.from_gnark(loadGnarkProof(proofFile)); + const inputs = loadGnarkInputs(inputsFile).map(BigInt); + + doOffChainVerify(verifyEndpoint, [{ vk, proof, inputs }], verifierAddress); + }, +}); + +export const verify_snarkjs = command({ + name: "verify-gnark", + description: "Use an off-chain verification service to verify a gnark proof", + args: { + verifyEndpoint: verifyEndpoint(), + verifierAddress: verifierAddress(), + vkFile: positional({ + type: string, + displayName: "snarkjs-vk-file", + description: "VK in snarkjs format", + }), + proofFile: positional({ + type: string, + displayName: "snarkjs-proof-file", + description: "Proof and inputs in snarkjs format", + }), + }, + handler: async function ({ + verifyEndpoint, + verifierAddress, + vkFile, + proofFile, + }): Promise { + if (!verifyEndpoint) { + throw "no verify-endpoint specified"; + } + + const vk = Groth16VerifyingKey.from_snarkjs(loadSnarkjsVK(vkFile)); + const { proof: snarkjsProof, publicSignals } = JSON.parse( + fs.readFileSync(proofFile, "ascii") + ); + const proof = Groth16Proof.from_snarkjs(snarkjsProof); + const inputs = publicSignals.map(BigInt); + + doOffChainVerify(verifyEndpoint, [{ vk, proof, inputs }], verifierAddress); + }, +}); + export const offChain = subcommands({ name: "off-chain", description: "Utilities for off-chain submission", @@ -427,5 +570,8 @@ export const offChain = subcommands({ "refund-fee": refundFee, "get-state": getState, "get-parameters": getParameters, + verify, + "verify-gnark": verify_gnark, + "verify-snarkjs": verify_snarkjs, }, }); diff --git a/upa/src/tool/options.ts b/upa/src/tool/options.ts index c66a934..ee575af 100644 --- a/upa/src/tool/options.ts +++ b/upa/src/tool/options.ts @@ -105,6 +105,34 @@ export function submissionEndpoint(): Option { }); } +export function verifyEndpoint(): Option { + return option({ + type: string, + long: "verify-endpoint", + defaultValue: () => process.env.VERIFY_ENDPOINT || "", + description: "Verify endpoint (defaults to VERIFY_ENDPOINT env var)", + }); +} + +export function verifierAddress(): OptionalOption { + return option({ + type: optional(string), + long: "verifier-address", + short: "v", + description: + "Trusted verifier address for signature verification (VERIFIER_ADDRESS)" + + ". If not given, any well-formed signature from the verifier will be " + + "accepted.", + defaultValue: () => { + const val = process.env.VERIFIER_ADDRESS; + if (val) { + return val; + } + return undefined; + }, + }); +} + export function depositContract() { return option({ type: string, @@ -188,6 +216,24 @@ export function vkProofInputsBatchFilePositional(): Option { }); } +export function vkProofInputsSingleOrBatchFile(): Option { + return option({ + type: string, + long: "proofs-file", + description: + "JSON file: {vk, proof, inputs} object, or list of such objects", + }); +} + +export function vkProofInputsSingleOrBatchFilePositional(): Option { + return positional({ + type: string, + displayName: "proofs-file", + description: + "JSON file: {vk, proof, inputs} object, or list of such objects", + }); +} + /// A JSON file in one of the formats: /// - An array of { vk, proof, inputs } /// - An array of { circuitId, proof, inputs }