Integrate zkTLS into your Aztec app in 15 minutes

Elena

With Primus zkTLS attestations you can bring verified web data to your Aztec app. This web2 data can be public or private.

A few examples of what zkTLS can unlock for your Aztec app:

  • GitHub related proofs. E.g: I have a GitHub profile or I am part of organization xyz on GitHub

  • Proof of certain amount of funds on-chain, for example via the Binance API

  • Publicly available like cryptocurrency exchange rates or trading pair availability

So how does this work in practice? Primus provides an attestation over the web2 data. As mentioned before, this can be public or private data, since the data might be something only available to a logged in user of the particular website, or it can just be information visible to all. Then you send this attestation to your Aztec smart contract and verify it in a private function. The verification will include a check whether the data comes from the right URL, whether the data was signed by an attestor we trust and whether the private data belongs to the public attestation data that was sent. If that all checked out, you can do checks on the private data that relate to the specific business case.

Don't worry about all the checks that have to be done on the attestation data; we're providing you with a Noir library that takes care of that part. All you have to focus on is adding the checks that your business logic requires. For example, if you are using profile information from X (formerly Twitter), then your business check can be "nr of followers > 100". Or if the information is coming from Binance it could be "has > 0.1 ETH".

In this tutorial you can add the power of zkTLS to your existing Aztec app in 3 steps:

  1. Obtain a zkTLS attestation

  2. Add verification to your Aztec contract

  3. Parse and submit the attestation on-chain

You can check out our demo that contains all 3 parts. It obtains an attestation about contributors on Github to a public repo. In the Aztec smart contract it can be verified that that the username and Github id are certain values. After verification is emits a public success event. Note that for this example we're querying the first alphabetical contributor, but you can change it to your usecase.

1. Obtain a zkTLS attestation

We'll be using the DVC (Data Verification and Computation) mode of Primus zkTLS. This allows us to do checks/computation on the verified data. The complete implementation details can be found here and we're using the demo code. You can use hash-based or commitment-based attestations. In the rest of the example code we'll focus on hash-based, but the full demo code contains both approaches.

Primus Labs: "Hash-based attestation is well-suited for low-cost integrity anchoring, where users can generate private attestations off-chain and store the hashed attestation on-chain.

Commitment-based attestation is more appropriate for hide-and-reveal scenarios, such as voting or sealed bids, where the committed value will later be used in the zk circuits."

First, define what URL you're quering. For example in our demo we query the GitHub contributors API for some owner and repo:

  const requests = [
    {
      url: `https://api.github.com/repos/${owner}/${repo}/contributors?per_page=1&page=1`,
      method: 'GET',
      header: headers,
      body: '',
    },
  ];
  const requests = [
    {
      url: `https://api.github.com/repos/${owner}/${repo}/contributors?per_page=1&page=1`,
      method: 'GET',
      header: headers,
      body: '',
    },
  ];
  const requests = [
    {
      url: `https://api.github.com/repos/${owner}/${repo}/contributors?per_page=1&page=1`,
      method: 'GET',
      header: headers,
      body: '',
    },
  ];

Then define what data you want to obtain by specifying the JSON path to that data. If you want several pieces of data, you can define them separately. Per datapoint give it a keyName and specify the parsePath. For example, in the snippet below we are obtaining the Github username (login) and Github id (id) from the first returned contributor ($.[0]) to the repo.

Set parseType to json. For the operation type you can choose between SHA256_EX for hash-based attestations or REVEAL_GRUMPKIN_COMMITMENT for commitment-based attestations.

 const responseResolves = [
    [
      {
        keyName: 'username',
        parseType: 'json',
        parsePath: '$.[0].login',
        op: 'SHA256_EX',
      },
      {
        keyName: 'contributor-id',
        parseType: 'json',
        parsePath: '$.[0].id',
        op: 'SHA256_EX',
      },
    ],
  ];
 const responseResolves = [
    [
      {
        keyName: 'username',
        parseType: 'json',
        parsePath: '$.[0].login',
        op: 'SHA256_EX',
      },
      {
        keyName: 'contributor-id',
        parseType: 'json',
        parsePath: '$.[0].id',
        op: 'SHA256_EX',
      },
    ],
  ];
 const responseResolves = [
    [
      {
        keyName: 'username',
        parseType: 'json',
        parsePath: '$.[0].login',
        op: 'SHA256_EX',
      },
      {
        keyName: 'contributor-id',
        parseType: 'json',
        parsePath: '$.[0].id',
        op: 'SHA256_EX',
      },
    ],
  ];

Note that you can query larger pieces of data, but this will have a negative effect on the performance of the Aztec smart contract. When input sizes grow, proof verification is slower. Additionally, if you want to check properties over that larger data it probably requires json parsing, which is very expensive within a circuit (which an Aztec private function is under the hood).

Moving on, add the code to request the zkTLS attestation and let it be saved to a file. For this we use the zkTLS Client functionality from here to be able to call it. Before running it, copy .env.example to .env and fill in your PRIVATE_KEY(of a Base Sepolia wallet with a small amount of ETH for gas), CHAIN_ID, RPC_URL, and the GITHUB_REPO_OWNER & GITHUB_REPO_NAME. Then:

  const zkvmRequestData = await client.doZKTLS(requests, responseResolves, {
    verifyVersion: '2',
    algorithmType: 'proxytls',
    runZkvm: false,
    noProxy: false,
  });

  if (zkvmReqeustData && zkvmReqeustData.attestationData) {
    saveToFile("github-contributors-attestation-hash.json", JSON.stringify(zkvmReqeustData.attestationData));
  }
  const zkvmRequestData = await client.doZKTLS(requests, responseResolves, {
    verifyVersion: '2',
    algorithmType: 'proxytls',
    runZkvm: false,
    noProxy: false,
  });

  if (zkvmReqeustData && zkvmReqeustData.attestationData) {
    saveToFile("github-contributors-attestation-hash.json", JSON.stringify(zkvmReqeustData.attestationData));
  }
  const zkvmRequestData = await client.doZKTLS(requests, responseResolves, {
    verifyVersion: '2',
    algorithmType: 'proxytls',
    runZkvm: false,
    noProxy: false,
  });

  if (zkvmReqeustData && zkvmReqeustData.attestationData) {
    saveToFile("github-contributors-attestation-hash.json", JSON.stringify(zkvmReqeustData.attestationData));
  }

There are two different "algorithm types" you can pick that represent 2 different modes in which the attestation will be generated; proxy mode proxytls or MPC mode mpctls. They come with tradeoffs for performance and security; basically in the proxy mode the attestor is the sole actor between the client and data source server, offering higher performance, but requiring more trust on towards the attestor. In the MPC mode the attestor and the client collaboratively compute the attestation data. Please check the Primus documentation to learn more and choose the best setting for your usecase.

2. Add verification to your Aztec contract

Great, now that we have an attestation JSON file we can work with, the next step is to adjust the Aztec contract. In the zktls-verification-noir repo you'll find all the pieces we need for this.

Firstly, we have an attestation verifier library in Noir which contains the general zkTLS verification functionality and secondly a smart contract template that we invite you to use to make your life easier! This uses the Noir attestation library and contains the necessary storage and functions to use zkTLS in your Aztec smart contract. Of course we understand you might prefer to add bits and pieces of the contract template to your existing one, so we'll do a walktrough here of the smart contract template.

The contract template has some storage (see code snippet), of which the allowed_url_hashes are absolutely necessary and the adminoptional. As mentioned before, one of the checks we do on the attestation is whether the web data comes from a URL that is "allowed". In your contract you have to define which URLs are allowed, and those are stored as hashes to save space. We recommend to add an admin that can update allowed_url_hashes, who themselves would be stored in storage as well (admin below).

#[storage]
struct Storage<Context> {
    // Admin address that can update allowed_url_hashes
    admin: PublicMutable<AztecAddress, Context>,
    // Hashes of allowed URLs (storing complete URLs was too large for contract storage)
    allowed_url_hashes: PublicMutable<[Field; 3], Context>
}
#[storage]
struct Storage<Context> {
    // Admin address that can update allowed_url_hashes
    admin: PublicMutable<AztecAddress, Context>,
    // Hashes of allowed URLs (storing complete URLs was too large for contract storage)
    allowed_url_hashes: PublicMutable<[Field; 3], Context>
}
#[storage]
struct Storage<Context> {
    // Admin address that can update allowed_url_hashes
    admin: PublicMutable<AztecAddress, Context>,
    // Hashes of allowed URLs (storing complete URLs was too large for contract storage)
    allowed_url_hashes: PublicMutable<[Field; 3], Context>
}

Then, the main verification function. Depending on whether you opted to go for hash-based or commitment-based attestations, you need to add the correct verification function to your contract. To see the full version of the demo contract (which contains verification functions for both approaches), check here.

The important function is verify_hash, which will:

  • check the signature

  • check that the private data contents corresponds to the hashes in the public data

  • return the allowed url hashes that have to be checked against the public storage values

Note that the verification function has a variety of input values, all of which you can obtain from the attestation JSON using the Aztec attestation SDK in the next step.

global MAX_URL_LEN: u32 = 128;
global MAX_PLAINTEXT_LEN: u32 = 50;

#[external("private")]
fn verify_hash(
    public_key_x: [u8; 32],
    public_key_y: [u8; 32],
    hash: [u8; 32],
    signature: [u8; 64],
    request_urls: [BoundedVec<u8, MAX_URL_LEN>; 2],
    allowed_urls: [BoundedVec<u8, MAX_URL_LEN>; 3],
    data_hashes: [[u8; 32]; 2],
    contents: [BoundedVec<u8, MAX_PLAINTEXT_LEN>; 2],
    id: Field,
) -> bool {
    let allowed_url_matches_hashes: [Field; 2] = verify_attestation_hashing(
        public_key_x,
        public_key_y,
        hash,
        signature,
        request_urls,
        allowed_urls,
        data_hashes,
        contents,
    );

    // TODO insert checks on `contents`

    BusinessProgram::at(self.address)
        .check_urls_emit_event(
            self.msg_sender(),
            self.address,
            id,
            allowed_url_matches_hashes,
        )
        .enqueue(self.context);

    true
}
global MAX_URL_LEN: u32 = 128;
global MAX_PLAINTEXT_LEN: u32 = 50;

#[external("private")]
fn verify_hash(
    public_key_x: [u8; 32],
    public_key_y: [u8; 32],
    hash: [u8; 32],
    signature: [u8; 64],
    request_urls: [BoundedVec<u8, MAX_URL_LEN>; 2],
    allowed_urls: [BoundedVec<u8, MAX_URL_LEN>; 3],
    data_hashes: [[u8; 32]; 2],
    contents: [BoundedVec<u8, MAX_PLAINTEXT_LEN>; 2],
    id: Field,
) -> bool {
    let allowed_url_matches_hashes: [Field; 2] = verify_attestation_hashing(
        public_key_x,
        public_key_y,
        hash,
        signature,
        request_urls,
        allowed_urls,
        data_hashes,
        contents,
    );

    // TODO insert checks on `contents`

    BusinessProgram::at(self.address)
        .check_urls_emit_event(
            self.msg_sender(),
            self.address,
            id,
            allowed_url_matches_hashes,
        )
        .enqueue(self.context);

    true
}
global MAX_URL_LEN: u32 = 128;
global MAX_PLAINTEXT_LEN: u32 = 50;

#[external("private")]
fn verify_hash(
    public_key_x: [u8; 32],
    public_key_y: [u8; 32],
    hash: [u8; 32],
    signature: [u8; 64],
    request_urls: [BoundedVec<u8, MAX_URL_LEN>; 2],
    allowed_urls: [BoundedVec<u8, MAX_URL_LEN>; 3],
    data_hashes: [[u8; 32]; 2],
    contents: [BoundedVec<u8, MAX_PLAINTEXT_LEN>; 2],
    id: Field,
) -> bool {
    let allowed_url_matches_hashes: [Field; 2] = verify_attestation_hashing(
        public_key_x,
        public_key_y,
        hash,
        signature,
        request_urls,
        allowed_urls,
        data_hashes,
        contents,
    );

    // TODO insert checks on `contents`

    BusinessProgram::at(self.address)
        .check_urls_emit_event(
            self.msg_sender(),
            self.address,
            id,
            allowed_url_matches_hashes,
        )
        .enqueue(self.context);

    true
}

Here we are using hardcoded values for the array lengths for clarity, but in practice you can set these values yourself.

Note the TODO in the code; that is where you need to add the checks on the data you have to do for your usecase. In our example, the private information in JSON format will have a structure like this:

"private_data":[
      {
         "id":"username",
         "content":"ewynx"
      },
      {
         "id":"contributor-id",
         "content":"22170967"
      }
   ]
"private_data":[
      {
         "id":"username",
         "content":"ewynx"
      },
      {
         "id":"contributor-id",
         "content":"22170967"
      }
   ]
"private_data":[
      {
         "id":"username",
         "content":"ewynx"
      },
      {
         "id":"contributor-id",
         "content":"22170967"
      }
   ]

For this demo, we add 2 input arguments to verify_hash, namely the github_username and github_id that are then checked to be equal to those obtained from the attestation json:

// Verify the attested username matches the expected value
assert(contents[0].storage() == github_username.storage());
// Verify the attested contributor ID matches the expected value
assert(contents[1].storage() == github_id.storage());
// Verify the attested username matches the expected value
assert(contents[0].storage() == github_username.storage());
// Verify the attested contributor ID matches the expected value
assert(contents[1].storage() == github_id.storage());
// Verify the attested username matches the expected value
assert(contents[0].storage() == github_username.storage());
// Verify the attested contributor ID matches the expected value
assert(contents[1].storage() == github_id.storage());

As stated before, it's recommended to query very specific pieces of data so there is no need for json parsing at this step. However, if your usecase requires you to parse the json in the circuit, we recommend to use this slightly adapted version of the json_parser, which will use for vectors (as is needed here).

Great job! Let's move on to the final step.

3. Parse and submit the attestation on-chain

The final step is to deploy the Aztec smart contract and let it verify the attestation. We'll do this on a local network you run. Compile the contract you created and obtain the right artifacts:




In the script, initialize the client and deploy an account:

import { Client } from "aztec-attestation-sdk";

const client = new Client({ nodeUrl: "http://localhost:8080" });
await client.initialize();
const account = await client.getAccount(0);
import { Client } from "aztec-attestation-sdk";

const client = new Client({ nodeUrl: "http://localhost:8080" });
await client.initialize();
const account = await client.getAccount(0);
import { Client } from "aztec-attestation-sdk";

const client = new Client({ nodeUrl: "http://localhost:8080" });
await client.initialize();
const account = await client.getAccount(0);

Parse attestation data that you obtained from Primus in step 1:

import { parseHashingData } from "aztec-attestation-sdk";

// Customize this to your use-case
const ALLOWED_URLS = ["https://api.github.com", "https://www.okx.com", "https://x.com"];
const MAX_RESPONSE_NUM = 2;

const attestationData = JSON.parse(fs.readFileSync(ATT_PATH, "utf-8"));
const parsed = parseHashingData(rawData, {
  maxResponseNum: MAX_RESPONSE_NUM,
  allowedUrls: ALLOWED_URL,
});
import { parseHashingData } from "aztec-attestation-sdk";

// Customize this to your use-case
const ALLOWED_URLS = ["https://api.github.com", "https://www.okx.com", "https://x.com"];
const MAX_RESPONSE_NUM = 2;

const attestationData = JSON.parse(fs.readFileSync(ATT_PATH, "utf-8"));
const parsed = parseHashingData(rawData, {
  maxResponseNum: MAX_RESPONSE_NUM,
  allowedUrls: ALLOWED_URL,
});
import { parseHashingData } from "aztec-attestation-sdk";

// Customize this to your use-case
const ALLOWED_URLS = ["https://api.github.com", "https://www.okx.com", "https://x.com"];
const MAX_RESPONSE_NUM = 2;

const attestationData = JSON.parse(fs.readFileSync(ATT_PATH, "utf-8"));
const parsed = parseHashingData(rawData, {
  maxResponseNum: MAX_RESPONSE_NUM,
  allowedUrls: ALLOWED_URL,
});

Deploy contract. Note that parameter pointH here is strictly only necessary for the commitment-based approach, but because our verifier contract supports both, it needs this for the setup.

import { ContractHelpers } from "aztec-attestation-sdk";
import { GithubVerifierContract } from "./bindings/GithubVerifier.ts";

const DEPLOY_TIMEOUT = 300000; // 5 min
const MAX_URL_LEN = 128;

const contract = await ContractHelpers.deployContract(GithubVerifierContract, client, {
  admin: account.address,
  allowedUrls: ALLOWED_URLS,
  maxUrlLen: MAX_URL_LEN,
  pointH: H, // This is for the commitment-based approach
  from: account.address,
  timeout: DEPLOY_TIMEOUT,
});
import { ContractHelpers } from "aztec-attestation-sdk";
import { GithubVerifierContract } from "./bindings/GithubVerifier.ts";

const DEPLOY_TIMEOUT = 300000; // 5 min
const MAX_URL_LEN = 128;

const contract = await ContractHelpers.deployContract(GithubVerifierContract, client, {
  admin: account.address,
  allowedUrls: ALLOWED_URLS,
  maxUrlLen: MAX_URL_LEN,
  pointH: H, // This is for the commitment-based approach
  from: account.address,
  timeout: DEPLOY_TIMEOUT,
});
import { ContractHelpers } from "aztec-attestation-sdk";
import { GithubVerifierContract } from "./bindings/GithubVerifier.ts";

const DEPLOY_TIMEOUT = 300000; // 5 min
const MAX_URL_LEN = 128;

const contract = await ContractHelpers.deployContract(GithubVerifierContract, client, {
  admin: account.address,
  allowedUrls: ALLOWED_URLS,
  maxUrlLen: MAX_URL_LEN,
  pointH: H, // This is for the commitment-based approach
  from: account.address,
  timeout: DEPLOY_TIMEOUT,
});

Now, define what you check the Github username and id to be equal to:

const githubUsernameBytes = Array.from(new TextEncoder().encode("ewynx"));
const githubId = Array.from(new TextEncoder().encode("22170967"));
const githubUsernameBytes = Array.from(new TextEncoder().encode("ewynx"));
const githubId = Array.from(new TextEncoder().encode("22170967"));
const githubUsernameBytes = Array.from(new TextEncoder().encode("ewynx"));
const githubId = Array.from(new TextEncoder().encode("22170967"));

Verify attestation:

const TX_TIMEOUT = 120000;     // 2 min

const { receipt } = await contract.methods.verify_hash(
    parsed.publicKeyX, 
    parsed.publicKeyY, 
    parsed.hash, 
    parsed.signature,
    parsed.requestUrls, 
    parsed.allowedUrls, 
    parsed.dataHashes, 
    parsed.plainJsonResponses,
    parsed.id, 
    githubUsernameBytes, 
    githubId
).send({ from: account.address, wait: { timeout: TX_TIMEOUT } });
const TX_TIMEOUT = 120000;     // 2 min

const { receipt } = await contract.methods.verify_hash(
    parsed.publicKeyX, 
    parsed.publicKeyY, 
    parsed.hash, 
    parsed.signature,
    parsed.requestUrls, 
    parsed.allowedUrls, 
    parsed.dataHashes, 
    parsed.plainJsonResponses,
    parsed.id, 
    githubUsernameBytes, 
    githubId
).send({ from: account.address, wait: { timeout: TX_TIMEOUT } });
const TX_TIMEOUT = 120000;     // 2 min

const { receipt } = await contract.methods.verify_hash(
    parsed.publicKeyX, 
    parsed.publicKeyY, 
    parsed.hash, 
    parsed.signature,
    parsed.requestUrls, 
    parsed.allowedUrls, 
    parsed.dataHashes, 
    parsed.plainJsonResponses,
    parsed.id, 
    githubUsernameBytes, 
    githubId
).send({ from: account.address, wait: { timeout: TX_TIMEOUT } });

Check for success event:

const { events } = await getPublicEvents<SuccessEvent>(
  client.getNode(), OKXVerifierContract.events.SuccessEvent, { txHash: receipt.txHash, contractAddress: contract.address }
);
if (events.length === 0) throw new Error("SuccessEvent was NOT emitted!");
const { events } = await getPublicEvents<SuccessEvent>(
  client.getNode(), OKXVerifierContract.events.SuccessEvent, { txHash: receipt.txHash, contractAddress: contract.address }
);
if (events.length === 0) throw new Error("SuccessEvent was NOT emitted!");
const { events } = await getPublicEvents<SuccessEvent>(
  client.getNode(), OKXVerifierContract.events.SuccessEvent, { txHash: receipt.txHash, contractAddress: contract.address }
);
if (events.length === 0) throw new Error("SuccessEvent was NOT emitted!");

That's it! You have successfully incorporated Primus zkTLS in your Aztec App.

Finishing up

We're looking forward to the new type of Aztec apps that can be built using Primus zkTLS! If you're using the zktls-verification-noir repository, please don't hesitate to reach out for any questions or support. In addition to this tutorial, we'll also be publishing a writeup on this project this week, so keep an eye out for that. Follow any further updates of HashCloak on Twitter and Github.