Cross-Chain Reputation Oracle

Build a cross-chain reputation protocol using Wormhole messaging on testnets.

Introduction

30 Second Summary

Your reputation on one platform means nothing on another. The same problem exists in blockchain. A user's activity on one chain is completely invisible to applications running on a different chain.

In this project, you will build a cross-chain reputation oracle that broadcasts on-chain reputation scores from Arbitrum Sepolia to Base Sepolia using Wormhole's Executor messaging framework.

What You'll Build

A cross-chain reputation protocol with smart contracts deployed on two testnets, a read-only Next.js dashboard displaying live on-chain data, and a CI/CD pipeline with Docker containerization.

By the end of this project, you'll have:

  • A ReputationHub smart contract deployed on both Arbitrum Sepolia and Base Sepolia that sends and receives cross-chain reputation updates via Wormhole's Executor framework.
  • A read-only Next.js dashboard that fetches live reputation data from both chains without requiring a wallet connection.
  • A production-ready repository with Docker containerization, GitHub Actions CI/CD, and verified contracts on block explorers.
  • Secret Mission: A multi-chain reputation aggregator that combines scores from multiple source chains into a weighted average.

Are there any prerequisites?

This project assumes you have experience writing Solidity smart contracts and working with Foundry (forge build, forge test, forge script).

You should also be comfortable with React/Next.js basics and have a general understanding of cross-chain messaging concepts.

Set Up the Project Structure

A cross-chain reputation oracle needs two major components: smart contracts that run on multiple blockchains, and a frontend that reads data from both chains.

In this step, you will scaffold the project using Foundry for the smart contract side and Next.js for the frontend. You will also install the Wormhole Solidity SDK, which provides the base contracts for cross-chain messaging.

In this step, get ready to:
  • Initialize a Foundry project and install the Wormhole Solidity SDK.
  • Set up a Next.js frontend with wagmi and viem for multi-chain reads.
  • Confirm the full project directory structure and commit to Git.
Initialize the Foundry project and install the Wormhole SDK

Foundry gives you a complete Solidity development environment with compilation, testing, and deployment tools. The forge init command creates a new project with the standard directory layout.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Foundry project and move into it by running these commands:
forge init reputation-oracle
cd reputation-oracle
  • Confirm the project was created by running:
ls

You should see src/, test/, script/, lib/, and foundry.toml in the output.

Seeing 'command not found' for forge?

Make sure Foundry is installed and on your PATH. Run foundryup to install or update Foundry, then try again.

Help me fix this forge error

Now you need the Wormhole Solidity SDK, which provides the base contract your ReputationHub will extend.

  • Install the Wormhole Solidity SDK as a Foundry dependency by running:
forge install wormhole-foundation/wormhole-solidity-sdk@v1.1.0

What does this command do?

This installs the Wormhole Solidity SDK at version v1.1.0 as a Git submodule inside your lib/ directory. Foundry uses Git submodules to manage Solidity dependencies, similar to how npm manages JavaScript packages.

  • Verify the SDK was installed by running:
ls lib/wormhole-solidity-sdk

You should see the SDK's source files listed in the output.

Install failed or timed out?

The install pulls from GitHub, so check your internet connection. If you see a permissions error, make sure your GitHub SSH keys are configured or try using HTTPS by running this command first:

git config --global url."https://github.com/".insteadOf git@github.com:

Help me troubleshoot the SDK install

Foundry needs a remappings file to know where to find the SDK's Solidity source files when compiling your contracts.

  • Create a remappings.txt file in your project root by running:
echo 'wormhole-solidity-sdk/=lib/wormhole-solidity-sdk/src/' > remappings.txt

What does this remapping do?

When your Solidity code imports from wormhole-solidity-sdk/, Foundry resolves that path to lib/wormhole-solidity-sdk/src/ on disk. This lets you write clean import statements like import {ExecutorSendReceiveQuoteOffChain} from "wormhole-solidity-sdk/Executor/Integration.sol" without specifying the full file path.

  • Confirm the file was created correctly by running:
cat remappings.txt

You should see wormhole-solidity-sdk/=lib/wormhole-solidity-sdk/src/ printed in the terminal.

✔️ Awesome, I've got everything!

Great. Make sure you have saved remappings.txt in the root of your reputation-oracle folder.

ⓧ I'd like to double check the full code

Here is the complete remappings.txt file:

wormhole-solidity-sdk/=lib/wormhole-solidity-sdk/src/
Set up the Next.js frontend with wagmi

The frontend dashboard needs to read data from two different blockchains simultaneously. You will use wagmi v3 and viem v2 to handle multi-chain contract reads, with TanStack Query managing the caching layer.

  • Make sure you are in the reputation-oracle root directory (not inside src/ or lib/).
  • Scaffold the Next.js frontend by running this command:
npx create-next-app@latest frontend --typescript --app --src-dir

What do these flags do?

  • --typescript enables TypeScript for type-safe contract interactions.
  • --app uses the App Router, which is the recommended pattern for Next.js projects.
  • --src-dir puts application code inside a src/ folder to keep the root clean.

This command creates a frontend/ subdirectory with all the Next.js boilerplate already in place.

What will create-next-app ask?

The wizard may prompt you about Tailwind and ESLint. You can accept the defaults for each prompt. If npx asks to install create-next-app, type y to confirm.

  • Verify the frontend folder was created by running:
ls frontend/src

You should see an app/ directory in the output, confirming the App Router structure is in place.

The command failed or produced unexpected output?

If you see a network error, check your internet connection and try again. If npx cannot find the package, make sure Node.js is installed correctly by running node --version first.

Help me fix create-next-app

Now install the blockchain-specific dependencies that let the frontend talk to both chains.

  • Move into the frontend directory and install wagmi, viem, and TanStack Query by running:
cd frontend
npm install wagmi@3 viem@2 @tanstack/react-query

What are these packages?

  • wagmi provides React hooks for reading and writing to EVM-compatible blockchains.
  • viem is the low-level TypeScript library that wagmi uses to encode contract calls and parse responses.
  • @tanstack/react-query handles data fetching, caching, and background refetching for the on-chain reads.
  • Verify the packages installed correctly by checking the output for any errors.
  • Move back to the project root by running:
cd ..

Seeing peer dependency warnings?

Wagmi v3 requires React 18+ and TypeScript 5.7+. If you see peer dependency warnings, they are usually informational and the install still works. If the install fails outright, try adding the legacy peer deps flag:

npm install wagmi@3 viem@2 @tanstack/react-query --legacy-peer-deps

Help me resolve dependency conflicts

Confirm the project structure and commit

Your project should now have a clear separation between smart contract code and frontend code. Time to verify the full structure and lock it in with a Git commit.

  • View the top-level directory structure by running:
ls

You should see these directories: src/, test/, script/, lib/, and frontend/.

What is each directory for?

  • src/ holds your Solidity smart contracts (ReputationHub, ReputationScorer).
  • test/ holds Foundry test files written in Solidity.
  • script/ holds deployment scripts for each chain.
  • lib/ holds external dependencies installed via forge install (including the Wormhole SDK).
  • frontend/ holds the Next.js dashboard that reads from both chains.

The forge init command already initialized a Git repository and made an initial commit. Now commit the new files you added (the SDK, remappings, and frontend).

  • Stage all new files and commit by running:
git add .
git commit -m "Add Wormhole SDK, remappings, and Next.js frontend"
  • Confirm the commit was created by running:
git log --oneline

You should see your new commit at the top of the log output.

Your project structure is locked in. Next up, you will write the ReputationHub smart contract that sends and receives cross-chain reputation messages via Wormhole.

Write the Smart Contracts

Your project environment is ready with Foundry and the Wormhole Solidity SDK installed. Now you need the smart contracts that power the cross-chain reputation system.

The core challenge is: how do you securely send a reputation score from one blockchain to another? You need a contract that can publish messages through Wormhole's Core Bridge, request execution on a remote chain, and safely receive incoming messages from trusted peers.

In this step, get ready to:
  • Create the ReputationHub contract with cross-chain messaging infrastructure.
  • Implement the syncReputation function for broadcasting scores.
  • Create the ReputationScorer helper contract for calculating scores.
Create the ReputationHub contract

The ReputationHub is the central contract deployed on every chain in your protocol. It extends the Wormhole SDK's ExecutorSendReceiveQuoteOffChain base contract, which provides the plumbing for publishing messages to the Core Bridge and requesting delivery via the Executor.

  • Create a new file called ReputationHub.sol inside your src/ folder.
  • Paste the following code to define the contract's imports, state variables, events, modifier, and constructor:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ExecutorSendReceiveQuoteOffChain} from "wormhole-solidity-sdk/Executor/Integration.sol";

contract ReputationHub is ExecutorSendReceiveQuoteOffChain {
    mapping(address => uint256) public reputationScores;
    mapping(uint16 => mapping(address => uint256)) public crossChainScores;
    mapping(bytes32 => bool) public processedMessages;
    mapping(uint16 => bytes32) public peers;

    address public owner;

    event ReputationUpdated(address indexed user, uint256 score);
    event ReputationSynced(address indexed user, uint256 score, uint16 targetChain, uint64 sequence);
    event CrossChainReputationReceived(address indexed user, uint256 score, uint16 sourceChain);
    event PeerRegistered(uint16 chainId, bytes32 peerAddress);

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor(address coreBridge, address executor)
        ExecutorSendReceiveQuoteOffChain(coreBridge, executor)
    {
        owner = msg.sender;
    }

What does this code do?

This establishes the contract's data model and access control.

  • reputationScores stores each user's local reputation score on this chain.
  • crossChainScores stores scores received from other chains, indexed by source chain ID and user address.
  • processedMessages tracks which delivery hashes have already been processed to prevent replay attacks.
  • peers maps Wormhole chain IDs to trusted contract addresses on remote chains.
  • The constructor passes the Core Bridge and Executor addresses to the Wormhole base contract.

The file is not complete yet. Do not save it.

  • Add the following functions directly below the constructor to implement peer management, the Wormhole override hooks, and a cross-chain score getter:
    function setPeer(uint16 chainId, bytes32 peerAddress) external onlyOwner {
        peers[chainId] = peerAddress;
        emit PeerRegistered(chainId, peerAddress);
    }

    function updateReputation(address user, uint256 score) external onlyOwner {
        reputationScores[user] = score;
        emit ReputationUpdated(user, score);
    }

    function _getPeer(uint16 chainId) internal view override returns (bytes32) {
        return peers[chainId];
    }

    function _replayProtect(bytes32 deliveryHash) internal override {
        require(!processedMessages[deliveryHash], "Message already processed");
        processedMessages[deliveryHash] = true;
    }

    function _executeVaa(bytes memory payload, uint16 sourceChain) internal override {
        (address user, uint256 score, ) = abi.decode(payload, (address, uint256, uint256));
        crossChainScores[sourceChain][user] = score;
        emit CrossChainReputationReceived(user, score, sourceChain);
    }

    function getCrossChainScore(uint16 sourceChain, address user) external view returns (uint256) {
        return crossChainScores[sourceChain][user];
    }
}

What do these functions do?

These functions implement both the contract's own logic and the required Wormhole Executor overrides.

  • setPeer registers a trusted contract on a remote chain. Only messages from registered peers are accepted.
  • updateReputation lets the owner set a user's local reputation score.
  • _getPeer is a Wormhole override that the base contract calls to verify a message came from a trusted source.
  • _replayProtect is a Wormhole override that prevents the same cross-chain message from being processed twice.
  • _executeVaa is the Wormhole override called when a verified message arrives. It decodes the payload and stores the received score.
  • Save src/ReputationHub.sol.

Seeing a compilation error?

If the file does not compile in the next substep, check that the closing } brace is present at the very end of the file. Also confirm your remappings.txt contains wormhole-solidity-sdk/=lib/wormhole-solidity-sdk/src/ so the import resolves correctly.

help me fix my ReputationHub.sol compilation error

Implement the syncReputation function

The contract can receive cross-chain messages, but it cannot send them yet. The syncReputation function encodes a user's score into a payload and publishes it through Wormhole's Core Bridge for delivery to a target chain.

  • In src/ReputationHub.sol, find the updateReputation function.
  • Add the following function between updateReputation and _getPeer:
    function syncReputation(
        address user,
        uint16 targetChain,
        address refundAddr,
        bytes calldata signedQuote,
        uint256 gasLimit,
        uint256 msgValue,
        bytes calldata extraInstructions
    ) external payable returns (uint64 sequence) {
        uint256 score = reputationScores[user];
        require(score > 0, "No reputation to sync");

        bytes memory payload = abi.encode(user, score, block.timestamp);

        sequence = _publishAndRelay(
            payload,
            1,
            msg.value,
            targetChain,
            refundAddr,
            signedQuote,
            gasLimit,
            msgValue,
            extraInstructions
        );

        emit ReputationSynced(user, score, targetChain, sequence);
    }

What does syncReputation do?

This is the outbound messaging function that triggers cross-chain delivery.

  • It reads the user's current score, then ABI-encodes it alongside the user's address and a timestamp into a payload.
  • _publishAndRelay is inherited from the Wormhole base contract. It publishes the message to the Core Bridge (with consistency level 1 for instant confirmation) and requests execution on the target chain via the Executor.
  • The signedQuote parameter is an off-chain quote from the Executor service that specifies the delivery fee. The caller attaches that fee as msg.value.
  • Save src/ReputationHub.sol.
  • Verify the contract compiles by running:
forge build

You should see a successful compilation message with no errors.

Build failed with unrecognized function?

Make sure syncReputation is placed between the updateReputation closing brace and the _getPeer function. If you placed it after the contract's closing } brace, Solidity treats it as a standalone function outside the contract.

help me fix my forge build error for ReputationHub

Create the ReputationScorer helper contract

The ReputationHub stores and syncs scores, but something needs to calculate those scores. The ReputationScorer contract simulates an activity-based scoring system that could track on-chain behaviour like transaction count or token holdings.

  • Create a new file called ReputationScorer.sol inside your src/ folder.
  • Paste the following code into the file:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ReputationHub} from "./ReputationHub.sol";

contract ReputationScorer {
    ReputationHub public hub;
    address public owner;

    uint256 public constant BASE_SCORE = 100;
    uint256 public constant ACTIVITY_MULTIPLIER = 10;
    uint256 public constant MAX_SCORE = 1000;

    mapping(address => uint256) public activityCount;

    event ActivityRecorded(address indexed user, uint256 newCount);
    event ScoreCalculated(address indexed user, uint256 score);

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor(address hubAddress) {
        hub = ReputationHub(hubAddress);
        owner = msg.sender;
    }

    function recordActivity(address user) external onlyOwner {
        activityCount[user] += 1;
        emit ActivityRecorded(user, activityCount[user]);
    }

    function calculateAndUpdateScore(address user) external {
        uint256 rawScore = BASE_SCORE + (activityCount[user] * ACTIVITY_MULTIPLIER);
        uint256 score = rawScore > MAX_SCORE ? MAX_SCORE : rawScore;

        hub.updateReputation(user, score);
        emit ScoreCalculated(user, score);
    }

    function getActivityCount(address user) external view returns (uint256) {
        return activityCount[user];
    }
}

What does ReputationScorer do?

This contract is a simple scoring engine that feeds the ReputationHub.

  • recordActivity increments a counter each time a user performs an on-chain action.
  • calculateAndUpdateScore computes a score using the formula: base score (100) plus activity count times 10, capped at 1000. It then calls hub.updateReputation to store the result.
  • In production, you would replace this with real DeFi metrics like TVL contributed, governance participation, or protocol usage.
  • Save src/ReputationScorer.sol.
  • Verify both contracts compile by running:
forge build

You should see both ReputationHub and ReputationScorer compile successfully with no errors.

ReputationScorer failing to compile?

This contract imports ReputationHub from a relative path. If ReputationHub.sol has any syntax errors, both contracts will fail to build. Fix any issues in ReputationHub.sol first, then re-run forge build.

help me debug my ReputationScorer compilation error

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/ReputationHub.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ExecutorSendReceiveQuoteOffChain} from "wormhole-solidity-sdk/Executor/Integration.sol";

contract ReputationHub is ExecutorSendReceiveQuoteOffChain {
    mapping(address => uint256) public reputationScores;
    mapping(uint16 => mapping(address => uint256)) public crossChainScores;
    mapping(bytes32 => bool) public processedMessages;
    mapping(uint16 => bytes32) public peers;

    address public owner;

    event ReputationUpdated(address indexed user, uint256 score);
    event ReputationSynced(address indexed user, uint256 score, uint16 targetChain, uint64 sequence);
    event CrossChainReputationReceived(address indexed user, uint256 score, uint16 sourceChain);
    event PeerRegistered(uint16 chainId, bytes32 peerAddress);

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor(address coreBridge, address executor)
        ExecutorSendReceiveQuoteOffChain(coreBridge, executor)
    {
        owner = msg.sender;
    }

    function setPeer(uint16 chainId, bytes32 peerAddress) external onlyOwner {
        peers[chainId] = peerAddress;
        emit PeerRegistered(chainId, peerAddress);
    }

    function updateReputation(address user, uint256 score) external onlyOwner {
        reputationScores[user] = score;
        emit ReputationUpdated(user, score);
    }

    function syncReputation(
        address user,
        uint16 targetChain,
        address refundAddr,
        bytes calldata signedQuote,
        uint256 gasLimit,
        uint256 msgValue,
        bytes calldata extraInstructions
    ) external payable returns (uint64 sequence) {
        uint256 score = reputationScores[user];
        require(score > 0, "No reputation to sync");

        bytes memory payload = abi.encode(user, score, block.timestamp);

        sequence = _publishAndRelay(
            payload,
            1,
            msg.value,
            targetChain,
            refundAddr,
            signedQuote,
            gasLimit,
            msgValue,
            extraInstructions
        );

        emit ReputationSynced(user, score, targetChain, sequence);
    }

    function _getPeer(uint16 chainId) internal view override returns (bytes32) {
        return peers[chainId];
    }

    function _replayProtect(bytes32 deliveryHash) internal override {
        require(!processedMessages[deliveryHash], "Message already processed");
        processedMessages[deliveryHash] = true;
    }

    function _executeVaa(bytes memory payload, uint16 sourceChain) internal override {
        (address user, uint256 score, ) = abi.decode(payload, (address, uint256, uint256));
        crossChainScores[sourceChain][user] = score;
        emit CrossChainReputationReceived(user, score, sourceChain);
    }

    function getCrossChainScore(uint16 sourceChain, address user) external view returns (uint256) {
        return crossChainScores[sourceChain][user];
    }
}

And here is the complete src/ReputationScorer.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ReputationHub} from "./ReputationHub.sol";

contract ReputationScorer {
    ReputationHub public hub;
    address public owner;

    uint256 public constant BASE_SCORE = 100;
    uint256 public constant ACTIVITY_MULTIPLIER = 10;
    uint256 public constant MAX_SCORE = 1000;

    mapping(address => uint256) public activityCount;

    event ActivityRecorded(address indexed user, uint256 newCount);
    event ScoreCalculated(address indexed user, uint256 score);

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor(address hubAddress) {
        hub = ReputationHub(hubAddress);
        owner = msg.sender;
    }

    function recordActivity(address user) external onlyOwner {
        activityCount[user] += 1;
        emit ActivityRecorded(user, activityCount[user]);
    }

    function calculateAndUpdateScore(address user) external {
        uint256 rawScore = BASE_SCORE + (activityCount[user] * ACTIVITY_MULTIPLIER);
        uint256 score = rawScore > MAX_SCORE ? MAX_SCORE : rawScore;

        hub.updateReputation(user, score);
        emit ScoreCalculated(user, score);
    }

    function getActivityCount(address user) external view returns (uint256) {
        return activityCount[user];
    }
}

Your smart contracts are written and compiling cleanly. Next up, you will write Foundry tests to verify that reputation updates, payload encoding, and replay protection all work correctly before deploying to testnets.

Write and Run Foundry Tests

Your smart contracts compile, but you have no way to prove they actually work. Cross-chain contracts are especially risky because bugs only surface after expensive deployments to live networks.

In this step, you will write mock contracts that simulate the Wormhole Core Bridge and Executor locally. Then you will build a full test suite covering reputation updates, access control, payload encoding, and the scorer-to-hub integration flow.

In this step, get ready to:
  • Create mock contracts for the Wormhole Core Bridge and Executor.
  • Write unit tests covering reputation updates, payload encoding, replay protection, and peer authorization.
  • Write integration tests simulating the full cross-chain scoring flow.
Create mock contracts for local testing

The ReputationHub contract depends on a Wormhole Core Bridge and an Executor at specific addresses. On a live testnet those contracts exist, but in Foundry's local EVM there is nothing deployed. Mock contracts let you simulate those external dependencies so tests run instantly with no network calls.

  • Create a new file called test/ReputationHub.t.sol inside your project's test/ directory.
  • Paste the following imports and mock contracts into the file:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {Test, console} from "forge-std/Test.sol";
import {ReputationHub} from "../src/ReputationHub.sol";
import {ReputationScorer} from "../src/ReputationScorer.sol";

// Simulates the Wormhole Core Bridge for local testing
contract MockCoreBridge {
    uint64 public sequence;

    function publishMessage(uint32, bytes memory, uint8) external payable returns (uint64) {
        sequence += 1;
        return sequence;
    }

    function messageFee() external pure returns (uint256) {
        return 0;
    }
}

// Simulates the Wormhole Executor for local testing
contract MockExecutor {
    function requestExecution(
        uint16,
        bytes32,
        address,
        bytes calldata,
        bytes calldata,
        bytes calldata
    ) external payable {}
}

What do these mocks do?

MockCoreBridge mimics the Wormhole Core Bridge. It exposes publishMessage (which just increments a sequence counter) and messageFee (which returns zero so tests don't require ETH).

MockExecutor mimics the Executor contract. Its requestExecution function accepts the same parameters as the real one but does nothing. This lets syncReputation complete without reverting.

  • Save test/ReputationHub.t.sol.
Write unit tests for core contract logic

Unit tests exercise individual functions in isolation. You will test access control, peer registration, replay protection state, payload encoding, and the guard on syncReputation.

  • In test/ReputationHub.t.sol, add the test contract declaration with state variables and the setUp function below the MockExecutor contract:
contract ReputationHubTest is Test {
    ReputationHub public hub;
    ReputationScorer public scorer;
    MockCoreBridge public coreBridge;
    MockExecutor public executor;

    address public owner = address(this);
    address public user1 = address(0x1);
    address public user2 = address(0x2);
    uint16 public constant BASE_SEPOLIA_CHAIN_ID = 10004;

    // Deploy all contracts fresh before each test
    function setUp() public {
        coreBridge = new MockCoreBridge();
        executor = new MockExecutor();
        hub = new ReputationHub(address(coreBridge), address(executor));
        scorer = new ReputationScorer(address(hub));
    }

What does setUp do?

Foundry calls setUp() before every single test function. Each test gets fresh contract instances, so one test cannot pollute another's state.

The BASE_SEPOLIA_CHAIN_ID constant (10004) matches the Wormhole chain ID for Base Sepolia. Using it in tests ensures your cross-chain logic targets the correct destination.

  • Add the following unit test functions inside the ReputationHubTest contract, below setUp:
    // Test that the owner can update a user's reputation
    function test_UpdateReputation() public {
        hub.updateReputation(user1, 500);
        assertEq(hub.reputationScores(user1), 500);
    }

    // Test that non-owners cannot update reputation
    function test_UpdateReputationOnlyOwner() public {
        vm.prank(user1);
        vm.expectRevert("Not owner");
        hub.updateReputation(user1, 500);
    }

    // Test peer registration for cross-chain messaging
    function test_SetPeer() public {
        bytes32 peerAddr = bytes32(uint256(uint160(address(0xBEEF))));
        hub.setPeer(BASE_SEPOLIA_CHAIN_ID, peerAddr);
        assertEq(hub.peers(BASE_SEPOLIA_CHAIN_ID), peerAddr);
    }

    // Test that unprocessed messages return false
    function test_ReplayProtection() public {
        bytes32 hash1 = keccak256("message1");
        assertEq(hub.processedMessages(hash1), false);
    }

    // Test that payload encoding and decoding are symmetric
    function test_PayloadEncoding() public {
        address user = address(0xCAFE);
        uint256 score = 750;
        uint256 timestamp = block.timestamp;

        bytes memory payload = abi.encode(user, score, timestamp);
        (address decodedUser, uint256 decodedScore, uint256 decodedTimestamp) =
            abi.decode(payload, (address, uint256, uint256));

        assertEq(decodedUser, user);
        assertEq(decodedScore, score);
        assertEq(decodedTimestamp, timestamp);
    }

    // Test that syncing fails when user has no reputation
    function test_SyncReputationRequiresScore() public {
        vm.expectRevert("No reputation to sync");
        hub.syncReputation(user1, BASE_SEPOLIA_CHAIN_ID, owner, "", 200000, 0, "");
    }

What does this code do?

  • test_UpdateReputation confirms the owner can set a score and read it back.
  • test_UpdateReputationOnlyOwner uses vm.prank to impersonate a non-owner and asserts the call reverts.
  • test_SetPeer registers a peer address and verifies it persists in state.
  • test_PayloadEncoding encodes and decodes a payload, proving the format matches what _executeVaa expects.
  • test_SyncReputationRequiresScore confirms the guard prevents syncing when a user has zero reputation.
  • Save test/ReputationHub.t.sol.

Seeing a compilation error?

Make sure the test contract opening brace from the previous chunk is still present. The unit test functions must live inside the ReputationHubTest contract body, not outside it.

Check that your imports at the top of the file match exactly. The ReputationHub and ReputationScorer imports use relative paths from the test/ directory.

help me fix a compilation error in my Foundry test file

Write integration tests for the scorer flow

Integration tests exercise the full flow from recording user activity through the ReputationScorer to the ReputationHub. These tests prove that the scorer correctly computes scores and pushes them into the hub's state.

  • Add the following integration test functions below test_SyncReputationRequiresScore, still inside the ReputationHubTest contract. Then close the contract with a }:
    // Integration: record multiple activities and verify count
    function test_ScorerRecordActivity() public {
        scorer.recordActivity(user1);
        scorer.recordActivity(user1);
        scorer.recordActivity(user1);
        assertEq(scorer.activityCount(user1), 3);
    }

    // Integration: scorer calculates correct score from activity
    function test_ScorerCalculateScore() public {
        scorer.recordActivity(user1);
        scorer.recordActivity(user1);
        scorer.recordActivity(user1);

        scorer.calculateAndUpdateScore(user1);
        assertEq(hub.reputationScores(user1), 130);
    }

    // Integration: scorer caps score at MAX_SCORE (1000)
    function test_ScorerMaxScore() public {
        for (uint256 i = 0; i < 100; i++) {
            scorer.recordActivity(user1);
        }
        scorer.calculateAndUpdateScore(user1);
        assertEq(hub.reputationScores(user1), 1000);
    }
}

What does this code do?

  • test_ScorerRecordActivity calls recordActivity three times and verifies the counter increments correctly.
  • test_ScorerCalculateScore records 3 activities then calculates the score. The expected result is 100 + (3 * 10) = 130.
  • test_ScorerMaxScore records 100 activities, which would produce a raw score of 1100. The contract caps it at MAX_SCORE (1000).
  • Save test/ReputationHub.t.sol.
  • Run the full test suite by running this command:
forge test -vvv

You should see all 9 tests pass with detailed execution traces for each one.

Tests failing?

If you see assertion failures, check that your ReputationScorer.sol uses BASE_SCORE = 100 and ACTIVITY_MULTIPLIER = 10. The test expects 130 for 3 activities.

If you see a compiler error about missing closing braces, make sure the final } closes the ReputationHubTest contract.

help me debug my failing Foundry tests

✔️ Awesome, I've got everything!

Great. Double check you have saved test/ReputationHub.t.sol and that all 9 tests pass.

ⓧ I'd like to double check the full code

Here is the complete test/ReputationHub.t.sol file for comparison:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {Test, console} from "forge-std/Test.sol";
import {ReputationHub} from "../src/ReputationHub.sol";
import {ReputationScorer} from "../src/ReputationScorer.sol";

contract MockCoreBridge {
    uint64 public sequence;

    function publishMessage(uint32, bytes memory, uint8) external payable returns (uint64) {
        sequence += 1;
        return sequence;
    }

    function messageFee() external pure returns (uint256) {
        return 0;
    }
}

contract MockExecutor {
    function requestExecution(
        uint16,
        bytes32,
        address,
        bytes calldata,
        bytes calldata,
        bytes calldata
    ) external payable {}
}

contract ReputationHubTest is Test {
    ReputationHub public hub;
    ReputationScorer public scorer;
    MockCoreBridge public coreBridge;
    MockExecutor public executor;

    address public owner = address(this);
    address public user1 = address(0x1);
    address public user2 = address(0x2);
    uint16 public constant BASE_SEPOLIA_CHAIN_ID = 10004;

    function setUp() public {
        coreBridge = new MockCoreBridge();
        executor = new MockExecutor();
        hub = new ReputationHub(address(coreBridge), address(executor));
        scorer = new ReputationScorer(address(hub));
    }

    function test_UpdateReputation() public {
        hub.updateReputation(user1, 500);
        assertEq(hub.reputationScores(user1), 500);
    }

    function test_UpdateReputationOnlyOwner() public {
        vm.prank(user1);
        vm.expectRevert("Not owner");
        hub.updateReputation(user1, 500);
    }

    function test_SetPeer() public {
        bytes32 peerAddr = bytes32(uint256(uint160(address(0xBEEF))));
        hub.setPeer(BASE_SEPOLIA_CHAIN_ID, peerAddr);
        assertEq(hub.peers(BASE_SEPOLIA_CHAIN_ID), peerAddr);
    }

    function test_ReplayProtection() public {
        bytes32 hash1 = keccak256("message1");
        assertEq(hub.processedMessages(hash1), false);
    }

    function test_ScorerRecordActivity() public {
        scorer.recordActivity(user1);
        scorer.recordActivity(user1);
        scorer.recordActivity(user1);
        assertEq(scorer.activityCount(user1), 3);
    }

    function test_ScorerCalculateScore() public {
        scorer.recordActivity(user1);
        scorer.recordActivity(user1);
        scorer.recordActivity(user1);

        scorer.calculateAndUpdateScore(user1);
        assertEq(hub.reputationScores(user1), 130);
    }

    function test_ScorerMaxScore() public {
        for (uint256 i = 0; i < 100; i++) {
            scorer.recordActivity(user1);
        }
        scorer.calculateAndUpdateScore(user1);
        assertEq(hub.reputationScores(user1), 1000);
    }

    function test_PayloadEncoding() public {
        address user = address(0xCAFE);
        uint256 score = 750;
        uint256 timestamp = block.timestamp;

        bytes memory payload = abi.encode(user, score, timestamp);
        (address decodedUser, uint256 decodedScore, uint256 decodedTimestamp) =
            abi.decode(payload, (address, uint256, uint256));

        assertEq(decodedUser, user);
        assertEq(decodedScore, score);
        assertEq(decodedTimestamp, timestamp);
    }

    function test_SyncReputationRequiresScore() public {
        vm.expectRevert("No reputation to sync");
        hub.syncReputation(user1, BASE_SEPOLIA_CHAIN_ID, owner, "", 200000, 0, "");
    }
}

Your contracts are now proven correct locally. Next up, you will deploy them to real testnets and wire up cross-chain communication between Arbitrum Sepolia and Base Sepolia.

Deploy to Arbitrum Sepolia and Base Sepolia

Your contracts compile and pass all tests locally. The next milestone is deploying them to live testnets so they can actually send cross-chain messages.

In this step you will deploy ReputationHub to both Arbitrum Sepolia and Base Sepolia, register each contract as a trusted peer of the other, verify the source code on block explorers, and execute a test cross-chain reputation sync via Wormhole.

In this step, get ready to:
  • Write and run a Foundry deployment script targeting both testnets.
  • Register cross-chain peers and verify contracts on Arbiscan and Basescan.
  • Execute a test reputation sync and confirm delivery on Wormholescan.
Write the Foundry deployment script

Foundry scripts are Solidity files that deploy contracts and broadcast transactions on-chain. You will write a single script that works for both chains by reading chain-specific addresses from environment variables.

  • Create a .env file in the root of your project with the following environment variables:
# Private key for deployment (DO NOT commit this file)
PRIVATE_KEY=your-private-key-here

# Wormhole Core Bridge addresses
ARB_CORE_BRIDGE=0x6b9C8671cdDC8dEab9c719bB87cBd3e782bA6a35
BASE_CORE_BRIDGE=0x79A1027a6A159502049F10906D333EC57E95F083

# Wormhole Executor addresses
ARB_EXECUTOR=0xBF161de6B819c8af8f2230Bcd99a9B3592f6F87b
BASE_EXECUTOR=0x51B47D493CBA7aB97e3F8F163D6Ce07592CE4482

# Block explorer API keys for verification
ARBISCAN_API_KEY=your-arbiscan-api-key-here
BASESCAN_API_KEY=your-basescan-api-key-here

# Filled after deployment
ARB_HUB_ADDRESS=
BASE_HUB_ADDRESS=

What are these addresses?

Each testnet has its own Wormhole Core Bridge and Executor contract. The Core Bridge publishes messages, and the Executor relays them to the destination chain. These addresses are published in Wormhole's official contract reference.

  • Create the script/ directory and the deployment script by running:
mkdir -p script
  • Create the file script/Deploy.s.sol and paste the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {Script, console} from "forge-std/Script.sol";
import {ReputationHub} from "../src/ReputationHub.sol";
import {ReputationScorer} from "../src/ReputationScorer.sol";

contract DeployScript is Script {
    function run() public {
        // Read the deployer private key from environment
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");

        // Read chain-specific addresses from environment
        address coreBridge;
        address executor;

        if (block.chainid == 421614) {
            // Arbitrum Sepolia
            coreBridge = vm.envAddress("ARB_CORE_BRIDGE");
            executor = vm.envAddress("ARB_EXECUTOR");
        } else if (block.chainid == 84532) {
            // Base Sepolia
            coreBridge = vm.envAddress("BASE_CORE_BRIDGE");
            executor = vm.envAddress("BASE_EXECUTOR");
        } else {
            revert("Unsupported chain");
        }

        vm.startBroadcast(deployerPrivateKey);

        // Deploy the ReputationHub with chain-specific Wormhole addresses
        ReputationHub hub = new ReputationHub(coreBridge, executor);
        console.log("ReputationHub deployed at:", address(hub));

        // Deploy ReputationScorer only on Arbitrum Sepolia (source chain)
        if (block.chainid == 421614) {
            ReputationScorer scorer = new ReputationScorer(address(hub));
            console.log("ReputationScorer deployed at:", address(scorer));
        }

        vm.stopBroadcast();
    }
}

What does this script do?

The script reads block.chainid at runtime to determine which chain it is deploying to. It picks the correct Core Bridge and Executor addresses for that chain.

On Arbitrum Sepolia (chain ID 421614) it also deploys ReputationScorer because Arbitrum is the source chain where scores originate. On Base Sepolia (chain ID 84532) it only deploys the hub to receive cross-chain messages.

  • Save script/Deploy.s.sol and .env.
  • Confirm the script compiles by running:
forge build

You should see a successful compilation with no errors.

Seeing a compilation error?

Make sure script/Deploy.s.sol imports from the correct paths. The forge-std/Script.sol import gives you access to vm cheatcodes and console. Check your remappings include forge-std/ pointing to the correct lib path.

help me fix my Deploy.s.sol compilation error

Deploy to both testnets and register peers

You will deploy the script twice, once per chain. After both deploys complete, you will register each hub as a trusted peer of the other so they accept cross-chain messages from each other.

What is peer registration?

Wormhole's Executor framework requires each contract to maintain a list of trusted senders (peers) keyed by Wormhole chain ID. When a message arrives, the contract checks that the source address matches its registered peer for that chain. Without peer registration, incoming messages are rejected.

  • Make sure you have testnet ETH in your deployer wallet on both Arbitrum Sepolia and Base Sepolia. You can get these from their respective faucets.
  • Load your .env file and deploy to Arbitrum Sepolia by running:
source .env

forge script script/Deploy.s.sol --broadcast --rpc-url https://sepolia-rollup.arbitrum.io/rpc --verify --etherscan-api-key $ARBISCAN_API_KEY

What does this command do?

  • --broadcast sends the transactions to the live network.
  • --rpc-url tells Foundry which chain to target.
  • --verify automatically submits source code for verification on the block explorer.
  • --etherscan-api-key passes your API key for the Arbiscan verification service.
  • Copy the deployed ReputationHub address from the console output and update your .env file's ARB_HUB_ADDRESS field with that value.
  • Now deploy to Base Sepolia by running:
source .env

forge script script/Deploy.s.sol --broadcast --rpc-url https://sepolia.base.org --verify --etherscan-api-key $BASESCAN_API_KEY
  • Copy the deployed ReputationHub address from the console output and update your .env file's BASE_HUB_ADDRESS field with that value.

With both hubs deployed, you now need to register peers. Each hub must know the other's address (in bytes32 format) and the Wormhole chain ID of the chain where it lives.

  • Register the Base Sepolia hub as a peer on the Arbitrum Sepolia hub by running:
cast send $ARB_HUB_ADDRESS "setPeer(uint16,bytes32)" 10004 $(cast --to-bytes32 $BASE_HUB_ADDRESS) --rpc-url https://sepolia-rollup.arbitrum.io/rpc --private-key $PRIVATE_KEY
  • Register the Arbitrum Sepolia hub as a peer on the Base Sepolia hub by running:
cast send $BASE_HUB_ADDRESS "setPeer(uint16,bytes32)" 10003 $(cast --to-bytes32 $ARB_HUB_ADDRESS) --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY

Why bytes32 for addresses?

Wormhole uses a universal address format that is 32 bytes wide. This allows it to support non-EVM chains whose addresses are longer than 20 bytes. On EVM, you left-pad the 20-byte address with zeroes to fill 32 bytes. The cast --to-bytes32 command handles this conversion for you.

  • Verify peer registration succeeded on Arbitrum Sepolia by running:
cast call $ARB_HUB_ADDRESS "peers(uint16)" 10004 --rpc-url https://sepolia-rollup.arbitrum.io/rpc

You should see a 32-byte hex value matching your Base Sepolia hub address (left-padded with zeroes).

  • Confirm the Arbitrum Sepolia contract is verified by visiting https://sepolia.arbiscan.io and searching for your ARB_HUB_ADDRESS. You should see a green checkmark on the **Contract** tab indicating the source code is verified.
  • Confirm the Base Sepolia contract is verified by visiting https://sepolia.basescan.org and searching for your BASE_HUB_ADDRESS.

Verification failing?

If verification did not complete during deployment, you can retry manually. Make sure you pass the constructor arguments that the contract received at deployment time. For ReputationHub, the constructor takes coreBridge and executor addresses.

help me verify my contract on Arbiscan or Basescan

Execute a test reputation sync

The final proof that your cross-chain system works is sending an actual reputation score from Arbitrum Sepolia to Base Sepolia through Wormhole. You will set a test score, trigger the sync, and confirm delivery on Wormholescan.

  • First, set a reputation score for your deployer address on Arbitrum Sepolia by running:
cast send $ARB_HUB_ADDRESS "updateReputation(address,uint256)" $(cast wallet address --private-key $PRIVATE_KEY) 500 --rpc-url https://sepolia-rollup.arbitrum.io/rpc --private-key $PRIVATE_KEY
  • Verify the score was stored by running:
cast call $ARB_HUB_ADDRESS "reputationScores(address)" $(cast wallet address --private-key $PRIVATE_KEY) --rpc-url https://sepolia-rollup.arbitrum.io/rpc

You should see 500 returned (as a hex value 0x01f4).

What happens during syncReputation?

When you call syncReputation, the hub encodes the user's address, score, and timestamp into a payload. It then calls Wormhole's Core Bridge to publish the message and the Executor to relay it. The Executor picks up the message, gets it attested by Wormhole Guardians, and delivers it to the destination chain's hub.

The signedQuote parameter is a price quote from the Executor API that tells your contract how much to pay for relay. For testnet, you can request a quote from the Wormhole Executor API endpoint for your target chain.

  • Request an Executor quote and call syncReputation to send the score from Arbitrum Sepolia to Base Sepolia. The exact quote and gas parameters depend on current testnet conditions. Use the Wormhole Executor API to fetch a signed quote for your target chain (Wormhole chain ID 10004 for Base Sepolia), then pass it to the contract call.
  • After the transaction confirms on Arbitrum Sepolia, copy the transaction hash.
  • Visit https://wormholescan.io and paste your transaction hash in the search bar.
  • You should see the message status progressing from **Signed** to **Delivered**. Delivery to Base Sepolia typically takes 1-3 minutes on testnets.
  • Once Wormholescan shows the message as delivered, verify the score arrived on Base Sepolia by running:
cast call $BASE_HUB_ADDRESS "crossChainScores(uint16,address)" 10003 $(cast wallet address --private-key $PRIVATE_KEY) --rpc-url https://sepolia.base.org

You should see 500 returned, confirming the cross-chain reputation sync completed successfully.

Score showing 0 on Base Sepolia?

Check that peer registration completed correctly on both sides. Call peers(10003) on your Base Sepolia hub to confirm the Arbitrum hub is registered. Also check Wormholescan for the delivery status. If it shows an error, the gas limit or quote may have been insufficient.

help me debug why my cross-chain message is not being delivered

Your cross-chain reputation oracle is live. Both contracts are deployed, verified, and communicating through Wormhole. Next up, you will build a frontend dashboard that reads reputation data from both chains without requiring a wallet connection.

Build the Frontend Dashboard

Your contracts are deployed, verified, and syncing reputation across chains. The missing piece is a way for anyone to see this data without installing a wallet or running a node.

In this step, you will build a Next.js frontend that reads live reputation scores from both Arbitrum Sepolia and Base Sepolia simultaneously. The dashboard uses wagmi v3's useReadContracts hook to fetch on-chain data without requiring a wallet connection.

In this step, get ready to:
  • Configure wagmi v3 with multi-chain transports and the injected wallet connector.
  • Build a read-only dashboard component that fetches cross-chain reputation scores.
  • Deploy the frontend to Vercel with environment variables pointing at your deployed contracts.
Set up the wagmi config and provider wrapper

wagmi needs a config object that tells it which chains to talk to, which wallet connectors to offer, and which RPC transports to use for each chain.

  • Create the file frontend/src/config/wagmi.ts and add the following configuration:
import { http, createConfig } from "wagmi";
import { arbitrumSepolia, baseSepolia } from "wagmi/chains";
import { injected } from "wagmi/connectors";

export const config = createConfig({
  chains: [arbitrumSepolia, baseSepolia],
  connectors: [injected()],
  transports: {
    [arbitrumSepolia.id]: http("https://sepolia-rollup.arbitrum.io/rpc"),
    [baseSepolia.id]: http("https://sepolia.base.org"),
  },
});

What does this code do?

The createConfig function sets up wagmi's multi-chain awareness. It registers both Arbitrum Sepolia and Base Sepolia so that hooks like useReadContracts can target either chain by its chainId.

The injected() connector detects browser wallets like MetaMask. The http() transports point at each chain's public RPC endpoint so the dashboard can read data without a wallet connected.

Next, the provider wrapper ensures every component in the app has access to the wagmi config and a React Query client for caching.

  • Create the file frontend/src/app/providers.tsx and add the provider wrapper:
"use client";

import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "@/config/wagmi";
import { ReactNode, useState } from "react";

export function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WagmiProvider>
  );
}

Why useState for the QueryClient?

Creating the QueryClient inside useState ensures each browser tab gets exactly one instance. If you created it at module level, server-side rendering could share state across requests.

The "use client" directive marks this as a client component. wagmi hooks rely on React context, which only works on the client side in Next.js App Router.

  • Save both files.
  • Confirm the frontend/src/config/ and frontend/src/app/ directories now contain your new files by running:
ls frontend/src/config/wagmi.ts frontend/src/app/providers.tsx

You should see both file paths echoed back with no errors.

Seeing "No such file or directory"?

Make sure you created the config directory inside frontend/src/. The path must be frontend/src/config/wagmi.ts, not frontend/config/wagmi.ts.

Help me figure out the correct directory structure

Build the read-only dashboard component

The dashboard component reads reputation data from both chains in a single render cycle. Because useReadContracts batches multiple contract calls, it can fetch the Arbitrum Sepolia local score and the Base Sepolia cross-chain received score simultaneously.

  • Create the file frontend/src/components/ReputationDashboard.tsx and add the ABI definition and constants:
"use client";

import { useReadContracts } from "wagmi";
import { formatUnits } from "viem";
import { arbitrumSepolia, baseSepolia } from "wagmi/chains";

const REPUTATION_HUB_ABI = [
  {
    name: "reputationScores",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "user", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "crossChainScores",
    type: "function",
    stateMutability: "view",
    inputs: [
      { name: "sourceChain", type: "uint16" },
      { name: "user", type: "address" },
    ],
    outputs: [{ name: "", type: "uint256" }],
  },
] as const;

const ARB_SEPOLIA_HUB = process.env.NEXT_PUBLIC_ARB_HUB_ADDRESS as `0x${string}`;
const BASE_SEPOLIA_HUB = process.env.NEXT_PUBLIC_BASE_HUB_ADDRESS as `0x${string}`;

const DEMO_USER = process.env.NEXT_PUBLIC_DEMO_USER as `0x${string}`;
const ARB_SEPOLIA_WORMHOLE_CHAIN_ID = 10003;

What does this code do?

The REPUTATION_HUB_ABI array defines the two view functions you need: reputationScores (local score on Arbitrum) and crossChainScores (score received from another chain on Base). You only include the functions you call, not the entire contract ABI.

The NEXT_PUBLIC_ prefix is required by Next.js to expose environment variables to client-side code. ARB_SEPOLIA_WORMHOLE_CHAIN_ID is set to 10003 because that is Arbitrum Sepolia's Wormhole chain ID. Base Sepolia uses it to look up scores received from Arbitrum.

  • Below the constants you just added, add the ReputationDashboard component:
export function ReputationDashboard() {
  const { data, isLoading } = useReadContracts({
    contracts: [
      {
        address: ARB_SEPOLIA_HUB,
        abi: REPUTATION_HUB_ABI,
        functionName: "reputationScores",
        args: [DEMO_USER],
        chainId: arbitrumSepolia.id,
      },
      {
        address: BASE_SEPOLIA_HUB,
        abi: REPUTATION_HUB_ABI,
        functionName: "crossChainScores",
        args: [ARB_SEPOLIA_WORMHOLE_CHAIN_ID, DEMO_USER],
        chainId: baseSepolia.id,
      },
    ],
  });

  const arbScore = data?.[0]?.result;
  const baseReceivedScore = data?.[1]?.result;

  if (isLoading) {
    return <div className="animate-pulse p-6 bg-gray-800 rounded-lg">Loading cross-chain data...</div>;
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
      <div className="p-6 bg-gray-800 rounded-lg border border-blue-500/30">
        <h3 className="text-sm text-gray-400 mb-1">Arbitrum Sepolia</h3>
        <p className="text-xs text-gray-500 mb-3">Source Chain (Wormhole ID: 10003)</p>
        <p className="text-3xl font-bold text-blue-400">
          {arbScore ? arbScore.toString() : "0"}
        </p>
        <p className="text-sm text-gray-400 mt-2">Local Reputation Score</p>
      </div>

      <div className="p-6 bg-gray-800 rounded-lg border border-green-500/30">
        <h3 className="text-sm text-gray-400 mb-1">Base Sepolia</h3>
        <p className="text-xs text-gray-500 mb-3">Destination Chain (Wormhole ID: 10004)</p>
        <p className="text-3xl font-bold text-green-400">
          {baseReceivedScore ? baseReceivedScore.toString() : "0"}
        </p>
        <p className="text-sm text-gray-400 mt-2">Cross-Chain Received Score</p>
      </div>
    </div>
  );
}

Why does this work without a wallet?

The useReadContracts hook only performs eth_call operations. These are free, read-only RPC calls that do not require a signer. Anyone visiting your Vercel URL sees live on-chain data immediately.

Each contract call specifies its own chainId, so wagmi routes the first call to the Arbitrum Sepolia RPC and the second to the Base Sepolia RPC. The results arrive in the same data array, indexed by position.

  • Save frontend/src/components/ReputationDashboard.tsx.

TypeScript errors on the ABI?

Ensure the ABI array ends with as const. Without it, TypeScript infers wide string types and wagmi cannot narrow the return type of each contract call.

Help me debug my ABI TypeScript errors

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full wagmi.ts file:

import { http, createConfig } from "wagmi";
import { arbitrumSepolia, baseSepolia } from "wagmi/chains";
import { injected } from "wagmi/connectors";

export const config = createConfig({
  chains: [arbitrumSepolia, baseSepolia],
  connectors: [injected()],
  transports: {
    [arbitrumSepolia.id]: http("https://sepolia-rollup.arbitrum.io/rpc"),
    [baseSepolia.id]: http("https://sepolia.base.org"),
  },
});

Here is the full providers.tsx file:

"use client";

import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "@/config/wagmi";
import { ReactNode, useState } from "react";

export function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WagmiProvider>
  );
}

Here is the full ReputationDashboard.tsx file:

"use client";

import { useReadContracts } from "wagmi";
import { formatUnits } from "viem";
import { arbitrumSepolia, baseSepolia } from "wagmi/chains";

const REPUTATION_HUB_ABI = [
  {
    name: "reputationScores",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "user", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "crossChainScores",
    type: "function",
    stateMutability: "view",
    inputs: [
      { name: "sourceChain", type: "uint16" },
      { name: "user", type: "address" },
    ],
    outputs: [{ name: "", type: "uint256" }],
  },
] as const;

const ARB_SEPOLIA_HUB = process.env.NEXT_PUBLIC_ARB_HUB_ADDRESS as `0x${string}`;
const BASE_SEPOLIA_HUB = process.env.NEXT_PUBLIC_BASE_HUB_ADDRESS as `0x${string}`;

const DEMO_USER = process.env.NEXT_PUBLIC_DEMO_USER as `0x${string}`;
const ARB_SEPOLIA_WORMHOLE_CHAIN_ID = 10003;

export function ReputationDashboard() {
  const { data, isLoading } = useReadContracts({
    contracts: [
      {
        address: ARB_SEPOLIA_HUB,
        abi: REPUTATION_HUB_ABI,
        functionName: "reputationScores",
        args: [DEMO_USER],
        chainId: arbitrumSepolia.id,
      },
      {
        address: BASE_SEPOLIA_HUB,
        abi: REPUTATION_HUB_ABI,
        functionName: "crossChainScores",
        args: [ARB_SEPOLIA_WORMHOLE_CHAIN_ID, DEMO_USER],
        chainId: baseSepolia.id,
      },
    ],
  });

  const arbScore = data?.[0]?.result;
  const baseReceivedScore = data?.[1]?.result;

  if (isLoading) {
    return <div className="animate-pulse p-6 bg-gray-800 rounded-lg">Loading cross-chain data...</div>;
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
      <div className="p-6 bg-gray-800 rounded-lg border border-blue-500/30">
        <h3 className="text-sm text-gray-400 mb-1">Arbitrum Sepolia</h3>
        <p className="text-xs text-gray-500 mb-3">Source Chain (Wormhole ID: 10003)</p>
        <p className="text-3xl font-bold text-blue-400">
          {arbScore ? arbScore.toString() : "0"}
        </p>
        <p className="text-sm text-gray-400 mt-2">Local Reputation Score</p>
      </div>

      <div className="p-6 bg-gray-800 rounded-lg border border-green-500/30">
        <h3 className="text-sm text-gray-400 mb-1">Base Sepolia</h3>
        <p className="text-xs text-gray-500 mb-3">Destination Chain (Wormhole ID: 10004)</p>
        <p className="text-3xl font-bold text-green-400">
          {baseReceivedScore ? baseReceivedScore.toString() : "0"}
        </p>
        <p className="text-sm text-gray-400 mt-2">Cross-Chain Received Score</p>
      </div>
    </div>
  );
}
Configure environment variables and deploy to Vercel

The dashboard reads contract addresses and the demo user address from environment variables. You need to create a local .env.local file and configure the same variables on Vercel for production.

  • Create the file frontend/.env.local with your deployed contract addresses:
NEXT_PUBLIC_ARB_HUB_ADDRESS=[[ARB_HUB_ADDRESS="your Arbitrum Sepolia ReputationHub contract address"]]
NEXT_PUBLIC_BASE_HUB_ADDRESS=[[BASE_HUB_ADDRESS="your Base Sepolia ReputationHub contract address"]]
NEXT_PUBLIC_DEMO_USER=[[DEMO_USER_ADDRESS="the wallet address you used for the test reputation sync"]]

Where do I find these addresses?

Your ARB_HUB_ADDRESS and BASE_HUB_ADDRESS are in the .env file at the project root from your deployment step. The demo user address is the wallet that has a reputation score recorded on Arbitrum Sepolia from the test sync you ran earlier.

  • Start the development server to verify the dashboard loads by running:
cd frontend && npm run dev

Open http://localhost:3000 in your browser. You should see two cards: a blue-bordered Arbitrum Sepolia card showing the local reputation score, and a green-bordered Base Sepolia card showing the cross-chain received score.

Both cards showing 0?

This means the demo user address has no recorded reputation. Confirm that NEXT_PUBLIC_DEMO_USER in .env.local matches the wallet you used for the test reputation sync in the previous step.

Also check that NEXT_PUBLIC_ARB_HUB_ADDRESS and NEXT_PUBLIC_BASE_HUB_ADDRESS are valid checksummed addresses starting with 0x.

Help me debug my environment variables and contract addresses

  • Stop the dev server with Ctrl+C.
  • Push your repository to GitHub if you have not already.
  • Go to vercel.com and sign in with your GitHub account.
  • Click Add New... then Project.
  • Import your repository.
  • Set the Root Directory to frontend.
  • Under Environment Variables, add three variables: NEXT_PUBLIC_ARB_HUB_ADDRESS, NEXT_PUBLIC_BASE_HUB_ADDRESS, and NEXT_PUBLIC_DEMO_USER with the same values from your .env.local file.
  • Click Deploy.

After a minute or two, Vercel will display a live URL. Open it to confirm the dashboard shows your cross-chain reputation scores.

Why deploy a read-only frontend?

A recruiter or reviewer can visit your Vercel URL and immediately see live blockchain data from two chains. They do not need MetaMask, testnet ETH, or any setup. This makes your cross-chain protocol tangible and verifiable with a single click.

Your frontend is live and reading from both chains. Next up, you will containerize the app with Docker and set up a CI/CD pipeline with GitHub Actions.

Set Up Docker and CI/CD Pipeline

Your frontend is live on Vercel, but a professional blockchain project also needs containerized deployment and automated testing. Docker packages your application into a portable image that runs identically on any machine or cloud provider.

GitHub Actions automates the build-test-deploy cycle so every push compiles your contracts, runs your test suite, and publishes a fresh Docker image. In this step, you will create a production Dockerfile, write a CI/CD workflow, and add a README that ties the whole project together.

In this step, get ready to:
  • Create a multi-stage Dockerfile for the Next.js frontend.
  • Write a GitHub Actions workflow that compiles contracts, runs tests, and pushes a Docker image.
  • Add a comprehensive README with architecture and deployment info.
Create the multi-stage Dockerfile

A multi-stage Docker build separates the heavy compilation work from the lightweight production image. The first stage installs dependencies and builds your Next.js app. The second stage copies only the compiled output, keeping the final image small and secure.

Before creating the Dockerfile, you need to configure Next.js to produce a standalone output bundle. This tells Next.js to trace and copy only the files needed for production into a self-contained folder.

  • Open frontend/next.config.js (or next.config.ts if your project uses TypeScript config) and add the output option:
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
};

module.exports = nextConfig;

What does standalone output do?

Setting output: 'standalone' tells Next.js to create a .next/standalone folder during build that includes a minimal server.js file and only the traced dependencies your app actually uses.

This means the Docker image can run without node_modules, making it much smaller and faster to pull.

  • Save frontend/next.config.js.
  • Now create a Dockerfile at the project root (not inside frontend/) by running:
touch Dockerfile
  • Open Dockerfile and paste the following multi-stage build configuration:
FROM node:20-alpine AS builder

WORKDIR /app

COPY frontend/package*.json ./
RUN npm ci

COPY frontend/ .
RUN npm run build

FROM node:20-alpine AS runner

WORKDIR /app

ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

What does this Dockerfile do?

The builder stage installs dependencies with npm ci (a clean install) and builds the Next.js production bundle.

The runner stage starts fresh from node:20-alpine and copies only the standalone output, static files, and public assets. It creates a non-root nextjs user for security, then runs the minimal server.js on port 3000.

  • Save Dockerfile.
  • Test that the Docker image builds successfully by running:
docker build -t reputation-oracle .
  • You should see the build complete with both stages finishing without errors.

Build failing at the npm run build step?

Make sure you added output: 'standalone' to frontend/next.config.js and saved the file. Without this setting, the .next/standalone folder won't exist and the COPY step in the runner stage will fail.

Also confirm that your frontend/ folder contains a valid package-lock.json file. npm ci requires this file to install exact dependency versions.

help me debug my Docker build failure

Write the GitHub Actions workflow

GitHub Actions lets you define automated workflows that trigger on events like pushes and pull requests. Your workflow will have two jobs: one for contract compilation and testing, and another for building and pushing the Docker image.

  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/ci.yml
  • Open .github/workflows/ci.yml and paste the following workflow configuration:
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  contracts:
    name: Compile & Test Contracts
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1

      - name: Build contracts
        run: forge build

      - name: Run tests
        run: forge test -vvv

  frontend:
    name: Build Frontend & Docker Image
    runs-on: ubuntu-latest
    needs: contracts
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Install dependencies
        working-directory: frontend
        run: npm ci

      - name: Build frontend
        working-directory: frontend
        run: npm run build

      - name: Login to Docker Hub
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push Docker image
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/reputation-oracle:latest

What does this workflow do?

The contracts job checks out the repo with submodules (needed for the Wormhole Solidity SDK), installs Foundry, then runs forge build and forge test -vvv.

The frontend job waits for contracts to pass (via needs: contracts), builds the Next.js app, then conditionally logs into Docker Hub and pushes the image. The Docker steps only run on pushes to main, not on pull requests.

For the Docker push to work, you need to configure secrets in your GitHub repository and create a Docker Hub access token.

  • Go to hub.docker.com and sign in (create a free account if you don't have one).
  • Click your avatar in the top-right corner, then select Account settings.
  • Select Personal access tokens.
  • Click Generate new token.
  • Enter a description like reputation-oracle-ci and set access permissions to Read & Write.
  • Click Generate and copy the token immediately. You will not be able to see it again.
  • Now go to your GitHub repository.
  • Click Settings in the repository navigation.
  • Click Secrets and variables, then Actions.
  • Click New repository secret.
  • Add a secret named DOCKERHUB_USERNAME with your Docker Hub username as the value.
  • Add another secret named DOCKERHUB_TOKEN with the personal access token you just copied.
  • Save .github/workflows/ci.yml.

Docker push step failing with authentication error?

Double check that your GitHub repository secrets are named exactly DOCKERHUB_USERNAME and DOCKERHUB_TOKEN. The names must match what the workflow YAML references.

Confirm that your Docker Hub PAT has Read & Write permissions. A read-only token cannot push images.

help me fix my GitHub Actions Docker Hub authentication

Add a README and push to GitHub

A strong README is what turns a code repository into a portfolio piece. It gives reviewers immediate context about what the project does and how to run it.

  • Create a README.md at the project root by running:
touch README.md
  • Open README.md and add the following sections (customize the contract addresses and URLs with your own deployed values):
# Cross-Chain Reputation Oracle

A cross-chain reputation system that broadcasts on-chain reputation scores from Arbitrum Sepolia to Base Sepolia via Wormhole's Executor framework.

## Architecture

```
[Arbitrum Sepolia]          [Wormhole Network]          [Base Sepolia]
ReputationHub  ──────────▶  VAA + Executor  ──────────▶  ReputationHub
ReputationScorer                                         (receives scores)
```

## Live Contracts

| Chain | Contract | Address |
|-------|----------|---------|
| Arbitrum Sepolia | ReputationHub | [Your address on Arbiscan](https://sepolia.arbiscan.io/address/YOUR_ADDRESS) |
| Arbitrum Sepolia | ReputationScorer | [Your address on Arbiscan](https://sepolia.arbiscan.io/address/YOUR_ADDRESS) |
| Base Sepolia | ReputationHub | [Your address on Basescan](https://sepolia.basescan.org/address/YOUR_ADDRESS) |

## Quick Start

```bash
# Install dependencies
forge install
cd frontend && npm install

# Run tests
forge test -vvv

# Start frontend locally
cd frontend && npm run dev
```

## Deployment

```bash
# Deploy to Arbitrum Sepolia
forge script script/Deploy.s.sol --rpc-url $ARB_SEPOLIA_RPC --broadcast

# Deploy to Base Sepolia
forge script script/Deploy.s.sol --rpc-url $BASE_SEPOLIA_RPC --broadcast
```

## Docker

```bash
docker build -t reputation-oracle .
docker run -p 3000:3000 reputation-oracle
```

## CI/CD

GitHub Actions runs on every push:
1. Compiles Solidity contracts with Foundry
2. Runs the full test suite
3. Builds and pushes Docker image to Docker Hub (on main branch only)

## Tech Stack

- **Contracts**: Solidity, Foundry, Wormhole Solidity SDK v1.1.0
- **Frontend**: Next.js, wagmi v3, viem v2, TanStack Query
- **Infrastructure**: Docker, GitHub Actions, Vercel
- **Networks**: Arbitrum Sepolia, Base Sepolia

What should I customize?

Replace the YOUR_ADDRESS placeholders with your actual deployed contract addresses from Steps 4. You can also add your live Vercel URL and a link to your Wormholescan transaction.

  • Save README.md.
  • Commit all your changes and push to GitHub by running:
git add .
git commit -m "Add Dockerfile, CI/CD pipeline, and README"
git push origin main
  • Go to the Actions tab in your GitHub repository.
  • You should see the CI/CD Pipeline workflow running. The contracts job runs first, followed by the frontend and Docker build.

Workflow failing at the contracts job?

The submodules: recursive checkout option is essential. Without it, the Wormhole Solidity SDK (installed as a git submodule via forge install) won't be available and forge build will fail with missing import errors.

Make sure your .gitmodules file and lib/ folder are committed to your repository.

help me fix my Foundry CI build

✔️ Awesome, I've got everything!

Great. Double check that you've saved all three new files: Dockerfile, .github/workflows/ci.yml, and README.md.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile:

FROM node:20-alpine AS builder

WORKDIR /app

COPY frontend/package*.json ./
RUN npm ci

COPY frontend/ .
RUN npm run build

FROM node:20-alpine AS runner

WORKDIR /app

ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

Here is the complete .github/workflows/ci.yml:

name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  contracts:
    name: Compile & Test Contracts
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1

      - name: Build contracts
        run: forge build

      - name: Run tests
        run: forge test -vvv

  frontend:
    name: Build Frontend & Docker Image
    runs-on: ubuntu-latest
    needs: contracts
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Install dependencies
        working-directory: frontend
        run: npm ci

      - name: Build frontend
        working-directory: frontend
        run: npm run build

      - name: Login to Docker Hub
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push Docker image
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/reputation-oracle:latest

Your project now has a professional DevOps setup. Every push triggers automated contract tests, and every merge to main publishes a fresh Docker image. The README ties it all together for anyone reviewing your repository.

Secret mission

Multi-Chain Reputation Aggregator

Your reputation oracle currently works between two chains. Real protocols need to aggregate scores from many sources. Extend your system to receive reputation from multiple source chains, compute a weighted average, and display per-chain breakdowns on the frontend.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on free testnets and free-tier services, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local project files (Foundry contracts, frontend, tests).
  • GitHub repository.
  • Vercel frontend deployment.
  • Docker Hub image.

What about the deployed smart contracts?

Smart contracts deployed on Arbitrum Sepolia and Base Sepolia persist on-chain forever. There is no way to delete them, but testnet contracts cost nothing to maintain. They will remain accessible indefinitely at no charge.

Keep everything running

No action needed. Choose this if you are still actively building or want to showcase the project to others.

  • Your testnet contracts remain deployed and queryable at no cost.
  • Your Vercel frontend stays live on the free tier.
  • Your Docker Hub image remains available for pulling.
  • Your GitHub repository stays as a portfolio piece.

Pause - I'll come back to this later

Since all resources are free, pausing looks the same as keeping everything running. The only thing to consider is freeing up local disk space.

  • Stop any local dev servers if they are still running by pressing Ctrl+C in each terminal.
  • Your testnet contracts, Vercel deployment, and Docker image will persist at no cost until you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below for the resources you want to clean up.

  • Delete local project files:

macOS

rm -rf ~/Desktop/reputation-oracle

Windows

rmdir /s /q %USERPROFILE%\Desktop\reputation-oracle
  • Delete the GitHub repository by opening your repository page, clicking Settings, scrolling to the bottom, and clicking Delete this repository.
  • Delete the Vercel deployment by opening your Vercel dashboard, selecting the project, clicking Settings, scrolling to the bottom, and clicking Delete Project.
  • Delete the Docker Hub image by logging into Docker Hub, navigating to your repository, clicking Settings, and clicking Delete Repository.

Testnet contracts cannot be deleted

Your smart contracts on Arbitrum Sepolia and Base Sepolia will remain deployed. This is normal for blockchain applications. They cost nothing and will not affect any other projects.

That's a Wrap!

That's a Wrap!

Nice work! You have built and deployed a production-quality cross-chain reputation oracle using Wormhole's Executor framework across two live testnets.

You've learned how to:

  • Deploy a cross-chain messaging protocol using the Wormhole Executor framework on Arbitrum Sepolia and Base Sepolia.
  • Implement secure message passing with peer authorization, replay protection, and payload verification for trustless cross-chain communication.
  • Build a professional DevOps pipeline with Docker containerization, GitHub Actions CI/CD, and verified smart contracts on block explorers.
  • Secret Mission: Extended the system to aggregate reputation across 3+ chains with weighted scoring.

Ready to quiz yourself?