obol.trade, $OBOL

Documentation

This page describes the whole protocol: the state it keeps, the arithmetic it performs, the properties that follow from that arithmetic, and the ways it can fail. It is written to be read in order, but every section stands on its own.

Where this page gives numbers, they are a worked example chosen to be arithmetically consistent, the reference state used everywhere on this site: 951,321.600000 USDC in the vault, 33,032,000 OBOL in supply, 1,080,000.000000 USDC paid in since genesis, and a market price of 0.041300 USDC. All amounts use a point as decimal separator and a comma as thousands separator. USDC amounts are written with six decimals when they come out of an exact calculation, because USDC has six decimals and the vault is counted in whole units of 0.000001 USDC, the micro-USDC.

01

Overview

Ticker$OBOL
Nameobol.trade
Token contract0xD47f1668F3DC00FEB7937a0bc4F69b87ceC4e4aA
The sentenceEvery swap pays USDC into a vault, and the redemption price per token can only go up.
Vault assetUSD Coin (USDC), issued by Circle Internet Financial, an ERC-20 with 6 decimals on Ethereum mainnet. Nothing else. No basket, no debt token, no lending position.
The property that does the workThe pro-rata redemption identity. If the vault holds R and the supply is S, the floor per token is F = R / S. Redeeming n tokens at the floor removes exactly n×F from the vault and burns n tokens. The new floor is (R − nF) / (S − n), and since R = F×S, that is F×(S − n) / (S − n) = F. Redemption is neutral by algebraic identity, not by rule. Every other inflow, every fee paid to the vault, raises R without touching S: R/S goes up. No operation of the protocol lowers R/S.
BuyPays USDC to the vault. The floor per token rises.
SellTwo doors. Selling to the pool also pays USDC to the vault and raises the floor. Redeeming at the vault burns tokens and takes out exactly the floor, pro rata: the floor does not move.
The public counterFloor 0.028800, market 0.041300: 412,900.000000 USDC are missing from the vault for the floor to reach the market.
The quantumThree quanta are hard-coded: the fee is rounded to the whole micro-USDC, redemption happens in multiples of 1,000,000,000,000 wei of OBOL (one micro-OBOL), and the floor is displayed to the micro-USDC. Minimum redemption: 1 OBOL.
The feeA hard-coded table of 5 steps, 200 down to 40 basis points, identical for buys and sells, capped by the constant MAX_FEE_BPS = 200. The step depends only on the cumulative amount ever paid into the vault: it can never go back up.
Where the fee goes100% to the vault. Zero to liquidity providers (the pool fee is set to 0). Zero to a team. No treasury address exists in the contract.
On-chain surfaceOne Uniswap v4 hook on a single USDC / OBOL pool. No governance, no admin key, no proxy, no parameter that can change after deployment.
The unresolved flawThe vault is denominated in USDC. Circle's USDC contract exposes a blacklist function, and Circle can apply it to any address, including the vault's. A freeze makes 100% of the floor unreachable: at the reference state, 951,321.600000 USDC, that is 0.028800 USDC on each of 33,032,000 tokens. There is no recourse, no fallback path, no insurance. Sized in section 15.

The whole idea in seven sentences

A token normally has no minimum price: its value is whatever the order book is willing to give it, and that floor can fall to zero. Obol builds a floor that is a claim instead: a vault of USDC, a supply of tokens, and a redemption function that is always open and pays vault ÷ supply per token, without permission and without delay. Every swap on the Uniswap v4 pool takes between 40 and 200 basis points in USDC and pays them into that vault, and the swap itself is never cancelled, never blocked, never delayed. Redemption is algebraically neutral: taking your exact share does not change anyone else's share, (R − nF)/(S − n) = F. What follows is a quantity that cannot go down, and that is the protocol's only claim: the floor rises or stays, never the reverse. The market price does what it wants. It can drop below the floor, in which case redeeming immediately pays more than selling and the gap closes on its own. The only public counter of the protocol is the distance between the two: how many USDC the vault is missing for the floor to catch up with the market.

What the protocol does not claim to be

It does not claim to push the market price up. It does not claim to protect from volatility. It does not claim that the floor is high: at the reference state it is worth less than 70% of the market. It claims exactly one thing, verifiable by reading two integers on chain: R/S has never gone down and cannot go down.

02

The property that does the work

There is no object

Obol is a financial instrument and nothing else. There is no hourglass, no wheel, no hive. There is a flow of real assets between real participants:

  • USD Coin, an ERC-20 with six decimals, issued by Circle Internet Financial, on Ethereum mainnet;
  • a vault, which is a contract address that holds it;
  • the OBOL token, an ERC-20 with eighteen decimals, whose supply is public;
  • a redemption function, open to everyone, with no condition.

The word ratchet names the shape of the problem, a quantity that can only move in one direction, not an object to draw. The site never draws a mechanism.

The real property: the pro-rata redemption identity

The mechanics rest on no invented rule. They rest on an algebraic identity that is true for any division, independently of the protocol. Let R be the content of the vault and S the token supply. The floor per token is defined as the division F = R / S. It is not a parameter: it is what one share is worth when R is split into S equal shares. Redeeming n tokens at the floor means: burn n tokens and take n×F out of the vault. After the operation:

R' = R − nF
S' = S − n
F' = R' / S' = (R − nF) / (S − n)

Since R = F×S by definition of F:

(R − nF) / (S − n) = (F×S − nF) / (S − n) = F×(S − n) / (S − n) = F

The floor is invariant under redemption. Not because someone decided it, but because it is the same identity that makes one slice taken from a cake cut into equal slices leave the other slices the same size.

The consequence: monotonicity

The set of operations that touch R or S is finite and closed:

OperationEffect on REffect on SEffect on R/S
Buy on the pool+ fee0rises
Sell on the pool+ fee0rises
Redeem at the vault− n×F− nunchanged
Donate USDC to the vault+ gift0rises
Nothing elsenonenonenone

There is no operation that increases S after genesis: the mint function is called once, in the constructor. There is no operation that decreases R other than redemption. So R/S is non-decreasing. That is the ratchet, and it is structural, not declarative.

Buy on the poolfee in USDC Sell on the poolfee in USDC Redeem at the vaultburn n, take n×F R, the vault+ fee, or − n×F S, the supplyonly − n F = R / Srises after a swapunchanged after a redeem
Two operations add USDC to R without touching S. The third removes from both in the exact proportion that leaves R/S where it was.

Why it is a real property and not a promise

A protocol that wrote "the floor will never fall" would be making a promise held by code that could be changed. Here nobody promises anything: there is simply no edge in the graph of state transitions that leads to a smaller R/S. To lower the floor, one would have to add an operation that does not exist, to a contract that cannot be modified. The difference is verifiable by anyone: read the non-view functions of the contract and count four, beforeSwap, afterSwap, redeem and the constructor, three of which leave R/S rising or stable.

Rounding, the only place where the property has to be defended

The identity is exact in rational arithmetic. In integer arithmetic, division has a remainder. The redemption payout is computed as payout = ⌊R × n / S⌋, rounded down. So:

R' = R − ⌊R×n/S⌋  ≥  R − R×n/S
S' = S − n
R'/S' ≥ (R − R×n/S)/(S − n) = R/S

Rounding down can only reinforce the ratchet: the remainder of the division stays in the vault and benefits those who remain. That is invariant I07. Any other rounding convention would break the property.

03

Buy, sell, redeem

DirectionWhat the trader doesWhat the protocol doesWhy it is natural
Buy, USDC to OBOLBrings USDCThe hook keeps 40 to 200 basis points of it and pays them to the vault. The floor rises.The buyer brings the exact asset the vault is made of. Nothing to convert, nothing to assume. The fee is taken in the currency brought.
Sell on the pool, OBOL to USDCTakes USDC outThe hook keeps the same rate and pays it to the vault. The floor rises.The seller takes the vault's asset away and leaves a fraction of it. Exact symmetry: same table, same rate, same destination.
Sell to the vault (redeem), OBOL to USDCExercises the claimThe vault burns the tokens and pays exactly the share. The floor does not move.Leaving through the floor door is not a trade: it is a shared liquidation. It cannot cost the others anything, by identity.

Why there are two exit doors, and why that is correct

A holder who wants out compares two numbers:

exit through the pool  :  market price P, minus the hook fee
exit through the vault :  floor F, with no fee

They pick the larger one. That is all. There is no trap:

  • If P×(1 − fee) > F, they sell on the pool, the vault collects the fee, and the floor rises for those who stay.
  • If P×(1 − fee) < F, they redeem at the vault, and the floor does not move.

In both cases nobody else is harmed. This is not a fragile equilibrium held by parameters: it is the result of the identity in section 02.

The arbitrage that closes the gap from below

If the market price drops below the floor, a mechanical opportunity opens: buy on the pool at P < F and redeem immediately at the vault at F. The gross gain per token is F − P, minus the buy fee. The exact threshold at step 2 (120 basis points):

buy with X USDC     →  X×(1 − 0.012) is left to buy on the pool
tokens obtained     ≈  X×0.988 / P
value at the vault  =  X×0.988×F / P
profitable if          0.988×F / P > 1,  that is  P < 0.988×F

At the reference state F = 0.028800, so the arbitrage becomes profitable as soon as P < 0.028454 USDC. Below that price, every buyer is paid to push the market back up towards the floor, and the vault collects the fee on the way. The floor does not "support" the price: it pays someone else to, which is more robust and more honest.

What the mapping does not claim

It does not claim that buying pushes the market price up more than usual. It does not claim that selling is painless. A seller who leaves through the pool takes exactly the pool's price impact, like anywhere else. The protocol has no opinion about the market price: it only adds a claim that, for its part, does not go down.

04

The public counter

G = P × S − R

where P is the market price read on the pool, S the supply, R the vault. G is the number of USDC that would have to be paid into the vault, at constant supply and price, for F = R/S to equal P. It is an integer of micro-USDC. It emerges entirely from the state: three reads, one multiplication, one subtraction. No calendar, no date, no parameter.

Its value at the reference state

P × S  =  41,300 µUSDC/OBOL  ×  33,032,000 OBOL  =  1,364,221,600,000 µUSDC
R      =                                            951,321,600,000 µUSDC
G      =                                            412,900,000,000 µUSDC
                                              =     412,900.000000 USDC

Displayed on the site as: missing 412,900.000000 USDC.

The second readable integer: coverage

c  =  ⌊ F × 10000 / P ⌋  =  ⌊ 28,800 × 10000 / 41,300 ⌋  =  6973 basis points

That is 69.73%. Two integers, two reads, no interpretation.

0 Floor 0.028800 Market 0.041300 coverage 6973 bps missing 412,900.000000 USDC every USDC of swap fee removes exactly one USDC from the hatched zone
The full zone is the floor as a share of the market, F/P. The hatched zone is what the vault is missing, G = P×S − R.

How it moves

EventEffect on G
A fee f enters the vaultG decreases by exactly f
A redemption of n tokensG decreases by n×(P − F), since P×S drops by n×P and R drops by n×F
The market price rises by δG increases by δ×S
The market price falls by δG decreases by δ×S

The first line is the most important one, and it is the one the site writes out in full: every USDC of fee removes exactly one USDC from the counter. That is what makes it a countdown and not an indicator.

Verification of the first line

A buy of 10,000.000000 USDC at step 2:

amount   =  10,000.000000 USDC              =  10,000,000,000 µUSDC
fee      =  10,000,000,000 × 120 / 10,000   =  120,000,000 µUSDC  =  120.000000 USDC
to pool  =  10,000,000,000 − 120,000,000    =  9,880,000,000 µUSDC =  9,880.000000 USDC
R'       =  951,321,600,000 + 120,000,000   =  951,441,600,000 µUSDC
S'       =  S                               =  33,032,000 OBOL
P × S    =  41,300 × 33,032,000             =  1,364,221,600,000 µUSDC
G'       =  1,364,221,600,000 − 951,441,600,000  =  412,780,000,000 µUSDC
         =  412,780.000000 USDC
G − G'   =  120.000000 USDC                 =  exactly the fee
V'       =  1,080,000,000,000 + 120,000,000 =  1,080,120,000,000 µUSDC
         =  1,080,120.000000 USDC           →  still step 2

And the displayed floor goes from 0.028800 to 0.028803 USDC (truncated to the micro-USDC; the exact value is 28,803.6328 micro-USDC per OBOL).

Verification of the second line

A redemption of 1,000,000 OBOL at the reference state:

payout   =  ⌊ 951,321,600,000 × 1,000,000 / 33,032,000 ⌋  =  28,800,000,000 µUSDC
         =  28,800.000000 USDC
R'       =  951,321,600,000 − 28,800,000,000  =  922,521,600,000 µUSDC
S'       =  33,032,000 − 1,000,000            =  32,032,000 OBOL
F'       =  922,521,600,000 / 32,032,000      =  28,800 µUSDC   ← identical
P × S'   =  41,300 × 32,032,000               =  1,322,921,600,000 µUSDC
G'       =  1,322,921,600,000 − 922,521,600,000  =  400,400,000,000 µUSDC
         =  400,400.000000 USDC
G − G'   =  12,500.000000 USDC  =  1,000,000 × (41,300 − 28,800) µUSDC  ✓

The floor is rigorously unchanged: 28,800 before, 28,800 after.

What the counter is not

It is not a target. The protocol does not try to reach it, does not stop if it is reached, does not change behaviour as it gets close. If the floor went above the market, which is possible in a falling market, the counter would turn negative and the interface would display: floor is above market by 0.000000 USDC per token. Redeeming pays more than selling. The pool is the expensive exit right now. That is a perfectly valid state, and the interface plans for it.

05

Why this and not something else

Why a redemption floor rather than a redistribution

Periodic redistribution, taking a fee and paying it back to holders, has three problems the floor does not have. You have to know who holds, which is impossible to do cleanly when the sender is a router (section 09). You need a distribution moment, hence a calendar. And you need a claim mechanism, hence a page, hence per-address state, hence a gas cost that grows. A redemption floor needs none of the three: it knows nobody, it has no moment, and its "claim" is a division performed at the moment someone asks for it.

Why a ratchet rather than a fixed floor

A fixed floor ("this token is worth at least 0.01 USDC") requires a reserve raised in advance, hence a raise, hence an allocation, hence someone who decides. A ratcheting floor builds itself, from the only flow that already exists, the trading volume, and needs no decision.

Why USDC and nothing else

The floor must be a quantity, not a price. If the vault held ETH, R/S would be a quantity of ETH per token, and its value in dollars could fall without any protocol operation having taken place. The ratchet would be true in units of ETH and false in units of use. The only way to have a readable ratchet is a vault whose unit of account does not move. This choice is also what creates the unresolved flaw. That is accepted, and it is written in section 15, in appendix B and on the front page.

The trader test

A trader has to understand who pays what to whom in a single read. Here is the sentence: every swap pays USDC into a vault, and the redemption price per token can only go up. Who pays: the one who swaps. What: USDC issued by Circle on Ethereum. To whom: the vault, of which every holder owns an exact share. How much: 40 to 200 basis points, public table. No jargon, no metaphor, no imaginary asset.

06

Protocol state

What is stored

The hook stores a single 32-byte slot. Nothing else. No mapping, no array, no list, no history.

struct Vault {
    uint128 reserve;   // R, vault balance, in µUSDC
    uint120 paidIn;    // V, everything ever paid in, in µUSDC
    uint8   step;      // current step, 0 to 4
}                      // 128 + 120 + 8 = 256 bits, exactly one slot

Vault internal v;      // slot 0
FieldTypeUnitMonotonicBound
reserveuint128µUSDCno (redemption lowers it)2^128 − 1 ≈ 3.40 × 10^38 µUSDC
paidInuint120µUSDCyes, non-decreasing2^120 − 1 ≈ 1.33 × 10^36 µUSDC
stepuint8noneyes, non-decreasing4

The total supply S is not stored by the hook: it is read from OBOL.totalSupply(), a single slot read in the token contract. It is never read on the path of a swap; only redemption needs it.

The hard-coded constants

uint16  internal constant MAX_FEE_BPS   = 200;   // absolute cap, both directions

uint16  internal constant FEE_STEP_0    = 200;
uint16  internal constant FEE_STEP_1    = 160;
uint16  internal constant FEE_STEP_2    = 120;
uint16  internal constant FEE_STEP_3    = 80;
uint16  internal constant FEE_STEP_4    = 40;

uint120 internal constant THRESHOLD_1   =   200_000_000_000; //   200,000 USDC
uint120 internal constant THRESHOLD_2   =   600_000_000_000; //   600,000 USDC
uint120 internal constant THRESHOLD_3   = 1_200_000_000_000; // 1,200,000 USDC
uint120 internal constant THRESHOLD_4   = 2_000_000_000_000; // 2,000,000 USDC

uint256 internal constant GENESIS_SUPPLY = 40_000_000e18;    // 40,000,000 OBOL
uint256 internal constant BURN_QUANTUM   = 1e12;             // 1 micro-OBOL
uint256 internal constant MIN_REDEEM     = 1e18;             // 1 OBOL
uint256 internal constant BPS            = 10_000;

None of these values is an immutable set at deployment: they are constants, inlined in the bytecode. There is no path, even theoretical, to modify them.

What is not stored, and why it matters

Not storedWhy
Each address's balance in the protocolThe ERC-20 token already does it. The hook does not need to know who holds.
The number of holdersNo operation depends on it.
A price historyNo operation depends on it. No oracle on the hot path.
The date of the last operationThere is no calendar in this protocol.
A list of allowed routersNo sender is ever privileged (section 09).
An owner, an admin, a pauserThey do not exist.
07

Derived quantities

All of them are computed from R, S, V, plus P for display. None is stored.

SymbolNameFormula (integer arithmetic)UnitReference value
FFloor per token⌊R / S⌋ in µUSDC per whole OBOLµUSDC / OBOL28,800 → 0.028800 USDC
payout(n)Redemption amount⌊R × n / S⌋µUSDC28,800,000,000 for n = 1,000,000 OBOL
cCoverage⌊F × 10,000 / P⌋basis points6973 → 69.73%
GPublic counterP × S − RµUSDC412,900,000,000 → 412,900.000000 USDC
premiumMarket premiumP − FµUSDC / OBOL12,500 → 0.012500 USDC
WPaid out to redeemersV − RµUSDC128,678,400,000 → 128,678.400000 USDC
BBurned by redemptionGENESIS_SUPPLY − SOBOL6,968,000
claim_poolClaim of the pool's inventoryL × F, where L = OBOL held by the PoolManagerµUSDC118,656,000,000 → 118,656.000000 USDC
vol_to_closeVolume needed to close the gap at the current step⌊G × 10,000 / fee_bps⌋µUSDC34,408,333,333,333 → 34,408,333.333333 USDC
arb_thresholdPrice below which redemption arbitrage opens⌊F × (10,000 − fee_bps) / 10,000⌋µUSDC / OBOL28,454 → 0.028454 USDC

Verification of the accounting identity

V  =  R  +  W
1,080,000.000000  =  951,321.600000  +  128,678.400000     ✓

This equality is not a reconciliation: it is true by construction, because V only increases when R increases by the same amount, and W is defined as V − R. It serves as a consistency test for any state read.

Verification of the coverage

F × 10,000 / P  =  28,800 × 10,000 / 41,300  =  288,000,000 / 41,300  =  6973.365
⌊ ⌋              =  6973 basis points

Verification of the volume needed

G             =  412,900,000,000 µUSDC
fee_bps       =  120
gap × 10,000  =  4,129,000,000,000,000
vol_to_close  =  ⌊ 4,129,000,000,000,000 / 120 ⌋
              =  34,408,333,333,333 µUSDC
              =  34,408,333.333333 USDC

Verification of the inventory claim

L  =  4,120,000 OBOL still held by the PoolManager
F  =  28,800 µUSDC / OBOL
claim_pool     =  4,120,000 × 28,800  =  118,656,000,000 µUSDC  =  118,656.000000 USDC
share of vault =  118,656,000,000 / 951,321,600,000  =  12.47%

Verification of the sell example

A sale on the pool, gross output of 20,000.000000 USDC before the fee, at step 2:

gross output  =  20,000.000000 USDC             =  20,000,000,000 µUSDC
fee           =  20,000,000,000 × 120 / 10,000  =  240,000,000 µUSDC
              =  240.000000 USDC
net to seller =  20,000,000,000 − 240,000,000   =  19,760,000,000 µUSDC
              =  19,760.000000 USDC
R'            =  951,321,600,000 + 240,000,000  =  951,561,600,000 µUSDC
              =  951,561.600000 USDC
S'            =  33,032,000 OBOL, unchanged
R'/S'         =  951,561,600,000 / 33,032,000   =  28,807.2656 µUSDC / OBOL
F' displayed  =  28,807 µUSDC                   =  0.028807 USDC
V'            =  1,080,000,000,000 + 240,000,000  =  1,080,240,000,000 µUSDC
              =  1,080,240.000000 USDC          →  still step 2

The floor rises from 28,800 to 28,807 micro-USDC per token. The seller paid 240.000000 USDC and handed all of it to the remaining holders, including themselves if they keep any.

Verification of the arbitrage threshold

F                =  28,800 µUSDC / OBOL
current fee      =  120 basis points
arb_threshold    =  ⌊ 28,800 × (10,000 − 120) / 10,000 ⌋
                 =  ⌊ 28,800 × 9,880 / 10,000 ⌋
                 =  ⌊ 284,544,000 / 10,000 ⌋
                 =  28,454 µUSDC              =  0.028454 USDC

net exit on the pool at P = 41,300 :
                 =  ⌊ 41,300 × 9,880 / 10,000 ⌋
                 =  40,804 µUSDC              =  0.040804 USDC   >  F

At the reference state, selling on the pool therefore pays more than redeeming at the vault: 0.040804 against 0.028800. The redemption arbitrage only opens if the market price falls below 0.028454 USDC.

08

Why a hook, and what it costs

Why a hook and not a separate contract

Without a hook, the only way to get a fee into the vault on every trade would be to take it in the token itself, through a taxed transfer. That is bad for three reasons: a token with taxed transfers breaks most routers and aggregators; the fee would be in OBOL and not in USDC, so the vault would no longer be single-asset; and the rate would be the same in every context, including transfers between wallets of the same person. A v4 hook allows exactly the opposite: the token is a perfectly standard ERC-20, the fee exists only on the pool, it is denominated in USDC, and it is taken by the PoolManager itself with no extra transfer.

The permissions requested

PermissionUsed forCan it block a swap?
beforeInitializeCheck that the pool is USDC/OBOL, that the pool fee is 0, that the tickSpacing is the intended one. Record which currency is currency0.Yes, but only at initialisation, never on a swap.
beforeAddLiquidityReject any liquidity addition that does not come from the hook itself.No, it is not a swap.
beforeRemoveLiquidityReject every liquidity removal, without exception.No, it is not a swap.
beforeSwapTake the fee when USDC is the specified currency.No. Never. Invariant I01.
beforeSwapReturnDeltaReturn the delta on the specified currency.No.
afterSwapTake the fee when USDC is the unspecified currency.No. Never. Invariant I01.
afterSwapReturnDeltaReturn the delta on the unspecified currency.No.

The hook address must encode these seven flags in its low-order bits: deployment goes through a CREATE2 with a salt mined until a conforming address is found. That is a v4 constraint, not a choice.

The four fee cases

CaseDirectionTypeSpecifiedUnspecifiedWhere the fee is takenCallback
C1Buy, USDC to OBOLexactInputUSDCOBOLOn the USDC input, before the swapbeforeSwap, delta on specified
C2Buy, USDC to OBOLexactOutputOBOLUSDCOn the USDC input, after the swapafterSwap, delta on unspecified
C3Sell, OBOL to USDCexactInputOBOLUSDCOn the USDC output, after the swapafterSwap, delta on unspecified
C4Sell, OBOL to USDCexactOutputUSDCOBOLOn the requested USDC output, before the swapbeforeSwap, delta on specified

Single rule: beforeSwap when USDC is the specified currency, afterSwap otherwise. The fee is therefore always denominated in USDC, in all four cases. The vault never holds anything else. In cases C2 and C4 the trader pays the fee on top of what they asked for, which preserves the exactOutput semantics: they receive exactly the requested amount. In cases C1 and C3 the fee is withheld, which preserves the exactInput semantics: they do not spend more than they announced.

The skeleton of the hook

function _beforeSwap(
    address,                       // sender, ignored, see section 09
    PoolKey calldata key,
    SwapParams calldata p,
    bytes calldata                 // hookData, ignored
) internal override returns (bytes4, BeforeSwapDelta, uint24) {

    // is USDC the specified currency?
    bool usdcSpecified = _usdcIsSpecified(p.zeroForOne, p.amountSpecified);
    if (!usdcSpecified) {
        return (BaseHook.beforeSwap.selector, ZERO_DELTA, 0);
    }

    uint256 abs = p.amountSpecified < 0
        ? uint256(-p.amountSpecified)
        : uint256(p.amountSpecified);

    Vault memory s = v;                                   // 1 SLOAD
    uint256 fee = (abs * _feeBps(s.step)) / BPS;          // rounded down
    if (fee == 0) {
        return (BaseHook.beforeSwap.selector, ZERO_DELTA, 0);
    }

    poolManager.take(_usdc(key), address(this), fee);     // the fee lands here
    _credit(s, fee);                                      // 1 SSTORE

    return (
        BaseHook.beforeSwap.selector,
        toBeforeSwapDelta(int128(int256(fee)), 0),
        0
    );
}

function _credit(Vault memory s, uint256 fee) private {
    unchecked {
        s.reserve += uint128(fee);
        s.paidIn  += uint120(fee);
    }
    s.step = _stepOf(s.paidIn);
    v = s;                                                // 1 SSTORE, 1 slot
}

function _stepOf(uint120 paid) private pure returns (uint8) {
    if (paid < THRESHOLD_1) return 0;
    if (paid < THRESHOLD_2) return 1;
    if (paid < THRESHOLD_3) return 2;
    if (paid < THRESHOLD_4) return 3;
    return 4;                                             // 4 comparisons, no loop
}

function _feeBps(uint8 step) private pure returns (uint16) {
    if (step == 0) return FEE_STEP_0;
    if (step == 1) return FEE_STEP_1;
    if (step == 2) return FEE_STEP_2;
    if (step == 3) return FEE_STEP_3;
    return FEE_STEP_4;                                    // bounded by MAX_FEE_BPS
}

_afterSwap has the same shape, with delta.amount1() or delta.amount0() as the base of the calculation depending on the unspecified currency, and an int128 return instead of a BeforeSwapDelta.

The proof that nothing loops

PathVariable number of instructions?Proof
_beforeSwapnoNo for, no while, no recursion. _stepOf and _feeBps are comparison chains of fixed depth (4 and 4). One slot read, one slot write.
_afterSwapnoIdentical.
redeemnoTwo reads, one wide multiplication, one division, one burn, one transfer. The ERC-20 burn is O(1).
beforeAddLiquiditynoOne address comparison.
beforeRemoveLiquiditynoOne unconditional revert.
ConstructornoOne mint, one liquidity addition. Off the hot path.

There is no iterable data structure in the contract. No array, no linked list, no queue. The gas cost of a swap therefore depends on no quantity of the state: it is the same on the first swap and on the billionth. That is invariant I08.

What the hook costs in gas

Indicative estimates, in gas added compared with the same swap on a pool without a hook. They do not replace a measurement on deployed code.

PathDetailGas added (estimate)
beforeSwap (cases C1, C4)1 warm SLOAD, 1 mulDiv, 1 take, 1 warm SSTORE, delta return≈ 19,000
afterSwap (cases C2, C3)same, plus reading the BalanceDelta≈ 21,000
First swap of a blockThe SLOAD is cold (2,100) instead of warm (100)≈ +2,000
redeem2 SLOAD, 1 256-bit mulDiv, 1 burn, 1 USDC transfer≈ 62,000
beforeAddLiquidity, rejected1 comparison, 1 revert≈ 2,400

At the price of a typical Uniswap v4 swap (≈ 110,000 gas), the hook's overhead is about 17% to 19%. That is the price of the vault, and it is paid by every trade, in both directions, without exception.

09

The identity problem

The statement of the problem

In a Uniswap v4 hook, the first parameter of beforeSwap and afterSwap is not the user. It is the address that called PoolManager.swap(), that is, the router: Universal Router, an aggregator, a strategy contract, a searcher bot, or any contract written this morning. A hook that wrote balances[sender] += x would credit the router. A hook that granted a preferential rate to a list of addresses would give it to anyone who goes through the right contract. A hook that limited "one operation per address per block" would be bypassed by deploying two routers. The hookData field saves nothing: it is supplied by the caller, so it can be forged at will. This is the most frequent design flaw of v4 hooks, and it has already broken several redistribution designs.

How Obol solves it: by having no identity at all

The hook has no per-address state. Here is the totality of what it writes on the path of a swap:

v.reserve += fee          // global
v.paidIn  += fee          // global
v.step     = f(v.paidIn)  // global

Three writes, all global, none indexed by an address. So there is nothing to forge: impersonating someone else gives access to nothing, because no right is attached to an address in the hook. The sender parameter is read nowhere. It appears unnamed in the signatures, precisely so that the compiler flags any attempt to use it. That is invariant I12.

Where an identity really exists, and where it is reliable

The redemption function is not a hook callback. It is a direct call:

function redeem(uint256 amount) external returns (uint256 payout) {
    require(amount >= MIN_REDEEM, "TOO_SMALL");
    amount = (amount / BURN_QUANTUM) * BURN_QUANTUM;      // truncate to the quantum

    uint256 supply = OBOL.totalSupply();
    Vault memory s = v;

    payout = FullMath.mulDiv(uint256(s.reserve), amount, supply);  // rounded down

    OBOL.burnFrom(msg.sender, amount);                    // msg.sender IS the caller
    s.reserve -= uint128(payout);
    v = s;
    USDC.safeTransfer(msg.sender, payout);
}

Here msg.sender is the true identity of the caller, in the EVM sense: it is they who see their tokens burned and they who receive the USDC. No router is involved. If a contract calls redeem on behalf of a user, that contract is burned and paid, which is the correct and expected behaviour, as for any ERC-20.

The identity surface

FunctionWho is msg.senderReliable?Does the contract depend on it?
beforeSwapThe PoolManageryes, but uselessno
beforeSwap, sender parameterThe routernono, never read
afterSwap, sender parameterThe routernono, never read
hookDataSupplied by the callernono, never read
redeemThe real calleryesyes, to burn and to pay
beforeAddLiquidity, sender parameterThe position routernoyes, compared with address(this) only, which is the only reliable use: the hook recognises itself, it recognises nobody else

The last line deserves a second read: the only address comparison in the entire contract is sender == address(this), in the constructor, to allow itself to place the initial liquidity. It grants a privilege to nobody else, and it is on a path that is not a swap.

10

The quantum

QuantumValueWhere it appliesWhy
Fee quantum1 µUSDC = 0.000001 USDCThe calculation abs × bps / 10000 is an integer division, truncated downUSDC has six decimals. There is no fraction of a micro-USDC on chain. Truncating down leaves the remainder with the trader, never with the vault: it is the only rounding of the protocol that does not favour the vault, and it is in the user's favour.
Burn quantum1,000,000,000,000 wei = 10^-6 OBOLAny amount passed to redeem is truncated to a multiple of this valuePrevents dust redemptions that would cost more in gas than they return, and makes the burned amount exactly representable in micro-OBOL.
Floor display quantum1 µUSDC per OBOLThe interface displays F truncated to the micro-USDCA floor displayed with eighteen decimals is unreadable. Six decimals are enough, and truncating down never oversells the floor.

The minimum redemption

uint256 internal constant MIN_REDEEM = 1e18;   // 1 OBOL

At the reference state, a redemption of 1 OBOL pays:

payout = ⌊ 951,321,600,000 × 1 / 33,032,000 ⌋ = 28,800 µUSDC = 0.028800 USDC

Below that threshold, the gas cost far exceeds the amount received. The threshold is hard-coded and has no exception.

What happens when truncation gives zero

If someone got around the minimum, for instance if the floor were very low, and a redemption produced payout = 0, the operation still succeeds: the tokens are burned and nothing is paid. R stays unchanged, S decreases, so R/S rises. It is a gift to the remaining holders, it is voluntary, and it breaks no invariant. Example, at the reference state, for a redemption of a single quantum (10^-6 OBOL):

payout = ⌊ 951,321,600,000 × 10^-6 / 33,032,000 ⌋
       = ⌊ 0.0288 ⌋
       = 0 µUSDC

The redemption costs gas and returns nothing. That is why MIN_REDEEM exists: not to protect the protocol, but to protect the user from an operation they would regret.

Why there is no quantum on the swap amount

The protocol imposes no minimum, maximum or multiple on swaps. A swap of one micro-USDC is accepted; it produces a fee of zero and advances nothing. Imposing a minimum would amount to reverting a swap, which invariant I01 forbids.

11

The fee steps

The fee is never computed by a continuous formula. It is a table of five rows, indexed by a single integer: V, the cumulative amount that has ever entered the vault since genesis. V never decreases, so the step never goes back up, so the fee never goes back up.

StepV, cumulative paid into the vault (USDC)Fee (bps)Fee (%)Volume in the step (USDC)Fees produced (USDC)V at end of step (USDC)
00 to 199,999.9999992002.00%10,000,000200,000200,000
1200,000 to 599,999.9999991601.60%25,000,000400,000600,000
2600,000 to 1,199,999.999999 (reference state)1201.20%50,000,000600,0001,200,000
31,200,000 to 1,999,999.999999800.80%100,000,000800,0002,000,000
42,000,000 and above400.40%unboundednonenone
2001601208040 bps 0200,000600,0001,200,0002,000,000 V, USDC ever paid into the vault now, V = 1,080,000, 120 bps
Five steps, hard-coded. The step is chosen by V alone, and V only ever grows, so the staircase is only ever walked down.

Row by row verification, in integer arithmetic on micro-USDC:

step 0 :  10,000,000,000,000 × 200 / 10,000 =   200,000,000,000  →   200,000.000000 USDC
step 1 :  25,000,000,000,000 × 160 / 10,000 =   400,000,000,000  →   400,000.000000 USDC
step 2 :  50,000,000,000,000 × 120 / 10,000 =   600,000,000,000  →   600,000.000000 USDC
step 3 : 100,000,000,000,000 ×  80 / 10,000 =   800,000,000,000  →   800,000.000000 USDC

cumulative :  200,000  →  600,000  →  1,200,000  →  2,000,000  USDC
thresholds :  THRESHOLD_1..4 = 200,000 / 600,000 / 1,200,000 / 2,000,000 USDC   ✓

Each threshold is exactly the cumulative produced by the previous steps. This is not a coincidence: the volumes in the fifth column were chosen so that the table closes on itself, which makes the calibration of section 17 readable without a calculator.

The symmetry

The same step, the same rate, applies to buys and to sells, without a single basis point of difference; in exactInput and in exactOutput; whatever the router, the caller, the amount, the time; whatever the history of the address, which is not known anyway. There is no parameter, no modifier, no list, no context that can produce two different rates for two swaps in the same block.

The cap

uint16 internal constant MAX_FEE_BPS = 200;

The cap is checked at compile time by an assertion on the five constants, and at run time by the fact that _feeBps can only return one of the five values, all ≤ 200. There is no path through which a swap pays more than 2%.

// checked once, off the hot path
assert(FEE_STEP_0 <= MAX_FEE_BPS && FEE_STEP_1 <= MAX_FEE_BPS
    && FEE_STEP_2 <= MAX_FEE_BPS && FEE_STEP_3 <= MAX_FEE_BPS
    && FEE_STEP_4 <= MAX_FEE_BPS);
assert(FEE_STEP_0 > FEE_STEP_1 && FEE_STEP_1 > FEE_STEP_2
    && FEE_STEP_2 > FEE_STEP_3 && FEE_STEP_3 > FEE_STEP_4);
assert(THRESHOLD_1 < THRESHOLD_2 && THRESHOLD_2 < THRESHOLD_3
    && THRESHOLD_3 < THRESHOLD_4);

Why a table and not a curve

A continuous formula, for instance fee = k / (1 + V), would have three fatal defects here. It would be impossible for a trader to verify in their head before trading. It would make every swap slightly different from the previous one, hence not reproducible in simulation. And above all, it would make the price depend on a calculation whose result changes with every unit of V, which hands an advantage to the first bot of the block. A five-step table is readable, reproducible, and flat: inside a step, two identical swaps pay exactly the same amount, whatever their position in the block.

Moving from one step to the next

The crossing is evaluated after the credit, never before. A swap that takes V from 1,199,999.900000 to 1,200,000.100000 therefore pays 120 basis points on its whole amount, and it is the next swap that pays 80. That avoids a two-part calculation inside a swap, which would introduce a second division on the hot path.

before :  V = 1,199,999.900000 USDC → step 2 → 120 bps
swap   :  an amount such that the fee is 0.200000 USDC
after  :  V = 1,200,000.100000 USDC → step 3 → 80 bps for the next one
12

Where the fees go

DestinationBasis points of the feePercentage
The vault10,000100.00%
Liquidity providers00.00%
A team address00.00%
A protocol treasury00.00%
Total10,000100.00%

There is no split variable in the contract. The fee is taken by poolManager.take(USDC, address(this), fee): it lands directly on the vault's address, which is the hook itself. There is no possible second destination because there is no second address in the code.

Why the pool fee is set to zero

The pool is initialised with lpFee = 0. The reason is simple: the only liquidity position in the pool is the hook's, created in the constructor, and it is locked forever: beforeRemoveLiquidity reverts unconditionally. LP fees accumulated on a position nobody can withdraw would be value locked up with no beneficiary. Setting lpFee = 0 means the whole cost of a trade is the hook fee, and all of that cost goes to the vault. The trader pays between 40 and 200 basis points, and knows exactly where they go.

The genesis allocation

DestinationOBOLShare
v4 liquidity position, locked40,000,000100.00%
Team00.00%
Private buyers00.00%
Treasury00.00%
Total minted40,000,000100.00%

The constructor mints 40,000,000e18 OBOL, deposits all of it into a single-sided liquidity position on the pool, and loses every means of taking it back. No token is distributed to anyone before a buyer buys it on the pool. Liquidity is added once, at deployment, by the hook itself. The hook reverts every subsequent add-liquidity and every remove-liquidity call. The position cannot be withdrawn by anyone, including its author.

The only exit from the vault

redeem(n)  →  burns n OBOL from msg.sender
           →  transfers ⌊R×n/S⌋ USDC to msg.sender

That is the only USDC transfer instruction in the entire contract. There is no sweep, no rescueTokens, no withdraw, no emergencyExit. Invariant I09 is verified by searching for USDC.safeTransfer in the source: there is one occurrence, and its recipient is msg.sender.

13

Invariants

Each invariant is numbered. The numbers are the ones displayed on the app page and cited across this documentation.

I01, the hook never reverts a swap.
No path of beforeSwap or afterSwap contains a revert, a require, an assert, or an operation that can overflow. All hot-path arithmetic is unchecked on types proven not to overflow before the next century (a uint128 of micro-USDC ≈ 3.4 × 10^32 USDC). If the computed fee is zero, the hook returns a zero delta and the swap proceeds. The worst case is "the vault does not grow", never "the trade fails".
I02, R/S never decreases.
Proven in section 02 by exhaustive enumeration of the operations, and for rounding.
I03, redemption pays exactly pro rata.
payout = ⌊R×n/S⌋. No fee, no haircut, no queue, no cap. The first redeemer and the last receive the same amount per token, to the truncation unit.
I04, V never decreases.
v.paidIn only ever appears in a +=. No subtraction exists in the code.
I05, the step never goes back up.
step = _stepOf(V) and _stepOf is an increasing function of a non-decreasing argument. So the fee is non-increasing over time.
I06, the fee never exceeds MAX_FEE_BPS = 200, in either direction.
_feeBps can only return one of the five constants, all ≤ 200, and the same value is used for buys and sells.
I07, no rounding ever lowers R/S.
The redemption payout is truncated down, which leaves the remainder in the vault and can only raise R/S. The fee is truncated down, which leaves the remainder with the trader and has no effect on R/S since S does not change and R still rises.
I08, every path is a fixed number of operations.
Proven in section 08. No iterable structure exists in the contract.
I09, no value ever leaves to a team address.
A single USDC transfer instruction in the contract, recipient msg.sender, in redeem. No constant address of any beneficiary.
I10, redemption is always open.
No whenNotPaused, no onlyWhitelisted, no per-block cap, no delay. The only two conditions are amount ≥ MIN_REDEEM and owning the tokens.
I11, the vault never pays out more than it holds.
payout = ⌊R×n/S⌋ ≤ R since n ≤ S. The case n = S gives payout = R exactly, which empties the vault at the same time as the supply.
I12, no write depends on the caller's address.
The three hot-path writes are global. The sender parameter of the callbacks is never read (section 09).

Verification of the invariants on the worked examples

EventR beforeR afterS beforeS afterR/S beforeR/S afterI02
Buy 10,000 USDC at 120 bps951,321.600000951,441.60000033,032,00033,032,00028,800.000028,803.6328rises ✓
Sell, gross output 20,000 USDC at 120 bps951,321.600000951,561.60000033,032,00033,032,00028,800.000028,807.2656rises ✓
Redeem 1,000,000 OBOL951,321.600000922,521.60000033,032,00032,032,00028,800.000028,800.0000stable ✓
Redeem one quantum (10^-6 OBOL)951,321.600000951,321.60000033,032,00033,031,999.99999928,800.000028,800.0000+rises ✓

R/S values are in micro-USDC per OBOL, with four decimals, before display truncation.

14

What the hook cannot do

A hook sits between the router and the pool, and the honest question about any hook is what it can do to you while you are inside a swap. Here is the complete list of what this one cannot do. These are not policies. They are properties of code that has no admin key, no proxy, and no mutable parameter.

#ImpossibilityWhy it is structural
1Revert a swapNo revert on the swap path, in any branch. I01.
2Block a redemptionNo pause, no owner, no list. I10.
3Charge more than 200 basis pointsMAX_FEE_BPS is a constant. I06.
4Change the fee tableFive constants inlined in the bytecode.
5Move USDC out other than through redeemOne transfer instruction, to msg.sender. I09.
6Mint OBOLThe mint is in the constructor; the function does not exist afterwards.
7Be upgradedNo proxy, no delegatecall, no re-callable initializer.
8Know who is tradingsender is the router, and it is never read. I12.
9LoopNo iterable structure. I08.
10Withdraw the initial liquiditybeforeRemoveLiquidity reverts unconditionally, including for the hook.
11Read a priceNo oracle, no slot0 read on the hot path. The displayed price is computed on the interface side.
12Prevent a Circle freezeNo contract can constrain the issuer of an asset it holds. That is the unresolved flaw.

Line 12 deserves to be developed. It is the only line of the table that describes powerlessness and not self-restraint. The first eleven are choices: an owner could have been added, it was not. The twelfth is not a choice: no provision of the code, no architecture, no arrangement can prevent the issuer of a backed token from refusing transfers from an address.

15

The unresolved flaw

Circle can freeze the vault
951,321.600000 USDC100% of the floor, unreachable, with no recourse

The vault is an Ethereum address that holds USDC. Circle's USDC contract exposes a blacklist function. An address on that list can no longer send or receive USDC. The decision belongs entirely to the issuer, it is unilateral, immediate, and without any appeal procedure accessible to a contract. If the vault address were listed:

  1. USDC.safeTransfer(msg.sender, payout) would revert on every call;
  2. so redeem would revert on every call, for everyone, forever;
  3. so the floor would remain computable (R and S are still readable) but would become uncollectible;
  4. and subsequent fees could no longer even come in, since the address can no longer receive USDC. The hook itself would keep running: poolManager.take would revert, and that would be the only situation in which a swap could fail because of the hook, handled below.

The sizing

QuantityValue at the reference state
USDC that becomes unreachable951,321.600000 USDC
Share of the floor lost100%, not a fraction, all of it
Loss per token0.028800 USDC
Tokens concerned33,032,000 OBOL
Holders concernedall, without exception, including those who never traded
On-chain recoursenone
Off-chain recourseno contract can exercise one
Probabilitycannot be honestly quantified; the function exists and has been used by the issuer on third-party addresses
Insurance covernone

There is no partial failure mode. The vault is a single address holding a single asset. It falls entirely or not at all.

What was considered, and why it is worse

AlternativeWhat it fixesWhat it breaksVerdict
Vault in ETHNo issuer can freeze itR/S becomes a quantity of ETH. In units of use, the floor can fall. The ratchet disappears.Rejected: destroys the central property
Basket of several stablecoinsA single issuer cannot freeze all of itThe floor becomes a weighted price and not a division. The identity (R − nF)/(S − n) = F no longer holds, because R is no longer a scalar.Rejected: destroys the proof
Decentralised stablecoinNo blacklistIntroduces de-peg exposure, with less financial disclosure and collateral that may itself contain USDCRejected: moves the problem and obscures it
Vault spread over k addressesReduces the exposure of one freeze to 1/kThe supply S is unique, so R has to be unique for the division to make sense. Spreading requires an accumulator, hence a loop over k, hence I08 falls.Rejected: breaks the O(1) invariant
Escape hatch that converts the vaultWould react to a freezeA listed address cannot send USDC. The hatch would revert too. It only adds an attack surface and a key.Rejected: does not work
On-chain insuranceWould compensateIt would need an outflow to an insurer, hence a second destination for the fees, hence I09 falls and the floor is no longer R/S.Rejected: destroys the 100% split

The case where a freeze would make a swap fail

This is the only known crack in invariant I01, and it has to be written as it is: if the vault were on the issuer's blacklist, poolManager.take(USDC, address(this), fee) would revert, and the swap would revert with it. The hook would not have "decided" to revert: it would be reverted by a third party, on an asset it does not control. The exact wording of I01 is therefore: the hook never reverts a swap by a decision that belongs to it. A partial mitigation is possible and has to be implemented: wrap the take so that a failure does not cancel the swap but simply forgoes the fee for that swap.

// mitigation: a failed take does not cancel the trade
try poolManager.take(_usdc(key), address(this), fee) {
    _credit(s, fee);
    // the hook returns a delta equal to the fee actually taken
    return (selector, toBeforeSwapDelta(int128(int256(fee)), 0), 0);
} catch {
    // the vault can no longer receive: take nothing, the swap continues
    return (selector, ZERO_DELTA, 0);
}

With this shape, a freeze produces exactly what the rule demands: "the game does not update", never "the trade fails". The vault freezes, the floor freezes, trades continue. It is the acceptable worst case, and it is the one to aim for. Cost of the mitigation: about 700 extra gas per swap for the try/catch, and one more branch to test. The hook's overhead therefore goes from about 19,000 to about 19,700 gas on the beforeSwap path.

The secondary flaw, also sized

The OBOL still held by the PoolManager under the locked liquidity position are part of S, and the vault therefore reserves a share for them. Since the position can never be withdrawn, that share is only claimed if the tokens are bought one day.

L           =   4,120,000 OBOL in the PoolManager
F           =      28,800 µUSDC per OBOL
claim_pool  = 118,656,000,000 µUSDC = 118,656.000000 USDC
share of R  = 118,656,000,000 / 951,321,600,000 = 12.47%

It is not a loss to a third party: nobody collects it. It is value that stays in the vault and reaches nobody as long as the inventory is not sold.

Everything that can go wrong, in order of how much it would cost

01, Circle freezes the vault. 951,321.600000 USDC, 100% of the floor.
USDC contains a blacklist function controlled by its issuer. A blacklisted address can neither send nor receive USDC. If the vault were blacklisted, redemption would revert on every call and the entire floor would become uncollectible. There is no recourse, no insurance and no key that could move the funds, because there is no key.
02, the pool's own inventory has a claim nobody will exercise. 118,656.000000 USDC, 12.47% of the vault.
4,120,000 OBOL still sit inside the pool as unsold liquidity, and they are part of the supply S. The vault therefore reserves 0.028800 USDC for each of them. The liquidity position is locked forever, so if those tokens are never bought, that USDC stays in the vault and is never claimed by anyone. It is not lost to a third party. It simply raises nothing and reaches nobody.
03, the market price on this page comes from the pool. Display only, no protocol exposure.
The market number shown next to the floor is the pool's spot price, and a spot price can be pushed around inside a single block. Nothing in the contract reads it: the fee step depends only on cumulative USDC paid in, and redemption depends only on R and S. A manipulated price changes what this page displays for a few seconds. It changes nothing that anyone is paid.
04, a long silence freezes the fee step. No loss, a delay.
The step only advances when fees are paid in. If volume stops, the step stops. This is not a failure: a step that advanced with time would be a calendar, and this protocol does not have one. But it does mean that a protocol with no volume charges its highest rate to the first trader who returns.
05, nothing has been audited. Unquantified.
Every property described on this site is a property of a specification. A specification cannot have a reentrancy bug; an implementation can. Treat every claim on this site as a claim about intent until there is an address and a report.

If you find a sixth, it belongs on this page. That is the point of the page.

16

Strategies

#StrategyWhat it doesWhat it gainsWhat it loses
S1HoldDo nothingEvery swap by others raises R/S. The floor rises with no gas cost.Nothing of the market price: the floor does not "support" the valuation. Full exposure to a market fall as long as P > F.
S2Redeem nowCall redeemCollects F immediately, with no fee, no price impact.Gives up every future F, which can only rise. Also gives up P − F if the market is above.
S3Sell on the poolSwap OBOL to USDCCollects P minus price impact minus the fee, which exceeds F as long as P×(1 − fee) > F.Pays the fee, which it hands to the vault, hence to those who stay. Takes the price impact.
S4Arbitrage from belowBuy on the pool below 0.028454 then redeem at the vaultCollects F − P×(1 + fee) per token, with no price risk if both legs are in the same transaction.Only gains if someone sells below the threshold, which does not happen permanently. The buy fee feeds the vault.
S5Churn volumeBuy and sell in a loopRaises R/S for everyone, hence for oneselfPays the fee twice and only gets back its pro-rata share. Strictly losing for a minority holder.

Why none dominates

S1 does not dominate. Holding is free and the floor rises, but only if others trade. In a market with no volume, R/S is frozen and S1 earns nothing while staying exposed to a fall in P. S1 is dominated by S2 as soon as one expects zero future volume.

S2 does not dominate. Redeeming immediately locks in an amount that, by construction, can only grow. It is the only strategy in the game that voluntarily gives up a monotonic quantity. It is rational only if one needs liquidity now, or if one judges that the freeze risk (section 15) dominates everything else, which is a serious argument, not a weakness.

S3 does not dominate. Selling on the pool pays more than redeeming as long as P×(1 − fee) > F, which is the case at the reference state (0.041300 × 0.988 = 0.040804 > 0.028800). But price impact grows with size, and for a large enough amount the effective exit price falls below F: S3 then turns into S2 for the excess. The two strategies take over from each other instead of dominating.

S4 does not dominate. The arbitrage only has something to feed on if someone sells below the threshold. It is intermittent by nature and its size is bounded by the imbalance of the book. It cannot be a permanent strategy.

S5 is dominated, and that is intentional. A participant who holds a fraction α of the supply and does X USDC of volume pays X × f of fee and only recovers α × X × f in floor value. They lose (1 − α) × X × f. For α = 1% and X = 1,000,000 USDC at step 2:

fee paid            =  1,000,000 × 0.012        =  12,000.000000 USDC
recovered pro rata  =  12,000 × 0.01            =     120.000000 USDC
net loss            =                              11,880.000000 USDC

There is therefore no profitable loop that manipulates the counter. This is the most important point of the section: the counter cannot be pushed artificially, because pushing it costs a hundred times what it returns to the one who pushes.

The two-player matrix

Two identical holders, each with α = 50% of the supply, choose between hold and redeem. The market is flat, no outside volume.

The other holdsThe other redeems
I holdF each, unchangedF for me, F for them: the identity makes their exit neutral
I redeemF for me, F for themF each, vault and supply at zero

The four cells are identical. That is the result of the identity: the exit game is a game without conflict. There is no run for the exit, no premium for the first, no penalty for the last. This is precisely what distinguishes this protocol from a fractional reserve, where the cell "I redeem, the other holds" is strictly better.

What remains a real conflict

The only conflict in the game is not between holders: it is between those who trade and those who hold. Every swap transfers 40 to 200 basis points from the trader to the set of holders. That transfer is explicit, capped, symmetric, and written on the front page. It is not income: it is a fee taken from an identifiable counterparty, who accepts it by trading.

17

Indicative calibration

None of these values is a projection. They are conversions: at such a volume, such a cumulative. They assert nothing about the volume that will happen.

Volume needed to cross each step

StepFeeVolume in the stepFees producedV reachedCumulative volume
0200 bps10,000,000 USDC200,000 USDC200,000 USDC10,000,000 USDC
1160 bps25,000,000 USDC400,000 USDC600,000 USDC35,000,000 USDC
2120 bps50,000,000 USDC600,000 USDC1,200,000 USDC85,000,000 USDC
380 bps100,000,000 USDC800,000 USDC2,000,000 USDC185,000,000 USDC
440 bpsunboundednonenonenone

The reference state, placed in the table

V observed          =  1,080,000.000000 USDC
step                =  2  (600,000 ≤ V < 1,200,000)
current fee         =  120 basis points

volume of step 0    =  10,000,000 USDC          →  200,000 USDC of fees
volume of step 1    =  25,000,000 USDC          →  400,000 USDC of fees
volume in step 2    =  (1,080,000 − 600,000) / 0.012  =  40,000,000 USDC
                                                →  480,000.000000 USDC of fees
------------------------------------------------------------------
cumulative volume   =  10,000,000 + 25,000,000 + 40,000,000
                    =  75,000,000 USDC
cumulative fees     =  200,000.000000 + 400,000.000000 + 480,000.000000
                    =  1,080,000.000000 USDC                        ✓

Effective average fee since genesis: 1,080,000 / 75,000,000 = 0.0144 = 144 basis points.

The floor at different cumulatives, at constant supply

A pure conversion table, at S = 33,032,000 OBOL constant. It shows where the floor would be for a given cumulative, if nobody had redeemed.

V cumulative (USDC)R if no redemption (USDC)F (µUSDC/OBOL)F (USDC)
200,000200,000.0000006,0540.006054
600,000600,000.00000018,1640.018164
1,080,0001,080,000.00000032,6950.032695
1,200,0001,200,000.00000036,3280.036328
1,364,221.6000001,364,221.60000041,3000.041300
2,000,0002,000,000.00000060,5470.060547

The fifth line is the one that matters: 1,364,221.600000 USDC in the vault would give exactly F = P = 0.041300 USDC at the current supply and price. The difference with the observed R is the public counter: 1,364,221.600000 − 951,321.600000 = 412,900.000000 USDC.

Sensitivity of the counter

How fast the counter moves, depending on what happens. All lines computed at the reference state, step 2.

EventEffect on the counter G
1,000,000 USDC of volume−12,000.000000 USDC
10,000,000 USDC of volume−120,000.000000 USDC
34,408,333.333333 USDC of volume−412,900.000000 USDC, counter at zero
Redemption of 1,000,000 OBOL−12,500.000000 USDC
The market rises by 0.001000 USDC+33,032.000000 USDC
The market falls by 0.001000 USDC−33,032.000000 USDC

The last pair of lines is the most honest one on this page: the counter is more sensitive to price than to volume. A 2.42% price move shifts the counter as much as 2,752,666 USDC of volume. The site must never suggest the opposite.

price move of 1,000 µUSDC  →  ΔG = 1,000 × 33,032,000 = 33,032,000,000 µUSDC
equivalent volume at step 2 =  ⌊ 33,032,000,000 × 10,000 / 120 ⌋
                            =  2,752,666,666,666 µUSDC
                            =  2,752,666.666666 USDC
1,000 µUSDC on a price of 41,300 µUSDC  =  2.42%
18

What can go wrong

#ScenarioProbabilitySized impactMitigation
1The USDC issuer freezes the vaultcannot be quantified951,321.600000 USDC, 100% of the floorNone. The try/catch of section 15 only preserves the trades.
2Implementation bug in the pro-rata calculationnon-zero until there is an auditup to 100% of the vaultProperty tests on the identity (R − nF)/(S − n) = F with fuzzing on n, R, S. Conservation test V = R + W after every operation.
3uint128 overflow on reservezero in practicenone2^128 − 1 µUSDC ≈ 3.4 × 10^32 USDC. It would take more USDC than will ever exist. Checked by assertion off the hot path.
4Zero volume for a long timehighThe step freezes. The first trader who returns pays the rate of the frozen step, not a "caught-up" rate.None, and it is intentional: a step that advanced with time would be a calendar.
5Manipulation of the displayed priceeasyZero on the protocol. The price enters no calculation of the contract.The interface labels the price as pool spot and only uses it for display.
6Gift of OBOL to the PoolManager to reduce SpossibleRaises R/S for the others. It is a gift, not an attack.None needed.
7Massive simultaneous redemptionpossibleNone. The identity makes the exit order irrelevant.None needed. It is the central property.
8The pool runs out of USDC liquiditypossible in a bear marketThe pool can no longer pay sellers. Redemption keeps working: it does not go through the pool.None needed. This is precisely the case where the vault is useful.
9A malicious router lies about sendercertain, permanentlyZero. No right is attached to an address.I12.
10Reentrancy from the USDC transferlow (USDC has no hook)up to the whole vault if the asset changedState is written before the transfer in redeem (checks-effects-interactions), plus a reentrancy lock on redeem.
11The pool's tickSpacing or lpFee are not the intended oneslowThe hook could be attached to a misconfigured poolbeforeInitialize reverts if lpFee != 0 or if the currencies are not the right ones. That is not a swap: reverting there is allowed.
12A second OBOL pool is created without the hookcertainThe fee does not apply there. The floor is not fed there.None. Redemption stays open and arbitrage links the two markets.
19

Open questions

  1. Should there be a sixth step at 20 basis points? Beyond 185,000,000 USDC of cumulative volume, 40 basis points may still be expensive for a mature market. Argument against: adding a step after deployment is impossible, and adding one now pushes back the moment when the fee becomes negligible.
  2. Should redemption emit an event separate from the burn's Transfer? A Redeemed(address, uint256 burned, uint256 paid) would make indexing easier. Cost: about 1,800 gas per redemption. Undecided.
  3. Should floorPerToken() be exposed as a view on the hook? The interface can compute it from reserve() and totalSupply(). Exposing the division saves a call but hard-codes a rounding convention in the contract. Undecided.
  4. Should the initial liquidity position cover the whole price range or a bounded range? A bounded range concentrates liquidity and increases volume per unit of capital, hence feeds the vault faster; but it can be fully crossed, which leaves the pool without inventory on one side. Undecided: it is the most debatable deployment parameter.
  5. What to do with the claim of the pool's inventory (118,656.000000 USDC)? A variant would exclude balanceOf(PoolManager) from S. It is rejected because a buy would then make S rise, and therefore R/S fall: the ratchet would be broken by a simple purchase. No other variant has been found that preserves I02. Open question.
  6. Should a formal proof of I02 be published? The identity is simple enough to be checked by an SMT solver on bounded integers. It would be the first deliverable after a classic audit.
  7. Should the try/catch of section 15 also wrap _credit? No according to the current analysis, _credit cannot revert, but it deserves confirmation by a review.
20

Glossary

Vault
The hook's address, which holds the USDC. Written R when talking about its balance.
Floor (F)
R / S. The amount in USDC that one OBOL redeems for at this instant.
Supply (S)
OBOL.totalSupply(). Decreases only by burning during a redemption.
Paid in (V)
The sum of every fee that ever entered the vault. Non-decreasing.
Step
An integer from 0 to 4 that selects the rate in the table. Non-decreasing.
Redeem
A direct call that burns n OBOL and pays ⌊R×n/S⌋ micro-USDC to the caller.
Public counter (G)
P×S − R. The number of USDC the vault is missing for the floor to reach the market.
Coverage (c)
⌊F×10000/P⌋, in basis points. 6973 at the reference state.
µUSDC
One millionth of a USDC. The whole unit in which everything is counted.
Burn quantum
10^12 wei of OBOL, that is 10^-6 OBOL. The granularity of redemption.
Basis point (bps)
One ten-thousandth. 120 bps = 1.20%.
Specified currency
In a v4 swap, the one whose amount was fixed by the trader.
Unspecified currency
The other one. Its amount comes out of the pool's calculation.
BeforeSwapDelta
The value beforeSwap returns to take a fee on the specified currency.
Router
The contract that calls PoolManager.swap(). It, and not the trader, appears as sender in the callbacks.
Blacklist
A function of the USDC contract that lets the issuer forbid an address from sending or receiving. The protocol's unresolved flaw.
Transfer
The word used on this site for a gain that comes from another participant. Never "income".
Ratchet
The shape of the problem: a quantity that only moves in one direction. Not an object.
Appendix A

The twelve decisions and the alternatives set aside

A.1, the vault is in USDC, a single asset.
Kept: a single-asset vault in USD Coin, issued by Circle on Ethereum. Set aside: ETH; a basket; a decentralised stablecoin; an interest-bearing token from a third-party issuer. Why: the floor has to be a quantity whose unit of account does not move, otherwise R/S can fall in value of use without any operation having taken place, and the ratchet disappears. What it costs: full exposure to a freeze, section 15.
A.2, S is totalSupply(), pool inventory included.
Kept: S = OBOL.totalSupply(). Set aside: S = totalSupply() − balanceOf(PoolManager), to exclude the unsold inventory. Why: with the variant set aside, a simple buy would move tokens out of the PoolManager and increase S, which would lower R/S. The ratchet would be broken by the most ordinary operation of the protocol. What it costs: 118,656.000000 USDC of claim that reaches nobody as long as the inventory is not sold.
A.3, the step is indexed on V, not on R, not on time.
Kept: step = f(V) where V is the cumulative ever paid in. Set aside: indexing on R (the current balance); on elapsed time; on the price; on the coverage F/P. Why: R decreases on redemptions, so the step would go back up and the fee with it, which would no longer be a ratchet. Time would be a calendar, forbidden. The price and the coverage can be manipulated inside a block. What it costs: a protocol with no volume stays at the step it reached.
A.4, five steps, not a curve.
Kept: a five-row table in constants. Set aside: fee = max(40, 200 − k×V) or any other interpolation. Why: verifiability in one's head, reproducibility, and no advantage to the first bot of the block. What it costs: discontinuities at the crossings, handled in section 11.
A.5, 100% of the fee to the vault, lpFee = 0.
Kept: no split. Everything to the vault. Set aside: 70/30 to LPs; 90/10 to a treasury; a flow to an insurer. Why: the only liquidity position is locked forever, so LP fees would be value with no beneficiary. And any outgoing share would make R/S stop being the whole story. What it costs: no third-party LP has an incentive to come. The pool is single-position by construction.
A.6, redemption has no fee.
Kept: redeem takes nothing. Set aside: an exit fee of 25 to 100 basis points that would stay in the vault. Why: an exit fee would raise R/S, hence reinforce the ratchet, but it would destroy the property that makes the protocol understandable: "you receive exactly your share". The word "exactly" is worth more than a few basis points. What it costs: the vault does not grow when people leave through the vault door.
A.7, the fee is always in USDC.
Kept: beforeSwap when USDC is specified, afterSwap otherwise. Set aside: taking the fee in the specified currency whichever it is, and burning the OBOL taken. Why: burning OBOL would also raise R/S and would be simpler to implement. But the protocol's sentence would become false: the vault would no longer be fed by "every swap". And the public counter would lose its most readable property, one USDC of fee removes one USDC from the counter. What it costs: two code paths instead of one.
A.8, no address is privileged, sender is never read.
Kept: zero per-address state in the hook. Set aside: a list of trusted routers; decoding hookData; a reduced rate for holders. Why: section 09. Anything that depends on sender can be forged by deploying a contract. What it costs: impossible to reward individual behaviour. The protocol can only handle aggregates.
A.9, liquidity locked forever.
Kept: beforeRemoveLiquidity reverts unconditionally. Set aside: a time-limited lock; a lock that a vote can lift. Why: a lock that can be lifted is a lock that will be lifted. A limited duration is a date, hence a calendar. What it costs: the initial position can never be rebalanced if the price range turns out to be badly chosen.
A.10, the try/catch around the take.
Kept: a failed fee does not cancel the swap. Set aside: letting it revert, which would be simpler and 700 gas cheaper. Why: the worst case must be "the game does not update", never "the trade fails". What it costs: about 700 gas per swap and one more branch to test.
A.11, minimum redemption of 1 OBOL, burn quantum of 10^-6 OBOL.
Kept: MIN_REDEEM = 1e18, BURN_QUANTUM = 1e12. Set aside: no minimum; a minimum expressed in USDC received. Why: a minimum in USDC would depend on F, hence change over time, hence be a moving parameter. A minimum in tokens is fixed and readable. What it costs: very small holders have to pool. At the reference state, 1 OBOL is worth 0.028800 USDC at redemption, which is already below the gas cost.
A.12, no date, no projection, anywhere.
Kept: the site announces no launch, no target, no future price. Set aside: a launch countdown; a roadmap; a twelve-month floor estimate. Why: the protocol's only countdown must emerge from the state, not from a calendar. What it costs: a less exciting front page. It is what makes it credible.
Appendix B

What remains unresolved

B.1, the freeze of the vault by the USDC issuer.
Full restatement of section 15. The vault holds USDC. The USDC contract has a blacklist function that Circle can trigger unilaterally. If the vault address were listed: 951,321.600000 USDC become unreachable, 100% of the floor is lost, 0.028800 USDC per token, 33,032,000 OBOL concerned, no recourse. No mitigation exists on the vault side. The only possible mitigation is on the trade side, the try/catch that guarantees that swaps continue even if the fee can no longer come in. The protocol would then survive in a degraded form where the floor is frozen at its last value and uncollectible. It is mitigated nowhere because it cannot be.
B.2, the claim of the pool's inventory.
4,120,000 OBOL are still in the PoolManager under the locked position. They are part of S, so the vault reserves 118,656.000000 USDC for them, 12.47% of the vault. Since the position can never be withdrawn, that share will reach nobody as long as those tokens are not bought. The only known alternative breaks invariant I02 (A.2). Unresolved.
B.3, the displayed market price cannot be verified by the contract.
The public counter displayed on the site uses the pool's spot price. That price can be manipulated inside a block. The contract does not read it and no payment depends on it, so the protocol's exposure is zero, but the display can lie for a few seconds, and no time average is proposed. Unresolved, judged minor.
B.4, the behaviour of the step during a long silence.
The step only advances with volume. A protocol with no volume keeps its highest rate indefinitely. That is consistent with the ban on calendars, but it means that a return of volume after a long absence pays more than the "natural" trajectory would have produced. No correction is proposed, because any correction would be a clock.
B.5, the choice of the initial liquidity range.
Nothing settles between a full-range position and a bounded one. It is the most debatable deployment parameter and it has no good market-independent answer.
B.6, the absence of an audit.
Nothing on this page describes deployed code. Every property stated is a property of a specification. A specification cannot have a reentrancy bug; an implementation can. Only an audit of deployed code can settle this.
B.7, what the site cannot display yet.
Every number on this site is a worked example, arithmetically consistent, and labelled as such. There is no way to make these numbers real before there is a chain to read.
Appendix C

What to watch once deployed

What to look at once deployed, how often, and what should raise an alert. None of these watches has any power over the contract: they are observations, not levers.

#QuantitySourceFrequencyReference valueAlert thresholdMeaning of an alert
D01R, vault balancev.reserveevery block951,321.600000 USDCany drop not explained by a redeemLeak. Critical bug.
D02S, supplyOBOL.totalSupply()every block33,032,000 OBOLany riseMinting is impossible in theory. Critical bug.
D03R/S, floorcomputedevery block28,800 µUSDCany drop, even by one unitViolation of I02. Maximum alert.
D04V, paid inv.paidInevery block1,080,000.000000 USDCany dropViolation of I04.
D05V − R − Wcomputedevery hour0≠ 0Violation of the accounting identity.
D06Stepv.stepevery block2any riseViolation of I05.
D07Observed effective feeswap eventsevery hour120 bps> 200 bpsViolation of I06.
D08Buy/sell rate gapswap eventsevery day0 bps≠ 0Violation of the symmetry.
D09Counter Gcomputedevery block412,900.000000 USDCcrossing below 0Valid state, but the interface must switch to the "floor is above market" message.
D10Coverage ccomputedevery block6973 bps> 10,000 bpsThe floor exceeds the market. Redemption arbitrage active.
D11Swaps reverted by the hooktracesevery block0≥ 1Violation of I01. Check the vault's blacklist status immediately.
D12Blacklist status of the vaultUSDC.isBlacklisted(vault)every blockfalsetrueThe flaw of section 15 has materialised. Switch the site to degraded mode, display the freeze message.
D13L, OBOL in the PoolManagerOBOL.balanceOf(poolManager)every day4,120,000 OBOLnoneTracking of the inventory claim.
D14Gas added per swaptracesevery week≈ 19,700> 30,000A path costs more than planned. Check that no loop has been introduced.
D15Cumulative volumeeventsevery day75,000,000 USDCnonePlaces the protocol in the calibration table.

The degraded-mode message

If D12 triggers, the site must display, at the top of every page: the vault address has been blacklisted by the USDC issuer. Redemption reverts. The floor is still computable and is no longer collectible. Swaps continue and no longer pay into the vault. This is the failure described on this page. That message is written in advance, here, because a protocol that discovers its own failure should not have to draft its communication in a hurry.