Opinionated, minimal scaffolding to move an ERC‑20 from Substrate to Base using only audited components:
- Snowbridge for Substrate ↔ Ethereum L1
- OP Standard Bridge for Ethereum L1 ↔ Base
This repo gives you ready‑to‑run scripts, tiny ABIs, and a clean way to lock in canonical addresses per network.
substrate-to-base-bridge-starter/
├─ README.md ← this file
├─ .env.sample
├─ ops/
│ └─ addresses.json
├─ packages/
│ ├─ foundry/
│ │ ├─ foundry.toml
│ │ ├─ script/
│ │ │ ├─ DeployL2Token.s.sol
│ │ │ └─ DepositL1toBase.s.sol
│ │ └─ src/
│ │ └─ Interfaces.sol
│ ├─ hardhat/
│ │ ├─ package.json
│ │ ├─ hardhat.config.ts
│ │ └─ scripts/
│ │ ├─ deployL2Token.ts
│ │ └─ deposit.ts
│ └─ snowbridge/
│ ├─ package.json
│ ├─ tsconfig.json
│ ├─ scripts/
│ │ ├─ registerToken.ts
│ │ └─ sendToken.ts
│ └─ abis/
│ └─ Gateway.json
└─ LICENSE
-
Copy
.env.sampleto.envand fill values. -
Install toolchains:
- Foundry:
curl -L https://foundry.paradigm.xyz | bash && foundryup - Node 20+:
corepack enable && corepack prepare pnpm@latest --activate
- Install deps:
cd packages/hardhat && pnpm i
cd ../snowbridge && pnpm i
-
Create the L2 token on Base using the canonical factory (via Foundry or Hardhat script), then deposit from L1 with the Standard Bridge.
-
If the asset is Substrate‑native, first register an L1 representation with Snowbridge and move it L1↔AssetHub using the Snowbridge scripts.
ops/addresses.json holds canonical contract addresses per network. Fill in your L1 token and the L2 token address once created.
Tip: keep this file as the single source of truth for addresses. Scripts load from here.
.env.sample contains environment variables for RPC endpoints, private keys, and token metadata.
Install Foundry
curl -L https://foundry.paradigm.xyz | bash
# restart your shell or:
source ~/.bashrc # or ~/.profile / ~/.zshrc, depending on your shell
foundryup
forge --versionDeploy L2 token
cd packages/foundry
forge script script/DeployL2Token.s.sol --rpc-url $BASE_RPC --broadcastDeposit from L1 to Base
AMOUNT=1000000000000000000 L2_GAS=200000 forge script script/DepositL1toBase.s.sol \
--rpc-url $ETHEREUM_RPC --broadcastDeploy L2 token
cd packages/hardhat
pnpm run deploy:l2Deposit tokens
AMOUNT=1000000000000000000 L2_GAS=200000 pnpm run depositWithdraw from Base to L1 (initiate only):
cd packages/hardhat
pnpm run withdraw -- --network sepolia \
--amount 100000000000000 \
--mode initiate-only \
--pk 0xYOUR_PRIVATE_KEYWithdraw and auto-finalize on L1 (uses OP SDK; polls until ready):
pnpm run withdraw -- --network sepolia \
--amount 100000000000000 \
--mode initiate-and-finalize \
--poll-interval 30 \
--timeout 0 \
--pk 0xYOUR_PRIVATE_KEYFinalize-only (retry later from an existing L2 withdraw tx):
pnpm run withdraw -- --network sepolia \
--mode finalize-only \
--l2-tx 0xYOUR_L2_WITHDRAW_TX_HASH \
--pk 0xYOUR_PRIVATE_KEYNotes:
- The script validates canonical pairing via
remoteToken()and writes machine-readable artifacts underartifacts/withdraw/andartifacts/finalize/. - Optional OP Stack contract overrides can be provided via
ops/addresses.json:ethereum|sepolia.L1CrossDomainMessengerethereum|sepolia.OptimismPortal(optional)base|base_sepolia.L2CrossDomainMessengerIf present, they are passed to the OP SDK to ensure compatibility across chain configs.
Check supply + escrow invariant:
pnpm run check:supply -- --network sepolia --pretty --verify-codehashOutput JSON includes:
l1.escrow_balance,l2.total_supply, per-accountl1_balance/l2_balance(when--accounts a,b,...is provided)- Invariant
invariants.l1_escrow_equals_l2_supplyandinvariants.delta_wei - Exits non-zero if invariant fails unless
--no-strictis set
Register token with Snowbridge:
cd packages/snowbridge
pnpm run registerSend tokens to Substrate:
AMOUNT=1000000000000000000 DEST_BYTES=0x... pnpm run send- Set
L2_GASconservatively for deposits, then tune. - Always track supply conservation across Substrate, L1 escrow, and Base L2 total supply.
- Prefer a Safe for any admin keys.
Policy
- develop: default branch. All work lands here via PRs.
- production: release branch. Only merge from
developvia reviewed PRs. - No direct pushes to
production.
Protect production (GitHub Settings → Branches → Add rule)
- Branch name pattern:
production - Require a pull request before merging
- Require approvals (e.g., 1–2)
- Dismiss stale approvals on new commits
- Require status checks to pass (enable this repo’s CI checks)
- Restrict who can push to matching branches (optional, recommended)
- Do not allow bypassing the above settings
Check branch divergence
git fetch --all --prune
# Counts: A = commits only on develop, B = commits only on production
git rev-list --left-right --count develop...production
# Recent diverged commits (up to 50)
git log --oneline --decorate --graph --left-right develop...production -n 50Syncing
- Bring
developup-to-date withproduction:git checkout develop git merge --no-ff production git push origin develop
- If unwanted commits landed on
production:- Prefer revert via PR:
git checkout production git revert <sha1> <sha2> ... git push origin production
- Hard reset only if safe (destructive):
git checkout production git reset --hard origin/develop git push --force-with-lease origin production
- Prefer revert via PR:
MIT