🌿 Intermediate ⏱️ 45 min

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.

📖 What You Will Build

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

ComponentWhat It IsYour RoleAlternative
InfuraHosted Ethereum node — your RPC endpointSign up, get API key, use URL in forge commandsAlchemy, QuickNode, own node
Sepolia TestnetEthereum test network with fake ETHDeploy and test here before mainnetHolesky (validator testing), Goerli (deprecated)
Foundry forge scriptSolidity deployment script runnerWrite a Script contract, run with forge scriptHardhat deploy, raw cast send
Etherscan (Sepolia)Block explorer — read all on-chain activityVerify contract source, read state, watch eventsBlockscout, Tenderly
castFoundry CLI for on-chain interactionCall functions, read storage, decode calldataethers.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.

💻 Infura Setup (5 minutes)
# 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 hash
⚠️ Private Key Safety — Read This First

Create 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

⛽ Getting Free Testnet 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.io

Step 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.

📜 The Contract to Deploy — SimpleVault.sol
// 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; } }
🚀 The Deployment Script — Deploy.s.sol
// 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

📡 Running the Deployment
# 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/0xdef456
📖 What --verify Does

The --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.

🔍 What to Read on Etherscan After Deployment
# 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-mortem

Step 6 — Interact With Your Live Contract Using cast

🔧 cast — Full Interaction Workflow
# 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 --follow

Understanding 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.

🔍 What Happens When You Call deposit()
// 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.

🎯 Finding and Auditing Testnet Protocol Deployments
// 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 EVENT
🧠 The Bug Hunter Mindset on Testnet

Testnet 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

🧪 Fork the Live Testnet — Test With Real 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 -vvvv

Common Deployment Mistakes and How to Avoid Them

MistakeConsequencePrevention
Committing .env to gitPrivate key exposed — bots drain your wallet within secondsAdd .env to .gitignore before your first commit. Use git status to check before every push.
Using your main wallet as deployerIf key is exposed, real funds are at riskAlways use a dedicated testnet-only wallet with zero real ETH
Not pinning the fork blockTests fail non-deterministically as chain advancesAlways use --fork-block-number
Deploying without verificationEtherscan shows bytecode only — cannot inspect state or call functions via UIAlways add --verify --etherscan-api-key
Not testing locally before testnetWasted gas on broken deployments (testnet ETH is limited)Run forge test locally first, then dry-run with forge script without --broadcast
Hardcoded addresses in scriptsScript breaks when redeployed to a different chainUse environment variables or config files per network
✏️ Try It — Full End-to-End Checklist
# 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 --follow

Key Takeaways

  • Always use a dedicated testnet wallet. Export its private key, put it in .env, add .env to .gitignore before your first commit. This is the single most important security habit in deployment work.
  • Dry-run before broadcasting. forge script without --broadcast simulates 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.
  • cast is your on-chain shell. cast call reads state for free, cast send writes transactions, cast storage reads raw slots, cast run replays 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.