From 72da4ccf18d800076824f5b5a22ae312223f95e7 Mon Sep 17 00:00:00 2001 From: Duncan Tebbs Date: Wed, 4 Dec 2024 11:08:51 +0000 Subject: [PATCH 1/7] feat: upa off-chain verify sdk support and command --- upa/src/sdk/index.ts | 1 + upa/src/sdk/offChainClient.ts | 8 ++++---- upa/src/sdk/offChainVerify.ts | 34 ++++++++++++++++++++++++++++++++++ upa/src/tool/config.ts | 22 ++++++++++++++++++++++ upa/src/tool/convert.ts | 2 +- upa/src/tool/offChain.ts | 33 +++++++++++++++++++++++++++++++++ upa/src/tool/options.ts | 27 +++++++++++++++++++++++++++ 7 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 upa/src/sdk/offChainVerify.ts diff --git a/upa/src/sdk/index.ts b/upa/src/sdk/index.ts index 8921fd02..c75406d9 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 f67a22e7..f07c1843 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 00000000..a2c42a79 --- /dev/null +++ b/upa/src/sdk/offChainVerify.ts @@ -0,0 +1,34 @@ +import { jsonPostRequest } from "./offChainClient"; +import { AppVkProofInputs } from "./application"; + +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 verify(data: AppVkProofInputs[]): Promise { + const request = new VerifyRequest(data); + const response = await jsonPostRequest(this.url, request); + + if (typeof response !== "boolean") { + throw ( + `Unexpected response type: {typeof response}\n` + + `{JSON.stringify(response)}` + ); + } + + return response; + } +} diff --git a/upa/src/tool/config.ts b/upa/src/tool/config.ts index 33f7027f..24f6db50 100644 --- a/upa/src/tool/config.ts +++ b/upa/src/tool/config.ts @@ -249,6 +249,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 9b0f978e..0cdcabd3 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 b2845cef..5dcb1641 100644 --- a/upa/src/tool/offChain.ts +++ b/upa/src/tool/offChain.ts @@ -11,6 +11,7 @@ import { loadWallet, loadAppVkProofInputsBatchFile, readAddressFromKeyfile, + loadAppVkProofInputsSingleOrBatchFile, } from "./config"; import { password, @@ -20,6 +21,8 @@ import { submissionEndpoint, depositContract, chainEndpoint, + verifyEndpoint, + vkProofInputsSingleOrBatchFilePositional, } from "./options"; import { computeCircuitId, @@ -27,6 +30,7 @@ import { computeSubmissionId, JSONstringify, } from "../sdk/utils"; +import { offchainVerify } from "../sdk"; import { getSignedResponseData, OffChainClient, @@ -414,6 +418,34 @@ export const withdrawAtBlock = command({ }, }); +export const verify = command({ + name: "verify", + description: "Use a verification service to verify off-chain", + args: { + verifyEndpoint: verifyEndpoint(), + vkProofInputsFile: vkProofInputsSingleOrBatchFilePositional(), + }, + handler: async function ({ + verifyEndpoint, + vkProofInputsFile, + }): Promise { + if (!verifyEndpoint) { + throw "no verify-endpoint specified"; + } + + const proofAndInputs = + loadAppVkProofInputsSingleOrBatchFile(vkProofInputsFile); + const verifier = new offchainVerify.VerifierClient(verifyEndpoint); + const result = await verifier.verify(proofAndInputs).catch((e) => { + console.log(`Error during request: ${e}`); + process.exit(1); + }); + + console.log(result ? "valid" : "invalid"); + process.exit(result ? 0 : 1); + }, +}); + export const offChain = subcommands({ name: "off-chain", description: "Utilities for off-chain submission", @@ -427,5 +459,6 @@ export const offChain = subcommands({ "refund-fee": refundFee, "get-state": getState, "get-parameters": getParameters, + verify, }, }); diff --git a/upa/src/tool/options.ts b/upa/src/tool/options.ts index c66a934f..1fba1ea2 100644 --- a/upa/src/tool/options.ts +++ b/upa/src/tool/options.ts @@ -105,6 +105,15 @@ 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 depositContract() { return option({ type: string, @@ -188,6 +197,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 } From ec1465b908ed6a41674c2b55520e04bbcac14cec Mon Sep 17 00:00:00 2001 From: Duncan Tebbs Date: Wed, 4 Dec 2024 15:14:26 +0000 Subject: [PATCH 2/7] fix[upa]: remove unnecessary console output --- upa/src/tool/config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/upa/src/tool/config.ts b/upa/src/tool/config.ts index 24f6db50..0627f379 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; } From 6de1d4be7ddb294dba11ffe7fd1471d56638bd62 Mon Sep 17 00:00:00 2001 From: Duncan Tebbs Date: Wed, 4 Dec 2024 15:14:44 +0000 Subject: [PATCH 3/7] feat[upa]: off-chain verify-gnark and verify-snarkjs commands --- upa/src/tool/offChain.ts | 121 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/upa/src/tool/offChain.ts b/upa/src/tool/offChain.ts index 5dcb1641..913e35c5 100644 --- a/upa/src/tool/offChain.ts +++ b/upa/src/tool/offChain.ts @@ -5,6 +5,8 @@ import { option, optional, positional, + boolean, + flag, } from "cmd-ts"; import * as log from "./log"; import { @@ -12,6 +14,10 @@ import { loadAppVkProofInputsBatchFile, readAddressFromKeyfile, loadAppVkProofInputsSingleOrBatchFile, + loadGnarkVK, + loadGnarkProof, + loadGnarkInputs, + loadSnarkjsVK, } from "./config"; import { password, @@ -30,7 +36,12 @@ import { computeSubmissionId, JSONstringify, } from "../sdk/utils"; -import { offchainVerify } from "../sdk"; +import { + AppVkProofInputs, + Groth16Proof, + Groth16VerifyingKey, + offchainVerify, +} from "../sdk"; import { getSignedResponseData, OffChainClient, @@ -418,6 +429,21 @@ export const withdrawAtBlock = command({ }, }); +// The common part of offchain verify commands +export async function doOffChainVerify( + endpoint: string, + proofs: AppVkProofInputs[] +): Promise { + const verifier = new offchainVerify.VerifierClient(endpoint); + const result = await verifier.verify(proofs).catch((e) => { + console.log(`Error during request: ${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", @@ -435,14 +461,91 @@ export const verify = command({ const proofAndInputs = loadAppVkProofInputsSingleOrBatchFile(vkProofInputsFile); - const verifier = new offchainVerify.VerifierClient(verifyEndpoint); - const result = await verifier.verify(proofAndInputs).catch((e) => { - console.log(`Error during request: ${e}`); - process.exit(1); - }); + doOffChainVerify(verifyEndpoint, proofAndInputs); + }, +}); + +export const verify_gnark = command({ + name: "verify-gnark", + description: "Use an off-chain verification service to verify a gnark proof", + args: { + verifyEndpoint: verifyEndpoint(), + 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, + 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 }]); + }, +}); + +export const verify_snarkjs = command({ + name: "verify-gnark", + description: "Use an off-chain verification service to verify a gnark proof", + args: { + verifyEndpoint: verifyEndpoint(), + 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, + 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); - console.log(result ? "valid" : "invalid"); - process.exit(result ? 0 : 1); + doOffChainVerify(verifyEndpoint, [{ vk, proof, inputs }]); }, }); @@ -460,5 +563,7 @@ export const offChain = subcommands({ "get-state": getState, "get-parameters": getParameters, verify, + "verify-gnark": verify_gnark, + "verify-snarkjs": verify_snarkjs, }, }); From 7dc5019a3f312f140cf275fe324bff60cf5aeef2 Mon Sep 17 00:00:00 2001 From: Duncan Tebbs Date: Thu, 5 Dec 2024 13:37:15 +0000 Subject: [PATCH 4/7] feat: verify signatures for off-chain verifier --- upa/src/sdk/offChainVerify.ts | 25 ++++++++++++++++++++++--- upa/src/tool/offChain.ts | 20 ++++++++++++++------ upa/src/tool/options.ts | 17 +++++++++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/upa/src/sdk/offChainVerify.ts b/upa/src/sdk/offChainVerify.ts index a2c42a79..11ae93f8 100644 --- a/upa/src/sdk/offChainVerify.ts +++ b/upa/src/sdk/offChainVerify.ts @@ -1,5 +1,7 @@ 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[]; @@ -18,17 +20,34 @@ export class VerifyRequest { export class VerifierClient { constructor(public readonly url: string) {} - public async verify(data: AppVkProofInputs[]): Promise { + public async getSignature(data: AppVkProofInputs[]): Promise { const request = new VerifyRequest(data); const response = await jsonPostRequest(this.url, request); - if (typeof response !== "boolean") { + if (typeof response !== "object") { throw ( `Unexpected response type: {typeof response}\n` + `{JSON.stringify(response)}` ); } - return response; + return Signature.from(response as Signature); + } + + public async verify( + data: AppVkProofInputs[], + verifierAddress: string + ): Promise { + const signature = await this.getSignature(data); + const expectAddress = getAddress(verifierAddress); + + // 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); + return address == expectAddress; } } diff --git a/upa/src/tool/offChain.ts b/upa/src/tool/offChain.ts index 913e35c5..0f6aeeec 100644 --- a/upa/src/tool/offChain.ts +++ b/upa/src/tool/offChain.ts @@ -29,6 +29,7 @@ import { chainEndpoint, verifyEndpoint, vkProofInputsSingleOrBatchFilePositional, + verifierAddress, } from "./options"; import { computeCircuitId, @@ -432,11 +433,12 @@ export const withdrawAtBlock = command({ // The common part of offchain verify commands export async function doOffChainVerify( endpoint: string, - proofs: AppVkProofInputs[] + proofs: AppVkProofInputs[], + verifierAddress: string ): Promise { const verifier = new offchainVerify.VerifierClient(endpoint); - const result = await verifier.verify(proofs).catch((e) => { - console.log(`Error during request: ${e}`); + const result = await verifier.verify(proofs, verifierAddress).catch((e) => { + console.log(`${e}`); process.exit(1); }); @@ -450,10 +452,12 @@ export const verify = command({ args: { verifyEndpoint: verifyEndpoint(), vkProofInputsFile: vkProofInputsSingleOrBatchFilePositional(), + verifierAddress: verifierAddress(), }, handler: async function ({ verifyEndpoint, vkProofInputsFile, + verifierAddress, }): Promise { if (!verifyEndpoint) { throw "no verify-endpoint specified"; @@ -461,7 +465,7 @@ export const verify = command({ const proofAndInputs = loadAppVkProofInputsSingleOrBatchFile(vkProofInputsFile); - doOffChainVerify(verifyEndpoint, proofAndInputs); + doOffChainVerify(verifyEndpoint, proofAndInputs, verifierAddress); }, }); @@ -470,6 +474,7 @@ export const verify_gnark = command({ 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", @@ -493,6 +498,7 @@ export const verify_gnark = command({ }, handler: async function ({ verifyEndpoint, + verifierAddress, vkFile, proofFile, inputsFile, @@ -509,7 +515,7 @@ export const verify_gnark = command({ const proof = Groth16Proof.from_gnark(loadGnarkProof(proofFile)); const inputs = loadGnarkInputs(inputsFile).map(BigInt); - doOffChainVerify(verifyEndpoint, [{ vk, proof, inputs }]); + doOffChainVerify(verifyEndpoint, [{ vk, proof, inputs }], verifierAddress); }, }); @@ -518,6 +524,7 @@ export const verify_snarkjs = command({ 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", @@ -531,6 +538,7 @@ export const verify_snarkjs = command({ }, handler: async function ({ verifyEndpoint, + verifierAddress, vkFile, proofFile, }): Promise { @@ -545,7 +553,7 @@ export const verify_snarkjs = command({ const proof = Groth16Proof.from_snarkjs(snarkjsProof); const inputs = publicSignals.map(BigInt); - doOffChainVerify(verifyEndpoint, [{ vk, proof, inputs }]); + doOffChainVerify(verifyEndpoint, [{ vk, proof, inputs }], verifierAddress); }, }); diff --git a/upa/src/tool/options.ts b/upa/src/tool/options.ts index 1fba1ea2..601d4bda 100644 --- a/upa/src/tool/options.ts +++ b/upa/src/tool/options.ts @@ -114,6 +114,23 @@ export function verifyEndpoint(): Option { }); } +export function verifierAddress() { + return option({ + type: string, + long: "verifier-address", + short: "v", + description: + "Trusted verifier address for signature verification (VERIFIER_ADDRESS)", + defaultValue: () => { + const val = process.env.VERIFIER_ADDRESS; + if (val) { + return val; + } + throw "verifier address not specified"; + }, + }); +} + export function depositContract() { return option({ type: string, From 5fb24151ceb2e1da8edf6671e1e1b27a518d9afa Mon Sep 17 00:00:00 2001 From: Duncan Tebbs Date: Fri, 6 Dec 2024 15:10:20 +0000 Subject: [PATCH 5/7] feat: optional trusted verifier address --- upa/src/sdk/offChainVerify.ts | 15 ++++++++++++--- upa/src/tool/offChain.ts | 2 +- upa/src/tool/options.ts | 10 ++++++---- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/upa/src/sdk/offChainVerify.ts b/upa/src/sdk/offChainVerify.ts index 11ae93f8..f05fe34d 100644 --- a/upa/src/sdk/offChainVerify.ts +++ b/upa/src/sdk/offChainVerify.ts @@ -36,10 +36,9 @@ export class VerifierClient { public async verify( data: AppVkProofInputs[], - verifierAddress: string + verifierAddress?: string ): Promise { const signature = await this.getSignature(data); - const expectAddress = getAddress(verifierAddress); // Verify the signature and confirm it is for expectAddress const proof_ids = data.map((vki) => { @@ -48,6 +47,16 @@ export class VerifierClient { }); const submission_id = computeSubmissionId(proof_ids); const address = recoverAddress(submission_id, signature); - return address == expectAddress; + + // 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/offChain.ts b/upa/src/tool/offChain.ts index 0f6aeeec..e0e7680a 100644 --- a/upa/src/tool/offChain.ts +++ b/upa/src/tool/offChain.ts @@ -434,7 +434,7 @@ export const withdrawAtBlock = command({ export async function doOffChainVerify( endpoint: string, proofs: AppVkProofInputs[], - verifierAddress: string + verifierAddress?: string ): Promise { const verifier = new offchainVerify.VerifierClient(endpoint); const result = await verifier.verify(proofs, verifierAddress).catch((e) => { diff --git a/upa/src/tool/options.ts b/upa/src/tool/options.ts index 601d4bda..ee575af4 100644 --- a/upa/src/tool/options.ts +++ b/upa/src/tool/options.ts @@ -114,19 +114,21 @@ export function verifyEndpoint(): Option { }); } -export function verifierAddress() { +export function verifierAddress(): OptionalOption { return option({ - type: string, + type: optional(string), long: "verifier-address", short: "v", description: - "Trusted verifier address for signature verification (VERIFIER_ADDRESS)", + "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; } - throw "verifier address not specified"; + return undefined; }, }); } From 64fa190f2864f89b7af6c19c76d0bb9e41e4c2d5 Mon Sep 17 00:00:00 2001 From: Duncan Tebbs Date: Fri, 6 Dec 2024 16:41:58 +0000 Subject: [PATCH 6/7] chore[upa]: tick package version to 2.0.3 --- upa/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upa/package.json b/upa/package.json index 4f2b5f66..53558a21 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": { From f4aced3982fbfebc48b735be8fc3fee0e20a809f Mon Sep 17 00:00:00 2001 From: Duncan Tebbs Date: Fri, 6 Dec 2024 16:46:49 +0000 Subject: [PATCH 7/7] chore[upa]: release notes --- upa/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/upa/README.md b/upa/README.md index 8a653da3..4b097691 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.