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)

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 })