Date: 2026-04-03
Auditor: Claude Sonnet 4.6 (clarity-audit skill)
Repository: friedger/clarity-ccip-026
Audit Phases: 1 (Redemption), 2 (Governance), 3 (Spec Delta), 4 (Patterns & Coverage)
Scope: Near-deployment security and correctness review following at-block removal refactor
The CCIP-026 MiamiCoin Burn-to-Exit proposal and its associated redemption extension have been reviewed across four phases covering contract correctness, security, specification alignment, pattern compliance, and test coverage.
Overall Verdict: CONDITIONAL_PASS Overall Risk Level: LOW-MEDIUM Deployment Recommendation: Approved with required pre-deployment actions
Both contracts correctly implement the core burn-to-exit mechanism. No critical or high-severity security vulnerabilities were identified. The Merkle proof-based voting architecture is sound, the redemption ratio formula is correct, and the authorization model is properly constructed. The most significant concerns are:
- A token accounting precision loss in the v1 burn path (BUG-01) that causes minor over-payment of STX relative to tokens burned — bounded at ~0.005 STX per transaction.
- A semantic block-height domain mismatch in
execute()that writes a Stacks block height into a field representing a Bitcoin block height — functionally safe but semantically incorrect. - An empty cryptographic hash field in contract metadata that must be filled before deployment.
- A governance spec that predates the Merkle proof architecture and requires an Activation section rewrite.
None of the findings represent a fund-drain vulnerability, governance bypass, or systemic integrity failure at the bounds tested. The Rendezvous fuzz test invariants confirm the key mathematical properties. The contracts are ready for deployment once the items in the Pre-Deployment Checklist are addressed.
| Contract | Lines (approx.) | Purpose |
|---|---|---|
contracts/ccd013-burn-to-exit-mia.clar |
~370 | Redemption extension: burns MIA v1/v2, transfers proportional STX from rewards treasury. Implements extension-trait. |
contracts/ccip026-miamicoin-burn-to-exit.clar |
~430 | Governance proposal: Merkle proof-based voting on cycles 82/83 snapshot. Enables ccd013 on passage. Implements proposal-trait. |
Supporting files reviewed:
tests/merkle-helpers.ts— TypeScript Merkle tree builder (mirrors Clarity verifier)simulations/calculate-mia-votes.ts— Off-chain vote calculation toolingcontracts/ccd013-burn-to-exit-mia.tests.clar— Rendezvous fuzz tests (ccd013)contracts/ccip026-miamicoin-burn-to-exit.tests.clar— Rendezvous fuzz tests (ccip026)tests/ccd013-burn-to-exit-mia_test.clar— Clarunit teststests/ccip026-miamicoin-burn-to-exit_flow_test.clar— Clarunit flow testccips/ccip-026/ccip-026-miamicoin-burn-to-exit.md— Governance specification
| Severity | ccd013 | ccip026 | Total |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 0 |
| Medium | 4 | 1 | 5 |
| Low | 4 | 5 | 9 |
| Informational | 0 | 3 | 3 |
| Total | 8 | 9 | 17 |
| ID | Severity | Contract | Title |
|---|---|---|---|
| BUG-01 | Medium | ccd013 | v1 burn rounding causes phantom micro-MIA in STX calculation |
| BUG-02 | Medium | ccd013 | Treasury balance snapshotted while stacking may still be locked |
| BUG-03 | Medium | ccd013 | Same error code for call failure and zero balance |
| BUG-04 | Medium | ccd013 | get-user-redemption-info quotes full balance, ignores MAX_PER_TRANSACTION cap |
| BUG-05 | Medium | ccip026 | execute() writes stacks-block-height into burn-block-height field (voteEnd) |
| DESIGN-01 | Low | ccd013 | Authorization check after side-effectful let bindings |
| DESIGN-02 | Low | ccd013 | scale-up / scale-down private functions are dead code |
| DESIGN-03 | Low | ccd013 | Error code 13008 absent without explanation |
| DESIGN-04 | Low | ccd013 | Rendezvous invariant invariant-total-transferred-leq-balance may be too strict post-stacking-payouts |
| DESIGN-05 | Low | ccip026 | ERR_PANIC is unreachable dead code |
| DESIGN-06 | Low | ccip026 | ERR_SAVING_VOTE is unreachable dead code |
| DESIGN-07 | Low | ccip026 | is-none current branch in fold-proof-step-inner is dead code |
| DESIGN-08 | Low | ccip026 | CCIP_026 metadata hash field is empty |
| DESIGN-09 | Low | ccip026 | Simulation script references non-existent set-snapshot-root function |
| INFO-01 | Informational | ccip026 | Proof depth (9) is at exact limit for current snapshot size |
| INFO-02 | Informational | ccip026 | is-executable compares voter count, not MIA-weighted amount |
| INFO-03 | Informational | ccip026 | v1 burn delegates to miamicoin-core-v1-patch (correct, but undocumented) |
Title: v1 burn rounding causes phantom micro-MIA in STX calculation
Location: redeem-mia, line 177
(redemptionV1InMia (/ redemptionAmountUMiaV1 MICRO_CITYCOINS))Description: MIA v1 tokens are denominated in whole tokens, not micro tokens. The conversion redemptionAmountUMiaV1 / MICRO_CITYCOINS truncates any sub-whole-token remainder. The STX calculation at line 186 uses redemptionTotalUMia, which retains the pre-truncation amount. This means a user is reimbursed in STX for the full micro-MIA value, but the actual v1 burn only covers the truncated (floor) amount. The difference — at most MICRO_CITYCOINS - 1 = 999,999 micro-MIA per transaction — is received as STX without the corresponding token burn.
Impact: Minor over-payment. At the mainnet STX/MIA ratio of ~5 uSTX per uMIA, the maximum rounding gift per transaction is ~4,999,995 uSTX (~0.005 STX). Not exploitable to a meaningful degree given MAX_PER_TRANSACTION limits, but represents an unintended transfer from the treasury.
Recommendation:
(redemptionV1InMia (/ redemptionAmountUMiaV1 MICRO_CITYCOINS))
(actualBurnedUMiaV1 (* redemptionV1InMia MICRO_CITYCOINS))
(remainingAmountUMia (- maxAmountUMia actualBurnedUMiaV1))
;; Use actualBurnedUMiaV1 in redemptionTotalUMia, not redemptionAmountUMiaV1Test Coverage: NOT EXERCISED. All existing v1 test cases use balances that are exact multiples of MICRO_CITYCOINS. The Rendezvous test-redeem-mia-ratio-consistency uses the reported uMia return value (which includes the phantom amount), masking the discrepancy. See GAP-01.
Title: Treasury balance snapshotted while stacking may still be locked
Location: initialize-redemption, line 101 vs lines 115-118
Description: miaRedemptionBalance is computed at the start of the let block, before revoke-delegate-stx is called. stx-get-balance returns only the unlocked STX balance. If the treasury has stacked STX that has not yet unlocked at initialization time, the ratio is calculated against only the unlocked portion. After stacking completes and unlocks, the remaining STX is stranded — no second initialization is permitted, and there is no mechanism to claim the additional unlocked portion into the redemption ratio.
Impact: If initialized mid-cycle, the effective redemption ratio may be significantly lower than the eventual treasury total. The Rendezvous invariant documents two valid fork totals (31,767,086,308 and 953,618,961,322 uSTX), confirming this bifurcation is a known consequence of the at-block removal design. This is an architectural tradeoff, not an introduced bug, but it should be explicitly documented.
Recommendation: Document this behavior in the contract header. Optionally expose a read-only function get-redemption-locked-stx returning (get locked (stx-account treasury)) so the community can observe any locked amount post-initialization and understand the ratio basis.
Test Coverage: PARTIALLY COVERED. The bifurcation is acknowledged in the Rendezvous invariant but not directly tested against a mid-cycle vs. end-of-cycle initialization scenario.
Title: Same error code for call failure and zero balance
Location: redeem-mia, lines 147-159 and line 192
Description: ERR_BALANCE_NOT_FOUND (err u13006) is returned for two distinct failure modes: (a) the get-balance contract call itself fails, and (b) the combined balance evaluates to zero. The error name implies a missing balance record, but the most common trigger is "user holds zero MIA." This ambiguity complicates error handling in integrators and front-ends.
Impact: Developer experience / integration ergonomics. No security or fund-safety implications.
Recommendation: Introduce ERR_ZERO_BALANCE (err u13008) (filling the unexplained gap at 13008) for the zero-balance case at line 192. Reserve ERR_BALANCE_NOT_FOUND for call failures.
Test Coverage: PARTIALLY COVERED. The zero-balance path is tested at lines 103 and 128 of the vitest suite. The call-failure path (token contract returning an error) is not tested and is not easily exercisable in the MXS mainnet fork.
Title: get-user-redemption-info quotes full balance, ignores MAX_PER_TRANSACTION cap
Location: get-user-redemption-info, line 323
Description:
(redemptionAmount (default-to u0 (get-redemption-for-balance (get totalBalance miaBalances))))For holders with more than 10M MIA, this returns the STX equivalent of their full balance — not what a single redeem-mia transaction would actually pay out. A holder with 50M MIA sees a quote for 50M MIA worth of STX, but each call only processes 10M MIA.
Impact: Misleading UX for large holders. No safety or fund-loss implications (read-only function).
Recommendation:
(cappedBalance (if (> (get totalBalance miaBalances) MAX_PER_TRANSACTION)
MAX_PER_TRANSACTION
(get totalBalance miaBalances)
))
(redemptionAmount (default-to u0 (get-redemption-for-balance cappedBalance)))Test Coverage: NOT COVERED. The test user SP39EH... holds ~321M MIA but get-user-redemption-info is only read post-redemption (balance exhausted), not pre-redemption with a large balance. See GAP-02.
Title: execute() writes stacks-block-height into burn-block-height field (voteEnd)
Location: execute, line 107
(var-set voteEnd stacks-block-height)Description: voteEnd is used throughout the contract as a Bitcoin block height (burn-block-height). voteStart is set with burn-block-height, and all vote-window comparisons use burn-block-height. After execute runs, voteEnd holds a Stacks block height (~3.5M) while callers reading get-vote-period expect it to be a Bitcoin block height (~900K). Stacks block heights are approximately 4x larger than corresponding Bitcoin block heights.
Functional impact: voteActive is also set to false in execute, and is-vote-active short-circuits on that flag — preventing further votes regardless of the block comparison. The contract is functionally safe. However, get-vote-period returns a semantically invalid endBlock post-execution, which will mislead indexers, explorers, and any UI reading vote period state.
Recommendation:
(var-set voteEnd burn-block-height)Test Coverage: NOT COVERED. No test reads get-vote-period after execute is called to verify the endBlock value. See GAP-03.
Title: Authorization check after side-effectful let bindings
Location: initialize-redemption, lines 98-105
Description: The let block at lines 98-102 makes three external contract reads (get-total-supply x2, get-redemption-current-balance) before the (try! (is-dao-or-extension)) check at line 105. The reads are side-effect-free and do not pose a security risk, but they consume gas for unauthorized callers and deviate from the idiomatic pattern of auth-first.
Recommendation: Consider restructuring to place authorization before external reads. In Clarity's eager-evaluation model, this requires moving the let bindings after the auth assertion, using nested let or a wrapper function.
Title: scale-up / scale-down private functions are dead code
Location: Lines 364-370
Description: Both functions are defined (credited to ALEX math-fixed-point-16.clar) but never called. All fixed-point arithmetic is inlined at the call sites.
Recommendation: Remove both functions or inline a reference comment explaining they are retained for future use.
Title: Error code 13008 absent without explanation
Location: Constants block (lines 19-27)
Description: Error codes jump from ERR_NOTHING_TO_REDEEM (err u13007) to ERR_SUPPLY_CALCULATION (err u13009), skipping 13008. No comment explains the gap.
Recommendation: Either document the gap with a comment or use 13008 for the recommended ERR_ZERO_BALANCE from BUG-03.
Title: Rendezvous invariant may fire false positive after stacking payouts
Location: invariant-total-transferred-leq-balance in contracts/ccd013-burn-to-exit-mia.tests.clar
Description: The invariant checks totalTransferred <= contractBalance where contractBalance is the STX snapshot at initialization. The spec explicitly states the rewards treasury will continue to receive stacking payouts post-initialization. If payout deposits increase the treasury above its initial snapshot, cumulative totalTransferred could eventually exceed the initial contractBalance while remaining well within the live treasury balance.
Recommendation: Review and update the invariant to check against live treasury balance rather than the initialization snapshot, or document the known limitation with a comment.
Title: ERR_PANIC is unreachable dead code
Location: is-executable, line 209
Description: (unwrap! (get-vote-totals) ERR_PANIC) — but get-vote-totals always returns (some {...}) via default-to, so the unwrap! path can never trigger.
Recommendation: Document the invariant with a comment, or simplify by inlining the tuple construction to eliminate the optional chain.
Title: ERR_SAVING_VOTE is unreachable dead code
Location: vote-on-proposal, line 183
Description: map-insert is guarded by ERR_SAVING_VOTE in the none branch of (match voterRecord ...). Because we reach this branch only when map-get? returns none, and Clarity is single-threaded, the insert cannot fail.
Recommendation: Add a comment documenting this as defensive code, or simplify.
Title: is-none current branch in fold-proof-step-inner is dead code
Location: fold-proof-step-inner, line 374
Description: current is initialized to (some leaf) and each fold step replaces it with (some parent). The (is-none current) early-exit branch was likely a design artifact from an earlier implementation and can never execute.
Recommendation: Remove or add a comment explaining this is kept for defensive clarity.
Title: CCIP_026 metadata hash field is empty
Location: CCIP_026 constant, line 30
Description:
(define-constant CCIP_026 { name: "MiamiCoin Burn to Exit", link: "https://...", hash: "" })The link field correctly pins a specific git commit (eea941ea). The hash field is intended to hold a SHA-256 commitment to the spec document content, providing tamper-evidence for the linked proposal. It is empty.
Impact: The on-chain proposal is not cryptographically bound to its specification document. An empty hash is a deployment blocker — it should be filled before the contract is deployed.
Recommendation: Compute SHA256(spec document contents) and populate hash before deployment. Update the corresponding test assertion from hash: "" to the actual hash value.
Title: Simulation script references non-existent set-snapshot-root function
Location: simulations/calculate-mia-votes.ts, output section
Description: The script outputs a Clarity call to set-snapshot-root, which does not exist. snapshotMerkleRoot is a define-constant — immutable after deployment. The script predates the architectural decision to hardcode the root.
Recommendation: Update the script output to remove or replace this line with a comment explaining the root is baked in at deployment. The script's computed root value was used to generate the constant — that context should be preserved.
Title: Proof depth (9) is at exact limit for current snapshot size
Description: The current stacking snapshot contains ~300 eligible voters, padded to a 512-leaf tree (depth 9). The contract defines proofs as (list 9 (buff 32)). This is sufficient for the current snapshot but cannot accommodate a future snapshot with more than 512 voters without changing the contract type. Since the snapshot is hardcoded and the contract is immutable post-deployment, this is not an active risk — but it should be documented.
Recommendation: Add a comment near PROOF_INDICES documenting the depth limit and its relationship to snapshot size.
Title: is-executable compares voter count, not MIA-weighted amount
Description: (> totalVotesYes totalVotesNo) counts votes by address, not by MIA balance. The spec states "more yes votes than no votes" — this is consistent. However, the design choice (one address = one vote, regardless of stake) is not documented in the code.
Recommendation: Add a comment to is-executable noting this is a voter-count comparison per CCIP-015 compatibility.
Title: v1 burn delegates to miamicoin-core-v1-patch (correct, but undocumented)
Description: MIA v1 tokens use a patch contract (miamicoin-core-v1-patch) for burns rather than a standard SIP-010 burn interface. The delegation is correct but not explained anywhere in the contract.
Recommendation: Add an inline comment at the v1 burn call explaining why the patch contract is required for legacy v1 token burns.
Eight divergences were identified between the governance specification (ccips/ccip-026/ccip-026-miamicoin-burn-to-exit.md) and the contracts as implemented. Five are consequences of the at-block removal refactor; three are independent.
| Delta | Section | Cause | Severity | Action |
|---|---|---|---|---|
| D1 | Activation — voting methodology | at-block removal | High | Rewrite Activation section: CCIP-015 reference is obsolete; describe Merkle proof mechanism |
| D2 | Activation — scale factor | at-block removal | Medium | Replace "No scale factor is required" with explanation of VOTE_SCALE_FACTOR = 10^16 |
| D3 | Activation — vote function signature | at-block removal | High | Document 4-parameter signature: vote, scaledMiaVoteAmount, proof, positions |
| D4 | Activation — execute() block height record | at-block removal | Low | Note that recorded end block after execution is a Stacks block height |
| D5 | Activation — deployment prerequisites | at-block removal + typo | Medium | Add Merkle snapshot generation prerequisite; correct "mining" to "rewards" treasury |
| D6 | Activation — user registry requirement | Unrelated | Medium | Document ccd003-user-registry prerequisite for voters |
| D7 | Activation — vote tally criterion | Unrelated | Low | Clarify vote count (not token weight) determines passage |
| D8 | Specification — transaction unit | Clarification | Low | Specify "10,000,000 MIA = 10,000,000,000,000 micro-MIA" with unit note |
Priority 1 (before deployment): Deltas D1, D3, D5, D6 — functional blockers for community members trying to vote using only the spec as guidance.
Priority 2 (concurrent): Deltas D2, D7 — undocumented design decisions that affect voter understanding.
Priority 3 (minor): Deltas D4, D8 — low-stakes clarifications.
| Gap ID | Priority | Contract | Finding | Description |
|---|---|---|---|---|
| GAP-01 | High | ccd013 | BUG-01 | No test exercises a v1 balance with non-multiple-of-10^6 units. The Rendezvous property masks the bug by checking the reported uMia value rather than actual tokens burned. |
| GAP-02 | High | ccd013 | BUG-04 | No test calls get-user-redemption-info for a pre-redemption address holding >10M MIA to verify the misleading quote. |
| GAP-03 | High | ccip026 | BUG-05 | No test reads get-vote-period after execute() to detect the stacks-block-height/burn-block-height mismatch in voteEnd. |
| GAP-04 | High | ccip026 | DESIGN-08 | The test for get-proposal-info explicitly asserts hash: "" — it will pass even after deployment with an unfilled hash. Must be updated when DESIGN-08 is resolved. |
| GAP-05 | Medium | ccd013 | — | redeem-mia before initialize-redemption (ERR_NOT_ENABLED 13005) is not tested. |
| GAP-06 | Medium | ccd013 | — | initialize-redemption called twice (ERR_ALREADY_ENABLED 13004) is not tested. |
- Rendezvous invariants cover the key mathematical properties: ratio non-zero when enabled, total redeemed bounded by supply snapshot, treasury conservation (rewards treasury + redeemed = constant), mining treasury immutability.
- Merkle proof verification is tested end-to-end via
ccip026-miamicoin-burn-to-exit-vote.test.ts, including changed-vote, invalid proof, wrong amount, duplicate vote, and non-registered voter. - Vote tally correctness for all four vote-change direction combinations is verified.
- TypeScript/Clarity Merkle consistency is confirmed: leaf hash field order, parent hash construction, position semantics, and empty-proof edge case all match between the TypeScript builder and Clarity verifier.
test-redeem-mia-amount-cappeduses hardcodedu10000000000000instead of theMAX_PER_TRANSACTIONconstant — risk of drift if the constant changes.test-redeem-mia-ratio-consistencyallows ±1 uSTX tolerance (correct for fixed-point truncation) but uses the returneduMiavalue rather than actual burned tokens, masking BUG-01.- The ccip026 Rendezvous correctly works around Merkle-proof gating by testing
update-city-votesdirectly.
The following items must be completed before the contracts are deployed to mainnet. Items are grouped as blockers (must fix), recommended (should fix), and optional (low-risk improvements).
- [DESIGN-08] Populate
CCIP_026.hashfield. ComputeSHA256of the spec document and set thehashfield in theCCIP_026constant. Then update the corresponding test assertion fromhash: ""to the computed hash value. - [BUG-01] Fix v1 burn rounding in
redeem-mia. ComputeactualBurnedUMiaV1 = redemptionV1InMia * MICRO_CITYCOINSand use this value inredemptionTotalUMiainstead ofredemptionAmountUMiaV1. Prevents STX over-payment relative to v1 tokens burned. - [Spec — D1, D3, D6] Rewrite spec Activation section. Remove CCIP-015 reference; document Merkle proof voting mechanism, 4-parameter
vote-on-proposalsignature, off-chain proof tooling, and ccd003-user-registry prerequisite. - [GAP-01] Add BUG-01 regression test. Test
redeem-miawith a v1 holder whose balance is NOT a clean multiple of 10^6 micro-MIA. Verify thatmiaV1burned anduMiacredited are consistent after fix. - [GAP-04] Update
get-proposal-infotest. After fillingCCIP_026.hash, change the test assertion fromhash: ""to the actual hash value so the test becomes a guard rather than a confirmation of the unfilled state.
- [BUG-05] Fix
execute()to useburn-block-heightforvoteEnd. Change(var-set voteEnd stacks-block-height)to(var-set voteEnd burn-block-height). One-line fix. Prevents misleadingget-vote-periodoutput post-execution. - [BUG-04] Fix
get-user-redemption-infocap. ApplyMAX_PER_TRANSACTIONcap before computingredemptionAmountfor large holders. - [GAP-03] Add post-execute
get-vote-periodtest. Readget-vote-periodafterexecutecompletes and assertendBlockis a Bitcoin block height value (not a Stacks block height). - [GAP-05, GAP-06] Add guard tests. Test
redeem-miabefore initialization (expect ERR_NOT_ENABLED 13005) and test duplicateinitialize-redemptioncalls (expect ERR_ALREADY_ENABLED 13004). - [DESIGN-09] Update simulation script. Remove the
set-snapshot-rootoutput line fromsimulations/calculate-mia-votes.ts. Replace with a comment explaining that the computed root was used to setsnapshotMerkleRootas a constant. - [BUG-03] Add
ERR_ZERO_BALANCEerror code at 13008. Separate the zero-balance rejection from the call-failure error for integrator clarity.
- [DESIGN-02] Remove dead
scale-up/scale-downfunctions in ccd013, or document their retention with a comment. - [DESIGN-05, DESIGN-06, DESIGN-07] Document or remove dead code in ccip026 (
ERR_PANIC,ERR_SAVING_VOTE,is-none currentbranch). - [DESIGN-03] Explain error code 13008 gap with a comment, or fill it with the new
ERR_ZERO_BALANCE. - [INFO-01] Document proof depth limit near
PROOF_INDICESconstant (depth 9 = max 512 leaves). - [INFO-02] Add comment to
is-executablenoting voter-count (not token-weighted) comparison. - [INFO-03] Add comment to v1 burn call explaining why
miamicoin-core-v1-patchis required. - [Spec — D2, D7] Update spec with scale factor and tally clarifications for completeness.
- [DESIGN-01] Consider restructuring
initialize-redemptionto run auth before external reads (low urgency — reads are side-effect-free). - [DESIGN-04] Review Rendezvous invariant
invariant-total-transferred-leq-balancefor robustness against post-initialization stacking payout deposits. - [Rendezvous] Replace hardcoded
u10000000000000intest-redeem-mia-amount-cappedwith a reference toMAX_PER_TRANSACTION.
The CCIP-026 contracts represent a well-engineered burn-to-exit mechanism with sound cryptographic foundations. The Merkle proof-based voting architecture correctly mirrors the TypeScript tooling, domain separation between leaf and parent tags prevents second-preimage attacks, and the authorization model correctly uses tx-sender for token operations to prevent proxy attacks.
The most significant technical risk is BUG-01 (v1 rounding), which causes a minor STX over-payment per transaction. This is bounded and not economically exploitable, but it violates the accounting invariant that tokens burned should exactly equal the value reimbursed. It should be fixed before deployment.
The governance proposal has one semantic correctness issue (BUG-05: stacks vs. burn block height in voteEnd), one deployment blocker (DESIGN-08: empty spec hash), and a governance specification that requires a substantial rewrite of its Activation section to reflect the Merkle proof architecture.
Once the Blocker and Recommended items in the Pre-Deployment Checklist are addressed, the contracts will be production-ready.
Final Verdict: CONDITIONAL_PASS — ready for deployment after checklist completion.
Report generated from phases 1-4 of the ccip026-audit quest. Individual phase reports are available in .planning/2026-04-03-ccip026-audit/phases/.