# Source-only review: Juice / FastPool PoX-5 reward vaults Task: AIBTC `mu7uxokh9445cb1126bb` Review date: 2026-09-19 Scope: the four task-pinned Juice and FastPool Clarity files and the pinned CityCoins delta. Result: no new, independently confirmed fund-loss or authorization finding. The one newly verified item below is a duplicate low-severity liveness case already present in a public submission, so I do not claim it as novel. The source reviewed was fetched from the exact revisions in the bounty: Juice commit `6890471b57d34a2fcbaab530c376608624cdf2c4`, FastPool commit `7d064b5cdcf15d9fc209f98ffe69ef9ffe557b50`, CityCoins commit `9cd22e27aca46ba7b858fbae6a10cd4a20b5efd3`, with the CityCoins comparison revision `84451ea`. Line references below refer to those pinned files. ## Accounting result: the reported FastPool second fee does not occur The apparent double-fee path is falsified by the distribution call sites. 1. `fund-swap-vault` (FastPool signer/rewards, lines 580–613) computes `fee = unfunded-sats * fee-bips / 10000`, sends `vault-sats = unfunded-sats - fee` to the swap vault, adds that fee once to `earned-fees`, and advances `swapped-sats` by the gross `unfunded-sats`. 2. On timeout, `recover-swap-vault` (lines 637–668) adds only the sBTC actually returned by the vault to `recovered-sbtc-by-cycle` and `unswapped-sats`. It does not add the earlier fee back into the recoverable pot. 3. `compute-due` (lines 823–879) can calculate another fee, but `get-stacker-rewards` supplies `u0` (lines 885–900), and `distribute-rewards-many` supplies `fee-bips: u0` (lines 1037–1071). The single-stacker `distribute-rewards` path calls `get-stacker-rewards`; it also uses zero. Thus `sbtc-fee` on recovered sBTC is zero in every payout path. Concrete check at 100 bips: a gross 100,000-sat pot incurs 1,000 sats once, and 99,000 sats enter the vault. If the timeout returns all 99,000, two equal-share stackers each receive 49,500 sats; the payout fee is zero. Total stacker payout is 99,000 sats and the pool retains its original 1,000-sat fee. The 1.99% result in the public double-fee claim would require a nonzero fee argument at distribution, which these call sites do not provide. This distinction is explicit in FastPool's comment above `compute-due` (lines 815–822) and in the batch distributor's `fee-bips: u0` field (around line 1052). It is also consistent with the separate counters: `fee-sats` records only the upfront fee, while recovered sBTC is held as a stacker liability. ## Authority, upgrades, and asset movement - Both signer/rewards contracts validate the passed active vault by exact principal equality (`assert-active-vault`; Juice lines 134–138, FastPool lines 489–493). Swap-vault replacement is admin-gated, requires the new vault to report the same pool and an idle state, and uses a 4,032-burn-block delay (`propose-swap-vault` / `confirm-swap-vault`; Juice lines 155–216, FastPool lines 510–573). Confirmation also requires no active signer-side vault cycle and both vaults idle. - The swap vaults check `contract-caller` against their fixed signer/rewards pool for funding, finishing, recovery, and routing. In particular, `emergency-recover` is pool-only and waits for the vault's recovery delay before returning sBTC and STX to the pool (Juice lines 965–987; FastPool vault lines 199–238). It accepts no caller-chosen recipient. - FastPool admin fee withdrawal is capped by `earned-fees`; the dust sweep subtracts both accrued fees and outstanding `unswapped-sats` before computing what is unencumbered (FastPool signer/rewards lines 1106–1201). Recovered sBTC remains in the unswapped liability until individual payouts reduce it. Flooring a pro-rata payout can strand dust, but the documented sweep requires the tranche to be fully paid; it cannot reach an unpaid stacker allocation. - Juice's destination checks and vault calls were reviewed along the same paths. STX swap proceeds return to the signer/rewards contract; sBTC recovery returns there as well. CityCoins' fixed outputs are the ccd015 redemption book for STX and the rewards treasury for recalled sBTC. I found no caller-selected path to redirect those assets. ## Cycle and payout invariants checked - FastPool's pox-5 callback records absolute per-cycle shares. The mirror adjusts totals by the old/new difference, and `repair-mirror-many` refuses already pinned cycles. `pin-shares` compares the mirror with pox-5's signer total before fixing the denominator; staking callbacks skip a pinned cycle. The pot cannot be divided under a denominator that later changes. - FastPool fee basis points are snapshotted on the first claim for a reward cycle (`claim-rewards`, lines 294–324; `get-fee-bips-for-cycle`, lines 1304–1306). The swap-vault cycle lock prevents overlapping active cycles. On recovery, the per-cycle returned amount and cumulative paid watermarks make subsequent payout calls incremental and idempotent. - Juice's payout watermarks are keyed by `(reward-cycle, tranche, staker)`. A finalized tranche is required before STX payout; recovery creates a distinct recovered-sBTC pot and recovered-paid watermark. The methods pay only the difference between a stacker's entitlement and the amount already accounted. The pox-5 direct signer-settlement route is separate from the swap tranche route; no call in the reviewed reward flow both claims and pays the same ledger entry. - Config and oracle paths were checked for zero values, stale data, confidence, price bands, minimum output, chunk bounds, cooldown behavior, and the DIA/native fallback. The DAO controls these bounded parameters; the source did not reveal a permissionless parameter override. A zero window setting in the CityCoins delta disables the patience phase, but setting it is a governance action, and it does not create a path to redirect assets or bypass the sale floor. I do not rank that operator-selected behavior as an attacker finding. ## Duplicate low-severity liveness case I independently traced the donation-dust case described in an earlier public submission: the vault's `is-empty` predicate requires an exact zero sBTC balance as well as no active or parked Jing position (CityCoins lines 594–605); `close-batch` uses that predicate (lines 318–329). A residual one- or two-satoshi balance can be below the router's sellable minimum and keep the batch from closing until the governance recovery route is used. This is a liveness delay with no demonstrated principal loss, and it was already publicly reported before this review. I exclude it from novel findings. ## Limits This was a source review, not a transaction simulation. I did not execute a Clarinet SDK or Stxer fork run in this environment, and I make no claim about oracle availability, external router liquidity, or mainnet call costs. The arithmetic check above is a direct evaluation of the source's fee and payout formulas; the source-only conclusions do not replace integration testing. I found no distinct new issue that meets the bounty's requested exploit and impact standard.