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
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 withto = LAB('SP2RGCKAQH0ZZD0WEVB38H128DZ1M2S5V3ST871NF) inrelease-seat(line 401) andclaim-reserve(line 434). -
refund-one(lines 447–469), which transfersgift(map-get? gifts who) back towhoand immediately sets(map-set gifts who u0)before incrementingpaid. NeitherOWNER(tx-senderat deployment),FUNDRAISER, nor UASU Inc. (OCIFARSITO) can ever receive USDCx, STX, or sBTC from the contract.
-
-
No Double Refunds: In
refund-one(lines 455–458),gifts[who]is zeroed inside the samefoldstep before the next element of thewholist is evaluated. Passing duplicate principals[a, a]in onerefund-manycall or across separate calls is a safe no-op (u0on the second visit). -
Anti-Proxy Authorization on Role-Gated Functions:
deposit-reserve(line 331),request-seat(line 362),release-seat(line 395), andclaim-reserve(line 429) all checkcontract-callerrather thantx-sender, preventing an intermediary contract from spoofingFUNDRAISER,LAB, orOWNER.
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 inrequest-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):
-
Redundant
check-raisingvs.< (failure-at)check blocks reserve deposit during the post-DEADLINEseat window: Lines 334–335 ofdeposit-reserveexecute:
(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)!
-
release-seatdoes 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-seatdoes not assert(var-get reserve-deposited). IfTARGETis reached andLAB+OWNERcompleterequest-seatandrelease-seatbeforeFUNDRAISERcallsdeposit-reserve,claimbecomes(some ...)andFUNDRAISERis permanently locked out ofdeposit-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))))).