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.
Overview
| Ticker | $OBOL |
| Name | obol.trade |
| Token contract | 0xD47f1668F3DC00FEB7937a0bc4F69b87ceC4e4aA |
| The sentence | Every swap pays USDC into a vault, and the redemption price per token can only go up. |
| Vault asset | USD 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 work | The 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. |
| Buy | Pays USDC to the vault. The floor per token rises. |
| Sell | Two 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 counter | Floor 0.028800, market 0.041300: 412,900.000000 USDC are missing from the vault for the floor to reach the market. |
| The quantum | Three 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 fee | A 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 goes | 100% 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 surface | One 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 flaw | The 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.
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:
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:
| Operation | Effect on R | Effect on S | Effect on R/S |
|---|---|---|---|
| Buy on the pool | + fee | 0 | rises |
| Sell on the pool | + fee | 0 | rises |
| Redeem at the vault | − n×F | − n | unchanged |
| Donate USDC to the vault | + gift | 0 | rises |
| Nothing else | none | none | none |
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.
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.
Buy, sell, redeem
| Direction | What the trader does | What the protocol does | Why it is natural |
|---|---|---|---|
| Buy, USDC to OBOL | Brings USDC | The 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 USDC | Takes USDC out | The 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 USDC | Exercises the claim | The 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.
The public counter
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.
How it moves
| Event | Effect on G |
|---|---|
| A fee f enters the vault | G decreases by exactly f |
| A redemption of n tokens | G 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.
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.
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
| Field | Type | Unit | Monotonic | Bound |
|---|---|---|---|---|
| reserve | uint128 | µUSDC | no (redemption lowers it) | 2^128 − 1 ≈ 3.40 × 10^38 µUSDC |
| paidIn | uint120 | µUSDC | yes, non-decreasing | 2^120 − 1 ≈ 1.33 × 10^36 µUSDC |
| step | uint8 | none | yes, non-decreasing | 4 |
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 stored | Why |
|---|---|
| Each address's balance in the protocol | The ERC-20 token already does it. The hook does not need to know who holds. |
| The number of holders | No operation depends on it. |
| A price history | No operation depends on it. No oracle on the hot path. |
| The date of the last operation | There is no calendar in this protocol. |
| A list of allowed routers | No sender is ever privileged (section 09). |
| An owner, an admin, a pauser | They do not exist. |
Derived quantities
All of them are computed from R, S, V, plus P for display. None is stored.
| Symbol | Name | Formula (integer arithmetic) | Unit | Reference value |
|---|---|---|---|---|
| F | Floor per token | ⌊R / S⌋ in µUSDC per whole OBOL | µUSDC / OBOL | 28,800 → 0.028800 USDC |
| payout(n) | Redemption amount | ⌊R × n / S⌋ | µUSDC | 28,800,000,000 for n = 1,000,000 OBOL |
| c | Coverage | ⌊F × 10,000 / P⌋ | basis points | 6973 → 69.73% |
| G | Public counter | P × S − R | µUSDC | 412,900,000,000 → 412,900.000000 USDC |
| premium | Market premium | P − F | µUSDC / OBOL | 12,500 → 0.012500 USDC |
| W | Paid out to redeemers | V − R | µUSDC | 128,678,400,000 → 128,678.400000 USDC |
| B | Burned by redemption | GENESIS_SUPPLY − S | OBOL | 6,968,000 |
| claim_pool | Claim of the pool's inventory | L × F, where L = OBOL held by the PoolManager | µUSDC | 118,656,000,000 → 118,656.000000 USDC |
| vol_to_close | Volume needed to close the gap at the current step | ⌊G × 10,000 / fee_bps⌋ | µUSDC | 34,408,333,333,333 → 34,408,333.333333 USDC |
| arb_threshold | Price below which redemption arbitrage opens | ⌊F × (10,000 − fee_bps) / 10,000⌋ | µUSDC / OBOL | 28,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.
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
| Permission | Used for | Can it block a swap? |
|---|---|---|
| beforeInitialize | Check 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. |
| beforeAddLiquidity | Reject any liquidity addition that does not come from the hook itself. | No, it is not a swap. |
| beforeRemoveLiquidity | Reject every liquidity removal, without exception. | No, it is not a swap. |
| beforeSwap | Take the fee when USDC is the specified currency. | No. Never. Invariant I01. |
| beforeSwapReturnDelta | Return the delta on the specified currency. | No. |
| afterSwap | Take the fee when USDC is the unspecified currency. | No. Never. Invariant I01. |
| afterSwapReturnDelta | Return 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
| Case | Direction | Type | Specified | Unspecified | Where the fee is taken | Callback |
|---|---|---|---|---|---|---|
| C1 | Buy, USDC to OBOL | exactInput | USDC | OBOL | On the USDC input, before the swap | beforeSwap, delta on specified |
| C2 | Buy, USDC to OBOL | exactOutput | OBOL | USDC | On the USDC input, after the swap | afterSwap, delta on unspecified |
| C3 | Sell, OBOL to USDC | exactInput | OBOL | USDC | On the USDC output, after the swap | afterSwap, delta on unspecified |
| C4 | Sell, OBOL to USDC | exactOutput | USDC | OBOL | On the requested USDC output, before the swap | beforeSwap, 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
| Path | Variable number of instructions? | Proof |
|---|---|---|
| _beforeSwap | no | No for, no while, no recursion. _stepOf and _feeBps are comparison chains of fixed depth (4 and 4). One slot read, one slot write. |
| _afterSwap | no | Identical. |
| redeem | no | Two reads, one wide multiplication, one division, one burn, one transfer. The ERC-20 burn is O(1). |
| beforeAddLiquidity | no | One address comparison. |
| beforeRemoveLiquidity | no | One unconditional revert. |
| Constructor | no | One 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.
| Path | Detail | Gas 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 block | The SLOAD is cold (2,100) instead of warm (100) | ≈ +2,000 |
| redeem | 2 SLOAD, 1 256-bit mulDiv, 1 burn, 1 USDC transfer | ≈ 62,000 |
| beforeAddLiquidity, rejected | 1 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.
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
| Function | Who is msg.sender | Reliable? | Does the contract depend on it? |
|---|---|---|---|
| beforeSwap | The PoolManager | yes, but useless | no |
| beforeSwap, sender parameter | The router | no | no, never read |
| afterSwap, sender parameter | The router | no | no, never read |
| hookData | Supplied by the caller | no | no, never read |
| redeem | The real caller | yes | yes, to burn and to pay |
| beforeAddLiquidity, sender parameter | The position router | no | yes, 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.
The quantum
| Quantum | Value | Where it applies | Why |
|---|---|---|---|
| Fee quantum | 1 µUSDC = 0.000001 USDC | The calculation abs × bps / 10000 is an integer division, truncated down | USDC 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 quantum | 1,000,000,000,000 wei = 10^-6 OBOL | Any amount passed to redeem is truncated to a multiple of this value | Prevents dust redemptions that would cost more in gas than they return, and makes the burned amount exactly representable in micro-OBOL. |
| Floor display quantum | 1 µUSDC per OBOL | The interface displays F truncated to the micro-USDC | A 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.
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.
| Step | V, cumulative paid into the vault (USDC) | Fee (bps) | Fee (%) | Volume in the step (USDC) | Fees produced (USDC) | V at end of step (USDC) |
|---|---|---|---|---|---|---|
| 0 | 0 to 199,999.999999 | 200 | 2.00% | 10,000,000 | 200,000 | 200,000 |
| 1 | 200,000 to 599,999.999999 | 160 | 1.60% | 25,000,000 | 400,000 | 600,000 |
| 2 | 600,000 to 1,199,999.999999 (reference state) | 120 | 1.20% | 50,000,000 | 600,000 | 1,200,000 |
| 3 | 1,200,000 to 1,999,999.999999 | 80 | 0.80% | 100,000,000 | 800,000 | 2,000,000 |
| 4 | 2,000,000 and above | 40 | 0.40% | unbounded | none | none |
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
Where the fees go
| Destination | Basis points of the fee | Percentage |
|---|---|---|
| The vault | 10,000 | 100.00% |
| Liquidity providers | 0 | 0.00% |
| A team address | 0 | 0.00% |
| A protocol treasury | 0 | 0.00% |
| Total | 10,000 | 100.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
| Destination | OBOL | Share |
|---|---|---|
| v4 liquidity position, locked | 40,000,000 | 100.00% |
| Team | 0 | 0.00% |
| Private buyers | 0 | 0.00% |
| Treasury | 0 | 0.00% |
| Total minted | 40,000,000 | 100.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.
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
| Event | R before | R after | S before | S after | R/S before | R/S after | I02 |
|---|---|---|---|---|---|---|---|
| Buy 10,000 USDC at 120 bps | 951,321.600000 | 951,441.600000 | 33,032,000 | 33,032,000 | 28,800.0000 | 28,803.6328 | rises ✓ |
| Sell, gross output 20,000 USDC at 120 bps | 951,321.600000 | 951,561.600000 | 33,032,000 | 33,032,000 | 28,800.0000 | 28,807.2656 | rises ✓ |
| Redeem 1,000,000 OBOL | 951,321.600000 | 922,521.600000 | 33,032,000 | 32,032,000 | 28,800.0000 | 28,800.0000 | stable ✓ |
| Redeem one quantum (10^-6 OBOL) | 951,321.600000 | 951,321.600000 | 33,032,000 | 33,031,999.999999 | 28,800.0000 | 28,800.0000+ | rises ✓ |
R/S values are in micro-USDC per OBOL, with four decimals, before display truncation.
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.
| # | Impossibility | Why it is structural |
|---|---|---|
| 1 | Revert a swap | No revert on the swap path, in any branch. I01. |
| 2 | Block a redemption | No pause, no owner, no list. I10. |
| 3 | Charge more than 200 basis points | MAX_FEE_BPS is a constant. I06. |
| 4 | Change the fee table | Five constants inlined in the bytecode. |
| 5 | Move USDC out other than through redeem | One transfer instruction, to msg.sender. I09. |
| 6 | Mint OBOL | The mint is in the constructor; the function does not exist afterwards. |
| 7 | Be upgraded | No proxy, no delegatecall, no re-callable initializer. |
| 8 | Know who is trading | sender is the router, and it is never read. I12. |
| 9 | Loop | No iterable structure. I08. |
| 10 | Withdraw the initial liquidity | beforeRemoveLiquidity reverts unconditionally, including for the hook. |
| 11 | Read a price | No oracle, no slot0 read on the hot path. The displayed price is computed on the interface side. |
| 12 | Prevent a Circle freeze | No 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.
The unresolved flaw
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:
- USDC.safeTransfer(msg.sender, payout) would revert on every call;
- so redeem would revert on every call, for everyone, forever;
- so the floor would remain computable (R and S are still readable) but would become uncollectible;
- 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
| Quantity | Value at the reference state |
|---|---|
| USDC that becomes unreachable | 951,321.600000 USDC |
| Share of the floor lost | 100%, not a fraction, all of it |
| Loss per token | 0.028800 USDC |
| Tokens concerned | 33,032,000 OBOL |
| Holders concerned | all, without exception, including those who never traded |
| On-chain recourse | none |
| Off-chain recourse | no contract can exercise one |
| Probability | cannot be honestly quantified; the function exists and has been used by the issuer on third-party addresses |
| Insurance cover | none |
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
| Alternative | What it fixes | What it breaks | Verdict |
|---|---|---|---|
| Vault in ETH | No issuer can freeze it | R/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 stablecoins | A single issuer cannot freeze all of it | The 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 stablecoin | No blacklist | Introduces de-peg exposure, with less financial disclosure and collateral that may itself contain USDC | Rejected: moves the problem and obscures it |
| Vault spread over k addresses | Reduces the exposure of one freeze to 1/k | The 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 vault | Would react to a freeze | A 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 insurance | Would compensate | It 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.
Strategies
| # | Strategy | What it does | What it gains | What it loses |
|---|---|---|---|---|
| S1 | Hold | Do nothing | Every 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. |
| S2 | Redeem now | Call redeem | Collects 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. |
| S3 | Sell on the pool | Swap OBOL to USDC | Collects 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. |
| S4 | Arbitrage from below | Buy on the pool below 0.028454 then redeem at the vault | Collects 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. |
| S5 | Churn volume | Buy and sell in a loop | Raises R/S for everyone, hence for oneself | Pays 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 holds | The other redeems | |
|---|---|---|
| I hold | F each, unchanged | F for me, F for them: the identity makes their exit neutral |
| I redeem | F for me, F for them | F 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.
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
| Step | Fee | Volume in the step | Fees produced | V reached | Cumulative volume |
|---|---|---|---|---|---|
| 0 | 200 bps | 10,000,000 USDC | 200,000 USDC | 200,000 USDC | 10,000,000 USDC |
| 1 | 160 bps | 25,000,000 USDC | 400,000 USDC | 600,000 USDC | 35,000,000 USDC |
| 2 | 120 bps | 50,000,000 USDC | 600,000 USDC | 1,200,000 USDC | 85,000,000 USDC |
| 3 | 80 bps | 100,000,000 USDC | 800,000 USDC | 2,000,000 USDC | 185,000,000 USDC |
| 4 | 40 bps | unbounded | none | none | none |
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,000 | 200,000.000000 | 6,054 | 0.006054 |
| 600,000 | 600,000.000000 | 18,164 | 0.018164 |
| 1,080,000 | 1,080,000.000000 | 32,695 | 0.032695 |
| 1,200,000 | 1,200,000.000000 | 36,328 | 0.036328 |
| 1,364,221.600000 | 1,364,221.600000 | 41,300 | 0.041300 |
| 2,000,000 | 2,000,000.000000 | 60,547 | 0.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.
| Event | Effect 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%
What can go wrong
| # | Scenario | Probability | Sized impact | Mitigation |
|---|---|---|---|---|
| 1 | The USDC issuer freezes the vault | cannot be quantified | 951,321.600000 USDC, 100% of the floor | None. The try/catch of section 15 only preserves the trades. |
| 2 | Implementation bug in the pro-rata calculation | non-zero until there is an audit | up to 100% of the vault | Property tests on the identity (R − nF)/(S − n) = F with fuzzing on n, R, S. Conservation test V = R + W after every operation. |
| 3 | uint128 overflow on reserve | zero in practice | none | 2^128 − 1 µUSDC ≈ 3.4 × 10^32 USDC. It would take more USDC than will ever exist. Checked by assertion off the hot path. |
| 4 | Zero volume for a long time | high | The 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. |
| 5 | Manipulation of the displayed price | easy | Zero 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. |
| 6 | Gift of OBOL to the PoolManager to reduce S | possible | Raises R/S for the others. It is a gift, not an attack. | None needed. |
| 7 | Massive simultaneous redemption | possible | None. The identity makes the exit order irrelevant. | None needed. It is the central property. |
| 8 | The pool runs out of USDC liquidity | possible in a bear market | The 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. |
| 9 | A malicious router lies about sender | certain, permanently | Zero. No right is attached to an address. | I12. |
| 10 | Reentrancy from the USDC transfer | low (USDC has no hook) | up to the whole vault if the asset changed | State is written before the transfer in redeem (checks-effects-interactions), plus a reentrancy lock on redeem. |
| 11 | The pool's tickSpacing or lpFee are not the intended ones | low | The hook could be attached to a misconfigured pool | beforeInitialize reverts if lpFee != 0 or if the currencies are not the right ones. That is not a swap: reverting there is allowed. |
| 12 | A second OBOL pool is created without the hook | certain | The fee does not apply there. The floor is not fed there. | None. Redemption stays open and arbitrage links the two markets. |
Open questions
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
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.
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.
| # | Quantity | Source | Frequency | Reference value | Alert threshold | Meaning of an alert |
|---|---|---|---|---|---|---|
| D01 | R, vault balance | v.reserve | every block | 951,321.600000 USDC | any drop not explained by a redeem | Leak. Critical bug. |
| D02 | S, supply | OBOL.totalSupply() | every block | 33,032,000 OBOL | any rise | Minting is impossible in theory. Critical bug. |
| D03 | R/S, floor | computed | every block | 28,800 µUSDC | any drop, even by one unit | Violation of I02. Maximum alert. |
| D04 | V, paid in | v.paidIn | every block | 1,080,000.000000 USDC | any drop | Violation of I04. |
| D05 | V − R − W | computed | every hour | 0 | ≠ 0 | Violation of the accounting identity. |
| D06 | Step | v.step | every block | 2 | any rise | Violation of I05. |
| D07 | Observed effective fee | swap events | every hour | 120 bps | > 200 bps | Violation of I06. |
| D08 | Buy/sell rate gap | swap events | every day | 0 bps | ≠ 0 | Violation of the symmetry. |
| D09 | Counter G | computed | every block | 412,900.000000 USDC | crossing below 0 | Valid state, but the interface must switch to the "floor is above market" message. |
| D10 | Coverage c | computed | every block | 6973 bps | > 10,000 bps | The floor exceeds the market. Redemption arbitrage active. |
| D11 | Swaps reverted by the hook | traces | every block | 0 | ≥ 1 | Violation of I01. Check the vault's blacklist status immediately. |
| D12 | Blacklist status of the vault | USDC.isBlacklisted(vault) | every block | false | true | The flaw of section 15 has materialised. Switch the site to degraded mode, display the freeze message. |
| D13 | L, OBOL in the PoolManager | OBOL.balanceOf(poolManager) | every day | 4,120,000 OBOL | none | Tracking of the inventory claim. |
| D14 | Gas added per swap | traces | every week | ≈ 19,700 | > 30,000 | A path costs more than planned. Check that no loop has been introduced. |
| D15 | Cumulative volume | events | every day | 75,000,000 USDC | none | Places 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.
Legal
Nothing on this site is an offer to sell, a solicitation to buy, or an invitation to invest. Nothing on this site is financial, legal or tax advice.
Every number on this site is a worked example, constructed to be arithmetically consistent with the specification. No number on this site is a reading from a chain, a measurement of a real market, or a forecast of any kind.
The specification described here has not been audited. When there are contract addresses and audit reports, this page will carry them, or it will say that there are none.
