Put vault
Copied from the Tender programs' own docs on 2026-09-28: current, plus: init_vault now requires the INIT_AUTHORITY signer (D5, commit e0ebe78), and new vaults default to the put image on pump.fun IPFS (D14).
Built and tested: commits 40b3e77 (M2), 81258bf (sandwich-resistant reads and live-spot clock, from report 25) and c18c8c1 (optional check pool). Described as of c18c8c1.
The product is a daily, European, cash-settled capped put spread (owner decisions 2026-09-28: cash-settled; capped at roughly 3-4x the premium). The engine is not used. The vault holds only USDC and keeps its own position records. This is the one place in tender where a price read settles money; see "Why this is not the engine's rule" below.
Source: review/0dte/SPEC-SOLANA.md section 3 including "As built"; commits 40b3e77, 81258bf, c18c8c1.
Payoff
Per token, at expiry: claim = min(max(K - S, 0), W). K is the strike, W the cap width, S the settlement price. The vault locks W x size of USDC per position at sale, its maximum loss on that position. No tokens move at any point.
Source: SPEC-SOLANA section 3 "Product".
Accounts and seeds
| Account | Seeds | Holds |
|---|---|---|
| PutVault | ["putvault", underlying_mint] | fixed VaultConfig; share and USDC bucket counters; the round's reads; live and settled day |
| Vault USDC | ["usdc", vault] | all of the vault's USDC |
| Depositor | ["depositor", vault, owner] | shares and one queued request |
| Epoch | ["epoch", vault, index] | one settlement's queue result: NAV, shares before, deposits, shares minted, withdrawals |
| Day | ["day", vault, expiry] | up to 6 series sharing an expiry: strike and width (bps of S and Q64.64), 11-point fair table, sold and locked per series, settlement S and payout per series, claims |
| Position | ["position", asset] | one purchase: day, series, Core asset, size, premium, collateral, time |
USDC is always in exactly one counter bucket: idle, locked (max payout of live positions), reserved (claims set aside), pending_deposits, or withdrawable. The buckets are counters, never balances, so a donation moves nothing.
Source: programs/tender_puts/src/state.rs at c18c8c1.
VaultConfig (fixed at init_vault)
| Field | Meaning | Program bound |
|---|---|---|
| pool, pool_type | main pool: PumpSwap or Meteora DLMM (Raydium AMM v4 and Orca Whirlpool are declared but return UnsupportedPool) | |
| treasury | fee recipient (its USDC account) | |
| lister | only caller of list_day; the default key makes listing open | |
| expiry_cap, series_cap | max payout (sum of W x size) per expiry and per series, USDC | above 0 |
| start_bps, floor_bps | clock multiples of fair | floor at most start, start at most 10x |
| decay_secs, sale_window | clock decay and sale window | decay above 0; window at least decay and under 1 day minus 30 min |
| band_bps | refuse sales further than this from S | at most 5% (the fair table's range) |
| min_fair_bps | fair premium at S (fair bps x width bps) must be at least this many bps of S to list | above 0, below 10,000 |
| fee_bps | protocol fee | at most 10% |
| listing_hour | UTC hour (20) | below 24 |
| twap_secs | DLMM only: read the pool oracle's time-weighted bin over this many seconds (0 = instantaneous) | at most 3,600; non-zero only for a DLMM main pool |
| check_pool, check_twap_secs, max_deviation_bps | optional DLMM pool of the same token and its TWAP window and allowed deviation | not the main pool; TWAP 1 to 3,600 s; deviation 1 to 5,000 bps |
| symbol, image_uri | NFT naming and image | symbol 1 to 10 bytes, URI at most 200 |
Whoever calls init_vault first for a mint sets its config; there is no admin afterwards. The front end lists vaults by known address.
Source: programs/tender_puts/src/state.rs VaultConfig::check at c18c8c1; SPEC-SOLANA section 3 "Open risks".
Instructions
| Instruction | Who | Effect |
|---|---|---|
| init_vault(config) | anyone, once per mint | creates the vault, its USDC account and its Core collection |
| request_deposit(amount) | depositor | USDC moves now (measured); shares are minted at the next settle_day |
| request_withdraw(shares) | depositor | shares leave the balance now; redeemed at the next settle_day |
| collect | depositor | credits a processed request, pro rata of that epoch's totals |
| record_price | anyone | one read per 20-second bucket from listing_hour - 5 min to + 15 min (up to 60). Must be the only instruction in its transaction (compute budget aside) and not a CPI. Cross-checked against the check pool if set |
| settle_day | anyone | after the reads close (or all 60 are in): S = median of at least 3 reads; the expiring day's claims are set aside, the rest of its lock freed; the queue is processed at NAV = idle USDC |
| list_day(strike_bps[], width_bps[], fair_bps[][11]) | lister | within 30 min after the reads close, after settle_day, with no live day: refused if the interquartile spread of the reads exceeds 5%; per series checks strike, width at most strike, fair values in range, fair at S at least min_fair_bps; expiry = this round's listing time + 24 h (20:00 UTC the next day) |
| buy(series, size, max_premium) | buyer | see below |
| claim | holder (non-zero claim) or anyone (zero claim) | after settlement: pays the claim in USDC to the asset's owner, burns the asset, returns rent to the holder |
Source: programs/tender_puts/src/lib.rs at c18c8c1.
buy
- Inside the sale window of an unsettled day.
- Refused if any other instruction in the transaction names the pool.
- Reads spot (and the check pool if set). Refused outside the band of S, or at or below K.
- Premium = W x size x fair(interpolated at live spot) x clock multiple, where the clock falls linearly from start to floor over decay_secs from listing.
- Refused if premium exceeds max_premium, or if premium x 4.5 is less than W x size (max payout at most 4.5x the premium).
- Refused unless series and expiry caps have room and idle USDC plus the net premium covers W x size.
- Premium in, measured: fee to the treasury, the rest to the vault.
- Mints a Core asset in the vault's collection with the terms (see Option NFTs).
Measured compute (verification run of tests-svm/tests/puts.rs at 6108c98, programs unchanged since c18c8c1): buy 145,094 CU without a check pool and 167,761 with one (commit c18c8c1's message says about 171k); record_price 16,724 (PumpSwap), 49,353 (DLMM TWAP, 163 observations) and 34,582 (PumpSwap plus DLMM check pool TWAP); claim about 153k.
Source: programs/tender_puts/src/lib.rs buy at c18c8c1; commit messages 81258bf and c18c8c1; verification run (docs/protocol/verification.md).
Price reads
- PumpSwap: (quote token account + the pool's BOOST virtual quote reserve at byte 245) / base token account, quoted in USDC or wSOL, the same pricing the pool and the launch vault use. NEET's pool has 0 virtual reserve. Fixed in a386675 (earlier builds left the reserve out).
- Meteora DLMM: the active bin, or with twap_secs the pool oracle's time-weighted bin. The oracle updates before each swap, so a same-slot move carries no weight. ANSEM, ZCAT and PAID have initialized oracles (163, 100 and 100 observations); NEET's PumpSwap pool has none.
- SOL-quoted reads are converted to USD with a Pyth SOL/USD PriceUpdateV2 passed in: Full-verified, at most 60 s old, confidence at most 1% (the engine's checks).
- Check pool: record_price and buy refuse with CheckPoolDeviation when the main pool is more than max_deviation_bps from the check pool's oracle TWAP, both in USD from the same Pyth update. NEET's vault uses PumpSwap 5wNu... as main and Meteora DLMM BkocTz... as check at 200 bps (2%). The commit message calls 200 the default; there is no on-chain default, the value is passed at init_vault.
Source: SPEC-SOLANA section 3 "As built"; tests-svm/fixtures/puts/README.md; programs/tender_puts/src/price.rs (pumpswap) at c18c8c1.
Recommended settings (report 25)
| Setting | Value |
|---|---|
| Strike | 0.98 x S |
| Width | solved so W = 3.5 x the floor premium (median 12.0% of S NEET, 13.9% ANSEM, 28.8% ZCAT and 45.6% PAID on proxy paths) |
| Fair | max(30-day fair, 72-hour-regime fair), passed as 11 points at -5% to +5% of S |
| Clock | start 3.0x, floor 1.2x, 15 min decay, 30 min window |
| Band | +/-5% |
| Caps, max payout per expiry | NEET $1.3k, ANSEM $1.8k, ZCAT $3k (pilot), PAID $4k (pilot) |
Report 25's verdict: no strike, width or floor made the originally specified clock (fixed USD price, floor 0.8) depositor-positive against disciplined buyers. Re-pricing at live spot off the higher of the two fairs with floor 1.2 makes the vault "positive or idle": disciplined and regime-aware buyers never buy; the vault sells only to buyers paying at least 1.2x fair, and every profit comes from them.
Source: 25-first-list.md TL;DR, findings 1-3, section 5; addendum.
Invariants
- Every live position is backed by W x size of USDC in the locked bucket.
- Sold max payout never exceeds series_cap per series or expiry_cap per expiry.
- The buckets always sum to the vault's accounted USDC; donations are ignored.
- The queue is processed only at settle_day, when the vault's value is all USDC, so shares never need a price.
- A claim is paid once, to the Core asset's owner at the time of claim.
- No sale outside the band, in the money, beside another instruction naming the pool, or under W / 4.5.
Source: programs/tender_puts/src/state.rs and lib.rs at c18c8c1; 19 LiteSVM tests in tests-svm/tests/puts.rs.
Why this is not the engine's rule
The engine settles by delivery so that no price is ever read for a payout. These spreads settle on a price. What bounds the damage is the cap: the vault can lose at most W x size per position and expiry_cap per day, and report 25 sized the caps so that displacing the median costs more than it can win, for a sandwich of each read (model A) and for a read that must survive arbitrage (model B).
Open risks recorded in the spec:
- a multi-transaction bundle can still sandwich reads (the median and TWAP only raise the cost);
- buy on a PumpSwap pool reads instantaneous spot (pump, buy, dump in one bundle gets a cheaper premium, bounded by the band, floor, W/4.5 and caps, not modelled); the check pool closes this for NEET;
- the first caller of init_vault sets a mint's config;
- unclaimed claims stay reserved.
Source: SPEC-SOLANA section 3 "As built" (open risks) and "Manipulation"; 25-first-list.md section 4.
Permissionless cranks
record_price, settle_day, collect and zero-value claim need no key. list_day needs the lister key, which is a trusted role (owner decision pending).
Source: programs/tender_puts/src/lib.rs at c18c8c1; SPEC-SOLANA section 3 "As built".
