Deploy Your First Contract

Deploy a vulnerable vault to BattleChain, create a Safe Harbor agreement, and open it for attack.

🏛
You are the Protocol
Your contract is audited. Time to stress-test it for real.
You'll deploy a deliberately vulnerable vault, put it under a Safe Harbor agreement, and open it to whitehat attack — before anything like this can happen on mainnet.

Prerequisites

Git

git --version

If not installed: git-scm.com

Foundry

curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version

If you hit issues, see the Foundry installation guide.

Just

just is a command runner used to execute the repo's scripts cleanly. Install it with:

brew install just

Verify: just --version

A Wallet with BattleChain Testnet ETH

You'll need an EVM wallet with ETH on BattleChain to pay for gas. Three steps:

1. Add BattleChain to your wallet

FieldValue
Network NameBattleChain Testnet
RPC URLhttps://testnet.battlechain.com:3051
Chain ID627
ℹ️

See Add BattleChain to MetaMask for a step-by-step guide.

2. Get Sepolia ETH

You need Sepolia ETH to bridge to BattleChain. Go to the Google Cloud faucet, paste your wallet address, and request testnet ETH. You only need a small amount — 0.05 ETH is plenty for this tutorial.

3. Bridge to BattleChain

Open the BattleChain Portal, connect your wallet, and bridge Sepolia ETH to BattleChain Testnet:

  1. Select Sepolia as the source network and BattleChain Testnet as the destination
  2. Enter the amount to bridge
  3. Confirm the transaction in your wallet

Wait for the bridge transaction to finalize — this usually takes a few minutes. Once your BattleChain balance shows ETH, you're ready to deploy.


Setup

Clone the starter repo and install dependencies:

git clone https://github.com/Cyfrin/battlechain-starter
cd battlechain-starter
forge install

Import your key into Foundry's keystore

Never put your private key in a file. Instead, import it into Foundry's encrypted keystore:

cast wallet import battlechain --interactive

You'll be prompted to paste your private key and set an encryption password. The key is stored encrypted at ~/.foundry/keystores/battlechain — the password is required every time you run a script. Your raw private key is never written to disk.

Verify it imported correctly:

cast wallet list

Configure .env

cp .env.example .env

Open .env and set your values. Note that SENDER_ADDRESS is your public wallet address — no private key goes here:

# Your public wallet address
SENDER_ADDRESS=0x...your_wallet_address...

# Filled in after: just setup
TOKEN_ADDRESS=
VAULT_ADDRESS=

# Filled in after: just create-agreement
AGREEMENT_ADDRESS=

# Set to your wallet address (same as SENDER_ADDRESS for this tutorial)
RECOVERY_ADDRESS=

What You're Deploying

Before running anything, understand what you're putting on-chain. Open src/VulnerableVault.sol and look at withdrawAll():

function withdrawAll() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "nothing to withdraw");

    // ❌ INTERACTION before EFFECT
    TOKEN.transfer(msg.sender, amount); // external call — may trigger a hook

    // ❌ Balance cleared after the transfer — too late
    balances[msg.sender] = 0;
}

This is a CEI (Checks-Effects-Interactions) violation. The vault transfers tokens before zeroing the caller's balance. If the token notifies the recipient on transfer — which MockToken does via its hook system — an attacker can re-enter withdrawAll() before the balance ever hits zero, draining the vault entirely.

The correct order is:

// ✅ Effect first, then interaction
balances[msg.sender] = 0;
TOKEN.transfer(msg.sender, amount);

You've shipped the wrong version. That's the point.


Step 1 — Deploy

⚠️

Known issue: out-of-gas failures

Forge estimates gas locally rather than via an RPC call, which can significantly underestimate costs on BattleChain. Transactions may fail with a generic error that doesn't mention gas.

If any just command fails unexpectedly, retry with one of these workarounds:

  • -g 300 — tells Forge to use 3× the estimated gas: forge script ... -g 300
  • --skip-simulation — skips gas estimation entirely: forge script ... --skip-simulation

If the justfile doesn't expose these flags directly, add the flag to the underlying forge script call in the relevant justfile recipe.

⚠️

Required: --legacy flag

BattleChain Testnet does not support EIP-1559 transactions. All forge script calls must include --legacy, otherwise your transaction will be rejected or silently fail.

forge script ... --legacy

If you're using the justfile recipes and hitting unexplained failures, check whether --legacy is present in each underlying forge script call and add it if missing.

just setup deploys MockToken and VulnerableVault, then seeds the vault with 1,000 tokens to simulate protocol liquidity:

just setup

Expected output:

MockToken deployed: 0x...
VulnerableVault deployed: 0x...
Vault seeded with 1000 tokens

--- Add to your .env ---
TOKEN_ADDRESS=0x...
VAULT_ADDRESS=0x...

Copy both addresses into your .env.

Your vault is live on BattleChain and registered with the AttackRegistry.

ℹ️

To verify your contracts on the block explorer, see Verifying Contracts.


Step 2 — Create a Safe Harbor Agreement

The Safe Harbor agreement defines the rules of engagement: which contracts are in scope, what bounty whitehats earn, and where recovered funds go.

Make sure VAULT_ADDRESS is set in .env, then:

just create-agreement

This script:

  • Puts your vault in scope on BattleChain (eip155:627)
  • Sets a 10% bounty, retainable from recovered funds
  • Sets your wallet as the recovery address — drained funds come back here
  • Locks terms for 30 days

Expected output:

Agreement created: 0x...
Commitment window extended 30 days
Safe Harbor adopted

--- Add to your .env ---
AGREEMENT_ADDRESS=0x...

Copy AGREEMENT_ADDRESS into your .env and set RECOVERY_ADDRESS to your deployer wallet address.


Step 3 — Request Attack Mode

Submit your vault for DAO review:

just request-attack-mode

Your agreement is now in ATTACK_REQUESTED state. The DAO will verify:

  • This is a new contract, not a copy of a live mainnet deployment
  • Bounty terms are reasonable
  • Scope is clearly defined

Once approved, the state moves to UNDER_ATTACK and whitehats can begin. Poll for approval:

just check-state
OutputMeaning
2ATTACK_REQUESTED — waiting for DAO
3UNDER_ATTACK — approved, vault is open
ℹ️

On testnet, the DAO moderator is a MockRegistryModerator — a permissionless contract that lets you approve your own request immediately rather than waiting for a real governance action:

cast send 0x6C2DFbdF0714FC8CE065039911758b2821818745 \
  "approveAttack(address)" $AGREEMENT_ADDRESS \
  --account battlechain \
  --rpc-url https://testnet.battlechain.com:3051 \
  --legacy

This is testnet-only. On mainnet, approval is a real DAO action.

Once you see 3, your vault is live and attackable. Head to Execute Your First Attack to see what happens next.


What You Just Accomplished

You deployed a contract, created a Safe Harbor agreement, and opened it to whitehat attack — all on a testnet built for exactly this workflow.

This matters beyond BattleChain. Safe Harbor agreements protect protocols and whitehats on mainnet too. Every production contract holding real funds benefits from a clear agreement that defines scope, bounties, and recovery rules before something goes wrong. The process you just walked through is the same one you'd follow to protect a live deployment.

You now know how to:

  • Deploy contracts to a custom chain with Foundry
  • Import keys securely using cast wallet import (no plaintext private keys)
  • Create and configure a Safe Harbor agreement on-chain
  • Register contracts for coordinated security testing

You've deployed your first contract to BattleChain and set up a Safe Harbor agreement. These are skills you'll use on mainnet — not just here.


Troubleshooting

Stuck or pending transactions

If a transaction gets stuck in a pending state, it usually means it was submitted with too low a gas price or without --legacy. To clear it, send a replacement transaction with the same nonce at a higher gas price:

cast send \
  --account battlechain \
  --rpc-url https://testnet.battlechain.com:3051 \
  --nonce <stuck-nonce> \
  --gas-price <higher-price-in-wei> \
  --legacy \
  --value 0 \
  0x0000000000000000000000000000000000000000

Replace <stuck-nonce> with the nonce of the stuck transaction (visible in your wallet or on the block explorer) and <higher-price-in-wei> with a value meaningfully above the original. This replaces the stuck transaction with a no-op transfer that clears the nonce.

To find your current pending nonce:

cast nonce --rpc-url https://testnet.battlechain.com:3051 <your-wallet-address>

Transaction rejected: EIP-1559 not supported

If you see an error like transaction type not supported or only legacy transactions allowed, add --legacy to your forge script command. See the warning above in Step 1.

Contract too large to deploy

BattleChain enforces the standard EVM contract size limit of 24,576 bytes (EIP-170). If deployment fails with a size-related error, enable the Solidity optimizer in foundry.toml:

[profile.default]
optimizer = true
optimizer_runs = 200