ReserveStable

ReserveStable implements 5 of the fourteen Uniswap v4 callbacks: beforeInitialize, beforeAddLiquidity, beforeRemoveLiquidity, beforeSwap, beforeSwapReturnsDelta.

drag to orbit

Uniswap v4 hook · Curves

ReserveStable

A pegged-pair pool that pays arbitrageurs to rebalance it, out of a fund it filled while going wrong.

Family
Curves
Callbacks
5 of 14
Fee
static
Admin keys
none
Licence
Apache-2.0

How it works

Every stable pool in production prices with a curve that is flat near parity and steep away from it, and every one of them shares a defect: the only force restoring balance is the price the curve happens to quote. When one side runs low, the pool makes it expensive to take more, and then it waits. Nothing pays anybody to bring the missing side back.

Arbitrage does it eventually, if an external venue happens to make it worth doing, and if it does not, the pool sits lopsided with its deep side useless and its shallow side unusable. This pool prices at parity and adjusts with a signed spread rather than a curve. A trade that takes from the scarce side pays a spread that grows as that side gets scarcer, from `baseSpreadBps` up to `maxSpreadBps`.

That much is ordinary. The part that is not: everything charged above the base rate goes into an explicit stability fund rather than to the providers, and a trade that takes from the *abundant* side is paid a rebate out of that fund. Restoring the balance is not merely cheaper than breaking it, it is profitable, and it is profitable in exact proportion to how badly the pool needs it.

The fund is what makes this safe rather than a subsidy with no source. It only ever grows from spread charged above the base rate, and no rebate can exceed it, so across any sequence of trades the pool pays out strictly less than it took in for this purpose. Providers cannot be made worse off than they would have been in the same pool charging a flat `baseSpreadBps` and nothing else, which is a property, not an aspiration: it follows from the fund being a separate balance that a rebate can only draw down.

The round trip does not pay either. `maxRebateBps` is capped at `maxSpreadBps - baseSpreadBps` at construction, so breaking the balance and restoring it costs the base spread twice and recovers at most what breaking it paid in. The fund is also empty when the pool is balanced, which is exactly when a round-tripper would want to start.

Underneath both of those sits a floor: no swap may leave either side holding less than `minSideBps` of the pool. That is the band. A pegged pool that will sell the last unit of one side at any price is a pool that can be emptied, and no spread schedule fixes that, because the last unit is worth more than any finite fee.

Reserves are normalized to eighteen decimals from the units given at construction, so a six-decimal stablecoin and an eighteen-decimal one are compared correctly rather than off by a factor of a trillion.

Prior art

Curve's StableSwap and the v4 hooks that reimplement it quote a flat-then-steep curve. Frax's AMOs, Angle's transmuter and Reflexer's redemption rate move a peg by minting or by changing a target, all with a governed controller. Dynamic-fee hooks price imbalance in one direction only, because a fee cannot be negative.

A v4 pool that pays a bounded negative spread to whoever restores its balance, funded solely by the surcharge it collected while losing that balance, and provably unable to pay out more than it took in, is the contribution here.

Where it does not help

Only sound for a genuinely pegged pair. The fund is denominated in normalized units and treats one side as interchangeable with the other, which is true while the peg holds and false the moment it does not: against an asset that has actually broken, this pool will pay a rebate for taking the good side and call it rebalancing. Pair it with a depeg guard rather than trusting it alone.

The pricing is also constant-sum, so it never quotes anything but parity plus a spread, and it does not move the v4 pool price at all, which means it publishes no oracle a downstream contract can read.

Using it

Uniswap v4 removed hookData from initialize, so per-pool parameters arrive out of band. Fix them for a pool key whose pool does not exist yet, then initialize. Nobody can change them afterwards, including you.

poolManager.initialize(key, startingSqrtPriceX96);

Parameters

This hook takes no per-pool configuration.

From TypeScript

npm i @hookforge/sdk

import {getHook, hookAddress, poolKeyFor} from "@hookforge/sdk";

const hook = getHook("reserve-stable");
const key  = poolKeyFor({
  hook: hookAddress("reserve-stable", 8453),   // Base
  currencyA: USDC, currencyB: WETH,
  tickSpacing: 60,
});

What it reverts with

ErrorMeaning
AlreadyInitialized()Hook was already initialized.
AmountTooSmall()A deposit was too small to mint any shares, or a withdrawal too small to return anything.
BreaksFloor(uint256,uint256)The swap would push a side below the floor, which is the one thing this pool will not do.
ERC20InsufficientAllowance(address,uint256,uint256)Indicates a failure with the spender’s allowance. Used in transfers.
ERC20InsufficientBalance(address,uint256,uint256)Indicates an error related to the current balance of a sender. Used in transfers.
ERC20InvalidApprover(address)Indicates a failure with the approver of a token to be approved. Used in approvals.
ERC20InvalidReceiver(address)Indicates a failure with the token receiver. Used in transfers.
ERC20InvalidSender(address)Indicates a failure with the token sender. Used in transfers.
ERC20InvalidSpender(address)Indicates a failure with the spender to be approved. Used in approvals.
ExpiredPastDeadline()A liquidity modification order was attempted to be executed after the deadline.
FundInsolvent(uint256,uint256)A rebate came out larger than the fund backing it, which the quote is supposed to make impossible.
InsufficientInitialLiquidity()The first deposit must exceed the permanently locked minimum.
InvalidFloor()The floor must leave room to trade, so it has to be below half the pool.
InvalidHalfPoint()The half point of the spread curve cannot be zero, or the spread jumps to its cap immediately.
InvalidNativePayer(address)The native currency was settled on behalf of a payer other than the contract paying it.
InvalidNativeValue()Native currency was not sent with the correct amount.
InvalidSpread()The base spread must sit below the cap, and the cap below a tenth.
InvalidUnit()A unit must be a power of ten no larger than 1e18, because that is what a token's decimals can produce.
LiquidityOnlyViaHook()Liquidity was attempted to be added or removed via the PoolManager instead of the hook.
NoReserves()The pool holds nothing yet, so there is no balance to price against.
PoolNotInitialized()Pool was not initialized.
RebateTooLarge()A rebate larger than the surcharge that funds it would make the round trip profitable.
SafeERC20FailedOperation(address)An operation with an ERC-20 token failed.
TooMuchSlippage()Principal delta of liquidity modification resulted in too much slippage.
ValueTooLarge()A quantity too large to be represented as a signed integer, which no real reserve reaches.

The callbacks it claims

Uniswap v4 reads a hook's permissions from the low fourteen bits of its own address, which is why deploying one means mining a CREATE2 salt. This hook claims 5, so every deployment of it has an address ending in 0x2a88.

It says what it is, on-chain

Nothing about a hook's address tells an indexer, a wallet, a router or an agent what the pool does, which is why hook discovery today is a curated list. This hook answers for itself, in one eth_call, with no registry in the loop.

cast call $HOOK "hookName()(string)"    # ReserveStable
cast call $HOOK "specURI()(string)"     # https://reserve-stable.pages.dev/hook.json
cast call $HOOK "hookTags()(string[])"  # curve, stablecoin, custom-curve, rebalancing, no-admin

Build, test and deploy

git clone --recurse-submodules https://github.com/nirholas/reserve-stable
cd reserve-stable
forge build && forge test

# Dry run: mines the salt, prints the address, sends nothing.
forge script script/Deploy.s.sol --rpc-url $RPC_URL

# For real.
forge script script/Deploy.s.sol --rpc-url $RPC_URL --broadcast --verify

Status

Unaudited. Built to an audited shape, on OpenZeppelin's audited hook bases, and tested against a real PoolManager. No third party has reviewed it. Read "where it does not help" above before putting money behind it. Not affiliated with Uniswap Labs.

Try it

This is the hook running, not a picture of it. Connect a wallet on a chain it is deployed to, or bring the whole stack up locally in one command and use it with no funds and no wallet risk at all.

Loading the demo… if this does not change, JavaScript is blocked and the demo cannot run.

Run the whole thing locally
git clone --recurse-submodules https://github.com/nirholas/reserve-stable
cd reserve-stable

anvil &
forge script script/DeployLocal.s.sol --rpc-url http://127.0.0.1:8545 --broadcast \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

node web/build.mjs && npx serve web/dist

The deploy script writes web/local.json itself and the build merges it, so the page points at the chain you just created without you editing anything. Point a wallet at http://127.0.0.1:8545 and every button on this page works.

Anvil's first account is pre-funded and its key is public by design. Never use it anywhere real.