Skip to content

feat(engine)!: charge storage as a transaction writes it - #2418

Closed
sdbondi wants to merge 1 commit into
engine-recharge-fees-on-persisted-statefrom
engine-charge-storage-during-execution
Closed

feat(engine)!: charge storage as a transaction writes it#2418
sdbondi wants to merge 1 commit into
engine-recharge-fees-on-persisted-statefrom
engine-charge-storage-during-execution

Conversation

@sdbondi

@sdbondi sdbondi commented Aug 11, 2026

Copy link
Copy Markdown
Member

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

Problem

Storage was tallied once, at finalization. Two things followed from that.

A doomed transaction ran to completion. Nothing checked whether a transaction could pay for the state it was producing until every instruction had run, so the cheapest way to make a validator do work was to write state and never pay for it. Compute already fails fast — the WASM allowance is funded by payments and the grace credit ends at the fee checkpoint — but storage had no equivalent.

The fee-intent checkpoint was vacuous for storage. checkpoint_fee_intent is the one gate on the fee intent, and it weighed payments against charges that contained no storage at all, because none had been computed by then. A fee intent could leave behind state that nothing paid for. In the test added here it required 34 for the substates it writes, where the real cost is 486 — and at a payment of 100 it committed the fee intent and its unpaid state.

Meter growth as it happens

WorkingStateStore now records two things: the substates written since it was last read, and the encoded size each has already been metered for. Draining the tally re-encodes only what was touched and counts only growth, so a substate written ten times is charged for what it grew by rather than for its whole size ten times.

The transaction processor drains that tally at every instruction boundary and hands the raw bytes to the runtime modules via a new on_storage_written hook. That seam is far enough from the write to keep the store free of any fee-table knowledge, and close enough to stop a transaction at the instruction that overran.

Past the fee checkpoint the running charge is tested against payments and the transaction stops there. Within the fee intent it is not — a transaction may still be sourcing its fee, which is what the checkpoint's own check exists for, and that check now sees the storage those instructions accrued.

Why an over-estimate is fine

The running charge is provisional throughout. It is assigned rather than accumulated, and the pass finalization makes over the persisted state (#2417) replaces it. So it only ever has to be an over-estimate to do its job, which is why substates that shrink are never refunded and why over-marking a substate as written costs nothing — it re-encodes to the same size and contributes zero.

Rounding is applied once against the running total, mirroring how WASM points are charged: rounding each drain separately would let a transaction write in sub-divisor increments and be charged nothing for any of them.

Consequence worth reviewing

A transaction that pays fees from within its main instructions must now pay before it writes, not after. This is the same rule compute already follows past the checkpoint, so it is a consistency change rather than a new constraint — but it is a real semantic tightening for templates that call pay_fee late.

fail_partial_paid_fees shows the shift: it is now rejected as InsufficientFeesPaid at main instruction #1, where before it ran on and trapped on compute metering.

Testing

Two tests, both verified to fail with the processor's drain call removed:

  • the_fee_intent_pays_for_the_storage_it_writes — a fee intent that writes ~450 of state and pays 100 is now rejected outright, where before it committed the fee intent and left the state unpaid.
  • fail_partial_paid_fees — now asserts the transaction stops at the instruction it could not pay for, rather than being rejected at finalization.

tari_engine, engine_types, transaction, ootle_sdk_core, storage and consensus_tests all pass.

@sdbondi
sdbondi force-pushed the engine-charge-storage-during-execution branch from 2bd9c9b to 6e30633 Compare August 11, 2026 12:28
@sdbondi
sdbondi force-pushed the engine-recharge-fees-on-persisted-state branch from ec37297 to 1d43477 Compare August 11, 2026 12:28
@sdbondi
sdbondi force-pushed the engine-recharge-fees-on-persisted-state branch from 1d43477 to 388b02d Compare August 11, 2026 12:42
@sdbondi
sdbondi force-pushed the engine-charge-storage-during-execution branch from 6e30633 to 55d959c 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 force-pushed the engine-charge-storage-during-execution branch 2 times, most recently from 1ad3b92 to 95f7293 Compare August 13, 2026 11:54
Storage was tallied once, at finalization. Two consequences followed from that.

A transaction that cannot afford the state it is producing ran every instruction
before anyone checked, and was rejected only at the end — so the cheapest way to
make a validator do work was to write state and never pay for it.

And `checkpoint_fee_intent`, which is the one gate on the fee intent, weighed
payments against charges that contained no storage at all, because none had been
computed by then. A fee intent could leave behind state nothing paid for: it
required 34 for the substates it wrote in the test added here, where the real
cost is 486.

Meter substate growth as it happens. `WorkingStateStore` records the substates
written since it was last read and the size each has already been metered for, so
a substate that is written repeatedly is charged for its growth rather than for
its whole size each time, and only what was touched is re-encoded. The transaction
processor drains that tally at every instruction boundary and hands the bytes to
the runtime modules, which is far enough from the write to keep the store free of
fee-table knowledge and close enough to stop a transaction at the instruction that
overran.

Past the fee checkpoint the running charge is tested against payments, so the
transaction stops there. Within the fee intent it is not — a transaction may still
be sourcing its fee — which is exactly what the checkpoint's own check is for, and
that check now sees the storage those instructions accrued.

The running charge is provisional throughout: it is assigned, not accumulated, and
the pass finalization makes over the persisted state replaces it. So it only has
to be an over-estimate to do its job, and shrinking substates are never refunded.

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

sdbondi commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Closing — superseded by the fix in #2417.

This PR charged storage during execution so that checkpoint_fee_intent would see the fee intent's own writes. That turned out to be the wrong shape of defence. Each charge category moved before the gate leaves the others behind — SubstateCreate and ExhaustBurn still landed at finalization — and the receipt is not a substate during execution at all, so it can never be metered this way. Reproduced against a local swarm running this branch: a testnet claim with max_fee: 1000 against 2548 µT of charges still committed, because roughly 1280 of the 2129 storage charge was the receipt and never reached the gate.

#2417 now tests the payment against the fully recomputed charge for the exact state being persisted, and rejects the whole transaction when it falls short. That is complete by construction, so this PR's early-abort no longer guards anything.

It also no longer earns its cost. With #2417 in place, removing the per-instruction drain from TransactionProcessor::process_instructions leaves the engine, engine_types and template_builtin suites entirely green — there is no test that fails without it. Probing why: the receipt is a large share of the storage cost and is never metered during execution, so the running total stays under the payment until finalization. The five-component transaction at 1000 µT fails with Required fees 1922 but 1000 paid from the finalization gate, not from an instruction boundary.

What it would have cost to keep: a written-set and per-substate metered-size map in WorkingStateStore, per-instruction re-encoding of everything touched, a RuntimeModule::on_storage_written hook and a RuntimeInterface::charge_storage_written method.

Failing fast on a doomed transaction is still worth having on its own merits, but it would need to meter the receipt too, and it should be argued as an optimisation rather than a security boundary. The branch is left in place if anyone wants to revisit it.

@sdbondi sdbondi closed this Aug 14, 2026
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