A Uniswap v4 hook that charges 2 bps up front and sends the rest of the bill afterwards, to whoever actually caused the loss.
UHI10 Hookathon · Sustainable Liquidity and MEV Protection
Every MEV hook shipped so far protects LPs by charging everyone more when the weather looks bad — volatility fees, priority-fee taxes, reserve surcharges. None of them can tell the arbitrageur from the retail swapper at trade time, so they overcharge retail to underprice arbitrage.
Postmark inverts the sequence. It quotes a near-zero fee up front, writes a receipt, and settles the adverse selection afterwards — billing the specific counterparty who caused it, from a bond they posted, with the loss measured from the pool's own realized price path.
No signed oracle. No offchain sequencer. No priority-ordering assumption. The markout is computed from what the chain has already witnessed, and you cannot un-happen a price.
EvenFlow taxes the weather. Postmark bills the driver.
| Approach | Prices on | Why it falls short |
|---|---|---|
| Aegis DFM, Arrakis Pro, AdaptiveSwap | Realized volatility | Volatility is a proxy for the probability of toxic flow, not its identity. Charges retail for the arbitrageur's crime. |
| Angstrom L2 | Priority fee on the tx | Only works where the sequencer orders by tip. Blind on L1, on FCFS chains, and to out-of-band builder payments. |
| Angstrom L1 | Batch auction | Requires an entire offchain consensus network. That's a protocol, not a hook. |
| EvenFlow | Pool-wide surcharge | Surcharges the flow, not the counterparty. No attribution, no enforcement, no rebate for proven benign flow. |
| Brevis volume discount | Cumulative volume | Rewards the largest traders, who are disproportionately the informed ones. Wrong sign. |
| Postmark | Realized markout, per payer, settled ex post | — |
sequenceDiagram
participant P as Payer
participant V as FlowVault
participant H as PostmarkHook
participant PM as PoolManager
participant K as Anyone
P->>V: deposit(bond)
Note over P,V: opt-in, permissionless
P->>PM: swap()
PM->>H: beforeSwap
H->>V: free bond >= required?
H-->>PM: fee = tierFee(tier) — 2 / 8 / 15 / 30 bps
PM->>H: afterSwap
H->>V: lock(requiredBond)
H->>H: write receipt + price observation
Note over H: ...W blocks pass...
K->>H: settle(receiptIds)
H->>H: markout vs worst price printed in W
alt markout positive — LPs adversely selected
H->>V: debit bond → LPs, keeper, rebate pool
else markout zero or negative — benign
H->>V: credit rebate to payer
end
H->>H: ScoreRegistry.update(payer, markout)
H->>V: unlock bond
Bonding is opt-in and only ever lowers your cost. An unbonded address still swaps; it just lands in the top tier, which is the vanilla 30 bps baseline. That is the sybil answer, and it is structural rather than defensive.
beforeSwap never reverts. A bond too small for the swap's notional silently loses the discount rather than failing the trade. Reverting destroys router integration.
Settlement is self-enforcing. A payer's bond stays locked until their receipts settle, and the bond (2% of notional) is strictly larger than the maximum charge (1% of notional), so forfeiting is always worse than paying. Benign payers settle to unlock capital and claim rebates; the keeper cut covers everyone else. There is no settlement liveness problem, and the failure mode if nobody ever settles is a locked bond — it fails closed.
| Contract | Role | Status |
|---|---|---|
PostmarkHook.sol |
v4 hook: afterInitialize, beforeSwap, afterSwap, permissionless settle, one-way emergency brake |
live |
FlowVault.sol |
Bond escrow. Balance, lock and cooldown share one storage slot per (payer, currency) | live |
ScoreRegistry.sol |
Per-payer EWMA markout score, shared across all Postmark pools | live |
ReceiptBook.sol |
Append-only receipts per pool, notional stored exactly in uint96 |
live |
PriceAccumulator.sol |
Tick observations in a 128-slot ring; supplies the settlement reference price | live |
Hook permissions are AFTER_INITIALIZE | BEFORE_SWAP | AFTER_SWAP. The pool must be created with LPFeeLibrary.DYNAMIC_FEE_FLAG, and the hook address is mined with HookMiner.
| Parameter | Value | Note |
|---|---|---|
tierFee[0] |
2 bps | proven benign |
tierFee[1] |
8 bps | |
tierFee[2] |
15 bps | bonded, no settled history yet |
tierFee[3] |
30 bps | unbonded or unknown — matches the vanilla baseline |
BOND_RATIO_BPS |
200 (2% of notional) | locked per open receipt |
MAX_CHARGE_BPS |
100 (1% of notional) | hard cap per receipt |
MIN_HISTORY |
5 settled receipts | required before promotion past the entry tier |
LAMBDA_BPS |
9000 (λ = 0.9) | EWMA retention |
| tier score bounds | 1 / 5 / 20 bps | EWMA realized markout, in bps of notional |
W |
100 blocks | settlement window, ~20 min on mainnet. Set from measured data, see below |
| reference price | window extremum | the most adverse tick that printed, not the mean — see A1 |
alpha |
0.6 | LVR recapture rate, must stay below 1 |
keeperBps |
5% of charge | |
rebateShareBps |
15% of charge | the rest, 80%, goes to LPs |
rebateCapRatio |
0.5 | a rebate can never exceed half of fees paid |
BOND_RATIO_BPS > MAX_CHARGE_BPS is the invariant the whole mechanism rests on.
| Attack | Defence | Test | |
|---|---|---|---|
| A1 | TWAP manipulation — trade back inside your own settlement window so the receipt reads benign | Settlement prices against the most adverse tick that printed in the window, not its average. A price that has printed cannot be un-printed. Tested across three worlds — honest, manipulate, and a control making the identical trades with the reversing trade moved after the window — so the manipulation is isolated from that trade's own P&L. All three now settle to the same charge. | ✅ |
| A2 | Sybil — a fresh address per swap | Reputation only ever lowers cost, and a fresh address cannot buy below the entry tier at any bond size. Over identical flow, rotating addresses paid 1.8e13 in fees against 8.9e12 — 2.02x — for one address that settles its receipts. | ✅ |
| A3 | Wash trading for rebates | The washer puts the entire proceeds straight back, so the token1 leg closes out exactly. Round trip cost 3.0e12 of token0 and earned zero rebates; a rebate is capped at half the fee that generated it. | ✅ |
| A4 | Receipt spam — dust swaps to bloat state | 20 dust swaps wrote zero receipts and locked zero bond, having burned 3.9M gas. | ✅ |
| A5 | Hook rug risk | FlowVault's hook is set once, forever. A one-way guardian brake stops new receipts and bond locks while never blocking a swap, a settlement, an LP withdrawal, or a bond withdrawal. LP removal is exercised with a receipt open. |
✅ |
All five live in test/Adversarial.t.sol.
3,465 real USDC/WETH swaps from Ethereum mainnet, 92 distinct payers over 6.9 days, replayed through both pools (scripts/backtest.py).
Chart 1 — the mechanism predicting toxicity, then pricing it. Each swap is grouped by the tier Postmark assigned it from the payer's prior behaviour, and the bar shows what that flow then turned out to be worth:
| tier | quoted | realized markout | effective fee paid | share of volume |
|---|---|---|---|---|
| T0 | 2 bps | 0.98 bps | 2.59 bps | 0.15% |
| T1 | 8 bps | 6.41 bps | 11.85 bps | 1.31% |
| T2 | 15 bps | 14.90 bps | 23.94 bps | 27.75% |
| T3 | 30 bps | 39.14 bps | 53.48 bps | 70.79% |
Monotone, with 40x separation between the cheapest and most expensive tier. The mechanism sorts flow by toxicity using only the past, and the realized markout confirms the sort was right.
Chart 2 — LP PnL. Postmark 71,286 USDC against vanilla's 34,594 — 2.06x, on identical flow.
Postmark is not a fee reduction. Averaged over all volume it charges 30.60 bps against the flat pool's 30 — essentially the same. You cannot pay LPs more and charge traders less; the money has to come from somewhere.
What changes is who pays. Benign flow pays 2.59 bps where a flat pool charges it 30, a 12x discount. The toxic tail pays 53.48. LPs end up twice as well off because the fees now land on the flow that actually causes their losses, instead of being spread evenly across everyone.
That is the claim, and it is a price-discrimination claim rather than a cheapness claim. Anyone who reads the mean fee and expects it to be below 30 will find 30.60; better to say this first.
Note the volume distribution: on this pool 71% of volume sits in the top tier. The traders getting the large discount are numerous but small. That is what adverse selection looks like in real flow, and it is why a flat fee is the wrong instrument — but it does mean the discount reaches a minority of volume, even while reaching a majority of swaps.
export ETH_RPC_URL="https://eth-mainnet.g.alchemy.com/v2/<key>"
LOOKBACK_BLOCKS=50000 python3 scripts/backtest.py # ~22 min on a free tier, then cached
python3 scripts/backtest.py --cached # re-runs the charts, no RPC callsAn archive endpoint is required — public RPCs serve only head-adjacent blocks. Free-tier Alchemy
caps eth_getLogs at a 10-block range; the harness detects that and adapts its batch size.
Read these before the mechanism convinces you.
donate()pays whoever is in range at settlement time, not necessarily the LPs who were harmed at swap time. v1 accepts this approximation; a per-tick snapshot accumulator is the v2 design.- Attribution is per-router in v1. The payer resolves to whoever called
poolManager.swap, which is usually a router. A router can attest to its end user by passing a 32-byte address inhookData— this works and is tested — but it is opt-in and self-reported. It is safe in the direction that matters: a router can only ever move cost onto an address it names, never off itself, because a named address with no bond lands in the top tier. - Bonds are ERC20-only and denominated in
currency1, the quote asset under v4's token ordering. Native bonds revert. - The charts are not yet regenerated. The backtest harness is rewritten and its math is unit tested, but producing Chart 1 and Chart 2 needs an archive RPC (a free Alchemy or Infura key). The charts previously in this repo were generated by a simulation whose per-receipt cap was 50x the contract's and which omitted the upfront fee entirely, so they were removed rather than shipped.
ScoreRegistryhas an owner-managed hook allowlist, because it is shared across pools and cannot be a single immutable address. It holds no funds and can only ever lower a fee, so the blast radius of a bad entry is a mispriced fee on that hook's own pools — never a loss of principal. Production wants a timelock here.
| Day | Milestone | Gate | Status |
|---|---|---|---|
| 1 | Scaffold, hook flags, address mining, dynamic-fee pool | a swap executes through the hook | ✅ |
| 2 | Bonds and tiers | bonded vs unbonded pay measurably different fees | ✅ |
| 3 | Receipts, observation ring, price accumulator | overhead under 40k, hard stop 80k | |
| 4 | Settlement math | charge ≈ known realized LVR | ✅ exact match |
| 5 | Rebates, EWMA, cross-pool registry | benign payer's fee declines over 20 swaps | ✅ |
| 6 | Bond invariant property test | locked bond ≥ chargeable amount | ✅ |
| 7 | Adversarial suite | A1–A5 all lose money for the attacker | ✅ |
| 8 | Backtest harness | Chart 1 shows the staircase | |
| 9 | Deploy + scoreboard | a judge could open the URL and swap | |
| 10 | Freeze and docs | — | ⬜ |
46 Solidity tests (45 green, 1 deliberately red) + 23 backtest-math checks.
The red one is the local-EVM gas gate, superseded by the on-chain measurement below. It is left failing rather than relaxed, so the open question stays visible.
forge coverage, source contracts only:
| lines | statements | branches | |
|---|---|---|---|
| PostmarkHook.sol | 100% | 99.4% | 80.0% |
| FlowVault.sol | 97.4% | 90.4% | 50.0% |
| ScoreRegistry.sol | 89.3% | 77.4% | 42.9% |
| Total | 90.4% | 90.8% | 61.9% |
base/BaseHook.sol sits at 26% and is excluded from that judgement — it is abstract stubs that
revert by design, not logic anyone should be exercising.
Measured on Unichain Sepolia, in the transaction performing the swap, against an identical
vanilla pool seeded with the same liquidity and warmed by the same flow
(GasProbe, scripts/swap_gas.py).
| path | gas | overhead |
|---|---|---|
| vanilla swap | 97,875 | — |
| Postmark, quote only | 134,203 | +36,328 |
| Postmark, receipt + bond lock | 196,084 | +98,209 |
Stable to ~0.1% across rounds; the first swap into a fresh pool is warm-up and is excluded.
The quote-only path meets the plan's 40k target. A swap pays the full ~98k only when it opens a receipt, which requires a posted bond — and that is the swap receiving a fee discount worth far more than the gas. Unbonded flow, which is most flow, pays under 40k.
At Unichain's 0.0005 gwei that full overhead costs $0.00015 per swap. The same overhead on Ethereum mainnet at 20 gwei would be $6.48, which is why Postmark is an L2 mechanism: below roughly a $2,300 swap, mainnet gas would cost a trader more than the fee discount saves them. On an L2 the break-even is essentially zero.
A separate Foundry measurement (test/GasOverhead.t.sol) reports ~108k
for the full path in a local EVM. The on-chain figure above is the one to quote.
The Day 4 gate, from test/SettlementMath.t.sol. The scenario pins the reference price analytically rather than reading it back out of the contract under test:
tick at execution : 19
tick after market : 99
reference tick : 99 → an 80-tick adverse move
notional : 1e18
gross markout : 80.3 bps = 1.0001^80 − 1
charge : 48.2 bps = α × 80.3, α = 0.6
Expected and actual matched to the wei.
HANDOFF.md is the project overview — what's done, what's left, decisions already made, and the gotchas worth knowing before you touch the code. WORKLOG.md is the running session-by-session log, updated at the end of every working session.
Requires Foundry.
git clone --recurse-submodules https://github.com/TechnicallyKiller/postmark
cd postmark
forge build
forge testAlready cloned without submodules:
git submodule update --init --recursiveSolidity 0.8.26, EVM target cancun (the hook uses transient storage to carry the resolved payer from beforeSwap into afterSwap).
forge test --isolate --match-path test/GasOverhead.t.sol -vv--isolate matters: without it each call reuses warm storage and the figures understate what a
standalone transaction pays.
Needs an archive RPC — the harness reads logs thousands of blocks back, and public endpoints serve only head-adjacent blocks. A free Alchemy or Infura key is enough.
pip install pandas numpy matplotlib requests tqdm
export ETH_RPC_URL="https://eth-mainnet.g.alchemy.com/v2/<key>"
python3 scripts/backtest.py # fetches, simulates, writes both charts
python3 scripts/backtest.py --cached # re-run from swaps_cache.csv, no RPC calls
python3 scripts/test_backtest.py # simulation math checks, writes no chartThe harness mirrors the contract's constants and reputation rules, and attributes each swap to the
Swap event's sender — the same per-router attribution the hook uses in v1, rather than an
idealised per-trader assumption.
export PRIVATE_KEY=0x...
export POOL_MANAGER=0x... # v4 PoolManager on the target chain
export GUARDIAN=0x... # optional, defaults to the deployer
export TOKEN0=0x... TOKEN1=0x... # optional, sorted; creates a dynamic-fee pool
forge script script/DeployPostmark.s.sol:DeployPostmark \
--rpc-url unichain_sepolia --broadcast --verifyThe hook address is mined against the canonical CREATE2 proxy so its low 14 bits carry the permission flags. Mining against the EOA instead produces a salt that yields a different address when broadcast, and the constructor's own check then reverts.
src/
PostmarkHook.sol v4 hook surface, settlement, emergency brake
FlowVault.sol bond escrow, one slot per (payer, currency)
ScoreRegistry.sol cross-pool reputation
base/BaseHook.sol local BaseHook — v4-periphery moved theirs to a separate repo
libraries/ ReceiptBook, PriceAccumulator, PostmarkMath, HookMiner
script/
DeployPostmark.s.sol mine, deploy, wire, optionally open a pool
test/
PostmarkHook.t.sol hook wiring, dynamic fee, access control, bond invariant
SettlementMath.t.sol Day 4 gate — charge vs independently computed LVR
Adversarial.t.sol A1–A5, each measuring the attacker's money
FeeTiers.t.sol tier resolution, bonded vs unbonded, router attestation
FlowVault.t.sol escrow, locks, cooldown, debit caps
GasOverhead.t.sol overhead vs an identical vanilla pool
Verify_PartnerCode.t.sol receipt-notional regressions
utils/ shared fixture
scripts/
backtest.py replay real mainnet flow, produce both charts
test_backtest.py simulation math checks
docs/
BUILD_PLAN.md the full ten-day plan
Unichain Sepolia (chain id 1301), verified live on chain.
| Contract | Address |
|---|---|
| PostmarkHook | 0xdB86D5Fd78174d6ACE2EB268DB12F29C335A10C0 |
| FlowVault | 0x0F1bf92EE0C79F7Ca5C1e30E9412aD5BFF45c7C8 |
| ScoreRegistry | 0x9872b13257E958c2F7E4DcCc3F96b3C70c8e050c |
| v4 PoolManager | 0x00B036B58a818B1BC34d502D3fE730Db729e62AC |
A live pool is running on it, with a vanilla 30 bps pool alongside for comparison:
| Pool ID | |
|---|---|
| Postmark pool | 0xcdeaf3cf7e1fd1332eece659d1832f331a2808582e1bb11a3c363304b23ee885 |
| Vanilla 30 bps pool | 0xcfd1b3e91e47f87be5c35a9f2fc182736a5ff8fc430d5dad870f333ee4f6d1b0 |
Fifteen swaps have run through the pair. The hook has written 6 receipts, each recording its
payer, the exact notional, the execution tick and the tier quoted — including one where a router
attested its end user through hookData and the hook billed that address rather than the router.
Reproduce with script/SetupPool.s.sol.
Verified against the live pool with the canonical V4Quoter at 0x56dcd40a…:
Postmark pool, no attestation → 974,507,951,428,743 out 99,306 gas
Postmark pool, hookData attests → 975,972,665,198,715 out 161,440 gas
vanilla 30 bps pool → 986,155,426,021,753 out 37,163 gas
The hook quotes cleanly through the standard quoter, and the attested quote returns more output than the unattested one — the quoter is picking up the named payer's tier discount. That matters more than it looks: a hook that cannot be quoted cannot be routed to, and a fee that only resolves at execution time would be invisible to an aggregator. Postmark's fee is fully visible in the quote.
Note the quote's own gas is higher on the attested path, because resolving an attested payer reads their bond and reputation. That is quoting cost, not swap cost — the swap figures are above.
The hook address ends in 10c0 — that is not decoration. A v4 hook declares its permissions in
the low 14 bits of its own address, and 0x10c0 is AFTER_INITIALIZE | BEFORE_SWAP | AFTER_SWAP.
The address was mined to carry them, and the constructor rejects any address that does not.
Confirmed on chain: vault.hook() and registry.isAuthorizedHook() both point at the hook,
W = 100, withdraw cooldown 150 blocks, tier fees 200/800/1500/3000 pips, emergency brake not
engaged. Whole deploy cost 0.0000023 ETH.