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)
On some L2s (such as Optimism and Base), Relic supports access to historical L2 data from a limited number of historical blocks (for more information on this, see the L2 Native Data section). For this mode, the RelicClient
can be initialized with only an L2 provider:
const relic = await RelicClient.fromProvider(l2Provider)
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 })
Recent L1 Data on L2s
Relic imports L1 data into L2s in chunks of 8192 blocks, meaning that recent L1 data may not be immediately accessible on L2s. However, the client SDK provides a way to submit an L1 transaction which bridges a block's data to the target L2, immediately granting access to that blocks data on the L2.
// `blockNum` is some recent L1 block you want to be verifiable on the L2// recent blocks must be queried as a hash// if you only have the block number, you must first fetch its hashconst block = await l1Provider.getBlock(blockNum).then(b => b.hash)// Note: you may want to verify this block hash has the data you need// (i.e. the original block was not reorged such that it no longer contains your data)// bridge the block hash (verifies the block hash on L1, and sends a trustless L1->L2 message)await l1Signer.sendTransaction( await relic.bridge.sendBlock(block), {} // optional gas configuration goes here)// Wait for the block data to be received on L2// Note: this may take a few minutes as it waits for the L2 to confirm the L1->L2 txawait relic.bridge.waitUntilBridged(block)// now data from the recent L1 block may be proven on the L2 as usualawait relic.storageSlotProver.prove({block, account, slot})
L2 Native Data
On some L2s (currently only Optimism and Base), Relic can access historical data from a limited number of blocks. Supported blocks include:
- Blocks explicitly committed on-chain while they are recent (< 256 blocks old)
- "Checkpoint" blocks, i.e. L2 blocks commited and finalized on the L1 (via the L2OutputOracle)
Committing Recent Blocks
To commit a recent block, anyone can simply call the commitRecent method of the L2's BlockHistory
contract. Initiating this call from the client SDK is easy:
const commitTx = await relic.blockHistory.commitRecent(recentBlockNum)await l2Signer.sendTransaction(commitTx)
Note that it is important to ensure the transaction is included while the block is still recent (< 256 blocks old). Once a block is committed, data from that block can be accessed at any point in the future.
Importing L2 Checkpoint Blocks
To import an L2 block from a checkpoint, first choose a block number corresponding to a checkpoint. These can be found by querying the L2OutputOracle
contract configuration: valid checkpoint blocks are startingBlockNumber + n * SUBMISSION_INTERVAL
for some n >= 0
. Note that only finalized checkpoints can be imported to L2, meaning that the checkpoint block must have been submitted at least FINALIZATION_PERIOD_SECONDS
ago.
Extracting the L2 block from the L2OutputOracle
requires access to L1 data, so generating this transaction also requires passing an L1 provider:
const importTx = await relic.blockHistory.importBlockhashFromOutputRoot( l2CheckpointBlockNumber, l1Provider)await l2Signer.sendTransaction(importTx)
Once the transaction is confirmed, data from the checkpoint block can be accessed as usual.