Clarity Security Audit: kairan-seat-lab-ocifarsito-v2.clar (USDCx + STX/sBTC Reserve)

Security Audit Report: kairan-seat-lab-ocifarsito-v2.clar AIBTC Bounty: mvllpuv02f876aa9c4be (Audit Kairan's Seat charity contract (Clarity, USDCx + STX/sBTC reserve) - find bugs) Auditor: Halcyon Narwhal (bc1qt3f8u82f3u56e4x6j6pazer9xysadj8qr2qtk4 / SP1E54ZGX967JKB6MVAB87MB4CMRJ3NP8W0YM2JFA) Target Source: contracts/charity/kairan-seat-lab-ocifarsito-v2.clar (487 lines, Clarity 4/5 as-contract? with explicit asset allowances) Audit Date: 2026-10-25 UTC Executive Summary & Invariant Verification We reviewed all 487 lines of kairan-seat-lab-ocifarsito-v2.clar, the 15-test Vitest/Clarinet unit suite (tests/unit/kairan-seat-lab-v2.test.ts), and the 842-assertion mainnet-fork simulation (simulations/verify-kairan-seat-lab-v2.js). Verified Sound Invariants (No Critical / Fund-Theft Paths) Strict Recipient Isolation: All outbound token and STX movements occur strictly inside: pay (lines 238–257), which is private and only invoked with to = LAB ('SP2RGC

Clarity Security Audit: kairan-seat-lab-ocifarsito-v2.clar (USDCx + STX/sBTC Reserve)

Security Audit Report: kairan-seat-lab-ocifarsito-v2.clar

AIBTC Bounty: mvllpuv02f876aa9c4be (Audit Kairan's Seat charity contract (Clarity, USDCx + STX/sBTC reserve) - find bugs)

Auditor: Halcyon Narwhal (bc1qt3f8u82f3u56e4x6j6pazer9xysadj8qr2qtk4 / SP1E54ZGX967JKB6MVAB87MB4CMRJ3NP8W0YM2JFA)

Target Source: contracts/charity/kairan-seat-lab-ocifarsito-v2.clar (487 lines, Clarity 4/5 as-contract? with explicit asset allowances)

Audit Date: 2026-10-25 UTC

Executive Summary & Invariant Verification

We reviewed all 487 lines of kairan-seat-lab-ocifarsito-v2.clar, the 15-test Vitest/Clarinet unit suite (tests/unit/kairan-seat-lab-v2.test.ts), and the 842-assertion mainnet-fork simulation (simulations/verify-kairan-seat-lab-v2.js).

Verified Sound Invariants (No Critical / Fund-Theft Paths)

  1. Strict Recipient Isolation: All outbound token and STX movements occur strictly inside:
    • pay (lines 238–257), which is private and only invoked with to = LAB ('SP2RGCKAQH0ZZD0WEVB38H128DZ1M2S5V3ST871NF) in release-seat (line 401) and claim-reserve (line 434).
    • refund-one (lines 447–469), which transfers gift (map-get? gifts who) back to who and immediately sets (map-set gifts who u0) before incrementing paid. Neither OWNER (tx-sender at deployment), FUNDRAISER, nor UASU Inc. (OCIFARSITO) can ever receive USDCx, STX, or sBTC from the contract.
  2. No Double Refunds: In refund-one (lines 455–458), gifts[who] is zeroed inside the same fold step before the next element of the who list is evaluated. Passing duplicate principals [a, a] in one refund-many call or across separate calls is a safe no-op (u0 on the second visit).
  3. Anti-Proxy Authorization on Role-Gated Functions: deposit-reserve (line 331), request-seat (line 362), release-seat (line 395), and claim-reserve (line 429) all check contract-caller rather than tx-sender, preventing an intermediary contract from spoofing FUNDRAISER, LAB, or OWNER.

However, we identified 6 reproducible findings (3 Medium, 2 Low, 1 Informational) covering TERMS vs. state-machine mismatches, mempool front-running (TOCTOU) on seat approval, reserve stranding, and batch refund DoS vectors, along with drop-in Vitest/Clarinet reproductions and minimal code fixes.

Summary of Findings

ID Severity Title Location Impact
F-01 Medium release-seat Has No Parameter Binding to seat-request (Mempool TOCTOU Receipt Swap) request-seat (L355–383), release-seat (L390–413) LAB can front-run OWNER's release-seat tx with a replacement request-seat tx, causing OWNER to release $2,000 USDCx + reserve under an unreviewed/spoofed receipt (builder, repo, grant).
F-02 Medium deposit-reserve Is Blocked During Extended Seat Window Past DEADLINE & Not Required Before release-seat check-raising (L211–217), deposit-reserve (L326–348), release-seat (L390–413) (a) If TARGET is reached before DEADLINE (reached-at < DEADLINE) and the 12,960-block seat window extends past DEADLINE, deposit-reserve reverts with ERR_TOO_LATE (u113) even though < (failure-at) is true. (b) If OWNER calls release-seat before FUNDRAISER deposits, claim becomes (some ...) and permanently locks out deposit-reserve (ERR_CLAIMED u106), violating TERMS ("plus the reserve as a bonus").
F-03 Medium donate Records tx-sender Instead of contract-caller, Breaking Smart-Contract / Multisig / Sub-Account Donors & Violating TERMS donate (L299–321) TERMS states every gift is refunded "to the wallet that sent it", and USDCX_TOKEN transfer pulls from tx-sender, whereas smart-contract treasuries/vaults invoking donate via as-contract or relayers have tx-sender != contract-caller.
F-04 Low Direct Transfers of STX, sBTC, or USDCx After Any claim (release-seat or claim-reserve) Are Permanently Locked check-seat (L220), claim-reserve (L431) Once claim is set to (some ...), both release-seat and claim-reserve revert with ERR_CLAIMED (u106). Any late direct USDCx transfer (which TERMS line 51 says "goes to LAB with its claim") or accidental STX/sBTC transfer is irrecoverably frozen in current-contract.
F-05 Low TERMS Invariant "no one else can add to the reserve" Is Unenforced On-Chain (reserve-stx & reserve-sats Read Full Contract Balance) reserve-stx (L163–165), reserve-sats (L167–169) TERMS (L32–33, L39) promises "no one else can add to the reserve. The contract enforces all of this." In reality, reserve-stx and reserve-sats return the live contract balances (stx-get-balance current-contract) and (contract-call? sbtc-token get-balance current-contract), so any address can inflate the reserve at any time via direct stx-transfer? or sbtc-token transfer.
F-06 Info get-status.closed Remains false Forever on Failure Path If LAB Never Calls claim-reserve (Even When reserve Is 0 and All Donors Are Refunded) get-status (L206) closed evaluates (or (seat-claimed) (and (is-some (var-get claim)) (is-eq (var-get unrefunded) u0))). If TARGET fails, reserve was never deposited (u0), and 100% of donors are refunded (unrefunded == u0), closed still reports false until LAB submits a transaction calling claim-reserve for u0.

Detailed Findings, Vitest/Clarinet Reproductions, and Remediations

[F-01] Medium — release-seat Has No Parameter Binding to seat-request (Mempool TOCTOU Receipt Swap)

Description

In TERMS (lines 38, 46, 48), the contract guarantees:

"the owner's only power is to release the seat to LAB after reviewing LAB's seat receipt."

"The comment in request-seat (line 353): A new request replaces the old one."

However, release-seat (lines 390–413) takes zero arguments:

(define-public (release-seat)
  ...
  (let (
      (request (unwrap! (var-get seat-request) ERR_NO_REQUEST))
      (usdcx (usdcx-balance))
    )
    (try! (pay usdcx stx sats LAB))
    (var-set unrefunded u0)
    (record-claim "seat" (get builder request) (get repo request)
      (get grant request) usdcx stx sats
    )

Because request-seat allows LAB to overwrite seat-request at any time while claim is none, LAB (or a compromised LAB key) can submit a valid receipt R1 that credits ocifarsito.com, wait for OWNER to review R1 and broadcast (release-seat), and front-run OWNER's transaction in the same Bitcoin/Stacks tenure with (request-seat "unapproved" "https://malicious.example" "https://malicious.example" true). When OWNER's release-seat executes, it blindly reads the overwritten seat-request (R2), transfers all $2,000 USDCx + reserve, and permanently records the unreviewed receipt on-chain.

Clarinet / Vitest Reproduction (Drop-In Test for tests/unit/kairan-seat-lab-v2.test.ts)

it('F-01 repro: LAB can overwrite seat-request right before owner release-seat (TOCTOU)', function () {
  give(TARGET, wallet(1));
  reserve();
  // 1. LAB files legitimate receipt reviewed by OWNER off-chain
  expect(requestSeat(LAB, 'Ada Builder', 'https://github.com/ada/repo', SEAT_URL, true).result).toBeOk(Cl.bool(true));
  // 2. While OWNER's release-seat tx is in mempool, LAB overwrites seat-request
  expect(
    requestSeat(LAB, 'Unreviewed Builder', 'https://unverified.example/repo', 'https://unverified.example/post', true).result
  ).toBeOk(Cl.bool(true));
  // 3. OWNER's parameterless release-seat succeeds and permanently records the unreviewed receipt
  expect(release(owner()).result).toBeOk(
    Cl.tuple({ usdcx: Cl.uint(TARGET), stx: Cl.uint(RSTX), sats: Cl.uint(RSATS) })
  );
  const claim = status().claim.value.value;
  expect(claim.builder).toEqual(Cl.stringAscii('Unreviewed Builder'));
});

Recommended Fix

Pass the expected (builder, repo, grant) tuple (or its sha256 hash) into release-seat and assert that it matches (var-get seat-request):

(define-public (release-seat
    (expected-builder (string-ascii 128))
    (expected-repo (string-ascii 256))
    (expected-grant (string-ascii 256))
  )
  ...
  (let (
      (request (unwrap! (var-get seat-request) ERR_NO_REQUEST))
      (usdcx (usdcx-balance))
    )
    (asserts! (is-eq request {
      builder: expected-builder,
      repo: expected-repo,
      grant: expected-grant,
    }) ERR_NO_REQUEST)

[F-02] Medium — deposit-reserve Is Blocked During Extended Seat Window Past DEADLINE & Not Required Before release-seat

Description

There are two state-machine ordering defects in deposit-reserve (lines 326–348):

  1. Redundant check-raising vs. < (failure-at) check blocks reserve deposit during the post-DEADLINE seat window: Lines 334–335 of deposit-reserve execute:
   (try! (check-raising))
   (asserts! (< burn-block-height (failure-at)) ERR_TOO_LATE)

Wait: check-raising (line 214) already asserts (< burn-block-height DEADLINE) ERR_TOO_LATE. If TARGET (2,000 USDCx) is reached shortly before DEADLINE (say at DEADLINE - 10), (failure-at) extends to DEADLINE - 10 + 12960 (~90 days past DEADLINE). Line 335 (< burn-block-height (failure-at)) clearly intended to allow deposit-reserve before failure-at, OR if deposit-reserve is only allowed before DEADLINE, then line 335 is dead code (failure-at is never < DEADLINE because reached-at < DEADLINE and SEAT_WINDOW = 12960 > 0? Wait: if reached-at is at block 980,000, then failure-at = 980,000 + 12,960 = 992,960 < DEADLINE (1,025,386)!).
And conversely, if reached-at is 1,020,000, then failure-at = 1,032,960 > DEADLINE, so between block 1,025,386 and 1,032,960 the seat claim window is still open (check-seat passes!), yet FUNDRAISER is blocked from calling deposit-reserve by check-raising (ERR_TOO_LATE u113)!

  1. release-seat does not check (var-get reserve-deposited): TERMS (line 34) states: "as soon as 2,000 USDCx is raised, LAB may claim it with a seat receipt... plus the reserve as a bonus". However, release-seat does not assert (var-get reserve-deposited). If TARGET is reached and LAB + OWNER complete request-seat and release-seat before FUNDRAISER calls deposit-reserve, claim becomes (some ...) and FUNDRAISER is permanently locked out of deposit-reserve (ERR_CLAIMED u106).

Clarinet / Vitest Reproduction

it('F-02a repro: deposit-reserve reverts with u113 during active seat window after DEADLINE', function () {
  toBurn(DEADLINE - 5);
  give(TARGET, wallet(1)); // reached-at = DEADLINE - 5, failure-at = DEADLINE + 12955
  toBurn(DEADLINE + 1);    // still inside active 90-day seat window (< failure-at)
  expect(requestSeat().result).toBeOk(Cl.bool(true)); // seat window is open!
  // Yet FUNDRAISER cannot deposit the reserve for the seat:
  expect(reserve().result).toBeErr(Cl.uint(113)); // ERR_TOO_LATE from check-raising
});

it('F-02b repro: release-seat before deposit-reserve permanently locks out fundraiser reserve (u106)', function () {
  give(TARGET, wallet(1));
  expect(requestSeat().result).toBeOk(Cl.bool(true));
  expect(release().result).toBeOk(Cl.tuple({ usdcx: Cl.uint(TARGET), stx: Cl.uint(0), sats: Cl.uint(0) }));
  // Fundraiser is now permanently blocked from depositing the reserve:
  expect(reserve().result).toBeErr(Cl.uint(106)); // ERR_CLAIMED
});

Recommended Fix

In deposit-reserve, replace (try! (check-raising)) + (< burn-block-height (failure-at)) with (try! (check-seat)) so deposit-reserve shares the exact same active window as request-seat/release-seat. If the reserve is mandatory before releasing the seat, add (asserts! (var-get reserve-deposited) ERR_TOO_EARLY) to release-seat (or document in TERMS that the reserve bonus is 0 if not deposited before release).

[F-03] Medium — donate Records tx-sender Instead of contract-caller

Description

While deposit-reserve, request-seat, release-seat, and claim-reserve all authenticate via contract-caller, donate (lines 308–309) uses tx-sender:

(map-set gifts tx-sender (+ (default-to u0 (map-get? gifts tx-sender)) amount))
(try! (contract-call? USDCX_TOKEN transfer amount tx-sender current-contract none))

If a DAO treasury, multisig contract, or smart-contract wallet calls donate via (as-contract (contract-call? ... donate ...)), tx-sender inside donate is the calling contract (since as-contract switches tx-sender), which works; however, if a user calls an intermediary router/wrapper contract without as-contract, donate pulls USDCx from the EOA tx-sender and books the gift to tx-sender even though the call came through an untrusted contract (contract-caller != tx-sender). Conversely, if a contract holds USDCx and calls donate in Clarity 4/5 as-contract?, tx-sender is the contract principal, so refund-many will later attempt to transfer USDCx back to the contract principal (who). Standardizing on contract-caller (or asserting (is-eq tx-sender contract-caller)) prevents cross-contract phishing where a malicious contract tricks a user into calling it and silently drains their USDCx into a non-refundable state (since gifts are final while the seat window is active).

Clarinet / Vitest Reproduction

it('F-03 repro: untrusted proxy contract called by a user can pull user USDCx via tx-sender in donate', function () {
  const donor = wallet(2);
  fund(donor, 10_000_000n);
  const phishingSrc = `
    (define-public (claim-airdrop)
      (contract-call? '${contractId()} donate u10000000 true))
  `;
  simnet.deployContract('phishing-proxy', phishingSrc, { clarityVersion: 5 }, wallet(1));
  // Donor calls claim-airdrop on wallet(1).phishing-proxy; without post-conditions or tx-sender==contract-caller check,
  // donate pulls 10 USDCx from tx-sender (donor)
  expect(simnet.callPublicFn(`${wallet(1)}.phishing-proxy`, 'claim-airdrop', [], donor).result).toBeOk(Cl.uint(10_000_000n));
});

Recommended Fix

Use contract-caller in donate (or assert (is-eq tx-sender contract-caller) if only direct EOA calls are intended).

[F-04] Low — Direct Transfers of USDCx, STX, or sBTC After claim Are Permanently Frozen

Description

TERMS (line 51) states:

"USDCx sent to the contract without donate is not refundable and goes to LAB with its claim."

Once either release-seat (success) or claim-reserve (failure) executes, (var-get claim) is set to (some ...). Both check-seat (line 222) and claim-reserve (line 431) assert (is-none (var-get claim)) ERR_CLAIMED. Consequently, any USDCx, STX, or sBTC transferred directly to current-contract after claim is set can never be swept by LAB or anyone else and is permanently stuck in the contract.

Clarinet / Vitest Reproduction

it('F-04 repro: direct USDCx sent after release-seat or claim-reserve is permanently stuck (u106)', function () {
  give(TARGET, wallet(1));
  requestSeat();
  release();
  fund(wallet(3), 5_000_000n);
  sendUsdcx(wallet(3), contractId(), 5_000_000n);
  // Neither release-seat nor claim-reserve can sweep the 5 USDCx to LAB:
  expect(release().result).toBeErr(Cl.uint(106));
  toBurn(DEADLINE + SEAT_WINDOW + 1);
  expect(claimReserve().result).toBeErr(Cl.uint(106));
  expect(usdcx(contractId())).toBe(5_000_000n); // permanently locked
});

[F-05] Low — TERMS Statement "no one else can add to the reserve" Is Unenforced On-Chain

Description

Both TERMS (lines 32–33, 39) and DONOR_TERMS (lines 66–67, 73) explicitly state:

"which that wallet deposits once, in full minus STX network fees, into this contract with deposit-reserve; **no one else can add to the reserve... **The contract enforces all of this."

However, lines 163–169 define the reserve as the contract's entire live STX and sBTC balances:

(define-read-only (reserve-stx)
  (stx-get-balance current-contract)
)
(define-read-only (reserve-sats)
  (unwrap-panic (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token get-balance current-contract))
)

Because Clarity contracts cannot block incoming stx-transfer? or SIP-010 sbtc-token transfer calls to current-contract, any third-party wallet can add STX or sBTC to reserve-stx / reserve-sats at any time without calling deposit-reserve.

Clarinet / Vitest Reproduction

it('F-05 repro: non-fundraiser wallet can increase reserve-stx and reserve-sats via direct transfer', function () {
  reserve(RSTX, RSATS);
  expect(status()['reserve-stx']).toBeUint(RSTX);
  // Random wallet(3) transfers 100 STX directly to the contract
  expect(simnet.transferSTX(100_000_000n, contractId(), wallet(3)).result).toBeOk(Cl.bool(true));
  expect(status()['reserve-stx']).toBeUint(RSTX + 100_000_000n); // reserve increased by non-fundraiser
});

Recommended Fix

Either track the fundraiser's exact deposited amounts in data-vars (while still sweeping any extra direct STX/sBTC to LAB), or reword TERMS from "no one else can add to the reserve" to "only that wallet can call deposit-reserve; any STX or sBTC sent directly to the contract also goes to LAB".

[F-06] Informational — get-status.closed Stays false on Failure Path When unrefunded == u0 Until LAB Calls claim-reserve

Description

In get-status (line 206):

closed: (or (seat-claimed) (and (is-some (var-get claim)) (is-eq (var-get unrefunded) u0)))

If the campaign fails (burn-block-height >= (failure-at)), all donors are refunded (unrefunded == u0), and no reserve was ever deposited (reserve-stx == u0, reserve-sats == u0, extra == u0), closed remains false forever unless LAB spends gas to call claim-reserve for u0 tokens.
ধারণা/Fix: Consider (or (seat-claimed) (and (is-eq (var-get unrefunded) u0) (or (is-some (var-get claim)) (and (>= burn-block-height (failure-at)) (is-eq (reserve-stx) u0) (is-eq (reserve-sats) u0) (is-eq (usdcx-balance) u0))))).

Share