Verifying log entries
This guide shows how to verify log entries from a specific chain on a different chain in an interop cluster. This lets you use interoperability with applications on a different chain that was never written with interoperability in mind.
To demonstrate this functionality, we'll use an attestation (opens in a new tab) from one chain in another.
Overview
About this tutorial
What you'll learn
- How to verify log messages from one blockchain on another.
- How to use the EAS SDK to attest for facts and add schemas from offchain code.
Technical knowledge
- Intermediate JavaScript knowledge
- Understanding of smart contract development
- Familiarity with blockchain concepts
Development environment
- Unix-like operating system (Linux, macOS, or WSL for Windows)
- Node.js version 16 or higher
- Git for version control
Required tools
The tutorial uses these primary tools:
- Node: For running TypeScript code from the command line
- Viem: For blockchain interaction
- Foundry: For smart contract development
What you'll build
- A JavaScript program to create an attestation and then verify it on a different chain.
- A Solidity contract that can verify attestations onchain.
Directions
Preparation
-
If you are using Supersim, setup the SuperchainERC20 starter kit. The
pnpm dev
step also starts Supersim. -
Store the configuration in environment variables.
export PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 URL_CHAIN_A=http://127.0.0.1:9545 URL_CHAIN_B=http://127.0.0.1:9546 export CHAIN_B_ID=`cast chain-id --rpc-url $URL_CHAIN_B`
Create an attestation
There are already attestations in the production chains. However, that may not be the case in the devnets, and it is definitely not the case in the Supersim instance you just started.
So the first step is to actually send an attestation to one of the chains.
-
Create a new JavaScript project.
mkdir -p verify-messages/offchain cd verify-messages/offchain npm init -y npm install @eth-optimism/viem @ethereum-attestation-service/eas-sdk ethers viem
-
Create a file,
attest.mjs
.import { EAS, NO_EXPIRATION, SchemaEncoder, SchemaRegistry, } from "@ethereum-attestation-service/eas-sdk" import { createWalletClient, http, publicActions, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { JsonRpcProvider, Wallet } from "ethers" import { interopAlpha0, supersimL2A } from '@eth-optimism/viem/chains' // Contract addresses in all OP Stack blockchains const EASContractAddress = "0x4200000000000000000000000000000000000021" const schemaRegistryContractAddress = "0x4200000000000000000000000000000000000020" // Turn a viem wallet into an ethers provider const walletClientToSigner = walletClient => { const chain = walletClient.chain // Get the RPC URL from the chain config (or supply your own) const rpcUrl = chain.rpcUrls?.default?.http?.[0] if (!rpcUrl) { throw new Error('RPC URL not found in chain configuration') } // Create a provider for the given chain const provider = new JsonRpcProvider(rpcUrl, chain.id) const signer = new Wallet(process.env.PRIVATE_KEY, provider) return signer } // Initialize the sdk with the address of the EAS Schema contract address const eas = new EAS(EASContractAddress) const schemaRegistry = new SchemaRegistry(schemaRegistryContractAddress) const account = privateKeyToAccount(process.env.PRIVATE_KEY) const useSupersim = process.env.CHAIN_B_ID == 902 const wallet0 = createWalletClient({ chain: useSupersim ? supersimL2A : interopAlpha0, transport: http(), account }).extend(publicActions) // Turn a viem wallet into an ethers provider const signer0 = await walletClientToSigner(wallet0) schemaRegistry.connect(signer0) eas.connect(signer0) const schema = "string name"; let schemaTxn, schemaUID // Register the schema if needed, and get the schemaUID. try { schemaTxn = await schemaRegistry.register({schema}) schemaUID = await schemaTxn.wait() } catch (err) { // Schema is already registered if (err.info.error.data == "0x23369fa6") schemaUID = "0x234dee4d3e6a625b4121e2042d6267058755e53a2ecc55555da51a1e6f06cc58" } const schemaEncoder = new SchemaEncoder(schema) const attestedData = schemaEncoder.encodeData([ { name: "name", value: "Bill Hamm", type: "string" } ]); const attestTxn = await eas.attest({ schema: schemaUID, data: { recipient: "0x0123456789012345678901234567890123456789", expirationTime: NO_EXPIRATION, revocable: true, // Be aware that if your schema is not revocable, this MUST be false data: attestedData, }, }) // To get the attestation ID we'd use // const attestationID = await transaction2.wait() // However, here we need the attestation transaction's hash. const request = await wallet0.prepareTransactionRequest(attestTxn.data) const serializedTransaction = await wallet0.signTransaction(request) const attestHash = await wallet0.sendRawTransaction({ serializedTransaction }) console.log(`export ATTEST_TXN=${attestHash}`)
Explanation
// Turn a viem wallet into an ethers provider const walletClientToSigner = walletClient => { const chain = walletClient.chain // Get the RPC URL from the chain config (or supply your own) const rpcUrl = chain.rpcUrls?.default?.http?.[0] if (!rpcUrl) { throw new Error('RPC URL not found in chain configuration') } // Create a provider for the given chain const provider = new JsonRpcProvider(rpcUrl, chain.id) const signer = new Wallet(process.env.PRIVATE_KEY, provider) return signer }
The EAS SDK (opens in a new tab) we use for attestations uses Ethers (opens in a new tab) rather than Viem (opens in a new tab). This function lets us use a Viem wallet (opens in a new tab) as an Ethers signer (opens in a new tab).
const schema = "string name"; let schemaTxn, schemaUID // Register the schema if needed, and get the schemaUID. try { schemaTxn = await schemaRegistry.register({schema}) schemaUID = await schemaTxn.wait() } catch (err) { // Schema is already registered if (err.info.error.data == "0x23369fa6") schemaUID = "0x234dee4d3e6a625b4121e2042d6267058755e53a2ecc55555da51a1e6f06cc58" }
Register the EAS Schema (opens in a new tab) if necessary. This schema ties Ethereum addresses to names.
const schemaEncoder = new SchemaEncoder(schema) const attestedData = schemaEncoder.encodeData([ { name: "name", value: "Bill Hamm", type: "string" } ]); const attestTxn = await eas.attest({ schema: schemaUID, data: { recipient: "0x0123456789012345678901234567890123456789", expirationTime: NO_EXPIRATION, revocable: true, // Be aware that if your schema is not revocable, this MUST be false data: attestedData, }, })
Attest (opens in a new tab) that the name for Ethereum address
0x0123456789012345678901234567890123456789
isBill Hamm
.// To get the attestation ID we'd use // const attestationID = await transaction2.wait() // However, here we need the attestation transaction's hash.
This is the recommended way to execute the attestation transaction, which provides you with the attestation's UID. However, for our purpose we need the transaction receipt, which you do not get. We can either use this and search for the log entry or just use the EAS SDK to create the transaction and send it ourselves. Here we use the second solution.
const request = await wallet0.prepareTransactionRequest(attestTxn.data) const serializedTransaction = await wallet0.signTransaction(request) const attestHash = await wallet0.sendRawTransaction({ serializedTransaction })
Here we send the attestation transaction created by the EAS SDK ourselves (using viem's
sendRawTransaction
(opens in a new tab)), so we'll have the transaction hash. -
Run the attestation program.
node attest.mjs
-
The program output includes an
export ATTEST_TXN
line. Run it to inform our verification code where to find the attestation.
Offchain verification
The next step is to call CrossL2Inbox.verifyMessage
.
For testing purposes, we'll start by calling it offchain.
-
Create a file,
verify-attestation.mjs
.import { createWalletClient, http, publicActions, getContract, keccak256, toBytes, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { interopAlpha0, interopAlpha1, supersimL2A, supersimL2B } from '@eth-optimism/viem/chains' import { walletActionsL2, publicActionsL2, crossL2InboxAbi } from '@eth-optimism/viem' // Contract addresses in all OP Stack blockchains const EASContractAddress = "0x4200000000000000000000000000000000000021" const account = privateKeyToAccount(process.env.PRIVATE_KEY) const useSupersim = process.env.CHAIN_B_ID == 902 const wallet0 = createWalletClient({ chain: useSupersim ? supersimL2A : interopAlpha0, transport: http(), account }).extend(publicActions) .extend(publicActionsL2()) const wallet1 = createWalletClient({ chain: useSupersim ? supersimL2B : interopAlpha1, transport: http(), account }).extend(publicActions) .extend(walletActionsL2()) let receipt try { receipt = await wallet0.getTransactionReceipt({ hash: process.env.ATTEST_TXN }) } catch(err) { console.log(`Verification failed, there is no ${process.env.ATTEST_TXN} transaction on the source chain`) process.exit(0) } const attestLogEntry = receipt.logs.filter(x => (x.address == EASContractAddress) && (x.topics[0] == "0x8bf46bf4cfd674fa735a3d63ec1c9ad4153f033c290341f3a588b75685141b35"))[0] // attestLogEntry.topics[1] = "0x8bf46bf4cfd674fa735a3d63ec1c9ad4153f033c290341f3a588b75685141b34" const relayMessageParams = await wallet0.interop.buildExecutingMessage({ log: attestLogEntry, }) const crossL2Inbox = getContract({ address: '0x4200000000000000000000000000000000000022', abi: crossL2InboxAbi, client: wallet1, }) let executingTransaction, executingTransactionReceipt try { executingTransaction = await crossL2Inbox.write.validateMessage( { args: [ relayMessageParams.id, keccak256(toBytes(relayMessageParams.payload)) ], accessList: relayMessageParams.accessList, } ) } catch (err) { console.log("Verification failed (revert)") process.exit(0) } try { executingTransactionReceipt = await wallet1.waitForTransactionReceipt({ hash: executingTransaction, timeout: 10_000 }) } catch (err) { console.log("Verification failed (timeout)") process.exit(0) } const verified = executingTransactionReceipt.logs.filter( x => x.address=="0x4200000000000000000000000000000000000022" && x.topics[0] == "0x5c37832d2e8d10e346e55ad62071a6a2f9fa5130614ef2ec6617555c6f467ba7" && x.topics[1] == keccak256(toBytes(relayMessageParams.payload)) ).length > 0 if (verified) { console.log("Verification successful") } else { console.log("Verification failed") }
Explanation
try { receipt = await wallet0.getTransactionReceipt({ hash: process.env.ATTEST_TXN }) } catch(err) { console.log(`Verification failed, there is no ${process.env.ATTEST_TXN} transaction on the source chain`) process.exit(0) } const attestLogEntry = receipt.logs.filter(x => (x.address == EASContractAddress) && (x.topics[0] == "0x8bf46bf4cfd674fa735a3d63ec1c9ad4153f033c290341f3a588b75685141b35"))[0]
To create the executing message, we need several fields that appear in the log entry we are verifying. This is how we obtain the relevant log entry.
The first topic in a log entry emitted by Solidity is the event type. This event type (opens in a new tab) is emitted when an attestation is created. Of course, we only care about attestations created in the official attestations contract.
// attestLogEntry.topics[1] = "0x8bf46bf4cfd674fa735a3d63ec1c9ad4153f033c290341f3a588b75685141b34"
Part of testing a system is ensuring that it does not accept invalid input. You can uncomment this line to see that the verification fails with an incorrect log entry.
const relayMessageParams = await wallet0.interop.buildExecutingMessage({ log: attestLogEntry, })
Build the executing message (opens in a new tab).
try { executingTransaction = await crossL2Inbox.write.validateMessage( { args: [ relayMessageParams.id, keccak256(toBytes(relayMessageParams.payload)) ], accessList: relayMessageParams.accessList, } ) } catch (err) { console.log("Verification failed (revert)") process.exit(0) }
Call
CrossL2Inbox.validateMessage
(opens in a new tab). For some invalid inputs this function reverts, which means that the log entry requested is not validated.try { executingTransactionReceipt = await wallet1.waitForTransactionReceipt({ hash: executingTransaction, timeout: 10_000 }) } catch (err) { console.log("Verification failed (timeout)") process.exit(0) }
Wait for the transaction receipt for the executing message. Note the timeout, it is necessary because if the executing message is valid, but the log entry has not been seen yet the sequencer does not put the transaction with the message into a block.
const verified = executingTransactionReceipt.logs.filter( x => x.address=="0x4200000000000000000000000000000000000022" && x.topics[0] == "0x5c37832d2e8d10e346e55ad62071a6a2f9fa5130614ef2ec6617555c6f467ba7" && x.topics[1] == keccak256(toBytes(relayMessageParams.payload)) ).length > 0
Look for the log entry from
CrossL2Inbox
. To ensure this is the correct log entry, we look at the message type (opens in a new tab) and check that the ID is the correct one. -
Execute this program.
node verify-attestation.mjs
Onchain verification
The code in the above example is not very useful, because it is running offchain. Offchain code can talk to any chain it wants, so it can verify the attestation directly on the chain where it originated. The real value of interop is when you have onchain code from one chain verify information from another chain. This is what we'll build now.
The access list of the transaction has to be calculated offchain, so we might as well just use the same interop.buildExecutingMessage
function and just use relayParams
to see what exactly we are validating.
Value | Location | Sample value |
---|---|---|
Event signature (opens in a new tab) | relayMessageParams.payload.slice(2,66) | 8bf46bf4cfd674fa735a3d63ec1c9ad4153f033c290341f3a588b75685141b35 |
Recipient address | relayMessageParams.payload.slice(90,130) | 0123456789012345678901234567890123456789 |
Attester address | relayMessageParams.payload.slice(154,194) | f39fd6e51aad88f6f4ce6ab8827279cfffb92266 |
Schema | relayMessageParams.payload.slice(194,258) | 234dee4d3e6a625b4121e2042d6267058755e53a2ecc55555da51a1e6f06cc58 |
Attestation ID | relayMessageParams.payload.slice(194,258) | c88cbbc15b9fb4aa12f70c9c97e6c1dd733a29db2816142809504718d615ff9f |
Log entry origin | relayMessageParams.id.origin | 0x4200000000000000000000000000000000000021 |
Log entry location | relayMessageParams.id.logIndex | 0n |
Block number | relayMessageParams.id.blockNumber | 6746n |
Timestamp | relayMessageParams.id.timestamp | 1747518156n |
Chain Id | relayMessageParams.id.chainId | 901n |
The attestation ID itself is also a hash (opens in a new tab), but luckily most of the fields are either unused (such as revocationTime
) or are already known to us (such as the schema).
The sole exception is the data, which in our case includes the name being attested to.
We can provide this data to the Solidity code as a parameter.
-
Create a new Foundry project.
mkdir ../onchain cd ../onchain forge init
-
Add the EAS contracts (opens in a new tab) and Optimism contract to the project.
cd lib npm install @ethereum-attestation-service/eas-contracts cd .. echo @ethereum-attestation-service/=lib/node_modules/@ethereum-attestation-service/ >> remappings.txt
-
Create
src/Verifier.sol
.// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Attestation} from "@ethereum-attestation-service/eas-contracts/contracts/Common.sol"; // Code from https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/interfaces/L2/ICrossL2Inbox.sol, // which is not in the npm package yet so we copy it. struct Identifier { address origin; uint256 blockNumber; uint256 logIndex; uint256 timestamp; uint256 chainId; } interface ICrossL2Inbox { function validateMessage(Identifier calldata _id, bytes32 _msgHash) external; } contract Verifier { bytes32 private constant SCHEMA_UID = 0x234dee4d3e6a625b4121e2042d6267058755e53a2ecc55555da51a1e6f06cc58; uint256 private constant EVENT_SIG = 0x8bf46bf4cfd674fa735a3d63ec1c9ad4153f033c290341f3a588b75685141b35; address private constant EAS_CONTRACT = 0x4200000000000000000000000000000000000021; address private constant CROSS_L2_INBOX = 0x4200000000000000000000000000000000000022; /// @dev Calculates a UID for a given attestation. /// @param attestation The input attestation. /// @param bump A bump value to use in case of a UID conflict. /// @return Attestation UID. function _getUID(Attestation memory attestation, uint32 bump) private pure returns (bytes32) { return keccak256( abi.encodePacked( attestation.schema, attestation.recipient, attestation.attester, attestation.time, attestation.expirationTime, attestation.revocable, attestation.refUID, attestation.data, bump ) ); } function makePayloadHash( address recipient, address attester, bytes32 attestationID ) pure private returns (bytes32) { return keccak256( abi.encode( EVENT_SIG, recipient, attester, SCHEMA_UID, attestationID ) ); } function verifyAttestation( address recipient, address attester, uint256 logIndex, uint256 blockNumber, uint64 timestamp, uint256 chainId, string memory name ) public { Attestation memory attestation; attestation.schema = SCHEMA_UID; attestation.recipient = recipient; attestation.attester = attester; attestation.revocable = true; attestation.time = timestamp; attestation.data = abi.encode(name); bytes32 attestationUID = _getUID(attestation, 0); bytes32 payloadHash = makePayloadHash(recipient, attester, attestationUID); Identifier memory logEntryIdentifier; logEntryIdentifier.origin = EAS_CONTRACT; logEntryIdentifier.blockNumber = blockNumber; logEntryIdentifier.logIndex = logIndex; logEntryIdentifier.timestamp = timestamp; logEntryIdentifier.chainId = chainId; // Signal that this is a cross chain call that needs to have the identifier validated ICrossL2Inbox(CROSS_L2_INBOX).validateMessage(logEntryIdentifier, payloadHash); // Code that uses the attestation goes here } }
Explanation
// Code from https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/interfaces/L2/ICrossL2Inbox.sol, // which is not in the npm package yet so we copy it. struct Identifier { address origin; uint256 blockNumber; uint256 logIndex; uint256 timestamp; uint256 chainId; } interface ICrossL2Inbox { function validateMessage(Identifier calldata _id, bytes32 _msgHash) external; }
At writing these definitions from
ICrossL2Inbox
(opens in a new tab) are not yet part of the npm package (opens in a new tab), so we include them here for now./// @dev Calculates a UID for a given attestation. /// @param attestation The input attestation. /// @param bump A bump value to use in case of a UID conflict. /// @return Attestation UID. function _getUID(Attestation memory attestation, uint32 bump) private pure returns (bytes32) { return keccak256( abi.encodePacked( attestation.schema, attestation.recipient, attestation.attester, attestation.time, attestation.expirationTime, attestation.revocable, attestation.refUID, attestation.data, bump ) ); }
This function is part of the EAS contract code (opens in a new tab) that calculates the attestation ID.
function makePayloadHash( address recipient, address attester, bytes32 attestationID ) pure private returns (bytes32) { return keccak256( abi.encode( EVENT_SIG, recipient, attester, SCHEMA_UID, attestationID ) ); }
Calculate the payload hash.
function verifyAttestation( address recipient, address attester, uint256 logIndex, uint256 blockNumber, uint64 timestamp, uint256 chainId, string memory name ) public { Attestation memory attestation; attestation.schema = SCHEMA_UID; attestation.recipient = recipient; attestation.attester = attester; attestation.revocable = true; attestation.time = timestamp; attestation.data = abi.encode(name); bytes32 attestationUID = _getUID(attestation, 0); bytes32 payloadHash = makePayloadHash(recipient, attester, attestationUID); Identifier memory logEntryIdentifier; logEntryIdentifier.origin = EAS_CONTRACT; logEntryIdentifier.blockNumber = blockNumber; logEntryIdentifier.logIndex = logIndex; logEntryIdentifier.timestamp = timestamp; logEntryIdentifier.chainId = chainId; // Signal that this is a cross chain call that needs to have the identifier validated ICrossL2Inbox(CROSS_L2_INBOX).validateMessage(logEntryIdentifier, payloadHash);
This is the function that gets the attestation information, builds the call, and calls
CrossL2Inbox
.// Code that uses the attestation goes here
Here you would do anything that requires the attesattion to have been verified. There is nothing here because it is sample code, but in a real world application this would be the most important part.
-
Deploy the new contract and preserve the address.
export VERIFIER_ADDRESS=`forge create Verifier --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY --broadcast | awk '/Deployed to:/ {print $3}'`
-
You can try to just call
verifyAttestation
directly, but without the correct access list it is guaranteed to fail. Instead, get back to the offchain part of the project.cd ../offchain
-
Create a file,
onchain-verification.mjs
.import { createWalletClient, http, publicActions, getContract, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { interopAlpha0, interopAlpha1, supersimL2A, supersimL2B } from '@eth-optimism/viem/chains' import { walletActionsL2, publicActionsL2 } from '@eth-optimism/viem' import { readFile } from 'fs/promises'; async function loadVerifierAbi() { const data = await readFile('../onchain/out/Verifier.sol/Verifier.json') return JSON.parse(data); } const verifierAbi = (await loadVerifierAbi()).abi // Contract addresses in all OP Stack blockchains const EASContractAddress = "0x4200000000000000000000000000000000000021" const account = privateKeyToAccount(process.env.PRIVATE_KEY) const useSupersim = process.env.CHAIN_B_ID == 902 const wallet0 = createWalletClient({ chain: useSupersim ? supersimL2A : interopAlpha0, transport: http(), account }).extend(publicActions) .extend(publicActionsL2()) const wallet1 = createWalletClient({ chain: useSupersim ? supersimL2B : interopAlpha1, transport: http(), account }).extend(publicActions) .extend(walletActionsL2()) let receipt try { receipt = await wallet0.getTransactionReceipt({ hash: process.env.ATTEST_TXN }) } catch(err) { console.log(`Verification failed, there is no ${process.env.ATTEST_TXN} transaction on the source chain`) process.exit(0) } const attestLogEntry = receipt.logs.filter(x => (x.address == EASContractAddress) && (x.topics[0] == "0x8bf46bf4cfd674fa735a3d63ec1c9ad4153f033c290341f3a588b75685141b35"))[0] const relayMessageParams = await wallet0.interop.buildExecutingMessage({ log: attestLogEntry, }) const verifier = getContract({ address: process.env.VERIFIER_ADDRESS, abi: verifierAbi, client: wallet1, }) const verificationTransaction = await verifier.write.verifyAttestation({ args: [ "0x" + relayMessageParams.payload.slice(90,130), "0x" + relayMessageParams.payload.slice(154,194), relayMessageParams.id.logIndex, relayMessageParams.id.blockNumber, relayMessageParams.id.timestamp, relayMessageParams.id.chainId, "Bill Hamm" ], accessList: relayMessageParams.accessList }) console.log(`VERIFICATION_TRANSACTION_HASH=${verificationTransaction}`)
Explanation
This is a modified version of the
verify-attestation.mjs
we used earlier, so we only go over the new parts.import { readFile } from 'fs/promises'; async function loadVerifierAbi() { const data = await readFile('../onchain/out/Verifier.sol/Verifier.json') return JSON.parse(data); } const verifierAbi = (await loadVerifierAbi()).abi
Read the ABI for
Verifier
we created onchain.const verifier = getContract({ address: process.env.VERIFIER_ADDRESS, abi: verifierAbi, client: wallet1, }) const verificationTransaction = await verifier.write.verifyAttestation({ args: [ "0x" + relayMessageParams.payload.slice(90,130), "0x" + relayMessageParams.payload.slice(154,194), relayMessageParams.id.logIndex, relayMessageParams.id.blockNumber, relayMessageParams.id.timestamp, relayMessageParams.id.chainId, "Bill Hamm" ], accessList: relayMessageParams.accessList })
Call
Verifier
with the information needed to verify the attestation.console.log(`VERIFICATION_TRANSACTION_HASH=${verificationTransaction}`)
Report the transaction hash.
-
Run the verification program.
node onchain-verification.mjs
-
The program output includes a
VERIFICATION_TRANSACTION_HASH=
line. Run it to keep track of the verification transaction. -
See the receipt for the verification transaction.
cast receipt $VERIFICATION_TRANSACTION_HASH --rpc-url $URL_CHAIN_B
-
Modify the name in line 73 of
onchain-verification.mjs
and see that false attestations don't get verified.
Next steps
- Read the Superchain Interop Explainer or check out this Superchain interop design video walk-thru (opens in a new tab).
- Use Supersim, a local dev environment that simulates Superchain interop for testing applications against a local version of the Superchain.
- Find a cool dapp that only works on a single blockchain and extend it to the entire interop cluster, at least for reading information.