feat!: treasury settlement layer — Mint/Burn, chain_id, tx fees, consensus params in genesis - #9
Merged
Merged
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bind-mounted target/ on Docker Desktop Windows made a no-op incremental run exceed 10 minutes; a named volume keeps builds on the VM's own fs. MSYS_NO_PATHCONV stops Git Bash rewriting -w /app. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces ceiling percent (2% of 3 rounded to 33%) with bps floor per treasury spec 4a. Driver share stays remainder-based; property test pins request+offer+driver == fare for all inputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The result <= fare guarantee holds only for bps <= 10_000; above that the u128 quotient can exceed u64 and truncate. split_fare's .min(fare) cap contains it, and a later task validates the range at boot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New RLP tag 9, genesis-only (verify_state always rejects). Writes chain_params + total_supply state keys and the testnet faucet credit. Adds Mint/Burn/TxFeePaid/TxFeeEarned balance-effect kinds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ds removed Genesis hash now commits to chain_id/fees/mint authority, so mismatched nodes cannot peer (fixes the config-divergence class block_reward had). Faucet allocation is testnet-gated and fails loudly on mainnet flags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Handshake carried genesis_block_hash but never compared it, so mismatched-parameter nodes peered and failed later at block validation. Both handshake paths now refuse on mismatch. A pre-release database without chain_params now panics at boot instead of failing every block import silently. Adds boot-assert coverage and drops a stale import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signatures now commit to the chain — a testnet Mint can never replay on mainnet. Wire RLP is 8 items; preimage is [from, nonce, chain_id, data]. SDK and hub faucet must adopt the same format (coordinated release). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 4 changed the preimage and wire list but left the old shapes described in CLAUDE.md, in rlp_decode_test's printed field list, and in verify_hash's doc comment - the exact texts the SDK and hub plans read to build a matching encoder. Renames a fixture that no longer pins cross-language output, adds a raw-bytes `from` decode test, and tightens the wrong-chain assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every non-exempt tx pays chain_params.tx_fee; validation requires balance >= direct debit + fee. Sender fee merges into one balance write; author credited once per block. Spam now has a price. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The block-level author fee credit was appended after the tx loop, so an author's own transaction debit was overwritten and the value re-minted. RidePay took a standalone fee debit while also crediting driver and referrers, so a payer who was also the driver or a referrer lost that credit entirely. Both now emit one storage write per account with per-reason effects preserved. Also rejects self-transfer, saturates the fee subtraction, and covers the untested RideCancel driver branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RLP tag 6. Only chain_params.mint_authority may mint; credit_ref (64-hex, hash of the treasury intent id) is a write-once state marker, so a replayed or duplicated mint intent can never credit twice. total_supply updates once per block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ches first_duplicate_sender now canonicalizes, so the same-block exactly-once guarantee for mints no longer depends on strict string equality in signature verification. Pins Mint's tag-6 wire contract with a round-trip test, covers the amount and supply-range rejections, validates the mint recipient, and tightens two bare is_err assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RLP tag 7, permissionless. Redemptions carry hex(keccak256(intent_id)) so the treasury payout worker matches burns to intents; plain burns allowed. Burner pays amount + fee in one balance write; supply shrinks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mint's same-block ref protection relied on every Mint sharing the one authorized sender, so the duplicate-sender guard caught collisions. Burn is permissionless, so two senders could carry one redemption_ref into a block, both validate against pre-block state, and collapse the marker - letting an attacker claim a pending redemption for dust plus a fee. Block validation now rejects a repeated ref across Mint and Burn, authoring filters the loser out, and the stale comment documenting the old premise is corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Treasury reconciliation's on-chain supply source; hub faucet's testnet-flag check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty chain total_supply is the one get_chain_info field that can exceed 2^53 (~$9B at this peg), and the treasury's reconciliation job treats a supply mismatch as a P1 - a silently rounded number would fabricate or mask one. The other numeric fields cannot approach that bound and stay bare. A failed latest-block read no longer reports as a fresh chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan specified a bare JSON number for total_supply, which is the precision-loss shape the field cannot afford: it is the one value here that passes 2^53 (~$9B at this peg) and a rounded supply either fabricates or masks a reconciliation P1. Other numeric fields stay bare. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous test built its own JSON and asserted a string it had just created was a string, so a handler regressing to a bare number would not have failed it. Extracts the response builder and asserts against that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registers chain_params, total_supply and processed_ref_{64-hex}. Updates
the tx-type list (Mint 6, Burn 7, ChainInit 9 genesis-only), the RPC list
(get_chain_info, with total_supply as a decimal string), and the config
section (new consensus params, block_reward_amount removed, percent->bps).
Documents the author fee-credit merge and corrects the stale claim that
genesis funds the faucet with i64::MAX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two txs from different senders writing one account collapsed to the last staged write. With Burn this was an attacker-profitable reserve drain: a burn plus a 1-unit transfer to the burner, ordered by nonce, left the burner holding the burned amount while total_supply still fell and the redemption ref committed - so the treasury paid out against a burn that never debited. Mint mirrors it, destroying the credit while supply rises. Block validation now rejects intersecting written-account sets and authoring defers the loser. Also moves the supply-range check to the mempool, validates Transfer recipients, surfaces a chain-info read error, and updates the README off the deleted block-reward model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tionale The drain test asserted only that the error named the account, which the nonce and insufficient-balance errors also do; it now also requires the guard's own phrase. The doc comment credited verify_state with catching an unresolvable RidePay lookup, which it never performs - state_transaction unwrapping the same lookup is what makes a short list sound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 28, 2026
fix!: index the treasury settlement chain (bps floor referrer fees)
clutchprotocol/clutch-explorer#5
Merged
…r amplify Two pre-existing defects, both reported earlier and left unfixed. 1. Idle chain stalled at genesis -------------------------------- validate_transactions rejected an empty transaction list, so author_new_block failed every slot when nothing was being submitted and the chain stopped at genesis. Not cosmetic. Confirmation depth is counted in blocks, so anything waiting on `confirmations` blocks of depth needed LATER blocks to exist — and later blocks needed more transactions. A single Mint on an otherwise-quiet chain therefore never reached confirmed depth and never got credited: the treasury's whole credit path stalled permanently on an idle chain. Emptiness is not a state question, so it no longer lives in a state validator. Whether an empty block is WANTED is an authoring decision, and author_new_block now makes it — emitting at most one empty block per slot via a new Consensus::block_is_in_current_slot. That bound is what makes this safe: the authoring loop ticks every second while a slot lasts step_duration seconds (60 with one authority), so allowing empty blocks without it would emit a block per second. Blocks WITH transactions stay unthrottled, since draining a busy pool across several blocks in one slot is how throughput is achieved at all under the one-tx-per-sender-per-block ceiling. An empty block credits no tx fee, so it cannot become a source of new CLT — asserted, not assumed. 2. Seq logger panicked per log line, and could amplify ----------------------------------------------------- on_event spawned a task per log event that did `.unwrap()` on the HTTP send, so an unreachable Seq host panicked one task per log line. The unwrap was the visible half. The worse half: a panic message re-entering tracing calls on_event again, which spawns another failing shipment, which panics again — a self-amplifying storm triggered by nothing worse than the log host being down. Failures now report via eprintln! (never tracing), and only on the down/up transition, so an outage costs one line instead of one per event and still never fails silently. Also fixed while in here, all same-cause: - No request timeout, so every shipment hung on reqwest's default of none and tasks accumulated for the length of the outage. Now 5s. - tokio::spawn panics with no runtime, and on_event can fire from a non-async context (startup). Now checks for a runtime and drops the shipment instead — the fmt layer has already put the event on stdout. - serde_json unwrap on a String->String map (cannot fail, but no path through a logger should be able to panic). - Dropped the Arc<Mutex<SeqLogger>>: log_to_seq takes &self and reqwest::Client is already internally shared, so the lock was never needed — and it was held ACROSS the network await, serialising every log event in the process behind one Seq round-trip. Both fixes mutation-proven: restoring the empty-list rejection fails an_empty_block_imports_and_advances_the_chain; deleting the slot guard fails author_refuses_a_second_empty_block_in_the_same_slot. Full suite 128 passed, 0 failed. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns clutch-node into the settlement layer for a fully-reserved, redeemable token. Breaking: state format, transaction wire format, and node config all change. Testnet only; DBs must be wiped.
Peg: 1 USD = 1,000,000 CLT, so CLT is an integer micro-dollar. Balances stay
u64, deltasi64— no i128 migration (see Decision 1).What lands
ChainInittransaction (tag 9, genesis-only)chain_idin the hash preimage and wire formattx_feeto the block authorMint(tag 6) — authority-gated, exactly-oncecredit_refBurn(tag 7) — permissionless, optionalredemption_refget_chain_infoRPCtotal_supply; faucet readsis_testnetConsensus params now ride in the genesis
ChainInittransaction, whose hash feeds the genesis block hash, which peers compare at handshake. A node configured differently cannot peer — previouslyblock_reward_amountwas per-node config, so a misconfigured validator silently computed different state.Verification
get_chain_infochecked over a real WebSocket from a JS client (total_supplyarrives as a string — it is the one field that can pass 2^53). Divergentchain_idon one node → both sides refused the handshake naming both hashes, and the odd node never synced.Bugs this found and fixed (all would have shipped silently)
All are instances of one hazard: a block's writes commit as one deferred RocksDB batch, and every
state_transactionreads pre-block state — so two writes to the same key collapse to the last one staged.RidePaytook a standalone fee debit while also crediting driver/referrers; nothing stops a passenger being their own driver or referrer, so the credit was erased after the fare had already been escrowed.BurnfromBplusTransfer{to: B, value: 1}from an accomplice at a higher nonce:Bkeeps the burned amount, yettotal_supplystill falls andredemption_refcommits, so the treasury pays out USDT against a burn that never debited. Cost: two fees. Fixed by a fail-closed guard rejecting blocks whose transactions write intersecting accounts (Decision 2).Burnbroke that premise, letting two senders claim one ref. Fixed with a block-level ref-uniqueness guard.Decisions needing your sign-off
RidePays can no longer share a block. Strictly better than today, which silently destroys one of the two fees. Merging is documented in-code as the upgrade path.chain_id = 2077,tx_fee = 1000($0.001),mint_authority= the node1 dev key (testnet only).chain_idand was deleted rather than left asserting something untrue —TODO(sdk-v3)marks where to restore it.Known / follow-ups
seq.rs:104unwraps the log-ship result, so an unreachable Seq panics a task on every log line — and this repo's standalone compose has noseqservice; and an idle chain produces no blocks past genesis (validate_transactionsrejects an empty tx list).🤖 Generated with Claude Code