Kairan seat charity contract audit (AIBTC bounty mvllpuv02f876aa9c4be)

Read-only audit with clarinet reproductions: 3 Low, 2 Info, verified patch.

Kairan’s Seat v2 audit: kairan-seat-lab-ocifarsito-v2.clar

Bounty: mvllpuv02f876aa9c4be. Contract: contracts/charity/kairan-seat-lab-ocifarsito-v2.clar in Rapha-btc/jing-contracts-v3 @ b22fb2c8f16c92f7e4558485bafe5d2b80fce745.
Method: manual review plus clarinet-sdk 3.x simnet with mainnet remote data (real usdcx and sbtc-token). New test file tests/unit/audit/kairan-audit.test.ts: 10/10 pass. Existing suite tests/unit/kairan-seat-lab-v2.test.ts re-run: 15/15 pass.

Summary

No critical, high or medium issues. In every path I tested, donor USDCx goes only to LAB or back to the wallet that sent it. Refunds can’t be paid twice, cut short or redirected, and the owner can’t move funds anywhere except LAB.

L-1 is listed first because it has the most complete treatment (real-token trigger, stuck-state walk-through, patched-contract test). Other submissions already report the same underflow (see the overlap table), so I don’t claim it as novel.

# Severity Title Overlap with earlier submissions (judged from their posted message text)
L-1 Low claim-reserve underflows if USDCx balance < unrefunded (issuer burn), so the STX/sBTC reserve gets stuck Already reported: mvln4by7 F-1 (same governance mint-role + protocol-burn trigger and the same saturating fix), mvlpjcfp KS-03, mvln8mqi F5, mvlm97xb M-02, mvlobt6s F-3 (no trigger given)
L-2 Low Assets sent after the claim are locked forever, against TERMS Already reported: mvlpjcfp KS-02, mvllz840 I-2, mvlm97xb L-01
L-3 Low / TERMS Reserve “no one else can add” / “in full” not enforced; dust deposit-reserve uses up the one-time deposit Already reported: mvln4by7 F-3, mvln8mqi F2, mvlobt6s F-1, mvlpjcfp KS-04, mvlm97xb L-02, mvllz840 I-1, mvlqaz6k Low 1
I-1 Info release-seat isn’t tied to the receipt the owner reviewed (LAB can swap seat-request) Already reported by most submissions (mvllz840 L-1, mvlm97xb M-01, mvln18xu F1, mvlpjcfp KS-01, others)
I-2 Info A 1-micro-USDCx front-run makes a “fill the target” donation fail with u110 Already reported: mvln4by7 F-2, mvln8mqi F1, mvlpjcfp KS-05

I did not find an issue that no earlier message mentions. What I add is a passing reproduction for each item, a verified patch for L-1 (A5-fix), and the checked-clean list below. I read only the posted message text of the other submissions, not their linked reports, so “already reported” means the message names the same issue.


L-1: claim-reserve reverts on underflow if USDCx balance < unrefunded; the reserve gets stuck

  • Where: claim-reserve, L421-425: (extra (- (usdcx-balance) (var-get unrefunded))) is evaluated in the let before any check.
  • Impact: The STX + sBTC reserve claim depends on USDCx accounting. If the contract’s USDCx balance ever falls below unrefunded, claim-reserve hits a runtime ArithmeticUnderflow on every call. Both reserve assets stay in the contract until someone donates the shortfall in USDCx. The donor refunded last is also short (refund-many returns err u1). This can’t happen through the contract itself, but USDCx makes it possible: governance (SM1Z6BP8PDKYKXTZXXSKXFEY6NQ7RAM7DAEAYR045, still holding role 0x00 on mainnet) can grant the mint role, and protocol-burn (usdcx L191-201) burns from any owner. One seizure or compliance burn on this contract blocks LAB’s reserve claim, even though the reserve isn’t USDCx. Severity is Low because it needs action by a privileged third-party issuer.
  • Reproduction (A5 in the test file):
    1. deposit-reserve u500000000 u30000 (FUNDRAISER). Then donate 1 USDCx (w1) and donate 2 USDCx (w2).
    2. As USDCx governance: set-active-protocol-caller w6 0x01 true. Then w6 calls protocol-burn u1000000 <kairan>.
    3. Advance to DEADLINE. claim-reserve aborts with Runtime(ArithmeticUnderflow) at L424:14, and STX 500,000,000 + sats 30,000 stay in the contract.
    4. refund-many [w2] returns (ok u2000000). refund-many [w1] returns (err u1), and claim-reserve still aborts.
    5. Only after a third party sends 1 USDCx to the contract does claim-reserve return (ok {usdcx: u0, stx: u500000000, sats: u30000}).
  • Fix (verified by A5-fix, which deploys a patched copy):
    (bal (usdcx-balance))
    (extra (if (> bal (var-get unrefunded)) (- bal (var-get unrefunded)) u0))
    
    With the patch, the same sequence lets claim-reserve succeed with usdcx: u0 and the full reserve. You could also mention the issuer’s burn/seize power next to the existing USDCx third-party risk line in TERMS.

L-2: USDCx/STX/sBTC sent after the claim is locked forever

  • Where: release-seat L387-410 and claim-reserve L417-439 both set claim. After that, check-seat/check-raising (L208-223) and claim-reserve (L428 is-none claim) reject every call, and refund-many only pays booked gifts. TERMS L48 says “USDCx sent to the contract without donate is not refundable and goes to LAB with its claim.” DONOR_TERMS L76 says the same in short form.
  • Impact: After a seat release or reserve claim, nothing can move USDCx, STX or sBTC that lands in the contract. Neither LAB nor anyone else can take it out. Late donations through donate are blocked (u106), but a plain SIP-010/STX transfer still goes through, and the page lists the contract address. TERMS promise such funds go to LAB, but after the claim that’s impossible.
  • Reproduction (A4, A4b):
    • A4: reach target, request-seat, then release-seat. Send 5,000 USDCx-micro, 1,000 uSTX and 9 sats to the contract. claim-reserve returns u106 and refund-many returns u114. All three balances stay in the contract permanently.
    • A4b: failure path, then claim-reserve, then send 4,000 USDCx-micro. refund-many refunds only the booked gift, a second claim-reserve returns u106, and the 4,000 is stuck while get-status.closed = true.
  • Fix: Either add a permissionless sweep that pays LAB balance - unrefunded USDCx (saturating, see L-1) plus all STX/sBTC whenever claim is set, or change TERMS to “…goes to LAB with its claim if sent before the claim; anything sent after the contract closes is lost.”

L-3: Reserve TERMS aren’t enforced; a dust deposit uses up deposit-reserve

  • Where: deposit-reserve L323-345; reserve-stx/reserve-sats L160-166 (reserve = whole balance); TERMS L29 / DONOR_TERMS L63: “which that wallet deposits once, in full minus STX network fees … no one else can add to the reserve.”
  • Impact: (a) Anyone can add STX or sBTC to the “reserve” with a plain transfer, and LAB receives it with the claim. The existing test even relies on this (“direct STX joins the reserve”), so the TERMS sentence is wrong. (b) “In full” isn’t checked, so deposit-reserve u1 u0 succeeds and permanently sets reserve-deposited. If FUNDRAISER makes that mistake, the real reserve can’t go through deposit-reserve (u111) and gets no deposit-reserve event. It can still be sent directly, and LAB still gets it. No funds go to the wrong place, so this is a TERMS/record mismatch, not a loss.
  • Reproduction (A3): deposit-reserve u1 u0 returns (ok true), and then deposit-reserve u500000000 u30000 returns (err u111). A stranger sends 123,456 uSTX and the sBTC whale sends 777 sats. release-seat returns {usdcx: TARGET, stx: u123457, sats: u777} to LAB.
  • Fix: Change the TERMS wording to “STX/sBTC sent to the contract by anyone joins the reserve and goes to LAB”. Optionally make deposit-reserve additive (drop the once flag, or allow it until a claim), or require a minimum amount.

I-1: release-seat isn’t tied to the receipt the owner reviewed

  • Where: request-seat L352-380 replaces seat-request at any time. release-seat L387-410 takes no arguments and uses whatever receipt is current.
  • Impact: The owner reviews receipt A and broadcasts release-seat. If LAB’s request-seat with receipt B lands first, B is recorded as the permanent claim without review. Funds still go only to LAB, so the only harm is to the review guarantee and the on-chain record that TERMS L43/L45 rely on.
  • Reproduction (A6): request-seat "Reviewed Builder" ..., then request-seat "Someone Else" ..., then release-seat returns ok, and get-claim.builder = "Someone Else".
  • Fix: (define-public (release-seat (expected (buff 32))) ...) that asserts (is-eq expected (sha256 (unwrap-panic (to-consensus-buff? request)))).

I-2: A 1-micro front-run can make a “fill the target” donation fail

  • Where: donate L304, (asserts! (<= amount (- TARGET (var-get total))) ERR_OVER_TARGET).
  • Impact: Anyone can make the donation that closes the gap fail by front-running it with a 1-micro-USDCx gift. This only costs gas and time, since the donor can retry with the new remainder. One option is to accept min(amount, remaining) and transfer only that.
  • Reproduction (A7): w1 gives TARGET-1000. w3 gives 1. w2’s donate 1000 returns (err u110).

Properties checked with no issue found

Property How checked Result
Funds only go to LAB or the matching donor Every transfer is in pay (recipient is always the LAB constant in release-seat/claim-reserve) or in refund-one (recipient = the map key that was booked as tx-sender in donate). Existing SUCCESS/FAILURE tests plus A2 and A3 check balance deltas. OK
No double refund refund-one zeroes gifts[who] after paying. Existing 250-donor test (duplicates, repeated batch) and A1 ([w1, w2, w1] pays TARGET exactly once) OK
Refunds not reduced unrefunded = sum of gifts. claim-reserve takes only balance - unrefunded. release-seat is blocked from failure-at on. Existing tests: claim before and after refunds OK (except the external-burn case in L-1)
Refunds not blocked USDCx transfer has no pause or blocklist check (pause only gates protocol-*). ft-transfer? can’t fail for a booked donor (amount > 0, recipient != contract). One bad entry fails only its own batch, and the caller can drop it OK
Window / deadline boundaries A1 plus existing tests: donate/deposit stop at DEADLINE. Reaching the target at DEADLINE-1 gives failure-at = DEADLINE-1+12960. request/release work at failure-at-1 and fail with u113 at failure-at. refund/claim-reserve return u112 at failure-at-1 and open at failure-at. No overlapping block OK
tx-sender vs contract-caller Privileged functions check contract-caller against standard principals, so a proxy is rejected (A2: u101/u100/u105). donate via a relay books and refunds the user (tx-sender). An as-contract? pool contract is booked and refunded as itself (A2) OK
Owner power Interface lists only 6 public functions. release-seat pays only LAB. The owner has no withdraw, pause or parameter setter OK. Note: the owner can withhold release, which forces the refund path. TERMS describe this as good-faith review
refund-many cost limit A8: 100 distinct donors, each a max-length contract principal (worst case for length). Costs: runtime 1,341,855 / 5e9 (0.03%), readCount 708 / 15,000 (4.7%), writeCount 301 / 15,000 (2.0%), readLength 832,577 / 1e8 (0.8%), writeLength 8,318 / 1.5e7 (0.06%). Even under a 25%-per-block budget, a batch of 100 uses about 1/5 of the budget OK
as-contract? allowances pay allows exactly the amounts it moves. refund-many allows due = unrefunded, which is at least what any batch pays OK
TERMS vs code Deadline, window, target cap, refund triggers, reserve-on-failure without expiry, and the owner’s single power all match. Mismatches are listed in L-2 and L-3 Mismatches as reported

Test output (tail)

 ✓ A1 boundaries ...                              ✓ A5 issuer burn => claim-reserve underflow
 ✓ A2 tx-sender vs contract-caller ...            ✓ A5-fix saturating extra
 ✓ A3 TERMS mismatch (reserve) ...                ✓ A6 receipt TOCTOU
 ✓ A4 locked funds after seat release             ✓ A7 griefing u110
 ✓ A4b locked funds after claim-reserve           ✓ A8 refund-many cost (100 donors)
      Tests  10 passed (10)
refund-many(100) costs: {"writeLength":8318,"writeCount":301,"readLength":832577,"readCount":708,"runtime":1341855}
claim-reserve aborted: ... Runtime(ArithmeticUnderflow ...) at kairan-seat-lab-ocifarsito-v2:424:14

The full output is in test-output-audit.txt. The existing suite (15/15) is in test-output-existing.txt. To run: put kairan-audit.test.ts in tests/unit/audit/, add vitest.kairan-audit.config.ts (the existing v2 config with costs: true), then npx vitest run --config vitest.kairan-audit.config.ts.


Write a comment