Documentation

How it works

Two bulls. Two V4 pools. One hook keeping them honest.

The idea

Two bulls. One ETH price. The market decides which conviction is louder. No oracle picks a winner; no settlement date forces an exit. Pricing is the answer.

A market starts as a binary question — VITALIK vs HAYDEN, BULL vs BEAR, BTC vs ETH — and ships as two ERC-20 bull tokens, each with its own Uniswap V4 pool against WETH. The two pools launch at parity. From there, where they drift is the only score that matters.

You participate by picking a bull and depositing ETH. The protocol mints synthetic supply against your collateral and provides it as single-sided liquidity into the pool. You walk away with an LP NFT and a debt entry. Repay whenever — the contract burns the synthetic, unwinds the LP, and returns your share of the pool. If your bull ran, you collect more ETH than you put in. If it stalled, less.

This is a conviction market, not a prediction market: there is no payout calendar, no truth oracle, no “winner” ever declared. The price of each bull token is the aggregate weight of capital behind it, and the fee on each pool dynamically rewards the winning side and punishes the losing one. Everything is on-chain, non-custodial, permissionless.

Mechanism

Every market is three contracts working in lockstep: one Stabilizer coordinates a pair of HaloSide ERC-20s, each with its own V4 pool against WETH, and a shared OracleHook reads the TWAP on every swap to retune fees in real time.

Stabilizer
├─ HaloSide A  →  V4 pool · WETH / A
└─ HaloSide B  →  V4 pool · WETH / B
 
shared singletons:
Factory · OracleHook · V4 PoolManager · halo

Launching at parity

Both pools open with identical liquidity. The creator picks a starting size (in ETH); each pool seeds with that much WETH against initialSupply bull tokens. The two pools begin indistinguishable. Drift only emerges from real activity.

How drift gets corrected

If bull A pulls ahead, the OracleHook sees the TWAP divergence on its next swap and adjusts: A's fee falls, B's fee rises. Selling A is suddenly cheap and minting B is suddenly expensive — a restoring force pulls capital back across. The market never locks itself in; conviction has to keep paying for the lead it already won.

Positions

A position is an on-chain debt record. One transaction opens it, one closes it. While it's open you hold an LP NFT minted in your name; it just lives in the HaloSide contract until repay.

Calling HaloSide.deposit() with msg.value = X ETH kicks off the open flow. The contract wraps your ETH to WETH, provides it as single-sided liquidity into the pool, mints synthetic supply against your collateral, and stores a DebtRecord with (lpTokenId, collateralAmount, mintedAmount, timestamp). Your address indexes into one or more debtIds.

Closing

HaloSide.repay(debtId)burns the synthetic, pulls the LP out of the pool, and ships you the net WETH (unwrapped to ETH on the way out). What you get back is the current pool value of your position — bigger than your deposit if your bull ran, smaller if it didn't.

The LP NFT is custodied by HaloSide for the lifetime of the position so the Stabilizer can read pool state and enforce fee logic without making you sign a message on every rebalance.

Dynamic fees

The OracleHook is a Uniswap V4 hook with the BEFORE_SWAPflag set. Before each swap it reads the TWAP of both pools and computes the current fee for the pool being swapped. The fee is returned to the PoolManager as part of the hook's response — no external oracle, no off-chain keeper.

TWAP period

The Stabilizer stores a twapPeriod (default ~1800 seconds). The hook reads the cumulative tick accumulator at now - twapPeriod and now, divides by the interval, and converts the average tick to a price ratio.

Drift and fee direction

getPriceDeltaBps(sideAddr) returns the signed divergence of the two pools in basis points. Positive = the queried side is ahead. getFeePercentage(sideAddr) maps that divergence to a fee in bps — higher fee for the trailing bull, lower for the winning bull.

driftbps = (priceA − priceB) / priceref × 10,000
   where priceref = initialSqrtPriceX96² / 2192  (the launch price)
 
fee(side) = f(driftbps)
   f is monotone — fee rises on the losing side, falls on the winning side

The fee schedule creates a self-correcting market: as one side dominates, minting new tokens on the losing side becomes cheaper (lower fee), attracting contrarians who believe in a reversion. Meanwhile, the winning side charges higher fees, harvesting revenue from latecomers.

The math

Uniswap V4 price encoding

V4 stores pool price as sqrtPriceX96 — a Q64.96 fixed-point number equal to √(token₁/token₀) × 2⁹⁶. To recover a human-readable price:

sqrtPriceX96 = √(token1 / token0) × 296
 
pricetoken1/token0 = (sqrtPriceX96 / 296)2
 
   For a WETH/Side pool where WETH = token0, Side = token1:
   priceside/weth = (sqrtPriceX96 / 296)2  (WETH per side token)

Currency ordering in V4 is deterministic: currency0 < currency1 by address. Whether WETH or the side token is token0 depends on which address sorts lower — the frontend derives this at runtime before building PoolKey.

Initial sqrtPriceX96

At market creation the creator picks a “starting size” S (ETH) and both sides launch withinitialSupply N tokens. The reference price is p₀ = S / N ETH per token:

p0 = S / N    // ETH per side token
 
sqrtPriceX96 = isqrt(p0 × 2192)
 
   isqrt = integer square root via Newton’s method (BigInt, avoids float overflow at 2192)
   Example: S = 10 ETH, N = 100,000,000 ⇒ p0 = 10−7 ETH/token

Pool ID derivation

Uniswap V4 identifies pools by the keccak256 hash of the ABI-encoded PoolKey struct:

PoolKey = { currency0, currency1, fee, tickSpacing, hooks }
 
PoolId = keccak256(abi.encode(PoolKey))
 
   fee = 0x800000  (DYNAMIC_FEE_FLAG — hook controls fee per-swap)
   tickSpacing = 60
   hooks = OracleHook address

Swap direction and price limits

To buy bull tokens with ETH (ETH → Side): set zeroForOne = truewhen WETH is currency0, false otherwise. The sqrtPriceLimitX96 is clamped to just inside the pool's tick bounds to prevent a full single-transaction drain:

MIN_SQRT_PRICE = 4,295,128,739
MAX_SQRT_PRICE = 1,461,446,703,485,210,103,287,273,052,203,988,822,378,723,970,342
 
ETH → Side:   sqrtPriceLimitX96 = MIN_SQRT_PRICE + 1  // zeroForOne
Side → ETH:   sqrtPriceLimitX96 = MAX_SQRT_PRICE − 1

TWAP calculation

The hook stores cumulative tick accumulators at each block boundary (via the afterInitializeand beforeSwap callbacks). A TWAP over window T is:

TWAPtick = (Σtick · dt) / T
 
priceTWAP = 1.0001TWAPtick  // V4 tick ⇒ price ratio
 
   driftbps = (priceTWAP,A − priceTWAP,B) / priceref× 10,000

Collateral accounting

Not all deposited ETH becomes collateral. The hook's current fee applies at mint time:

netCollateral = depositAmount × (1 − fee)
mintedTokens = Stabilizer.getAmountToMint(sideAddr, netCollateral)
 
   fee is the fee-at-mint snapshot from OracleHook, not a fixed parameter

Secondary market

You don't have to open a debt position to participate. Side tokens trade freely in their V4 pools. Any address can buy or sell using PoolSwapTest.swap() (on Mainnet) or the Uniswap Universal Router on production deployments.

The swap flow for ETH → Side A:

01Wrap ETH
WETH9.deposit{value}() — converts native ETH to WETH ERC-20.
02Approve
WETH9.approve(PoolSwapTest, MAX_UINT256) — once per router address.
03Swap
PoolSwapTest.swap(poolKey, { zeroForOne, amountSpecified: -wethIn, sqrtPriceLimitX96 }, { takeClaims: false, settleUsingBurn: false }, hookData)

amountSpecified < 0 = exact input (you specify how much WETH to spend). The output amount (bull tokens received) depends on the current pool price. Side → ETH swaps return WETH, not native ETH.

Launching a market

Anyone holding enough halo can deploy a new market. The cost is a one-time creation fee in halo (10,000 halo). The fee is burned / sent to the treasury; gas costs ~1.95M units for the full deployment.

Two-transaction flow

halo.approve
Approve the Factory to spend creationFee halo. Skipped if existing allowance already covers it.
factory.createTuple
createTuple(name0, symbol0, name1, symbol1, initialSupply, initialSqrtPriceX96, feeReceiver) — clones a Stabilizer proxy, deploys two HaloSide ERC-20 proxies, registers both V4 pools with the OracleHook, and emits TupleCreated.

TupleCreated event

event TupleCreated(
  address indexed creator,
  address indexed stabilizer,  // route param for the market page
  address tuple0,               // HaloSide A address
  address tuple1,               // HaloSide B address
  string name0, string symbol0,
  string name1, string symbol1,
  uint256 initialSupply,
  uint160 initialSqrtPriceX96,
  address feeReceiver,
  uint256 totalIndex              // global tuple count at creation
)

The frontend decodes this event from the createTuple receipt to extract the new Stabilizer address and redirect to /markets/{stabilizer}.

Once deployed: no pause, no redeploy, no settlement. The market lives until both pools are drained by repayments. The creator can set the feeReceiver address at deploy time; this address receives the per-mint fee skim for that market.

Contracts

Ethereum Mainnet (chain id 1)
Halo Protocol
L1 Markets · halo
ERC-20 governance token · 18 decimals
0x02dBe30f5c4781766e48E6e123E11C025Ee32e20
Factory
Deploys and indexes Stabilizer + HaloSide clones
0xAa7ef737f7eA096DdD59a5896Fb27f0933005d61
Oracle Hook
V4 hook singleton — TWAP oracle + dynamic fee controller
0x25520fE01F23EEBc4612f1ff05E3a6787D231Ac0
Side Impl
Implementation contract for all HaloSide ERC-20 proxies
0x1f89539a9dEEc1b07bE89035bFfB33f82006ad0a
Stabilizer Impl
Implementation contract for all Stabilizer proxies
0xb7fdC9717415852Ea01b7f37bF8b95C91f553261
Uniswap V4 (canonical Mainnet)
Pool Manager
Core V4 singleton — holds all pool state
0x000000000004444c5dc75cB358380D2e3dE08A90
Position Manager
ERC-721 LP NFTs · Permit2 integrated
0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e
State View
Read-only: getSlot0(poolId), getLiquidity(poolId)
0x7fFE42C4a5DEeA5b0feC41C94C136Cf115597227
Pool Swap Test
Test router for direct V4 swaps
0x0000000000000000000000000000000000000000
V4 Quoter
Off-chain swap simulation — quoteExactInputSingle
0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203
Permit2
Canonical Uniswap Permit2 (same address all chains)
0x000000000022D473030F116dDEE9F6B43aC78BA3
WETH9
Wrapped Ether — deposit ETH → WETH, withdraw WETH → ETH
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2

ABI surfaces

Factory
tupleCount() → uint256
tupleAt(uint256) → address
creationFee() → uint256
createTuple(string×4, uint256, uint160, address) → address
Stabilizer
TUPLE_0/1() → address
getPrice(address) → uint256
getPriceDeltaBps(address) → int256
getFeePercentage(address) → uint16
totalCollateral(address) → uint256
getAmountToMint(address, uint256) → uint256
HaloSide
deposit() payable → uint256 debtId
repay(uint256 debtId)
getDebt(uint256) → DebtRecord
getDebtIds(address) → uint256[]
debtCount(address) → uint256
OracleHook
BEFORE_SWAP flag
afterInitialize — seeds TWAP accumulator
beforeSwap — reads TWAP, sets dynamic fee
Halo · v0.2 · Ethereum MainnetNo oracle. No settlement. Exit whenever.