feat(engine)!: charge storage as a transaction writes it - #2418
Conversation
2bd9c9b to
6e30633
Compare
ec37297 to
1d43477
Compare
1d43477 to
388b02d
Compare
6e30633 to
55d959c
Compare
388b02d to
4baadaa
Compare
1ad3b92 to
95f7293
Compare
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>
95f7293 to
5b9f851
Compare
|
Closing — superseded by the fix in #2417. This PR charged storage during execution so that #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 What it would have cost to keep: a written-set and per-substate metered-size map in 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. |
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_intentis 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
WorkingStateStorenow 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_writtenhook. 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_feelate.fail_partial_paid_feesshows the shift: it is now rejected asInsufficientFeesPaidat 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,storageandconsensus_testsall pass.