Skip to content

feat(rfq): RfqSettlement + RfqPoolArb, atomic fills of signed issuer quotes - #73

Merged
danvaneijck merged 26 commits into
mainfrom
feat/rfq-settlement
Oct 16, 2026
Merged

danvaneijck merged 26 commits into
mainfrom
feat/rfq-settlement

Conversation

@danvaneijck

@danvaneijck danvaneijck commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Carries the stock vault stack (10-16). #74 → #80 were each reviewed in their own PR and merged top-down into this branch: #80 into #78, then #78 down to #74. So merging this PR lands the whole stack on main:

The stack's own CI fixes are in #80: forge fmt, and the size gate skipping abstract contracts. CI here tests the stack merged with today's main, which includes #79.

What

  • RfqSettlement settles EIP-712 quotes signed by one off-chain market maker against inventory parked in the contract, in one transaction.
    • Exact signed amounts, no partial fills, single-use unordered nonces, a named taker (or open).
    • Per-token payout caps per 1-day window: a token with no cap cannot be paid out.
    • Rotating the signer voids every outstanding quote. The signer or owner can pause; only the owner resumes. ERC-1271 signers are accepted.
  • RfqPoolArb runs one Choice CL pool swap and one such fill in a single transaction, in either order. It reverts unless the starting currency comes back ahead by minProfit. It keeps a pool in an issued asset at the issuer's mark without a hedge.
  • script/tools/rfq-sign-quote.sh is the reference quote signer. It is keystore-backed and checks the signature and the contract's live signer before printing.

Checked

  • forge fmt --check clean; FOUNDRY_PROFILE=ci forge test --isolate green (RfqSettlement.t.sol, RfqPoolArb.t.sol).
  • On mainnet as a test ($50, not in the address book): both arb directions ran on a live CL pool, each matching its eth_call simulation exactly.

Not in this PR

Nothing here is added to deployments/. The stock vault work is stacked on this branch (next PR).

🤖 Generated with Claude Code

danvaneijck and others added 6 commits October 12, 2026 12:21
…quotes

RfqSettlement settles EIP-712 quotes signed by one off-chain market maker against
inventory parked in the contract, in one transaction: exact signed amounts, no partial
fills, single-use unordered nonces, a named taker (or open), per-token payout caps per
1-day window (no cap = cannot be paid out), signer rotation voiding every outstanding
quote, pause by signer or owner and resume by owner only. ERC-1271 signers accepted.

RfqPoolArb runs one Choice CL pool swap and one such fill in a single transaction, in
either order, and reverts unless the starting currency comes back ahead by minProfit.
It is what keeps a pool in an issued asset priced at the issuer's mark without a hedge.

script/tools/rfq-sign-quote.sh is the reference quote signer (keystore-backed, verifies
the signature and the contract's live signer before printing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s own pools

An ERC4626 cash vault that is also the CL hook of one pool per issued asset.
While the asset's oracle (0x67) is fresh, beforeSwap fills the whole swap from
the vault's inventory at the oracle price plus or minus a spread, settling in
vault claims so the swap path moves no token. The spread leans toward a target
inventory, never past the oracle. The vault holds a perpetual short against its
holdings through 0x65 (syncHedge, permissionless, one order per market per
block), and values shares at cash + assets at the oracle + shorts at margin and
PnL.

Every fill is guarded: oracle age, a jump breaker, trade size, an outflow
window, claims inventory, an inventory cap, a margin reserve, and an unhedged
cap. A refused swap reverts while the pool's curve is closed, because the pool
checks a swap's price limit against the curve before it sees the hook took
the swap, so a refusal falling through to an empty curve would strand every
later no-limit swap in that direction.

BasketLib is its external library (hedge, valuation, pricing guards) so the
vault fits EIP-170 at the pinned 25,666 runs (vault 22,884 bytes). A proof of
concept: allowlisted depositors and an owner who can sweep.

Gates: forge fmt --check; contract-size gate (none over); fork pins;
FOUNDRY_PROFILE=ci forge test --isolate, 430/430.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d Pyth Pro updates

One vault per stock: an ERC4626 vault over cash that is the hook of its one stock/cash CL
pool and holds a 0x65 perp short. It fills the whole swap at the newer of the stored price
and a signed update handed in as hookData, plus a spread computed at each fill (session base,
price age, bid/ask, confidence, size, window use, the opening window, a keeper widen, the
re-anchor widen, and an inventory lean that never crosses the price). In session a stale price
is a refusal; weekend mode fills on the close only when the MarketCalendar says the market is
shut and the feed's own timestamp is at the close.

The short tracks target inventory while the issuer can restock and the holding when it cannot,
moving only past a band, confirmed from the position a block later, and capped so it never runs
more than maxUnhedged ahead of what is held. Stock is valued at what the vault booked, so a
delivery cannot be counted twice; restocking is buy-only tracked memo orders with a minimum and
a deadline. Deposits and withdrawals settle the short's funding first (a one-unit
decreasePositionMargin) and run only while the market is open, behind a NAV-jump stop.

PriceStore recovers the Lazer signer itself against a pinned, rotatable signer instead of
calling Pyth's upgradeable verifier, and judges nothing: readers check a feed's own update
time (property 12). MarketCalendar computes US daylight saving and takes holidays and half days
from governance; its guardian can only close a day.

EIP-170 split the vault into four code bodies on one address: StockVault (hook, shares, fill
path), StockLib and StockOps (external libraries) and StockVaultExtension (settings, keeper
actions, views, through the vault's fallback), all on one ERC-7201 storage slot.

Tests: 75 vault, 23 store (a real mainnet update verified against Pyth's production signer),
10 calendar; the full CI profile passes (537 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tick

Requests are committed to the next boundary with their input in escrow, and
every request for that boundary fills at one price: the signed update stamped
exactly at boundary + delay, on Pyth Pro's 200 ms grid, so no settler chooses
among updates. Settlement is permissionless and books one total per side in
constant gas; requesters pull their fills. A batch not settled within its
window lapses and refunds in full.

No minimum out and no cancel: a request is an order at the fixing, so there is
no option to fill only when the price moved one's way, and no penalty to price
one. The vault takes on the batch's net, capped by its booked stock, its cash
above the reserve and the hedge margin, its inventory cap and maxNet; past
those the larger side fills pro rata.

What the vault holds for requesters (escrow, unclaimed outputs, refunds) is
tracked as cashOwed / stockOwed and kept out of its value, free cash, windfall
and every spend: instant fills, withdrawals, restock orders and hedge margin.

StockFixing is a fifth code body (external library); the entry points live on
the vault, since the extension had no room. The stock vault test harness moves
to test/utils/StockVaultHarness.sol so both suites share it.

Tests: StockFixing 30 (fills, crossing, pro-rata cuts with exact dust, every
capacity cut, the one valid tick, stale / jump / halt / spread refusals,
sessions, lapse, pause, escrow isolation, config limits, constant settlement
gas, and a fuzz that every unit is accounted for and the vault never loses at
the fixing). FOUNDRY_PROFILE=ci forge test --isolate: 568 passed, 19 suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keeper can rest one post-only limit order for the same gap syncHedge
would cross the book for, at a price it picks inside hedgeSlippageBps of the
reference and on the market's tick. Post-only means it only ever fills as
maker; one that would cross reverts. Placing again replaces it.

Per injective-core's exchange module (mainnet release): a post-only order is
written to the resting book in the placing transaction, so
derivativeOrdersByHashes reads it back at once, and the precompile serves that
query under STATICCALL (it gates only writes on read-only), so it is declared
view and read inside totalAssets. Its margin leaves the bank balance at
placement and the unfilled part comes back on cancel.

- confirmHedge reads the order back with the position: what may still fill,
  or clears it once gone (filled, or dropped by matching, which the position
  tells apart).
- totalAssets counts the margin the order still holds.
- The instant lane's exposure guard judges the short both with and without
  what may still fill.
- A market hedge cancels the resting order first: the keeper any time, anyone
  once it has rested makerTimeout (zero turns maker hedging off; at most a
  day). cancelMaker follows the same rule.

placeMaker and cancelMaker live on the vault: the extension is at 24,310 B.

Tests: StockMaker 16 (rest, partial and full fills, matching drops, replace,
reduce-only buys, band and tick limits, crossing, roles, timeout, market hedge
cancels first, exposure counts what may still fill). The exchange mock gains
resting post-only orders, cancels, the order query, maker fills and
matching-cancels. FOUNDRY_PROFILE=ci forge test --isolate: 584 passed, 20 suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StockVault replaces it. The PoC stays deployed and verified on Blockscout as it
was; its source is in this branch's history at d45e465. IOracleModule goes
with it: the stock vault prices only off signed Pyth Pro updates and nothing
else reads the 0x67 precompile.

FOUNDRY_PROFILE=ci forge test --isolate: 536 passed, 19 suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
danvaneijck and others added 3 commits October 12, 2026 22:39
No behaviour change; every P1 and P2 test passes unchanged except for renamed
symbols. Each body now sits inside the budget in STOCK_VAULTS_PLAN.md:

| Body | Runtime | Budget |
| --- | --- | --- |
| StockVault (hook, ERC-4626, fallback) | 19.2 KB | 20 KB |
| StockPublic (views, permissionless, fixing lane) | 12.7 KB | 20 KB |
| StockAdmin (owner, keeper, guardian) | 15.7 KB | 20 KB |
| StockPricing (quote, spread, guards, anchor) | 9.2 KB | 20 KB |
| StockHedge (confirm, market and maker hedges, value) | 14.1 KB | 20 KB |
| StockOps (bookkeeping, restock, NAV checks, config) | 13.7 KB | 16 KB |
| StockFixing | 11.0 KB | 16 KB |

- StockVaultExtension becomes a chain: the vault's fallback DELEGATECALLs
  StockPublic, whose fallback hands any selector it does not know to
  StockAdmin. Each link is an immutable, so the chain is fixed at deployment
  and no selector list exists to drift. StockModule is the shared base (the
  delegated-only guard, the role checks, the shared reentrancy slot); the
  first module's fallback refuses direct calls too.
- StockLib becomes the internal shared library (limits, flags, shared errors,
  the reference price, the hedge gap, helpers), inlined and never deployed.
  StockPricing and StockHedge are the external libraries it split into;
  checkConfig moves to StockOps.
- The fixing and maker entry points leave the vault for the modules, so the
  vault holds only the hook and the share token.

FOUNDRY_PROFILE=ci forge test --isolate: 536 passed, 19 suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stry, factory

Redeem queue (StockQueue, entry points in StockPublic). ERC-7540's request
side with operators: requestRedeem escrows the shares, per controller
(requestId 0), no cancel; queued shares still count in the share price. Each
epoch settles whole:
- in cash, by anyone, once cash above the reserve covers it at the share price
  less exitFeeBps, which stays with those who stay;
- in kind, the backstop: the keeper any time, anyone once queueDeadline has
  passed. The epoch takes its share of the booked stock and the cash at once,
  and its share of the rest (the short, a restock in transit, a maker order's
  margin) as a debt. The debt is out of the vault's value from the moment it is
  owed (debtOwed) and is paid by finishInKind once the short is cut.
Epochs settle in order, instant withdrawals cannot take the cash the queue is
waiting for, and claims (claimRedeem: cash, plus stock for in kind) are pro
rata on what remains and never paused. The vault exposes two self-only hooks
(escrowShares, burnEscrowed) so the module can reach the share token.

Issuer quotes (StockQuotes, fillQuote in StockAdmin). EIP-712 quotes signed by
quoteSigner (ERC-1271 accepted), both directions, filled by the keeper, both
legs pulled and checked in one transaction. Price within restockSlippageBps of
a fresh reference, single-use nonces, expiry, the shared daily restock cap,
the inventory cap, own cash only. Rotating the signer voids every quote.

StockRegistry: issuer caps and each vault's inventory cap as its allocation;
setConfig asks it and is refused past the issuer's cap; never on a fill path.
StockFactory: pinned to the vault's creation-code hash and every shared part;
deploys by CREATE2, registers, opens the pool, offers ownership to the vault
owner (Ownable2Step). One vault per stock token, guarded by the registry.

sweep now also refuses the vault's own share token (queued shares sit in
escrow there).

Found by the new invariant suite: before debtOwed, an in-kind epoch's unpaid
debt stayed in totalAssets, so the share price rose at startInKind and fell
back at finishInKind. A deposit in between would have paid too much.

Sizes against the plan's budgets: StockVault 18.3 / 20, StockPublic 17.0 / 20,
StockAdmin 16.1 / 20, StockQueue 10.2 / 16, StockQuotes 7.2 / 12, StockFactory
5.8 / 12, StockRegistry 3.5 / 12 KB.

Tests: StockQueue 16, StockQuotes 11, StockFactory 7 (a factory vault is the
same runtime code as a hand deployment), and a stateful invariant suite (swaps,
fixing, queue in cash and in kind, quotes, the hedge): what is held covers what
is owed, only the open epoch is in escrow, and the share price never falls at a
constant oracle. FOUNDRY_PROFILE=ci forge test --isolate: 573 passed, 23 suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
danvaneijck and others added 4 commits October 14, 2026 21:21
Prices are per token: the vault keeps a tokens-per-share ratio and divides
the feed's per-share price by it wherever a price is read, so fills, NAV,
the anchor and the share price are per token, while the perp side uses the
per-share price and converts tokens to shares.

A corporate action halts the vault (keeper, guardian, or a ratio the new
StockRatios registry announces) until the owner accepts the new ratio,
which re-anchors in the same call and is refused if the price per token
moved more than the owner reviewed (a ratio accepted before the feed moves).
poke cannot re-anchor through a halt. While halted with nothing open, the
owner may move the short to a new perp market (Helix settles and relaunches
a stock perp ahead of a split).

Memo restocking is now LIMIT orders in both directions (StockRestock): the
memo built on chain from an owner-set template, the limit near a fresh
reference and the anchor, a sell's stock kept in the value and the hedge
while in transit, and refunds or payments closing the order without a halt.
Quotes stay as the optional path and gain the anchor check.

Tests: StockRestock 24, StockAction 11, the invariant suite gains split and
LIMIT order actions; CI profile 602 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iption

A PriceStore whose latest() returns the newer of its own stored signed
update and the Pyth Pro price Injective's oracle module already holds,
read from 0x67. The module dates a price by the block it landed in (whole
seconds), not by the feed's time, so the store dates it relayLagMs earlier
(owner-set, at most 5 s): relayers land updates about 0.8 s after Pyth
publishes them (p99 2 s, measured over 300 blocks). Signed payloads taken
from relay transactions still verify and win when newer. The vault is
unchanged.

Checked: OraclePriceStore 9 tests (store, and a vault filling off the
module alone, refusing an old one, weekend mode at the close); the
compiled runtime eth_call'd against mainnet 0x67 with a code override
(AAPL and MSFT decoded, a missing feed returns zero); CI profile 611 green.
OraclePriceStore 7,018 B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iums

High, each proven against the unfixed code first:
- The issuer is the registry's (setRoles), and the memo template must name the
  vault as did_id (placeholder 0x04) and leave the expiry key to the vault
  (0x03 writes "expiry_hours"/"expiry_days" from the unit), so the owner cannot
  redirect restock cash or stock. restockDailyCap <= maxInventory.
- A halt always has a way out: acceptActionAt checks against a per-token price
  the owner states, for a market that moved past MAX_ACTION_MOVE while halted.
- Signed updates must carry the exponent and the feed timestamp, on channel 3
  (what the relayed updates on Injective carry, measured 10-15); others are
  refused by verify and skipped by update.
- poke halts on a jump past MAX_ACTION_MOVE instead of re-anchoring through it
  (a split in the feed before the ratio registry).
- topUpMargin works on equity (margin + unrealised PnL); the keeper may top up
  below target, anyone below 70% of it.

Medium/Low:
- order() books stray stock first, so only this order's delivery settles it.
- A buy in transit is valued as its stock, as it is hedged.
- Restock, quotes, instant fills and fixing keep the reserve, an unwinding
  epoch's debt and the new stock's margin; open buys count in inventory caps.
- No pre-hedge while an in-kind epoch unwinds; hedgeGap counts min(booked, own).
- Hedge margin at the higher of order price and mark, plus the loss a sell
  opens at; anyone's hedge crosses at most minSpread; publicHedgeDelay floor.
- Exchange bool returns checked; a refused funding settlement no longer
  freezes deposits and exits.
- restockExpiryUnit is 1 hour or 1 day; restockDeadline at least 1 hour.

622 tests green; sizes all under 24,576 with 5.4 KB+ to spare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
danvaneijck and others added 13 commits October 15, 2026 21:43
…test caps

Deploys the parts that do not touch the stock token (a forge script cannot run
an MTS bank ERC20's decimals()), then writes factory.deploy calldata for cast.
Fork dry run 10-15: all parts, factory.deploy (5.19M gas), owner calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mainnet rehearsal's unwind was refused RestockCapExceeded(19.77, 10.00):
a buy the issuer had refunded still counted against the $50 day, so the sale
of the stock it bought could not go out the same day.

- A sale refunded in stock (which the vault sees arrive) gives back what it
  counted, if the window it was counted in is still the current one. The order
  now records that window and amount (orderWindow, orderCounted).
- A buy refunded in cash closes on the keeper's word, which the vault cannot
  check, so it gives nothing back by itself: otherwise a faulty keeper could
  order, close as refunded and order again past the cap. The owner confirms it
  with creditRestock(cash), capped at what is counted.

Tests: a stock refund gives the cap back; a cash refund only through the
owner, never past what was counted; a refund after its window gives nothing.
forge test: 625 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An overnight priced as closed (overnightLive off) on a price from after
  the 20:00 close: the overnight market is trading, so that price must be
  as fresh as one in session. A price stamped at the close still needs no
  age, as at the weekend.
- A redeem epoch keeps the exit fee it opened under (RedeemEpoch.feeBps)
  and pays the lower of that and the fee at settlement: a queued request
  cannot be cancelled, so a later raise never applies to it.
- A queue settlement records the share price it leaves as the NAV
  breaker's last: the exit fee stays with those who stay and moves the
  price on purpose, which would otherwise trip the next deposit.
- maxDeposit/maxMint/maxWithdraw/maxRedeem answer zero whenever the call
  would revert: paused, a lock open or a hedge pending, the market closed
  or the price stale, or a share price outside the breaker. The allowlist
  stays out of maxDeposit: it holds the caller, whom the view cannot see,
  and NotDepositor says more than a zero.

Tests: one regression test each in StockReviewFixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vault could not tell a restock's cash leg (a sale paid, a buy refunded)
from other cash: its default exchange subaccount trades off the bank
balance, so margin, PnL, fees and funding moved it with no call of the
vault's. A cash leg therefore closed on the keeper's word, and until then
NAV counted it twice (seen live on 10-15).

- The short trades from subaccount 1, which holds a deposit of its own.
  Each order or top-up first leaves exactly what it needs available there
  (deposit the shortfall from booked cash, or bring the rest back); a
  confirmation brings back whatever is free. The bank balance now moves
  only on the vault's own calls and on arrivals.
- cashBooked, like stockBooked: deposits, withdrawals, fills, quotes, the
  fixing lane, queue settlement, restock sends and subaccount moves book
  it. Own cash is min(held - owed, booked); cash above it (an issuer leg,
  a donation) is out of the value and unspendable until booked.
- settleRestock is anyone's, any time: stock at least the order's, else
  cash at least the order's, closes it and books it; nothing back by the
  deadline lapses it (RestockLapsed), and a late leg is a windfall
  (syncWindfall now books cash too). An order books what was already here
  first, so only its own legs settle it. A cash refund gives its daily cap
  back on its own, so creditRestock is gone.
- value() reads the subaccount's total, which holds a resting maker
  order's margin, instead of reading the order back.
- The cash denom is fixed at construction (Init, factory Parts); an owner
  setter could point the value at an empty denom.

Mainnet facts this rests on: deposit/withdraw to subaccount 1 from a
contract (probe, 10-12); subaccountDeposit answers in token units and a
default subaccount reads zero (10-16). Not yet proven on chain: orders and
margin moves on a non-default subaccount from a contract.

Tests: StockCashLedger (6), the restock suite rewritten to the new closes,
and the invariant suite now holds every unit of cash booked or owed. The
mock exchange gains subaccount deposits with holds.
forge test (ci): 637 passed. Sizes: least headroom StockVault, 3,158 B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e dust

Proven 10-16 from a probe contract on mainnet, all on subaccount 1 (the
calls StockHedge makes): deposit, a market short (its hold out of the
available, not the total), a post-only order resting in the same
transaction (its hold in the total, not the available) and its cancel, a
withdrawal with the short open, increasePositionMargin and the one-unit
decreasePositionMargin, and the reduce-only close, whose margin and PnL
landed in the subaccount. Probe 0xea36CE0eC6f2e3Fe67D3118804F4d117B8D57c30,
txs 0x83b91e95 (open), 0x524f276b (the rest), 0xac937d50 (emptied).

The one bank arrival the vault causes itself: the 40% relayer share of its
own orders' fees (feeRecipient empty = the sender) accrues on the DEFAULT
subaccount and is swept to the bank in whole units. Unbooked, so out of
the value until the next order or syncWindfall books it; noted where the
ledger claims the bank moves only on the vault's own calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Format step had failed since #76. One change by hand rather than the
formatter's: acceptAction's refusal reads as a named condition, which
forge fmt otherwise splits into a detached block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StockModule (#75) is the repo's first abstract contract. Forge leaves an
abstract contract out of the size report - it has no bytecode - so the
gate failed it as "not in the size report". The list the gate holds every
contract to now skips abstract ones; a renamed or deleted deployable
contract still breaks it.

Checked locally on the top of the stack: fork pins, fmt, build, the size
gate (23 contracts, none missing, none over) and forge test --isolate
(ci profile): 637 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(stock): a cash ledger, with the short in its own subaccount
fix(stock): the 10-15 review's High findings and the restock/exit Mediums
feat(stock): OraclePriceStore, vault prices without a Pyth Pro subscription (P3.2)
…stock

feat(stock): corporate actions and LIMIT restocking (P3.1)
feat(stock): P3, the planned layout, the redeem queue, issuer quotes, registry and factory
feat(stock): StockVault, a hedged single-stock vault with an instant and a fixing lane
@danvaneijck
danvaneijck merged commit b79164c into main Oct 16, 2026
2 checks passed
@danvaneijck
danvaneijck deleted the feat/rfq-settlement branch October 16, 2026 11:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant