Skip to content

Instantly share code, notes, and snippets.

@whoabuddy
Created April 3, 2026 21:05
Show Gist options
  • Select an option

  • Save whoabuddy/cc4fe687e836f7cf79a8087596f3127b to your computer and use it in GitHub Desktop.

Select an option

Save whoabuddy/cc4fe687e836f7cf79a8087596f3127b to your computer and use it in GitHub Desktop.
CCIP-026 MiamiCoin Burn-to-Exit — Security Audit Report (2026-04-03)

CCIP-026 MiamiCoin Burn-to-Exit — Consolidated Audit Report

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


Executive Summary

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:

  1. 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.
  2. 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.
  3. An empty cryptographic hash field in contract metadata that must be filled before deployment.
  4. 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.


Contracts Audited

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 tooling
  • contracts/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 tests
  • tests/ccip026-miamicoin-burn-to-exit_flow_test.clar — Clarunit flow test
  • ccips/ccip-026/ccip-026-miamicoin-burn-to-exit.md — Governance specification

Findings Summary

Count by Severity

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

All Findings

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)

Detailed Findings

BUG-01 — Medium | ccd013-burn-to-exit-mia.clar

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 redemptionAmountUMiaV1

Test 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.


BUG-02 — Medium | ccd013-burn-to-exit-mia.clar

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.


BUG-03 — Medium | ccd013-burn-to-exit-mia.clar

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.


BUG-04 — Medium | ccd013-burn-to-exit-mia.clar

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.


BUG-05 — Medium | ccip026-miamicoin-burn-to-exit.clar

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.


DESIGN-01 — Low | ccd013-burn-to-exit-mia.clar

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.


DESIGN-02 — Low | ccd013-burn-to-exit-mia.clar

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.


DESIGN-03 — Low | ccd013-burn-to-exit-mia.clar

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.


DESIGN-04 — Low | ccd013-burn-to-exit-mia.clar

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.


DESIGN-05 — Low | ccip026-miamicoin-burn-to-exit.clar

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.


DESIGN-06 — Low | ccip026-miamicoin-burn-to-exit.clar

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.


DESIGN-07 — Low | ccip026-miamicoin-burn-to-exit.clar

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.


DESIGN-08 — Low | ccip026-miamicoin-burn-to-exit.clar

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.


DESIGN-09 — Low | ccip026-miamicoin-burn-to-exit.clar

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.


INFO-01 — Informational | ccip026-miamicoin-burn-to-exit.clar

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.


INFO-02 — Informational | ccip026-miamicoin-burn-to-exit.clar

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.


INFO-03 — Informational | ccd013-burn-to-exit-mia.clar

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.


Spec Delta Summary

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.


Test Coverage Assessment

Coverage Gaps — Prioritized

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.

Coverage Strengths

  • 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.

Rendezvous Quality Notes

  • test-redeem-mia-amount-capped uses hardcoded u10000000000000 instead of the MAX_PER_TRANSACTION constant — risk of drift if the constant changes.
  • test-redeem-mia-ratio-consistency allows ±1 uSTX tolerance (correct for fixed-point truncation) but uses the returned uMia value rather than actual burned tokens, masking BUG-01.
  • The ccip026 Rendezvous correctly works around Merkle-proof gating by testing update-city-votes directly.

Pre-Deployment Checklist

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).

Blockers — Must Address Before Deployment

  • [DESIGN-08] Populate CCIP_026.hash field. Compute SHA256 of the spec document and set the hash field in the CCIP_026 constant. Then update the corresponding test assertion from hash: "" to the computed hash value.
  • [BUG-01] Fix v1 burn rounding in redeem-mia. Compute actualBurnedUMiaV1 = redemptionV1InMia * MICRO_CITYCOINS and use this value in redemptionTotalUMia instead of redemptionAmountUMiaV1. 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-proposal signature, off-chain proof tooling, and ccd003-user-registry prerequisite.
  • [GAP-01] Add BUG-01 regression test. Test redeem-mia with a v1 holder whose balance is NOT a clean multiple of 10^6 micro-MIA. Verify that miaV1 burned and uMia credited are consistent after fix.
  • [GAP-04] Update get-proposal-info test. After filling CCIP_026.hash, change the test assertion from hash: "" to the actual hash value so the test becomes a guard rather than a confirmation of the unfilled state.

Recommended — High Value, Low Effort

  • [BUG-05] Fix execute() to use burn-block-height for voteEnd. Change (var-set voteEnd stacks-block-height) to (var-set voteEnd burn-block-height). One-line fix. Prevents misleading get-vote-period output post-execution.
  • [BUG-04] Fix get-user-redemption-info cap. Apply MAX_PER_TRANSACTION cap before computing redemptionAmount for large holders.
  • [GAP-03] Add post-execute get-vote-period test. Read get-vote-period after execute completes and assert endBlock is a Bitcoin block height value (not a Stacks block height).
  • [GAP-05, GAP-06] Add guard tests. Test redeem-mia before initialization (expect ERR_NOT_ENABLED 13005) and test duplicate initialize-redemption calls (expect ERR_ALREADY_ENABLED 13004).
  • [DESIGN-09] Update simulation script. Remove the set-snapshot-root output line from simulations/calculate-mia-votes.ts. Replace with a comment explaining that the computed root was used to set snapshotMerkleRoot as a constant.
  • [BUG-03] Add ERR_ZERO_BALANCE error code at 13008. Separate the zero-balance rejection from the call-failure error for integrator clarity.

Optional — Code Quality and Documentation

  • [DESIGN-02] Remove dead scale-up / scale-down functions 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 current branch).
  • [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_INDICES constant (depth 9 = max 512 leaves).
  • [INFO-02] Add comment to is-executable noting voter-count (not token-weighted) comparison.
  • [INFO-03] Add comment to v1 burn call explaining why miamicoin-core-v1-patch is required.
  • [Spec — D2, D7] Update spec with scale factor and tally clarifications for completeness.
  • [DESIGN-01] Consider restructuring initialize-redemption to run auth before external reads (low urgency — reads are side-effect-free).
  • [DESIGN-04] Review Rendezvous invariant invariant-total-transferred-leq-balance for robustness against post-initialization stacking payout deposits.
  • [Rendezvous] Replace hardcoded u10000000000000 in test-redeem-mia-amount-capped with a reference to MAX_PER_TRANSACTION.

Conclusion

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/.

CCIP-026 Spec Delta: Contract vs. Governance Document

Date: 2026-04-03 Audit Phase: 3 — Spec Delta Auditor: Claude (claude-sonnet-4-6) Spec file: ccips/ccip-026/ccip-026-miamicoin-burn-to-exit.md Authoritative source: contracts/ccip026-miamicoin-burn-to-exit.clar + contracts/ccd013-burn-to-exit-mia.clar


Summary

The governance spec predates the at-block removal refactor. The primary architectural change is that voting now uses a hardcoded Merkle snapshot root with cryptographic proof verification instead of CCIP-015-style at-block stacking cycle balance lookups. This affects the Activation section most heavily. Several secondary divergences exist in the Specification section regarding the redemption cap and the Activation section regarding scale factor usage.

Total divergences found: 7 (5 related to at-block removal, 2 unrelated)


Section-by-Section Delta


Section: Specification > Burn-to-Exit Mechanism > Item 5

Spec text (item 5, second bullet):

Claims are limited to 10,000,000 MIA per transaction. If a user attempts to redeem more, the transaction will process up to 10,000,000 MIA, and the remainder of the user's balance will be unaffected.

What the code actually does: ccd013-burn-to-exit-mia.clar defines MAX_PER_TRANSACTION as:

(define-constant MAX_PER_TRANSACTION (* u10000000 MICRO_CITYCOINS))

where MICRO_CITYCOINS = 10^6. This is 10,000,000 * 1,000,000 = 10,000,000,000,000 micro-MIA, which equals 10,000,000 MIA. The spec language is correct on the MIA amount, but the parameter amountUMia is in micro-MIA units (not whole MIA). This is a minor clarification needed — the spec should state the units explicitly.

Severity: Low (clarification, not incorrect)

Suggested spec language:

Claims are limited to 10,000,000 MIA (10,000,000,000,000 micro-MIA) per transaction. If a user attempts to redeem more, the transaction will process up to 10,000,000 MIA worth, and the remainder of the user's balance will be unaffected. The redeem-mia function accepts amounts in micro-MIA (6 decimal places).


Section: Activation (Major — at-block removal)

[DELTA 1] Voting methodology — at-block vs. Merkle snapshot

Spec text:

This CCIP will be voted on using a vote contract that adheres to CCIP-015 using the last two active stacking cycles for the protocol:

  • MIA cycles 82 and 83

What the code actually does: The contract does NOT use CCIP-015 at-block stacking cycle lookups. Instead, it uses a pre-computed Merkle snapshot root hardcoded as a define-constant:

(define-constant snapshotMerkleRoot 0x776695e7e2659b4a92ed54d411456f244568e2572d8e8133fc0c2381c9d154b3)

Voters must submit a Merkle proof proving their balance at the snapshot. The snapshot corresponds to stacking data captured off-chain (likely cycles 82 and 83, but this is not stated in the contract). The CCIP-015 reference is entirely obsolete for this implementation.

Suggested spec language:

This CCIP will be voted on using a Merkle proof-based snapshot mechanism. Voting power is determined by a snapshot of MIA stacking balances from the last two active stacking cycles for the protocol (MIA cycles 82 and 83), captured off-chain and committed to the proposal contract as a Merkle root. Voters submit a cryptographic proof of their balance at the snapshot time. The Merkle root is hardcoded in the proposal contract as a define-constant and cannot be changed after deployment.


[DELTA 2] Scale factor claim

Spec text:

No scale factor is required.

What the code actually does: A scale factor of 10^16 (VOTE_SCALE_FACTOR) IS required and used. The Merkle tree encodes balances as actualMIA * 10^16 (scaled values). The vote-on-proposal function receives scaledMiaVoteAmount and divides by VOTE_SCALE_FACTOR before storing:

(define-constant VOTE_SCALE_FACTOR (pow u10 u16))
;; ...
(let ((miaVoteAmount (scale-down scaledMiaVoteAmount)))

This 16-decimal scale is necessary for fixed-point precision in the Merkle leaf values.

Suggested spec language:

Vote amounts in the Merkle tree are scaled by 10^16 (16 decimal places) to preserve fixed-point precision. The vote-on-proposal function accepts the scaled amount as submitted by the voter's wallet, and the contract internally converts to actual MIA by dividing by 10^16 before recording the vote.


[DELTA 3] Vote function signature — new parameters required

Spec text (implicit, from CCIP-015 reference): The spec implies voters call a function with a vote boolean and their cycle balances looked up on-chain (the CCIP-015 pattern is: vote-on-proposal(vote bool)).

What the code actually does: The vote-on-proposal function requires four parameters:

(define-public (vote-on-proposal
    (vote bool)
    (scaledMiaVoteAmount uint)
    (proof (list 9 (buff 32)))
    (positions (list 9 bool))
  )
  • vote — true for yes, false for no
  • scaledMiaVoteAmount — the voter's MIA balance from the snapshot, scaled by 10^16
  • proof — up to 9 sibling hashes for Merkle path verification
  • positions — sibling direction flags (true = sibling is on the left)

Voters cannot call this function without an off-chain tool that generates the Merkle proof for their address. Changed-vote path reuses the stored amount and does not require proof re-submission.

Suggested spec language:

To vote, a MIA holder calls vote-on-proposal with:

  • vote (bool): true for yes, false for no
  • scaledMiaVoteAmount (uint): the voter's MIA balance at snapshot, multiplied by 10^16
  • proof (list of up to 9 32-byte hashes): the Merkle sibling hashes for the voter's path
  • positions (list of up to 9 bools): true means the sibling is on the left at that level

Voters who wish to change their vote call vote-on-proposal again with the new direction — the proof is not re-required for a vote change; the stored balance is reused.

An off-chain tool is provided to generate the Merkle proof for any registered address.


[DELTA 4] Voting window — block height type mismatch in execute()

Spec text:

Begin when the contract is deployed and continue for 2,016 Bitcoin blocks (approximately 2 weeks)

What the code actually does: The voting window start and end are set using burn-block-height (Bitcoin blocks) at deployment, which matches the spec:

(var-set voteStart burn-block-height)
(var-set voteEnd (+ burn-block-height VOTE_LENGTH))

However, when execute() is called after the vote passes, voteEnd is overwritten with stacks-block-height (a Stacks block number, not a Bitcoin block number):

(var-set voteEnd stacks-block-height)

This means the stored voteEnd after execution is a Stacks block height but the field semantics are Bitcoin block heights. The get-vote-period read-only function will return a misleading value for endBlock post-execution. This is a code-level bug documented in Phase 2 audit (low severity), but the spec should also acknowledge that the end block is recorded at execution time.

Suggested spec language:

The vote begins when the contract is deployed and runs for 2,016 Bitcoin blocks (approximately 2 weeks). Upon successful execution, the actual end block is recorded in the contract state. Note: the recorded end block after execution reflects the Stacks block height at execution time, not the Bitcoin block height.


[DELTA 5] Activation steps — missing Merkle infrastructure step

Spec text:

Upon successful vote (more yes votes than no votes):

  1. The extension contract (ccd013-burn-to-exit-mia) will be enabled in the DAO
  2. The initialize-redemption function will be called to start redemptions
  3. The redemption ratio will be locked based on the mining treasury balance and total MIA supply

What the code actually does: The execute() function performs steps 1-3 correctly, but the spec omits the prerequisite that the Merkle snapshot must be generated and the root committed to the contract BEFORE deployment. The snapshot root is a define-constant — it cannot be set after deployment. The off-chain snapshot generation is a required deployment prerequisite not mentioned in the spec.

Additionally, the spec's step 3 says "mining treasury balance" but the code uses the rewards treasury balance (ccd002-treasury-mia-rewards-v3), not the mining treasury. This matches the Specification section ("secondary treasury") and is likely a typo in the Activation section.

Suggested spec language:

Prerequisites (before deployment):

  • Capture a snapshot of MIA stacking balances at the close of cycles 82 and 83
  • Compute the Merkle tree from the snapshot and record the root hash
  • Hardcode the Merkle root in the proposal contract as snapshotMerkleRoot before deployment

Upon successful vote (more yes votes than no votes), execute() will:

  1. Enable the extension contract (ccd013-burn-to-exit-mia) in the DAO
  2. Call initialize-redemption to start redemptions
  3. Lock the redemption ratio based on the rewards treasury balance and total MIA supply

Section: Activation — voter registry requirement

[DELTA 6] User registry dependency (unrelated to at-block removal)

Spec text: The spec does not mention any prerequisite for voters being registered in the CityCoins user registry.

What the code actually does: vote-on-proposal requires the voter to be registered in ccd003-user-registry:

(voterId (unwrap!
  (contract-call?
    'SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.ccd003-user-registry
    get-user-id contract-caller
  )
  ERR_USER_NOT_FOUND
))

If the voter's principal is not in the registry, the call fails with ERR_USER_NOT_FOUND (err u26004). The spec does not document this requirement. Voters who have never interacted with the CityCoins DAO cannot vote even if they hold MIA and have a valid Merkle proof.

Severity: Medium (functional gap — affects voter eligibility)

Suggested spec language (add to Activation section):

Voters must be registered in the CityCoins user registry (ccd003-user-registry). Addresses that have previously interacted with the CityCoins DAO are typically already registered. Voters who are not registered will receive an error and must register before voting.


Section: Activation — vote tallying (unrelated to at-block removal)

[DELTA 7] Vote tally criterion — vote count vs. vote amount

Spec text:

Be tallied and available in read-only functions

The spec does not specify whether the tally uses vote count (number of voters) or vote amount (total MIA weighted).

What the code actually does: is-executable checks totalVotesYes > totalVotesNo (vote COUNT, not MIA amount):

(asserts! (> (get totalVotesYes voteTotals) (get totalVotesNo voteTotals))
  ERR_VOTE_FAILED
)

The contract tracks both counts and amounts but uses counts to determine passage. This means a single whale cannot override many small holders on a count basis, but the spec does not document this.

Severity: Low (undocumented behavior, not incorrect)

Suggested spec language:

The vote passes if the number of yes voters exceeds the number of no voters (vote count, not weighted MIA amount). Both vote counts and total MIA amounts are tracked and available via read-only functions.


Summary Table

# Section Cause Severity Type
1 Specification > Burn-to-Exit > Item 5 Clarification needed (micro-MIA units) Low Clarification
2 Activation — voting methodology at-block removal High Architectural rewrite
3 Activation — scale factor at-block removal Medium Incorrect claim
4 Activation — vote function signature at-block removal High Missing documentation
5 Activation — execute() block height at-block removal Low Code quirk to document
6 Activation — activation steps at-block removal + typo Medium Missing prereqs + typo
7 Activation — user registry Unrelated Medium Missing requirement
8 Activation — vote tally criterion Unrelated Low Undocumented behavior

Recommended Spec Update Priority

  1. Rewrite the Activation section (Deltas 2, 3, 4, 6) — the CCIP-015 reference and "no scale factor" claim are the most misleading parts. A voter following the spec cannot successfully vote without the Merkle proof tooling.

  2. Add user registry prerequisite (Delta 7) — functional blocker for unregistered holders.

  3. Clarify vote tally criterion (Delta 8) — prevents misunderstanding of what constitutes a passing vote.

  4. Note the execute() block height type mismatch (Delta 5) — low severity but worth documenting as a known artifact.

  5. Clarify micro-MIA units (Delta 1) — low priority, minor UX clarification.


Out-of-Scope Notes

The following observations from the Phase 1 and Phase 2 audits are code-level issues that do not require spec changes:

  • execute() setting voteEnd to stacks-block-height instead of burn-block-height is a code-level bug (Phase 2, low severity). The spec should document the intent (record end block at execution time) but the fix belongs in the contract.
  • The ERR_PANIC error code usage for get-vote-totals returning none is an internal implementation detail not requiring spec documentation.
  • Merkle proof depth is limited to 9 levels (512 leaves max). This is an implementation constraint that should be communicated to snapshot tooling authors but does not require a spec change unless the voter set exceeds 512.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment