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 Providers 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 certificate
let bcProveCall = await relic.birthCertificateProver.prove({ account })
// prove a historical storage slot
let 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 signers
const signer = await provider.getSigner()
let sent = await signer.sendTransaction(bcProveCall)
// wait for it to be included on-chain
await 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.eth
const WETH_balanceOf_slot = 0x3
const 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 = 15000000
const wethAddr = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' // WETH contract address
// compute the expected value of balanceOf(account) at the target block
const 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 match
const 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 simultaneously
const 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 cheaper
const asTx = await relic.accountStorageProver.prove({
block: 15000000,
account: wethAddr,
})
// prove storage root
let tx = await signer.sendTransaction(asTx)
// wait for confirmation
await 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 slot
await 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 well
const 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 details
const 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 included
const 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 SDK
const receiver = '0x...'
// prove a historical block header is valid
const 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 transaction
await 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 relic
const { proof } = relic.blockHeaderProver.getProofData({ block: min.hash })
const proverAddr = relic.blockHeaderProver.contract.address
const fee = await relic.blockHeaderProver.fee()
// pass the data to your contract
await 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 hash
const 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 tx
await relic.bridge.waitUntilBridged(block)
// now data from the recent L1 block may be proven on the L2 as usual
await 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.