bow.fun docs
Open app →

bow.fun documentation

A fair-launch token platform on Robinhood Chain. Launch an ERC-20 two ways — Standard (Uniswap V3 · WETH) or RWA (Uniswap V4 · paired with a tokenized stock) — with liquidity locked forever, earn a share of trading fees, and let tokens graduate at 3.7 ETH. Every token address ends in b03.

Introduction

  • Non-custodial — every action is signed by the user's own wallet.
  • Immutable tokens — no owner, no mint, no tax, no blacklist.
  • LP locked by contract — there is no withdraw / decreaseLiquidity path.
  • One entry point — both modes launch through the FactoryHub contract.

Network

FieldValue
ChainRobinhood Chain
Chain ID4663 (0x1237)
RPChttps://rpc.mainnet.chain.robinhood.com
Explorerhttps://robinhoodchain.blockscout.com
Gas tokenETH
Block-time gotcha. The block.number opcode advances ~every 14 s, even though RPC blocks come ~10/sec. The trading gate (launchDelay) counts opcode blocks — so launchDelay = 1 ≈ 14 seconds. Use launchDelay = 0 for instant trading.

Launch modes

Every launch goes through the FactoryHub — a single launch(slotId, params) entry point where the slot picks the mode:

ModeSlotVersionPoolAccessNotes
Standard13Uniswap V3 · WETHPublicSame mechanics as before — single-sided pool, graduates at 3.7 ETH
RWA35Uniswap V4 · stock quotePublicPaired with 1 of 25 tokenized stocks (AAPL, NVDA, SPY…)

The version in the Launched event tells you what kind of token you're looking at: 3 = V3 (the pool address is set, poolId = 0); 4+ = V4 (the poolId is set, pool = 0 — V4 pools live inside the PoolManager singleton and have no address of their own).

Deployed contracts

ContractAddressRole
FactoryHub0x229Faa919ABf14279E2461Dba53F039c5B4C7E29Launch entry point — all 3 modes (UUPS proxy)
LaunchFactory legacy0xC70E510E14710Ea535CAB7b2414860aF63FEab79Old V3 factory — existing tokens only, no new launches
Legacy Locker0x904dCCB96d877E6db365282251Fa3dD156476660Positions of legacy-factory tokens
BowZap0xCCA95E5442BbF175d8a1Ad136Be317fA6D55CC381-tx buy / sell helper — V3 tokens only
WETH0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73Quote asset (Standard)
USDG0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168RWA routing intermediary — 6 decimals
SwapRouter020xCaf681a66D020601342297493863E78C959E5cb2Uniswap V3 router (Standard tokens)
PositionManager (V3)0x73991a25C818Bf1f1128dEAaB1492D45638DE0D3V3 positions
UniswapV3Factory0x1f7d7550B1b028f7571E69A784071F0205FD2EfAV3 pools
PoolManager (V4)0x8366a39CC670B4001A1121B8F6A443A643e40951V4 singleton — every RWA pool lives inside it
PositionManager (V4)0x58daec3116aae6D93017bAAea7749052E8a04fA7V4 positions
Universal Router0x8876789976dEcBfCbBbe364623C63652db8C0904V4 swaps — a fork, see Trade RWA tokens
Permit20x000000000022D473030F116dDEE9F6B43aC78BA3Token approvals for V4 sells
V4 Quoter0xe8393F168D54B622218Adc36037008f254404C40Exact V4 quotes — view-only, never holds funds
The locker depends on the slot — the hub has no locker() getter. Read it per slot from hub.getSlotInfo(slotId), or use the table in Collect fees.

How it works

Standard (slot 1), one tx: the hub deploys the token via CREATE2 (full 1B supply), creates & prices the V3 WETH pool at your target market cap, mints a single-sided position (only the token), locks the position NFT in the slot's locker forever, writes on-chain socials/IPFS metadata, and optionally performs a dev buy in the same transaction. Graduates when the pool holds 3.7 ETH.

RWA (slot 3): one V4 pool pairing the token against a tokenized stock (the quote token). Price and graduation are denominated in the quote; ETH buys/sells route WETH ↔ USDG ↔ quote ↔ token through three pools (see Trade RWA tokens).

Anti-snipe (all modes): a per-wallet 2% cap is enforced for limitWindow blocks after launch, then lifts. The launch gate blocks public trading for launchDelay opcode-blocks (deployer & launchpad exempt, so the dev buy always lands first).


Developer guide — Setup

All examples use ethers v6 (npm i ethers).

import { ethers } from "ethers";

const RPC = "https://rpc.mainnet.chain.robinhood.com";
const provider = new ethers.JsonRpcProvider(RPC, 4663);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);

const A = {
  hub:     "0x229Faa919ABf14279E2461Dba53F039c5B4C7E29", // FactoryHub — all launches
  weth:    "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
  usdg:    "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
  zap:     "0xCCA95E5442BbF175d8a1Ad136Be317fA6D55CC38", // V3 tokens only
  router:  "0xCaf681a66D020601342297493863E78C959E5cb2", // V3 SwapRouter02
  ur:      "0x8876789976dEcBfCbBbe364623C63652db8C0904", // Universal Router (V4)
  permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
};
const POOL_FEE = 10000; // 1% tier — every launch pool, V3 and V4 (tickSpacing 200 on V4)

Launch a token

All modes share one call: hub.launch(slotId, params). LaunchParams is a 21-field tuple — field order matters. Fields that don't apply to your mode are zero / empty.

const HUB_ABI = [
  "function getSlotInfo(uint8 slotId) view returns (uint8 version, bool active, bool launchEnabled, address npm, address weth, address router, address locker, address treasury, uint256 launchFee)",
  "function launch(uint8 slotId, (string name,string symbol,uint256 totalSupply,uint256 launchDelay,uint256 maxWallet,uint256 limitWindow,bytes32 salt,string description,string website,string telegram,string twitter,string logoURI,string tokenURI,uint256 targetFdvWeth,uint256 devBuyMinTokens,uint64 vestingCliff,uint64 vestingDuration,uint256 seedEth,uint256 buyEth,address quoteToken,uint24 wethQuoteFee) p) payable returns (address token, uint256 positionId)",
  "event Launched(address indexed token, address indexed deployer, uint8 indexed version, uint8 slotId, address pool, bytes32 poolId, uint256 positionId, uint256 launchId)",
];
const hub = new ethers.Contract(A.hub, HUB_ABI, wallet);

// 1. build params as an ORDERED array (Standard / slot 1 shown)
const supply = 1_000_000_000n * 10n ** 18n;   // 1B tokens
const buyEth = ethers.parseEther("0.1");      // dev buy — now an explicit field
const p = [
  "My Token",                    // name
  "mytoken",                     // symbol (any case — lowercase allowed)
  supply,                        // totalSupply
  0n,                            // launchDelay (opcode-blocks; 0 = instant)
  (supply * 200n) / 10000n,       // maxWallet = 2%
  10n,                           // limitWindow (blocks the 2% cap is active)
  ethers.hexlify(ethers.randomBytes(32)), // salt (raw CREATE2 salt — see b03 note)
  "before pepe, there was...",   // description
  "https://mytoken.xyz",          // website  (optional "")
  "https://t.me/mytoken",         // telegram (optional "")
  "https://x.com/mytoken",        // twitter  (optional "")
  "ipfs://<logoCid>",            // logoURI  (optional — pin your own)
  "ipfs://<metaCid>",            // tokenURI (optional metadata json)
  ethers.parseEther("1.5"),      // targetFdvWeth = start market cap in WETH
  0n,                            // devBuyMinTokens (slippage floor; 0 = any)
  0n,                            // vestingCliff    — reserved, leave 0
  0n,                            // vestingDuration — reserved, leave 0
  0n,                            // seedEth         — reserved, leave 0
  buyEth,                        // buyEth = the dev buy
  ethers.ZeroAddress,            // quoteToken   — RWA only
  0,                             // wethQuoteFee — RWA only
];

// 2. launch. msg.value must equal launchFee + buyEth EXACTLY (EthMismatch otherwise)
const info = await hub.getSlotInfo(1);        // [8] = launchFee (currently 0)
const est = await hub.launch.estimateGas(1, p, { value: info[8] + buyEth });
const tx = await hub.launch(1, p, { value: info[8] + buyEth, gasLimit: est * 125n / 100n });
const rc = await tx.wait();

// 3. token address + positionId from the Launched event in the receipt
const ev = rc.logs.map(l => { try { return hub.interface.parseLog(l); } catch { return null; } })
                  .find(e => e && e.name === "Launched");
console.log("token:", ev.args.token, "positionId:", ev.args.positionId.toString());
Gas: a real Standard launch burns ~3.5M gas — some wallets under-estimate and OOG-revert. Always estimateGas and pad ~25% (the site uses max(4.5M, est × 1.25)).

RWA launches (slot 3)

Set quoteToken to one of the 25 supported stock tokens and wethQuoteFee to that stock's USDG-pool fee tier (e.g. NVDA 0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC10000). Your buyEth is routed WETH → USDG → stock → token inside the launch tx.

The b03 address suffix

The hub exposes no predictToken/tokenInitCodeHash helpers, but it CREATE2-deploys with your raw salt, and the token creation code is readable from the on-chain TokenCodeProvider (0xF53C1Be2088dD9345CCC74582e81e0c19F462F1b, via hub.codeProvider()) — so vanity mining still works, and the bow.fun frontend mines …b03 client-side for both modes. If you integrate directly and don't want to reimplement that, just use a random salt and read the token address from the Launched event.

Dev buy goes to you, is exempt from the 2% cap, and lands before any public buyer — it's the explicit buyEth field now, not leftover msg.value. IPFS is optional — pass empty strings for no image.

Buy (Standard) — BowZap.buy

V3 tokens only (Standard / legacy) — RWA tokens trade through the Universal Router, see Trade RWA tokens.

Native ETH → token, in one transaction. The zap wraps your ETH and swaps to the token, sent straight to you.

const ZAP_ABI = ["function buy(address token, uint24 fee, uint256 minOut) payable returns (uint256)"];
const zap = new ethers.Contract(A.zap, ZAP_ABI, wallet);

const ethIn = ethers.parseEther("0.05");
const minOut = 0n;   // slippage floor in tokens (see Quoting)
const tx = await zap.buy(token, POOL_FEE, minOut, { value: ethIn });
await tx.wait();   // tokens sent to wallet.address
During the opening 2% window, a buy larger than 2% of supply reverts with MaxWalletExceeded — that's the intended anti-snipe.

Sell (Standard) — via the router

Don't sell through the Zap during the opening window — the zap would hold your tokens mid-swap and trip the 2% cap. Sell directly through the router so the token flows you → pool (the pool is cap-exempt), then unwrap WETH → native ETH back to you, in one tx.

const ERC20_ABI = [
  "function approve(address,uint256) returns (bool)",
  "function allowance(address,address) view returns (uint256)",
];
const ROUTER_ABI = [
  "function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96) params) payable returns (uint256)",
  "function unwrapWETH9(uint256 amountMinimum, address recipient) payable",
  "function multicall(bytes[] data) payable returns (bytes[])",
];
const tokenC = new ethers.Contract(token, ERC20_ABI, wallet);
const router = new ethers.Contract(A.router, ROUTER_ABI, wallet);

const amountIn = ethers.parseEther("1000000"); // tokens to sell (18 decimals)

// 1. one-time approval of the router
if ((await tokenC.allowance(wallet.address, A.router)) < amountIn) {
  await (await tokenC.approve(A.router, ethers.MaxUint256)).wait();
}

// 2. swap token -> WETH (kept in router), then unwrap to native ETH — atomic
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; // "router keeps output"
const minOut = 0n; // slippage floor in WETH
const call1 = router.interface.encodeFunctionData("exactInputSingle", [{
  tokenIn: token, tokenOut: A.weth, fee: POOL_FEE, recipient: ADDRESS_THIS,
  amountIn, amountOutMinimum: minOut, sqrtPriceLimitX96: 0n,
}]);
const call2 = router.interface.encodeFunctionData("unwrapWETH9", [minOut, wallet.address]);
await (await router.multicall([call1, call2])).wait(); // native ETH to your wallet
Want WETH instead of native ETH? Call exactInputSingle with recipient: wallet.address and skip the multicall/unwrap.

Trade RWA tokens

RWA tokens live in Uniswap V4 pools and trade through the Universal Router: execute(commands, inputs, deadline), where each command byte has one ABI-encoded input.

The deployed router is a fork. Its V4 swap structs carry an extra uint256[] minHopPriceX36 field (uint256 in the single-hop struct) after amountOutMinimum — pass [] / 0 to disable the per-hop check. Stock Uniswap SDK encoding abi-decodes garbage here and reverts.

Route. RWA tokens pair a stock quote, so an ETH buy is three hops: WETH →(200/4)→ USDG →(stock's usdgFee/usdgTs)→ stock →(10000/200)→ token (reverse to sell). Each PathKey is (intermediateCurrency, fee, tickSpacing, hooks, hookData) describing the pool into that currency. All launch pools are fee 10000 / tickSpacing 200, no hooks.

Buy (native ETH in): commands 0x0b10 = WRAP_ETH then V4_SWAP. Wrap to the router (ADDRESS_THIS = 0x…02), then V4 actions SWAP_EXACT_INSETTLE(weth, 0 = open delta, payerIsUser: false)TAKE_ALL(token, minOut). SETTLE_ALL would try to pull WETH from you instead of the router's wrapped balance — that's why plain SETTLE with payerIsUser=false is required.

const UR_ABI = ["function execute(bytes commands, bytes[] inputs, uint256 deadline) payable"];
const ur = new ethers.Contract(A.ur, UR_ABI, wallet);
const abi = ethers.AbiCoder.defaultAbiCoder();
const ROUTER_THIS = "0x0000000000000000000000000000000000000002";

// 3-hop path — NVDA-paired token shown (NVDA's USDG pool is 10000/200; each stock differs)
const stock = "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC";
const path = [
  [A.usdg, 200, 4, ethers.ZeroAddress, "0x"],
  [stock, 10000, 200, ethers.ZeroAddress, "0x"],
  [token, 10000, 200, ethers.ZeroAddress, "0x"],
];
// NOTE the empty [] = minHopPriceX36 — fork field!
const swap = abi.encode(
  ["tuple(address,tuple(address,uint24,int24,address,bytes)[],uint256[],uint128,uint128)"],
  [[A.weth, path, [], ethIn, minOut]]
);
const params = [
  swap,
  abi.encode(["address","uint256","bool"], [A.weth, 0n, false]), // SETTLE: router pays its wrapped WETH
  abi.encode(["address","uint256"], [token, minOut]),               // TAKE_ALL: tokens → you, enforces minOut
];
const actions = "0x070b0f"; // SWAP_EXACT_IN, SETTLE, TAKE_ALL
const inputs = [
  abi.encode(["address","uint256"], [ROUTER_THIS, ethIn]),          // WRAP_ETH — WETH stays in router
  abi.encode(["bytes","bytes[]"], [actions, params]),
];
const deadline = BigInt(Math.floor(Date.now() / 1000) + 600);
await (await ur.execute("0x0b10", inputs, deadline, { value: ethIn })).wait();

Sell: two one-time approvals — ERC-20 approve(permit2, max), then Permit2 approve(token, router, 2¹⁶⁰−1, 2⁴⁸−1). Commands 0x100c = V4_SWAP then UNWRAP_WETH; actions 0x070c0e: SWAP_EXACT_IN (reversed path) → SETTLE_ALL(token, amountIn) pulls your tokens via Permit2 → TAKE(weth, ADDRESS_THIS, 0) leaves WETH in the router, and UNWRAP_WETH(MSG_SENDER = 0x…01, minOut) pays you native ETH.

Quoting & slippage

V3 (Standard): the single-sided pool behaves like a constant-product curve early on. Estimate, or staticCall for an exact quote and apply your slippage %.

// buy: tokens out ≈ supply * ethIn*(1-fee) / (targetFdvWeth + ethIn*(1-fee))
const est = await zap.buy.staticCall(token, POOL_FEE, 0n, { value: ethIn });
const minOut = est * 95n / 100n; // 5% slippage

V4 (RWA): use the on-chain V4 Quoter at 0xe8393F168D54B622218Adc36037008f254404C40 — exact, includes every hop's fees, and never touches funds.

const Q_ABI = [
  "function quoteExactInput((address exactCurrency,tuple(address,uint24,int24,address,bytes)[] path,uint128 exactAmount) p) returns (uint256 amountOut, uint256 gasEstimate)",
];
const quoter = new ethers.Contract("0xe8393F168D54B622218Adc36037008f254404C40", Q_ABI, provider);
const [out] = await quoter.quoteExactInput.staticCall([A.weth, path, ethIn]); // same path as the buy
const minOut = out * 95n / 100n;
V4 slippage reverts surface as V4TooLittleReceived — raise slippage or shrink the trade.

Collect fees

Permissionless — anyone can trigger it; fees route to the configured receivers regardless of caller. Each slot has its own locker (the hub has no locker() getter — it's per slot):

TokensLockerCreator share
Legacy factory0x904dCCB96d877E6db365282251Fa3dD15647666035% of the WETH side
Standard (slot 1)0x7fABf1aE9a26A1Ac15836b892f8bC2de96E0b8F535% of the WETH side
RWA (slot 3)0x90B667f32a9f61CeaD06F194fd0165cf620efA2435% of the quote (stock) side
const LOCKER_ABI = ["function collect(uint256 tokenId)"];
const locker = new ethers.Contract("<locker for this token's slot>", LOCKER_ABI, wallet);
await (await locker.collect(positionId)).wait();
positionId spaces differ. V3 positionIds are Uniswap-V3 NPM tokenIds; V4 positionIds (RWA) are V4 PositionManager tokenIds. The same number can exist in both — always pair a positionId with its locker, never look it up cross-system.

Preview pending fees (V3 tokens, no spend)

const NPM = "0x73991a25C818Bf1f1128dEAaB1492D45638DE0D3";
const NPM_ABI = [
  "function ownerOf(uint256) view returns (address)",
  "function collect((uint256 tokenId,address recipient,uint128 amount0Max,uint128 amount1Max) params) payable returns (uint256 amount0, uint256 amount1)",
];
const npm = new ethers.Contract(NPM, NPM_ABI, provider);
const owner = await npm.ownerOf(positionId); // the locker
const MAX = (1n << 128n) - 1n;
const [a0, a1] = await npm.collect.staticCall(
  { tokenId: positionId, recipient: owner, amount0Max: MAX, amount1Max: MAX },
  { from: owner }
);
const wethTotal = A.weth.toLowerCase() < token.toLowerCase() ? a0 : a1;
const creatorWeth = wethTotal * 3500n / 10000n; // creator's 35% share

V4 positions have no equivalent staticCall preview — pending fees must be computed from PoolManager storage (feeGrowthInside deltas via extsload). The bow.fun dev-profile API does this for you and shows exact claimable amounts.

Read state

const TOKEN_ABI = [
  "function symbol() view returns (string)",
  "function pool() view returns (address)",
  "function migrated() view returns (bool)",        // graduated?
  "function checkMigration() returns (bool)",        // permissionless latch
  "function logoURI() view returns (string)",
  "function GRADUATION_WETH() view returns (uint256)", // 3.7e18 (V3; RWA: graduationQuote() in quote units)
];
const t = new ethers.Contract(token, TOKEN_ABI, provider);
console.log("graduated:", await t.migrated()); // works on every token version — one-way latch

// list all hub launches (all 3 modes)
const LIST_ABI = [
  "function launchCount() view returns (uint256)",
  "function getLaunch(uint256 launchId) view returns (address token, address pool, bytes32 poolId, uint256 positionId, address deployer, uint8 slotId, uint8 version)",
  "function deployerOf(address token) view returns (address)",
];
const f = new ethers.Contract(A.hub, LIST_ABI, provider);
const n = Number(await f.launchCount());
for (let i = n - 1; i >= 0; i--) console.log(await f.getLaunch(i));

// stream new launches over WebSocket (topic0 of the hub's Launched event)
const TOPIC = "0x65f174315961cf8b1c0d0763569c6c8746f20dc81df9151164bec284e6ed9f01";
// Launched(address idx token, address idx deployer, uint8 idx version, uint8 slotId,
//          address pool, bytes32 poolId, uint256 positionId, uint256 launchId)
// wsProvider.on({ address: A.hub, topics: [TOPIC] }, log => { ... })

V4 pool state (price/tick) is read from the PoolManager singleton with extsload: the pool's state root is keccak256(abi.encode(poolId, 6)); word 0 packs sqrtPriceX96 (bits 0–159) and tick (bits 160–183). V4 Swap events are emitted by the PoolManager itself, keyed by bytes32 poolId — filter on your token's poolId from the Launched event.


Gotchas

  • The Universal Router is a fork — its V4 swap structs carry an extra minHopPriceX36 field. Hand-encode as shown in Trade V4 tokens; the stock Uniswap SDK reverts.
  • msg.value must match exactlylaunchFee + buyEth, or the launch reverts with EthMismatch. Do the math in BigInt, never floats.
  • The hub has no predictToken — read the token address from the Launched event in your receipt (the frontend still mines …b03 via the TokenCodeProvider bytecode).
  • V4 Swap amounts are swapper deltas — negative means the swapper paid that currency into the pool (the opposite of the V3 pool-view convention).
  • USDG has 6 decimals — and RWA stock tokens vary; always read decimals(), never assume 18.
  • Symbols keep their casedoge or DOGE, both survive on-chain.
  • b03 is checksummed lowercase — mine against ethers.getAddress(raw).endsWith("b03"), not just the raw bits, or you may get …B03.
  • Trading gate uses opcode blocks (~14s each). launchDelay = 0 → instant.
  • Slippage reverts surface as Too little received (V3) or V4TooLittleReceived (V4) — raise minOut.
  • Immutable token — no owner, mint, tax, pause, or blacklist. setPool/setMetadata are one-time launchpad-only calls in the launch tx.