# Kairan's Seat v2: underfunded `release-seat` still closes successfully **Severity:** Low (requires a privileged third-party USDCx protocol action) **Bounty:** `mvllpuv02f876aa9c4be` **Repository:** `Rapha-btc/jing-contracts-v3` **Reviewed commit:** `f01bffcd4d809e59b87a3ff818e385a6c35f0f40` **Contract SHA-256:** `0a1c081de6ef70e87d911f0039c131679c173fd55430975ef5b7032f406c9eb7` **Affected code:** `contracts/charity/kairan-seat-lab-ocifarsito-v2.clar`, lines 387–410 (especially 396–401) ## Finding `release-seat` pays LAB the escrow's current USDCx balance, sets `unrefunded` to zero, records a successful seat claim, and closes the contract without checking that the balance still covers the donor obligations. After donations reach `TARGET == 2_000_000_000` micro-USDCx and LAB files a seat request, a privileged USDCx `protocol-burn` can reduce the escrow balance. Even a one-micro-USDCx reduction makes `release-seat` return success with `usdcx = 1_999_999_999`, clear `unrefunded` from `2_000_000_000` to `0`, and permanently make donor refunds fail with `err u114`. The same logic accepts any shortfall, including a zero balance. The impact is an underfunded successful claim and loss of the failure/refund path; this is not an arbitrary-caller theft finding. The external token privilege requirement is why I rate it Low. ## Distinction from existing reports This uses the already-reported USDCx governance/mint-role `protocol-burn` trigger; I do not claim that trigger as new. Existing submissions show that a balance below `unrefunded` makes `claim-reserve` underflow at line 424. This finding concerns the opposite release-path behavior: `release-seat` does **not** revert. It pays the reduced balance and finalizes the seat claim. One existing report discusses a hypothetical future relaxation of `request-seat` that would permit `total < TARGET`. This reproduction requires no contract change and keeps `total == TARGET`; only the actual token balance is lower. ## Observed reproduction The focused Clarinet mainnet-remote-data test performed these steps: 1. A donor contributes exactly `2_000_000_000` micro-USDCx through `donate`. 2. LAB calls `request-seat` successfully. 3. USDCx governance grants a test principal the mint role, which calls `protocol-burn(1, escrow)`. 4. The escrow balance is `1_999_999_999`, while `total` and `unrefunded` remain `2_000_000_000`. 5. OWNER calls `release-seat`. Observed result: ```text release-seat => (ok {usdcx: u1999999999, stx: u0, sats: u0}) LAB balance delta => 1999999999 escrow balance => 0 status.total => u2000000000 status.unrefunded => u0 status.closed => true refund-many after the seat window => (err u114) ``` Two controls also passed: - Without the burn, `release-seat` pays exactly `2_000_000_000`. - With the remediation assertion below added to a fixture copy, the same burned-balance release returns `err u109`; LAB receives nothing, the escrow retains `1_999_999_999`, `unrefunded` remains `2_000_000_000`, and `closed` remains false. Focused test result: ```text Test Files 1 passed (1) Tests 3 passed (3) ``` ## Minimal runnable test From the reviewed repository, save the following as `tests/unit/kairan-seat-release-shortfall.test.ts`. It uses the repository's existing `Clarinet-kairan-seat-lab-v2.toml` mainnet-remote-data manifest. ```ts import { describe, expect, it } from 'vitest'; import { Cl } from '@stacks/transactions'; const CONTRACT = 'kairan-seat-lab-ocifarsito-v2'; const USDCX = 'SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx'; const GOVERNANCE = 'SM1Z6BP8PDKYKXTZXXSKXFEY6NQ7RAM7DAEAYR045'; const USDCX_WHALE = 'SP13JV946AF23WA94SVANA8D6VRTTYEWCJJ8Q5YMJ'; const LAB = 'SP2RGCKAQH0ZZD0WEVB38H128DZ1M2S5V3ST871NF'; const TARGET = 2_000_000_000n; function call(contract: string, fn: string, args: any[], sender: string) { return simnet.callPublicFn(contract, fn, args, sender); } function usdcx(who: string): bigint { return (simnet.callReadOnlyFn(USDCX, 'get-balance', [Cl.principal(who)], simnet.deployer).result as any).value.value; } function prepareSeat() { const donor = simnet.getAccounts().get('wallet_1')!; const burner = simnet.getAccounts().get('wallet_2')!; const escrow = `${simnet.deployer}.${CONTRACT}`; expect(call(USDCX, 'transfer', [Cl.uint(TARGET), Cl.principal(USDCX_WHALE), Cl.principal(donor), Cl.none()], USDCX_WHALE).result).toBeOk(Cl.bool(true)); expect(call(CONTRACT, 'donate', [Cl.uint(TARGET), Cl.bool(true)], donor).result).toBeOk(Cl.uint(TARGET)); expect(call(CONTRACT, 'request-seat', [Cl.stringAscii('Ada'), Cl.stringAscii('https://github.com/ada/repo'), Cl.stringAscii('https://letafricabuild.com/grant'), Cl.bool(true)], LAB).result).toBeOk(Cl.bool(true)); return { donor, burner, escrow }; } describe('release-seat balance shortfall', () => { it('normal control: pays TARGET', () => { const { escrow } = prepareSeat(); const before = usdcx(LAB); expect(call(CONTRACT, 'release-seat', [], simnet.deployer).result).toBeOk( Cl.tuple({ usdcx: Cl.uint(TARGET), stx: Cl.uint(0), sats: Cl.uint(0) }) ); expect(usdcx(LAB) - before).toBe(TARGET); expect(usdcx(escrow)).toBe(0n); }); it('pays less than TARGET, closes, and blocks refunds', () => { const { donor, burner, escrow } = prepareSeat(); expect(call(USDCX, 'set-active-protocol-caller', [Cl.principal(burner), Cl.bufferFromHex('01'), Cl.bool(true)], GOVERNANCE).result).toBeOk(Cl.bool(true)); expect(call(USDCX, 'protocol-burn', [Cl.uint(1), Cl.principal(escrow)], burner).result).toBeOk(Cl.bool(true)); expect(usdcx(escrow)).toBe(TARGET - 1n); const before = usdcx(LAB); expect(call(CONTRACT, 'release-seat', [], simnet.deployer).result).toBeOk( Cl.tuple({ usdcx: Cl.uint(TARGET - 1n), stx: Cl.uint(0), sats: Cl.uint(0) }) ); expect(usdcx(LAB) - before).toBe(TARGET - 1n); expect(usdcx(escrow)).toBe(0n); const status = (simnet.callReadOnlyFn(CONTRACT, 'get-status', [], simnet.deployer).result as any).value; expect(status.total).toBeUint(TARGET); expect(status.unrefunded).toBeUint(0); expect(status.closed).toBeBool(true); simnet.mineEmptyBurnBlocks(12_960); expect(call(CONTRACT, 'refund-many', [Cl.list([Cl.principal(donor)])], donor).result).toBeErr(Cl.uint(114)); }); }); ``` Save this as `vitest.kairan-seat-release-shortfall.config.ts`: ```ts import { defineConfig } from 'vitest/config'; import { vitestSetupFilePath } from '@stacks/clarinet-sdk/vitest'; export default defineConfig({ test: { include: ['tests/unit/kairan-seat-release-shortfall.test.ts'], testTimeout: 300000, environment: 'clarinet', pool: 'forks', poolOptions: { forks: { singleFork: true } }, setupFiles: [vitestSetupFilePath], environmentOptions: { clarinet: { manifestPath: './Clarinet-kairan-seat-lab-v2.toml', initBeforeEach: true, coverage: false, costs: false, }, }, }, }); ``` Run: ```bash npx vitest run --config vitest.kairan-seat-release-shortfall.config.ts --reporter=verbose ``` ## Remediation Before paying or changing claim state, require the actual balance to cover all outstanding gifts: ```clarity (asserts! (>= usdcx (var-get unrefunded)) ERR_TARGET_NOT_REACHED) ``` Place the assertion immediately after binding `usdcx` in `release-seat` and before `pay`. This preserves legitimate direct USDCx donations above the target while preventing an underfunded success claim. A separate shortfall recovery path may also be appropriate, since the same token-balance deficit already affects refunds and `claim-reserve`.