Most deploy guides hand you an ERC-20 token with a mint function and call it a day. You copy-paste, deploy, and learn nothing about building something people actually use.
This guide is different. You're building a PvP coin flip game where two players wager USDC, one wins the pot, and your protocol takes a fee. Real money, real users, real revenue. By the end, you'll have a tested, verified, gasless-ready contract on Base that could power an actual product.
Total cost to deploy on Base: under $0.50.
Prerequisites
You need:
- A working dev environment (follow the setup guide if you haven't)
- Foundry or Hardhat installed
- Base Sepolia ETH in your wallet (see the dev environment guide for faucets)
- A Basescan API key
Environment variables
Create a .env file in your project root (and add it to .gitignore):
PRIVATE_KEY=your_wallet_private_key_here
BASESCAN_API_KEY=your_basescan_api_key_here
Never commit your private key. Use a dedicated dev wallet with only testnet funds during development.
The contract: PvP Coin Flip
Here's what we're building. Player A creates a game and deposits a USDC wager. Player B joins, deposits the same amount, and the contract instantly picks a winner. The winner takes the pot minus a protocol fee. Think of it as a decentralized coin toss with built-in monetization.
// src/CoinFlip.sol (Foundry) or contracts/CoinFlip.sol (Hardhat)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// @title CoinFlip - PvP wager game on Base
/// @notice Two players deposit equal USDC wagers. One wins the pot minus protocol fee.
contract CoinFlip is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
IERC20 public immutable usdc;
uint256 public protocolFeeBps; // basis points (500 = 5%)
uint256 public accumulatedFees;
uint256 public nextGameId;
struct Game {
address playerA;
address playerB;
uint256 wager; // USDC amount (6 decimals)
address winner;
bool resolved;
}
mapping(uint256 => Game) public games;
event GameCreated(uint256 indexed gameId, address indexed playerA, uint256 wager);
event GameJoined(uint256 indexed gameId, address indexed playerB);
event GameResolved(uint256 indexed gameId, address indexed winner, uint256 payout);
event FeesWithdrawn(address indexed to, uint256 amount);
error GameNotOpen();
error GameAlreadyResolved();
error CannotPlayYourself();
error InvalidWager();
error NoFeesToWithdraw();
constructor(address _usdc, uint256 _feeBps) Ownable(msg.sender) {
require(_usdc != address(0), "Invalid USDC address");
require(_feeBps <= 1000, "Fee too high"); // max 10%
usdc = IERC20(_usdc);
protocolFeeBps = _feeBps;
}
/// @notice Create a new game and deposit your wager
/// @param wager Amount of USDC to wager (must approve this contract first)
function createGame(uint256 wager) external nonReentrant returns (uint256 gameId) {
if (wager == 0) revert InvalidWager();
gameId = nextGameId++;
games[gameId] = Game({
playerA: msg.sender,
playerB: address(0),
wager: wager,
winner: address(0),
resolved: false
});
usdc.safeTransferFrom(msg.sender, address(this), wager);
emit GameCreated(gameId, msg.sender, wager);
}
/// @notice Join an existing game, deposit matching wager, and resolve immediately
/// @param gameId The game to join
function joinGame(uint256 gameId) external nonReentrant {
Game storage game = games[gameId];
if (game.playerA == address(0) || game.playerB != address(0)) revert GameNotOpen();
if (game.resolved) revert GameAlreadyResolved();
if (msg.sender == game.playerA) revert CannotPlayYourself();
game.playerB = msg.sender;
usdc.safeTransferFrom(msg.sender, address(this), game.wager);
emit GameJoined(gameId, msg.sender);
// Resolve: use prevrandao for randomness
// prevrandao is sufficient for small wagers on L2s but NOT secure for high-value games.
// For production with large pots, integrate Chainlink VRF or similar.
bool playerAWins = uint256(keccak256(abi.encodePacked(
block.prevrandao,
block.timestamp,
gameId,
msg.sender
))) % 2 == 0;
address winner = playerAWins ? game.playerA : game.playerB;
uint256 totalPot = game.wager * 2;
uint256 fee = (totalPot * protocolFeeBps) / 10_000;
uint256 payout = totalPot - fee;
game.winner = winner;
game.resolved = true;
accumulatedFees += fee;
usdc.safeTransfer(winner, payout);
emit GameResolved(gameId, winner, payout);
}
/// @notice Withdraw accumulated protocol fees
function withdrawFees() external onlyOwner nonReentrant {
uint256 fees = accumulatedFees;
if (fees == 0) revert NoFeesToWithdraw();
accumulatedFees = 0;
usdc.safeTransfer(owner(), fees);
emit FeesWithdrawn(owner(), fees);
}
/// @notice Update protocol fee (owner only, max 10%)
function setProtocolFee(uint256 _feeBps) external onlyOwner {
require(_feeBps <= 1000, "Fee too high");
protocolFeeBps = _feeBps;
}
}
Design decisions worth understanding
SafeERC20 everywhere. USDC's transfer returns a bool. If you call it raw and ignore the return value, a failed transfer silently succeeds. SafeERC20 reverts on failure. Non-negotiable for any contract handling real tokens.
ReentrancyGuard on state-changing functions. The joinGame function transfers tokens, updates state, and transfers again. Without reentrancy protection, a malicious token contract could re-enter and drain the pot. Even though USDC is not malicious, guard everything -- it costs minimal gas and prevents entire categories of exploits.
Custom errors instead of require strings. revert InvalidWager() costs less gas than require(wager > 0, "Invalid wager"). Custom errors also encode cleanly for frontend error handling.
Events on every state change. Your frontend needs to know when games are created, joined, and resolved. Events are the standard way to subscribe to contract activity. Every indexed parameter becomes a filterable topic -- so you can query "all games where address X won" efficiently.
prevrandao for randomness. On L2s like Base, block.prevrandao provides pseudo-randomness that's good enough for small wagers. Validators on L2s have less incentive and ability to manipulate it compared to L1. For games with pots above a few hundred dollars, integrate Chainlink VRF -- the extra cost is worth the security guarantee.
Protocol fee capped at 10%. The require(_feeBps <= 1000) in both the constructor and setProtocolFee means you can never set an extractive fee, even by accident. Users can verify this onchain before playing.
Testing: prove it works before you deploy
Deploying untested contracts is how protocols get drained. Every test below catches a specific class of bug.
Option A: deploy with Foundry
Foundry is faster for pure Solidity workflows. If you prefer TypeScript, skip to Option B.
1. Create the project
forge init coinflip
cd coinflip
2. Install dependencies
forge install OpenZeppelin/openzeppelin-contracts --no-commit
3. Configure Foundry
Replace foundry.toml:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
remappings = ["@openzeppelin/=lib/openzeppelin-contracts/"]
[rpc_endpoints]
base = "https://mainnet.base.org"
base_sepolia = "https://sepolia.base.org"
[etherscan]
base = { key = "${BASESCAN_API_KEY}", url = "https://api.basescan.org/api" }
base_sepolia = { key = "${BASESCAN_API_KEY}", url = "https://api-sepolia.basescan.org/api" }
4. Add the contract
Delete the default src/Counter.sol and create src/CoinFlip.sol with the contract code above.
5. Write tests
// test/CoinFlip.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/CoinFlip.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/// @dev Minimal ERC-20 for testing
contract MockUSDC is ERC20 {
constructor() ERC20("USD Coin", "USDC") {}
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract CoinFlipTest is Test {
CoinFlip public flip;
MockUSDC public usdc;
address public owner = address(this);
address public alice = address(0xA11CE);
address public bob = address(0xB0B);
uint256 constant WAGER = 100e6; // 100 USDC
uint256 constant FEE_BPS = 500; // 5%
function setUp() public {
usdc = new MockUSDC();
flip = new CoinFlip(address(usdc), FEE_BPS);
// Fund players
usdc.mint(alice, 10_000e6);
usdc.mint(bob, 10_000e6);
// Approve CoinFlip contract
vm.prank(alice);
usdc.approve(address(flip), type(uint256).max);
vm.prank(bob);
usdc.approve(address(flip), type(uint256).max);
}
// --- Game creation ---
function testCreateGame() public {
vm.prank(alice);
uint256 gameId = flip.createGame(WAGER);
assertEq(gameId, 0);
assertEq(usdc.balanceOf(address(flip)), WAGER);
(address playerA,,uint256 wager,,) = flip.games(gameId);
assertEq(playerA, alice);
assertEq(wager, WAGER);
}
function testCreateGameEmitsEvent() public {
vm.expectEmit(true, true, false, true);
emit CoinFlip.GameCreated(0, alice, WAGER);
vm.prank(alice);
flip.createGame(WAGER);
}
function testCannotCreateZeroWager() public {
vm.prank(alice);
vm.expectRevert(CoinFlip.InvalidWager.selector);
flip.createGame(0);
}
// --- Game joining + resolution ---
function testJoinGameResolvesAndPays() public {
vm.prank(alice);
uint256 gameId = flip.createGame(WAGER);
uint256 aliceBefore = usdc.balanceOf(alice);
uint256 bobBefore = usdc.balanceOf(bob);
vm.prank(bob);
flip.joinGame(gameId);
(,,, address winner, bool resolved) = flip.games(gameId);
assertTrue(resolved);
assertTrue(winner == alice || winner == bob);
// Winner gets pot minus 5% fee
uint256 totalPot = WAGER * 2;
uint256 fee = (totalPot * FEE_BPS) / 10_000;
uint256 expectedPayout = totalPot - fee;
uint256 aliceAfter = usdc.balanceOf(alice);
uint256 bobAfter = usdc.balanceOf(bob);
if (winner == alice) {
assertEq(aliceAfter - aliceBefore, expectedPayout);
assertEq(bobBefore - bobAfter, WAGER);
} else {
assertEq(bobAfter - bobBefore, expectedPayout);
assertEq(aliceBefore - aliceAfter, WAGER);
}
}
// --- Access control ---
function testCannotPlayYourself() public {
vm.prank(alice);
uint256 gameId = flip.createGame(WAGER);
vm.prank(alice);
vm.expectRevert(CoinFlip.CannotPlayYourself.selector);
flip.joinGame(gameId);
}
function testCannotJoinNonExistentGame() public {
vm.prank(bob);
vm.expectRevert(CoinFlip.GameNotOpen.selector);
flip.joinGame(999);
}
function testCannotJoinAlreadyResolvedGame() public {
vm.prank(alice);
uint256 gameId = flip.createGame(WAGER);
vm.prank(bob);
flip.joinGame(gameId);
// Try joining the same game again with a new player
address charlie = address(0xC);
usdc.mint(charlie, 10_000e6);
vm.prank(charlie);
usdc.approve(address(flip), type(uint256).max);
vm.prank(charlie);
vm.expectRevert(CoinFlip.GameNotOpen.selector);
flip.joinGame(gameId);
}
// --- Fees ---
function testFeesAccumulate() public {
vm.prank(alice);
uint256 gameId = flip.createGame(WAGER);
vm.prank(bob);
flip.joinGame(gameId);
uint256 totalPot = WAGER * 2;
uint256 expectedFee = (totalPot * FEE_BPS) / 10_000;
assertEq(flip.accumulatedFees(), expectedFee);
}
function testOwnerCanWithdrawFees() public {
// Play a game to accumulate fees
vm.prank(alice);
uint256 gameId = flip.createGame(WAGER);
vm.prank(bob);
flip.joinGame(gameId);
uint256 fees = flip.accumulatedFees();
assertTrue(fees > 0);
uint256 ownerBefore = usdc.balanceOf(owner);
flip.withdrawFees();
uint256 ownerAfter = usdc.balanceOf(owner);
assertEq(ownerAfter - ownerBefore, fees);
assertEq(flip.accumulatedFees(), 0);
}
function testNonOwnerCannotWithdrawFees() public {
vm.prank(alice);
uint256 gameId = flip.createGame(WAGER);
vm.prank(bob);
flip.joinGame(gameId);
vm.prank(alice);
vm.expectRevert();
flip.withdrawFees();
}
function testCannotWithdrawZeroFees() public {
vm.expectRevert(CoinFlip.NoFeesToWithdraw.selector);
flip.withdrawFees();
}
// --- Fee configuration ---
function testOwnerCanUpdateFee() public {
flip.setProtocolFee(300);
assertEq(flip.protocolFeeBps(), 300);
}
function testCannotSetFeeAboveMax() public {
vm.expectRevert("Fee too high");
flip.setProtocolFee(1001);
}
}
Run the tests:
forge test -vvv
All 12 tests should pass. Each one validates a specific invariant:
- testCreateGame -- verifies USDC moves from player to contract on game creation.
- testCannotCreateZeroWager -- prevents griefing with zero-value games.
- testJoinGameResolvesAndPays -- the core mechanic works end-to-end: join, resolve, pay winner, deduct fee.
- testCannotPlayYourself -- without this check, a player could create and join their own game to manipulate randomness.
- testCannotJoinNonExistentGame / testCannotJoinAlreadyResolvedGame -- prevents double-spend on the same game slot.
- testFeesAccumulate / testOwnerCanWithdrawFees -- your revenue model works.
- testNonOwnerCannotWithdrawFees -- nobody else can drain your fees.
- testCannotWithdrawZeroFees -- clean error instead of a no-op transfer.
6. Write the deployment script
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Script.sol";
import "../src/CoinFlip.sol";
contract DeployScript is Script {
// Base Mainnet USDC: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
// Base Sepolia USDC: use a mock or testnet USDC address
address constant BASE_USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
uint256 constant PROTOCOL_FEE_BPS = 500; // 5%
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
CoinFlip flip = new CoinFlip(BASE_USDC, PROTOCOL_FEE_BPS);
console.log("CoinFlip deployed at:", address(flip));
console.log("USDC address:", BASE_USDC);
console.log("Protocol fee:", PROTOCOL_FEE_BPS, "bps");
vm.stopBroadcast();
}
}
7. Deploy to Base Sepolia
source .env
forge script script/Deploy.s.sol:DeployScript \
--rpc-url base_sepolia \
--broadcast \
--verify \
-vvvv
The --verify flag automatically verifies on Basescan after deployment. Copy the deployed address from the output.
8. Verify on Basescan (if auto-verify failed)
Sometimes auto-verification times out. Run it manually:
forge verify-contract \
0xYOUR_CONTRACT_ADDRESS \
src/CoinFlip.sol:CoinFlip \
--chain base-sepolia \
--constructor-args $(cast abi-encode "constructor(address,uint256)" 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 500) \
--etherscan-api-key $BASESCAN_API_KEY
Option B: deploy with Hardhat
1. Create the project
mkdir coinflip-hardhat
cd coinflip-hardhat
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox @nomicfoundation/hardhat-verify dotenv
npm install @openzeppelin/contracts
npx hardhat init
Select "Create a TypeScript project."
2. Add the contract
Create contracts/CoinFlip.sol with the contract code from above.
3. Configure Hardhat
// hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@nomicfoundation/hardhat-verify";
import * as dotenv from "dotenv";
dotenv.config();
const PRIVATE_KEY = process.env.PRIVATE_KEY || "";
const BASESCAN_API_KEY = process.env.BASESCAN_API_KEY || "";
const config: HardhatUserConfig = {
solidity: "0.8.24",
networks: {
baseSepolia: {
url: "https://sepolia.base.org",
accounts: PRIVATE_KEY ? [PRIVATE_KEY] : [],
chainId: 84532,
},
base: {
url: "https://mainnet.base.org",
accounts: PRIVATE_KEY ? [PRIVATE_KEY] : [],
chainId: 8453,
},
},
etherscan: {
apiKey: {
baseSepolia: BASESCAN_API_KEY,
base: BASESCAN_API_KEY,
},
customChains: [
{
network: "baseSepolia",
chainId: 84532,
urls: {
apiURL: "https://api-sepolia.basescan.org/api",
browserURL: "https://sepolia.basescan.org",
},
},
{
network: "base",
chainId: 8453,
urls: {
apiURL: "https://api.basescan.org/api",
browserURL: "https://basescan.org",
},
},
],
},
};
export default config;
4. Write tests
// test/CoinFlip.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { CoinFlip, MockUSDC } from "../typechain-types";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
describe("CoinFlip", function () {
let flip: CoinFlip;
let usdc: MockUSDC;
let owner: HardhatEthersSigner;
let alice: HardhatEthersSigner;
let bob: HardhatEthersSigner;
let charlie: HardhatEthersSigner;
const WAGER = 100_000_000n; // 100 USDC (6 decimals)
const FEE_BPS = 500n; // 5%
beforeEach(async function () {
[owner, alice, bob, charlie] = await ethers.getSigners();
const MockUSDC = await ethers.getContractFactory("MockUSDC");
usdc = await MockUSDC.deploy();
const CoinFlip = await ethers.getContractFactory("CoinFlip");
flip = await CoinFlip.deploy(await usdc.getAddress(), FEE_BPS);
// Fund and approve
const flipAddress = await flip.getAddress();
for (const player of [alice, bob, charlie]) {
await usdc.mint(player.address, 10_000_000_000n); // 10,000 USDC
await usdc.connect(player).approve(flipAddress, ethers.MaxUint256);
}
});
describe("Game creation", function () {
it("should create a game and transfer USDC", async function () {
await flip.connect(alice).createGame(WAGER);
const game = await flip.games(0);
expect(game.playerA).to.equal(alice.address);
expect(game.wager).to.equal(WAGER);
expect(await usdc.balanceOf(await flip.getAddress())).to.equal(WAGER);
});
it("should emit GameCreated event", async function () {
await expect(flip.connect(alice).createGame(WAGER))
.to.emit(flip, "GameCreated")
.withArgs(0, alice.address, WAGER);
});
it("should revert on zero wager", async function () {
await expect(flip.connect(alice).createGame(0))
.to.be.revertedWithCustomError(flip, "InvalidWager");
});
});
describe("Game joining + resolution", function () {
it("should resolve the game and pay the winner", async function () {
await flip.connect(alice).createGame(WAGER);
const aliceBefore = await usdc.balanceOf(alice.address);
const bobBefore = await usdc.balanceOf(bob.address);
await flip.connect(bob).joinGame(0);
const game = await flip.games(0);
expect(game.resolved).to.be.true;
expect([alice.address, bob.address]).to.include(game.winner);
const totalPot = WAGER * 2n;
const fee = (totalPot * FEE_BPS) / 10_000n;
const payout = totalPot - fee;
const aliceAfter = await usdc.balanceOf(alice.address);
const bobAfter = await usdc.balanceOf(bob.address);
if (game.winner === alice.address) {
expect(aliceAfter - aliceBefore).to.equal(payout);
expect(bobBefore - bobAfter).to.equal(WAGER);
} else {
expect(bobAfter - bobBefore).to.equal(payout);
expect(aliceBefore - aliceAfter).to.equal(WAGER);
}
});
});
describe("Access control", function () {
it("should prevent playing yourself", async function () {
await flip.connect(alice).createGame(WAGER);
await expect(flip.connect(alice).joinGame(0))
.to.be.revertedWithCustomError(flip, "CannotPlayYourself");
});
it("should prevent joining a non-existent game", async function () {
await expect(flip.connect(bob).joinGame(999))
.to.be.revertedWithCustomError(flip, "GameNotOpen");
});
it("should prevent joining an already resolved game", async function () {
await flip.connect(alice).createGame(WAGER);
await flip.connect(bob).joinGame(0);
await expect(flip.connect(charlie).joinGame(0))
.to.be.revertedWithCustomError(flip, "GameNotOpen");
});
});
describe("Fees", function () {
it("should accumulate fees after a game", async function () {
await flip.connect(alice).createGame(WAGER);
await flip.connect(bob).joinGame(0);
const totalPot = WAGER * 2n;
const expectedFee = (totalPot * FEE_BPS) / 10_000n;
expect(await flip.accumulatedFees()).to.equal(expectedFee);
});
it("should allow owner to withdraw fees", async function () {
await flip.connect(alice).createGame(WAGER);
await flip.connect(bob).joinGame(0);
const fees = await flip.accumulatedFees();
const ownerBefore = await usdc.balanceOf(owner.address);
await flip.withdrawFees();
expect(await usdc.balanceOf(owner.address)).to.equal(ownerBefore + fees);
expect(await flip.accumulatedFees()).to.equal(0);
});
it("should prevent non-owner from withdrawing fees", async function () {
await flip.connect(alice).createGame(WAGER);
await flip.connect(bob).joinGame(0);
await expect(flip.connect(alice).withdrawFees()).to.be.reverted;
});
it("should revert when no fees to withdraw", async function () {
await expect(flip.withdrawFees())
.to.be.revertedWithCustomError(flip, "NoFeesToWithdraw");
});
});
describe("Fee configuration", function () {
it("should allow owner to update fee", async function () {
await flip.setProtocolFee(300);
expect(await flip.protocolFeeBps()).to.equal(300);
});
it("should reject fee above 10%", async function () {
await expect(flip.setProtocolFee(1001)).to.be.revertedWith("Fee too high");
});
});
});
You also need the MockUSDC contract for Hardhat tests. Create contracts/test/MockUSDC.sol:
// contracts/test/MockUSDC.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockUSDC is ERC20 {
constructor() ERC20("USD Coin", "USDC") {}
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
Run the tests:
npx hardhat test
5. Write the deployment script
// scripts/deploy.ts
import { ethers } from "hardhat";
const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const PROTOCOL_FEE_BPS = 500; // 5%
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying with:", deployer.address);
const balance = await ethers.provider.getBalance(deployer.address);
console.log("Balance:", ethers.formatEther(balance), "ETH");
const CoinFlip = await ethers.getContractFactory("CoinFlip");
const flip = await CoinFlip.deploy(BASE_USDC, PROTOCOL_FEE_BPS);
await flip.waitForDeployment();
const address = await flip.getAddress();
console.log("CoinFlip deployed to:", address);
console.log(`Verify: npx hardhat verify --network baseSepolia ${address} "${BASE_USDC}" ${PROTOCOL_FEE_BPS}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
6. Deploy and verify
npx hardhat run scripts/deploy.ts --network baseSepolia
npx hardhat verify --network baseSepolia 0xYOUR_CONTRACT_ADDRESS "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" 500
For mainnet:
npx hardhat run scripts/deploy.ts --network base
npx hardhat verify --network base 0xYOUR_CONTRACT_ADDRESS "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" 500
Verify on Basescan
Verification lets anyone read your source code on Basescan and interact with your contract through the browser. Both Foundry's --verify flag and Hardhat's verify task submit your source to Basescan's API.
If verification fails immediately after deployment, wait 30 seconds. Basescan needs time to index the deployment transaction.
Once verified, go to your contract on sepolia.basescan.org (or basescan.org for mainnet). The "Read Contract" and "Write Contract" tabs let you call functions directly in the browser -- useful for testing createGame, joinGame, and withdrawFees without writing frontend code.
Going gasless: sponsored transactions and account abstraction
Your contract works. But if you ship a gambling app that asks users to hold ETH for gas before they can play, most will bounce. The friction between "I want to bet" and "I need to buy ETH on an exchange, bridge it to Base, then come back" kills conversion.
Base solves this with sponsored transactions and account abstraction. Your users never see gas.
Base Account (formerly Smart Wallet)
Base Account gives users smart contract wallets created with passkeys -- a fingerprint or Face ID, no browser extension, no seed phrase. This is the default wallet for new users on Base.
For your coin flip game, this means: a player can sign up with their fingerprint, approve USDC, create a game, and never once interact with MetaMask or think about gas.
Paymasters: your app pays the gas
A Paymaster is a contract that sponsors gas on behalf of your users. When your app sends a transaction via wallet_sendCalls, it includes paymaster capabilities that tell the network "charge gas to this paymaster, not the user."
This is how it works at the protocol level:
- User initiates an action (e.g., "Create a $10 game")
- Your app builds the transaction and attaches paymaster data
- The Paymaster verifies the request and agrees to pay gas
- The transaction executes -- user pays $0 in gas
On Base, gas costs are fractions of a cent. Sponsoring thousands of transactions per day might cost you a few dollars. Compared to the user drop-off from gas friction, it's the best money you'll spend.
Batch transactions: one click, multiple calls
Without batching, creating a coin flip game requires two separate transactions: first approve USDC spending, then call createGame. Two wallet popups, two confirmations, two waits. Users hate this.
With wallet_sendCalls, you batch both into a single atomic action. The user clicks once, both calls execute together. If either fails, both revert. This is especially powerful for the joinGame flow: approve + join + resolve all happen in one click.
Sub Accounts: seamless gaming without popups
For a gambling app, constant wallet popups destroy the experience. Sub Accounts are app-scoped wallets that your app controls within predefined limits. A player can authorize your app to spend up to 100 USDC on games, and every subsequent game creation or join happens instantly -- no popup, no confirmation delay.
Think of it like a casino chip balance. The player deposits once, then plays freely until the balance runs out.
Magic Spend
Magic Spend lets users spend their Coinbase balance directly onchain without a separate bridging step. A player with $50 in their Coinbase account can join a $10 coin flip game without first withdrawing to a wallet and bridging to Base. The funds appear onchain at transaction time.
ERC-20 gas payment
Users can pay gas fees in USDC instead of ETH. For a gambling app denominated in USDC, this eliminates the need to hold a second token entirely. Combined with paymaster sponsorship, your users may never need to touch ETH at all.
OnchainKit integration: gasless coin flip in 20 lines
OnchainKit wraps all of the above into React components. The <Transaction> component handles paymaster integration, batch calls, and wallet connection automatically.
Here's a complete gasless "Create Game" button that batches the USDC approval and game creation into one click with sponsored gas:
import { Transaction, TransactionButton, TransactionStatus } from '@coinbase/onchainkit/transaction';
import { encodeFunctionData, parseUnits } from 'viem';
const COINFLIP_ADDRESS = '0x...'; // Your deployed CoinFlip address
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const erc20Abi = [
{
name: 'approve',
type: 'function',
stateMutability: 'nonpayable',
inputs: [
{ name: 'spender', type: 'address' },
{ name: 'amount', type: 'uint256' }
],
outputs: [{ type: 'bool' }]
}
] as const;
const coinFlipAbi = [
{
name: 'createGame',
type: 'function',
stateMutability: 'nonpayable',
inputs: [{ name: 'wager', type: 'uint256' }],
outputs: [{ name: 'gameId', type: 'uint256' }]
}
] as const;
function CreateGame({ wagerAmount }: { wagerAmount: number }) {
const wagerWei = parseUnits(wagerAmount.toString(), 6);
const calls = [
// Step 1: Approve USDC spending
{
to: USDC_ADDRESS,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'approve',
args: [COINFLIP_ADDRESS, wagerWei]
})
},
// Step 2: Create the game
{
to: COINFLIP_ADDRESS,
data: encodeFunctionData({
abi: coinFlipAbi,
functionName: 'createGame',
args: [wagerWei]
})
}
];
return (
<Transaction
chainId={8453}
calls={calls}
capabilities={{
paymasterService: {
url: process.env.NEXT_PUBLIC_PAYMASTER_URL
}
}}
>
<TransactionButton text={`Wager $${wagerAmount} (Gas Free)`} />
<TransactionStatus />
</Transaction>
);
}
The user sees a single "Wager $10 (Gas Free)" button. Behind the scenes: their wallet batches the USDC approval and game creation, the paymaster covers gas, and the game goes live onchain. Zero friction.
When to sponsor gas
Not every transaction needs sponsoring. A practical framework:
Sponsor it: Onboarding actions (first game, first interaction -- remove friction at the top of funnel). High-frequency actions (joining games, claiming winnings -- gas adds up and kills engagement). Retention moments (coming back after inactivity -- don't let gas be the reason they bounce).
Let users pay: High-value transactions where gas is negligible relative to the amount. Speculative or adversarial actions where you want a small cost to prevent spam.
For a coin flip game with $5-100 wagers, sponsoring every transaction makes sense. Gas on Base is fractions of a cent; the conversion lift from zero-friction gameplay pays for itself many times over.
For full implementation details, see the Coinbase Smart Wallet guide.
Deploy to mainnet checklist
Before going live with real USDC:
- All tests pass (
forge testornpx hardhat test) - Contract deployed and verified on Base Sepolia
- You've played test games on Sepolia (create, join, check winner, withdraw fees)
protocolFeeBpsis set to your intended value- Deployer wallet has at least 0.002 ETH on Base mainnet
- You've tested against a local Base mainnet fork:
forge test --fork-url https://mainnet.base.org -vvv - Consider a security audit for contracts handling significant value (see the security checklist)
Deploy to mainnet:
Foundry:
forge script script/Deploy.s.sol:DeployScript \
--rpc-url base \
--broadcast \
--verify \
-vvvv
Hardhat:
npx hardhat run scripts/deploy.ts --network base
Interacting with your deployed contract
Using cast (Foundry)
# Read protocol fee
cast call 0xYOUR_CONTRACT --rpc-url base_sepolia "protocolFeeBps()(uint256)"
# Read accumulated fees
cast call 0xYOUR_CONTRACT --rpc-url base_sepolia "accumulatedFees()(uint256)"
# Read a game's details
cast call 0xYOUR_CONTRACT --rpc-url base_sepolia \
"games(uint256)(address,address,uint256,address,bool)" 0
# Approve USDC spending (required before createGame)
cast send 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 --rpc-url base_sepolia \
--private-key $PRIVATE_KEY \
"approve(address,uint256)" 0xYOUR_CONTRACT 100000000
# Create a game with 100 USDC wager
cast send 0xYOUR_CONTRACT --rpc-url base_sepolia \
--private-key $PRIVATE_KEY \
"createGame(uint256)(uint256)" 100000000
# Join game ID 0
cast send 0xYOUR_CONTRACT --rpc-url base_sepolia \
--private-key $PRIVATE_KEY \
"joinGame(uint256)" 0
# Withdraw accumulated fees (owner only)
cast send 0xYOUR_CONTRACT --rpc-url base_sepolia \
--private-key $PRIVATE_KEY \
"withdrawFees()"
Using Basescan
Once verified, go to your contract on sepolia.basescan.org. The "Read Contract" tab shows game states, accumulated fees, and protocol settings. The "Write Contract" tab lets you create games, join games, and withdraw fees directly through the browser after connecting your wallet.
Deployment cost breakdown
Base is cheap. Here's what the CoinFlip contract costs:
- Deploying the contract: $0.10-0.50
- Creating a game (approve + createGame): $0.002-0.01
- Joining a game (approve + joinGame + resolution + payout): $0.003-0.02
- Withdrawing fees: $0.001-0.005
These costs fluctuate with Ethereum L1 gas prices (Base posts data to L1), but they're consistently 100-1000x cheaper than Ethereum mainnet. After EIP-4844, Base transaction costs dropped by another 10x.
Common mistakes
Using the wrong chain ID. Base Mainnet is 8453. Base Sepolia is 84532. Mixing these up means your transaction goes nowhere.
Constructor arguments mismatch during verification. The constructor args you pass to verify must exactly match what you used during deployment. For CoinFlip, that's the USDC address and fee in basis points -- not human-readable percentages.
Forgetting USDC approval before createGame. safeTransferFrom reverts if the contract isn't approved to spend the player's USDC. Your frontend must call approve first (or batch it with wallet_sendCalls).
Testing with 18-decimal math instead of 6. USDC has 6 decimals, not 18. 100e6 is 100 USDC. 100e18 would be 100 trillion USDC -- and your test would fail on insufficient balance.
Not testing on a fork first. Before deploying to testnet, test against a local Base fork. It's free, instant, and catches issues that unit tests miss:
forge test --fork-url https://mainnet.base.org -vvv
Using prevrandao for high-value games. The randomness in this contract works for small wagers but is technically manipulable. If your game handles pots above a few hundred dollars, integrate Chainlink VRF before going to mainnet.
What's next
You've deployed a real contract that handles real money. Here's where to go:
- Set up Coinbase Smart Wallet -- full integration guide for passkey wallets and sponsored transactions
- Discover what's being built on Base with Sonarbot -- a curated feed of the best projects and agents shipping onchain
- Build DeFi integrations -- compose with Aerodrome, Morpho, and the Base DeFi stack
- Add Chainlink VRF for provably fair randomness on high-value games
- Build a frontend with OnchainKit that turns the contract interactions above into a one-click experience
You're onchain. Ship fast, iterate faster.