Client SDK
The client SDK simplifies fetching proofs from the Relic Prover and generating transaction data to be submitted on-chain for verification. This page serves as a developer guide for using the client SDK.
You can also check out the full Typescript API documentation for the client SDK.
Installation
npm install --save-dev @relicprotocol/client ethers
Importing from the Client SDK
import { RelicClient } from '@relicprotocol/client'import { ethers } from 'ethers'
Or, using require
:
const { RelicClient } = require('@relicprotocl/client')const { ethers } = require('ethers')
Using RelicClient
L1 Configuration
A RelicClient
is initialized by passing ethers.Provider
objects. These Provider
s can be created via an RPC url or by connecting to Metamask or another wallet extension.
const provider = new ethers.providers.JsonRpcProvider('[RPC URL here]')// or if using a Metamask provider,// const provider = new ethers.providers.Web3Provider(web3.currentProvider)const relic = await RelicClient.fromProvider(provider)
RelicClient.fromProvider
attempts to determine the network from the provider
and configure itself with the appropriate Relic Prover backend and deployed contracts.
L2 Configuration
On Ethereum L2s, Relic provides access to L1 state on the L2. Hence, the RelicClient needs a provider for both the L1 (to generate proof data) and the L2 (to generate transaction data):
const l2Provider = new ethers.providers.JsonRpcProvider('[L2 RPC URL here]')// note that some L2s libraries expose other providers, which should work// as long as they implement ethers.Provider. For instance:// import { Provider } from "zksync-web3";// const l2Provider = new Provider("[L2 RPC URL here]");const l1Provider = new ethers.providers.JsonRpcProvider('[L1 RPC URL here]')const relic = await RelicClient.fromProviders(l2Provider, l1Provider)
Using State Proofs
Once you have a RelicClient
, you're ready to start proving historical state facts! For example, you can prove an accoun't birth certificate or a historical storage slot's value:
// prove a birth certificatelet bcProveCall = await relic.birthCertificateProver.prove({ account })// prove a historical storage slotlet ssProveCall = await relic.storageSlotProver.prove({ block: blockNum, account: contractAddr, slot: storageSlot,})
The values returned from the prove(...)
calls are ethers.PopulatedTransaction
objects. To sign and send this transaction to the network, use an ethers Signer
:
// this uses the provider's default signer, see the ethers docs for other signersconst signer = await provider.getSigner()let sent = await signer.sendTransaction(bcProveCall)// wait for it to be included on-chainawait sent.wait()
Alternatively, you could pass proveCall.data
to another smart contract which then calls the on-chain prover. See the Solidity SDK for more information on interacting with Relic on-chain, including how to query the fact data once it is proven on-chain.
Computing Storage Slots
You may be wondering how to know which storage slot corresponds to the state you want to prove. The Relic client SDK implements helper functions for computing storage slots given the base slot of some variable.
To find the base slot, the Solidity specificiation has rules for how state variables correspond to storage slots. Since version 0.5.13, solc
supports a --storage-layout
argument which outputs the base slot for each state variable in a contract. For older contracts, you can often simply copy only the contract's state variable to a new contract and use a newer solc
build to extract the storage layout. For example, to get the storage slot for WETH contract's balanceOf
map, we can copy the state variables to a skeleton contract:
// original contract had:// pragma solidity ^0.4.18;pragma solidity >=0.8.0;contract WETH9Storage { string public name = "Wrapped Ether"; string public symbol = "WETH"; uint8 public decimals = 18; mapping (address => uint) public balanceOf; mapping (address => mapping (address => uint)) public allowance; constructor() {}}
Using solc --storage-layout WETH9Storage.sol
, we find that balanceOf
is based at slot 3:
... { "astId": 14, "contract": "WETH9Storage.sol:WETH9Storage", "label": "balanceOf", "offset": 0, "slot": "3", "type": "t_mapping(t_address,t_uint256)" },...
Now, using the Relic client SDK, we can compute the storage slot for any account's WETH balance:
import { utils } from '@relicprotocol/client'const account = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' // vitalik.ethconst WETH_balanceOf_slot = 0x3const slot = utils.mapElemSlot(WETH_balanceOf_slot, account) // calculate WETH.balanceOf(account) slot
The SDK similarly supports calculating slots for static and dynamic array elements with utils.staticArrayElemSlot
and utils.dynamicArrayElemSlot
.
Checking Storage Slots
To avoid miscalculating storage slots (or to simply double-check the expected value), you can provide an expected
slot value to StorageSlotProver.prove()
. Continuing with the example above, we can find the expected value of WETH.balanceOf(account)
using a contract call:
const blockNum = 15000000const wethAddr = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' // WETH contract address// compute the expected value of balanceOf(account) at the target blockconst contract = new ethers.Contract( wethAddr, ['function balanceOf(address) external view returns (uint256)'], provider)const expected = await contract.balanceOf(account, { blockTag: blockNum })// Note: expected is an optional parameter// If provided, `storageSlotProver.prove()` will throw if the slot value doesn't matchconst proveCall = await relic.storageSlotProver.prove({ block: blockNum, account: wethAddr, slot, expected,})
Proving Many Slots
If your application needs to prove multiple storage slots from the same account in the same block, Relic provides two ways to potentially reduce gas costs.
Batch Proofs
If you know every slot you want to prove, you can batch them all into a single compressed proof using the multiStorageSlotProver
:
// prove two storage slots from the same account simultaneouslyconst mssTx = await relic.multiStorageSlotProver.prove({ block: blockNum, account: wethAddr, slots: [slot, slot2], expected: [expected, expected2],})
Caching Storage Roots
If you do not want to batch prove all the slots at once, you can still reduce your gas usage by caching the account's storage root on-chain. To do this, you first use the accountStorageProver
to prove the account's storage root in the block of interest, followed by any number of proofs using the cachedStorageSlotProver
:
// prove the storage root an account in a particular block,// potentially making slot proofs in that block much cheaperconst asTx = await relic.accountStorageProver.prove({ block: 15000000, account: wethAddr,})// prove storage rootlet tx = await signer.sendTransaction(asTx)// wait for confirmationawait tx.wait()// once the above transaction is confirmed, you can use cheap cached storage// slot proofs for that (account, block)const cssTx = await relic.cachedStorageSlotProver.prove({ block: blockNum, account: wethAddr, slot, expected,})// prove a storage slotawait signer.sendTransaction(cssTx)
Proving Log Emissions
Relic also supports proving log emissions from any block. Logs can be proven by simply providing an ethers.Log
object to relic.logProver
. Below is an example of how to fetch an interesting log event using ethers, and then use Relic to prove it on-chain.
// get BAYC mint events// NOTE: this may be very slow if your RPC provider doesn't index logs wellconst logs = await provider.getLogs({ address: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', // BAYC contract topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', // Transfer event '0x0000000000000000000000000000000000000000000000000000000000000000', // from == address(0) ], fromBlock: 0,})// prove the first BAYC mint log// NOTE: using proveEphemeral for log proofs is highly encouraged// see https://docs.relicprotocol.com/developers/client#ephemeral-facts for detailsconst logTx = await relic.logProver.proveEphemeral( { initiator: await signer.getAddress(), receiver, gasLimit: 50000, }, logs[0])
In general, proving data from log emissions will be cheaper (in terms of gas usage) than accessing data from storage, so be sure to consider if the data your app needs can be accessed this way.
Proving Transaction Inclusion
Not all useful historical data can be verified through storage slots or log emissions alone. Thus, Relic also enables proving that a transaction hash was included in a historical block:
// prove a transaction was includedconst receipt = await provider.getTransactionReceipt(txHash)const txProofTx = await relic.transactionProver.prove(receipt)
Note: This is merely proving that the transaction hash was included in the block, and does not prove that the transaction was successful. Provers for specific transaction fields (e.g. to
, value
, data
) and receipt data (e.g. success / failure status) is currently in development.
Other provers
Relic has been gradually rolling out new provers for different types of facts, including block headers. Check out the full set of supported provers in the Typescript docs.
Ephemeral facts
Sometimes a fact proven with Relic will only be used once, so we can save some gas by not storing the fact data on-chain. This is especially important for large facts, such as block headers or logs. There are two main ways to use ephemeral fact proofs.
Method 1: Implement RelicReceiver
If your contract implements IRelicReceiver
(see the Solidity SDK for more information), it can receive ephemeral facts from Relic via a callback. You can initiate this using the proveEphemeral()
function in the client SDK:
// Your contract which implements IRelicReceiver// Consider using the RelicReceiver base contract in the solidity SDKconst receiver = '0x...'// prove a historical block header is validconst bhTx = await relic.blockHeaderProver.proveEphemeral( { initiator: await signer.getAddress(), receiver, gasLimit: 50000, // 50000 gas is enough for our receiver callback, be sure to check yours! }, { block: 15000000 })// send the transactionawait signer.sendTransaction(bhTx)
Method 2: Directly call a prover
A contract can also directly pass a proof to a Relic prover contract and receive the fact data in return. This method can be more gas efficient than Method 1, but has more potential pitfalls to be aware of.
To potentially take advantage of future provers, it is recommended that you do not hardcode the prover address. Instead, you can accept a prove address dynamically, and verify that it is a valid registered prover and that it returns the correct type of fact. For example,
function ephemeralExample(address prover, bytes calldata proof) external payable { // check that it's a valid prover reliquary.checkProver(reliquary.provers(prover)); // ephemerally prove the fact, forwarding along any proving fee // passing store=false makes the fact ephemeral Fact memory fact = IProver(prover).prove{value: msg.value}( proof, false ); CoreTypes.BlockHeaderData memory head = abi.decode( fact.data, (CoreTypes.BlockHeaderData) ); require( FactSignature.unwrap(fact.sig) == FactSignature.unwrap(FactSigs.blockHeaderSig(head.Number)), "prover returned unexpected fact signature" );}
The prover address and proof data can be fetched from the client SDK as follows:
// fetch the data from relicconst { proof } = relic.blockHeaderProver.getProofData({ block: min.hash })const proverAddr = relic.blockHeaderProver.contract.addressconst fee = await relic.blockHeaderProver.fee()// pass the data to your contractawait myContract.ephemeralExample(prover, proof, { value: fee })