Trustless verifiable data infrastructure powered by zero-knowledge proofs

Introduction

vlayer provides tools and infrastructure that give smart contracts super powers like time travel to past blocks, teleport to different chains, access to real data from the web, and email.

vlayer allows smart contracts to be executed off-chain. The result of the execution can then be used by on-chain contracts.

Sections

Getting Started

To get started with vlayer, install vlayer, set up your first project and check out the explainer section to learn how vlayer works.

Features

See how to time travel across block numbers, teleport from one chain to another, prove data coming from email or web and use helpers for JSON and Regex.

From JavaScript

Learn how to interact with vlayer from your JS code and how to generate web proofs and email proofs using our SDK.

Advanced

Learn in-depth how:

Appendix

References for:

Installation

Ready to Use

This feature is fully implemented and ready for use. If you encounter any issues, please submit a bug report on our Discord to help us improve.

The easiest way to install vlayer is by using vlayerup, the vlayer toolchain installer.

Prerequisites

Before working with vlayer, ensure the following tools are installed:

Additionally, you'll need Bun to run examples. For more details, refer to the Running Examples Locally section.

Get vlayerup

To install vlayerup, run the following command in your terminal, then follow the onscreen instructions.

curl -SL https://install.vlayer.xyz | bash

This will install vlayerup and make it available in your CLI.

Using vlayerup

Running vlayerup will install the latest (nightly) precompiled binary of vlayer:

vlayerup

You can check that the binary has been successfully installed and inspect its version by running:

vlayer --version

First steps with vlayer

Creating a new project

Run this command to initialize a new vlayer project:

vlayer init project-name

It creates a folder with sample contracts.

Adding to an existing project

Use the --existing flag to initialize vlayer within your existing Foundry project:

cd ./your-project && vlayer init --existing

Example project

To initialize vlayer project with example prover and verifier contracts use --template flag:

vlayer init my-airdrop --template private-airdrop

Directory structure

The vlayer directory structure resembles a typical Foundry project but with two additional folders: src/vlayer and vlayer.

  • src/vlayer: Contains the Prover and Verifier smart contracts.
  • vlayer: Has contract deployment scripts, client SDK calls to the prover, and verifier transactions.

Running examples locally

All examples

❗️ Make sure that you have Bun installed in your system to build and run the examples.

To run vlayer examples locally, first build the contracts by navigating to your project folder and running:

cd your-project
forge build

This compiles the smart contracts and prepares them for deployment and testing.

Please note that vlayer init installs Solidity dependencies and generates remappings.txt. Running forge soldeer install is not needed to build the example and may overwrite remappings, which can cause build errors.

Then, install Typescript dependencies in vlayer folder by running:

cd vlayer
bun install

Next, launch a local Ethereum node:

$ anvil 

and in a separate terminal start the Prover server:

vlayer serve

For Provers using functionalities like teleports or time travel, configure the appropriate JSON-RPC URLs for each chain used:

vlayer serve \
  --rpc-url '31337:http://localhost:8545' \
  --rpc-url '11155111:https://eth-sepolia.g.alchemy.com/v2/{ALCHEMY_KEY}' \
  --rpc-url '1:https://eth-mainnet.g.alchemy.com/v2/{ALCHEMY_KEY}' \
  --rpc-url '8453:https://base-mainnet.g.alchemy.com/v2/{ALCHEMY_KEY}' \
  --rpc-url '10:https://opt-mainnet.g.alchemy.com/v2/{ALCHEMY_KEY}'

To run the example from within the vlayer directory, use the following command:

bun run prove.ts

Web Proof example

First, install the vlayer browser extension from the Chrome Web Store (works with Chrome and Brave browsers). For more details about the extension, see the Web Proofs section.

Then deploy the WebProofProver and WebProofVerifier contracts on local anvil testnet:

cd vlayer
bun run deploy.ts

Start web app on localhost:

cd vlayer
bun run dev

The app will be available at http://localhost:5174 and will display buttons that will let you interact with the extension and vlayer server (open browser developer console to see the app activity).

How it works?

vlayer introduces new super powers to Solidity smart contracts:

  • Time Travel: Execute a smart contract on historical data.
  • Teleport: Execute a smart contract across different blockchain networks.
  • Web proof: Access verified web content, including APIs and websites.
  • Email proof: Access verified email content.

Prover and Verifier

To implement the above features, vlayer introduces two new contract types: Prover and Verifier.

The Prover code runs on the vlayer zkEVM infrastructure. Proof data structure is the result of this operation.

The Verifier verifies generated proof and runs your code on EVM-compatible chains.

Both types of contracts are developed using the Solidity programming language.

vlayer contract execution

A typical vlayer execution flow has three steps:

  1. The application initiates a call to the Prover contract that is executed off-chain in the zkEVM. All the input for this call is private by default and is not published on-chain.
  2. The result of the computation is passed along with a proof to be executed in the on-chain contract. All the output returned from Prover contract is public and is published on-chain as parameters to the Verifier contract.
  3. The Verifier contract verifies the data sent by the proving party (using the submitted proof by client) and then executes the Verifier code.

See the diagram below.

Off-chain execution simplified diagram The flow of vlayer contract execution

Prover

vlayer Prover contracts have a few distinct properties:

  • verifiability - can be executed off-chain and results can't be forged.
  • privacy - inputs are private by default and are not published on-chain.
  • no gas fees - no usual transaction size limits apply.

All arguments passed to the Prover contract functions are private by default. To make an argument public, simply add it to the list of returned values.

See the example Prover contract code below. It generates proof of ownership of the BYAC (Bored Ape Yacht Club) NFT.

contract BoredApeOwnership is Prover  {
    function main(address _owner, uint256 _apeId) public returns (Proof, address) {  
      // jumps to block 12292922 at ETH mainnet (chainId=1), when BYAC where minted
      setChainId(1, 12292922); 

      require(IERC721(BYAC_NFT_ADDR).ownerOf(_apeId) == _owner, "Given address not owning that BYAC");

      return (proof(), _owner); 
    }
}

In order to access Prover specific features, your contract needs to derive from the vlayer Prover contract. Then setChainId() teleport context to a historic block at Ethereum Mainnet (chainId=1) in which the first mint of BYAC NFT occurred. require makes sure that the given address (_owner) was the owner of the specific _apeId at that point of time. The owner address, which makes it public input for the Verifier contract.

Verifier

The Verifier smart contract validates the correctness of a computation generated by Prover, without revealing the underlying information. Such contracts can be used to facilitate more complex workflows, such as privacy-preserving decentralized finance (DeFi) applications or confidential voting systems.

Verification logic is immutable once deployed on the blockchain, ensuring consistent and permissionless access.

See the example Verifer contract below. It transfers tokens to proven owner of certain NFT:

contract Airdrop is Verifier {
  function claim(Proof calldata _p, address owner) 
    public 
    onlyVerified(PROVER_VLAYER_CONTRACT_ADDR, NftOwnership.main.selector) 
  {
    IERC20(TOKEN_ADDR).transfer(owner, 1000);
  }
}

Note that the above contract inherits from the Verfier vlayer contract. It is necessary for veryfing the computation done by the Prover contract from the previous step.

claim() function takes proof returned by the vlayer SDK as the first argument. Other arguments are public inputs returned from Prover main() function (in the same order).

onlyVerified(address, bytes4) modifier ensures that proof is valid and takes two arguments:

  • Address of the Prover contract
  • Function selector of the Prover main function

Proof doesn't have to be passed to onlyVerified as an argument. However, it has to be passed as an argument to function that is being decorated with onlyVerified, along with the public outputs.


To learn more about how the Prover and Verifier work under the hood, please refer to our Advanced section.

Time travel

Actively in Development

Currently, it’s possible to time travel to any past block. However, this will change once the proving code is fully developed; limits may be introduced on how far back you can travel. Until this update is complete, a malicious prover could potentially create fake time travel proofs.

Access to historical data

Unfortunately, direct access to the historical state from within smart contracts is not possible. Smart contracts only have access to the current state of the current block.

To overcome this limitation, vlayer introduced the setBlock(uint blockNo) function, available in our Prover contracts. This function allows switching context of subsequent call to the desired block number.

This allows aggregating data from multiple blocks in a single call to a function.

Example

Prover

The following is an example of Prover code that calculates the average USDC balance at specific block numbers.

contract AverageBalance is Prover {
    IERC20 immutable token;
    uint256 immutable startingBlock;
    uint256 immutable endingBlock;
    uint256 immutable step;

    constructor() {
        token = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); // USDC 
        startingBlock = 6600000;
        endingBlock = 6700000;
        step = 10000;
    }

    function averageBalanceOf(address _owner) public returns (Proof, address, uint256) {
        uint256 totalBalance = 0;
        uint256 iterations = 0;

        for (uint256 blockNo = startingBlock; blockNo <= endingBlock; blockNo += step) {
            setBlock(blockNo);
            totalBalance += token.balanceOf(_owner); // USDC balance
            iterations += 1;
        }
        return (proof(), _owner, totalBalance / iterations);
    }
}

First call to the setBlock(blockNo) function sets the Prover context for the startingBlock (6600000 configured in the constructor). This means that the next call to the token.balanceOf function will read data in the context of the 6600000 block.

Next call to setBlock() sets the Prover context to block numbered 6610000 when step is configured to 10000. The subsequent call to token.balanceOf checks again total balance, but this time in block 6610000.

Each call to token.balanceOf can return different results if the account balance changes between blocks due to token transfers.

The for loop manages the balance checks, and the function’s final output is the average balance across multiple blocks.

Verifier

After proving is complete, the generated proof and public inputs can be used for on-chain verification.

contract AverageBalanceVerifier is Verifier {
    address public prover;
    mapping(address => bool) public claimed;
    HodlerBadgeNFT public reward;

    constructor(address _prover, HodlerBadgeNFT _nft) {
        prover = _prover;
        reward = _nft;
    }

    function claim(Proof calldata, address claimer, uint256 average)
        public
        onlyVerified(prover, AverageBalance.averageBalanceOf.selector)
    {
        require(!claimed[claimer], "Already claimed");

        if (average >= 10_000_000) {
            claimed[claimer] = true;
            reward.mint(claimer);
        }
    }
}

In this Verifier contract, the claim function allows users to mint an NFT if their average balance is at least 10,000,000. The onlyVerified modifier ensures the correctness of the proof and the provided public inputs (claimer and average).

If the proof is invalid or the public inputs are incorrect, the transaction will revert.

💡 Try it Now

To run the above example on your computer, type the following command in your terminal:

vlayer init --template simple-time-travel

This command will download all the necessary artefacts into your current directory (which must be empty). Make sure you have Bun and Foundry installed on your system.

Teleport

Actively in Development

Currently, it’s possible to teleport between any blockchain networks. However, this will change once the proving code is fully developed. After that, only specific network pairs will support teleportation. Until this update is complete, a malicious prover could potentially create fake teleportation proofs.

Ethereum ecosystem of chains

The Ethereum ecosystem is fragmented, consisting of various EVM chains such as Arbitrum, Optimism, Base, and many more. Developing applications that interact with multiple chains used to be challenging, but Teleport makes it easy.

Teleporting betweens chains

setChain(uint chainId, uint blockNo) function, available in Prover contracts, allows to switch the context of execution to another chain (teleport). It takes two arguments:

  • chainId, which specifies the chain in the context of which the next function call will be executed
  • blockNo, which is the block number of the given chain

Example

Prover

The example below shows how to check USDC balances across three different chains:

contract CrossChainBalance is Prover {
    struct Erc20Token {
      address addr;
      uint256 chainId;
      uint256 blockNumber;
    }
    Erc20Token[] tokens = new Erc20Token[](3);

    constructor() {
        // Ethereum mainnet USDC
        tokens[0] = Erc20Token(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, 1, 20683110); 
        // Base USDC
        tokens[1] = Erc20Token(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, 8453, 19367633); 
        // Arbitrum USDC
        tokens[2] = Erc20Token(0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85, 10, 124962954); 
    }

    function balanceOf(address _owner) public returns (Proof, address, uint256) {
        uint256 balance = 0;

        for (uint256 i = 0; i < tokens.length; i++) {
            setChain(tokens[i].chainId, tokens[i].blockNumber);
            balance += IERC20(tokens[i].addr).balanceOf(_owner);
        }

        return (proof(), _owner_, balance);
    }
}

First, the call to setChain(1, 20683110) sets the chain to Ethereum mainnet (chainId = 1). Then, the ERC20 balanceOf function retrieves the USDC balance of _owner at block 20683110.

Next, setChain(8453, 19367633) switches the context to the Base chain. The balanceOf function then checks the balance at block 19367633, but this time on the Base chain.

Subsequent calls are handled by a for loop, which switches the context to the specified chains and block numbers accordingly.

Verifier

After proving is complete, the generated proof and public inputs can be used for on-chain verification.

contract SimpleTravel is Verifier {
    address public prover;
    mapping(address => bool) public claimed;
    WhaleBadgeNFT public reward;

    constructor(address _prover, WhaleBadgeNFT _nft) {
        prover = _prover;
        reward = _nft;
    }

    function claim(Proof calldata, address claimer, uint256 crossChainBalance)
        public
        onlyVerified(prover, SimpleTravelProver.crossChainBalanceOf.selector)
    {
        require(!claimed[claimer], "Already claimed");

        if (crossChainBalance >= 10_000_000_000_00) { // 100 000 USD
            claimed[claimer] = true;
            reward.mint(claimer);
        }
    }
}

In this Verifier contract, the claim function lets users mint an NFT if their cross-chain USDC average balance is at least $100,000. The onlyVerified modifier ensures that the proof and public inputs (claimer and crossChainBalance) are correct.

If the proof or inputs are invalid, the transaction will revert, and the NFT will not be awarded.

💡 Try it Now

To run the above example on your computer, type the following command in your terminal:

vlayer init --template simple-teleport

This command will download all the necessary artefacts into your current directory (which must be empty). Make sure you have Bun and Foundry installed on your system.

Finality considerations

Finality, in the context of blockchains, is a point at which a transaction or block is fully confirmed and irreversible. When using vlayer setChain teleports, chain finality is an important factor to consider.

One should be aware that different chains may have different finality thresholds. For example, Ethereum Mainnet blocks are final after no more than about 12 minutes.

In the case of L2 chains, things are a bit more complicated. For example in case of optimistic rollup, like Optimism and Arbitrum, after L2 blocks are submitted to L1, there's a challenge period (often 7 days). If there is no evidence of an invalid state transition during this period, the L2 block is considered final.

Now consider teleporting to blocks that are not yet final in the destination chain. This can lead to situations where we are proving things that can be rolled back. It is important to include this risk in a protocol. The simplest way is to only teleport to blocks that are final and cannot be reorganized.

Web

Actively in Development

Our team is currently working on this feature. If you experience any bugs, please let us know on our Discord. We appreciate your patience.

Existing web applications including finance, social media, government, ecommerce and many other types of services contain valuable information and can be turned into great data sources.

With vlayer, you can leverage this data in smart contracts.

Web Proofs

Web Proofs provide cryptographic proof of web data served by any HTTPS server, allowing developers to use this data in smart contracts. Only a small subset of the required data is published on-chain.

Web Proofs ensure that the data received has not been tampered with. Without Web Proofs, proving this on-chain is difficult, especially when aiming for an automated and trusted solution.

Example Prover

Let's say we want to mint an NFT for a wallet address linked to a specific X/Twitter handle.

Here’s a sample Prover contract:

import {Strings} from "@openzeppelin-contracts/utils/Strings.sol";
import {Proof} from "vlayer-0.1.0/Proof.sol";
import {Prover} from "vlayer-0.1.0/Prover.sol";
import {Web, WebProof, WebProofLib, WebLib} from "vlayer-0.1.0/WebProof.sol";

contract WebProofProver is Prover {
    using Strings for string;
    using WebProofLib for WebProof;
    using WebLib for Web;

    string dataUrl = "https://api.x.com/1.1/account/settings.json";

    function main(WebProof calldata webProof, address account)
        public
        view
        returns (Proof memory, string memory, address)
    {
        Web memory web = webProof.verify(dataUrl);

        string memory screenName = web.jsonGetString("screen_name");

        return (proof(), screenName, account);
    }
}

What happens in the above code?

  1. Setup the Prover contract:

    • WebProofProver inherits from the Prover contract, enabling off-chain proving of web data.
    • The main function receives a WebProof, which contains a signed transcript of an HTTPS session (see the chapter from JS section on how to obtain WebProof Security Considerations section for details about the TLS Notary).
  2. Verify the Web Proof:

    The call to webProof.verify(dataUrl) does the following:

    • Verifies the HTTPS transcript.
    • Verifies the Notary's signature on the transcript.
    • Ensures the Notary is on the list of trusted notaries (via their signing key).
    • Confirms the data comes from the expected domain (api.x.com in this case).
    • Check whether the HTTPS data comes from the expected dataUrl.
    • Ensures that the server's SSL certificate and its chain of authority are verified.
    • Retrieves the plain text transcript for further processing.
  3. Extract the relevant data:

    web.jsonGetString("screen_name") extracts the screen_name from the JSON response.

  4. Return the results:

    If everything checks out, the function returns the proof placeholder, screenName, and the account.

If there are no errors and the proof is valid, the data is ready for on-chain verification.

💡 Try it Now

To run the above example on your computer, type the following command in your terminal:

vlayer init --template web-proof

This command will download all the necessary artifacts to your project.
The next steps are explained in Running example

Example Verifier

The contract below verifies provided Web Proof and mints a unique NFT for the Twitter/X handle owner’s wallet address.

import {WebProofProver} from "./WebProofProver.sol";
import {Proof} from "vlayer/Proof.sol";
import {Verifier} from "vlayer/Verifier.sol";

import {ERC721} from "@openzeppelin-contracts/token/ERC721/ERC721.sol";

contract WebProofVerifier is Verifier, ERC721 {
    address public prover;

    constructor(address _prover) ERC721("TwitterNFT", "TNFT") {
        prover = _prover;
    }

    function verify(Proof calldata, string memory username, address account)
        public
        onlyVerified(prover, WebProofProver.main.selector)
    {
        uint256 tokenId = uint256(keccak256(abi.encodePacked(username)));
        require(_ownerOf(tokenId) == address(0), "User has already minted a TwitterNFT");

        _safeMint(account, tokenId);
    }
}

What’s happening here?

  1. Set up the Verifier:

    • The prover variable stores the address of the Prover contract that generated the proof.
    • The WebProofProver.main.selector gets the selector for the WebProofProver.main() function.
    • WebProofVerifier inherits from Verifier to access the onlyVerified modifier, which ensures the proof is valid.
    • WebProofVerifier also inherits from ERC721 to support NFTs.
  2. Verification checks:

    The tokenId (a hash of the handle) must not already be minted.

  3. Mint the NFT:

    Once verified, a unique TwitterNFT is minted for the user.

And that's it!

As you can see, Web Proofs can be a powerful tool for building decentralized applications by allowing trusted off-chain data to interact with smart contracts.

Security Considerations

The Web Proof feature is based on the TLSNotary protocol. Web data is retrieved from an HTTP endpoint and it's integrity and authenticity during the HTTP session is verified using the Transport Layer Security (TLS) protocol (the "S" in HTTPS), which secures most modern encrypted connections on the Internet. Web Proofs ensure the integrity and authenticity of web data after the HTTPS session finishes by extending the TLS protocol. A designated server, called Notary, joins the HTTPS session between the client and the server and can cryptographically certify its contents.

From privacy perspective, it is important to note that the Notary server never has access to the plaintext transcript of the connection and therefore, Notary can never steal client data and pretend to be client. Furthermore, the transcript can be redacted (i.e. certain parts can be removed) by the client, making these parts of the communication not accessible by Prover and vlayer infrastructure running the Prover.

Trust Assumptions

It is important to understand that the Notary is a trusted party in the above setup. Since the Notary certifies the data, a malicious Notary could collude with a malicious client to create fake proofs that would still be successfully verified by Prover. Currently vlayer runs it's own Notary server, which means that vlayer needs to be trusted to certify HTTPS sessions.

Currently vlayer also needs to be trusted when passing additional data (data other than the Web Proof itself) to Prover smart contract, e.g. account in the example above. The Web Proof could be hijacked before running Prover and additional data, different from the original, could be passed to Prover, e.g. an attacker could pass their own address as account in our WebProofProver example. Before going to production this will be addressed by making the setup trustless through an association of the additional data with a particular Web Proof in a way that's impossible to forge.

vlayer will publish a roadmap outlining how it will achieve a high level of security when using the Notary service.

Email

Actively in Development

Our team is currently working on this feature. If you experience any bugs, please let us know on our Discord. We appreciate your patience.

Email Significance

Many online services, from social media platforms to e-commerce sites, require an email address to create an account. According to recent surveys, more than 80% of businesses consider email to be their primary communication channel, both internally and with customers.

All of this means that our inboxes are full of data that can be leveraged.

Proof of Email

With vlayer, you can access email content from smart contracts and use it on-chain.

You do this by writing a Solidity smart contract (Prover) that has access to the parsed email and returns data to be used on-chain. This allows you to create claims without exposing the full content of an email.

Under the hood, we verify mail server signatures to ensure the authenticity and integrity of the content.

Example

Let's say someone wants to prove they are part of company or organization. One way to do this is to take a screenshot and send it to the verifier. However, this is not very reliable because screenshot images can be easily manipulated, and obviously such an image cannot be verified on-chain.

A better option is to prove that one can send email from it's organization domain. Below is a sample Prover contract that verifies that the sender sent email from a specific domain.

Below is an example of such proof generation:

import {Strings} from "@openzeppelin-contracts-5.0.1/utils/Strings.sol";
import {Prover} from "vlayer-0.1.0/src/Prover.sol";
import {VerifiedEmail, UnverifiedEmail, EmailProofLib} from "vlayer-0.1.0/src/EmailProof.sol";
import {EmailStrings} from "./EmailStrings.sol";

contract EmailDomainProver is Prover {
    using Strings for string;
    using EmailStrings for string;
    using EmailProofLib for UnverifiedEmail;

    string targetDomain;

    constructor(string memory _targetDomain) {
        targetDomain = _targetDomain;
    }

    function main(UnverifiedEmail calldata unverifiedEmail, address targetWallet)
        public
        view
        returns (Proof, bytes32, address)
    {
        VerifiedEmail memory email = unverifiedEmail.verify();

        require(email.from.contains(targetDomain), "incorrect sender domain");
        require(email.subject.equal("Verify me for company NFT"), "incorrect subject");

        return (proof(), sha256(abi.encodePacked(email.from)), targetWallet);
    }
}

It can be convenient to use Regular Expressions to validate the content of the email.

Email is passed to the Solidity contract as an UnverifiedEmail structure that can be created using the preverifyEmail function in the SDK. preverifyEmail should be called with the raw .eml file content as an argument. The email is also required to have "From" and "DKIM-Signature" headers.

// Note: more fields will be added soon
struct UnverifiedEmail {
  string email;
  string[] dnsRecords;
}

First, we verify the integrity of the email with the verify() function. Then we have a series of assertions (regular Solidity require()) that check the email details.

String comparison is handled by our StringUtils library (described in more details below). Date values are formatted in the Unix time notation, which allows them to be compared as integers.

If one of the string comparisons fails, require will revert the execution, and as a result, proof generation will fail.

💡 Try it Now

To run the above example on your computer, type the following command in your terminal:

vlayer init --template email-proof

This command will download create and initialise a new project with sample email proof contracts.

Email structure

The email structure of type VerifiedEmail is injected into the Prover and can be used in a main() function.

struct VerifiedEmail {
  string subject;
  string body;
  string from;
  string to;
}

An VerifiedEmail consists of the following fields

  • subject - a string with the subject of the email
  • body - a string consisting of the entire body of the email
  • from - a string consisting of the sender's email address (no name is available)
  • to - a string consisting of the intended recipient's email address (no name is available)

By inspecting and parsing the email payload elements, we can generate a claim to be used on-chain.

StringUtils

For convenient manipulation of strings, vlayer provides StringUtils library, which consists of functions like:

  • toAddress - converts a string to an address if properly formatted, reverts otherwise
  • match - matches RegExp pattern groups and returns them as a string
  • equal - checks the contents of two strings for equality. Returns true if both are equal, false otherwise.

Wallet Recovery Example

Below is another example of a Prover smart contract parsing an email. This time, however, the use case is a bit more advanced. It allows the caller to recover access to a MultiSig wallet (a smart contract that allows multiple wallets to authorize transactions).

The following implementation assumes that the recovery email is in a predefined format. It extracts the data needed to restore access to a MultiSig wallet.

To change the authorized account (recovery procedure), the user simply needs to send the email in the following format:

Date: 02 Jul 24 14:52:18+0300 GMT
From: [email protected]
To: <any email with trusted provider>
Subject: Wallet recovery of {old account address}
Body: New wallet address {new account address}

Now, we can access the email from the Prover contract:

import {Prover} from "vlayer/Prover.sol";
import {VerifiedEmail, UnverifiedEmail, EmailProofLib} from "vlayer/EmailProof.sol";
import {StringUtils} from "vlayer/StringUtils.sol"

contract RecoveryEmail is Prover {
    using StringUtils for string;
    using EmailProofLib for UnverifiedEmail;

    function main(address multisigAddr, UnverifiedEmail calldata unverifiedEmail) public returns (address, bytes32, address) {     
      VerifiedEmail memory email = unverifiedEmail.verify()
 
      address lostWallet = parseSubject(email.subject);
      address newAddress = parseBody(email.body);
      bytes32 emailAddrHash = getEmailAddressHash(email.from, multisigAddr, lostWallet);
      
      return (lostWallet, emailAddrHash, newAddress); 
    }

    function parseSubject(string calldata subject) internal returns (address) {
      string[] subjectMatches = subject.match(
        "^Wallet recovery of (0x[a-fA-F0-9]{40})$"
      );
      require(subjectMatches.length == 1, "Invalid subject");

      return subjectMatches[0].toAddress();
    }

    function parseBody(string calldata body) internal returns (address) {
      string[] bodyMatches = body.match(
        "^New wallet address: (0x[a-fA-F0-9]{40})$"
      );
      require(bodyMatches.length == 1, "Invalid body");
      
      return newbodyMatches[0].toAddress();
    }    

    function getEmailAddressHash(string calldata emailAddr, address multisig, address owner) 
      internal 
      returns (bytes32) 
    {
      MultiSigWallet wallet = MultiSigWallet(multisig);

      bytes32 memory recoveryMailHash = wallet.recoveryEmail(owner);
      bytes32 emailAddrHash = keccak256(abi.encodePacked(emailAddr);

      require(recoveryMailHash == emailAddrHash, "Recovery email mismatch");

      return emailAddrHash;
    }
}

What happens step by step in the above snippet?

  • RecoveryEmail inherits from Prover to obtain super powers of off-chain proving.
  • main function takes multisigAddr argument to access Multisig Wallet smart contract data.
  • parseSubject parses email subject and returns address of lost wallet
    • email.subject.match returns strings matching the regular expression for the subject, which must contain the correct wallet address to be recovered.
    • The subjectMatches.length == 1 condition ensures that the subject is not malformed.
  • parseBody extracts new owner address
    • email.body.match retrieves new wallet address from the email body
  • getEmailAddressHash compares the email address associated with the wallet with the one received.
    • recoveryMailHash == emailAddrHash check if correct email was used for recovery

On successful execution, proof of computation is returned. It also returns the recovered wallet address, the email address hash, the new wallet address, and the email timestamp as public input.

Verifier

Now we are ready to use the proof and results from the previous step for on-chain verification. Valid proof allows us to restore access to MultiSigWallet by adding new address to authorized list in smart contract.

Below is a sample implementation of this:

import { Verifier } from "vlayer/Verifier.sol";

import { RecoveryEmail } from "RecoveryEmail.sol";

address constant PROVER_ADDR = address(0xd7141F4954c0B082b184542B8b3Bd00Dc58F5E05);
bytes4 constant  PROVER_FUNC_SELECTOR = RecoveryEmail.main.selector;

contract MultiSigWallet is Verifier  {
    mapping (address => bool) public owners;
    mapping (address => bytes32) ownerToEmailHash;

    function recovery(
      Proof _p, 
      address lostWallet, 
      bytes32 emailAddrHash, 
      address newOwner, 
    ) 
      public 
      onlyVerified(PROVER_ADDR, PROVER_FUNC_SELECTOR) 
      returns (address) 
    {  
      require(
        ownerToEmailHash[lostWallet] == emailAddrHash, 
        "Recovery email mismatch"
      );

      require(owners[lostWallet]; "Not an owner");
      
      owners[lostWallet] = false;
      owners[newOwner] = true;

      return (newOwner); 
    }
}

What exactly happened in the above code?

  • First, note we need to let know Verifier, which Prover contract to verify:

    • The PROVER_ADDR constant holds the address of the Prover contract that generated the proof.
    • The PROVER_FUNC_SELECTOR constant holds the selector for the Prover.main() function.
    • MultiSigWallet inherits from Verifier, so we can call the onlyVerified modifier that makes sure the proof is correct or it will revert otherwise.
  • Next, we add two fields that an example MultiSig might use:

    • The owners mapping holds addresses that can use MultiSigWallet.
    • The ownerToEmailHash mapping holds hashes of email addresses associated with owners.
  • Finally, we need a logic that will perform all verifications and do actual recovery:

    • The recovery() function takes follwoing arguments:proof and returned values generated by Prover.main().
    • onlyVerified(PROVER_ADDR, PROVER_FUNC_SELECTOR) validates execution of Prover and correctness of arguments. If the proof is invalid or arguments don't match returned values it will revert.
    • You don't need to pass proof as an argument to onlyVerified because it is automatically extracted from msg.data.
    • ownerToEmailHash[lostWallet] == emailAddrHash make sure recovery email address matches the one that was set up previously in the wallet
    • owners[newOwner] = true sets up a new wallet to be authorized to use MultiSigWallet.

And voilà, we just successfully used email in the context of an on-chain smart contract.

Keep in mind that this is a simplified version of a real MultiSig wallet, demonstrating how an email recovery function could operate.

Security Assumptions

Billions of users trust providers to deliver and store their emails. Inboxes often contain critical information, including work-related data, personal files, password recovery links, and more. Email providers also access customer emails for purposes like serving ads. Email proofs can only be as secure as the email itself, and the protocol relies on the trustworthiness of both sending and receiving servers.

Outgoing Server

The vlayer prover verifies that the message signature matches the public key listed in the DNS records. However, a dishonest outgoing server can forge emails and deceive the prover into generating valid proofs for them. To mitigate this risk, vlayers support only a limited number of the world's most trusted email providers.

Preventing Unauthorized Actions

Both outgoing and incoming servers can read emails and use them to create proofs without the permission of the actual mail sender or receiver. This risk also extends to the prover, which accesses the email to generate claims. It is crucial for protocols to utilize email proofs in a manner that prevents the manipulation of smart contracts into performing unauthorized actions, such as sending funds to unintended recipients.

For example, it is advisable to include complete information in the email to ensure correct actions. Opt for emails like: "Send 1 ETH from address X to address Y on Ethereum Mainnet" over partial instructions, like: "Send 1 ETH," where other details come from another source, such as smart contract call parameters. Another approach is to use unique identifiers that unambiguously point to the necessary details.

JSON Parsing and Regular Expressions

Actively in Development

Our team is currently working on this feature. If you experience any bugs, please let us know on our Discord. We appreciate your patience.

When dealing with Web Proofs, the ability to parse JSON data is essential. Similarly, finding specific strings or patterns in the subject or body of an email is crucial for Email Proofs.

To support these needs, we provide helpers for parsing text using regular expressions and extracting data from JSON directly within vlayer Prover contracts.

JSON Parsing

We provide three functions to extract data from JSON based on the field type:

  • jsonGetInt: Extracts an integer value and returns int256.
  • jsonGetBool: Extracts a boolean value and returns bool.
  • jsonGetString: Extracts a string value and returns string memory.
import {Prover} from "vlayer/Prover.sol";
import {WebLib} from "vlayer/WebProof.sol";

contract JSONContainsFieldProof is Prover {
    using WebLib for string;

    function main(string calldata json) public returns (Proof memory, string memory) {
        require(json.jsonGetInt("deep.nested.field") == 42, "deep nested field is not 42");
        
        // If we return the provided JSON back, we will be able to pass it to verifier
        // Together with a proof that it contains the field
        return (proof(), json);
    }
}

In the example above, the function extracts the value of the field deep.nested.field from the JSON string below and checks if it equals 42.

{
  "deep": {
    "nested": {
      "field": 42
    }
  }
}

The functions will revert if the field does not exist or if the value is of the wrong type.

Currently, accessing fields inside arrays is not supported.

Regular Expressions

Regular expressions are a powerful tool for finding patterns in text.

We provide a function to match a regular expression against a string:

  • matches checks if a string matches a regular expression and returns true if a match is found.
import {Prover} from "vlayer/Prover.sol";
import {RegexLib} from "vlayer/Regex.sol";

contract RegexMatchProof is Prover {
    using RegexLib for string;

    function main(string calldata text) public returns (Proof memory, string memory) {
        // The regex pattern is passed as a string
        require(text.matches("^[a-zA-Z0-9]*$"), "text must be alphanumeric only");

        // Return proof and provided text if it matches the pattern
        return (proof(), text);
    }
}

Prover

Actively in Development

Our team is currently working on this feature. If you experience any bugs, please let us know on our Discord. We appreciate your patience.

vlayer Prover contracts are almost the same as regular Solidity smart contracts, with two main differences:

  • Access to Off-Chain Data: Prover contracts accept data from multiple sources through features such as time travel, teleport, email proofs, and web proofs. This allows claims to be verified on-chain without exposing all input the data.

  • Execution Environment: The Prover code executes on the vlayer zkEVM, where the proofs of computation are subsequently verified by the on-chain Verifier contract. Unlike the on-chain contract, the Prover does not have access to the current block. It can only access previously mined blocks. Under the hood, vlayer generates zero-knowledge proofs of the Prover's execution.

Prover in-depth

Prover parent contract

Any contract function can be run in the vlayer prover, but to access the additional features listed above, the contract should inherit from the Prover contract and any function can be used as a proving function.

Arguments and returned value

Arbitrary arguments can be passed to Prover functions. All arguments are private, meaning they are not visible on-chain; however, they are visible to the prover server.

All data returned by functions is public. To make an argument public on-chain, return it from the function.

Limits

We impose the following restrictions on the proof:

  • Calldata passed into the Prover cannot exceed 5 MB.

Proof

Once the Prover computation is complete, a proof is generated and made available along with the returned value. This output can then be consumed and cryptographically verified by the Verifier on-chain smart contract.

Note that all values returned from Prover functions becomes a public input for on-chain verification. Arguments passed to Prover functions remain private.

The list of returned arguments must match the arguments used by the Verifier (see the Verifier page for details).

vlayer Prover must return a placeholder proof as the first argument to maintain consistency with Verifier arguments. Placeholder Proof returned by Prover is created by its method proof(), which is later replaced by the real proof, once it's generated.

Deployment

The Prover contract code must be deployed before use. To do so, just use regular Foundry workflow.

Prepare deployment script:

contract SimpleScript is Script {
    function setUp() public {}

    function run() public {
        uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIV");
        vm.startBroadcast(deployerPrivateKey);

        SimpleProver simpleProver = new SimpleProver();
        console2.log("SimpleProver contract deployed to:", address(simpleProver));
    }
}

Local environment

In the separate terminal, run the local Ethereum test node:

anvil

Then save and execute it:

DEPLOYER_PRIV=PRIVATE_KEY forge script path/to/Script.s.sol --rpc-url http://127.0.0.1:8545

The above command deploys the SimpleProver contract code to local network.

If successful, the above command returns the contract address and the Prover is ready for generating proofs.

For production use proper RPC url and encrypt private key instead of using it via plain text

Verifier contract

Actively in Development

Our team is currently working on this feature. If you experience any bugs, please let us know on our Discord. We appreciate your patience.

vlayer provides Verifier smart contracts that allow on-chain verification of computations performed by Prover contracts. To use the output computed by Prover contract, follow the rules covered in the next section.

Proof Verification

Proof verification can be done by any function that uses the onlyVerified modifier and passes arguments in a particular way. We call such a function verification function. See the example below, with verification function claim.

contract Example is Verifier {

    function claim(
      Proof _p, 
      address verifiedArg1, 
      uint verifiedArg2, 
      bytes extraArg
    ) 
      public 
      returns (uint)
      onlyVerified(PROVER_ADDRESS, FUNCTION_SELECTOR) 
    {
        //...
    }
}

onlyVerified modifier

The onlyVerified modifier takes two arguments:

  1. Prover contract address
  2. the signature of the Prover function used to generate the proof

Proof argument

Passing Proof as the first argument to the verification function is mandatory. Note that even though the proof is not used directly in the body of the verified function, onlyVerified will have access to it via msg.data.

Verified arguments

After the proof, we need to pass verified arguments. Verified arguments are the values returned by the Prover contract function. We need to pass all the arguments returned by prover, in the same order and each of the same type.

See the example below.

contract Prover {

  function p() return (Proof p, address verifiedArg1, uint256 verifiedArg2, bytes32 verifiedArg3) {
    ...
  }
}

contract Verifier {
  function v(Proof _p, address verifiedArg1, uint256 verifiedArg2, bytes32 verifiedArg3) 
}

Note: Passing different variables (in terms of type, name, or order) would either revert execution or cause undefined behavior and should be avoided for security reasons.

Extra arguments

Extra arguments can be passed to Verifier by using additional function. This function manages all additional operations connected with extra arguments and then calls the actual verification function.

See the example below:

function f(Proof _p, verifiedArg1, verifiedArg2, extraArg1, extraArg2) {
  ...
  v(_p, verifiedArg1, verifiedArg2);
}

Prover Global Variables

Ready to Use

This feature is fully implemented and ready for use. If you encounter any issues, please submit a bug report on our Discord to help us improve.

In the global namespace, Solidity provides special variables and functions that primarily offer information about blocks, transactions, and gas.

Since Prover contracts operate in the vlayer zkEVM environment, some variables are either not implemented or behave differently, compared to standard EVM chains.

Current Block and Chain

vlayer extends Solidity with features like time traveling between block numbers and teleporting to other chains. As a result, the values returned by block.number and block.chainId are influenced by these features.

Initially, block.number returns one of the recently mined blocks in the settlement chain, known as the settlement block.

Typically, the prover will use the most recent block. However, proving takes time, and up to 256 blocks can be mined between the start of the proving process and the final on-chain settlement. Proofs for blocks older than 256 blocks will fail to verify. Additionally, a malicious prover might try to manipulate the last block number. Therefore, the guarantee is that the settlement block is no more than 256 blocks old. In the future, the number of blocks allowed to be mined during proving may be significantly increased.

It is recommended to set setBlock to a specific block before making assertions.

Regarding block.chainId, initially it is set to the settlement chain ID, as specified in the JSON RPC call. Later, it can be changed using the setChain() function.

Hashes of Older Blocks

The blockhash(uint blockNumber) function returns the hash for the given blockNumber, but it only works for the 256 most recent blocks. Any block number outside this range returns 0.

vlayer-Specific Implementations

  • block.number: The current block number, as described in the Current Block and Chain section.
  • block.chainid: The current chain ID, as described in the Current Block and Chain section.
  • blockhash(uint blockNumber): Returns the hash of the given block if blockNumber is within the 256 most recent blocks; otherwise, it returns zero.
  • block.timestamp: The current block timestamp in seconds since the Unix epoch.
  • msg.sender: Initially set to a fixed address, it behaves like in standard EVM after a call.
  • block.prevrandao: Returns pseudo-random uint, use with caution.
  • block.coinbase(address payable): Returns 0x0000000000000000000000000000000000000000.

Behaves the Same as in Solidity

  • msg.data: The complete calldata, passed by the prover.

Unavailable Variables

  • block.basefee: Not usable.
  • block.blobbasefee: Not usable.
  • block.difficulty: Not usable.
  • block.gaslimit: Returns 30000000.
  • msg.value: Payable functionalities are unsupported; returns 0.
  • msg.sig: Not usable; does not contain a valid signature.
  • tx.origin: Sender of the transaction (full call chain).
  • blobhash(uint index): Not usable.
  • gasleft: Unused.
  • tx.gasprice: Unused.

Tests

Actively in Development

Our team is currently working on this feature. If you experience any bugs, please let us know on our Discord. We appreciate your patience.

The prover and verifier contracts in vlayer are similar to regular smart contracts, allowing you to perform unit testing using your preferred smart contract testing framework.

vlayer introduces the vlayer test command, along with a couple of cheatcodes, which offers additional support for vlayer specific tests:

  • Testing prover functions that utilize setBlock and setChain
  • Integration testing involving both the prover and the verifier

This command uses Foundry's Forge testing framework, so if you are familiar with it, you will find the process straightforward.

Cheatcodes

To manipulate the blockchain state and test for specific reverts and events, Forge provides cheatcodes.

vlayer introduces additional cheatcodes:

  • callProver(): Executes the next call within the vlayer zkEVM environment, generating a proof of computation accessible via getProof.
  • getProof(): Retrieves the proof from the last call after using callProver.

Example Usage

import {VTest} from "vlayer-0.1.0/testing/VTest.sol";

contract WebProverTest is VTest {
    WebProver prover;
    WebVerifier verifier;

    function test_mainProver() public {
        callProver(); // The next call will execute in the Prover
        uint venmoBalance = prover.main();
        Proof memory proof = getProof();
        verifier.main(proof, venmoBalance);
    }
}

Running Tests

The vlayer test command searches for all contract tests in the current working directory.

Any contract with an external or public function beginning with test is recognized as a test. By convention, tests should be placed in the test/ directory and should have a .t.sol extension and derive from Test contract.

vlayer specific tests are located in the test/vlayer directory and should derive from the VTest contract, which provides access to additional cheatcodes.

To run all available tests, use the following command:

vlayer test

This command runs both Forge tests and vlayer specific tests.

Dev & Production Modes

The vlayer node is an HTTP server that acts as a prover and supports two proving modes:

  • FAKE: Used for development and testing. It executes code and verifies the correctness of execution but does not perform actual proving. In this mode, the Verifier contract can confirm computations, but a malicious Prover could exploit the system.
  • GROTH16: Intended for production and final testing, this mode performs real proving.

By default, the vlayer client SDK communicates with http://127.0.0.1:3000.

Running prover

Assuming vlayer is installed, you can start it in development mode with the following command:

vlayer serve

The vlayer prover server require urls of RPC node providers to query blockchain data. You can pass specific RPC URLs for each chain using the --rpc-url parameter:

vlayer serve --rpc-url <chain-id>:<url>

To configure multiple RPC URLs use --rpc-url parameter many times:

vlayer serve \
  --rpc-url 1:https://eth-mainnet.alchemyapi.io/v2/<alchemy_api_key> \
  --rpc-url 10:https://opt-mainnet.g.alchemy.com/v2/<optimism_api_key> 

Note: By default, no RPC node providers are configured. You will need to specify them manually using the --rpc-url parameter to run the vlayer prover.

FAKE Mode

By default, it listens for JSON-RPC client requests on port 3000 in FAKE mode. You can also specify the --proof argument explicitly:

vlayer serve --proof fake

See the JSON-RPC API appendix for detailed specifications on API calls.

Note: FAKE mode is limited to test and dev chains to prevent accidental errors.

GROTH16 Mode

GROTH16 mode is slower than FAKE mode and requires significant computational resources.

To speed up proof generation, vlayer supports the use of infrastructure like the Bonsai (and eventually Boundless) to offload heavy computations to high-performance machines.

To run a vlayer node in production mode, use this command:

BONSAI_API_URL=https://api.bonsai.xyz/ \
BONSAI_API_KEY={api_key_goes_here} \
vlayer serve --proof groth16

You can request a BONSAI_API_KEY here.

Note: Protocols should be designed with proving execution times in mind, as it may take a few minutes to generate proof.

Vanilla JS/TS

JavaScript

Actively in Development

Our team is currently working on this feature. If you experience any bugs, please let us know on our Discord. We appreciate your patience.

A common way to interact with blockchain is to make calls and send transactions from JavaScript, most often from a web browser. vlayer provides developer friendly JavaScript/TypeScript API - vlayer SDK. It combines well with the standard way of interacting with smart contracts.

Installation

To install vlayer SDK, run the following command in your JavaScript application

yarn add @vlayer/sdk

vlayer client

A vlayer client is an interface to vlayer JSON-RPC API methods to trigger and follow the status of proving. It also provides convenient access to specific vlayer features such as Web Proofs and Email Proofs.

Initialize a client with default prover.

import { createVlayerClient } from '@vlayer/sdk'
 
const vlayer = createVlayerClient();

Initialize a client with prover with specific url.

import { createVlayerClient } from '@vlayer/sdk'
 
const vlayer = createVlayerClient({
  proverUrl: 'http://localhost:3000',
})

Proving

In order to start proving, we will need to provide:

  • address - an address of prover contract
  • proverAbi - abi of prover contract
  • functionName - name of prover contract function to call
  • args - an array of arguments to functionName prover contract function
  • chainId - id of the chain in whose context the prover contract call shall be executed
import { foundry } from 'viem/chains'
import { proverAbi } from './proverAbi'

const { hash } = await vlayer.prove({
    address: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
    proverAbi,
    functionName: 'main',
    args: ['0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 123],
    chainId: foundry,
})

Waiting for result

Wait for the proving to be finished, and then retrieve the result along with Proof.

const result = await vlayer.waitForProvingResult({ hash });

Verification

Once we have obtained proving result, we can call verifier contract (below example demonstrates how to use createAnvilClient function for that purpose).

import { verifierAbi } from './verifierAbi'
import { testHelpers } from '@vlayer/sdk'

testHelpers.createAnvilClient().writeContract({
    abi: verifierAbi,
    address,
    account,
    functionName: 'verify',
    args: result,
})

Web proofs from javascript

Actively in Development

Our team is currently working on this feature. If you experience any bugs, please let us know on our Discord. We appreciate your patience.

Web Proofs

On top of access to vlayer JSON-RPC proving API, vlayer client provides functionality to generate and prove Web Proofs.

vlayer browser extension

vlayer provides a browser extension which can be launched (once installed in user's browser) from vlayer SDK and used to generate a Web Proof of a 3rd party website.
vlayer extension is compatible with Chrome and Brave browsers.

We start by instantiating vlayer client.

import { createVlayerClient } from '@vlayer/sdk'

const vlayer = createVlayerClient()

We can configure a Web Proof provider which uses vlayer browser extension and enables configuring custom Notary server and custom WebSocket proxy (see section WebSocket proxy below for more details).

import { createExtensionWebProofProvider } from '@vlayer/sdk/web_proof'

const webProofProvider = createExtensionWebProofProvider({
    notaryUrl: 'https://...',
    wsProxyUrl: 'wss://...',
})

Both notaryUrl and wsProxyUrl have default values, so the provider can be initialized without any configuration as:

const webProofProvider = createExtensionWebProofProvider();

In the future, vlayer is planning to provide additional Web Proof provider implementations, which can be e.g. ran server-side and don't require vlayer browser extension for the purpose of Web Proof generation.

The Web Proof provider exposes a low-level API to directly define proverCallCommitment (commitment to use the generated Web Proof only with the specified prover contract call details, so it's not possible to submit it in a different context) and to explicitly generate the Web Proof by calling getWebProof.

import {
  startPage,
  expectUrl,
  notarize,
} from '@vlayer/sdk/web_proof'

// all args required by prover contract function except webProof itself
const commitmentArgs = ['0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045']

const proverCallCommitment = {
  address: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
  functionName: 'main',
  commitmentArgs,
  chainId: sepolia,
  proverAbi,
}

const webProof = await webProofProvider.getWebProof({
  proverCallCommitment,
  logoUrl: 'http://twitterswap.com/logo.png',
  steps: [
    startPage('https://x.com/i/flow/login', 'Go to x.com login page'),
    expectUrl('https://x.com/home', 'Log in'),
    notarize('https://api.x.com/1.1/account/settings.json', 'GET', 'Generate Proof of Twitter profile'),
  ],
})

The above snippet defines a Web Proof, which is generated by the following steps:

  1. startPage - redirects the user's browser to https://x.com/i/flow/login.
  2. expectUrl - ensures that the user is logged in and visiting https://x.com/home URL.
  3. notarize - prompts the user to generate a Web Proof, i.e. to notarize an HTTP GET request sent to https://api.x.com/1.1/account/settings.json URL.

Each step also accepts a human-readable message which the user will see. We can also optionally pass a link to custom logo to display in the extension.

The call to webProofProvider.getWebProof opens vlayer browser extension and guides the user through the steps passed as an argument.

Once we have the Web Proof available we can directly call vlayer client prove method, adding the Web Proof to previously created proverCallCommitment.

import { sepolia } from 'viem/chains'
import { proverAbi } from './proverAbi'

const { hash } = await vlayer.prove({
    ...proverCallCommitment,
    args: [webProof, ...commitmentArgs],
})

To learn more details about the Web Proof feature, please see the Web Proof section.

WebSocket proxy

The WebSocket proxy is required in the Web Proofs setup to allow the vlayer extension to access the low-level TLS connection of the HTTPS request for which we are generating a Web Proof (browsers do not provide this access by default). The default WebSocket proxy, wss://notary.pse.dev/proxy, used in our SDK and hosted by the TLSN team, supports a limited number of domains (you can view the list here).

If you'd like to notarize a request for a different domain, you can run your own proxy server. To do this locally, install and run websocat:

cargo install websocat
websocat --binary -v ws-l:0.0.0.0:55688 tcp:api.x.com:443

Replace api.x.com with the domain you'd like to use. Then, configure your Web Proof provider to use your local WebSocket proxy (running on port 55688):

import { createExtensionWebProofProvider } from '@vlayer/sdk/web_proof'

const webProofProvider = createExtensionWebProofProvider({
  wsProxyUrl: "ws://localhost:55688",
})

Now the notarized HTTPS request will be routed through your local proxy server.

Email proofs from SDK

Actively in Development

Our team is currently working on this feature. If you experience any bugs, please let us know on our Discord. We appreciate your patience.

Email Proofs

In order to prove the content of an email, we firstly need to prepare it to be passed into the smart contract. We provide a handy function for it in the SDK, preverifyEmail.

import fs from "fs";
import { preverifyEmail } from "@vlayer/sdk";
// .. Import prover contract ABI

// Read the email MIME-encoded file content
const email = fs.readFileSync("email.eml").toString();

// Prepare the email for verification
const unverifiedEmail = await preverifyEmail(email);

// Create vlayer server client
const vlayer = createVlayerClient();

const { hash } = await vlayer.prove(prover, emailProofProver.abi, "main", [unverifiedEmail]);
const result = await vlayer.waitForProvingResult({ hash });

The email.eml file should be a valid email, probably exported from your email client.

The email cannot be modified in any way (including whitespaces and line breaks), because it will make the signature invalid.

Contributing

We're excited to have you here. Below are the key sections where you can get involved with vlayer:

  • Rust: Contribute to vlayer Rust codebase
  • JavaScript: Contribute to vlayer JS/TS codebase
  • Book: update content, or provide feedback to this book
  • Extension: help expand the functionality of our browser extension

Contributing to vlayer Rust codebase

Prerequisites

To start working with this repository, you will need to install following software:

Building

Before you build solidity smart contracts, make sure that dependencies are up to date:

git submodule update --init --recursive

cd contracts
forge soldeer install

To build vlayer project, first, navigate to the rust directory and type:

cargo build

Running

Run anvil in the background:

anvil

Then, to run proving server, execute the following command:

RUST_LOG=info RISC0_DEV_MODE=1 cargo run -- serve

Finally, to test proving navigate to any of the examples within /examples directory, run following commands to build example's contracts:

forge soldeer install
forge clean 
forge build

then navigate to vlayer directory within the selected example and run the following command:

bun install 
bun run prove.ts

For guides about the project structure, check out architecture appendix.

Guest Profiling

To profile execution of Guest code in zkVM, we leverage the profiling functionality provided by RISC Zero. In order to run profiling, follow the steps in the Usage section of the RISC Zero documentation, but in Step 2 replace the command you run with:

RISC0_PPROF_OUT=./profile.pb cargo run --bin vlayer serve --proof fake

which will start the vlayer server. Then just call the JSON RPC API and the server will write the profiling output to profile.pb, which can be later visualised as explained in the RISC Zero Profiling Guide. Please note that the profile only contains data about the Guest execution, i.e. the execution inside the zkVM.

Troubleshooting

Error on macOS while cargo build: assert.h file doesn't exist

In some cases while running cargo build, an error occurs with compiling mdbx-sys.
In that case install xcode-select:

xcode-select --install

If you get the message Command line tools are already installed, but the problem persists, reinstall it:

sudo rm -rf /Library/Developer/CommandLineTools
xcode-select --install

Then, install updates by "Software Update" in System Settings and finally restart your computer.

Contributing to vlayer JavaScript codebase

Prerequisites

To start working with this repository, you will need to install following software:

  • Bun JavaScript runtime.

Bumping version

  1. Apply changes to the code
  2. Run bun changeset
  3. Submit information about your changes (would be visible in the changelog)
  4. Run bun changeset version
  5. Commit modified files changes
  6. Push

Quick list of common questions to get you started engaging with changesets (tool for versioning) is in their docs

Contributing to vlayer Book

Prerequisites

Ensure you have Rust and the Cargo package manager installed:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

After installing Rust, install the required dependencies:

  • mdbook: A command-line tool for creating books with Markdown.
  • mdbook-mermaid: A preprocessor for compiling Mermaid diagrams.
  • mdbook-tabs: A plugin for adding tab functionality to the book.
cargo install mdbook mdbook-mermaid mdbook-tabs

Development

The book's source is in the vlayer monorepo. To start the development server, navigate to the book/ directory and run:

mdbook serve

Whenever you update the book's source, the preview will automatically refresh. Access the preview at http://localhost:3000.

Building

To build the book, navigate to the book/ directory and run:

mdbook build

Building

Rust allows you to set granular log levels for different crates using RUST_LOG. To debug a specific crate, you can set its log level to debug. For example:

RUST_LOG=info,call_engine=debug ./rust/target/debug/vlayer serve

The static HTML output will be generated in the book/book directory. You can use this output to preview the book locally or deploy it to a static site hosting service.

Contributing to the vlayer browser extension

Prerequisites

To start working with the vlayer browser extension, you need to install the following software:

Building

First build the vlayer server with:

cd rust
cargo build

Then build vlayer contracts with:

cd contracts
forge soldeer install
forge clean
forge build

Web app's files are in examples/web_proof/vlayer folder.

cd examples/web_proof
forge soldeer install
forge clean
forge build
cd examples/web_proof/vlayer
bun install

Extension's files are in packages/browser-extension folder.

cd packages
bun install

Local development

Run anvil:

anvil

Run the vlayer server:

cd rust
cargo run --bin vlayer serve --proof fake

Deploy WebProofProver and WebProofVerifier contracts on anvil:

cd examples/web_proof/vlayer
bun run deploy.ts

deploy.ts script deploys the Prover and Verifier contracts. Their addresses are saved in the .env.development file and later used by the web app.

Start web app on localhost:

cd examples/web_proof/vlayer
bun run dev

Then, start the browser extension:

cd packages/browser-extension
bun run dev

This will open a web browser with the vlayer app and browser extension installed. Now all the saved changes will be applied in your browser automatically.

There is a script, that runs all of the steps above.

Extension watch mode

Extension can be also built using:

bun run build:watch

in packages/browser-extension directory. It enables hot-reload of the extension.

Testing

Extension end-to-end tests are stored in packages/browser-extension/tests folder.

Testing uses Playwright web testing library. Install it with:

bunx playwright install --with-deps chromium

To run tests, firstly, install Typescript dependencies in packages folder:

cd packages
bun install

Then, build the extension:

cd packages/browser-extension
bun run build

Finally, run tests:

cd packages/browser-extension
bun run test:headless

Architecture overview

vlayer execution spans across three environments, each written in respective technologies and consisting of related components:

  • browser (js)
    • javascript SDK - thin wrapper around the vlayer JSON-RPC API
    • browser plugin - used for notarization of TLS Connections
  • server infrastructure (rust)
    • prover server - exposing vlayer functionality via vlayer JSON-RPC API
    • block proof cache - http server used as a cache for proofs of inclusion of a block in a chain
    • notary server - used to notarize TLS connections
    • workers - used to perform actual proving
  • blockchain (Solidity)

Schema

All the above components can be found in the monorepo. It also contains sources of this book.

Prover architecture

On the high level, vlayer runs zkEVM that produces a proof of proper execution of Prover smart contract. Under the hood, vlayer is written in Rust that is compiled to zero knowledge proofs. Currently, Rust is compiled with RISC Zero, but we aim to build vendor-lock free solutions working on multiple zk stacks, like sp-1 or Jolt. Inside rust revm is executed.

Our architecture is inspired by RISC Zero steel, with two main components that can be found in rust/ directory:

  • Host - (in host) - accepts the request, runs a preflight, during which it collects all data required by the guest. Then, guest proving is triggered.
  • Guest - performs execution of the code inside zkEVM. Consists of three crates:
    • guest - (in guest) - Library that contains code for EVM execution and input validation
    • risc0_guest - (in guest_wrapper/risc0_guest) - Thin wrapper that uses RISC Zero ZKVM IO and delegates work to guest
    • guest_wrapper - (in guest_wrapper) - Compiles the risc0_guest to RISC Zero target and makes it available to be run inside the host. It can be considered Rust equivalent of a code generation script.

In addition, there are several other crates in the rust/ directory:

  • engine: Main execution shared by Guest and Host, used in both Host's preflight and Guest's zk proving.
  • cli: vlayer command line interface used to start the server, run tests, and more.
  • server: Server routines accepting vlayer JSON RPC calls.
  • mpt: Sparse Merkle Patricia tries used to pass the database from host to guest.
  • test_runner: Fork of the forge test runner used to run vlayer tests.
  • web_proof: Web proofs data structures and verification routines.

Execution and proving

The host passes arguments to the guest via standard input (stdin), and similarly, the guest returns values via standard output (stdout).

zkVM works in isolation, without access to a disk or network.

On the other hand, when executing Solidity code in the guest, it needs access to the Ethereum state and storage. The state consist of Ethereum accounts (i.e. balances, contracts code and nonces) and the storage consist of smart contract variables.

Hence, all the state and storage needs to be passed via input.

However, all input should be considered insecure. Therefore, validity of all the state and storage needs to be proven.

Note: In off-chain execution, the notion of the current block doesn't exist, hence we always access Ethereum at a specific historical block. The block number can be the latest mined block available on the network. This is different than the current block inside on-chain execution, which can access the state at the moment of execution of the given transaction.

To deliver all necessary proofs, the following steps are performed:

  • In preflight, we execute Solidity code on the host. Each time the db is called, the value is fetched via Ethereum JSON RPC. Then, the proof is stored in the local database called ProofDb.
  • The serialized content of ProofDb is passed via stdin to the guest.
  • The guest deserializes content into a local database StateDb.
  • Solidity code is executed inside revm using a local copy of StateDb.

Since that Solidity execution is deterministic, database in the guest has exactly the data it requires.

Schema

Databases

We have two different databases run in two different places. Each is a composite database:

  • Host - runs ProofDb, which proxies queries to ProviderDb. In turn, ProviderDb forwards the call to Ethereum RPC provider. Finally, ProofDb stores information about what proofs will need to be generated for the guest.
  • Guest - runs WrapStateDb, which proxies calls to StateDb.
    • StateDb consists of state passed from the host and has only the content required to be used by deterministic execution of the Solidity code in the guest. Data in the StateDb is stored as sparse Ethereum Merkle Patricia Tries, hence access to accounts and storage serves as verification of state and storage proofs.
    • WrapStateDb is an adapter for StateDb that implements Database trait. It additionally does caching of the accounts, for querying storage, so that the account is only fetched once for multiple storage queries.
%%{init: {'theme':'dark'}}%%
classDiagram

class Database {
    basic(address): AccountInfo?
    code_by_hash(code_hash) Bytecode?
    storage(address, index) U256?
    block_hash(number) B256?
}

class StateDb {
    state_trie: MerkleTrie
    storage_tries: HashMap
    contracts: HashMap
    block_hashes: HashMap
    account(address: Address) StateAccount?
    code_by_hash(hash: B256) Bytes?
    block_hash(number: U256) B256
    storage_trie(root: &B256) MerkleTrie?
}

class ProviderDb {
    provider
}

class WrapStateDb {
    stateDb
}

class ProofDb {
    accounts: HashMap
    contracts: HashMap
    block_hash_numbers: HashSet
    providerDb
}

Database <|-- WrapStateDb
Database <|-- ProviderDb
Database <|-- ProofDb
WrapStateDb *-- StateDb
ProviderDb *-- Provider
ProofDb *-- ProviderDb
Database..AccountInfo
StateDb..StateAccount

class AccountInfo {
    balance: U256
    nonce: u64
    code_hash: B256
    code: Bytecode?
}

class StateAccount {
    balance: U256
    nonce: TxNumber
    code_hash: B256
    storage_root: B256
}

Environments

The environment in which the execution will take place is stored in the generic type EvmEnv<D, H>, where D is a connected database and H represents the type of the block header. The database connected to Engine varies between the Guest, Host and testing environment.

Block header

The block header type may vary between sidechains and L2s.

Life cycle

The environment is created in the host and converted into EvmInput and serialized. The data is then sent over standard input to the guest and deserialized in the guest. EthEvmInput is an EvmInput specialized by EthBlockHeader.

EvmInput stores state and storage trees as sparse Ethereum Merkle Patricia Trie implemented by MPT structures, which is a wrapped Node. The sparse tree is very similar to the standard MPT in that it includes four standard node types. However, it only keeps data necessary to execution and in place of unused nodes it uses a special node called Digest.

The data is serialized by host with the EVMInput.into_env() function. Additionally, this method verifies header hashes (current and ancestors). StateDb::new calculates bytecodes hashes and storage roots.

Verification of input data

The guest is required to verify all data provided by the host. Validation of data correctness is split between multiple functions:

  • EVMInput.into_env verifies:
    • equality of subsequent ancestor block hashes
    • equality of header.state_root and actual state_root
  • StateDb::new calculates:
    • smart contracts bytecode hashes
    • storage roots
  • MerkleTrie::from_rlp_nodes effectively verifies merkle proofs by:
    • Calculating the hash of each node
    • Reconstructing the tree in MerkleTrie::resolve_trie
%%{init: {'theme':'dark'}}%%
classDiagram

class EvmInput {
    header
    state_trie
    storage_tries
    contracts
    ancestors
    into_env(): EvmEnv<StateDb, H>
}

class EvmEnv {
    db: D,
    cfg_env: CfgEnvWithHandlerCfg
    header: Sealed<H>
}

EvmEnv <|-- EthEvmEnv
EvmEnv *-- CfgEnvWithHandlerCfg

EvmInput <|-- EthEvmInput
EvmInput -- MPT
MPT -- Node

class CfgEnvWithHandlerCfg {
    pub cfg_env: CfgEnv
    pub handler_cfg: HandlerCfg
}

class Node {
    <<enumeration>>
    Null
    Leaf
    Extension
    Branch
    Digest
}

Components

There are two main entry crates to the system: risk_host and risk_guest. Each of them should be a few simple lines of code and they should implement no logic. They depend on Host and Guest crates respectively. The part of code shared between the host and guest is stored in a separate component - Engine. In the future, there might be more entry points i.e. Sp1Host and Sp1Guest.

Below is a short description of the components:

  • The Host is an http server. The Host's main purpose is to parse an http request and execute logic and convert the result to an http response.

  • The Guest is a program which communicates via reading input and writing to output. For simplicity, all input is deserialized into GuestInput and all output is serialized into GuestOutput. The Guest's main purpose is to parse input and run logic from Engine.

  • The Engine consists of shared logic between the Host and the Guest. In the Host, it is used to run preflight and in the Guest it is used to perform proving. It mainly does two things:

    • runs Rust preprocessing of a call (e.g. email signature verification)
    • runs Solidity contracts inside revm
%%{init: {'theme':'dark'}}%%
classDiagram

Risc0Guest --> Guest
Sp1Guest --> Guest
JoltGuest --> Guest
Cli --> Host
Cli --> Server
Server --> Host
Guest --> Engine
Host --> Engine

class Engine {
    revm
    rust_hooks
    new(db)
    run(call)
}

class Host {
    new(out)
    run(call)
}

class Guest {
    new(in, out)
    call(Call)
}

class Risc0Guest {
    main()
}

class Server {
    host
    call(host)
}

Error handling

Error handling is done via custom semantic HostError enum type, which is converted into http code and a human-readable string by the server.

Instead of returning a result, to handle errors, Guest panics. It does need to panic with a human-readable error, which should be converted on Host to a semantic HostError type. As execution on Guest is deterministic and should never fail after a successful preflight, the panic message should be informative for developers.

Dependency injection

All components should follow the dependency injection pattern, which means all dependencies should be passed via constructors. Hence, components should not need to touch nested members.

There should be one build function per component.

Testing

Test types:

  • unit tests
  • integration tests for components Engine, Host, Guest
  • integration test of HttpServer, with:
    • single happy path test per http endpoint
    • single test per error code (no need to do per-error-per-end point test)
  • end-to-end test, running a server and settle result on-chain

Security audit

We will be auditing 100% of guest code, which consists of: RiscGuest, Guest and Engine.

We should minimize amount of dependencies to all three of them. Especially, there should be no code in Engine used by Host only.

Teleport and time-travel

To support execution on multiple blocks and multiple chains, we span multiple revms' instances during Engine execution.

Generic parameter DB

Note that Engine is parametrized with generic type DB, as it needs to run in revm with different Database in two different contexts: Guest and Host.

#![allow(unused)]
fn main() {
struct Engine<DB: DatabaseRef> {
  ...
}
}

This parametrization will bubble to several related traits and structs: EvmEnv, EnvFactory, HostEnvFactory, GuestEnvFactory.

EvmEnv

EvmEnv represents a configuration required to create a revm instance. Depending on the context, it might be instantiated with ProofDB (Host) or WrapStateDB (Guest).

It is also implicitly parametrized via dynamic dispatch by Header type, which may differ for various hard forks or networks.

See the code snippet below.

#![allow(unused)]
fn main() {
pub struct EvmEnv<DB> {
    pub db: DB,
    pub cfg_env: CfgEnvWithHandlerCfg,
    pub header: Box<dyn EvmBlockHeader> ,
}
}

EvmEnvFactory

EnvFactory is a type, responsible for creation of EvmEnv and, in consequence, revm instances. There are two variants of EnvFactory:

  • HostEnvFactory creates Databases and Headers dynamically, utilizing Providers created from MultiProvider, by fetching data from Ethereum Nodes. Then, the data is serialized to be sent to Guest.
  • GuestEnvFactory provides all required data returned from a cached copy deserialized at the beginning of Guest execution.
%%{init: {'theme':'dark'}}%%
classDiagram

class EnvFactory {
  create(ExecutionLocation)
}

class HostEnvFactory {
  providers: HashMap[chainId, Provider]
  new(MultiProvider)
}

class GuestEnvFactory {
  envs: HashMap[[ChainId, BlockNo], Env<WrapStateDB>]
  from(MultiInput)
}

class Env {
  db: DB
  config: Config
  header: dyn Header
}

class MultiProvider {
  providers: HashMap[chainId, EthersProvider]
}

EnvFactory <|-- GuestEnvFactory
EnvFactory <|-- HostEnvFactory
GuestEnvFactory o-- Env
HostEnvFactory <.. MultiProvider

Engine

Engine's responsibility is to execute calls. To do so, Engine spawns revms instances on demand. Engine calls are intercepted by TravelInspector.

The role of the TravelInspector is to intercept calls related to time travel and teleport features. It stores the destination location (set by setBlock and setChain calls) and delegates the call back to the Engine if needed.

%%{init: {'theme':'dark'}}%%
classDiagram

class Engine {
  call(ExecutionLocation, Call)
}

class TravelInspector {
  destination: Option[ExecutionLocation]
  callback: F
  chainId: ChainId
  setBlock(uint)
  setChain(uint, uint)
  delegateCall(call)
}

Engine *-- TravelInspector

Testing

Tests are run in a custom ContractRunner forked from forge.

In addition to the usual functionality, tests run by vlayer can use the execProver feature. The next call after execProver will be executed in the vlayer Engine.

Runner is extended with a custom Inspector that delegates certain calls to an instance of Engine. The design is similar to TravelInspector.

Block Proof

vlayer executes Solidity code off-chain and proves the correctness of that execution on-chain. For that purpose, it fetches state and storage data and verifies it with storage proofs.

Storage proofs prove that a piece of storage is part of a block with a specific hash. We say the storage proof is 'connected' to a certain block hash.

However, the storage proof doesn't guarantee that the block with the specific hash actually exists on the chain. This verification needs to be done later with an on-chain smart contract.

Motivation

vlayer provides time-travel functionality. As a result, state and storage proofs are not connected to a single block hash, but to multiple block hashes. To ensure that all those hashes exist on the chain, it's enough to prove two things:

  • Coherence - all the blocks' hashes belong to the same chain
  • Canonicity - the last block hash is a member of a canonical chain

2-step verification

Coherence

Will be proven using Block Proof Cache service.

It maintains a data structure that stores block hashes along with a zk-proof. The zk-proof proves that all the hashes contained by the data structure belong to the same chain.

Canonicity

Since the latest hash needs to be verified on-chain, but generating proofs is a slow process; some fast chains might prune our latest block by the time we are ready to settle the proof. Proposed solution is described here.

Proving Coherence

Naive Block Proof Cache

We need a way to prove that a set of hashes belongs to the same chain. A naive way to do this is to hash all of the subsequent blocks, from the oldest to the most recent, and then verify that each block hash is equal to the parentHash value of the following block. If all the hashes from our set appear along the way, then they all belong to the same chain.

See the diagram below for a visual representation.

Naive chain proof

Unfortunately, this is a slow process, especially if the blocks are far apart on the time scale. Fortunately, with the help of Block Proof Cache, this process can be sped up to logarithmic time.

Block Proof Cache

The Block Proof Cache service maintains two things:

  • a Block Proof Cache structure (a Merkle Patricia Trie) that stores block hashes,
  • a zk-proof 𝜋 that all these hashes belong to the same chain.

Given these two elements, it is easy to prove that a set of hashes belongs to the same chain.

  1. It needs to be verified that all the hashes are part of the Block Proof Cache structure.
  2. 𝜋 needs to be verified.

Block Proof Cache (BPC) structure

The Block Proof Cache structure is a dictionary that stores a <block_number, block_hash> mapping. It is implemented using a Merkle Patricia Trie. This enables us to prove that a set of hashes is part of the structure (point 1 from the previous paragraph) by supplying their corresponding Merkle proofs.

Chain proof elements

Adding hashes to the BPC structure and maintaining 𝜋

At all times, the BPC structure stores a sequence of consecutive block hashes that form a chain. In other words, we preserve the invariant that:

  • block numbers contained in the structure form a sequence of consecutive natural numbers,
  • for every pair of block numbers i, i+1 contained in the structure, block(i + 1).parentHash = hash(block(i)).

Every time a block is added, 𝜋 is updated. To prove that after adding a new block, all the blocks in the BPC structure belong to the same chain, two things must be done:

  • The previous 𝜋 must be verified.
  • It must be ensured that the hash of the new block 'links' to either the oldest or the most recent block.

Recursive proofs

In an ideal world - ZK Circuit will have access to its own ELF ID and therefore be able to verify the proofs produces by its previous invocations recursively. Unfortunately because ELF ID is a hash of a binary - it can't be included in itself.

Therefore, we extract ELF ID into an argument and "pipe" it through all the proofs. We also add it to an output. Finally - when verifying this proof within the call proof - we check ELF ID against a hard-coded constant. This can be done there as call and chain are different circuits and having an ID of one within the other does not present the cycle mentioned above.

We can reason about soundness backwards. If someone provided the proof which has correct ELF ID in the output and verifies with correct ELF ID - it also had correct ELF ID in the inputs and therefore correctly verified the internal proof.

If one would try to generate the proof with ELF ID for an empty circuit (no assertions) - they can do that but:

  • either the output will not match;
  • or the proof will not verify with our ELF ID.

Implementation

Guest code exposes two functions:

  • initialize() - Initializes the MPT and inserts first block;
  • append_and_prepend() - Extends the MPT inserting new blocks from the right and from the left while checking invariants and verifying previous proofs. In order to understand it's logic - we first explain in pseudocode how would a single append and a single prepend work before jumping into batch implementation.

Initialize

The initialize() function is used to create Block Proof Cache structure as a Merkle Patricia Trie (MPT) and insert the initial block hash into it. It takes the following arguments:

  • elf_id: a hash of the guest binary.
  • block: the block header of the block to be added.

It calculates the hash of the block using the keccak256 function on the RLP-encoded block. Then it inserts this hash into the MPT at the position corresponding to the block number. Notice that no invariants about neighbours are checked as there are no neighbours yet.

fn initialize(elf_id: Hash, block: BlockHeader) -> (MptRoot, elf_id) {
    let block_hash = keccak256(rlp(block));
    let mpt = new SparseMpt();
    mpt.insert(block.number, block_hash);
    (mpt.root, elf_id)
}

Append

The append() function is used to add a most recent block to the Merkle Patricia Trie. It takes the following arguments:

  • elf_id: a hash of the guest binary,
  • new_rightmost_block: the block header to be added,
  • mpt: a sparse MPT containing two paths: one from the root to the parent block and one from the root to the node where the new block will be inserted,
  • proof (π): a zero-knowledge proof that all contained hashes so far belong to the same chain. This function ensures that the new block correctly follows the previous block by checking the parent block's hash. If everything is correct, it inserts the new block's hash into the trie.
fn append(elf_id: Hash, new_rightmost_block: BlockHeader, mpt: SparseMpt<ParentBlockIdx, NewBlockIdx>, proof: ZkProof) -> (MptRoot, elf_id) {
    risc0_std::verify_zk_proof(proof, (mpt.root, elf_id), elf_id);
    let parent_block_idx = new_rightmost_block.number - 1;
    let parent_block_hash = mpt.get(parent_block_idx);
    assert_eq(parent_block_hash, new_rightmost_block.parent_hash, "Block hash mismatch");
    let block_hash = keccak256(rlp(new_rightmost_block));
    let new_mpt = mpt.insert(new_rightmost_block.number, block_hash);
    (new_mpt.root, elf_id)
}

Prepend

The prepend() function is used to add a new oldest block to the Merkle Patricia Trie. It takes the following arguments:

  • elf_id: a hash of the guest binary.
  • old_leftmost_block: the full data of the currently oldest block already stored in the MPT.
  • mpt: a sparse MPT containing the path from the root to the child block and the new block's intended position.
  • proof: a zero-knowledge proof that all contained hashes so far belong to the same chain. The function verifies the proof to ensure the full data from the child block fits the MPT we have so far. If the verification succeeds, it takes the parent_hash from the currently oldest block and inserts it with the corresponding number into the MPT. Note that we don't need to pass the full parent block as the trie only store hashes. However, we will need to pass it next time we want to prepend.
fn prepend(elf_id: Hash, old_leftmost_block: BlockHeader, mpt: SparseMpt<ChildBlockIdx, NewBlockIdx>, proof: ZkProof) -> (MptRoot, elf_id) {
    risc0_std::verify_zk_proof(proof, (mpt.root, elf_id), elf_id);
    let old_leftmost_block_hash = mpt.get(old_leftmost_block.number);
    assert_eq(old_leftmost_block_hash, keccak256(rlp(old_leftmost_block)), "Block hash mismatch");
    let new_mpt = mpt.insert(old_leftmost_block.number - 1, old_leftmost_block.parent_hash);
    (new_mpt.root, elf_id)
}

Batch version

In order to save on proving costs and latency - we don't expose singular versions of append and prepend but instead - a batch version. It checks the ZK proof only once at the beginning. The rest is the same.

fn append(mpt: SparseMpt, new_rightmost_block: BlockHeader) -> SparseMpt {
    let parent_block_idx = new_rightmost_block.number - 1;
    let parent_block_hash = mpt.get(parent_block_idx);
    assert_eq(parent_block_hash, new_rightmost_block.parent_hash, "Block hash mismatch");
    let block_hash = keccak256(rlp(new_rightmost_block));
    let new_mpt = mpt.insert(new_rightmost_block.number, block_hash);
    new_mpt
}

fn prepend(mpt: SparseMpt, old_leftmost_block: BlockHeader) -> SparseMpt {
    let old_leftmost_block_hash = mpt.get(old_leftmost_block.number);
    assert_eq(old_leftmost_block_hash, keccak256(rlp(old_leftmost_block)), "Block hash mismatch");
    let new_mpt = mpt.insert(old_leftmost_block.number - 1, old_leftmost_block.parent_hash);
    new_mpt
}

fn append_prepend(
  elf_id: Hash,
  prepend_blocks: [BlockHeader],
  append_blocks: [BlockHeader],
  old_leftmost_block: BlockHeader,
  mpt: SparseMpt<[NewLeft..OldLeft], [OldRight...NewRight]>,
  proof: ZkProof
) -> (MptRoot, elf_id) {
    risc0_std::verify_zk_proof(proof, (mpt.root, elf_id), elf_id);
    for block in append_blocks {
      mpt = append(mpt, block);
    } 
    for block in prepend_blocks.reverse() {
      mpt = prepend(mpt, old_leftmost_block);
      old_leftmost_block = block
    }
    (new_mpt.root, elf_id)
}

Prove Chain server

Block Proof Cache structure is stored in a distinct type of vlayer node, specifically a JSON-RPC server. It consists of a single call v_chain(chain_id: number, block_numbers: number[]).

Detailed JSON-RPC API docs

Diagram

%%{init: {'theme':'dark'}}%%
classDiagram
    namespace DB {
        class MDBX {
          // Unit tests
        }

        class InMemoryDatabase {
        }

        class Database {
            <<Interface>>
        }

      class MerkleProofBuilder {
        build_proof(root, key) Proof
      }

      class ChainDB {
          // Unit tests using InMemoryDatabase
          get_chain_info(id) ChainInfo
          get_sparse_merkle_trie(root, [block_num]) MerkleTrie
          update_chain(id, chain_info, new_nodes, removed_nodes)
      }

      class ChainInfo {
        BlockNum left
        BlockNum right
        Hash root
        ZK proof
      }
    }

    namespace ZKVM {
      class MerkleTrie {
          // Unit tests
          get(key) Value
          insert(key, value)
      }

      class BlockTrie {
          // Does not check ZK proofs
          // Unit test for each assertion
          MerkleTrie trie

          new(trie)
          init(block)
          append(new_rightmost_block)
          prepend(old_leftmost_block)
      }

      class Guest {
          init(elf_id, block) (elf_id, Hash)
          append_prepend(elf_id, mpt, old_leftmost, new_leftmost, new_rightmost)
      }
    }

    class Host {
        // Checks that BlockTrie and Guest returned the same root hash
        // Integration tests
        poll()
    }

    class Server {
        // E2E tests
        v_chain(id, [block_num]) [ZkProof, SparseMerkleTrie]
    }

    namespace Providers {
      class Provider {
        <<Interface>>
        get_block(number/hash)
        get_latest_block()
      }

      class EthersProvider {
      }

      class MockProvider {
        mock(request, response)
      }
    }

    Provider <|-- EthersProvider
    Provider <|-- MockProvider

    class Worker {
      // E2E test on Temp MDBX and anvil
    }

    Database <|-- MDBX
    Database <|-- InMemoryDatabase
    ChainDB --> Database
    ChainDB --> MerkleProofBuilder
    ChainDB -- ChainInfo
    MerkleProofBuilder --> Database
    Worker --> Host
    Host --> ChainDB
    Host --> Guest
    Host --> Provider
    Server --> ChainDB
    BlockTrie --> MerkleTrie
    Guest --> BlockTrie
    Host --> BlockTrie
    

Proving Canonicity

It is essential to be able to verify the latest block hash on-chain.

Without that - an attacker would be able to:

  • Execute code on a made-up chain with prepared, malicious data;
  • Execute code on a non-canonical fork.

blockhash

Solidity/EVM has a built-in function that allows us to do that.

blockhash(uint blockNumber) returns (bytes32)

It returns a hash of the given block when blockNumber is one of the 256 most recent blocks; otherwise returns zero.

We assert result of this function with the block hash found in the call assumptions of the call proof.

blockhash limitations

However, this method is limited, as it only works for the most recent 256 blocks on a given chain.

256 blocks is not a measure of time. We need to multiply it by block time to know - how much time we have to settle the proof on a specific chain.

  • Ethereum: 12 seconds - 51 minutes
  • Optimism: 2 seconds - 8.5 minutes
  • Arbitrum One: 250ms - 1 minute

With current prover performance - it takes a couple of minutes to generate a proof. That means by the time it's ready, we will already have missed the slot to settle on Arbitrum.

Block Pinning

Instead of waiting for the proof - we can have a smart-contract that pins block hashes we are planning to use in storage.

Therefore, the flow will be like this:

  • As soon as Host is ready to start the proof generation - it will do two things in parallel:

    • Send a transaction on-chain pinning the latest block
    • Start generating the proof
  • Once the proof is ready, in order to settle on-chain we:

    • First try to use blockhash
    • If it fails - fallback to the list of pinned blocks

This is not implemented yet.

EIP2935

EIP2935 proposes a very similar solution but on a protocol level. Instead of pinning blocks - it requires nodes to make some (8192) range of blocks available through the storage of system contract. It's planned to be included in a Pectra hard fork and go live on mainnet early 2025.

Solidity

Proving

On-chain verification is implemented by using a customized verification function. It receives a list of arguments in the same order as returned by the Prover (public output).

Proof structure must always be returned from the Prover as the first returned element (more on that here), which means that Proof structure must also be passed as the first argument to the verification function.

The verification function should use the onlyVerified() modifier, which takes two arguments: the address of a smart contract and a selector of function that was executed in the Prover contract.

See an example verification function below:

contract Example is Verifier {

    function claim(Proof _p, address verifiedArg1, uint verifiedArg2, bytes extraArg) public returns (uint)
        onlyVerified(PROVER_ADDRESS, FUNCTION_SELECTOR) {
        //...
    }

}

proof is not an argument to onlyVerified because it is automatically extracted from msg.data.

Data flow

Proving data flow consists of three steps:

Step 1: GuestOutput

It starts at Guest, which returns GuestOutput structure. GuestOutput consists of just one field - evm_call_result. evm_call_result field is abi encoded Prover function output. Since Prover returns Proof placeholder as its first returned value, Guest pre-fills length and call_assumptions fields of the Proof structuture.

length field of Proof structure is equal to the length of abi encoded public outputs, not including size of Proof placeholder.

See the code snippets below for pseudocode:

#![allow(unused)]
fn main() {
pub struct GuestOutput {
    pub evm_call_result: Vec<u8>,
}
}

Schema

Step 2: Host output as v_call result

In the next step, the Host replaces the seal field in the Proof placeholder with the actual seal, which is a cryptographic proof of the Prover's execution. The Host then returns this via the JSON-RPC v_call method, delivering the seal as a byte string in the result field.

This approach allows the smart contract developer to decode the v_call result as though they were decoding the Prover function's output directly. In other words, the v_call result is compatible with, and can be decoded according to, the ABI of the called Prover function.

Step 3: Verifier call

Finally, the method on the on-chain smart contract is called to verify the proof. More on that in the next section.

Proof verification

To verify a zero-knowledge proof, vlayer uses a verify function, delivered by Risc-0.

function verify(Seal calldata seal, bytes32 imageId, bytes32 journalDigest) { /* ... */ }

onlyVerified gets seal and journalDigest by slicing it out of msg.data.

length field of Proof structure is used, when guest output bytes are restored in Solidity in order to compute journalDigest. length field hints the verifier, which bytes should be included in the journal, since they belong to encoding of the public outputs, and which bytes belong to extra arguments, passed additionally in calldata.

imageId is fixed on blockchain and updated on each new version of vlayer.

Data encoding summary

Below, is a schema of how a block of data is encoded in different structures at different stages.

Schema

Structures

The Proof structure looks as follows:

struct Proof {
    uint32 length;
    Seal seal;
    CallAssumptions callAssumptions;
}

with Seal having the following structure:

enum ProofMode {
    GROTH16,
    FAKE
}

struct Seal {
    bytes32[8] seal;
    ProofMode mode;
}

and the following structure of CallAssumptions:

struct CallAssumptions {
    address proverContractAddress;
    bytes4 functionSelector;
    uint256 settleBlockNumber;
    bytes32 settleBlockHash;
}

Note that Proof, Seal and CallAssumptions structures are generated based on Solidity code from with sol! macro.

Two Proving Modes

To support two proving modes, vlayer provides a set of smart contracts connected to the Verifier contract, one for each mode:

  • DEVELOPMENT - Automatically deployed with each Prover contract, but only on development and test networks. This mode will be used if the ProofMode decoded from SEAL is FAKE.
  • PRODUCTION - This requires infrastructure deployed ahead of time that performs actual verification. This mode will be used if the ProofMode decoded from SEAL is GROTH16.

Deployment and Release

Environments

The process of releasing vlayer spans across four environments:

  1. User Environment - web browsers
  2. Developer Environment - Tools and libraries on the developer's local machine
  3. vlayer Node Infrastructure - Consists of various server types
  4. Blockchain Networks - Smart contracts deployed across multiple chains

The diagram below illustrates these environments, along with associated artifacts and key interdependencies:

Schema

User Experience

From a delivery perspective, the key aspects of user experience are:

  • Reliable, functioning software
  • Clear error messages when issues arise
  • An easy update process for deprecated software

Users primarily interact with two main artifacts:

  • The SDK, embedded within the developer's web application
  • The developer's smart contracts, which interface with vlayer smart contracts
  • Optionally, a browser extension if the user is engaging with Web Proofs

Unfortunately, both the user and vlayer have limited control over the SDK version in use. The SDK is implemented by the developer and updated only at the developer’s discretion.

Developer Experience

Alpha and Beta Versions

To ensure developers have the best possible experience, we will encourage and/or require them to always update to the most recent version of vlayer. Our goal is to release new versions daily. This approach ensures that:

  • Developers have access to the latest features and bug fixes.
  • We can guarantee compatibility among various artifacts.

A potential downside of this approach is that it may require developers to address bugs in their code caused by breaking changes in vlayer.

Production

In the production environment, we still want to encourage developers to update to the latest version; however, we may choose to:

  • Release new versions less frequently (e.g., weekly).
  • Avoid introducing breaking changes and changes to audited code.

Artifacts and Deployment Cycles

Each environment includes multiple artifacts, each with distinct deployment cycle limitations, as detailed below.

User Environment (Web Browser)

  • Extension

    • Release: vlayer manually releases updates to the Chrome Web Store and other extension platforms. Although automated releases are technically feasible, the store acceptance process introduces some unpredictability.
    • Installation: Users install extensions manually from the store.
    • Updates: Browsers typically handle automatic updates, additionally users can be encouraged or enforced to update manually if needed.
  • SDK

    • Release: vlayer releases new SDK versions daily.
    • Installation: Developers add the SDK to their project dependencies.
    • Updates: Neither vlayer nor the user can enforce SDK version updates, making SDK updates the least controllable in terms of version management on the user's end.

Developer Environment (Command Line Tools)

  • vlayer Command Line Tool - Used in different contexts:
    • With init and test flags, tightly integrated with Foundry
    • With prover, an optional dependency for local development
  • Local Development SDK
  • vlayer Smart Contracts - Managed via Soldeer
  • Foundry - An external dependency requiring updates synchronized with vlayer to:
    • Ensure test and init commands operate in the same directory as forge and other tools
    • Support the latest REVM (Rust Ethereum Virtual Machine) changes, including hard-fork and network compatibility

Updating these artifacts is encouraged or enforced through vlayer CLI commands (test, init, prove) and is executable via vlayer update.

Blockchain Networks (Smart Contracts)

  • User’s Smart Contract - Derived from the Verifier base class, with deployment managed externally
  • Verifier Helper Smart Contract - Often deployed daily

vlayer Node Infrastructure (Servers)

  • User Dashboard - A user interface for managing proof history and purchasing
  • vlayer Prover - A server for executing Prover operations
  • Chain Proves Cache - A server for pre-proving on-chain data, including a JSON RPC server and worker components
  • Notary - Manages notarization in the Web Proofs context, deployed as needed
  • WebSocket Proxy - Handles TCP/IP connection proxying for Web Proofs, deployed as required
  • Additional Components - Includes monitoring infrastructure and networked proving systems

All server infrastructure may undergo daily deployments to accommodate updates.

ArtefactDestinationReleaseInstallationUpdate
User
ExtensionChrome Web Storeperiodicstoreauto + enforce
SDKDevelopers' appuncontrollableuncontrollable
Developer
Smart Contracts packageSoldeerdailysoldeervlayer update
vlayer (cli + prover)GitHubdailyvlayerupvlayer update
SDKNpmdailynpm installvlayer update
foundryfoundryupfoundry upvlayer update
Chains
User's contractsBlockchainuncontrollable-uncontrollable
vlayer contractsBlockchaindaily--
vlayer infrastructure
user dashboardServerdaily--
vlayer proverServerdaily--
block cacheServerdaily--
notaryServerdaily--
web socket proxyServerdaily
monitoringServerdaily--
proving network (Bonsai)Serveruncontrollable

vlayer JSON-RPC API

vlayer exposes one RPC endpoint under / with the following methods:

  • v_prove
  • v_getProofReceipt
  • v_proveChain

With general format of request looking a follows.

 {
    "method": "<method name>",
    "params": [{
        "<params object>"
    }]
 }

And the response format below.

{
    "jsonrpc": "<version>",
    "result": {
        "<result object>"
    }
}

v_prove

v_prove is the core endpoint that vlayer provides, with the following format request:

{
    "method": "v_prove",
    "params": [{   
        "to": "<contract address>",
        "data": "0x<abi encoded calldata>",
        "chain_id": "<desired chain id>",
    }]
}

and the response:

{
    "jsonrpc": "0.2",
    "result": {
        "id": "<proving_hash>",
        "result": "<abi encoded result of preflight execution>"
    }
}

v_getProofReceipt

Query

To get result of v_prove query v_getProofReceipt.

{
    "method": "v_getProofReceipt",
    "params": [{   
        "id": "<proof request hash>",
    }]
}

There are three possible results: pending, success and error.

Pending

{
    "jsonrpc": "0.2",
    "status": "pending",
}

Success

{
    "jsonrpc": "0.2",
    "status": "success",
    "result": {        
        "proof": "<calldata encoded Proof structure>",
        "data": "<calldata encoded result of execution>",
        "block_no": "<hex encoded settlement block>"
    }
}

data is an ABI encoded result of the function execution and proof is a Solidity Proof structure to prepend in verifier function. Note that settlement block is only available in receipt, as we don't want to make assumption on when the the settlement block is assigned.

Error

{
  "jsonrpc": "0.2",
  "status": "error",
  "error": {
    "message": "<error message>",
  }
}

v_proveChain

Query

This call takes chain ID and an array of block numbers as an argument.

An example call could look like this:

{
  "method": "v_chain",
  "params": {
    "chain_id": 1,
    "block_numbers": [
      12_000_000,
      12_000_001,
      20_762_494, // This should be recent block that can be verified on-chain
    ]
  }
}

Success

It returns two things:

  • Sparse MPT that contains proofs for all block numbers passed as arguments.
  • 𝜋 - the zk-proof that the trie was constructed correctly (invariant that all the blocks belong to the same chain is maintained).
{
    "result": {
        "proof": "0x...", // ZK Proof
        "nodes": [
          "0x..." // Root node. It's hash is proven by ZK Proof
          "0x..." // Other nodes in arbitrary order
          ...
        ]
    }
}

Proof composition

Proof composition is explained in RISC Zero documentation, the verifiable computation tooling used by vlayer. For more details, refer to their resources:

This page aims to describe it from a practical perspective focusing on our use-case.

Usage

We use proof composition in Chain Proofs. The trie is correct if:

  • the previous trie was correct;
  • the operation executed is correct.

In order to verify first point - we need to verify a ZK proof (correctness of the previous step) from within a ZK proof (correctness of this step).

Implementation

Proofs that we store in the DB are bincode serialized Receipts.

Receipt contains:

  • Journal - proof output: Bytes
  • Inner receipt - polymorphic receipt
enum InnerReceipt {
    /// Linear size receipt. We don't use that
    Composite,
    /// Constant size STARK receipt
    Succinct,
    /// Constant size SNARK receipt
    Groth16,
    /// Fake receipt
    Fake,
}

In order to use one proof within another in ZKVM - we need to convert a Receipt into an Assumption. This is trivial as AssumptionReceipt implements From<Receipt>.

executor_env_builder.add_assumption(receipt.into());

Within Guest - one should use env::verify function:

use risc0_zkvm::guest::env;

env::verify(HELLO_WORLD_ID, b"journal".as_slice()).unwrap();

This function accepts guest ID, journal and not the proof as all the available proofs are stored within env.

Important Proof composition only works on Succinct proofs and not Groth16 proofs.

In Chain Proofs - we store all proofs as Succinct receipts. Chain Proof gets injected into Call Proof as Succinct receipt. In the end Call Proof gets converted into a Groth16 receipt to be verified in a Smart Contract