# Quay pricing-DSL: feature catalog + change list

Companion to `SPEC.md` (the normative language reference). This doc is the
**at-a-glance opcode surface** plus a **triage list of opcodes/features flagged
for deletion, rename, semantic change, or doc-fix**.

- Source of truth for opcode bytes: `onchain/vm/src/opcode.rs`.
- Normative semantics: `onchain/vm/SPEC.md`.
- Runtime dispatch: `onchain/vm/src/vm.rs::evaluate`.

A curve is a stack program. Loads push values; arithmetic/lookup ops consume
and push; a terminator (`Halt`/`Reject`) ends evaluation. `Halt` leaves the Q24
`exec_price` on top of the stack. The same `evaluate` runs on-chain (BPF) and
off-chain (SDK simulator, fuzzer), so semantics are bit-identical by construction.

Limits (`opcode.rs`): `MAX_BYTECODE_LEN = 4096`, `MAX_STACK_DEPTH = 16`,
`MAX_STEPS = 1024`, `MAX_TABLE_ENTRIES = 256`.

---

## 1. Current DSL surface

### 1.1 Input variables (swap-time context)

The host fills `Inputs` before eval. These push a single value (`i128` on the
stack) and take no immediate unless noted.

| Op | Byte | Pushes |
|---|---|---|
| `LoadSize` | `0x03` | `amount_in` for this swap (saturating `i64`, see §2) |
| `LoadSide` | `0x04` | swap direction (`SIDE_SELL_BASE` vs buy-base) |
| `LoadInv` | `0x02` | **legacy.** Side-relative IN-asset inventory (saturating `i64`). See §2. |
| `LoadInvBase` | `0x2F` | base inventory, full `u64` (zero-ext) |
| `LoadInvQuote` | `0x30` | quote inventory, full `u64` (zero-ext) |
| `LoadVaultBase` | `0x2D` | base vault balance (saturating `i64`) |
| `LoadVaultQuote` | `0x2E` | quote vault balance (saturating `i64`) |
| `LoadBaseDecimals` | `0x32` | base mint decimals |
| `LoadQuoteDecimals` | `0x33` | quote mint decimals |
| `LoadNowSlot` | `0x27` | current Solana slot |
| `LoadNowUnixSec` | `0x31` | current unix time (s) |
| `LoadLastUpdateSlot` | `0x42` | slot of last swap settled vs this strategy (see §2 rename) |
| `LoadConst` | `0x05` | immediate `i64` literal |

### 1.2 Arena / oracle loads (writer-controlled, read-only)

| Op | Byte | Imm | Pushes |
|---|---|---|---|
| `LoadArenaQuote` | `0x01` | `u8` slot (0..241) | `quotes[i]` as `i32 → i128` sign-extended |
| `LoadArenaQuoteU` | `0x28` | `u8` slot | same, zero-extended |
| `LoadArenaTimestampSec` | `0x29` | — | `floor(arena_timestamp_ns / 1e9)` (seconds, by contract) |

### 1.3 Transaction-introspection loads (host-gathered `TxContext`)

Requires the Instructions sysvar account. The VM never syscalls; the host fills
`Inputs.tx`. Off-chain default is `TxContext::DIRECT`. Use for halt/widen
decisions, not for tightening (context-dependent pricing diverges from
router-cached quotes).

| Op | Byte | Imm | Pushes |
|---|---|---|---|
| `LoadIxDepth` | `0x4C` | — | invocation stack height (1 = top-level, 2 = router→quay) |
| `LoadTxFlags` | `0x4D` | — | heuristic bits: `TX_FLAG_JITO_TIP = 1<<0`, `TX_FLAG_FLASHLOAN = 1<<1` |
| `EntrypointIs` | `0x4E` | `[u8;32]` | 1 if top-level program == imm (== immediate caller iff `ix_depth <= 2`) |
| `IsSignedBy` | `0x4F` | `[u8;32]` | 1 if imm is in the tx signer set |

### 1.4 Userspace loads / store (unified MM-controlled region)

Imm is a `u32` byte offset into `Inputs.userspace`. Every access is runtime
bounds-checked (`off + width <= userspace.len()`), OOB → `UserspaceOutOfBounds`.

| Op | Byte | Width / sign |
|---|---|---|
| `LoadI8` | `0x43` | 1B sign-ext |
| `LoadI16` | `0x44` | 2B LE sign-ext |
| `LoadI32` | `0x45` | 4B LE sign-ext |
| `LoadI64` | `0x46` | 8B LE |
| `LoadU8` | `0x47` | 1B zero-ext |
| `LoadU16` | `0x4A` | 2B LE zero-ext |
| `StoreI64` | `0x48` | pop i128, cast i64, write 8B LE |

### 1.5 Stack ops

| Op | Byte | Effect |
|---|---|---|
| `Dup` | `0x09` | duplicate top |
| `Swap` | `0x0A` | swap top two |
| `Drop` | `0x0B` | pop top |
| `Pick` | `0x06` | imm `u8` depth; copy Nth-from-top to top (`Pick 0` == `Dup`) |

### 1.6 Arithmetic

| Op | Byte | Effect |
|---|---|---|
| `Add` | `0x0C` | `a + b` |
| `Sub` | `0x0D` | `a - b` |
| `Mul` | `0x0E` | `a * b` |
| `Div` | `0x0F` | `a / b` (truncate toward zero) |
| `Muldiv` | `0x10` | `(a * b) / c` via i256 intermediate |
| `Neg` | `0x11` | `-a` |
| `Abs` | `0x12` | `|a|` |
| `Sqrt` | `0x24` | integer sqrt |
| `DivCeil` | `0x3B` | ceil division |
| `Mod` | `0x3C` | remainder |

### 1.7 Fixed-point / Q-format helpers

i256 multiply intermediate; result must fit i128 or `ArithmeticOverflow`.

| Op | Byte | Imm | Effect |
|---|---|---|---|
| `MulShr` | `0x3E` | `u8 bits` | `(a * b) >> bits`, `bits ∈ [0,127]` (arithmetic shift = floor) |
| `MulQ48` | `0x3F` | — | `(a * b) >> 48`; sugar for `MulShr 48` |
| `DivQ48` | `0x40` | — | `(a << 48) / b` (truncate toward zero); `b==0` → `DivByZero` |
| `MuladdQ48` | `0x41` | — | `((a * b) >> 48) + c`; pop order c, b, a |

### 1.8 Bitwise / shifts

`And 0x13`, `Or 0x14`, `Xor 0x15`, `Not 0x16`, `Shl 0x17`, `Shr 0x18`.

### 1.9 Min / Max / Clamp

`Min 0x19`, `Max 0x1A`, `Clamp 0x1B` (`val, lo, hi → clamped`).

### 1.10 Comparisons + branchless select

`CmpLt 0x35`, `CmpGt 0x36`, `CmpLe 0x37`, `CmpGe 0x38`, `CmpEq 0x39` (push 0/1).
`Select 0x3A`: pop b, a, cond → `cond != 0 ? a : b`.

### 1.11 Tables (lookup in userspace)

Table header is `N as i64 LE` (1 ≤ N ≤ 256). Bounds + strict-ascending threshold
check at every lookup; malformed → `TierTableMalformed`.

| Op | Byte | Imm | Row | Effect |
|---|---|---|---|---|
| `TierLookup` | `0x1C` | `u32 off` | 16B (thresh, val) | step-function: `v_j` where `key < t_{j+1}` |
| `LerpLookup` | `0x3D` | `u32 off` | 16B (thresh, val) | linear interpolation between rows |
| `TierLookupDyn` | `0x26` | — | 16B | like `TierLookup` but pops `key` AND `table_off` |
| `TierLookup2` | `0x25` | `u32 off` | 24B (thresh, val_a, val_b) | pushes `val_a` then `val_b` (val_b on top) |

### 1.12 Control flow (forward-only jumps)

`IfLt 0x1D`, `IfGt 0x1E`, `IfLe 0x1F`, `IfGe 0x20`, `IfEq 0x21`, `Jmp 0x22` —
all take `i16` forward offset from after the instruction. Negative or
past-end → `InvalidJumpTarget`. Forward-only + bounded bytecode = termination.

### 1.13 Terminators + assertions

| Op | Byte | Imm | Effect |
|---|---|---|---|
| `Halt` | `0x23` | — | end OK; top of stack = Q24 `exec_price` |
| `Reject` | `0x34` | `u8` reason | abort; on-chain → `Custom(0xC000 \| reason)` |
| `AssertNonzero` | `0x49` | `u8` reason | pop v; `Reject(reason)` if v == 0. Sugar for `<cmp> → IfEq → Reject` |

Retired byte: `0x4B` (`HaltQ48`) — do not reuse.

---

## 2. Flagged for deletion / rename / change

Each item lists the **classification**, the **rationale**, and the **source**
(a redteam finding id under `onchain/redteam/findings/`, or a product/ergonomic
call made outside the security review). Security findings concluded **no opcode
needs deletion for safety**; the deletion below is a product decision about an
unused legacy op.

### 2.1 DELETE / DEPRECATE

**`LoadInv` (`0x02`) — delete or hard-deprecate.** *(product call)*
- Annotated `// side-relative inventory (legacy)` in `opcode.rs:27`; lowest
  opcode byte (oldest in the table).
- Pure sugar: equals `LoadSide` + select between `LoadInvBase`/`LoadInvQuote`.
- No real curve uses it. Every reference outside the VM core (`vm.rs`,
  `wire.rs`, `opcode.rs`) is a redteam test/finding, not an example/SDK/deck curve.
- It is the primitive behind two findings:
  - **rt_d2_3** — side-relativity lets a winning MM craft a two-sided curve
    whose real eval and opposite-side (agg) eval diverge, under-charging.
  - **rt_d1_11** — goes through `to_i64`, so an 18-decimal book above
    `i64::MAX` atoms **saturates**, while `LoadInvBase`/`LoadInvQuote` keep the
    full `u64`. Same logical inventory, two values depending on opcode choice.
- Both failure modes vanish if curves use the explicit base/quote variants
  (direction-agnostic + full-range u64).
- **Caveat / blocker:** the security review classifies the saturation as
  by-design (two-tier API). Removal is safe **only if no live mainnet strategy
  bytecode uses byte `0x02`**. Verify against `QUayE6…ArG` before removing;
  otherwise hard-deprecate (reject `0x02` in new `init_strategy`, keep the
  dispatch arm for existing accounts).

**`LoadVaultBase` (`0x2D`) / `LoadVaultQuote` (`0x2E`) — delete or hard-deprecate.** *(product call)*
- A curve should price off **MM inventory** (`LoadInvBase`/`LoadInvQuote`), not
  the raw vault token balance. Vault balance is the wrong economic signal: it
  includes amounts that are not the strategy's tradable book (e.g. shared-vault
  funds, in-flight/unsettled amounts), so pricing on it is misleading.
- Redundant with the inventory loads, which are the values curves actually want.
- Also carry the saturation footgun (rt_d1_12): declared `i64` in the ABI, so
  high-decimal vaults saturate at `i64::MAX` while the inventory loads keep full
  `u64`. rt_d1_12 closed this as by-design *for the saturation question* — the
  removal here is a separate decision: the opcodes shouldn't exist because vault
  balance isn't a pricing input.
- **Caveat / blocker:** same as `LoadInv` — safe to remove only if no live
  mainnet strategy uses bytes `0x2D`/`0x2E`; otherwise hard-deprecate at
  `init_strategy` and keep the dispatch arms for existing accounts.

### 2.2 RENAME

**`LoadLastUpdateSlot` (`0x42`) → `LoadLastTradeSlot`.** *(ergonomic, non-breaking)*
- The value is `clock.slot` written after each settled swap (`swap.rs:477`);
  the field comment already calls it "slot of the last swap settled against this
  strategy" (`state.rs:122`). `Update` falsely implies an oracle/arena refresh —
  the arena carries no slot, only `arena_timestamp_ns`.
- Non-breaking: opcode byte and semantics unchanged; identifier + doc only.
- Rename surface: `opcode.rs:39` (enum) + `:241` (`from_byte`); `vm.rs:255`
  (dispatch); `cu_estimate.rs:36`, `opcode.rs:319/544` (op-class lists);
  `SPEC.md` table; `tests.rs:27/116`. Optional: `StrategyHeader.last_update_slot`
  (`state.rs:123`) + its writes/reads in `swap.rs` (state-field, leave unless
  full consistency wanted).

**Arena → quotes opcode/field rename.** *(ergonomic, non-breaking; aligns VM with the renamed account)*
- The account was renamed to the **quotes account** on the program side
  (`QuotesHeader`, `quotes_account`, `load_quotes`, `QUOTES_NUM_SLOTS`), but the
  VM/opcode layer still says "arena." Same account, two names across crates —
  this is exactly what made `LoadArenaTimestampSec` confusing (it reads the
  quotes-account timestamp, nothing to do with a separate "arena").
- Non-breaking: opcode bytes unchanged; identifiers + comments only.

  | Current | → Proposed |
  |---|---|
  | `LoadArenaQuote` (`0x01`) | `LoadQuote` |
  | `LoadArenaQuoteU` (`0x28`) | `LoadQuoteU` |
  | `LoadArenaTimestampSec` (`0x29`) | `LoadQuotesTimestampSec` |
  | `Inputs.arena_timestamp_sec` (`vm.rs:171`, `wire.rs:56`) | `quotes_timestamp_sec` |
  | `ARENA_NUM_SLOTS` (`vm.rs:72`, `wire.rs:14`) | `QUOTES_NUM_SLOTS` (unify with state) |

- Rename surface: `opcode.rs` (enum + `from_byte` + op-class lists),
  `vm.rs` (dispatch + Inputs field + comments), `wire.rs` (field + offsets doc),
  `cu_estimate.rs`, `tests.rs`, `SPEC.md`.

### 2.3 CHANGE SEMANTICS

**`LoadSize` (`0x03`) — source full `u64`, not saturating `i64`.** *(product call; supersedes rt_d1_10 by-design)*
- Today `LoadSize` exposes `to_i64(amount_in)`, saturating at `i64::MAX`, while
  settlement uses the full `u64 amount_in`. So the value the curve prices on can
  diverge from the value actually settled for large sizes.
- Should source `amount_in` as `u64` and zero-extend into the i128 stack (same
  shape as `LoadInvBase`/`LoadInvQuote`), so the curve always sees the exact
  size that settles. For `amount_in <= i64::MAX` the value is identical; only
  the saturation cliff changes, so impact is confined to extreme sizes.
- rt_d1_10 closed this as by-design (taker still pays full `amount_in` and gets
  fair OUT). This is a separate ergonomic/correctness call: a pricing input
  should match the settled quantity, not silently cap.
- Implementation note: changes the value seen at `amount_in > i64::MAX`, so
  treat as a breaking VM change and bump `StrategyHeader.dsl_version`.

**`LoadSide` (`0x04`) — narrow source type to `u8` (or a bool-like enum).** *(ergonomic / type-correctness)*
- `side` is a 0/1 flag (`SIDE_SELL_BASE` vs buy-base) but is sourced as `i64`
  in `Inputs` (`vm.rs:162`). The full `i64` width is meaningless for a
  two-valued field and invites curves to treat it as an arbitrary integer.
- Source as `u8` (zero-extended into the i128 stack); value seen by curves is
  unchanged (still 0/1), so this is non-breaking at the bytecode level. Tightens
  the ABI and documents intent.

**Rounding-convention split: `MulShr`/`MulQ48`/`MuladdQ48` (floor) vs
`Div`/`Muldiv`/`DivQ48` (truncate-toward-zero).** *(rt_d1_5, low — skeptic
downgraded to false-positive on value extraction, ergonomic gap stands)*
- Arithmetic right shift floors toward −∞; integer division truncates toward 0.
  On curves with negative intermediates this is a 1-LSB bias depending on which
  scaling op you reach for.
- Options: unify on one convention with a VM parity proptest, OR document the
  per-op rounding guarantee uniformly so authors can reason about it. No
  correctness bug; this is consistency/ergonomics.

**Table-lookup CU cost is flat, not N-proportional.** *(rt_d2_12, medium —
confirmed; cost-model, not opcode semantics)*
- `cu_estimate` flat-costs `TierLookup`/`LerpLookup`/`TierLookup2` at ~80–110 CU
  regardless of N, but the VM walks up to 256 rows per lookup. A curve packed
  with large tables fits the bytecode/step limits while the estimate undercounts
  real CU by >27x in the test.
- Fix at the cost-model level: scale `op_cost` for table lookups (and i256
  divide ops) by the declared row count / `MAX_TABLE_ENTRIES`. Opcode behavior
  is correct; only the estimator needs calibration.

### 2.4 DOC-FIX

**`LoadLastUpdateSlot` description in `SPEC.md`.** Table currently reads
"slot of last oracle/arena update" — misleading (see §2.2). It is last
**settled swap**, not an oracle refresh. Fix wording even if the rename is
deferred.

**`LerpLookup` (`0x3D`) rounding.** *(rt_d1_4, low — false-positive)* Truncates
toward zero on all segments (including decreasing ones). Behavior is correct;
SPEC could state the rounding direction explicitly so authors don't assume floor.

**`LerpLookup` (`0x3D`) early-return validation gap.** *(rt_d1_6, medium-claimed
— pre-existing, already covered by `audit_tier_lookup_partial_ascending_validation`)*
The `key <= t0` and `key >= last_t` early returns skip per-row ascending
validation. Document that off-chain validators must not assume "malformed table
always errors" — early-return keys can succeed on an otherwise-malformed table.

**`LoadArenaTimestampSec` (`0x29`) granularity.** *(rt_r2_5, medium-claimed —
false-positive)* It is seconds-granularity by contract (`floor(ns / 1e9)`), as
named. Clarify in SPEC/SDK that it is not a nanosecond nonce. If full-resolution
replay gating is ever needed, add a separate `LoadArenaTimestampNanos`
(feature request, not a fix).

### 2.5 ADD

**`LoadU64` — zero-extending 8-byte userspace load.** *(closes a real gap)*
- Userspace loads today are `LoadI8/16/32/64` (sign-ext) + `LoadU8`/`LoadU16`
  (zero-ext). There is no unsigned 8-byte load, so a userspace field that uses
  the full `u64` range (bit 63 set) **cannot be read as unsigned**: `LoadI64`
  sign-extends it negative, and you can't cheaply mask it back, because the
  `0xFFFF_FFFF_FFFF_FFFF` mask is `-1` as an `i64` immediate (same root issue as
  `LoadConstU`). Recovering u64 needs ~5 ops to build the mask before an `And`.
- Consistent with the session-wide direction: quantities should be readable as
  `u64` without a sign/saturation footgun (cf. `LoadSize`→u64, vault-load
  removal). A u64 with bit 63 set is >9.2e18 atoms (>9.2 B tokens @ 9 dec) —
  exactly the high-decimal regime where the i64 path bites; AMM reserves /
  large thresholds stored in userspace need this.
- Assign a free opcode byte (`0x50+`; `0x4B` is retired-reserved).

**`LoadU32` — zero-extending 4-byte userspace load.** *(symmetry / ergonomics)*
- Strictly composable today: `LoadI32 off; LoadConst 0xFFFFFFFF; And` yields the
  zero-extended u32 (the mask fits a positive `i64` immediate). So unlike
  `LoadU64` this closes no hard gap.
- Worth adding anyway for **symmetry and clarity**: completes the I8/I16/I32/I64
  + U8/U16/U32/U64 grid so curve authors don't hand-roll the mask idiom, and
  the unsigned intent is explicit in the bytecode. One opcode, ~3 CU, trivial.

Note: `LoadConstU` (zero-extending `u64` immediate) is the matched pair for the
constant case (§ earlier discussion). Add it alongside `LoadU64` if u64-range
literals are needed; otherwise it stays composable-but-clunky like the masks
above.

**128-bit userspace loads — not adding.**
- `LoadU128`: **rejected (stack-width).** The stack is `i128`; a `u128` with bit
  127 set has no i128 representation, so the op is incoherent.
- `LoadI128`: **skipped.** Coherent (matches stack width) but unneeded. The
  motivating case (`a*b=k` constant product) should keep reserves (each ≤64-bit)
  and use `Muldiv` on the i256 intermediate, never materializing `k` — a wider
  load doesn't raise the i128 arithmetic ceiling. Revisit only if a curve must
  persist a genuine full-width 128-bit constant (e.g. a large Q-format
  coefficient).

Resulting width ladder caps at the stack width — signed up to 128, unsigned up
to 64 (largest that fits i128 as positive):
```
signed:   I8  I16  I32  I64  I128(skip)
unsigned: U8  U16  U32  U64
```

### 2.6 Reviewed, NO CHANGE (by-design)

Recorded so they aren't re-litigated. All downgraded to by-design / false-positive
by the security review.

| Finding | Opcode / feature | Why no change |
|---|---|---|
| rt_d1_10 | `LoadSize` (`0x03`) saturation | taker pays full `amount_in`, gets fair OUT; saturation documented + proptested. NB: separate product decision to source full `u64`, see §2.3 |
| rt_d1_11 | `LoadInv` vs `LoadInvBase`/`LoadInvQuote` | deliberate two-tier API (see §2.1 for the separate product decision to drop `LoadInv`) |
| rt_d1_12 | `LoadVaultBase`/`LoadVaultQuote` (`0x2D`/`0x2E`) saturation | i64 by published ABI; full-precision inv loads exist; saturation safer than wrap. NB: separate product decision to remove these entirely, see §2.1 |
| rt_d1_13 | decimals in pricing | program delegates decimal normalization to the curve by design |
| rt_e1_0 | protocol-fee half-spread in OUT atoms | `settle_inventory` `checked_sub` keeps I1 solvency exact |
| rt_e1_11 | `LoadArenaQuote` vs `LoadArenaQuoteU` | fee = spread of the two prices the curve actually emits; no independent "true spread" |
