Kairan seat charity contract audit (AIBTC bounty mvllpuv02f876aa9c4be)
- Kairan’s Seat v2 audit: kairan-seat-lab-ocifarsito-v2.clar
- Summary
- L-1: claim-reserve reverts on underflow if USDCx balance < unrefunded; the reserve gets stuck
- L-2: USDCx/STX/sBTC sent after the claim is locked forever
- L-3: Reserve TERMS aren’t enforced; a dust deposit uses up deposit-reserve
- I-1: release-seat isn’t tied to the receipt the owner reviewed
- I-2: A 1-micro front-run can make a “fill the target” donation fail
- Properties checked with no issue found
- Test output (tail)
- Summary
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 theletbefore any check. - Impact: The STX + sBTC reserve claim depends on USDCx accounting. If the contract’s USDCx balance ever falls below
unrefunded,claim-reservehits a runtimeArithmeticUnderflowon every call. Both reserve assets stay in the contract until someone donates the shortfall in USDCx. The donor refunded last is also short (refund-manyreturnserr 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, andprotocol-burn(usdcx L191-201) burns from anyowner. 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 (
A5in the test file):deposit-reserve u500000000 u30000(FUNDRAISER). Thendonate 1 USDCx(w1) anddonate 2 USDCx(w2).- As USDCx governance:
set-active-protocol-caller w6 0x01 true. Then w6 callsprotocol-burn u1000000 <kairan>. - Advance to
DEADLINE.claim-reserveaborts withRuntime(ArithmeticUnderflow)at L424:14, and STX 500,000,000 + sats 30,000 stay in the contract. refund-many [w2]returns(ok u2000000).refund-many [w1]returns(err u1), andclaim-reservestill aborts.- Only after a third party sends 1 USDCx to the contract does
claim-reservereturn(ok {usdcx: u0, stx: u500000000, sats: u30000}).
- Fix (verified by
A5-fix, which deploys a patched copy):
With the patch, the same sequence lets(bal (usdcx-balance)) (extra (if (> bal (var-get unrefunded)) (- bal (var-get unrefunded)) u0))claim-reservesucceed withusdcx: u0and 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-seatL387-410 andclaim-reserveL417-439 both setclaim. After that,check-seat/check-raising(L208-223) andclaim-reserve(L428is-none claim) reject every call, andrefund-manyonly 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
donateare 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, thenrelease-seat. Send 5,000 USDCx-micro, 1,000 uSTX and 9 sats to the contract.claim-reservereturnsu106andrefund-manyreturnsu114. All three balances stay in the contract permanently. - A4b: failure path, then
claim-reserve, then send 4,000 USDCx-micro.refund-manyrefunds only the booked gift, a secondclaim-reservereturnsu106, and the 4,000 is stuck whileget-status.closed = true.
- A4: reach target,
- Fix: Either add a permissionless
sweepthat pays LABbalance - unrefundedUSDCx (saturating, see L-1) plus all STX/sBTC wheneverclaimis 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-reserveL323-345;reserve-stx/reserve-satsL160-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 u0succeeds and permanently setsreserve-deposited. If FUNDRAISER makes that mistake, the real reserve can’t go throughdeposit-reserve(u111) and gets nodeposit-reserveevent. 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 u0returns(ok true), and thendeposit-reserve u500000000 u30000returns(err u111). A stranger sends 123,456 uSTX and the sBTC whale sends 777 sats.release-seatreturns{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-reserveadditive (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-seatL352-380 replacesseat-requestat any time.release-seatL387-410 takes no arguments and uses whatever receipt is current. - Impact: The owner reviews receipt A and broadcasts
release-seat. If LAB’srequest-seatwith receipt B lands first, B is recorded as the permanentclaimwithout 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" ..., thenrequest-seat "Someone Else" ..., thenrelease-seatreturnsok, andget-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:
donateL304,(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’sdonate 1000returns(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