← All guides
intermediate May 16, 2026

Cross-chain deploy: same contract on Sepolia, Base and Polygon Amoy

Deploy the same Solidity contract to three EVM testnets in one workflow using Foundry. Covers deterministic addresses with CREATE2 and multi-network verification.


One of the strengths of EVM-compatible networks is that the same contract bytecode can run on any of them. This guide deploys a single contract to Sepolia, Base Sepolia and Polygon Amoy in one Foundry workflow.

Why deploy to multiple testnets

  • Test cross-chain behavior before going to mainnet
  • Validate that your contract behaves identically across chains
  • Prepare for a multi-chain production deployment
  • Get deterministic addresses on all chains using CREATE2

Prerequisites

  • Foundry installed and up to date (foundryup)
  • Testnet ETH on all three networks:
  • API keys: Etherscan, Basescan, Polygonscan (all free)

1. Project setup

forge init cross-chain-demo
cd cross-chain-demo

2. Configure all three networks in foundry.toml

[profile.default]
src = "src"
out = "out"
libs = ["lib"]

[rpc_endpoints]
sepolia      = "${SEPOLIA_RPC_URL}"
base_sepolia = "${BASE_SEPOLIA_RPC_URL}"
polygon_amoy = "${POLYGON_AMOY_RPC_URL}"

[etherscan]
sepolia      = { key = "${ETHERSCAN_API_KEY}",  url = "https://api-sepolia.etherscan.io/api" }
base_sepolia = { key = "${BASESCAN_API_KEY}",   url = "https://api-sepolia.basescan.org/api" }
polygon_amoy = { key = "${POLYGONSCAN_API_KEY}", url = "https://api-amoy.polygonscan.com/api" }

3. Environment variables

# RPCs
SEPOLIA_RPC_URL=https://rpc.sepolia.org
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
POLYGON_AMOY_RPC_URL=https://rpc-amoy.polygon.technology

# Wallet (same key works on all EVM chains)
PRIVATE_KEY=0xYOUR_DEV_WALLET_PRIVATE_KEY

# Explorer API keys
ETHERSCAN_API_KEY=...
BASESCAN_API_KEY=...
POLYGONSCAN_API_KEY=...

# CREATE2 salt for deterministic address
DEPLOY_SALT=0x0000000000000000000000000000000000000000000000000000000000000001

4. The contract

A simple registry that stores a value per address — useful enough to test real interactions, simple enough to focus on the deployment workflow:

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

contract Registry {
    mapping(address => string) public records;
    uint256 public totalRecords;
    uint256 public immutable chainId;

    event Registered(address indexed user, string value);

    constructor() {
        chainId = block.chainid;
    }

    function register(string calldata value) external {
        require(bytes(value).length > 0, "Empty value");
        if (bytes(records[msg.sender]).length == 0) totalRecords++;
        records[msg.sender] = value;
        emit Registered(msg.sender, value);
    }
}

Note: storing block.chainid in the constructor lets you verify on-chain which network the contract is on.

5. Deployment script with CREATE2

Using CREATE2 gives you the same contract address on every chain, as long as the bytecode and salt are identical:

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

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

contract Deploy is Script {
    function run() external {
        bytes32 salt = vm.envBytes32("DEPLOY_SALT");

        vm.startBroadcast();

        Registry registry = new Registry{salt: salt}();

        console.log("Deployed Registry at:", address(registry));
        console.log("Chain ID:", registry.chainId());

        vm.stopBroadcast();
    }
}

6. Deploy to all three networks

Run the script three times, once per network:

source .env

# Sepolia
forge script script/Deploy.s.sol \
  --rpc-url sepolia \
  --private-key $PRIVATE_KEY \
  --broadcast --verify

# Base Sepolia
forge script script/Deploy.s.sol \
  --rpc-url base_sepolia \
  --private-key $PRIVATE_KEY \
  --broadcast --verify

# Polygon Amoy
forge script script/Deploy.s.sol \
  --rpc-url polygon_amoy \
  --private-key $PRIVATE_KEY \
  --broadcast --verify

If you used the same DEPLOY_SALT, the contract address will be identical on all three chains.

7. Verify the addresses match

# Read chainId from each deployment — should match the network
cast call <CONTRACT_ADDRESS> "chainId()(uint256)" --rpc-url $SEPOLIA_RPC_URL
cast call <CONTRACT_ADDRESS> "chainId()(uint256)" --rpc-url $BASE_SEPOLIA_RPC_URL
cast call <CONTRACT_ADDRESS> "chainId()(uint256)" --rpc-url $POLYGON_AMOY_RPC_URL

Expected output:

  • Sepolia: 11155111
  • Base Sepolia: 84532
  • Polygon Amoy: 80002

8. Interact on each chain

# Register on Sepolia
cast send <CONTRACT_ADDRESS> "register(string)" "hello-sepolia" \
  --private-key $PRIVATE_KEY --rpc-url $SEPOLIA_RPC_URL

# Register on Base Sepolia
cast send <CONTRACT_ADDRESS> "register(string)" "hello-base" \
  --private-key $PRIVATE_KEY --rpc-url $BASE_SEPOLIA_RPC_URL

# Read back on Polygon
cast call <CONTRACT_ADDRESS> \
  "records(address)(string)" <YOUR_ADDRESS> \
  --rpc-url $POLYGON_AMOY_RPC_URL

Each chain has independent state — the registration on Sepolia does not appear on Base or Polygon. This is expected and the key thing to understand before building cross-chain protocols.

Chain IDs reference

Network Chain ID Explorer
Ethereum Sepolia 11155111 sepolia.etherscan.io
Base Sepolia 84532 sepolia.basescan.org
Polygon Amoy 80002 amoy.polygonscan.com

Next steps

  • Add a cross-chain messaging layer (LayerZero, Wormhole, Hyperlane) to sync state between deployments
  • Use a Makefile to run all three deployments in one command
  • Set up a CI pipeline that deploys to all testnets on every merge to main