# Audit: kairan-seat-lab-ocifarsito-v2.clar Scope: `contracts/charity/kairan-seat-lab-ocifarsito-v2.clar` (487 lines, undeployed). Reviewed by reading the full source; no testnet deploy (no STX on my side), so every finding below states whether it is confirmed by control flow alone or depends on an assumption about the token contracts. Line numbers are from the file as published. ## 1. The reserve can be permanently locked out of the seat grant (medium) `release-seat` (L387-410) pays `(reserve-stx)` and `(reserve-sats)` to LAB and immediately records a claim, which makes `deposit-reserve` (L323-345) revert on `ERR_CLAIMED` because it calls `check-raising`, and `check-raising` asserts `(is-none (var-get claim))` at L210. Nothing in `request-seat` (L352-380) or `release-seat` requires `reserve-deposited` to be true. So this sequence is legal: 1. Donors fill `total` to TARGET; `reached-at` is set. 2. LAB files `request-seat`. 3. OWNER calls `release-seat` before FUNDRAISER ever calls `deposit-reserve`. 4. FUNDRAISER now calls `deposit-reserve` with the STX + sBTC it was holding. It aborts with `ERR_CLAIMED` forever. Result: the seat grant pays 2.000 USDCx and an empty reserve, while `TERMS` (L31-32) tells every donor the reserve "always goes to LAB" as "the reserve as a bonus". The reserve is not lost to a thief, but it is stranded in the fundraiser wallet and the on-chain receipt (`claim.stx == 0`, `claim.sats == 0`) contradicts the terms that donors accepted. Fix: either reject `release-seat` while `reserve-deposited` is false (simplest, one assert), or drop the `claim` assert from `deposit-reserve` so it can still be deposited after release — but then `release-seat` has already computed `reserve-stx`, so the deposit would be stranded anyway. The first option is correct. If "reserve optional" is intended, TERMS must say so explicitly and the reserve claim path must stay open. ## 2. A single reverting donor contract can block refund batches containing it (low, griefing) `refund-one` (L444-466) calls `USDCX_TOKEN transfer` to `who` inside a `fold` in one transaction. If `who` is a contract whose `transfer` callback aborts (a contract donating on behalf of itself, which `donate` allows because it uses `tx-sender`), then every batch that includes that principal reverts entirely. Keepers can work around it by splitting batches (the contract allows any number of calls, up to 100 principals each), so funds are not frozen — but a griefer can force batching around itself and, with enough such contracts, make it expensive to find the poison entry. This is not a loss of funds, and AUTH is respected; worth a note in the keeper design rather than a code change. If a change is wanted: have `refund-one` catch the failure and leave the gift unzeroed (`match (contract-call? ...) success (begin ... (ok (+ paid gift))) failure (ok paid)`), so one bad principal cannot revert a batch. ## 3. `claim-reserve` can underflow, aborting the only reserve path at the worst moment (low) L424 computes `extra (- (usdcx-balance) (var-get unrefunded))`. The invariant `usdcx-balance >= unrefunded` holds only if USDCx transfers are exact. With a fee-on-transfer or rebasing token, or if the token contract is ever upgraded behind the same principal, the subtraction aborts and `claim-reserve` can never run — the reserve path dies silently for LAB at `failure-at`. `USDCx` is named as a third-party token in TERMS (L54), so this dependency is already acknowledged; the defensible fix is `(if (> bal unref) (- bal unref) u0)` plus an assert that the reserve itself is non-zero, or documenting that the contract assumes an exact-transfer token. ## 4. Design observations (no action required, but worth a sentence in the docs) - `request-seat` can be called repeatedly and replaces the previous receipt (L366). A donor reading the event log sees several seat requests and must know only the last one before `release-seat` counts. The print event has no sequence number; `burn-height` in the claim is the only anchor. - `donate` books `gifts` by `tx-sender` (L305) while the transfer is `from tx-sender` (L306) — correct, and it is the only reason refunds can be trustless. Worth keeping as an invariant comment. - `get-status.closed` (L203) is false after `claim-reserve` while `unrefunded > 0`, which is right, but a UI that reads only `closed` will show an active contract that can no longer do anything except refund. Consider a separate `fully_refunded` flag. ## What I checked and found correct - `donate` caps at `TARGET - total` and sets `reached-at` exactly when the target is met (L304-309); no path raises `total` past TARGET. - `release-seat` and `claim-reserve` both go through `as-contract?` with explicit `with-ft`/`with-stx` allowances (L241-253), and both pay only `LAB`. - One `claim` var gates both the seat and the reserve path, so double payment is impossible (`ERR_CLAIMED` at L210, L428). - `refund-many` zeroes each gift before moving to the next principal, so a donor listed twice is paid once (L455). - OWNER has exactly one power (`release-seat`) and it pays only LAB; there is no owner path to any other principal. Method: full read of the 487-line source, control-flow walk of every public function, and a check of each `asserts!` against the promises in `TERMS`/`DONOR_TERMS`. No dynamic testing; the contract is undeployed and I hold no STX to deploy a copy.