Skip to content

feat!: treasury settlement layer — Mint/Burn, chain_id, tx fees, consensus params in genesis - #9

Merged
MehranMazhar merged 24 commits into
mainfrom
treasury-break
Jul 30, 2026
Merged

feat!: treasury settlement layer — Mint/Burn, chain_id, tx fees, consensus params in genesis#9
MehranMazhar merged 24 commits into
mainfrom
treasury-break

Conversation

@MehranMazhar

Copy link
Copy Markdown
Member

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, deltas i64 — no i128 migration (see Decision 1).

What lands

# Change Why
1 Referrer fees → basis points, floor rounding 2% of a fare of 3 previously ceiling-rounded to 33%
2 ChainInit transaction (tag 9, genesis-only) Carries consensus params into state
3 Params read from state; block rewards removed; faucet testnet-gated Unbacked minting breaks the reserve invariant
4 chain_id in the hash preimage and wire format A testnet Mint can no longer replay elsewhere
5 Flat tx_fee to the block author Restores validator pay as backed value moving, and gives the chain its first spam cost
6 Mint (tag 6) — authority-gated, exactly-once credit_ref The on-ramp
7 Burn (tag 7) — permissionless, optional redemption_ref The off-ramp
8 get_chain_info RPC Treasury reconciliation reads total_supply; faucet reads is_testnet
9 Docs, state-key registry, 3-node smoke

Consensus params now ride in the genesis ChainInit transaction, whose hash feeds the genesis block hash, which peers compare at handshake. A node configured differently cannot peer — previously block_reward_amount was per-node config, so a misconfigured validator silently computed different state.

Verification

  • 118 tests, 0 failures across 15 binaries (docker; no host cargo).
  • Per-task review on every task + a whole-branch review; every finding fixed.
  • 3-node docker smoke: get_chain_info checked over a real WebSocket from a JS client (total_supply arrives as a string — it is the one field that can pass 2^53). Divergent chain_id on one node → both sides refused the handshake naming both hashes, and the odd node never synced.
  • The three money-path guards are mutation-tested: disabling each one makes its regression test fail.

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_transaction reads pre-block state — so two writes to the same key collapse to the last one staged.

  1. Minted CLT — the author's fee credit was appended after the tx loop, so a validator transacting in its own block had its debit overwritten while the recipient kept the funds.
  2. Destroyed CLTRidePay took 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.
  3. Reserve drain (attacker-profitable)Burn from B plus Transfer{to: B, value: 1} from an accomplice at a higher nonce: B keeps the burned amount, yet total_supply still falls and redemption_ref commits, 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).
  4. Exactly-once hole — Mint's same-block protection relied on every Mint sharing one authorised sender; permissionless Burn broke that premise, letting two senders claim one ref. Fixed with a block-level ref-uniqueness guard.
  5. Genesis-hash enforcement was missing entirely — the hash committed to the params but nothing compared it at handshake.

Decisions needing your sign-off

  1. 0 decimals — CLT is the micro-dollar. Overrides spec §4a's locked "6 decimals + i128". At this peg both produce byte-identical integers, so the decimal layer and the whole i128 migration were pure cost. Also gives USDT parity (both 6dp): 1 USDT base unit = 1 CLT.
  2. Fail-closed guard over merging writes. Rejecting a block is auditable; computing a merged state on a consensus path is not. Real cost: the default config points every ride at one referrer address, so two 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.
  3. chain_id = 2077, tx_fee = 1000 ($0.001), mint_authority = the node1 dev key (testnet only).
  4. No test pins node hashing against real SDK bytes. The old fixture predated chain_id and was deleted rather than left asserting something untrue — TODO(sdk-v3) marks where to restore it.

Known / follow-ups

  • Merging this breaks clutch-hub-api, clutch-explorer, clutch-hub-sdk-js, clutch-deploy and the demo app until Plan D lands. Recommend landing them together.
  • Nonce tie-break makes account conflicts weaponisable for censorship (~$0.001/block, liveness only, authoring-policy-local, not consensus).
  • Pre-existing, untouched: seq.rs:104 unwraps the log-ship result, so an unreachable Seq panics a task on every log line — and this repo's standalone compose has no seq service; and an idle chain produces no blocks past genesis (validate_transactions rejects an empty tx list).
  • Cross-transaction credits to a third-party account still collapse; the new guard rejects those blocks rather than mis-applying them.

🤖 Generated with Claude Code

MehranMazhar and others added 23 commits July 28, 2026 07:00
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>
…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>
@MehranMazhar
MehranMazhar merged commit 24fae1a into main Jul 30, 2026
5 checks passed
@MehranMazhar
MehranMazhar deleted the treasury-break branch July 30, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant