# Quay Pricing DSL — Spec

A tiny stack VM. MMs encode their pricing curve as bytecode; the on-chain
`swap` ix evaluates it to compute the executable price. The same VM, compiled
as a standalone Rust crate, lets routers and MM dashboards simulate prices
off-chain with bit-identical semantics.

**Status:** shipped. Reference impl: [`onchain/vm/src/vm.rs`](src/vm.rs).
Opcode registry: [`onchain/vm/src/opcode.rs`](src/opcode.rs). Error mapping to
on-chain `ProgramError::Custom`:
[`onchain/program/src/instructions/swap.rs::map_vm_error`](../program/src/instructions/swap.rs).

---

## 1. Execution model

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

Design pillars:

- **Per-MM arbitrary curve shape** (not a fixed template).
- **Off-chain simulable** by any client without CPI or validator emulation.
- **Bounded compute** at swap time (`MAX_STEPS = 1024`, forward-only jumps).
- **Deterministic** across on-chain and off-chain runs.
- **Sandboxed by slice bounds**: bytecode can only read/write the `userspace`
  slice it was handed; every access is bounds-checked at runtime.
- **No install-time validator**: bytecode is stored as-is. Bad bytecode fails at
  first swap with a `RuntimeError`; the VM never crashes the host program.

The swap path skims the protocol fee from `amount_in` *before* pricing and runs
`evaluate` once on the net input — see §7. The curve never sees the fee.

## 2. Limits

| Constant | Value |
|---|---|
| `MAX_BYTECODE_LEN` | 4096 |
| `MAX_STACK_DEPTH` | 16 |
| `MAX_STEPS` | 1024 |
| `MAX_TABLE_ENTRIES` | 256 |
| quotes blob | `i32[QUOTES_NUM_SLOTS]`, 241 slots (964 B) |

## 3. Types & signedness model

There is exactly **one value type on the stack: `i128`**. There is no separate
unsigned type and no cast opcode. "Signed vs unsigned" is decided **once, at
load**, by sign-extend vs zero-extend. Three layers:

1. **Stack — signed `i128`.** Needed for arithmetic: `Sub`/`Neg`/`Abs`,
   inventory skew (`inv - target`), signed multiply/divide. Keep signed.
2. **Inputs — quantities load unsigned (`u64`/`u8`, zero-extend).** Sizes,
   inventories, slots, timestamps, decimals are never negative; loading them
   `u64` removes the saturation/sign footgun. A `u64` zero-extended into `i128`
   is always a positive `i128` (u64 max ≈ 1.8e19 ≪ i128 max ≈ 1.7e38), so signed
   `CmpLt`/`Min`/`Max` behave as unsigned on them. The only signed inputs are
   `LoadConst` (curve params may be negative) and the signed quote loads
   (`LoadQuote`, `LoadQuoteI64`).
3. **Output — non-negative, fits `u64`.** `Halt` pops the stack top as
   `amount_out` and range-checks it into `u64` (negative or `> u64::MAX` →
   `ResultOverflowsU64`). The swap additionally requires `out_to_taker > 0`
   (a zero fill fails).

Sign-aware ops to remember: `Shr` is **arithmetic** (sign-preserving) and `Not`
flips all 128 bits — both act on the full signed width.

**Load widths are 64-bit, except where the data dictates otherwise:**
- **Userspace**: `LoadI64` / `LoadU64` only (§5.4) — we control the layout, so
  one value per 8-byte slot, no narrow loads.
- **Quotes**: i32/u32 single-box + i64/u64 two-box (§5.1) — the account *is* an
  `i32[]`, so the box width is fixed by the data.
- **Scalar inputs**: semantic widths (`u8` for `LoadSide`, decimals) — width is
  the field's meaning, not a choice.

No 128-bit load exists (`i128` is the stack type itself; a `u128` with bit 127
set has no `i128` representation, so `LoadU128` is impossible).

## 4. Overflow & AMM safety

- Bare `Add`/`Mul` are **checked i128** → `ArithmeticOverflow` on overflow,
  never wrap (release builds run `overflow-checks = true`).
- `Muldiv`, `MulShr`, `MulQ48`, `DivQ48`, `MuladdQ48` compute the multiply in an
  **i256 intermediate**; only the final result must fit `i128`.
- Practical rule: a bare `Mul` is safe while each operand ≤ ~1.30e19
  (`sqrt(i128::MAX)`), i.e. ~13 B tokens/side at 9 decimals. For constant-product
  math, **keep reserves (each ≤ 64-bit) and use `Muldiv`** — e.g.
  `dy = Muldiv(y, dx, x+dx)` — so the product lives in i256 and the result
  (bounded by reserves) fits i128. Do not materialize `k = x·y`.

## 5. Opcodes

### 5.1 Quote loads (from the quotes account; read-only)

The quotes account is a row of 241 boxes, each an `i32` (LE). Single-box loads
take a `u8` box index (0..240); the 2-box load takes the **low** box index and
reads `[slot, slot+1]`. Combining rule: the low box contributes raw 32 bits
(masked to u32, never sign-extended); the high box sets the sign.

| Op | Byte | Imm | Effect |
|---|---|---|---|
| `LoadQuote` | `0x01` | `u8` slot | box as **i32**, sign-extended |
| `LoadQuoteU` | `0x28` | `u8` slot | box as **u32**, zero-extended |
| `LoadQuoteU64` | `0x52` | `u8` slot | `(box[slot] as u32) \| ((box[slot+1] as u32 as u64) << 32)` as **u64**, zero-extended. Bounds: `slot+1 < num_slots` |
| `LoadQuoteI64` | `0x53` | `u8` slot | as above but high box is **signed** → **i64**, sign-extended |
| `LoadQuotesTimestampSec` | `0x29` | — | `floor(quotes_timestamp_ns / 1e9)` as **u64**, **seconds by contract** |
| `LoadQuotesTimestampNanos` | `0x54` | — | `quotes_timestamp_ns` as **u64**, full nanosecond resolution (the source value; Sec is the truncated view) |

> Writer contract: with the 2-box loads the blob is packed LE bytes, not just an
> array of `i32` mids. The quotes-account writer must place fields at the box
> offsets curves expect.
>
> Timestamp overflow note: `LoadQuotesTimestampNanos` returns ~1.7e18-scale
> values. Multiplying a raw ns timestamp by anything sizable overflows i128 fast
> — subtract first (`now_ns - last_ns`, a small delta), then scale.

### 5.2 Scalar inputs (swap-time context)

| Op | Byte | Imm | Type | Pushes |
|---|---|---|---|---|
| `LoadSize` | `0x03` | — | `u64` | `amount_in` (full, no saturation) |
| `LoadSide` | `0x04` | — | `u8` | direction (0 = `SIDE_SELL_BASE`, 1 = buy-base) |
| `LoadInvBase` | `0x2F` | — | `u64` | base inventory |
| `LoadInvQuote` | `0x30` | — | `u64` | quote inventory |
| `LoadBaseDecimals` | `0x32` | — | `u8` | base mint decimals |
| `LoadQuoteDecimals` | `0x33` | — | `u8` | quote mint decimals |
| `LoadNowSlot` | `0x27` | — | `u64` | current Solana slot |
| `LoadNowUnixSec` | `0x31` | — | `u64` | current unix time (s) |
| `LoadLastTradeSlot` | `0x42` | — | `u64` | slot of the last swap settled vs this strategy |
| `LoadConst` | `0x05` | `i64` | `i64` | immediate literal (the one signed input) |

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

Requires the Instructions sysvar account. The VM never syscalls; the host fills
`Inputs.tx` (off-chain default `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` | — | `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 |

### 5.4 Userspace loads / store (MM-controlled region)

Userspace is organized as **8-byte slots — one value per slot, no sub-byte
packing**. This collapses the load/store set to a single width (64-bit) and
removes narrow loads entirely; sign is chosen at load. Imm is a `u32` byte
offset; access is runtime bounds-checked (`off + 8 ≤ userspace.len()`); OOB →
`UserspaceOutOfBounds`.

| Op | Byte | Effect |
|---|---|---|
| `LoadI64` | `0x46` | 8B LE, **signed** (sign-ext to i128) |
| `LoadU64` | `0x50` | 8B LE, **unsigned** (zero-ext to i128) |
| `StoreI64` | `0x48` | pop i128, range-check `[i64::MIN, i64::MAX]` (else `ResultOverflowsI64`), write 8B LE |
| `StoreU64` | `0x55` | pop i128, range-check `[0, u64::MAX]` (else `ResultOverflowsU64`), write 8B LE |

Loads and stores come in matched **signed/unsigned pairs**. For an in-range
value both stores write identical bytes — the difference is the range each
validates, so each enforces its slot's intended sign (`StoreI64` rejects values
above i64, `StoreU64` rejects negatives). To read a sub-field, store it in its
own slot — do not pack. (Trade-off: ~8 B min per field vs a dense packed layout;
worth it for a 3-op load set and zero masking footguns.)

### 5.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`) |

### 5.6 Arithmetic

| Op | Byte | Effect |
|---|---|---|
| `Add` | `0x0C` | `a + b` (checked) |
| `Sub` | `0x0D` | `a - b` (checked) |
| `Mul` | `0x0E` | `a * b` (checked i128) |
| `Div` | `0x0F` | `a / b`, **truncate toward zero** |
| `Muldiv` | `0x10` | `(a*b)/c`, i256 intermediate |
| `Neg` | `0x11` | `-a` |
| `Abs` | `0x12` | `\|a\|` |
| `Sqrt` | `0x24` | integer floor-sqrt |
| `DivCeil` | `0x3B` | ceil division |
| `Mod` | `0x3C` | remainder |
| `Pow` | `0x56` | `base ^ exp`; pop order exp, base; `exp` non-negative and ≤ `u32::MAX`, else `ArithmeticOverflow`; i128 result must fit |

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

i256 multiply intermediate; result must fit i128 or `ArithmeticOverflow`.
**Rounding:** these use arithmetic right shift → **floor toward −∞** (note the
difference vs `Div`/`Muldiv`/`DivQ48`, which truncate toward zero; matters only
for negative intermediates).

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

### 5.8 Bitwise / shifts

`And 0x13`, `Or 0x14`, `Xor 0x15`, `Not 0x16` (flips all 128 bits),
`Shl 0x17`, `Shr 0x18` (arithmetic). Shift amount outside `[0,127]` →
`InvalidShiftAmount`.

### 5.9 Min / Max / Clamp

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

### 5.10 Comparisons + branchless select

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

### 5.11 Tables (lookup in userspace)

Header `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 (truncate toward zero on all segments) |
| `TierLookupDyn` | `0x26` | — | 16B | like `TierLookup`; 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) |

> Note: `LerpLookup` early returns for `key ≤ t0` / `key ≥ last_t` skip per-row
> ascending validation, so an otherwise-malformed table can succeed on
> early-return keys. Off-chain validators must not assume "malformed ⇒ always
> errors."

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

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

### 5.13 Terminators + assertions

| Op | Byte | Imm | Effect |
|---|---|---|---|
| `Halt` | `0x23` | — | end OK; top of stack = `amount_out` (OUT-token atoms), range-checked into `u64` (**swap requires > 0**) |
| `Reject` | `0x34` | `u8` reason | abort → `Custom(0xC000 \| reason)` |
| `AssertNonzero` | `0x49` | `u8` reason | pop v; `Reject(reason)` if v == 0 |

### 5.14 Retired opcode bytes (never reuse)

`0x02` (`LoadInv`), `0x2D` (`LoadVaultBase`), `0x2E` (`LoadVaultQuote`),
`0x43` (`LoadI8`), `0x44` (`LoadI16`), `0x45` (`LoadI32`), `0x47` (`LoadU8`),
`0x4A` (`LoadU16`), `0x4B` (`HaltQ48`, retired earlier).

## 6. Settlement

`Halt` leaves `amount_out` (OUT-token atoms) on top of the stack. The curve has
already folded in `size`, `side`, the mid, and any decimal scaling, so the swap
credits the taker that amount verbatim — there is no post-VM price scaling. The
swap requires `out_to_taker > 0`; a zero (or over-`u64`) fill fails.

## 7. Input-derived protocol fee (outside the VM)

Curves do **not** declare a fee. The swap path skims
`fee = amount_in * protocol_fee_bps / 10_000` (in IN-token atoms) off the input,
prices the curve on the net input (`size = amount_in - fee`), and accrues `fee`
to the **IN-side** asset's `protocol_fee_amount`. The taker receives
`out_to_taker = evaluate(size = amount_in - fee)` and pays the full `amount_in`;
the MM inventory and the swept fee together balance the vault deposit, so the
solvency invariant holds on both sides. `protocol_fee_bps == 0` is the no-fee
fast path (`size = amount_in`). The curve never sees the fee.

## 8. Error namespace

`Reject(reason)` maps to `Custom(0xC000 | reason)` (`0x00..0x7F` protocol-named,
`0x80..0xFF` MM-custom). Runtime faults map to the `0xCE00` band (see
`map_vm_error` in `swap.rs`):

| Code | RuntimeError | Meaning |
|---|---|---|
| `0xCE00` | `ArithmeticOverflow` | i128 op overflow |
| `0xCE01` | `DivByZero` | divisor 0 |
| `0xCE02` | `ResultOverflowsI64` | `StoreI64` value outside i64 range |
| `0xCE03` | `StepLimitExceeded` | hit `MAX_STEPS` |
| `0xCE04` | `InvalidShiftAmount` | shift outside `[0,127]` |
| `0xCE05` | `UserspaceOutOfBounds` | userspace read/write past `userspace.len()` |
| `0xCE06` | `QuoteIndexOutOfBounds` | quote box index ≥ `num_slots` |
| `0xCE07` | `InvalidJumpTarget` | jump backward or past bytecode end |
| `0xCE08` | `TierTableMalformed` | bad table header / non-ascending thresholds |
| `0xCE09` | `StackOverflow` | push past `MAX_STACK_DEPTH` |
| `0xCE0A` | `StackUnderflow` | pop on empty stack |
| `0xCE0B` | `HaltNotReached` | ran off the end without `Halt`/`Reject` |
| `0xCE0C` | `UnknownOpcode` | undecodable opcode byte (incl. retired bytes) |
| `0xCE0D` | `TruncatedImmediate` | immediate runs past bytecode end |
| `0xCE0E` | `ResultOverflowsU64` | `Halt` `amount_out` or `StoreU64` value outside `[0, u64::MAX]` |

## 9. Versioning

`StrategyHeader.dsl_version` records the VM version a strategy was created under
(`CURRENT_DSL_VERSION`). Forward-compat guard at swap:
`strategy.dsl_version > CURRENT_DSL_VERSION` → `UnsupportedDslVersion`. Retired
opcode bytes are never reused, so a future removal fails cleanly
(`UnknownOpcode`) rather than silently re-interpreting old bytecode.

## 10. CU budget

~3–15 CU per opcode. Table lookups scale with row count N (cost model calibrated
to `MAX_TABLE_ENTRIES`, not a flat per-call charge): `TierLookup`/`TierLookup2`/
`LerpLookup` ~O(N). `Muldiv`/`DivQ48` ~200 (i256 division); `MulShr`/`MulQ48`
~80; `MuladdQ48` ~85; `Pow` ~120 (square-and-multiply). Use
`quay_vm::cu_estimate` for a conservative bound.
