Skip to content

fix(engine)!: charge finalization fees against the state actually persisted - #2417

Open
sdbondi wants to merge 3 commits into
engine-tx-receipt-charge-remove-logsfrom
engine-recharge-fees-on-persisted-state
Open

fix(engine)!: charge finalization fees against the state actually persisted#2417
sdbondi wants to merge 3 commits into
engine-tx-receipt-charge-remove-logsfrom
engine-recharge-fees-on-persisted-state

Conversation

@sdbondi

@sdbondi sdbondi commented Aug 11, 2026

Copy link
Copy Markdown
Member

Stack: #2416 ← this PR. Based on #2416; review the top commit only. Merge #2416 first.

Problem

Every finalization charge is computed against the live working state. But a transaction that cannot pay for its main intent commits only its fee intent, and that diff comes from the fee checkpoint — a snapshot taken when the fee instructions ended.

So Storage, SubstateCreate and TemplatePublish were priced over substates that are then thrown away, and ExhaustBurn inherited the inflation since it is taken over the running total. This predates #2416; pricing the receipt just made it visible, because the receipt bound scales with the discarded state's substate count and events.

Measured on fail_partial_paid_fees: 1019 µT of storage charged against a receipt and substate set that occupy 444 µT.

Why the obvious fix doesn't work

Charging only at the end cannot fix this on its own. The charge has to be in place before the paid-in-full check in select_finalized_state, or nothing gates a transaction that commits state it never paid for. But the charge decides the outcome, the outcome picks the state, and the state determines the charge — a cycle.

Charge twice

  • on_before_finalize runs against the live state, as before. Its only job is to gate the commit/reject decision. Pricing what would be persisted is the right question to gate on: to commit that state you must be able to pay for it.
  • on_before_persist (new hook) runs once the state has been chosen and before its fees are settled, replacing those charges with the cost of what is really written.

On a commit the second pass sees the same state and changes nothing. On a fee-intent commit it sees only what the fee intent touched.

For the replacement to be sound the finalization charges are now assigned rather than accumulated — FeeBreakdown::set — so a second pass over a different state replaces the first instead of doubling it. ExhaustBurn is zeroed before its total is taken, so it never compounds across passes.

Structure

StateTracker::finalize is split into select_finalized_state (chooses the working state or the fee checkpoint, and carries the live fee state onto it) and finalize (settles fees, builds the diff and receipt), so the runtime can drive its modules in between. The two near-identical commit and fee-intent-commit tails collapse into one. WorkingState becomes pub(crate) and gains count_newly_created_substates, so the whole computation can run against a detached state.

Consequence worth reviewing

A transaction rejected for insufficient fees now gets refunded the storage it was provisionally charged for state that was never written. Execution charges (WasmExecution, NativeExecution, RuntimeCall, …) are untouched — the work was done and is still paid for — so this is not a spam vector, but it is a real change in what a failed transaction costs.

Testing

New regression test a_fee_intent_commit_is_not_charged_for_the_state_it_abandons runs the same transaction at two fee levels and asserts the rejected run's storage charge is less than half the committed run's. With on_before_persist stubbed out it fails at 1769 vs 1772 — the two are within three bytes, which is exactly the bug.

tari_engine, engine_types, transaction, ootle_sdk_core, storage and consensus_tests all pass with no other test changes.

Follow-up

Storage is still charged in one tally at finalization, so a transaction that cannot afford its state only finds out after doing all the work — and checkpoint_fee_intent's paid-in-full check still sees no storage charge at all, since none has been computed by then. Charging incrementally during execution would close both. It composes cleanly with this PR: with the second pass authoritative, the running charge only has to be an over-estimate.

@sdbondi
sdbondi force-pushed the engine-tx-receipt-charge-remove-logs branch from 5fc5991 to f8818e9 Compare August 11, 2026 12:28
@sdbondi
sdbondi force-pushed the engine-recharge-fees-on-persisted-state branch 2 times, most recently from 1d43477 to 388b02d Compare August 11, 2026 12:42
@sdbondi
sdbondi force-pushed the engine-tx-receipt-charge-remove-logs branch from f8818e9 to 0ae4812 Compare August 11, 2026 12:42
@sdbondi
sdbondi force-pushed the engine-recharge-fees-on-persisted-state branch from 388b02d to 4baadaa Compare August 12, 2026 11:04
@sdbondi

sdbondi commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Update: closing the fee-intent commit hole

Charging categories earlier turned out not to be a workable defence — see the discussion on #2418. Each charge moved before checkpoint_fee_intent leaves the others behind, and the receipt is not a substate during execution at all, so it can never be metered that way. A fee intent could therefore still write state, cover only its execution, and have that state committed with the rest of the bill unpaid.

This PR now decides at the point where the answer is finally known. on_before_persist has just recomputed the charges over the exact state being persisted, so the payment is tested against them there: if it cannot cover committing, nothing commits. That is complete by construction — it tests the final total, so no charge added later can escape it.

Reproduced against a local swarm before the fix: claiming testnet funds with max_fee: 1000 against 2548 µT of charges left the account funded (999,999,000 µT, is_confirmed_on_chain: true) while the transaction reported Insufficient fees paid.

Whatever rejected the main intent stays the reason reported; the shortfall only decides that not even the fee intent survives it.

Test fallout, all of it the intended semantic change — underfunded fee intents that used to commit now reject:

  • an_unaffordable_fee_intent_commits_nothing (new) — pins it, including that nothing is charged and the account is not funded.
  • fail_pay_less_fees_than_fee_transaction — now expects a full reject, matching its name.
  • fail_partial_paid_fees, dangling_bucket_pay_fees, a_fee_intent_commit_is_not_charged_for_the_state_it_abandons — fee raised so the fee intent can afford its own commit, which is what makes a fee-only accept reachable at all.
  • fail_partial_paid_fees also now asserts the payer is charged only for what the fee intent persisted and refunded the rest — the invariant this PR introduces.

@sdbondi
sdbondi force-pushed the engine-tx-receipt-charge-remove-logs branch from 5f4d96e to 54d0e33 Compare August 14, 2026 07:07
@sdbondi
sdbondi force-pushed the engine-recharge-fees-on-persisted-state branch from e091f8f to a544897 Compare August 14, 2026 07:07
sdbondi and others added 3 commits August 14, 2026 13:35
…sisted

Every finalization charge was computed against the live working state, but a
transaction that cannot pay for its main intent commits only its fee intent —
and that diff comes from the fee checkpoint. `Storage`, `SubstateCreate` and
`TemplatePublish` were therefore priced over substates that are discarded, and
`ExhaustBurn` inherited the inflation since it is taken over the running total.
A transaction rejected for fees paid ~4x the storage its receipt and substates
actually occupy.

Charging only at the end cannot fix this on its own: the charge has to be in
place before the paid-in-full check, or nothing gates a transaction that commits
state it never paid for. The charge decides the outcome, the outcome picks the
state, and the state determines the charge.

Break the cycle by charging twice. `on_before_finalize` runs against the live
state and gates the commit/reject decision — a transaction is admitted on the
cost of the state it asked to commit, which is the right question to gate on.
A new `on_before_persist` hook then runs against the state that was chosen,
replacing those charges with the cost of what is really written.

To make that work the finalization charges are now assigned rather than
accumulated (`FeeBreakdown::set`), so a second pass over a different state
replaces the first instead of doubling it. `ExhaustBurn` is zeroed before its
total is taken so it never compounds across passes. `StateTracker::finalize` is
split into `select_finalized_state` and `finalize` so the runtime can drive its
modules in between; the two near-identical commit and fee-intent-commit tails
collapse into one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… state

`on_before_persist` and `with_working_state_mut` handed `WorkingState` — a
`pub(crate)` type — through public API, so rustc warned that both were reachable
at a visibility their parameter is not (`private_interfaces`, `private_bounds`).

Introduce `ChargeableState`: the view a module gets when charging for a
transaction's state. It exposes what a charge computed from the state needs to
read — the substates to persist, how many are new, the receipt's footprint, the
fee state to record against — and nothing that would let a module alter the state
it is pricing. `WorkingState` goes back to being private to the runtime module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fee-intent commit persists real state — the substates the fee instructions
touched, plus a receipt carrying every event they emitted — and it was committed
unconditionally. The only gate before it, `checkpoint_fee_intent`, weighs payments
against the charges accrued by then, and every charge proportional to state size
lands after it. So a fee intent could write state, cover only its execution, and
have that state committed with the rest of the bill unpaid. It is why a 1000 uT
claim commits against 2548 uT of charges.

Charging earlier does not fix this: each category moved before the gate leaves
the others behind, and the receipt is not a substate during execution at all, so
it can never be metered that way.

Instead, decide at the point where the answer is finally known. `on_before_persist`
has just recomputed the charges over this exact state, so test them against the
payments there: if the payment cannot cover committing, nothing commits. That is
complete by construction — it tests the final total, so no charge can be added
later and escape it.

Whatever rejected the main intent stays the reason reported; the shortfall only
decides that not even the fee intent survives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sdbondi
sdbondi force-pushed the engine-tx-receipt-charge-remove-logs branch from 54d0e33 to 99b9eff Compare August 14, 2026 09:35
@sdbondi
sdbondi force-pushed the engine-recharge-fees-on-persisted-state branch from a544897 to 8ae5af6 Compare August 14, 2026 09:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants