Risk model: how Bias lists any coin without being farmed
The goal is "long or short any coin on Robinhood Chain". The treasury is the counterparty, so every market is an invitation to manipulate its price source. This document states the attack, the math that bounds it, the controls that enforce the bound on-chain, and the tests that check it.
1. What decides whether a coin can be listed#
Pool liquidity, not market cap. A price read from a pool can be moved by trading against that pool. What it costs to move depends on the pool's depth and its trading friction (swap fee, token tax). Market cap does not enter.
Two regimes. Sections 2 and 3 assume a Pons pool with a 5% tax per leg, where a depth-share cap makes pumping unprofitable. The coins users actually want to trade on Robinhood Chain have no token tax: only a 0.3% to 1% LP fee. On those, any useful cap is profitable to attack (section 3's rule would allow 0.12% of depth). Section 8 describes how untaxed coins are listed instead: small hard limits with a known worst case per coin and per day, not an unprofitable attack.
2. The attack#
- Open a long at the fair price.
- Buy on the spot pool until the price is
1 + xtimes higher. Hold it there until the 30-minute TWAP catches up. - Close the long at the manipulated price.
- Sell the tokens back.
Mirror image (strategy B): pump first, wait for the TWAP, open a short at the pumped price, release the pump, wait, close the short.
3. The bound#
Constant-product pool with virtual TVL D (half ETH, half token), per-leg tax or fee t:
- Pushing the price up by
xneeds(D/2)(√(1+x) − 1)of buying. Selling back returns the same amount before tax. Round-trip loss ≈2t · (D/2)(√(1+x) − 1) = t·D·(√(1+x) − 1). - Perp profit on a long of size
c·D(capcas a share of depth) isc·D·min(x, reserve%). Memecoin reserve is 100% of notional, so profit is at mostc·D.
The attack loses money for every x when t·(√(1+x) − 1) > c·min(x, 1). The tightest point is x = 1 (a doubling),
where √2 − 1 = 0.414:
c < 0.414 · t → rule used here: oiDepthBps ≤ 0.4 × per-leg friction (bps)
| Pool | Per-leg friction | Max cap / side |
|---|---|---|
| Pons graduated pool (5% creator tax) | 500 bps | 2.0% of depth (launch: 1.5%) |
| 1% fee pool, no tax | 100 bps | 0.4% |
| 0.3% fee pool, no tax | 30 bps | 0.12% |
This is deliberately adversarial: it assumes no arbitrageurs (holding a pumped price is free) and honest keepers that mirror the manipulated spot. Real arbitrage only makes the attack more expensive. Open/close fees, spreads, funding and the impact fee also work against the attacker and are included in the test but not in the formula.
Measured (forge test --match-test test_ponsConfig_reportMargins -vv, $1M pool, 5% tax, 2% cap, 31-minute hold):
| Pump | A: long profit | B: short profit | Pump cost |
|---|---|---|---|
| +5% | $752 | $745 | $1,267 |
| +20% | $3,722 | $3,431 | $4,897 |
| +50% | $9,663 | $7,960 | $11,532 |
| +100% | $19,563 | $13,954 | $21,255 |
| +200% | $19,962 | $22,922 | $37,565 |
| +400% | $19,962 | $35,614 | $63,429 |
The thinnest margin is ~8%, at +100% on strategy A. That is why launch uses 1.5% instead of 2%. The fuzz tests
testFuzz_pumpLongIsUnprofitable and testFuzz_pumpThenShortIsUnprofitable check the rule for taxes from 1% to 10%,
pumps from +1% to +400% and holds of 1 to 40 minutes. test_untaxedPool_sameCapIsExploitable shows the same 2% cap on
an untaxed pool is free money, which is why the cap must follow the friction.
What this means for the listing floor#
Per-side cap at 1.5% of depth:
| Pool depth | Cap per side | Verdict |
|---|---|---|
| $100k | $1.5k | Technically safe, commercially pointless |
| $250k | $3.75k | Micro tier: listable at 3x |
| $1M | $15k | Real market |
| $5M+ | $25k (absolute cap binds) | Full memecoin market; raise the absolute cap with budget |
For untaxed tokens in fee-only pools the same depth supports roughly 12x less (0.12% for a 0.3% pool). Those need far deeper pools, or a different price source.
4. On-chain controls#
| Control | Where | What it stops |
|---|---|---|
| Fixed per-side OI cap + fixed net-skew cap on every market | PerpVault._checkCaps (oiCapAbs, maxSkewUsd), script/Configure.s.sol | Bounds loss per market regardless of any depth reading. Effective cap = min(fixed, depth-derived) (test/LaunchCaps.t.sol). |
Liquidity-scaled OI cap (oiDepthBps) | PerpVault._checkCaps + UniV4TwapSource.depthUsd | The pump attack (section 3). Cap shrinks the moment liquidity is pulled. Lower-only on top of the fixed cap. |
| Depth uses the minimum liquidity over 30 min | UniV4TwapSource.minLiquidityOver | Flash-adding liquidity to raise the cap (test_flashLiquidity_doesNotRaiseCap). |
| Depth fails closed | PerpVault._depth → NoDepth | Increases while records are stale or history is missing. |
| Net skew cap (absolute and % of depth) | PerpVault._checkCaps | The treasury's real directional exposure. Rebalancing trades are never blocked. |
| Skew impact fee | PerpVault._impactUsd | Entering big one-sided size more cheaply on the perp than on the pool. |
| Spot guard | OracleRouter._raw | "Pump then open": increases revert while spot is >5% from the TWAP (test_pumpThenOpenImmediately_refused). Spot also widens the decrease price, so "pump, release, close on the lagging TWAP" gets the collapsed spot. |
| Divergence breaker | OracleRouter | TWAP vs keeper median > 3% → increases and liquidations pause. |
| 3 signers, 2 required, one slot each, no replay | OracleRouter.postPrice | One leaked signer moving the price; replaying an older signed price. |
| Payout circuit breaker | PerpVault._recordPayout | An attack that slips past everything else: net trader PnL above the cap within a decaying hour pauses increases on that market for one hour (breakerUntil). A win realized while the same account holds the opposite side is netted against that side's loss, so a hedged pair cannot trip it. The group budget (section 8.4) is the daily, cross-market version. |
| Self-hedge netting | PerpVault._skew | Opening a hedge, then the real side, then closing the hedge to launder skew past the cap or around the impact fee. An account's own opposite position is ignored when measuring skew. |
| Withdrawal margin | PerpVault._decrease | Pulling collateral out of a losing position: withdrawals must keep size ≤ (collateral − unrealized loss) × maxLeverage; unrealized profit is not margin. |
| Exits survive a missing source | OracleRouter._raw | Guardian removing every signer, or a TWAP with too little history: decreases price from the remaining source as stale. enabled = false stops opens only. |
| Two-signer agreement | OracleRouter._medianKeeper | One leaked signer while another is offline: two fresh prices further apart than the spread count as stale. |
| Record spacing | UniV4TwapSource._write | record() is permissionless (8.6a), so anyone can choose observation times. Records closer than 15s are ignored, which is the floor on how briefly a manipulated tick can be the newest one: with a 180s window that caps one observation at 15/180 of the mark when someone records promptly after the pool reverts, and at the recorder's cadence (60-75s) when nobody does. See 8.6b. |
| Guardian (restrict-only) | vault / router / oracle / TWAP source | Instant close-only, instant removal of a leaked keeper or signer. Cannot re-open, add keepers or move funds. |
| 48h timelock + Safe | script/Handoff.s.sol | A stolen owner key swapping the oracle or router. Every change is public two days ahead. |
| Two-step execution | PositionRouter | Same-block front-running of oracle updates. emergencyDecrease enforces the acceptable price, works only for 1h after expiry, and only for positions that predate the request, so a pre-armed ticket is not an instant exit. |
| Exit guarantees | router + vault | Failed or expired opens refund in the same tx; an out-of-gas failure reverts instead of refunding; only the user can cancel a close for a live position; self-execute after 180s; closes, top-ups and liquidations ignore close-only and depth. Delays are bounded in code. |
| Conservative price | OracleRouter.getPrice | Opens, keeper closes, emergencyDecrease and settlement take the worse side of TWAP / keeper median / spot, plus spread (stale spread when an input is stale). Liquidations use the neutral mid of TWAP and median without spot (section 8.6). |
| Liquidity tiers | RiskManager.increaseLimits | Per-wallet cap, per-side OI cap and max leverage from the conservative depth, on every increase; min(tier, fixed caps). Below the Lite floor: close-only. Section 8. |
| Tier ceiling | RiskManager.poke | A pump that inflates depth cannot unlock a bigger tier: the ceiling rises only after 24h of continuous observations, falls at once. |
| Per-wallet cap | PerpVault._checkCaps (WalletCap) | One account's long + short size in a market after an increase. Majors: $5k. |
| Group daily budget | RiskManager.recordPnl / groupUsed | Treasury payouts to all coin markets over the current hour + previous 24; at the budget every coin market refuses increases. |
| Group reserve cap | PerpVault.groupReserveCap (GroupCap) | Escrow the coin group can win at any moment. Together with the budget: any 24h loss < budget + reserve cap. |
| Liquidity pull | RiskManager.increaseLimits (LiquidityPulled) | Live pool liquidity below 50% of the window minimum: increases refused immediately, before the next record. |
| Warming | RiskManager.increaseLimits (Warming) | A new source with less than 1h of history. |
| Dead-coin settlement | PerpVault.settle | A coin below the Lite floor for 3 days (or delisted 24h ago) is closed by anyone at the conservative exit price, releasing escrow. |
| Listing checks | MarketFactory.list | Quote in {ETH, WETH, USDG}, hook none or allowlisted, static fee, sane token, one market per token, listing fee, active cap. |
| Spreads ≥ feed lag | PARAMS.md | Trading against a Chainlink feed that is up to 0.5% behind. |
5. Pre-mainnet review#
An adversarial review on 2026-09-15 found nine issues, each proven with a Foundry test. All are fixed, and each proof
now lives on as a regression test in test/Review_*.t.sol:
| # | Severity | Issue | Fix |
|---|---|---|---|
| F1 | Critical | Collateral withdrawal ignored unrealized loss: a losing trader could pull collateral and cap the loss at size ÷ max leverage | Withdrawals checked against equity (unrealized profit excluded) |
| F2 | Medium | Self-hedge bypassed the net-skew cap and impact fee | Skew measured without the account's own opposite position |
| F3 | Low/Med | Hedged pair tripped the payout breaker into a 48h outage | Hedge netting; breaker is now a 1h cooldown |
| F4 | Medium | Guardian removing all signers froze every exit on TWAP markets | Decreases fall back to the TWAP as stale |
| F5 | Medium | With one honest signer offline, one leaked signer could move the price and force liquidations | Two fresh prices must agree within the spread |
| F6 | Medium | A pre-armed emergencyDecrease ticket bypassed the two-step delay | Acceptable price enforced, 1h window, position must predate the request |
| F7 | Low/Med | Moving the pool spot paused liquidations | Spot guard no longer affects the mark |
| F8 | Medium | A leaked recorder key could wipe TWAP history and freeze exits | 15s minimum record spacing. Superseded 2026-09-20: record() is permissionless, so a recorder key is a gas-theft risk only (8.6a) |
| F9 | Low | Keepers could execute increases long after expiry | Expired increases refund |
6. Invariants checked by fuzzing#
test/Invariants.t.sol and test/RiskInvariants.t.sol (stateful, fail_on_revert = true):
- vault USDG balance == total collateral + total reserves, exactly, always (
backing().fullyBacked) - USDG is conserved across treasury, vault, traders and liquidators
- totals equal the sum over positions; every reserve ≤ its notional
- after every successful increase: leverage ≤ max, side OI ≤ absolute and depth caps, a skew that grew is ≤ both skew caps, impact fee ≤ its cap, the market was not close-only, depth was healthy
- decreases never revert for any risk reason (only
Leverageon a partial close orEmpty) isLiquidatable()always agrees withliquidatePosition()
test/CoinInvariants.t.sol (three COIN markets with moving depth, one MAJOR, a coin group with budget and reserve cap):
- escrow exact; USDG conserved;
totalOiandgroupReservedequal the sums over positions; group reserve <= cap - after every admitted increase: side OI <= tier cap, account long + short <= wallet cap, leverage <= tier max, and the
group meter was below the budget;
DailyBudgetis only ever raised at or above the budget - the meter never exceeds used + reserved at the last admitted increase, which is below budget + reserve cap
- the treasury's equity (balance + escrowed reserves) never drops by budget + reserve cap or more within any 24h
- decreases, liquidations and settlement never revert for a risk reason
7. What is not covered#
- Concentrated liquidity (open, mitigated by fixed caps).
depthUsdtreats in-range liquidity as if it were full range. Pons graduation pools are full range and read correctly. On concentrated pools it overstates real depth 3-15x: the on-chain scan of 2026-09-15 read the SPY/USDG pool at $409M virtual depth, while the cost to move it ±10% implies about $51M (skew-site/scripts/scan-tokens.mjs,effDepth10Usd). A depth-scaled cap alone would then be far too large and the pump attack of §3 profitable. Launch mitigation: every market has a small fixed per-side OI cap and net-skew cap (PARAMS.md), and the vault enforcesmin(fixed, depth-derived), so an inflated depth can never lift a cap above the fixed number (test/LaunchCaps.t.sol). The most a pump attack can win per market is then the fixed cap times the reserve % ($2k on a meme market). TWAP-class listings are further limited to native-ETH-quoted pools with no hook or the Pons MemeHook (script/Configure.s.sol,script/DeployTwapSource.s.sol). Before raising caps: make depth conservative on-chain (a bounded tick walk mirroringeffDepth10Usd, or a timelocked per-market depth ceiling), or keep caps where the fixed number, not depth, always binds. - Unknown hooks. Pools with a hook other than none or Pons can charge fees or taxes the §3 math does not model.
MarketFactoryrefuses any hook not in its governance allowlist (launch: none and the Pons MemeHook) and every dynamic-fee pool. A market configured by hand through the timelock bypasses that check, so the Safe must verify the pool key of every TWAP source it lists (Configure.s.solchecks it). - Price-source dominance. The v4 pool is the only venue the contracts see. If a token trades mostly elsewhere (a v3
fork, a CEX), a thin v4 pool is a cheap price to move. Listing rule (
LAUNCH.md): list a TWAP market only when its v4 pool is the dominant venue by liquidity and 24h volume, checked on DexScreener and GeckoTerminal at listing time. - USDG-quoted pools (resolved 2026-09-16).
UniV4TwapSourcenow takes the quote's decimals and USD feed: native ETH and WETH (18 decimals, Chainlink ETH/USD) and USDG (6 decimals, exactly $1, the vault's own unit). Both currency orderings are tested (test/QuoteSource.t.sol) and checked on mainnet pools (PONS/USDG with PONS as currency0, FRONG/ETH,test/ForkListing.t.sol). Token-quoted pools (coin/coin) are still refused. - Keeper collusion. 2 of 3 signers colluding can post any price inside the 3% divergence band. Run the signers on separate hosts, operators and clouds.
- Multi-account hedges. Self-hedge netting works per account. Two colluding accounts can still open offsetting positions to move skew or trip the breaker; that costs spreads and fees on both legs, the per-side OI cap still holds, and a breaker trip is a one-hour pause of opens, never of exits.
- Sequencer. Robinhood Chain has one sequencer and no uptime feed. The router's timeouts and the stale-price path are the only protection if it halts.
- Stock gaps. Decreases after the close price at the last feed value plus the stale spread. A trader holding through a gap still wins or loses the real gap. The reserve caps what they can win.
- The treasury is short what the users like. Memecoin users are mostly long. Skew caps and 5x memecoin funding limit this but do not remove it. An optional off-chain hedge (the treasury holds spot equal to part of the net long skew) must buy slowly, because that same pool is the price source.
8. Untaxed coins: tiers, wallet caps, daily budget, dying coins#
8.1 Why caps cannot make the attack unprofitable here#
On a pool with LP fee f and no tax, section 3's rule allows a per-side cap of 0.4 f of depth: 0.12% on a 0.3% pool,
$17 per side on a $14k pool. Nobody can trade that. So for untaxed coins the goal changes: the attack is allowed to be
profitable, but the most it can take is small, known in advance, and paid for by a budget the treasury chose.
Measured on simulated full-range pools with no arbitrageurs, keepers mirroring the manipulated pool, Sybil wallets at the
wallet cap and 1x collateral (forge test --match-test test_report_perCoinExtraction -vv):
| Pool depth | Fee | Side cap | A: long, pump +200%, hold 70 min, close | Pump cost | B: short, dump -67%, hold, close | Dump cost |
|---|---|---|---|---|---|---|
| $14k (Lite) | 0.3% | $750 | $725 | $24 | $476 | $24 |
| $14k (Lite) | 1% | $750 | $725 | $82 | $476 | $84 |
| $35k (Small) | 0.3% | $1,500 | $1,452 | $61 | $953 | $62 |
| $35k (Small) | 1% | $1,500 | $1,452 | $205 | $953 | $211 |
| $175k (Mid) | 0.3% | $3,000 | $2,922 | $306 | $1,923 | $311 |
| $175k (Mid) | 1% | $3,000 | $2,922 | $1,028 | $1,923 | $1,057 |
Perp profit is net of open / close / impact fees and spreads; the difference to the cap is those costs. The fuzz tests
testFuzz_pumpThenCloseLong_boundedByTierCap, testFuzz_dumpThenCloseShort_boundedByTierCap and
testFuzz_pumpOpenShortRelease_boundedByTierCap (test/AttackUntaxed.t.sol) vary depth ($14k, $35k, $175k), fee (0.3%,
1%), quote (ETH, USDG), pump (+5% to +900%, or x100 for pump-then-short) and hold (1 to 120 minutes) and assert profit per
side <= the tier's side cap every time.
8.2 Liquidity tiers#
RiskManager evaluates every increase on a COIN market from the source's conservative depth: 2 x the quote-side
virtual reserve at the minimum liquidity recorded over the depth window (30 min), priced at the TWAP. Market cap is about
liquidity / 0.35 for these coins; liquidity is what is measured.
| Tier | Depth | Per-wallet max position | Per-coin OI cap per side | Max leverage |
|---|---|---|---|---|
| below floor | < $14k | close-only | close-only | - |
| Lite | $14k - $35k | $300 | $750 | 3x (2x configurable) |
| Small | $35k - $175k | $500 | $1,500 | 3x |
| Mid | $175k - $700k | $1,000 | $3,000 | 3x |
| Large | $700k+ | $2,000 | $5,000 | 5x |
Rules:
- The tier only affects increases. Closes, partial closes, collateral top-ups and liquidations never read it; a position
opened in a higher tier stays as it is when the coin drops a tier (
test_existingPositionUntouchedWhenTierDrops). - The vault enforces min(tier cap, fixed per-market caps, depth-share caps) and min(tier leverage, market leverage).
- Per-wallet cap is combined long + short per account per market. Both legs draw a reserve, both can be profitable in a pump-and-dump path (short at the top, long at the bottom), and a per-side wallet cap would let one wallet carry twice the cap. Sybils can still split size across wallets, which is why the per-side OI cap, not the wallet cap, is the hard per-coin bound; the wallet cap spreads access and keeps one account from taking the whole side.
- Tier ceiling. Pumping a coin raises its quote reserve and therefore its measured depth (a x25 pump multiplies it by
5). The effective tier is min(live tier, ceiling). The ceiling drops at once when a poke observes a lower tier and rises
only after
upgradeDelay(24h) of observations no more than 6h apart that all show the higher tier (test_tierCeiling_pumpCannotRaiseTier,test_pumpedDepthDoesNotRaiseCaps). Auto-listed coins start at Lite; governance-listed coins get a ceiling from the Safe. - Liquidity pull. If the pool's live in-range liquidity is below 50% of the window minimum, increases are refused
immediately (
LiquidityPulled), before any record catches the pull. - Warming. A source with less than 180s of history refuses increases (
Warming); the 180s TWAP needs it anyway (8.6b).
8.3 Max profit and reserve#
Coin markets set maxProfitBpsOf = 10_000: reserve = min(notional x 100%, net collateral x 100%). A trader can never win
more than the collateral they posted, and at 3x a position's reserve is a third of its notional. Majors keep the global
900%.
8.4 Daily loss budget and the worst case per day#
All coin markets share group 2 (vault.groupOf). Everything the treasury pays out to the group (realized profit, funding
received, the flat liquidation bounty on an underwater position, the settlement bounty taken from the treasury's share) is
metered in hourly buckets; trader losses consume the most recent buckets of the last 25 hours and are otherwise dropped,
so no credit is banked. While groupUsed (current hour + previous 24) >= dailyBudgetUsdg, every market in the group
refuses increases (DailyBudget). Exits are never affected.
Bound. For any 24h window, the treasury's net loss to the group is below dailyBudgetUsdg + groupReserveCap.
Why: buckets are never negative and a loss only reduces buckets at or before its own hour, so the net loss over the window
is at most the sum of the window's buckets, which is at most groupUsed at the window's end. groupUsed only grows through
payouts. At the last admitted increase it was below the budget, and every payout after that comes out of a reserve that
already existed then, whose total is at most the group reserve cap (enforced on every increase).
| Launch value (2026-09-23) | Where | |
|---|---|---|
| Coin daily budget | $1,000 | COIN_DAILY_BUDGET_USD |
| Coin group reserve cap | $3,000 | COIN_RESERVE_CAP_USD |
| Worst case, any 24h, all coin markets | < $4,000 | budget + reserve cap |
| Worst case, 3 consecutive days | < $31,500 | 3 windows |
Worst case per coin at one time (both sides fully won, 1x collateral, 100% max profit):
| Tier | Per wallet | Per coin, one side | Per coin, both sides |
|---|---|---|---|
| Lite | $300 | $750 | $1,500 |
| Small | $500 | $1,500 | $3,000 |
| Mid | $1,000 | $3,000 | $6,000 |
| Large | $2,000 | $5,000 | $10,000 |
The group reserve cap binds before many coins can be at their caps at once ($3,000 of escrow is four Lite sides at 1x, or twelve at 3x).
Measured (test_multiCoin_threeDays_dailyLossBounded): eight Lite coins (0.3% and 1%, ETH and USDG), the attacker fills
every cap with Sybils each round, pumps every coin +200%, holds 70 minutes, closes and unwinds, for three days. Worst 24h
treasury loss, against the bound of budget + reserve cap, with 20 of 23 rounds refused entirely by the budget. Since
2026-09-23 the harness runs the launch values ($1,000 budget, $3,000 reserve cap), so the asserted bound is $4,000
per 24h; the same test passed at the older $3,000/$7,500 with a measured worst 24h loss of $5,991 against $10,500. The
stateful campaign in test/CoinInvariants.t.sol checks the 24h bound after every call.
Majors are group 1 with their own budget (MAJOR_DAILY_BUDGET_USD, default 10% of the treasury budget) and no reserve
cap: their bound is that budget plus the majors' reserves, which the aggregate OI cap limits (at the launch $20k
aggregate OI and 50% reserve, $10k).
Griefing: a hedged pair (long and short in different wallets) closed hours apart nets out inside the 25h lookback
(test_hedgedPairAcrossHours_netsOut). Closing a genuinely winning position still counts, so someone who really wins the
budget from the treasury does pause coin opens for up to 25 hours; that is the budget doing its job.
8.5 Pricing on coin markets#
- TWAP window 1h (
twapWindow = 3600); the source finds the window start by binary search, so the cost does not grow with the window. - Every path, verified in
test/PricePaths.t.solwith TWAP, keeper median and spot pushed apart:
| Path | Price |
|---|---|
| Open long / close short (keeper) | max(TWAP, median, spot) x (1 + spread) |
| Open short / close long (keeper) | min(TWAP, median, spot) x (1 - spread) |
emergencyDecrease (keepers down) | same as a keeper close; stale inputs widen it with the stale spread |
settle | same as a keeper close of that side |
| Liquidation mark | (min + max of TWAP and median) / 2, no spot, no spread; reverts when stale or diverged |
The mark is deliberately neutral: using the worse side would let a spot wick liquidate healthy positions, and review finding F7 showed that letting spot into the mark lets anyone who moves the pool pause liquidations.
8.6 Oracle for auto-listed coins#
With 3 signers and minSigners = 2, signers cannot be assumed to cover every coin the moment it is listed (DexScreener
and GeckoTerminal index new pools with a delay, and both derive their price from the same on-chain pool). Decision:
- Auto-listed markets are keeper-optional. When signers post a fresh median it is used exactly as on any TWAP market
(divergence breaker included). Without one, the market prices from the 180s TWAP plus live spot (spot guard 5%) and
RiskManagercaps it at Lite (maxTierWithoutSigners = 0). - Justification: for a coin whose only venue is the pool, the signers' sources read that same pool, so they add little
against pool manipulation. What they do add is a second opinion on when the TWAP is being walked, which matters more
now that
record()is permissionless (see 8.6a). The Lite caps plus the group budget bound whatever anyone can do. Governance can turn keeper-optional off per market (oracle.setKeeperOptional) and require signers.
8.6a Permissionless record() (2026-09-20)#
UniV4TwapSource.record() used to be keeper-only, and this document claimed the recorder allowlist as a defence: a
"leaked recorder key" was a risk to manage, factory.removeRecorder was an emergency action, and the manipulation model
said an attacker had to hold a moved price until a keeper happened to record. None of that is true any more.
record() is now callable by anyone, because a market nobody records is a market nobody can open, and someone who lists
their own coin has to be able to warm it up without us. The honest reading of the new model:
- An attacker chooses observation times. Pump the pool,
record(), unwind, all in one transaction. The record tx no longer has to be front-run or timed; it is the attacker's own call. The old "private sequencer, cannot be sandwiched" argument is gone. - What still bounds it.
MIN_RECORD_SPACING(15s) is the floor on how briefly a manipulated tick can be the newest observation, and every manipulated round trip pays the pool fee twice (600 bps per leg on a Pons MemeHook pool, 25-100 bps on the hookless ones in the launch set). The observation is read from the pool, never from the caller. Depth is the minimum recorded liquidity, so manipulated observations cannot raise caps. How much of the mark one observation is worth is set by the window, which is now 180s: see 8.6b for the arithmetic. - What actually stops the trade.
OracleRouter's spot guard (spotGuardBps500 for coins): once the walked TWAP is more than 5% from live spot, the market stops pricing altogether rather than pricing wrongly. So a TWAP walk cannot be ridden past 5% without closing the market the attacker wanted to trade on, and any displacement it does buy is capped by the Lite per-wallet ($300) and per-side ($750) caps, the $1,000/hour per-market payout breaker and the $1,000/day coin group budget.test/Attack.t.solandtest/AttackUntaxed.t.solbound the payoff. Note the guard only reverts increases: a close is always priced, so the guard limits entry, not the walk itself (8.6b). - Consequences for operations. The recorder allowlist (
source.setKeeper,factory.setRecorder,isKeeper) is kept only as the published list of the recorders we run, for the keeper and the monitor. Removing a recorder no longer stops anything: treat a leaked recorder key as a gas-theft problem (rotate the key, it can burn its own ETH), not as an oracle problem.factory.removeRecorderis no longer an oracle emergency action. - Not changed:
RiskManager.pokewas already permissionless, andMarketFactory.listnow writes the first observation itself, so the warm-up clock starts at listing rather than whenever a recorder first turns up. - Governance-listed coins (
Configure.s.sol) are not keeper-optional: they need 2 of 3 signers, as before.
8.6b The TWAP window is the price of the wait (2026-09-23: 900s -> 180s)#
The window was shortened from 900s to 180s so a new coin is tradeable about three minutes after list() instead of
fifteen. The wait and the window are the same number - twap(window) returns nothing until the source's history
reaches back window - so this is a direct trade of oracle quality for time to market, not a free win. The full
arithmetic and the table of alternatives is in PARAMS.md ("Coin warm-up"); the risk summary:
- One observation is worth
dwell / windowof the mark, wheredwellis how long it stays the newest observation. To move the mark byEan attacker holds a spot dislocationSforE/S x windowseconds. At a fixed hold time, the extractable mark error scales as 1/window: 180s is exactly 5x easier than 900s, 60s would be 15x. - A round trip at the Lite ceiling ($750 of notional) costs $31.20 before any pool cost - 50 bps of spread each way,
8 bps of fees each way, and the skew impact fee, which at the $14k Lite depth floor hits its 300 bps cap at $22.50.
So the attacker needs
E > 4.16%to break even at all. - Worst case, a coin's own lister moving their own thin pool (no market impact to pay, only the pool fee): at 180s they need a 50% dislocation held through a full 15s record spacing, or 12.5% held for a minute, to reach break even. At 900s it was 250% / 62%. At 60s it would be 16.6% / 4.2% - and 4% over a minute is inside ordinary volatility on a new coin, which is why 60s was rejected.
- Deep pools are unaffected at any of these windows: on $846k of depth a 5% dislocation costs ~$57 in pool fees against at most ~$10 of mark error. The exposure is entirely in coins near the $14k Lite floor, where the impact fee cap stops scaling.
- The ceiling does not move. Whatever the window, extraction is bounded by $250 of profit per position
(
maxProfitBps10,000 at 3x), $1,000/hour per market (payoutCap) and $1,000/day across the whole coin group (COIN_DAILY_BUDGET_USD). At 180s an attacker needs roughly 40 marginally-profitable round trips, each requiring a held 50% pool dislocation, to reach that. The window decides whether attacking is worth attempting; the group budget decides how bad a day can get if it is. - Two things keep a short window honest, and both are asserted in tests:
maxObservationAge(90s) must stay below the window, or one tick extended flat becomes the entire mark (test/Factory.t.sol:test_maxObservationAge_staysUnderTheWindow); and the recorder's cadence must stay well under it (60-75s hot, 30-45s for the first 900s after a listing), or the same thing happens in practice. That cadence is ~3.5x the old recorder gas for markets that carry positions - seekeeper/README.md. - The spot guard does not cover this.
getPricereverts on divergence only for increases; a close is always priced. What the guard does is widen the range against the trader (lo = min(twap, spot),hi = max(twap, spot)), so a pumped spot never improves the price a long closes at.
8.7 Dying coins#
- Liquidity pull: increases refused immediately (8.2).
- Below the floor: increases refused while conservative depth < $14k.
- Dead: a COIN market observed below the floor continuously for 3 days is settleable.
riskManager.poke(market)is permissionless; keepers call it hourly. Continuity means every observation in the 3 days showed the market below the floor and no two observations were more than 6h apart; a gap or a single observation above the floor restarts the clock. Then anyone callsvault.settle(market, accounts, sides, receiver)in batches (about 100k gas per position). Each position closes at the conservative exit price, the trader receives their remaining collateral, the reserve returns to the treasury, and the caller receives 1 USDG per position out of that position's released escrow (trader payout first, then the treasury's share). - Delisted: guardian or owner delists through the factory (or the risk manager for governance coins). Increases stop at once; positions become settleable 24h later so users can close themselves first.
Edge cases (tested in test/Settlement.t.sol):
| Situation | Result |
|---|---|
| Signers never covered the coin, or stopped | Keeper-optional: the median is ignored and closes / settlement price from TWAP and spot. Signer-required: closes use the stale median at the stale spread. |
| Recorder stopped (stale TWAP) | Depth is unknown, so pokes change nothing and the dead-coin clock does not run: a keeper outage must not force-close every market. Users can still close at the stale TWAP with the stale spread. Governance or the guardian delists; settlement after the grace period uses the last TWAP at the stale spread. |
| TWAP history shorter than the window (new or wiped source) | The oracle prices exits from the median as stale; with neither, NoPrice and settlement waits for records. |
| Liquidity back for one observation | The clock restarts. |
| Keeper outage longer than 6h | The clock restarts when observations resume. |
| Position fully lost | Settlement still pays the bounty, from the treasury's share of its escrow. |
| Account that cannot receive USDG (frozen by the issuer) | That position reverts the batch; the settler leaves it out. |
8.8 Permissionless listing#
MarketFactory.list(poolKey): the checks are in the contract header. The market id is the pool id; the source is deployed
with CREATE2 (salt = pool id, predictSource). Spam stays cheap to ignore: a listing fee in USDG goes to the treasury
(launch 100 USDG), at most maxActive auto markets can be active (launch 20), a new market cannot open anything until it
has 1h of records and $14k of depth, and keepers only need to record and price markets that are warm and above the floor.
One market per token: a second pool of the same coin is refused, so caps cannot be doubled through another fee tier.
The on-chain checks are deliberately minimal (the goal is many listable coins): quote in {ETH, WETH, USDG}, hook none or
allowlisted (launch: the Pons MemeHook, declared at 600 bps per leg; hookFee also reads the hook's own hookFeeBps()),
static fee, a token contract with sane decimals() / totalSupply(), not denylisted, pool initialized with liquidity, plus
warming (1h of history) and the Lite depth floor before any open. Tokenized stocks, leveraged / pre-IPO wrappers,
stablecoins and wrapped assets are on a governance denylist (29 seeded by Configure.s.sol; the guardian can add more).
Everything else is off-chain policy in the keepers and listing UI (24h volume >= min(50% of liquidity, $50k), pool age
= 12h, honeypot sell test): a market that fails it is not recorded or priced, so it never leaves warming or goes stale and cannot open, and the guardian delists it. The caps and the budget, not the listing checks, are what bound losses.
8.9 Open risks#
- Profitable by design. On untaxed coins the attack in 8.1 makes money. The limits bound it; they do not stop it. Raise caps or the budget only with that in mind.
- Pool squatting. The first lister chooses which pool of a coin is listed, possibly a thin one. Caps follow that pool's
depth, so the bound holds; governance can delist and
setTokenMarket(token, 0)to allow the deeper pool. - Venue dominance (section 7) is not checked on-chain for auto listings. A thin v4 pool of a coin that trades mostly elsewhere is cheap to move; the tier of that thin pool and the budget are the bound.
- Recorder cost. Every active coin needs records at <= 5 minute spacing (
maxObservationAge300s) to stay fresh: about 72k gas per record, so 20 markets at 5 minutes is about 415M gas per day. KeepmaxActivein line with the keeper budget, and let the recorder skip markets that are below the floor with no open interest. - ETH/USD feed. ETH-quoted coins go
NoDepth(opens refused) when Chainlink ETH/USD is older than 26h. - Budget pause as a side effect. Real wins up to the budget pause every coin market for up to 25h.
- LP griefing of tiers. A large LP can pull liquidity across a record and a poke to drop a coin's tier ceiling, which then needs 24h to climb back, or keep it pulled for 3 days to make the market settleable. Both only ever reduce risk; positions settle at the normal exit price.
- Keeper-optional Lite markets rely on TWAP + spot only; see 8.6.
- Aggregate OI cap (
maxTotalOi, $60k at launch) is shared with majors; many coin markets can fill it. - Unaudited. RiskManager and MarketFactory are new code.