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:
- Prover and Verifier contracts are working.
- Global Variables are set.
- Tests are run.
Appendix
References for:
Installation
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 generatesremappings.txt
. Runningforge 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:
- 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.
- 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.
- 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.
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
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
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 executedblockNo
, 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
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?
-
Setup the
Prover
contract:WebProofProver
inherits from theProver
contract, enabling off-chain proving of web data.- The
main
function receives aWebProof
, which contains a signed transcript of an HTTPS session (see the chapter from JS section on how to obtainWebProof
Security Considerations section for details about the TLS Notary).
-
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.
-
Extract the relevant data:
web.jsonGetString("screen_name")
extracts thescreen_name
from the JSON response. -
Return the results:
If everything checks out, the function returns the
proof
placeholder,screenName
, and theaccount
.
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?
-
Set up the
Verifier
:- The
prover
variable stores the address of theProver
contract that generated the proof. - The
WebProofProver.main.selector
gets the selector for theWebProofProver.main()
function. WebProofVerifier
inherits fromVerifier
to access theonlyVerified
modifier, which ensures the proof is valid.WebProofVerifier
also inherits fromERC721
to support NFTs.
- The
-
Verification checks:
The
tokenId
(a hash of the handle) must not already be minted. -
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.
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 emailbody
- a string consisting of the entire body of the emailfrom
- 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 otherwisematch
- matches RegExp pattern groups and returns them as a stringequal
- 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 fromProver
to obtain super powers of off-chain proving.main
function takesmultisigAddr
argument to access Multisig Wallet smart contract data.parseSubject
parses email subject and returns address of lost walletemail.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 addressemail.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
, whichProver
contract to verify:- The
PROVER_ADDR
constant holds the address of theProver
contract that generated the proof. - The
PROVER_FUNC_SELECTOR
constant holds the selector for theProver.main()
function. MultiSigWallet
inherits fromVerifier
, so we can call theonlyVerified
modifier that makes sure the proof is correct or it will revert otherwise.
- The
-
Next, we add two fields that an example MultiSig might use:
- The
owners
mapping holds addresses that can useMultiSigWallet
. - The
ownerToEmailHash
mapping holds hashes of email addresses associated with owners.
- The
-
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 byProver.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 toonlyVerified
because it is automatically extracted frommsg.data
. ownerToEmailHash[lostWallet] == emailAddrHash
make sure recovery email address matches the one that was set up previously in the walletowners[newOwner] = true
sets up a new wallet to be authorized to useMultiSigWallet
.
- The
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
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 returnsint256
.jsonGetBool
: Extracts a boolean value and returnsbool
.jsonGetString
: Extracts a string value and returnsstring 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 returnstrue
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
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-chainVerifier
contract. Unlike the on-chain contract, theProver
does not have access to the current block. It can only access previously mined blocks. Under the hood, vlayer generates zero-knowledge proofs of theProver
'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 withVerifier
arguments. PlaceholderProof
returned byProver
is created by its methodproof()
, 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
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:
Prover
contract address- 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
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 ifblockNumber
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)
: Returns0x0000000000000000000000000000000000000000
.
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
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
andsetChain
- 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 viagetProof
.getProof()
: Retrieves the proof from the last call after usingcallProver
.
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 maliciousProver
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
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 contractproverAbi
- abi of prover contractfunctionName
- name of prover contract function to callargs
- an array of arguments tofunctionName
prover contract functionchainId
- 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
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
:
startPage
- redirects the user's browser tohttps://x.com/i/flow/login
.expectUrl
- ensures that the user is logged in and visitinghttps://x.com/home
URL.notarize
- prompts the user to generate a Web Proof, i.e. to notarize an HTTPGET
request sent tohttps://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
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.
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:
- Rust compiler
- Rust risc-0 toolchain
- Foundry
- Bun
- LLVM Clang compiler version which supports RISC-V build target available on the
PATH
timeout
terminal command (brew install coreutils
on macOS)
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
- Apply changes to the code
- Run
bun changeset
- Submit information about your changes (would be visible in the changelog)
- Run
bun changeset version
- Commit modified files changes
- 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)
- on-chain smart contracts - used to verify proofs
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 toguest
- guest_wrapper - (in
guest_wrapper
) - Compiles therisc0_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.
- guest - (in
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.
Databases
We have two different databases run in two different places. Each is a composite database:
- Host - runs
ProofDb
, which proxies queries toProviderDb
. 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 toStateDb
.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 theStateDb
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 forStateDb
that implementsDatabase
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 actualstate_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. TheHost
'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 intoGuestInput
and all output is serialized intoGuestOutput
. TheGuest
's main purpose is to parse input and run logic fromEngine
. -
The
Engine
consists of shared logic between theHost
and theGuest
. In theHost
, it is used to run preflight and in theGuest
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
createsDatabases
andHeaders
dynamically, utilizing Providers created fromMultiProvider
, 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
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.
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.
- It needs to be verified that all the hashes are part of the Block Proof Cache structure.
- 𝜋 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.
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[])
.
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
- First try to use
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 theProver
as the first returned element (more on that here), which means thatProof
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 toonlyVerified
because it is automatically extracted frommsg.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>, } }
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.
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
andCallAssumptions
structures are generated based on Solidity code from withsol!
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 eachProver
contract, but only on development and test networks. This mode will be used if theProofMode
decoded fromSEAL
isFAKE
.PRODUCTION
- This requires infrastructure deployed ahead of time that performs actual verification. This mode will be used if theProofMode
decoded fromSEAL
isGROTH16
.
Deployment and Release
Environments
The process of releasing vlayer spans across four environments:
- User Environment - web browsers
- Developer Environment - Tools and libraries on the developer's local machine
- vlayer Node Infrastructure - Consists of various server types
- Blockchain Networks - Smart contracts deployed across multiple chains
The diagram below illustrates these environments, along with associated artifacts and key interdependencies:
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
andtest
flags, tightly integrated with Foundry - With
prover
, an optional dependency for local development
- With
- Local Development SDK
- vlayer Smart Contracts - Managed via Soldeer
- Foundry - An external dependency requiring updates synchronized with vlayer to:
- Ensure
test
andinit
commands operate in the same directory asforge
and other tools - Support the latest REVM (Rust Ethereum Virtual Machine) changes, including hard-fork and network compatibility
- Ensure
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.
Artefact | Destination | Release | Installation | Update |
---|---|---|---|---|
User | ||||
Extension | Chrome Web Store | periodic | store | auto + enforce |
SDK | Developers' app | uncontrollable | uncontrollable | |
Developer | ||||
Smart Contracts package | Soldeer | daily | soldeer | vlayer update |
vlayer (cli + prover) | GitHub | daily | vlayerup | vlayer update |
SDK | Npm | daily | npm install | vlayer update |
foundry | foundryup | foundry up | vlayer update | |
Chains | ||||
User's contracts | Blockchain | uncontrollable | - | uncontrollable |
vlayer contracts | Blockchain | daily | - | - |
vlayer infrastructure | ||||
user dashboard | Server | daily | - | - |
vlayer prover | Server | daily | - | - |
block cache | Server | daily | - | - |
notary | Server | daily | - | - |
web socket proxy | Server | daily | ||
monitoring | Server | daily | - | - |
proving network (Bonsai) | Server | uncontrollable |
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: BytesInner 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