ObolDOCS

Documentation · v0 · unaudited

Every coin struck at
the price you paid

OBOL is an Obol-inspired token economy for Robinhood Chain: a fixed-supply ERC-20 governance token, a Uniswap v4 hook that ferries a toll to a retroactive-funding treasury, and ERC-402 — a draft standard where the token itself remembers each holder's cost basis.

Solidity 0.8.26 Uniswap v4 ERC-20 · Permit · Votes ERC-402 Robinhood Chain 20 / 20 tests
Independent & unofficial. Built for study. Not affiliated with, endorsed by, or a representation of Obol Labs, the Obol Collective, or their OBOL token — and it shares none of that token's contract code, deployment, or authority. The genuine OBOL token lives at 0x0B01…D5F7 on Ethereum; this is a different contract on a different chain.

Quickstart

The repo is a standard Foundry project. Dependencies (Uniswap v4-core / v4-periphery, OpenZeppelin, forge-std) are vendored under lib/.

# build all contracts
forge build

# run the full suite (ERC-402 accounting, token, v4 integration)
forge test -vv
PathWhat it is
src/ObolToken.solThe OBOL token — ERC-20 + Permit + Votes + ERC-402.
src/hooks/CharonTollHook.solUniswap v4 hook: launch-fee decay, toll, ERC-402 striker.
src/erc402/IERC402.sol + ERC402.sol reference implementation.
spec/ERC-402.mdThe draft standard, written up EIP-style.
src/launch/SingleSidedSeed — computes the single-sided launch range and liquidity.
script/Deploy.s.solOne-shot deploy + hook-address mining + pool init + single-sided seed.

Architecture

A single buy on the OBOL pool touches every piece in one transaction: the hook taxes the ETH side, enforces the max buy, and strikes the buyer's coins at the execution price.

ERC-402 — Price-Struck Fungible Tokens

ERC-20 tokens forget their history the moment coins move. ERC-402 tokens don't. Each holder carries an on-chain weighted-average strike price — a native cost basis, denominated in a declared quote asset. This makes PnL, tax lots, and execution reporting consensus facts rather than an indexer's opinion — which is exactly what a brokerage-grade chain needs.

Coins acquire a strike price two ways:

  • Transfers carry their strike. A recipient's average blends in the sender's average, weighted by amount. The sender's average never changes.
  • Strikers declare execution prices. Authorized venues (like the AMM hook that watched your swap execute) call strike() with the exact price, which takes precedence over blending for that delivery.
Why "402"? No ERC numbered 402 has been assigned by the EIP editors — this draft claims the number evocatively. HTTP 402 Payment Required is the web's placeholder for a price; ERC-402 records the price that was actually paid.

Interface IERC402 · requires ERC-20, ERC-165

interface IERC402 {
    event Struck(address indexed striker, address indexed account,
                 uint256 amount, uint256 priceWad);
    event StrikerSet(address indexed striker, bool allowed);

    function quoteAsset() external view returns (address);
    function priceStruckOf(address account) external view returns (uint256 avgPriceWad);
    function costBasisOf(address account) external view returns (uint256 quoteValue);
    function strikeCreditOf(address account) external view returns (uint256);
    function isStriker(address account) external view returns (bool);
    function strike(address account, uint256 amount, uint256 priceWad) external;
}

Units. Prices are wads — quote-asset smallest units per 1e18 token units. quoteAsset() may be address(0) for the native currency. By definition costBasisOf(a) == balanceOf(a) * priceStruckOf(a) / 1e18.

Accounting rules average-cost method

EventEffect on the average strike
Transfer A→B of nB's average becomes the amount-weighted blend of B's old average and A's average. A is unchanged.
strike(B, n, p)Blends n coins at price p into B immediately, and records a strike credit of n. Reverts unless the caller is a striker.
Delivery to a credited accountUp to credit coins skip blending (already priced) and reduce the credit. Remainder blends normally.
MintBlends at price 0 ("struck at genesis") unless a striker credited it first.
BurnNever changes the burner's average.
Why strike credits exist. Uniswap v4 settles with flash accounting: the hook runs before the coins physically move. The hook knows the execution price, so it strikes first; the credit lets the subsequent delivery skip re-blending. strike() must be called before the matching delivery, in the same transaction.

Contracts

Three contracts, wired together at deploy time. The token trusts the hook as an ERC-402 striker; the hook holds the treasury address and toll parameters.

ObolToken is ERC402, ERC20Permit, ERC20Votes, Ownable

Fixed supply of 600,000,000 OBOL, minted once at construction to the deployer — and every last coin is seeded into the launch pool. No team allocation, no vesting cliffs; the treasury is funded by the launch tax and toll instead. Governance ships in the token — it's ERC-20 Votes, delegable from block one — and permit enables gasless approvals.

constructor(
    address owner_,      // receives the full supply, for seeding the pool
    address quoteAsset_  // ERC-402 strike denomination (0 = native ETH)
)
MemberNotes
setStriker(account, allowed)onlyOwner. Authorize/revoke an ERC-402 striker — grant this to the CharonTollHook after deploy.
TOTAL_SUPPLYConstant, 600_000_000e18.
delegate / getVotesStandard ERC-20 Votes governance surface.

CharonTollHook is BaseHook, Ownable

A Uniswap v4 hook with four duties, all firing inside the swap.

  • The launch tax — buys and sells both open under a 50% tax that decays linearly to the resting 0.10% toll over the first hour, collected in the quote currency (ETH) for the treasury. Buy-side tax accrues as ERC-6909 claims (the buyer's ETH hasn't settled when the hook runs) redeemed via collectTax(); sell-side tax is taken from the ETH output directly. LP fee stays a flat 0.30%.
  • The max buy — any single swap buying more than 0.77% of supply reverts. Owner-tunable, 0 disables.
  • Charon's toll — after the decay, the resting 0.10% tax remains on every crossing (capped at 1%, owner-tunable).
  • The striker — when the pool trades OBOL, each buy calls strike() at the exact execution price, tax included. If the hook isn't (yet) an authorized striker, the call is caught and the pool never bricks.
MemberNotes
currentTaxBpsOf(poolId)The decaying buy/sell tax the pool charges right now, in bps.
setTollBps(bps)onlyOwner. The resting tax. Reverts with TollTooHigh above MAX_TOLL_BPS (100 = 1%).
setMaxBuyBps(bps)onlyOwner. Per-swap buy cap in bps of supply (default 77 = 0.77%); 0 disables.
setTreasury(addr)onlyOwner. Redirect where the tax lands.
collectTax(currency, to) / claimableTax(currency)onlyOwner. Redeem accrued buy-tax claims for real currency.
seedSingleSided(key, amount, payer)onlyOwner. Seed OBOL-only launch liquidity; the hook holds the position.
withdrawSeed(key, recipient)onlyOwner. Withdraw the seeded position plus accrued LP fees.
hookDataOptional ABI-encoded address naming the strike beneficiary; defaults to tx.origin.
Hook permissions. The hook uses afterInitialize, beforeSwap, afterSwap, and afterSwapReturnDelta. v4 encodes permissions in the address bits, so the deploy script mines a CREATE2 salt to land on a matching address.

Deploy — Robinhood Chain

Robinhood Chain is an Arbitrum Orbit L2 that settles to Ethereum, with ETH as the gas token — a fitting home for a cost-basis-aware token standard. Both RPCs are preconfigured in foundry.toml.

 TestnetMainnet
Chain ID466304663
RPCrpc.testnet.chain.robinhood.comrpc.mainnet.chain.robinhood.com
Explorerexplorer.testnet.chain.robinhood.comrobinhoodchain.blockscout.com
Faucetfaucet.testnet.chain.robinhood.com

Get testnet ETH from the faucet, then deploy. The script deploys the token, mines the hook address, grants it striker rights, initializes the dynamic-fee launch pool, and seeds it single-sided — OBOL only, in a range just above the launch price. Buyers bring the quote asset; the launch price is the floor, and there is nothing to dump into at t=0. The hook itself holds the LP position (protocol-owned liquidity), so launch fees accrue to the protocol, and the owner can withdraw the position plus accrued fees via withdrawSeed().

# minimal testnet deploy (fresh PoolManager, native-ETH quote)
PRIVATE_KEY=0x… forge script script/Deploy.s.sol \
  --rpc-url robinhood_testnet --broadcast
Env varDefaultMeaning
PRIVATE_KEYrequiredDeployer key.
POOL_MANAGERfreshReuse an existing v4 PoolManager instead of deploying one.
QUOTE_ASSETaddress(0)Pool + ERC-402 quote currency (native ETH by default).
RAF_TREASURYdeployerRetroactive-funding treasury.
START_MCAP_USD5000Launch market cap in USD; sets the pool's start price.
ETH_PRICE_USD1880ETH/USD used to convert the market cap.
SQRT_PRICE_X961:1Initial pool price (the launch floor).
SEED_OBOLfull supplyOBOL seeded single-sided at launch; 0 skips.

Verify on Blockscout:

forge verify-contract <ADDRESS> src/ObolToken.sol:ObolToken \
  --verifier blockscout \
  --verifier-url https://explorer.testnet.chain.robinhood.com/api \
  --chain-id 46630

Security notes

  • Strikers are trusted price oracles. A malicious striker can falsify cost bases or leave dangling credits. Grant it only to venues whose price reports are tied to real executions (like the AMM hook reading swap deltas).
  • priceStruckOf is provenance, not a price oracle. Transfers at an old average can nudge a counterparty's basis; protocol logic consuming it should treat it as informational unless every strike path is trusted.
  • Fresh PoolManager is testnet-only. Uniswap v4-core is BUSL-licensed; production chains use the governed canonical deployment.
  • Unaudited. Do not launch real-value tokens from this code without an audit.