Skip to content

Making Proofs about Ticket PODs

Zupass tickets are PODs, Provable Object Datatypes. This means that they’re ZK-friendly, and it’s easy to make proofs about them.

ZK proofs in Zupass are used to reveal information about some POD-format data that the user has. This might involve directly revealing specific POD entries, such as the e-mail address associated with an event ticket. Or, it might involve revealing that an entry has a value in a given range, or with a value that matches a list of valid values. Or, it might even involve revealing that a set of related entry values match a sets of values from a list.

Zupass allows you to configure proofs by providing a set of criteria describing one or more PODs that the user must have. If the user has matching PODs, they can make a proof and share it with someone else - such as with your application or another user of your app. As the app developer, it’s your job to design the proof configuration.

Because this system is very flexible, it can also be intimidating at first. Mistakes can be subtle, and might result in a proof that doesn’t really prove what you want it to. To help with this, there’s a specialized library for preparing ticket proof requests, ticket-spec. This guide will explain how to use it.

Quick Start

Below you will find more detailed background on ticket proofs. But if you just want to get started, here’s how to do it:

Following the Getting Started guide, install the App Connector module and verify that you can connect to Zupass.

Next, install the ticket-spec package:

npm i @parcnet-js/ticket-spec

Then, you can create a ticket proof request, and use it to create a proof:

src/main.ts
import { ticketProofRequest } from "@parcnet-js/ticket-spec";
const request = ticketProofRequest({
classificationTuples: [
// If you know the public key and event ID of a POD ticket, you can specify
// them here, otherwise delete the object below.
{
signerPublicKey: "PUBLIC_KEY",
eventId: "EVENT_ID"
}
],
fieldsToReveal: {
// The proof will reveal the attendeeEmail entry
attendeeEmail: true
},
externalNullifier: {
type: "string",
value: "APP_SPECIFIC_NULLIFIER"
}
});
const gpcProof = await z.gpc.prove({ request: request.schema });

This will create a GPC proof which proves that the ticket has a given signer public key and event ID, and also reveals the attendeeEmail address for the ticket.

If you know the signer public key and event ID of a ticket then you can specify those in the classificationTuples array, but for getting started you can delete those values if you don’t know which ones to specify.

The result should be an object containing proof, boundConfig, and revealedClaims fields. For now, we can ignore proof and boundConfig, and focus on revealedClaims, which, given the above example, should look like this:

{
"pods": {
"ticket": {
"entries": {
"attendeeEmail": {
"type": "string",
"value": "test@example.com"
}
},
"signerPublicKey": "MolS1FubqfCmFB8lHOSTo1smf8hPgTPal6FgpajFiYY"
}
},
"owner": {
"externalNullifier": {
"type": "string",
"value": "APP_SPECIFIC_NULLIFIER"
},
"nullifierHashV4": 18601332455379395925267579735435017582946383130668625217012137367106027237345
},
"membershipLists": {
"allowlist_tuple_ticket_entries_$signerPublicKey_eventId": [
[
{
"type": "eddsa_pubkey",
"value": "MolS1FubqfCmFB8lHOSTo1smf8hPgTPal6FgpajFiYY"
},
{
"type": "string",
"value": "fca101d3-8c9d-56e4-9a25-6a3c1abf0fed"
}
]
]
}
}

As we saw in the original proof request, the attendeeEmail entry is revealed: revealedClaims.pods.ticket.entries.attendeeEmail contains the value.

To understand how this works, read on!

Configuring a ticket proof request

Ticket proofs enable the user to prove that they hold an event ticket matching certain criteria. They also allow the user to selectively reveal certain pieces of data contained in the ticket, such as their email address or name.

A typical use-case for ticket proofs is to prove that the user has a ticket to a specific event, and possibly that the ticket is of a certain “type”, such as a speaker ticket or day pass. To determine this, we need to specify two or three entries:

  • Public key of the ticket signer or issuer
  • The unique ID of the event
  • Optionally, the unique ID of the product type, if the event has multiple types of tickets

For example, if you want the user to prove that they have a ticket to a specific event, then you want them to prove the following:

  • That their ticket POD was signed by the event organizer’s ticket issuance key
  • That their ticket has the correct event identifier
  • That their ticket is of the appropriate type, if you want to offer a different experience to holders of different ticket types

How to specify ticket details

To match a ticket based on the above criteria, you must specify either pairs of public key and event ID, or triples of public key, event ID, and product ID. For example:

src/main.ts
const pair = [{
publicKey: "2C4B7JzSakdQaRlnJPPlbksW9F04vYc5QFLy//nuIho",
eventId: "ab9306be-019f-40d9-990d-88826a15fde5"
}];
const triple = [{
publicKey: "2C4B7JzSakdQaRlnJPPlbksW9F04vYc5QFLy//nuIho",
eventId: "ab9306be-019f-40d9-990d-88826a15fde5",
productId: "672c6ff1-9947-41d4-8876-4ef1e3317f08"
}];

The first example, containing only a public key and event ID, will match any ticket which has those attributes. The second is more precise, requiring that the ticket have a specific product type.

It’s possible to specify multiple pairs or triples. For example:

src/main.ts
const pairs = [
{
publicKey: "2C4B7JzSakdQaRlnJPPlbksW9F04vYc5QFLy//nuIho",
eventId: "ab9306be-019f-40d9-990d-88826a15fde5"
},
{
publicKey: "2C4B7JzSakdQaRlnJPPlbksW9F04vYc5QFLy//nuIho",
eventId: "5ddb8781-b893-4187-9044-9ac229368aac"
}
]

These would be used like this:

src/main.ts
const request = ticketProofRequest({
classificationTuples: [
{
publicKey: "2C4B7JzSakdQaRlnJPPlbksW9F04vYc5QFLy//nuIho",
eventId: "ab9306be-019f-40d9-990d-88826a15fde5"
},
{
publicKey: "2C4B7JzSakdQaRlnJPPlbksW9F04vYc5QFLy//nuIho",
eventId: "5ddb8781-b893-4187-9044-9ac229368aac"
}
],
fieldsToReveal: {
// The proof will reveal the attendeeEmail entry
attendeeEmail: true
},
externalNullifier: {
type: "string",
value: "APP_SPECIFIC_NULLIFIER"
}
});

In this case, the proof request will match any ticket which matches either of the above pairs. If you provide more, then the ticket just needs to match any of the provided pairs.

This underlines an important principle: when the proof is created, you might not know which pair of values the user’s ticket matches. This is by design, and is part of how ZK proofs provide privacy. If you only need to know that the user has a ticket matching a list of values, but don’t need to know exactly which ticket the user has, then by default that information will not

What gets revealed in a ticket proof

If you have specified pairs or triples of public key, event ID and (optionally) product ID, then the list of valid values will be revealed in the proof. This might be the only information you want to reveal: the proof discloses that the user has a ticket which satisfies these criteria, but no more.

However, you might want the proof to disclose more information about the ticket. There are two further types of information that a proof might reveal: a “nullifier hash”, a unique value derived from the user’s identity, and a subset of the ticket’s entries.

Nullifier hash

A nullifier hash is a unique value which is derived from the user’s identity, but which cannot be used to determine the user’s identity. Typically this is used to pseudonymously identify a user: if the same user creates two proofs, both proofs will have the same nullifier hash, giving the user a consistent identity, but not one that can be used to determine their public key or other information.

The nullifier hash requires an “external nullifier”, a value which your application must provide. This ensures that the nullifier hash is derived from both the user’s identity and a value that your application provides. This means that the nullifier hash that the user has when creating proofs for your application will be different to the nullifier hash they have when creating proofs for another application.

Revealed entries

Proofs can also directly reveal the values held in certain entries, meaning that the revealedClaims object in the proof result will be populated with values from the ticket that the use selects when making the proof. In the Quick Start example above, the attendeeEmail entry is revealed, but you can reveal any of the ticket’s entries by setting them in the fieldsToReveal parameter:

src/main.ts
const request = ticketProofRequest({
classificationTuples: [
// If you know the public key and event ID of a POD ticket, you can specify
// them here, otherwise delete the object below.
{
signerPublicKey: "PUBLIC_KEY",
eventId: "EVENT_ID"
}
],
fieldsToReveal: {
// Reveal the unique ticket ID
ticketId: true,
// Reveal the attendee's name
attendeeName: true,
// Reveal if the ticket is checked in
isConsumed: true
},
externalNullifier: {
type: "string",
value: "APP_SPECIFIC_NULLIFIER"
}
});

Watermark

You can add a watermark to your proof, which allows you to uniquely identify a proof. Precisely which value to use for the watermark depends on your application and use-case, but you might use a unique session ID, or a single-use number generated by your application.

If you add a watermark to your proof request, you can check the watermark when later verifying the proof. A typical workflow might involve your client application requesting a random number from your server, which stores the number. The number is passed as a watermark in the proof request, and then you can send the proof to the server for verification. The server then checks that the watermark is equal to the random number it generated. By requiring the watermark to equal some single-use secret value, you ensure that the client cannot re-use a previously-generated proof.

Verifying a ticket proof

Once a proof has been made, you can verify it.

Typically, verification is done by a different person, computer, or program than the one that produced the proof. You don’t really need to verify a proof that you just created on the same computer, but if you received a proof from someone else, or if you have an application which sends proofs between, say, a client and a server then you will want to verify any proofs that you receive.

Verification covers a few important principles:

  • That, given a revealedClaims object which makes certain statements, a proof configuration, and a proof object, the proof object really does correspond to the configuration and revealed claims. If we didn’t check this, then the revealedClaims might contain data for which there is no proof!
  • That the configuration really is the one that you expect it to be. This is important because a proof might really match a set of claims and a configuration, but if the configuration is not the one you expect then the claims might not be valid for your use-case.

Proof requests

To make this easy to get right, we have a concept of “proof requests”. When you call ticketProofRequest as described above, you’re creating a proof request object which can be used to… request a proof. However, you can also use the proof request when verifying a proof, to ensure that the proof was produced in response to the correct request.

If you’re verifying the proof in the browser using the Z API, you can do so like this:

src/main.ts
import { ticketProofRequest } from "@parcnet-js/ticket-spec";
const request = ticketProofRequest({
/**
* As per examples above
*/
});
const { proof, boundConfig, revealedClaims } = await z.gpc.prove({ request: request.schema });
const isVerified = await z.gpc.verifyWithProofRequest(proof, boundConfig, revealedClaims, proofRequest);

This performs both of the checks described above. Of course, since you’re using the same proof request object in both cases, you already know that the proof matches the request!

However, you can use a similar technique when verifying the same proof in another environment, such as on a server:

src/server.ts
import { ticketProofRequest } from "@parcnet-js/ticket-spec";
import { gpcVerify } from "@pcd/gpc";
import isEqual from "lodash/isEqual";
const request = ticketProofRequest({
/**
* This should be the same proof request that you use on the client.
* It would be a good idea to define your proof request in a shared module or
* package.
*/
});
// Here we assume that some kind of web framework such as Express is being used
// to receive these variables via a HTTP POST or similar.
const { proof, boundConfig, revealedClaims } = httpRequest.body;
const { proofConfig, membershipLists, externalNullifier, watermark } = request.getProofRequest();
// This is necessary to satisfy the type of `GPCBoundConfig`
proofConfig.circuitIdentifier = boundConfig.circuitIdentifier;
// These changes ensure that the revealed claims say what they are supposed to
revealedClaims.membershipLists = membershipLists;
revealedClaims.watermark = watermark;
if (revealedClaims.owner) {
revealedClaims.owner.externalNullifier = externalNullifier;
}
const isVerified = await gpcVerify(
proof,
proofConfig as GPCBoundConfig,
revealedClaims,
pathToGPCArtifacts // This may vary depending on your installation
);

This ensures that our verified proof not only matches the claims that were sent, but that claims are those we expect them to be.

Devcon Ticket Proofs

Above we outlined a generic approach to making proofs about tickets. If you’re looking to make a proof about ownership of a Devcon ticket specifically, you can use this configuration:

src/main.ts
const request = ticketProofRequest({
classificationTuples: [
{
signerPublicKey: "YwahfUdUYehkGMaWh0+q3F8itx2h8mybjPmt8CmTJSs",
eventId: "5074edf5-f079-4099-b036-22223c0c6995"
}
],
fieldsToReveal: {
// Reveal the attendee's name
attendeeName: true,
// Reveal the attendee's email
attendeeEmail: true
},
externalNullifier: {
type: "string",
value: "APP_SPECIFIC_NULLIFIER"
}
});