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:
- Sepolia ETH — Google Cloud faucet
- Base Sepolia ETH — bridge from Sepolia at bridge.base.org
- Polygon Amoy MATIC — Polygon faucet
- 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