INV-1 — Wallet-held SPL shares could not back a resting ask (0x4001 MathOverflow)#
| Field | Value |
|---|---|
| ID | INV-1 |
| Date found | 2026-08-26 |
| Date fixed | 2026-08-26 |
| Verdict | CONFIRMED — protocol bug (not a harness artifact, not by design) |
| Severity | High (Impact Medium × Likelihood High) — protocol breakage, conditional fund loss under illiquidity |
| Status | FIXED — prime_position_share_mirror_from_authority is applied on both place-order lanes |
| Discovered by | Live-fork investigation during the platform-seeding work; escalated from the seeder's cheatcode |
Location#
| Role | Path |
|---|---|
| Failing arithmetic | program/src/processor/place_order_trait/position_delta.rs (apply_signed_delta_u64 checked_sub, applied by apply_position_changes) |
| Delta origin | program/src/logic/place_order_processor/resting.rs::apply_resting_ask (YES and NO branches) |
| Missing reconciliation gate | program/src/processor/place_order_trait/mod.rs — priming was wrapped in if using_balance_overrides |
| Missing credit | program/src/processor/mint_shares.rs — the position PDA is not an account of MintShares (0x07) |
Description#
UserPositionAccount.yes_shares / no_shares are documented as an analytics
mirror, not a source of truth (spec/STATE.md, spec/IX.md, and the comment
block in processor/withdraw_shares.rs — "a stale position mirror must not
gate"). WithdrawShares deliberately uses saturating_sub on those fields and
carries a unit test named
apply_withdraw_position_mirror_updates_never_gates_on_stale_share_mirror.
PlaceOrder did not honour that contract. When an order leaves a resting ask
remainder, the pure logic layer emits a negative share-mirror delta:
// logic/place_order_processor/resting.rs::apply_resting_ask
output.tokens_to_escrow_yes = output.tokens_to_escrow_yes.checked_add(remaining_qty)?;
add_position_delta(&mut output.position_changes.locked_yes_shares_delta, remaining_qty_i64)?;
add_position_delta(&mut output.position_changes.yes_shares_delta,
remaining_qty_i64.checked_neg()?)?; // <- free-share debit
and the trait layer applied it with checked (gating) arithmetic. Nothing on any wallet-funded path ever credited that mirror:
| Path that gives a user SPL YES/NO | Credits position.yes_shares? | Evidence |
|---|---|---|
MintShares (0x07) | No — the position PDA is not even an account of the instruction | processor/mint_shares.rs has zero references to UserPositionAccount; spec/IX.md account table |
| Ordinary taker fill (buy) | No — by explicit design | logic/place_order_processor/types.rs: "In the escrow model, ordinary taker fills transfer SPL tokens via CPI and leave this delta at 0." |
WithdrawShares (0x0A) | No — it only saturating_subs | processor/withdraw_shares.rs |
The only production credit sites are cancel_order, reduce_order and the
self-trade CancelProvide cleanup — i.e. the mirror could only be credited by
unwinding an ask that was already placed. A chicken-and-egg loop: the first
ask could never be placed, so the mirror never became positive.
The program already contained the reconciliation this path needed, but it was gated to the free-funds lane only:
if using_balance_overrides { // <- route_to_slot / *WithFreeFunds only
prime_position_share_mirror_for_balance_override(
&mut position, &output.position_changes, logic_side, user_share_balance,
)?;
}
apply_position_changes(&mut position, &output.position_changes, current_time)?;
On the plain lane balance_overrides is None, so user_share_balance is the
live SPL ATA balance — it was read, used for
verify_taker_balance_for_transfers, and then discarded instead of being used to
prime the mirror. The information needed was already in the frame.
The free-funds lane was not an escape hatch either: slot.yes_free is credited
only by MakerFillDelta application, Redeem, and cancel/reduce refunds. There
is no instruction that deposits SPL YES/NO into yes_free, so a user whose
shares live in their wallet was rejected by that lane with 0x300D InsufficientBalance.
Scope#
The mirror debit only fires when a resting remainder exists
(remaining_qty > 0 && order_type != ImmediateOrCancel):
| Order | Before the fix |
|---|---|
SellYes / BuyNo Limit that rests fully | REVERT 0x4001 |
SellYes / BuyNo Limit that partially crosses | REVERT 0x4001 (the whole tx, including the crossing part) |
SellYes / BuyNo PostOnly | REVERT 0x4001 |
PlaceMultiplePostOnlyOrders (0x0C) ask legs | REVERT 0x4001 |
SellYes / BuyNo IOC / market that fills | worked |
BuyYes / SellNo (bids) | worked — bids debit collateral, not the share mirror |
Impact#
- Every wallet-funded sell-side limit / post-only order reverted, for every
user, on every market. There was no client-side workaround in the shipped
apps: the free-funds lane rejects wallet-held shares with a different error,
and no instruction moves SPL shares into the ledger.
apps/web's default funding source iswallet, so this was the default path on web and mobile. - Market making on the ask side was impossible for anyone holding SPL shares.
- Conditional fund loss under illiquidity: a holder who wanted to exit and
found no bid depth could not post an ask to attract one. Shares were not
permanently locked (
Redeemstill worked), which is why this is High and not Critical. - Every seeded book in this repo only existed because a Surfpool cheatcode wrote
the mirror (
apps/integration-tests/src/seed/prime.ts,packages/e2e-helpers/src/settlement.ts::ensureCounterpartyPositionForMintedShares). Neither exists on mainnet. No automated test exercised the production ask path end-to-end — which is exactly why this shipped.
Evidence#
Live fork, before the fix#
Fresh keypair each run, no priming cheatcode, order built by @seesaw/core's
placeOrder (the builder selectOrderBuilder('wallet', Limit) returns for web
and mobile):
0x4001 = SeesawError::MathOverflow. The NO leg (BuyNo Limit @ 4000) failed
identically, and a taker-bought (rather than minted) wallet also had
position.yes_shares = 0 and failed the same way — so the failure was not
specific to minted shares. A fully-crossing ask (no remainder) succeeded.
Repository tests that encoded the bug as correct#
program/tests/security_first_resting_sell_overflow.rs previously asserted
0x4001 for exactly this state and concluded "harness artifact, NOT a program
bug", on the false premise that MintShares credits both the SPL supply and
position.yes_shares. Both of its premise assertions are contradicted by
processor/mint_shares.rs and by the live-fork evidence above.
Live fork, after the fix#
Same script, freshly generated wallets, program rebuilt and reinstalled on the fork, no cheatcode:
Fix#
Prime the mirror from the authoritative custody balance on both lanes, and rename the helper accordingly (it is no longer override-specific):
- if using_balance_overrides {
- prime_position_share_mirror_for_balance_override(
- &mut position,
- &output.position_changes,
- logic_side,
- user_share_balance,
- )?;
- }
+ prime_position_share_mirror_from_authority(
+ &mut position,
+ &output.position_changes,
+ logic_side,
+ user_share_balance,
+ )?;
apply_position_changes(&mut position, &output.position_changes, current_time)?;
user_share_balance is already resolved per lane by the caller: the
trader-ledger free share bucket when routing to a slot, the live YES/NO ATA
balance otherwise. The helper is fail-closed and unchanged: it raises the mirror
only, only up to |delta|, and only when the authority covers it; otherwise it
returns InsufficientBalance.
Blast radius: 2 files, no ABI change, no account-layout change, no SDK or indexer change.
Rejected alternatives#
MintSharescredits the mirror. It is an ABI break (spec/IX.mddefines an 11-account fixed layout with no position slot) and it does not fix the bug: a taker-bought wallet still hasyes_shares = 0. It was left open as possible "INV-V4 hygiene"; that follow-up is now rejected — see the close-out below.- Make the debit saturating. It would stop the revert but silently
desynchronise
locked_yes_sharesfromyes_shares(locked credited the full quantity while free saturates at 0), corrupting the conservation projection. The escrow CPI is the real custody gate; the mirror should be primed, not clamped.
Invariant implications#
| Invariant | Effect |
|---|---|
| Non-negative shares | Preserved — the write only ever raises a u64. |
locked <= available + locked | Preserved — raising yes_shares can only slacken it. |
collateral_locked <= collateral_deposited (hard assert) | Untouched; the ask path moves no collateral. The fix deliberately does not write collateral_deposited, unlike the seed cheatcode. |
| Settlement safety | No payout path reads position.{yes,no}_shares authoritatively. Redeem pays calculate_payout(amount, redemption_rate_bps) against the burned SPL amount (processor/redeem_trait.rs; the field appears in processor/redeem.rs only inside unit tests at L1421/L1440/L1449). ForceClose reads it at processor/force_close.rs:772-773 purely for the ForceClosePositionPrepared reporting payload (L839-840) and refunds position.collateral_locked (L789), explicitly leaving the shares intact for Redeem. CloseMarket (processor/close_market.rs) never reads either field. So priming cannot over-credit a settlement — unlike the harness cheatcode, which is why that cheatcode carried a "never treat a primed position as settlement evidence" caveat. |
INV-V4 (docs/security/invariants.md) | Unaffected. The old aggregate form (M.total_yes_shares == sum(P.yes_shares + P.locked_yes_shares)) was never an invariant and has since been retired — INV-V4 is now stated on mint supply plus attached spline inventory, which this fix neither touches nor depends on. See the close-out below. |
Regression coverage#
| Test | Level | Covers |
|---|---|---|
program/tests/place_order_wallet_funded_ask.rs | trait (MockContext, no BPF) | minted → resting SellYes; post-only; taker-filled shares → resting ask; NO leg; partially-crossing remainder; fail-closed beyond the ATA balance (both legs); consecutive asks bounded by the wallet; bids unaffected |
program/tests/security_first_resting_sell_overflow.rs | BPF (LiteSVM) | wallet-funded ask rests across the price range for Limit and PostOnly; fail-closed beyond the wallet balance; credited-mirror control |
program/tests/mutation_killers.rs::kill_mutation_plain_lane_primes_position_share_mirror | trait | re-gating the priming on using_balance_overrides fails the gate |
Follow-ups (out of scope for this fix)#
prime.ts/ensureCounterpartyPositionForMintedSharesare now dead workarounds.apps/integration-tests/src/seed/prime.tsandpackages/e2e-helpers/src/settlement.tswrite the mirror withsurfnet_setAccountsolely to work around this bug. They can be removed so the seeded book exercises the real production ask path.- The
yes_freebucket has no deposit path. SPL shares are a one-way street into the wallet. Even with this fix the two funding sources are not interchangeable for shares the way they are for quote. Worth a design decision. Spec contradiction.CLOSED — resolved in favour of the analytics projection reading. See the close-out below.
Close-out: INV-V4 redefined, option (b) rejected#
Date: 2026-08-27
Decision: redefine INV-V4 on authoritative state. Do not credit the
Position mirror in MintShares.
Follow-up 3 above named the contradiction: spec/STATE.md and spec/IX.md call
UserPositionAccount.{yes,no}_shares an analytics mirror, while
docs/security/invariants.md asserted an exact zero-tolerance aggregate over the
same fields. Two resolutions were on the table:
- (a) Redefine INV-V4 so it stops asserting the aggregate.
- (b) Make the aggregate true by crediting the mirror in
MintShares.
Option (a) was taken. INV-V4 now reads
market.total_yes_shares == yes_mint.supply + attached_spline.yes_inventory and
market.total_no_shares == no_mint.supply + attached_spline.no_inventory — every
term an authoritative on-chain value, collapsing to plain supply equality when no
market-maker spline is attached. The mint and burn legs move together in
MintShares and Redeem; every other share-moving instruction is a transfer
that conserves the inventory-inclusive total. The spline terms are load-bearing,
not decorative: a delivered sell-side share is burned as it moves into spline
inventory, so a pure total == supply reading would flag every spline-attached
market (see INV-SPLINE-5). The Position mirror keeps only a local property: a
resting Position's locked_* agrees with that Position's own live orders,
and is never summed across Positions.
apps/indexer/src/jobs/invariantWatchdog.ts now checks that equality against
chain — sampling the market, both mints, and the attached spline in one RPC
context — and reports the local mirror property as a non-alerting diagnostic. See
docs/security/invariants.md INV-V4.
Why option (b) is rejected#
Crediting the mirror in MintShares would describe only the initial recipient
and would still not make the aggregate true:
- Stale on transfer. YES/NO shares are bearer SPL tokens. An ordinary wallet-to-wallet transfer moves custody without touching either Position, so the minter's mirror stays credited for shares it no longer holds and the recipient's stays at zero. The aggregate is wrong again after one transfer.
- No fix for fills. Ordinary taker fills deliberately carry a zero Position
delta (
program/src/logic/place_order_processor/types.rs:383-400) and maker fills are credited to theTraderLedger, not the maker Position (program/src/utils/trader_ledger.rs:48-73). The recipient's next resting sell then primes a second mirror for the sameq(program/src/processor/place_order_trait/mod.rs:831-855,917-961;place_order_trait/position_delta.rs:165-213), so two rows report2qlocked againstqoutstanding. Crediting at mint time does not touch any of this; the aggregate over-counts rather than under-counts.WithdrawSharesalready treats the mirror as untrusted and clamps withsaturating_sub(program/src/processor/withdraw_shares.rs:88-113). - Phantom Positions block
CloseMarket. Creating a Position incrementsmarket.total_positions, andCloseMarketblocks while Positions remain, whereas bearer mint-only supply is checked separately through aggregate payout (packages/core/src/preflight/lifecycle.ts:123-140). A minter who transfers all their SPL away would leave behind a Position holding no custody that must still be settled and closed — a new liveness hazard on the terminal path. - ABI, CU, and rent surface for no gain.
spec/IX.mdfreezesMintSharesat an 11-account layout with no position slot. Adding one is a wire-format break across the program, IDL, all three SDKs, the CLI, the indexer parser, and both clients; it adds a rent-paying account and CPI/CU cost to the hottest entry path — all to buy an aggregate that points 1–3 show is still false.
The mirror is a diagnostic. The mints plus the attached spline's inventory are the custody record, and INV-V4 is now stated against them.