# Fable audit: Slawth Vawlt program

**Auditor:** Fable (Anthropic Claude Fable 5.1, independent adversarial review agent) · **Date:** 2026-09-08 · **Scope:** commit `9b7b726` (program v1.2: markets, protected floor, Jupiter relay), tests, mock router, design spec, off-chain keeper · **Method:** read-only, adversarial brief, report unedited below. Fixes are tracked on the site's audits page.

---

## Summary

Slawth Vawlt is a non‑custodial auto‑compounder that delegates a global `authority` PDA on users' reward‑token accounts and relays a keeper‑supplied Jupiter route via CPI, bounding the outcome with two post‑checks (user target ATA grew ≥ a reference‑derived threshold; the transit ATA ends empty). The account‑validation layer is tight and idiomatic — `has_one`, `associated_token::*`, seed/bump, pinned program address, and delegate checks are all present and well‑tested for the single‑source happy/sad paths. The two structural risks are (1) the CPI signer is a single PDA that is simultaneously the delegate over *every* user's reward account and the owner of *every* fee vault and transit ATA, so the safety of everything except the current transit rests entirely on Jupiter‑internal route semantics that cannot be verified offline; and (2) the slippage floor is derived from a **spot** reference price read in the same transaction, which a permissionless caller can manipulate, so the user is only ever guaranteed `reference_spot × (1 − cap)` and a keeper captures the remainder. **Recommendation: do not deploy as‑is.** Both issues are cheap to fix pre‑deployment: split the CPI signer into a per‑market `swap_authority` that owns only that market's transit (as the task proposes), and move the reference to a TWAP/observation‑based price. Neither is blocked by anything else in the codebase.

---

## Findings (most severe first)

### F1 — Over‑privileged CPI signer: one PDA is universal delegate + owner of all vaults/transits, signing a keeper‑controlled instruction
**Severity: High (Critical if Jupiter `route` accepts a second funding source)**
**Location:** `instructions/compound.rs:20‑22,39‑42,176‑180`; `jupiter.rs:39‑45`; `enroll.rs:56‑66`; `initialize.rs:16‑19`
**Status: CONFIRMED (over‑privilege + no allowlist) / PLAUSIBLE (actual drain depends on Jupiter internals)**

The `authority` PDA (`["authority"]`) is:
- the SPL delegate on **every** enrolled user's reward ATA, approved for `u64::MAX` (`enroll.rs:65`);
- the owner of **every** market's transit ATA (`compound.rs:39`, `ATA(authority, reward_mint)`) and **every** fee vault (`compound.rs:41‑42`, `token::authority = authority`);
- the owner of any intermediate ATAs Jupiter's setup instructions create for it (`lib.mjs:toIx`).

In `compound`, the relayed instruction's accounts come entirely from keeper‑supplied `remaining_accounts` with **no allowlist**, writability is passed through verbatim, and `authority` is marked signer wherever its key appears (`jupiter.rs:42`), then the whole thing is `invoke_signed` with the authority seeds (`compound.rs:180`). Because PDA signer privileges extend to the callee's downstream CPIs, inside the Jupiter invocation `authority` can act as the transfer authority for a token move out of **any** account it owns or is delegate on — not just this market's transit.

The program's only structural guards are the two post‑checks (`compound.rs:183‑187`): user target ATA grew ≥ threshold, and *this market's* transit ended at 0. These fully constrain a route with a **single** funding source (verified: mock‑router modes 2 and 3 leave transit non‑empty / pay nothing and correctly revert — `test_compound.rs:214‑234`). They do **not** constrain a route that consumes this market's transit **and additionally** pulls from a second `authority`‑owned/‑delegated account (a fee vault, another market's transit, or another user's reward ATA), dumping the combined proceeds into the user's target ATA — that would satisfy both checks while draining the second source. Whether such a route is constructible is purely a Jupiter‑v6 question (does `route`/`shared_accounts_route` permit more than one independent input token account / arbitrary per‑leg input accounts?), which I cannot verify offline.

**Attack scenario (plausible):** any keeper calls `compound` for a due user, supplying a route whose primary source is the transit but whose plan also sources `in2` from the shared fee vault (or another user's reward ATA via the delegation), delivering everything to the user's target ATA. Both post‑checks pass; the fee vault / other user is drained. There is no keeper reward, so the fee vault (2% of all volume, unspendable in v1) and cross‑user reward balances are the profit targets.

**Recommended fix (adopt the proposed mitigation now):** introduce a per‑market `swap_authority` PDA (`["swap", market]`) that owns *only* that market's transit ATA and is the *only* signer of the Jupiter CPI; keep the delegate `authority` (owner of fee vaults, delegate on reward ATAs) out of the CPI entirely — it signs only the two `transfer_checked` calls into the fee vault and transit (`compound.rs:100‑129`). Then the token program itself rejects any Jupiter leg that tries to spend a fee vault, a sibling transit, or a user reward ATA, because `swap_authority` owns/`delegates` none of them — closing the class regardless of Jupiter internals. **Residual:** intermediate ATAs owned by `swap_authority` can accrue dust (not user funds; Jupiter leaves them net‑zero by design, and they are sweepable); the transit itself is still fully consumed each compound. Also make transit per‑market (`ATA(swap_authority, reward_mint)`), which additionally removes the current transit/fee‑vault *sharing* across markets with the same reward mint.
**Evidence that would settle the PLAUSIBLE half:** a Jupiter v6 `route`/`shared_accounts_route` decoded example (or a devnet/mainnet‑fork test against the real Jupiter program) showing whether a single route can specify two independent input token accounts or an arbitrary per‑leg input index that isn't threaded from the prior leg's output.

---

### F2 — Slippage floor uses a spot reference price read in‑transaction; permissionless caller can manipulate it and capture the spread
**Severity: High**
**Location:** `compound.rs:138‑172` (spot read), `math.rs:25‑51` (spot/no‑TWAP), `constants.rs:10` (cap 200 bps)
**Status: CONFIRMED (spot, same‑tx, keeper‑supplied route/quote) / PLAUSIBLE (magnitude depends on pool depth & capital)**

The only economic protection for the user is `threshold = expected × (1 − max_slippage_bps)` where `expected` is computed from the pool's **current** `sqrt_price_x64` (CLMM, `compound.rs:146‑151`) or **current** reserves (CPMM, `compound.rs:153‑167`), both read in the same instruction. Jupiter's own internal min‑out is `quoted_out × (1 − slippage_bps)` with `quoted_out` and `slippage_bps` **both keeper‑supplied** (`jupiter.rs:10‑35`) — so it is not an independent bound. Consequences:

1. **Baseline leak (always present):** since the keeper supplies the route and `quoted_out`, it can deliver as little as `threshold` and route the difference through its own liquidity, capturing `(fair_output − reference_spot × (1 − cap))` per compound — at minimum the full slippage cap (1–2%) plus size impact.
2. **Amplified by manipulation:** because the reference is spot and read in‑tx, a caller can, in a prior instruction of the same transaction/bundle, push the pinned pool's price against the user, lowering `threshold`, compound the victim with a stingy route, then unwind. CLMM `expected_out` uses spot with **no price‑impact term** (`math.rs:25‑41`), so the floor is especially soft; CPMM includes impact but its reserves are equally flash‑movable. `min_compound` does not help — it bounds trade size, not price.

Compounding is permissionless and the caller chooses the victim, timing, and route (`lib.rs:56‑59`, `compound` open to any `caller`), so this is repeatable MEV/theft against users, not just a keeper‑trust issue.

**Recommended fix:** derive the reference from a manipulation‑resistant source — the CLMM `observation_state` TWAP (already present in fixtures: `GQV1z…`) or Raydium's oracle observations — rather than the live `sqrt_price`; and/or require the delivered amount to meet an oracle‑TWAP floor independent of keeper inputs. At minimum, document that v1 trusts keepers not to sandwich and gate keepers to an allowlist until then (which changes the trust model the spec advertises as "permissionless").

---

### F3 — Transit is a shared, public ATA and `in_amount` must match it exactly → front‑run‑donation griefing / DoS
**Severity: Medium**
**Location:** `compound.rs:132‑136` (`swap_in = transit.amount; require in_amount == swap_in`), `errors.rs:WrongAmount`
**Status: CONFIRMED**

`swap_in` is read as the whole transit balance and the route's `in_amount` must equal it exactly (`compound.rs:133,136`). Transit is `ATA(authority, reward_mint)` — a well‑known address anyone can transfer reward tokens into. An attacker who sends dust to the transit ATA *after* the keeper reads it but before the tx lands changes `transit.amount`, so `in_amount != swap_in` → `WrongAmount` → revert. This is cheap, permissionless, repeatable, and affects every user of a market (transit is shared per reward mint, so it is also shared across markets that share a reward mint). No funds are lost (donations become the next user's, per `test_compound.rs:253‑266`), but compounds can be stalled at will.

**Recommended fix:** isolate transit per market under the `swap_authority` from F1 (reduces blast radius but not the public‑donation race), and/or have the program tolerate `transit.amount >= in_amount` by sweeping any surplus deterministically to the user and still requiring transit == 0 afterward, so a donation cannot invalidate the route amount. Keeper‑side re‑read/retry (already partially present via fresh quotes) mitigates but does not eliminate it.

---

### F4 — `approve(u64::MAX)` makes the floor a purely program‑enforced (not token‑enforced) protection; upgrade authority can drain everything
**Severity: Medium (disclosed in design, but must be surfaced to users)**
**Location:** `enroll.rs:65` (`u64::MAX`), `compound.rs:79‑88` (floor enforced in program), `initialize.rs:23‑34` (admin = upgrade authority)
**Status: CONFIRMED**

The delegation is `u64::MAX`, so the token layer permits the delegate to move a user's *entire* reward balance; only the program's own `amount = balance − floor` logic (`compound.rs:81‑82`) keeps compounding above the floor. Because `admin` is the program's upgrade authority (`initialize.rs:12`, and the program is upgradeable), a program upgrade (or any bug that bypasses the floor arithmetic) can move all delegated reward tokens of all users, not just the amount above each floor. The design explicitly accepts upgrade‑authority trust "until v2," but the `u64::MAX` allowance means the blast radius is the users' full reward balances, which should be disclosed prominently and ideally capped.

**Recommended fix:** consider approving a bounded, periodically‑refreshed allowance instead of `u64::MAX`; commit to burning/timelocking the upgrade authority before meaningful TVL; and state the delegation semantics in user‑facing docs.

---

### F5 — Shared per‑reward‑mint delegate: `unenroll` revokes delegation for *all* same‑reward‑mint markets
**Severity: Medium**
**Location:** `unenroll.rs:24‑34` (unconditional `Revoke` on the reward ATA), `enroll.rs:56‑66` (single delegate per reward ATA)
**Status: CONFIRMED**

A user's reward ATA has a single delegate (`authority`) shared by every market with that reward mint. `unenroll` issues a blanket `Revoke` (`unenroll.rs:27‑33`). If a user is enrolled in two markets that share a reward mint (the design explicitly supports many StonkFun coins, and `add_market` allows multiple markets per reward mint), unenrolling from one silently disables compounding for the sibling market(s), whose enrollments still exist and still expect delegation. Compound then fails `NotDelegated` for the sibling with no obvious cause.

**Recommended fix:** on `unenroll`, only revoke when the user has no other active enrollment for that reward mint (requires tracking a per‑(user,reward_mint) enrollment count), or document that revoke is global and have the site re‑approve.

---

### F6 — `reference_config` owner not re‑checked in `compound`; `add_market` never binds the config to the pool
**Severity: Low**
**Location:** `compound.rs:141` (reads config data; only `has_one = reference_config`), `compound.rs:143` (pool owner *is* re‑checked); `add_market.rs:53,72` (owner + parse only, no pool↔config linkage)
**Status: CONFIRMED (low impact)**

In `compound`, the pool's owner is re‑verified (`compound.rs:143`) but the AmmConfig's owner is not — it is trusted via `has_one` to the pubkey pinned at `add_market`. Separately, `add_market` verifies the config is owned by the DEX and parses (`add_market.rs:53,72`) but never checks it is *the pool's own* config, so an admin could pin a valid‑but‑wrong AmmConfig, giving a slightly wrong `trade_fee_rate` in the threshold. Both are low impact: the config address is immutable and Raydium‑owned, and admin is trusted; the value only affects a reference bound, not execution. Still worth a defensive owner re‑check in `compound` and a pool→config consistency check in `add_market`.

---

### F7 — Floor footguns: re‑enroll snaps floor to current balance; closed/recreated reward ATA cannot be re‑approved without unenroll+enroll
**Severity: Low**
**Location:** `enroll.rs:52` (`floor = current balance`), `lib.rs:43‑45` (enroll fails if enrollment exists), `unenroll.rs` (only path to reset)
**Status: CONFIRMED**

`enroll` sets `floor` to the current reward balance (`enroll.rs:52`). A user who unenrolls (e.g., to revoke) and later re‑enrolls with a larger balance has their floor snap to that full balance, so nothing compounds until *new* airdrops arrive — the prior protected/compoundable distinction is lost. Likewise, if a user closes and recreates their reward ATA, the new ATA has no delegate and there is no re‑approve instruction (`enroll` fails because the enrollment PDA already exists), so they must `unenroll` then `enroll` (resetting the floor). UX/footgun, not a fund risk; the site should guide this.

---

### F8 — CLMM reference ignores price impact (liveness, not loss)
**Severity: Info**
**Location:** `math.rs:25‑41` (spot, no impact) vs `math.rs:44‑51` (CPMM includes x·y=k impact)
**Status: CONFIRMED**

For large compounds relative to CLMM depth, the spot‑based `expected` overstates the achievable output, so the threshold can exceed what an honest Jupiter fill can deliver → spurious `SlippageExceeded`. This is a liveness/retry issue (keeper backs off), not a loss. Overlaps with F2's fix (TWAP + impact awareness).

---

### F9 — Stats record gross amounts while Token‑2022 fees reduce what vaults receive (cosmetic)
**Severity: Info**
**Location:** `compound.rs:91,200` (`fee`/`total_fees` gross), fee vault actually receives `fee − transfer_fee` for Token‑2022 rewards (`test_compound.rs:298`)
**Status: CONFIRMED**

`total_fees`/`total_reward_in` accumulate gross figures, but for a Token‑2022 reward mint the fee vault and transit receive less than sent. Accounting/telemetry cosmetic only; the swap math correctly re‑reads the transit (`compound.rs:132‑133`) so execution is unaffected.

---

## Checked and found sound

- **Account validation on `Compound`** is comprehensive: `config`/`authority`/`market`/`enrollment` seed+bump; `market` `has_one` on reward/target mint, both token programs, `reference_pool`, `reference_config` (`compound.rs:23‑28`); `enrollment` `has_one = user` with the (market,user) pair pinned by seeds; `user_reward_ata`/`user_target_ata`/`transit` via `associated_token::{mint,authority,token_program}`; `fee_vault` via seeds + `token::{mint,authority}`; `jupiter_program` via `address = JUPITER_PROGRAM` + `executable` (`compound.rs:54`). Token‑program substitution is blocked by the `has_one` on both token programs. Verified against the substitution tests (`test_compound.rs:174‑251,268‑281`).
- **`build_route_ix` cannot be tricked into extra signers:** `is_signer` is recomputed as `key == authority` (`jupiter.rs:42`), so keeper‑supplied signer flags on relayed accounts are ignored — the only signer is ever the authority PDA. (Good design; this is why F1 is bounded to authority‑owned/‑delegated accounts, not arbitrary ones.)
- **Delegate checks:** `delegate == authority && delegated_amount >= amount` (`compound.rs:84‑88`), correctly rejecting revoked/reduced delegation (`test_compound.rs:132‑149`).
- **Floor logic:** `balance > floor`, `amount = balance − floor`, post‑condition balance == floor; airdrop/raise/lower behavior all verified (`test_compound.rs:82‑117`).
- **Token‑2022 handling on the reward side:** transit re‑read after transfer so `swap_in` reflects the net received amount (`compound.rs:132‑133`); `transfer_checked` uses the mint decimals; epoch‑aware transfer fee via `get_epoch_fee(epoch)` (`compound.rs:61‑71,140`). Verified (`test_compound.rs:283‑303`).
- **Checked arithmetic throughout** (`math.rs` returns `None`→`MathOverflow`; `compound.rs`/`add_market.rs`/`unenroll.rs` use `checked_*`); `overflow-checks = true` even in release (`Cargo.toml`). `apply_fees` rejects `trade_fee_rate >= 1e6` and `transfer_fee_bps >= 1e4` (`math.rs:12‑13,45`).
- **CLMM/CPMM direction and math** validated against live mainnet numbers (`math.rs:62‑111`) and layout‑pinned by guard tests against real snapshots (`test_layout.rs`, `test_cpmm.rs`); CPMM subtracts protocol/fund/creator fees from vault balances (`raydium.rs:59‑69`, `compound.rs:159‑162`) and checks the passed vaults are the pool's own (`compound.rs:155‑156`, verified `test_cpmm.rs:101‑107`).
- **`initialize` gating** on `ProgramData.upgrade_authority_address` derived by seeds (not a claimed account) (`initialize.rs:9‑13`); verified (`test_admin.rs:18‑33`).
- **Admin surface** is fully `has_one = admin` gated and capped (`admin.rs`, `add_market.rs:12`); no fee‑vault withdrawal path exists; caps `FEE ≤ 500`, `SLIP ≤ 200` enforced at both `initialize` and `set_params` (verified `test_admin.rs`).
- **`add_market` reinit safety:** `market` is `init` (re‑add fails "already in use", verified `test_admin.rs:105‑111`); `transit`/`fee_vault` `init_if_needed` accounts are ATA/PDA‑deterministic and constraint‑validated, so pre‑creation only donates rent.
- **Permissionless `compound` cannot redirect output:** `user` is bound by enrollment seeds + `has_one`, and `user_target_ata` is forced to `ATA(user, target_mint)`, so a caller can only credit the real enrolled user (verified `test_compound.rs:268‑281`).
- **Single‑source route deviations revert atomically:** underpay, no‑pay‑but‑pull, and pull‑nothing all fail `SlippageExceeded`/`TransitNotEmpty` with balances untouched (`test_compound.rs:214‑234`).
- **Off‑chain `toIx`** correctly re‑pays only ATA‑create setup instructions with the keeper as payer and refuses any setup instruction requiring a non‑payer signer (`lib.mjs:407‑412`); `skipUserAccountsRpcCalls: false` is the correct choice (ensures needed authority‑ATA setup instructions are returned). Jupiter discriminators match Anchor sighashes (verified `test_layout.rs:24‑28`).

---

## Test gaps

- **No test exercises the F1 multi‑source class.** The mock router (`test-programs/mock-router`) is hard‑wired to a single source (`accounts[2]`, set to transit in `mock_remaining`), so it cannot represent a route that consumes transit *and* a fee vault / sibling transit / another user's reward ATA. A LiteSVM test where the relayed source is the fee vault should exist (it would pass, asserting `TransitNotEmpty`, confirming single‑source is caught); the true multi‑source case can only be settled against real Jupiter on a fork.
- **No reference‑price‑manipulation test.** LiteSVM can mutate the pool account's `sqrt_price_x64` bytes between enroll and compound to demonstrate the threshold collapsing (F2); none exists.
- **No front‑run‑donation DoS test** (F3): the existing dust test donates *before* `prep` and recomputes; there is no test donating *after* the route is built to show `WrongAmount`.
- **No test for the "extra relayed account flagged signer is ignored" property** that the spec (§7) claims — `build_route_ix`'s `is_signer = key==authority` behavior is currently unverified by any test.
- **No multi‑market‑same‑reward‑mint test** covering the shared delegate/`Revoke` interaction (F5) or the shared transit/fee‑vault across such markets.
- **No `reference_config` owner‑substitution test in `compound`** (F6), and no pool↔config consistency test in `add_market`.
- **`shared_accounts_route`** is only tested at the discriminator level; the mock has one payload shape, so the `id`‑byte / shared‑account specifics are not exercised.

---

**Bottom line:** The code is careful and well‑tested for what it directly enforces, but it delegates its most important safety property (that nothing but the current transit can be spent in the swap) to Jupiter's route semantics, and its economic floor to a same‑transaction spot price. Fix F1 (per‑market `swap_authority`) and F2 (TWAP/oracle reference) before mainnet; both are cheap now and remove the two dependencies on external behavior that this offline review cannot fully discharge.
