# Fable re-audit: Slawth Vawlt program v1.4

**Auditor:** Fable (Anthropic Claude Fable 5.1, independent adversarial review agent) · **Date:** 2026-09-08 · **Scope:** commit `5d91406` (v1.4): every finding of the two v1.2 audits re-verified against the code, plus a hunt for issues introduced by the fixes · **Method:** read-only, report unedited below. Fixes for the new findings shipped as v1.5 and are tracked on the site's audits page; N1 was settled by a test that runs Raydium's real CLMM program against the pool snapshot (`programs/slawth-vawlt/tests/test_observation.rs`).

---

Read-only review of `programs/slawth-vawlt/src/**`, `tests/**`, `test-programs/mock-router`, `scripts/vawlt/lib.mjs`, `scripts/keeper/*`, the two v1.2 reports, the status page and the spec revision note. Line numbers are from the current working tree (program sources are unmodified vs HEAD; only shell scripts show as modified).

## Summary

v1.4 closes both structural theft vectors from v1.2. The keeper-supplied Jupiter `route` is now signed only by the per-market PDA `["swap", market]`, which owns nothing but that market's transit ATA; the delegate PDA `["authority"]` signs only the two `transfer_checked` calls and never enters the CPI; route slots 1/2/6 are bound, only the plain `route` discriminator is accepted, signer flags are recomputed, and the user's outcome is still pinned by `user_target_ata` delta ≥ threshold plus an exact transit post-condition. I found no way for a keeper to move anything beyond `in_amount` out of transit, to touch a fee vault or another user, or to pay a user less than reference×(1−cap−tolerance). The reference price is now guarded by a 5-minute observation TWAP that is directionally correct for reward=token_0 and token_1 on both CLMM and CPMM, but its soundness rests on an assumption no test exercises (that Raydium writes the *pre-swap* tick/price into the observation) and its 5-minute window makes a two-transaction manipulation on a thin pool economically plausible, bounded by aggregate pending rewards. Of the 19 prior findings, 14 are complete, 2 partial (AST-01's "observation must predate the transaction" element; F7's documentation half), 3 accepted as designed.

**Recommendation: deploy v1.4 and run the first real compounds — funds-safety does not depend on the open items. Do NOT burn the upgrade authority yet.** Gate the burn on (a) a mainnet-fork test proving a same-transaction Raydium swap cannot move the TWAP (N1), and (b) a conscious decision to freeze a 5-minute window, the pinned Jupiter/Raydium account layouts, and an irreplaceable admin key (N2, N5, N6). A timelocked multisig is a better end state than a burn.

## Prior findings

| ID | Verdict | Evidence in v1.4 | Residual risk |
|---|---|---|---|
| F1 Over-privileged CPI signer | **COMPLETE** | `compound.rs:31-33` swap_authority by seeds; `:114-115` two distinct seed sets; `:120-149` delegate signs only fee/transit transfers; `:222-225` `build_route_ix(.., swap_authority)` + `invoke_signed(swap_seeds)`; `jupiter.rs:57` `is_signer = key == signer`; transit = ATA(swap_authority) `add_market.rs:36-40`, fee vault owner = authority `:41-45`. Tests `test_compound.rs:323-342, 346-363`, `test_admin.rs:45-65`. | swap_authority may also own Jupiter intermediate ATAs (dust/donations only). Slots 3/4/5 unbound — a keeper can redirect output only by paying the user ≥ threshold itself (economically identical to the accepted slippage-cap capture). |
| F2 Spot reference | **COMPLETE** (with material residuals) | `raydium.rs:89-116` CLMM TWAP, `:171-199` CPMM; `compound.rs:176-182` (`spot ≥ twap−tol` for token_0, `spot ≤ twap+tol` for token_1 — both adverse directions right), `:202-207`; `constants.rs:17-19`. Tests `test_compound.rs:391-424`, `test_cpmm.rs:147-180`, `raydium.rs:230-268`. | N1 (unverified Raydium write ordering), N2 (5-min window). Guard degrades silently to spot-only when the newest observation is >1 h old (`compound.rs:176 if let Some`), safe only under pre-swap recording. |
| F3 Transit donation DoS | **COMPLETE** on-chain | `jupiter.rs:34-40` `in_amount ∈ [contributed, swap_in]`; `compound.rs:119, 152-157` delta-based contribution; `:231-232` `transit == swap_in − in_amount`. Test `test_compound.rs:368-386`. | Shipped keeper always folds the whole surplus into `swapIn` (N3). |
| F4 `u64::MAX` + upgradeable | **ACCEPTED (unchanged)** | `enroll.rs:52` still `u64::MAX`; disclosed `site/src/content/copy.ts:127,138,150`; mitigation `freeze.sh:18`. | Full delegated balances exposed to the upgrade key until the burn; after the burn, bugs are permanent (N6). |
| F5 Sibling-market revoke | **COMPLETE** | `unenroll.rs:36-48` (`revoke` flag; only the vawlt's delegate); `enroll.rs:69-100` reapprove. Tests `test_enroll.rs:105-116, 119-142`. | Client must pass `revoke=false` deliberately; CLI defaults to revoke (`lib.mjs:199`). |
| F6 Config binding / re-check | **COMPLETE** | `add_market.rs:60` owners, `:62-67` discriminators, `:71-72/77-78` pool↔config↔observation both ways; `compound.rs:164-167` owners re-checked. Test `test_admin.rs:102-139`. | Status page says "account types re-checked on every compound"; only owners are (N10). Immaterial: addresses are immutable and Raydium-owned. |
| F7 Floor footguns | **PARTIAL** | Code half done: `enroll.rs:69-100` reapprove leaves floor untouched (test `test_enroll.rs:119-136`). Doc half: no copy in `site/src` mentions that re-enrolling resets the floor to the current balance (`enroll.rs:62`); only the "never drops on its own" line (`copy.ts:81`). | UX only. |
| F8 CLMM ignores impact | **COMPLETE (as accepted, off-chain)** | `compound.rs:96-97` explicit bounded `amount`; keeper halving `lib.mjs:510-523`. | If even `min_compound` cannot clear the floor, the wallet stalls until liquidity improves (admin can lower `min_compound`). |
| F9 Gross stats | **ACCEPTED (unchanged)** | `compound.rs:238-244`. | Cosmetic. |
| AST-01 Manipulable reference | **PARTIAL** | Same code as F2. Astra asked to "require an observation predating the compound transaction or slot"; the guard accepts an observation written in the same slot (`raydium.rs:97-99` only checks staleness). | Harmless iff Raydium records the pre-swap tick (N1); fatal otherwise. Window residual N2. |
| AST-02 Global signer / unbound graph | **COMPLETE** | F1 evidence plus `jupiter.rs:45-51` slots 1/2/6, `:19-22` plain `route` only (`shared_accounts_route` refused: test `test_compound.rs:221`), `:57` signer stripping. Tests `test_compound.rs:229-249`. | Slots 3/4/5 unbound (see F1). Relies on Jupiter only CPI-ing allowlisted AMM adapters — inherent to relaying Jupiter. |
| AST-03 Upgrade authority | **ACCEPTED** | `initialize.rs:9-14`; plan `freeze.sh`; `runbook-vawlt-v1.md:78`. | Window until burn; after burn see N5/N6. Runbook `:83` still describes v2 as an in-place upgrade + timelock — contradicts the burn plan. |
| AST-04 Transfer-fee math | **COMPLETE** | `compound.rs:66-77` `calculate_epoch_fee(epoch, amount)` (bps + `maximum_fee`, current epoch); `:170-172` `venue_in = in_amount − fee`; `:214-216` target fee off `after_pool`. bps-only helpers in `math.rs:24-30,64-74` are test-only now. | `maximum_fee`-binding case untested (gap). Multi-hop routes with a fee-on-transfer *intermediate* are the keeper's problem (threshold still binds). |
| AST-05 Suffix-only args parse | **COMPLETE (by mitigation)** | Still suffix-parsed (`jupiter.rs:23`), but platform fee is now impossible without the account (`jupiter.rs:49`), and any `in_amount` divergence between a fake tail and Jupiter's real args fails `compound.rs:232` (transit must equal `swap_in − in_amount`). | `slippage_bps` in a fake tail could exceed the cap; irrelevant because the program's threshold is the binding check. |
| AST-06 CLMM impact / stuck | **COMPLETE (as accepted)** | = F8. | = F8. |
| AST-07 Quote races / donations | **COMPLETE** | Explicit `amount` (`compound.rs:96-97`, test `test_compound.rs:254-273`); surplus rule (F3). | N3 (keeper). |
| AST-08 Keeper setup ixs | **COMPLETE** | `lib.mjs:487-500`: ATA program only, data ∈ {∅,0,1}, owner slot = swap authority, address = derived ATA, payer replaced, no other signer. `cleanupInstruction` never executed; `computeBudgetInstructions` only read for the CU number (`:475-478`). | Token-program slot (`keys[5]`) unpinned → at most rent-sized loss (N8). |
| AST-09 Raydium binding / vaults | **COMPLETE** | `raydium.rs:18-24` + `add_market.rs:62-67` discriminators; `add_market.rs:71-72,77-78`; `compound.rs:80-85, 191-197` vault owner + mint. Tests `test_admin.rs:102-128`, `test_cpmm.rs:36-57, 136-142`. | Vault `state` byte unchecked — irrelevant, address is read from Raydium's pool. CPMM creator-fee offsets only sanity-checked (gap). |
| AST-10 Unenroll leaves delegation | **COMPLETE** | `unenroll.rs:21-27` canonical ATA always inspected (`init_if_needed`); `:38-47` revokes only when delegate == vawlt. Test `test_enroll.rs:145-160`. | `revoke=false` is a deliberate client choice. |

## New findings (ranked)

### N1 — High (conditional) — TWAP guard soundness rests on an unverified Raydium property: which tick is written to the observation
**Files:** `raydium.rs:89-116, 171-199`; `compound.rs:176-182, 202-207`; tests `test_compound.rs:391-424`, `test_cpmm.rs:147-180` (both patch pool/observation bytes directly; Raydium's swap never runs). **PLAUSIBLE.**

Raydium's `ObservationState::update_check` appends `tick_cumulative += tick × Δt` at most once per 15 s. The guard averages `(tc_latest − tc_oldest)/(ts_latest − ts_oldest)` over the walk-back that stops at the first entry ≥300 s old (`raydium.rs:102-111`). If `tick` is the *pre-swap* tick (Uniswap semantics, and what Raydium's "update the previous price to the observation" comment suggests), a manipulating swap credits the old tick for the elapsed interval and the guard holds within its window. If it is the *post-swap* tick, then on any pool idle ≥300 s the attacker's single swap writes `T_manip × Δidle` into the newest entry, the walk-back stops right there, TWAP = T_manip, and `compound` in the **same transaction** passes with the manipulated spot — a full bypass of F2/AST-01 exactly where manipulation is cheapest (quiet, thin pools). The staleness skip (`>3600 s → spot only`) is likewise safe only under pre-swap recording (the attacker's own swap refreshes the observation with the pre-swap tick). The same question applies to CPMM's `cumulative_token_*_price_x32`.

**Scenario (if post-swap):** ZEC/ZCAT pool idle 20 min → one tx: `swap_v2` pushing the tick down 5% → `compound` every due user with a route paying spot×0.99 → swap back. Profit ≈ 6% of aggregate pending rewards, cost ≈ 2× pool fee on the push.

**Fix:** before burning, run on a mainnet fork (surfpool / `solana-test-validator --clone`): (1) real Raydium `swap_v2` moving the tick >tolerance then `compound` in one transaction → must revert `PriceDeviates`; (2) same after warping the clock >300 s and >3600 s past the last observation. If Raydium turns out to write the post-swap tick, this oracle cannot be made safe by the program (the attacker authors the cumulative) and F2/AST-01 are not fixed; do not burn.

### N2 — Medium — 5-minute window permits a two-transaction manipulation whose cost is "hold a thin pool's price for 5 minutes"
**Files:** `constants.rs:17` (`TWAP_WINDOW_SECS = 300`), `raydium.rs:108-110` (walk-back stops at the first entry ≥ window). **CONFIRMED by construction** (under pre-swap semantics).

t₀: push the tick down by D (observation records the pre-swap tick). t₀+300 s: any dust swap records `+T_manip × 300`; the walk-back now stops at the t₀ entry, so TWAP = T_manip. Then compound all due wallets at a D+tolerance+cap discount and unwind. Being permissionless, one caller can sweep every enrolled user in one go. Payoff is bounded by aggregate pending rewards, which the keeper's 10-minute cadence keeps small — but the window is a compile-time constant that becomes immutable at the burn, and both launch markets are thin memecoin CLMM pools.

**Fix:** decide before freezing. Options: lengthen `TWAP_WINDOW_SECS` (900–1800 s) at the cost of more legitimate `PriceDeviates` on volatile assets; or add a second, longer-window check (spot must also be within tolerance of the 30-minute average) so the attacker must hold for the longer window; set per-market `twap_tolerance_bps` conservatively (admin-settable).

### N3 — Low — Reference keeper always folds the whole transit surplus into the route; an oversized donation stalls it
**File:** `lib.mjs:506-514` (`swapIn = transitBefore + contributed`), `:521-522` (only `amount` halves). **CONFIRMED.**

The program accepts `in_amount = contributed` regardless of surplus, but `buildCompound` quotes `transitBefore + contributed` on every attempt. A donation to the (public) transit ATA larger than the pool can absorb within 1% makes `quoteBelowThreshold` true for every halving → `compoundOne` throws → backoff. The program is unaffected (any keeper can take `in_amount = contributed`, or sweep the surplus in chunks since `[contributed, swap_in]` is a range), so this is a keeper-only liveness bug costing the attacker the donation.

**Fix:** quote `contributed` first; add surplus only while the quote still clears the floor; optionally a separate chunked sweep.

### N4 — Low — `PriceDeviates` griefing and volatility both trip a 6-hour per-wallet backoff
**Files:** `lib.mjs:57` (`isSlippageFailure` matches `PriceDeviates`), `keeper.mjs:51,115`, `logic.mjs:47-53`. **CONFIRMED.**

A griefer who sandwiches the keeper (push >tolerance for one block, restore) forces a revert for the cost of two pool fees; two such failures park the wallet for `backoffHours` (6). The pre-check throw (`keeper.mjs:51`, no transaction sent) also counts as a failure, so ordinary >1% moves within 5 minutes — routine for memecoins — accumulate backoff. No funds at risk; compounding merely lags.

**Fix:** don't count pre-check `priceDeviates` toward backoff; retry within the tick after a short delay; cap backoff for `PriceDeviates` separately from `SlippageExceeded`.

### N5 — Low — After the burn, `Config.admin` is an irreplaceable hot key with all remaining powers
**Files:** `admin.rs:9-46`, `initialize.rs:12,27` (admin must be the deployer/upgrade authority), no `set_admin` anywhere. **CONFIRMED.**

Post-freeze, one key holds forever: pause/unpause, `set_params` (can raise `max_slippage_bps` to 200 and, via `set_market_params`, tolerance to 300 → up to 5% keeper capture), `add_market`, `withdraw_fees`. Loss of the key strands fees and removes the emergency pause; compromise weakens user protection but cannot steal principal.

**Fix:** add a two-step `set_admin` before freezing, or initialize from a multisig/hardware wallet.

### N6 — Medium (liveness) — Freezing pins the ABI of two upgradeable third-party programs
**Files:** `constants.rs:25-38` + `jupiter.rs:19-30` (Jupiter v6 `route` account order and 19-byte tail), `raydium.rs` (CLMM/CPMM PoolState, AmmConfig, ObservationState offsets and sizes). **CONFIRMED by design.**

Jupiter v6 and both Raydium programs are upgradeable by their owners; Raydium has already changed the CLMM observation layout once (sqrt-price entries → `tick_cumulative`). After the burn, any layout/arg change silently breaks compounding forever (worst case: a re-laid-out observation account still ≥4483 bytes parses as garbage and either blocks with `PriceDeviates` or guards nothing). User funds are never at risk — `unenroll`/`revoke`/`set_floor` depend only on the token program — and `withdraw_fees` survives; only the service dies. The runbook (`:18`) already notes the 1.5× headroom becomes useless after the freeze, while `:83` still plans v2 as an in-place upgrade.

**Fix:** prefer a timelocked multisig (e.g., 7-day Squads timelock) over a burn; if burning anyway, say so on the security page ("if Jupiter or Raydium change, the Slawth stops; your tokens don't move").

### Info
- **N7** `relayed_signer_flags_are_stripped` is vacuous: `test_compound.rs:359-361` sets the sink to `is_signer = false` (its default). Put the keeper's own key (a real tx signer) in a mock slot flagged `true` and have the mock assert it arrives unsigned.
- **N8** `toIx` (`lib.mjs:495`) derives the ATA from an unvalidated `keys[5]`; a malicious API could make the keeper pay rent into an account owned by an arbitrary program. Require `keys[5] ∈ {Token, Token-2022}` and `keys[4] == System`.
- **N9** `threshold` can be 0 when `expected` rounds to 0 (tiny `amount`, or `contributed == 0` after a Token-2022 fee), letting a pay-nothing route pass (`compound.rs:230`). Irrelevant at the configured minimums (50,000 raw ZEC → ~4.35e9 raw ZCAT); add `require!(threshold > 0 && in_amount > 0)` for future markets.
- **N10** Status-page/spec inaccuracies: F6 note claims types are re-checked per compound (owners only); F7 note claims the re-enroll floor reset is documented in the app (not found); spec §1 table still says fee ≤ 500 (cap is 200, `constants.rs:11`), superseded by the revision note.

## Checked and sound
- **Swap authority scope:** signs only the Jupiter CPI (`compound.rs:225`); owns transit + any Jupiter intermediate ATAs; is nobody's delegate; has no lamports. A route can therefore only move transit contents and intermediate residue, and the output either lands in the user's target ATA (checked) or the keeper pays the threshold itself. Delegate PDA never appears as a CPI signer (`jupiter.rs:57`, `compound.rs:222`).
- **Route binding:** slot 1 = swap authority, slot 2 = transit, slot 6 = Jupiter id (`jupiter.rs:45-51`); `remaining.len() ≥ 9`; plain `route` only; discriminators are true Anchor sighashes (`test_layout.rs:35-38`); tail shape matches a live `/swap-instructions` response (`jupiter.rs:98-107`).
- **Amount/surplus rule:** `min_compound ≤ amount ≤ balance − floor` (`compound.rs:93-97`); `contributed` measured as a delta after the transfer (`:152-154`), so Token-2022 reward fees are exact; `in_amount ∈ [contributed, swap_in]`; post-condition `transit == swap_in − in_amount` (`:232`) means the user's contribution is always fully swapped and only external surplus can remain. No stuck state: surplus never affects the threshold (`venue_in` from `in_amount`), and any keeper can drain it in chunks.
- **Keeper `amount` griefing:** chunking at `min_compound` changes nothing economically (fee and floor are proportional; rounding is ≤1 raw unit per chunk in the keeper's favor); fees go to the vault, not the keeper.
- **TWAP mechanics:** offsets/sizes match Raydium (CLMM 4483 B: index@17, pool@19, entries@51×44; CPMM 4075 B: index@9, pool@11, entries@43×40) and are pinned against mainnet snapshots (`test_layout.rs:7-32`, `test_cpmm.rs:36-57`); ring wrap via `(idx+100−back)%100` with `back < 100` never re-reads `idx`; partially filled rings stop at `ts == 0`; `idx < 100` enforced; `floor_div` matches Uniswap's rounding and the 1-tick bias is immaterial; `tc` wrap impossible in practice, `cum` uses `wrapping_sub` matching Raydium's `wrapping_add`; CPMM comparison in u128 cannot overflow (`spot_x32 < 2^96`, ×10⁴); `spot_x32` uses reserves net of protocol/fund/creator fees exactly as Raydium's `token_price_x32`. Direction of the adverse check is right in all four (kind × side) cases; the favourable direction is never blocked (liveness).
- **Token-2022:** `calculate_epoch_fee` with `clock.epoch` matches what the token program charges in the same transaction; SPL mints short-circuit to 0 (`compound.rs:68`); the target fee is taken off `after_pool` (`:214-216`).
- **Discriminators/owners:** `add_market.rs:60-67`; CPMM `_ =>` arm is safe because `program_for` already rejected unknown kinds (`add_market.rs:56, 68-81`).
- **`withdraw_fees`:** admin-only, destination forced to `ATA(admin, reward_mint)` (`admin.rs:36-40`), amount 0 = all; test `test_compound.rs:465-485`.
- **`reapprove`/`unenroll(revoke)`:** user-signed, enrollment bound by seeds + `has_one`, floor untouched, foreign delegates untouched.
- **Caps:** fee ≤ 200 at init and `set_params` (`initialize.rs:24`, `admin.rs:51`), slippage ≤ 200, tolerance ≤ 300 at `add_market`/`set_market_params`.
- **Arithmetic:** `overflow-checks = true` in release (`Cargo.toml`), checked ops throughout, U256 for price math, `MathOverflow` on `None`.
- **Keeper:** `checkRoute` mirrors `jupiter.rs` (`lib.mjs:446-456`); `toIx` allowlist; keeper signs only its own key; `dryRun` path never loads a key.

## Remaining test gaps
1. **No test runs Raydium's real swap against the guard** (N1) — the single most important gap before the burn; needs a fork.
2. **No real-Jupiter test:** split routes (`percent` legs) vs the exact-consumption post-check; multi-hop routes creating swap-authority intermediate ATAs; a Token-2022 target delivered directly to the user (one fee, not two). The planned first real compounds cover this only if inspected for these specifics.
3. `relayed_signer_flags_are_stripped` is vacuous (N7). `MOCK_SLOT_DESTINATION` exists but no test uses a redirected destination to prove `SlippageExceeded`.
4. Token-2022 `maximum_fee` binding is untested on either leg; test `prep` (`test_compound.rs:24-37`) still uses the bps-only helpers, so tests would not notice a cap-aware regression.
5. CPMM creator-fee offsets (397/405) are only checked as `fees < balance`; the KBLH/ONE fixture may hold zeros there.
6. No `in_amount` strictly between `contributed` and `swap_in` (partial surplus sweep); no two markets sharing one reward mint (shared fee vault, distinct transits, `revoke=false` interplay); no `withdraw_fees` on a Token-2022 vault.
7. Keeper: no tests for `toIx` rejection cases, `buildCompound` halving/surplus behaviour, or backoff on pre-check throws (only `logic.mjs` has unit tests).
8. No test that a stale/short-span observation on a *live* (not patched) pool behaves as intended, and no test asserting the guard is skipped with an uninitialized observation account (new pool).
