Deploying to Testnet with Infura
Writing tests locally is essential, but deploying to a public testnet is where theory meets reality. You see real transaction hashes, real gas costs, real block confirmations, and real contract addresses you can share with others. For bug hunters, testnet deployment skills are crucial: many protocols deploy their contracts to testnet before mainnet, and hunting on those deployments is the closest thing to real auditing experience you can get without risking real money.
By the end of this lesson you will have: an Infura account with a working RPC endpoint, a Sepolia testnet wallet funded with test ETH, a Foundry deployment script that deploys and verifies a contract on Sepolia, and the ability to interact with the live contract using cast. You will also understand how to find and interact with testnet deployments from real protocols for bug hunting practice.
The Deployment Stack: What Each Piece Does
| Component | What It Is | Your Role | Alternative |
|---|---|---|---|
| Infura | Hosted Ethereum node — your RPC endpoint | Sign up, get API key, use URL in forge commands | Alchemy, QuickNode, own node |
| Sepolia Testnet | Ethereum test network with fake ETH | Deploy and test here before mainnet | Holesky (validator testing), Goerli (deprecated) |
| Foundry forge script | Solidity deployment script runner | Write a Script contract, run with forge script | Hardhat deploy, raw cast send |
| Etherscan (Sepolia) | Block explorer — read all on-chain activity | Verify contract source, read state, watch events | Blockscout, Tenderly |
| cast | Foundry CLI for on-chain interaction | Call functions, read storage, decode calldata | ethers.js scripts, Remix |
Step 1 — Set Up Infura
Infura gives you a hosted Ethereum node so you don't need to sync one yourself. Every blockchain call your tools make — reading state, submitting transactions — goes through Infura's node to the network.
# 1. Go to https://app.infura.io and create a free account
# Free tier: 100,000 requests/day — more than enough for learning
# 2. Create a new API key:
# Dashboard → Create New API Key → Name it "web3-learning" → Create
# 3. Copy your Sepolia RPC URL — it looks like this:
# https://sepolia.infura.io/v3/YOUR_API_KEY_HERE
# 4. Create a .env file in your Foundry project root:
# (NEVER commit this file — add it to .gitignore immediately)
# .env
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_API_KEY_HERE
PRIVATE_KEY=0xYOUR_DEPLOYER_PRIVATE_KEY
ETHERSCAN_API_KEY=YOUR_ETHERSCAN_KEY
# 5. Add .env to .gitignore RIGHT NOW:
echo ".env" >> .gitignore
# 6. Test the connection:
cast block latest --rpc-url $SEPOLIA_RPC_URL
# Expected output: shows the latest Sepolia block number and hashCreate a dedicated deployer wallet for testnet work — never use your main wallet's private key. In MetaMask: click account icon → Add Account → give it a name like "Testnet Deployer". Export its private key (Settings → Security → Export Private Key). This wallet should contain ONLY test ETH. Even if you accidentally expose this key, you lose nothing real. Keep your main wallet's key permanently offline.
Step 2 — Get Sepolia Test ETH
# Sepolia faucets — visit these in your browser with your wallet address:
# Option 1: Infura Sepolia Faucet (requires Infura account)
# https://www.infura.io/faucet/sepolia
# Gives: 0.5 ETH per day
# Option 2: Alchemy Sepolia Faucet (requires Alchemy account)
# https://sepoliafaucet.com
# Gives: 0.5 ETH per day
# Option 3: Chainlink Faucet (no account required, but slower)
# https://faucets.chain.link/sepolia
# Gives: 0.1 ETH per request
# Verify your balance after claiming:
cast balance YOUR_WALLET_ADDRESS --rpc-url $SEPOLIA_RPC_URL --ether
# Output: 0.500000000000000000
# Sepolia chain ID (useful to know):
# Chain ID: 11155111
# Currency: SepoliaETH (worthless — purely for testing)
# Block explorer: https://sepolia.etherscan.ioStep 3 — Write a Deployment Script
Foundry uses Solidity scripts for deployment — the same language as your contracts. A script is a contract that inherits from Script and runs in a special mode where vm.startBroadcast() marks which calls become real on-chain transactions.
// src/SimpleVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// A simple ETH vault — intentionally basic so you can focus on the deployment flow
contract SimpleVault {
address public immutable owner;
mapping(address => uint256) public balances;
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
constructor() {
owner = msg.sender;
}
function deposit() external payable {
require(msg.value > 0, "Send ETH");
balances[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
emit Withdrawn(msg.sender, amount);
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import "../src/SimpleVault.sol";
contract DeploySimpleVault is Script {
function run() external returns (SimpleVault vault) {
// Load the private key from environment — never hardcode it
uint256 deployerKey = vm.envUint("PRIVATE_KEY");
// Derive the deployer address from the key (for logging)
address deployer = vm.addr(deployerKey);
console.log("Deploying from:", deployer);
console.log("Deployer balance:", deployer.balance);
// vm.startBroadcast() — everything after this line is a real transaction
// vm.stopBroadcast() — everything after this is simulation only
vm.startBroadcast(deployerKey);
vault = new SimpleVault();
vm.stopBroadcast();
console.log("SimpleVault deployed at:", address(vault));
console.log("Owner:", vault.owner());
}
}Step 4 — Deploy and Verify
# Load env vars into your shell
source .env
# DRY RUN first — simulates without sending transactions (free, no gas)
forge script script/Deploy.s.sol:DeploySimpleVault \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY
# Expected output:
## Setting up 1 EVM...
## Deploying from: 0xYourAddress
## Deployer balance: 500000000000000000
## SimpleVault deployed at: 0xSIMULATED_ADDRESS
## [SIMULATION] - No transactions broadcast.
# REAL DEPLOYMENT — add --broadcast to actually send the transaction
forge script script/Deploy.s.sol:DeploySimpleVault \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY \
--broadcast \
--verify \ # Verify source code on Etherscan automatically
--etherscan-api-key $ETHERSCAN_API_KEY \
-vvvv # Maximum verbosity — see every step
# Successful output includes:
## Transaction hash: 0xabc123... ← copy this, paste into sepolia.etherscan.io
## Contract address: 0xdef456... ← your live contract
## Block: 5123456
## Gas used: 312,000
## Verification submitted — check https://sepolia.etherscan.io/address/0xdef456The --verify flag submits your contract's Solidity source code to Etherscan. Once verified, anyone can read your contract's source, call its functions through Etherscan's UI, and see decoded transaction inputs. For bug hunting, verified contracts are much easier to audit — always check if the testnet deployment is verified.
Step 5 — Read Everything on Etherscan
Once deployed, Etherscan is your window into what the contract is actually doing. Every auditor should be fluent in reading Etherscan — it shows you the ground truth of on-chain state, not what the code says it does.
# Open: https://sepolia.etherscan.io/address/YOUR_CONTRACT_ADDRESS
## TAB: "Contract" → "Read Contract"
# Shows all public view/pure functions you can call for free:
# owner() → your deployer address
# getBalance() → 0 (nothing deposited yet)
# balances(addr) → 0 for any address
## TAB: "Contract" → "Write Contract"
# Connect MetaMask → call deposit() with ETH, withdraw(), etc.
# This is what your users will see — tests the actual UX
## TAB: "Events"
# Every Deposited and Withdrawn event with decoded arguments
# For auditors: events are the audit log — missing events = missing transparency
## TAB: "Internal Txns"
# Shows ETH transfers made by the contract
# Critical for spotting unexpected ETH movements in complex protocols
## TRANSACTION PAGE (click any tx hash)
# Input Data → "Decode Input Data" shows the function + arguments called
# This is how you reverse-engineer what an attacker did in a hack post-mortemStep 6 — Interact With Your Live Contract Using cast
# Set your contract address as a variable for convenience
VAULT=0xYOUR_DEPLOYED_CONTRACT_ADDRESS
# ── READ STATE ──────────────────────────────────────────────
# Call owner() — no gas, no signature
cast call $VAULT "owner()(address)" --rpc-url $SEPOLIA_RPC_URL
# Call getBalance() — shows ETH in the vault
cast call $VAULT "getBalance()(uint256)" --rpc-url $SEPOLIA_RPC_URL
# Check balance of a specific depositor
cast call $VAULT "balances(address)(uint256)" $YOUR_WALLET --rpc-url $SEPOLIA_RPC_URL
# Read raw storage slot 0 (stores 'owner' since it's the first state var)
cast storage $VAULT 0 --rpc-url $SEPOLIA_RPC_URL
# Returns: 0x000000000000000000000000YOUR_OWNER_ADDRESS (padded to 32 bytes)
# ── WRITE TRANSACTIONS ───────────────────────────────────────
# Deposit 0.01 ETH into the vault
cast send $VAULT "deposit()" \
--value 0.01ether \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY
# Output includes transaction hash — paste into Etherscan to verify
# Withdraw 0.005 ETH
cast send $VAULT "withdraw(uint256)" 5000000000000000 \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY
# ── DECODE AND INVESTIGATE ──────────────────────────────────
# Decode any calldata (e.g., from a transaction you see on Etherscan)
cast 4byte-decode 0xd0e30db0
# Output: deposit() — 0xd0e30db0 is the function selector for deposit()
# Get the function selector yourself
cast sig "deposit()" # → 0xd0e30db0
cast sig "withdraw(uint256)" # → 0x2e1a7d4d
# Watch events in real-time (like tail -f for the blockchain)
cast logs --rpc-url $SEPOLIA_RPC_URL --address $VAULT --followUnderstanding What You See: Reading a Real Transaction
When you call deposit(), a full sequence of events happens. Understanding each step is what separates someone who "deploys contracts" from someone who truly understands what is happening on-chain — and that understanding is what lets you spot subtle bugs.
// STEP 1: Your wallet signs a transaction object
// {
// from: 0xYourWallet,
// to: 0xVaultAddress,
// value: 10000000000000000 (0.01 ETH in wei),
// data: 0xd0e30db0 (keccak256("deposit()")[0:4] = function selector),
// gasLimit: 50000,
// gasPrice: 5 gwei,
// nonce: 3 (your 4th ever transaction from this wallet)
// }
// STEP 2: Transaction goes to the Sepolia mempool
// Other nodes see it before it's included in a block
// → On mainnet: MEV bots are watching this. For large swaps, sandwich attacks happen here.
// STEP 3: A validator includes it in a block
// The EVM executes your calldata against the vault's bytecode:
// - msg.sender = 0xYourWallet
// - msg.value = 10000000000000000
// - Executes: balances[msg.sender] += msg.value
// - Emits: Deposited(0xYourWallet, 10000000000000000)
// STEP 4: State is committed to the blockchain
// balances[0xYourWallet] is now 10000000000000000 in storage slot keccak256(wallet . 1)
// The vault's ETH balance increased by 0.01 ETH
// This state change is permanent and publicly visible forever
// STEP 5: You can verify all of this with cast
cast call $VAULT "balances(address)(uint256)" $YOUR_WALLET --rpc-url $SEPOLIA_RPC_URL
// → 10000000000000000 (your balance in the vault)
cast balance $VAULT --rpc-url $SEPOLIA_RPC_URL --ether
// → 0.010000000000000000 (ETH held by the contract)Bug Hunting on Testnet Deployments
Many protocols deploy to Sepolia (or Holesky) before mainnet. These testnet deployments are your best practice ground: real codebases, real deployment environments, real complexity — but no financial risk. Here is exactly how to find and work with them.
// WHERE TO FIND TESTNET DEPLOYMENTS:
// 1. Protocol GitHub repos — look for deployments/ or addresses.json
// e.g., https://github.com/aave/aave-v3-core/blob/master/deployments/
// 2. Protocol documentation sites
// Most protocols list testnet contract addresses in their docs
// 3. Immunefi bug bounty scope pages
// Some bounties explicitly list testnet addresses in scope
// 4. Twitter / Discord announcements
// "We just deployed to Sepolia — help us test!"
// WORKFLOW: Once you have a testnet contract address
// Step 1: Get the ABI (application binary interface)
// If verified on Etherscan, download the ABI from the contract page
// Or use cast to get function signatures:
cast etherscan-source $CONTRACT_ADDRESS --chain sepolia
// → Downloads and prints the verified source code
// Step 2: Read all public state
cast call $CONTRACT "owner()(address)" --rpc-url $SEPOLIA_RPC_URL
cast call $CONTRACT "totalSupply()(uint256)" --rpc-url $SEPOLIA_RPC_URL
cast call $CONTRACT "paused()(bool)" --rpc-url $SEPOLIA_RPC_URL
// Step 3: Understand the role structure — who holds what role?
// OpenZeppelin AccessControl: roles are bytes32 hashes
bytes32 MINTER_ROLE = keccak256("MINTER_ROLE");
// = 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6
cast call $CONTRACT \
"hasRole(bytes32,address)(bool)" \
"0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6" \
$SUSPECT_ADDRESS \
--rpc-url $SEPOLIA_RPC_URL
// → true means that address has MINTER_ROLE — is that expected?
// Step 4: Replay interesting transactions locally in a fork
// Fork Sepolia at the block BEFORE a suspicious transaction
anvil --fork-url $SEPOLIA_RPC_URL --fork-block-number BLOCK_BEFORE_TX
// Then replay the exact transaction to study its effects:
cast run TX_HASH --rpc-url $SEPOLIA_RPC_URL
// → Shows full execution trace with every SSTORE, SLOAD, CALL, and EVENTTestnet deployments often have subtle differences from mainnet: different deployer addresses, different initial parameters, sometimes different contract versions. When studying a testnet deployment, always cross-reference with the protocol's official GitHub. If a function exists on-chain but isn't in the source code — that's a finding. If a parameter (like a fee cap) is set differently than documented — that might be a configuration bug. If an admin address on testnet is an EOA rather than a multisig — report it, because the same pattern on mainnet is a critical centralization risk.
Foundry Fork: Test Your Exploit Against the Live Testnet State
// test/LiveVaultTest.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/SimpleVault.sol";
contract LiveVaultTest is Test {
// Pin to a specific Sepolia block — reproducible regardless of when you run it
uint256 constant SEPOLIA_FORK_BLOCK = 6500000;
// Your deployed contract's address on Sepolia
address constant VAULT_ADDRESS = 0xYOUR_DEPLOYED_CONTRACT;
SimpleVault vault;
function setUp() public {
// Fork Sepolia — your test now runs against the actual deployed state
vm.createSelectFork(
vm.envString("SEPOLIA_RPC_URL"),
SEPOLIA_FORK_BLOCK
);
// Cast the already-deployed contract — no need to redeploy
vault = SimpleVault(payable(VAULT_ADDRESS));
}
function test_LiveOwner() public view {
// Reads the ACTUAL owner from the REAL testnet deployment
console.log("Live owner:", vault.owner());
assertNotEq(vault.owner(), address(0));
}
function test_DepositAndWithdraw_LiveFork() public {
address user = makeAddr("user");
vm.deal(user, 1 ether); // Give test user ETH on the fork
vm.prank(user);
vault.deposit{value: 1 ether}();
assertEq(vault.balances(user), 1 ether);
vm.prank(user);
vault.withdraw(1 ether);
assertEq(vault.balances(user), 0);
}
}
// Run against the live Sepolia fork:
// forge test --match-contract LiveVaultTest --rpc-url $SEPOLIA_RPC_URL -vvvvCommon Deployment Mistakes and How to Avoid Them
| Mistake | Consequence | Prevention |
|---|---|---|
| Committing .env to git | Private key exposed — bots drain your wallet within seconds | Add .env to .gitignore before your first commit. Use git status to check before every push. |
| Using your main wallet as deployer | If key is exposed, real funds are at risk | Always use a dedicated testnet-only wallet with zero real ETH |
| Not pinning the fork block | Tests fail non-deterministically as chain advances | Always use --fork-block-number |
| Deploying without verification | Etherscan shows bytecode only — cannot inspect state or call functions via UI | Always add --verify --etherscan-api-key |
| Not testing locally before testnet | Wasted gas on broken deployments (testnet ETH is limited) | Run forge test locally first, then dry-run with forge script without --broadcast |
| Hardcoded addresses in scripts | Script breaks when redeployed to a different chain | Use environment variables or config files per network |
# Complete deployment workflow — run these in order:
# 1. Project setup
forge init my-vault && cd my-vault
echo ".env" >> .gitignore
# → Create .env with SEPOLIA_RPC_URL, PRIVATE_KEY, ETHERSCAN_API_KEY
# 2. Write your contract in src/ and tests in test/
# 3. Run local tests — must pass before touching testnet
forge test -vvv
# 4. Dry-run the deployment script (no broadcast)
source .env
forge script script/Deploy.s.sol:DeploySimpleVault \
--rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
# 5. Deploy for real
forge script script/Deploy.s.sol:DeploySimpleVault \
--rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY \
--broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY -vvvv
# 6. Verify on Etherscan — check contract is verified
# https://sepolia.etherscan.io/address/YOUR_CONTRACT
# 7. Interact with cast
cast call $VAULT "owner()(address)" --rpc-url $SEPOLIA_RPC_URL
cast send $VAULT "deposit()" --value 0.001ether \
--rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
# 8. Run fork test against the live deployment
forge test --match-contract LiveVaultTest \
--rpc-url $SEPOLIA_RPC_URL --fork-block-number LATEST -vvvv
# 9. Watch live events from the contract
cast logs --address $VAULT --rpc-url $SEPOLIA_RPC_URL --followKey Takeaways
- Always use a dedicated testnet wallet. Export its private key, put it in
.env, add.envto.gitignorebefore your first commit. This is the single most important security habit in deployment work. - Dry-run before broadcasting.
forge scriptwithout--broadcastsimulates the full deployment locally — catch errors before spending testnet gas. - Verify your contract on Etherscan. Unverified contracts show only bytecode. Verification unlocks the Read/Write UI, decoded events, and makes your deployment auditable by others.
castis your on-chain shell.cast callreads state for free,cast sendwrites transactions,cast storagereads raw slots,cast runreplays any historical transaction with a full trace.- Fork testnet deployments to test locally.
vm.createSelectFork(SEPOLIA_RPC_URL, blockNumber)gives you an exact snapshot of the real deployment to run Foundry tests against — no gas needed. - Testnet deployments of real protocols are your training ground. Find them via GitHub, docs, and Immunefi scope pages. Study their role structure with
cast call, watch their events, replay suspicious transactions — this is real auditing with no financial risk.