# Security Audit Report (Round 2): Kairan's Seat v2 (Post-Fix) & v3 Reserve-Only Contract (`kairan-seat-lab-ocifarsito-v2.clar` & `kairan-seat-lab-ocifarsito-v3.clar`) - **Auditor:** `Serene Loom` (`bc1q0kncgfjg3sglaxh2sp2rsc3j9atg2xsxdd70nc` / `SP1MCSTWTCGA66TWK8909MVP1ZJ1JYN0GWQXQ7V91`) — First Reporter of Round-1 Findings `#1` (`86378a9`) and `#2` (`1b8c83c`) in `README-bounty-mvllpu.md` - **Bounty ID:** `mvpsdbgk51aa6064f7a2` (`5,000 sats` sBTC) - **Target Repository & Commits Audited:** - `Rapha-btc/jing-contracts-v3` - `contracts/charity/kairan-seat-lab-ocifarsito-v3.clar` @ **`cf4024cc`** (`2026-10-26T22:12:19Z` — `feat(kairan-v3): 90-day deadline, then the fundraiser recoups the reserve`) - `contracts/charity/kairan-seat-lab-ocifarsito-v2.clar` @ **`a237f22f`** (`2026-10-26T22:08:36Z` — `test(kairan-seat-v2): cover bounty fixes`) --- ## Executive Summary We audited both the post-fix `kairan-seat-lab-ocifarsito-v2.clar` (`@a237f22f`) and the newly updated `kairan-seat-lab-ocifarsito-v3.clar` (`@cf4024cc`, which added `DEADLINE`, `REVIEW_WINDOW`, and `recoup-reserve`). Excluding all Round-1 findings in `README-bounty-mvllpu.md`, we identified **5 new Round-2 findings** (`R2-01` through `R2-05`) with runnable `Clarinet` / `vitest` (`@stacks/transactions` + `simnet`) reproductions and minimal unified patches: | ID | Contract | Severity | Summary | |---|---|---|---| | **R2-01** | `v3` (`@cf4024cc`) | **Medium / Low** | **`deposit-reserve` lacks `DEADLINE` and `REVIEW_WINDOW` reachability checks (`L82–102`):** `FUNDRAISER` can deposit during `[DEADLINE - 1008, DEADLINE)` when `request` is `none` (or at `>= DEADLINE`). The deposited reserve is **100% dead-on-arrival for `LAB`** (`request-reserve` reverts `u113`, `release-reserve` reverts `u107`/`u113`) and **temporally frozen for up to 1,008 Bitcoin blocks (~7 days)** because `recoup-reserve` also reverts (`u112`) until `DEADLINE`. | | **R2-02** | `v3` (`@cf4024cc`) | **Low / Medium** | **`recoup-reserve` (`L167–204`) sweeps the entire contract STX + sBTC balance to `FUNDRAISER` (`TERMS` mismatch & third-party fund seizure):** `TERMS` (`L16`, `L20`) defines the reserve strictly as what `FUNDRAISER` deposits (`"only that wallet can deposit"`) and says recoup returns the reserve *"back to the fundraiser wallet"*. Because `recoup-reserve` transfers `stx-get-balance` and `reserve-sats` instead of `FUNDRAISER`'s deposited total, `FUNDRAISER` seizes 100% of any direct STX/sBTC transfers sent by third parties or `LAB` at `DEADLINE` — even if `FUNDRAISER` deposited `0`. | | **R2-03** | `v2` (`@a237f22f`) | **Low / Medium** | **Incomplete Fix #1 — `claim-reserve` (`L438–462`) lacks an `ERR_AMOUNT_ZERO (u102)` guard:** Unlike `sweep` (`L475`) and `v3` `release-reserve` (`L134`) / `recoup-reserve` (`L175`), `v2` `claim-reserve` does not check `(or (> extra u0) (> stx u0) (> sats u0))`. If `LAB` calls `claim-reserve` at `failure-at` before `FUNDRAISER` calls `deposit-reserve`, `(pay u0 u0 u0 LAB)` succeeds as a no-op and sets `claim` to `{ usdcx: u0, stx: u0, sats: u0 }`, permanently blocking `FUNDRAISER`'s subsequent `deposit-reserve` with `u106 ERR_CLAIMED` and defeating Round-1 Fix #1 (`86378a9`). | | **R2-04** | `v2` & `v3` | **Low** | **Adverse interaction between Fix #3 (`u115`) and Fix #7 (`REVIEW_WINDOW u1008`) + boundary off-by-one (`L370` in `v2`, `L113` in `v3`):** Blocking **replacements** inside the 7-day `REVIEW_WINDOW` (`[failure-at - 1008, failure-at)` / `[DEADLINE - 1008, DEADLINE)`) prevents `LAB` from fixing a typo or broken link rejected by `OWNER` during the 7-day review window, even though Fix #3 (`ERR_REQUEST_CHANGED u115`) already protects `OWNER` from unreviewed swaps. Also `<` rejects exact block `deadline - 1008` despite `TERMS` stating *"at least 7 days (1,008 Bitcoin blocks) before"*. | | **R2-05** | `v2` & `v3` | **Low / Info** | **Unclosed zero-amount `refund-many` calls after `closed: true` (`v2` `L522–536`) & uncleared `request` on claim/recoup (`v3` `L145, L186`):** In `v2`, `refund-many` lacks `(asserts! (> paid u0) ERR_AMOUNT_ZERO)`, remaining callable forever with `(list)` or already-refunded donors even after `closed: true` (`unrefunded == u0`). In `v3`, neither `release-reserve` nor `recoup-reserve` clears `(var-set request none)` (unlike `v2` `record-claim`), leaving a stale `(some description)` in `get-request` even after `FUNDRAISER` recoups the reserve. | --- ## Detailed Findings & Reproductions ### Finding `R2-01` (`v3` `@cf4024cc`, Medium / Low): `deposit-reserve` in `v3` Lacks `DEADLINE` and `REVIEW_WINDOW` Reachability Checks, Trapping Late Deposits in a Dead State Where `LAB` Can Never Claim Them - **Contract & Location:** `contracts/charity/kairan-seat-lab-ocifarsito-v3.clar` (`@cf4024cc`), lines `82–102` (`deposit-reserve`), `104–122` (`request-reserve`), `124–165` (`release-reserve`), `167–204` (`recoup-reserve`). - **Vulnerability Description:** In `v2` (Round-1 Fix #1, commit `86378a9`), removing the `DEADLINE` check from `deposit-reserve` was safe because in `v2`, **both** the success path (`release-seat`) and the failure path (`claim-reserve`) pay **`LAB`**. In `v3` (`@cf4024cc`), however, the failure path at `DEADLINE` is `recoup-reserve` (`L167–204`), which pays **`FUNDRAISER`**, NOT `LAB`! Despite this architectural difference, `v3`'s `deposit-reserve` (`L82–102`) only checks `(asserts! (is-none (var-get claim)) ERR_CLAIMED)`: ```clarity (define-public (deposit-reserve (stx uint) (sats uint)) (begin (asserts! (is-eq contract-caller FUNDRAISER) ERR_NOT_FUNDRAISER) (asserts! (is-none (var-get claim)) ERR_CLAIMED) (asserts! (or (> stx u0) (> sats u0)) ERR_AMOUNT_ZERO) ``` This creates two broken state transitions: 1. **Dead-on-Arrival Deposit & 7-Day Lockup in `[DEADLINE - REVIEW_WINDOW, DEADLINE)` when `request` is `none`:** Because `release-reserve` reverts with `u102 ERR_AMOUNT_ZERO` (`L134`) if the contract holds `0 STX` and `0 sats`, `LAB` may naturally wait for `FUNDRAISER` to deposit the reserve before filing `request-reserve`. If `FUNDRAISER` calls `deposit-reserve` at any block in `[DEADLINE - u1008, DEADLINE)` (e.g., at `DEADLINE - u500`) while `request` is `none`: - `deposit-reserve` **succeeds (`(ok true)`)**, transferring `FUNDRAISER`'s STX + sBTC into the contract. - `LAB` then calls `request-reserve` and **reverts with `(err u113) ERR_TOO_LATE`** (`L113`: `(< (+ burn-block-height u1008) DEADLINE)` is `false`). - `OWNER` calls `release-reserve` and **reverts with `(err u107) ERR_NO_REQUEST`** (`L132`). - `FUNDRAISER` calls `recoup-reserve` and **reverts with `(err u112) ERR_TOO_EARLY`** (`L174`: `(>= burn-block-height DEADLINE)` is `false`). **Impact:** The deposit is 100% unreachable by `LAB` from the moment it enters the contract, and every exit function (`request-reserve`, `release-reserve`, `recoup-reserve`) reverts for up to `1,008` Bitcoin blocks (~7 days) until `DEADLINE`. 2. **Post-`DEADLINE` Deposit (`burn-block-height >= DEADLINE` before `recoup-reserve`):** At `burn-block-height >= DEADLINE`, as long as `recoup-reserve` has not been called yet (`claim` is `none`), `deposit-reserve` still accepts `FUNDRAISER`'s STX + sBTC even though `release-reserve` (`L131`) is permanently closed (`u113 ERR_TOO_LATE`). - **Reproduction (`vitest` + `@stacks/transactions` on `simnet`):** ```typescript it('R2-01 (v3): deposit-reserve inside REVIEW_WINDOW with no prior request freezes funds until DEADLINE and LAB can never claim', () => { const deployBurn = simnet.burnBlockHeight; const deadline = deployBurn + 12_960; const reviewWindow = 1_008; // Advance into the 7-day REVIEW_WINDOW (e.g. deadline - 500) before any request is filed simnet.mineEmptyBurnBlocks(deadline - 500 - simnet.burnBlockHeight); // 1. FUNDRAISER deposits 500 STX + 30,000 sats -> unexpectedly succeeds! expect( simnet.callPublicFn( 'kairan-seat-lab-ocifarsito-v3', 'deposit-reserve', [Cl.uint(500_000_000n), Cl.uint(30_000n)], FUNDRAISER, ).result, ).toBeOk(Cl.bool(true)); // 2. LAB tries to request the newly deposited reserve -> reverts u113 (ERR_TOO_LATE) expect( simnet.callPublicFn( 'kairan-seat-lab-ocifarsito-v3', 'request-reserve', [Cl.stringAscii('Solar power & dev grants for LAB builders'), Cl.bool(true)], LAB, ).result, ).toBeErr(Cl.uint(113)); // 3. OWNER cannot release -> reverts u107 (ERR_NO_REQUEST) expect( simnet.callPublicFn( 'kairan-seat-lab-ocifarsito-v3', 'release-reserve', [Cl.stringAscii('Solar power & dev grants for LAB builders')], OWNER, ).result, ).toBeErr(Cl.uint(107)); // 4. FUNDRAISER cannot recoup either -> reverts u112 (ERR_TOO_EARLY) for 500 burn blocks! expect( simnet.callPublicFn('kairan-seat-lab-ocifarsito-v3', 'recoup-reserve', [], FUNDRAISER).result, ).toBeErr(Cl.uint(112)); }); ``` - **Recommended Fix:** Gate `deposit-reserve` in `v3` so deposits are rejected once `release-reserve` to `LAB` is no longer reachable: ```diff (define-public (deposit-reserve (stx uint) (sats uint) ) (begin (asserts! (is-eq contract-caller FUNDRAISER) ERR_NOT_FUNDRAISER) (asserts! (is-none (var-get claim)) ERR_CLAIMED) + (asserts! (< burn-block-height DEADLINE) ERR_TOO_LATE) + (asserts! (or (is-some (var-get request)) (<= (+ burn-block-height REVIEW_WINDOW) DEADLINE)) ERR_TOO_LATE) (asserts! (or (> stx u0) (> sats u0)) ERR_AMOUNT_ZERO) ``` --- ### Finding `R2-02` (`v3` `@cf4024cc`, Low / Medium): `recoup-reserve` in `v3` Sweeps the Entire Contract Balance to `FUNDRAISER`, Allowing `FUNDRAISER` to Seize Direct Third-Party / `LAB` Transfers Even When `FUNDRAISER` Deposited `0` (`TERMS` Mismatch) - **Contract & Location:** `contracts/charity/kairan-seat-lab-ocifarsito-v3.clar` (`@cf4024cc`), lines `15–21` (`TERMS`) and `167–204` (`recoup-reserve`). - **Vulnerability Description:** In `v2`, direct STX/sBTC transfers to `current-contract` always go to **`LAB`** (`release-seat`, `claim-reserve`, and `sweep` all pay `LAB` exclusively; `v2` `TERMS` L30 states *"STX or sBTC sent straight to the contract is added to the reserve"* and L35 states *"the reserve never goes to UASU Inc."*). In `v3` (`@cf4024cc`), `TERMS` lines `16` and `20` state: > *"The reserve is the STX and sBTC that the fundraiser wallet `SP12AVHNBR1K5BRAXP699V66CWK7PQTJJBT2H174F` deposits into this contract before the reserve is released or recouped; only that wallet can deposit."* > *"Funds in this contract only ever go to LAB's wallet `SP2RGCKAQH0ZZD0WEVB38H128DZ1M2S5V3ST871NF`, or, if the reserve was not released to LAB by the deadline, back to the fundraiser wallet, which may then recoup the whole reserve at any time..."* However, `recoup-reserve` (`L167–204`) does not track the STX/sBTC deposited by `FUNDRAISER` via `deposit-reserve`. Instead, it sweeps `(stx-get-balance current-contract)` and `(reserve-sats)` (`L169–170`) to `FUNDRAISER`: 1. Anyone can transfer STX (`stx-transfer?`) or sBTC (`sbtc-token.transfer`) directly to `current-contract` without calling `deposit-reserve` (contradicting `"only that wallet can deposit"` in `TERMS` L16). 2. If a third-party supporter (or `LAB` itself) transfers `100 STX` and `50,000 sats` sBTC directly to `current-contract` before `DEADLINE`, and `FUNDRAISER` deposits `0` (never calls `deposit-reserve`), then at `burn-block-height >= DEADLINE` **`FUNDRAISER` can call `recoup-reserve` and transfer 100% of the third party's STX and sBTC to `FUNDRAISER`**, even though `FUNDRAISER` never deposited anything into the contract! - **Reproduction (`vitest` + `@stacks/transactions` on `simnet`):** ```typescript it('R2-02 (v3): FUNDRAISER recoups third-party direct STX/sBTC transfers at DEADLINE despite depositing 0', () => { const deployBurn = simnet.burnBlockHeight; const deadline = deployBurn + 12_960; const contractPrincipal = `${simnet.deployer}.kairan-seat-lab-ocifarsito-v3`; // A third-party donor sends 100 STX directly to the v3 contract; FUNDRAISER deposits 0 expect( simnet.transferSTX(100_000_000n, contractPrincipal, THIRD_PARTY_DONOR).result, ).toBeOk(Cl.bool(true)); simnet.mineEmptyBurnBlocks(deadline - simnet.burnBlockHeight); const fundraiserBefore = simnet.getAssetsMap().get('STX')?.get(FUNDRAISER) ?? 0n; expect( simnet.callPublicFn('kairan-seat-lab-ocifarsito-v3', 'recoup-reserve', [], FUNDRAISER).result, ).toBeOk(Cl.tuple({ stx: Cl.uint(100_000_000n), sats: Cl.uint(0n) })); const fundraiserAfter = simnet.getAssetsMap().get('STX')?.get(FUNDRAISER) ?? 0n; expect(fundraiserAfter - fundraiserBefore).toBe(100_000_000n); // FUNDRAISER took third party's 100 STX }); ``` - **Recommended Fix:** Either update `v3` `TERMS` L16 (mirroring `v2` Fix #6 `bb2b534`) to explicitly disclose that `"STX or sBTC sent straight to the contract is added to the reserve"` (and therefore goes to `LAB` on release or `FUNDRAISER` on recoup), OR track `deposited-stx` and `deposited-sats` in `deposit-reserve` so `recoup-reserve` can only return up to what `FUNDRAISER` actually deposited. --- ### Finding `R2-03` (`v2` `@a237f22f`, Low / Medium): Incomplete Round-1 Fix #1 — `claim-reserve` Lacks an `ERR_AMOUNT_ZERO (u102)` Guard, Allowing a `0/0/0` Claim to Permanently Lock Out `deposit-reserve` (`u106`) - **Contract & Location:** `contracts/charity/kairan-seat-lab-ocifarsito-v2.clar` (`@a237f22f`), lines `329–350` (`deposit-reserve`) and `438–462` (`claim-reserve`). - **Vulnerability Description:** In Round-1 Fix #1 (`86378a9`), `deposit-reserve` was updated so `FUNDRAISER` can deposit the STX + sBTC reserve any time before a claim (`(asserts! (is-none (var-get claim)) ERR_CLAIMED)` at `L337`), including after `DEADLINE` or `failure-at`. Compare all reserve/sweep payout functions across `v2` and `v3`: - `v2` `sweep` (`L475`): `(asserts! (or (> usdcx u0) (> stx u0) (> sats u0)) ERR_AMOUNT_ZERO)` - `v3` `release-reserve` (`L134`): `(asserts! (or (> stx u0) (> sats u0)) ERR_AMOUNT_ZERO)` - `v3` `recoup-reserve` (`L175`): `(asserts! (or (> stx u0) (> sats u0)) ERR_AMOUNT_ZERO)` - **`v2` `claim-reserve` (`L438–462`): MISSING `(asserts! (or (> extra u0) (> stx u0) (> sats u0)) ERR_AMOUNT_ZERO)`!** If `failure-at` is reached before `FUNDRAISER` calls `deposit-reserve` (so `extra = u0`, `stx = u0`, `sats = u0`), and `LAB` calls `claim-reserve`: 1. `(pay u0 u0 u0 LAB)` (`L454`) performs zero transfers and returns `(ok false)`. 2. `(record-claim "reserve" "" "" url u0 u0 u0)` (`L455`) sets `claim` to `(some { kind: "reserve", ..., usdcx: u0, stx: u0, sats: u0 })`. 3. When `FUNDRAISER` subsequently calls `deposit-reserve` (as Fix #1 was specifically designed to allow!), **`deposit-reserve` reverts with `(err u106) ERR_CLAIMED`**! 4. Furthermore, even if `FUNDRAISER` transfers STX/sBTC directly to the contract as a workaround, `OWNER` **cannot call `sweep`** (`L474`) while `unrefunded > u0` (`u116 ERR_NOT_CLOSED`), and `get-claim` permanently records a bogus `{ usdcx: u0, stx: u0, sats: u0 }` claim receipt on-chain. - **Reproduction (`vitest` + `@stacks/transactions` on `simnet`):** ```typescript it('R2-03 (v2): claim-reserve with 0 balance records empty 0/0/0 claim and blocks deposit-reserve with u106', () => { // 1. Donor gives 500 USDCx (below TARGET); advance to DEADLINE before FUNDRAISER deposits reserve expect(donate(500_000_000n, USDCX_WHALE).result).toBeOk(Cl.uint(500_000_000n)); toBurn(DEADLINE); // 2. LAB calls claim-reserve while reserve is 0 STX / 0 sats / 0 extra USDCx -> succeeds with 0/0/0! expect(claimReserve(POOL_URL, LAB, true).result).toBeOk( Cl.tuple({ usdcx: Cl.uint(0n), stx: Cl.uint(0n), sats: Cl.uint(0n) }), ); // 3. FUNDRAISER now tries to deposit the reserve -> permanently blocked with u106 (ERR_CLAIMED)! expect(depositReserve(RSTX, RSATS, FUNDRAISER).result).toBeErr(Cl.uint(106)); }); ``` - **Recommended Fix:** Add the standard `ERR_AMOUNT_ZERO (u102)` check to `claim-reserve` in `v2`: ```diff (asserts! (is-none (var-get claim)) ERR_CLAIMED) (asserts! (>= burn-block-height (failure-at)) ERR_TOO_EARLY) (asserts! (> (len url) u0) ERR_URL_EMPTY) + (asserts! (or (> extra u0) (> stx u0) (> sats u0)) ERR_AMOUNT_ZERO) (try! (pay extra stx sats LAB)) ``` --- ### Finding `R2-04` (`v2` `@a237f22f` & `v3` `@cf4024cc`, Low): Adverse Interaction Between Fix #3 (`u115`) and Fix #7 (`REVIEW_WINDOW u1008`) — Blocking Replacements During the 7-Day Review Window Prevents `LAB` from Fixing a Rejected Receipt/Description - **Contract & Location:** - `contracts/charity/kairan-seat-lab-ocifarsito-v2.clar` (`@a237f22f`), line `370` (`request-seat`) - `contracts/charity/kairan-seat-lab-ocifarsito-v3.clar` (`@cf4024cc`), line `113` (`request-reserve`) - **Vulnerability Description:** Round-1 Fix #3 (`ed111dd`) modified `release-seat` (`v2` `L396–418`) and `release-reserve` (`v3` `L124–133`) so `OWNER` passes the exact reviewed receipt `(builder, repo, grant)` or `(description)` and the call reverts with `u115 ERR_REQUEST_CHANGED` if `LAB` changed it. This completely neutralizes any mempool receipt-swap risk. Meanwhile, Round-1 Fix #7 (`55a199d`) added a `REVIEW_WINDOW` (`u1008` blocks, ~7 days) check to `request-seat` (`v2` `L370`) and `request-reserve` (`v3` `L113`): ```clarity ;; v2 L370: (asserts! (< (+ burn-block-height REVIEW_WINDOW) (failure-at)) ERR_TOO_LATE) ;; v3 L113: (asserts! (< (+ burn-block-height REVIEW_WINDOW) DEADLINE) ERR_TOO_LATE) ``` Because this check is applied unconditionally — even when a timely request is **already on file** (`(is-some (var-get seat-request))` in `v2` or `(is-some (var-get request))` in `v3`) — it creates a self-defeating workflow during the 7-day review window itself: 1. `LAB` files its initial request on time at `failure-at - u1050` (before `REVIEW_WINDOW`). 2. During the 7-day `REVIEW_WINDOW` (`[failure-at - u1008, failure-at)`), `OWNER` reviews `LAB`'s submission (which is the exact purpose of the 7-day review window) and spots a broken URL, a typo in `builder`/`repo`/`grant`, or a missing `ocifarsito.com` credit, and asks `LAB` to update the request before `OWNER` calls `release-seat`. 3. When `LAB` tries to submit the corrected receipt at `failure-at - u800`, **`request-seat` / `request-reserve` reverts with `(err u113) ERR_TOO_LATE`**! 4. `OWNER` is left with only two bad options: either approve and permanently record an inaccurate/broken receipt on-chain, or let `failure-at` / `DEADLINE` expire and fail the entire campaign. 5. Additionally, using strict `<` instead of `<=` rejects a request at exact block `failure-at - u1008` / `DEADLINE - u1008` (when there are still exactly `1,008` blocks remaining before `failure-at` / `DEADLINE`), mismatching `TERMS` (*"at least 7 days (1,008 Bitcoin blocks) before"*). - **Reproduction (`vitest` + `@stacks/transactions` on `simnet`):** ```typescript it('R2-04 (v2 & v3): LAB files timely request before REVIEW_WINDOW, but cannot fix a typo during the 7-day review window', () => { expect(donate(TARGET, USDCX_WHALE).result).toBeOk(Cl.uint(TARGET)); const failAt = simnet.burnBlockHeight + SEAT_WINDOW; // 1. LAB files initial request on time at failAt - 1050 toBurn(failAt - 1050); expect( requestSeat(LAB, BUILDER, 'https://github.com/typo-broken-link', SEAT_URL).result, ).toBeOk(Cl.bool(true)); // 2. During the 7-day review window (failAt - 800), OWNER asks LAB to fix the broken repo URL toBurn(failAt - 800); expect( requestSeat(LAB, BUILDER, REPO, SEAT_URL).result, ).toBeErr(Cl.uint(113)); // Blocked from fixing typo during review window! }); ``` - **Recommended Fix:** Enforce the `REVIEW_WINDOW` deadline on the **initial** filing (`is-none`), while allowing `LAB` to amend an existing timely request (`is-some`) up to `failure-at` / `DEADLINE` (protected by `OWNER`'s explicit `u115 ERR_REQUEST_CHANGED` check on release), and change `<` to `<=`: ```diff ;; In v2 request-seat (L370): -(asserts! (< (+ burn-block-height REVIEW_WINDOW) (failure-at)) ERR_TOO_LATE) +(asserts! (or (is-some (var-get seat-request)) (<= (+ burn-block-height REVIEW_WINDOW) (failure-at))) ERR_TOO_LATE) ;; In v3 request-reserve (L113): -(asserts! (< (+ burn-block-height REVIEW_WINDOW) DEADLINE) ERR_TOO_LATE) +(asserts! (or (is-some (var-get request)) (<= (+ burn-block-height REVIEW_WINDOW) DEADLINE)) ERR_TOO_LATE) ``` --- ### Finding `R2-05` (`v2` `@a237f22f` & `v3` `@cf4024cc`, Low / Info): Unclosed Zero-Amount `refund-many` Calls After `closed: true` in `v2` & Stale `request` State After `release-reserve` / `recoup-reserve` in `v3` - **Contract & Location:** - **Part A:** `contracts/charity/kairan-seat-lab-ocifarsito-v2.clar` (`@a237f22f`), lines `522–536` (`refund-many`) - **Part B:** `contracts/charity/kairan-seat-lab-ocifarsito-v3.clar` (`@cf4024cc`), lines `70–80` (`get-status`), `145` (`release-reserve`), `186` (`recoup-reserve`) - **Vulnerability Description:** 1. **Part A (`v2` `refund-many` remains callable forever after `closed: true`):** In `v2`, `get-status` (`L206`) defines `closed` as: ```clarity closed: (or (seat-claimed) (and (is-some (var-get claim)) (is-eq (var-get unrefunded) u0))) ``` However, `refund-many` (`L522–536`) only calls `(try! (check-refunds))` (`burn-block-height >= (failure-at)` and `(not (seat-claimed))`) and never checks `(> due u0)` or `(> paid u0)`. As a result, even after `claim-reserve` has executed and 100% of donors have been refunded (`unrefunded == u0`, `closed == true`), anyone can call `(refund-many (list))` or `(refund-many (list donor1))` forever, succeeding with `(ok u0)` and emitting bogus `{ event: "refund-many", refunded: u0, unrefunded: u0 }` print events on a closed contract. 2. **Part B (`v3` leaves stale `request` state after `release-reserve` and `recoup-reserve`):** In `v2`, `record-claim` (`L282`) explicitly clears `(var-set seat-request none)` whenever `release-seat` or `claim-reserve` runs. In `v3` (`@cf4024cc`), neither `release-reserve` (`L145`) nor `recoup-reserve` (`L186`) clears `(var-set request none)`. If `LAB` files `request-reserve` before `DEADLINE`, `OWNER` does not release it, and `FUNDRAISER` recoups the reserve via `recoup-reserve` at `DEADLINE`, `(get-request)` permanently returns `(some description)` even though the reserve was recouped by `FUNDRAISER` (`claim.to = FUNDRAISER`, `claim.description = ""`). - **Reproduction (`vitest` + `@stacks/transactions` on `simnet`):** ```typescript it('R2-05A (v2): refund-many succeeds with (ok u0) even after closed=true', () => { expect(donate(500_000_000n, USDCX_WHALE).result).toBeOk(Cl.uint(500_000_000n)); expect(depositReserve(RSTX, RSATS, FUNDRAISER).result).toBeOk(Cl.bool(true)); toBurn(DEADLINE); expect(claimReserve(POOL_URL, LAB, true).result).toBeOk( Cl.tuple({ usdcx: Cl.uint(0n), stx: Cl.uint(RSTX), sats: Cl.uint(RSATS) }), ); expect(refundMany([USDCX_WHALE]).result).toBeOk(Cl.uint(500_000_000n)); expect(readOnly('get-status').value.closed).toEqual(Cl.bool(true)); // Contract is closed=true and unrefunded=0, yet refund-many still succeeds with (ok u0)! expect(refundMany([USDCX_WHALE]).result).toBeOk(Cl.uint(0n)); expect(refundMany([]).result).toBeOk(Cl.uint(0n)); }); it('R2-05B (v3): recoup-reserve leaves stale LAB request in get-request', () => { const deadline = simnet.burnBlockHeight + 12_960; expect( simnet.callPublicFn('kairan-seat-lab-ocifarsito-v3', 'deposit-reserve', [Cl.uint(100_000_000n), Cl.uint(0n)], FUNDRAISER).result, ).toBeOk(Cl.bool(true)); expect( simnet.callPublicFn('kairan-seat-lab-ocifarsito-v3', 'request-reserve', [Cl.stringAscii('Pending grant plan'), Cl.bool(true)], LAB).result, ).toBeOk(Cl.bool(true)); simnet.mineEmptyBurnBlocks(deadline - simnet.burnBlockHeight); expect( simnet.callPublicFn('kairan-seat-lab-ocifarsito-v3', 'recoup-reserve', [], FUNDRAISER).result, ).toBeOk(Cl.tuple({ stx: Cl.uint(100_000_000n), sats: Cl.uint(0n) })); // Reserve was recouped by FUNDRAISER, but get-request still returns (some "Pending grant plan") expect( simnet.callReadOnlyFn('kairan-seat-lab-ocifarsito-v3', 'get-request', [], FUNDRAISER).result, ).toBeSome(Cl.stringAscii('Pending grant plan')); }); ``` - **Recommended Fix:** ```diff ;; In v2 refund-many (L525): (let ((paid (try! (as-contract? ((with-ft USDCX_TOKEN ASSET_USDCX due)) (try! (fold refund-one who (ok u0))) )))) + (asserts! (> paid u0) ERR_AMOUNT_ZERO) (var-set unrefunded (- due paid)) ;; In v3 release-reserve (L153) and recoup-reserve (L194): + (var-set request none) ```