fix: adjust the response execution prepayment on its nominal part - #11431
fix: adjust the response execution prepayment on its nominal part#11431mraszyk wants to merge 15 commits into
Conversation
There was a problem hiding this comment.
🟢 Approval recommended
The accounting fix is focused, documented, and comprehensively tested with no unresolved issues.
Pull request overview
Fixes response-execution cycle metrics when Wasm execution modes change under the free cost schedule.
Changes:
- Compares nominal rather than real prepaid cycles.
- Adds matrix-based regression tests for adjustments, refunds, and unexecuted responses.
File summaries
| File | Description |
|---|---|
rs/cycles_account_manager/tests/cycles_account_manager.rs |
Adds comprehensive cost-schedule and Wasm-mode regression coverage. |
rs/cycles_account_manager/src/cycles_account_manager.rs |
Corrects prepayment adjustment comparisons and documents assumptions. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
✅ No security or compliance issues detected. Reviewed everything up to 1329ba8. Security Overview
Detected Code Changes
|
| /// schedule the real and the nominal parts coincide, so this comparison is | ||
| /// equivalent to comparing the real parts. | ||
| /// | ||
| /// # Assumption: the cost schedule of a subnet never changes |
There was a problem hiding this comment.
Hmm wait, the link I gave is about subnet splitting. We probably still want to enforce it there, but I was thinking if there was a mechanism to not allow changing the cost schedule via proposal.
There was a problem hiding this comment.
This is resolved by dropping the assumption rather than by enforcing it — thanks for pushing on it.
adjust_prepayment_for_response_execution no longer compares the prepayment against the requirement at all: it withdraws required - prepaid and refunds prepaid - required unconditionally, and since subtracting two CompoundCycles saturates part by part, each of the real and the nominal part is topped up or refunded on its own. So the accounting no longer depends on the two amounts carrying the same cost schedule, and nothing needs to be enforced at subnet splitting or against a proposal for this code to be correct.
I then went over the whole response path by hand for all four (schedule at the call, schedule at the response) pairs. There is no case in which cycles are created or destroyed, a refund_cycles assertion is violated, or the consumed cycles metrics drift from the nominal cost. Two paths would under-charge under a free → normal flip, both because they deliberately never top up a prepayment (the extra withdrawal could fail, and failing there is worse than under-charging), so they are capped against a prepayment whose real part is zero:
- a response whose callback is not executed at all pays
min(prepaid.real, base_fee.real) = 0; - the response transmission is refunded in full in the real part, so it is free.
Both are pre-existing consequences of that no-top-up rule, not something this PR introduces. They are written up under "What a free → normal flip would still forfeit" in the PR description.
Also worth recording: refund_unused_execution_cycles never sees a flip (in the response path it gets the adjusted prepayment; elsewhere prepayment and refund come from one execution under one schedule), and DTS cannot introduce one because PausedResponseExecution carries OriginalContext by value, so a resumed slice replays the adjustment under the schedule captured by the first slice.
There was a problem hiding this comment.
Update, since this thread is still open and a few things moved since the reply above.
#11529 has merged, so this PR targets master directly now: its diff is just the two
cycles_account_manager files.
The part of the argument above that was only by hand is now pinned by tests, each over the
full matrix of (cost schedule × Wasm execution mode) at the call × (cost schedule × Wasm
execution mode) at the response, i.e. 16 combinations:
response_execution_cycles_match_response_execution_setting: over the whole prepay →
adjust → refund sequence, the canister pays, and the consumed cycles metrics report,
exactly the cost of executing the response under the cost schedule and in the Wasm
execution mode in which it was executed — asserted right after the adjustment and again
after the refund;settle_prepayment_for_unexecuted_response_charges_only_the_base_fee: the first of the
two under-charging paths above — a response whose callback is not executed costs, and
reports, the fixed per-message execution fee only, and never more than was prepaid.
Added since: adjust_prepayment_for_response_execution_cannot_fail_without_a_real_shortfall,
whose second setting is a normal → free flip. There the whole real prepayment is refunded
while the nominal part is topped up, i.e. the two parts move in opposite directions in one
adjustment, and since nothing real is withdrawn it cannot fail even for a canister that
spent its whole balance on the prepayment.
The response transmission path is still only the by-hand argument; it is untouched by this
PR (its component-wise cap came with #11529).
There was a problem hiding this comment.
🟡 Changes recommended
The PR description materially contradicts the implemented cross-schedule accounting behavior and test matrix.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Balanced
`CompoundCycles` derived `Ord`, which orders lexicographically, i.e. by the real part first, and hence answers "which of the two amounts is larger?" from the real part alone whenever the real parts differ. That is the wrong tie-break under the free cost schedule, where the real part of a use case made free is zero no matter how large the nominal part is. The two parts of an amount are accounted for independently and there is no meaningful order on the pair, so the derive is removed and the type documents why it has none. All four uses of the resulting `min` turn out not to need an ordering at all: - `settle_prepayment_for_unexecuted_response`, `refund_for_response_transmission` and the net charge recorded in `load_canister_snapshot` clamped a cost to a prepayment only to subtract it from that prepayment right after. Subtracting two `CompoundCycles` saturates part by part, so `x - x.min(y)` is `x - y` and the clamp was redundant. The one in `load_canister_snapshot` mirrors the refund that `refund_unused_execution_cycles` derives from the identical formula, so both sides keep agreeing. - `refund_unused_execution_cycles` performs the one genuine clamp, now expressed as `x - (x - y)`, which is the part-wise minimum of `x` and `y` because both subtractions saturate. No cycle amount and no metric changes: under a single cost schedule the real and the nominal part of an amount agree on every ordering that was previously consulted, so the lexicographic tie-break was never reached. Six test assertions comparing accumulated execution costs now compare their nominal parts, as `assert_gt!` no longer applies to a `CompoundCycles`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three corrections to the comments added by the previous commit, all of them imprecise rather than wrong about the code: - The note on `CompoundCycles` blamed the free cost schedule for the lexicographic order being the wrong one. That is backwards: when both amounts carry the free cost schedule, the real part of a use case made free is zero on both sides, so the comparison falls through to the nominal parts and gets it right, just as it does under the normal cost schedule, where the two parts of an amount coincide. The order is misleading precisely when the two amounts carry different cost schedules, which is the case this crate hits: one amount is recorded when a call is performed and the other derived when its response is executed. - The same note claimed `x - x.min(y)` is `x - y`. That holds for a part-wise minimum, but not for the lexicographic one that was removed: with `x = (real 5, nominal 20)` and `y = (real 10, nominal 1)`, the latter gives `x.min(y) == x` and hence `(0, 0)`, whereas `x - y` is `(0, 19)`. The note now states the two idioms directly instead of relating them to an operation that no longer exists. - The cap in `refund_unused_execution_cycles` is not merely defense in depth against an inconsistent caller: `scale_cost` scales both parts of an amount by the subnet size, so a subnet that grew between the prepayment and the refund makes the refund exceed the prepayment. That is the one input of the four that can differ in practice today. The counterpart comment in `load_canister_snapshot` also claimed to recompute "exactly the refund" that function derived, which is the refund before its cap, and asserted that no cap is needed there without giving the identity that makes the two agree. Comments only, no functional change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two clarifications from review, comments only: - The note on the cap in `refund_unused_execution_cycles` listed the instruction count alongside the Wasm execution mode, the cost schedule and the subnet size as inputs "the prepayment was made with as well", and then allowed all of them to have changed since. That conflates two different things: the refund deliberately covers fewer instructions than the prepayment was made for, whereas the other three are meant to be the same and are the ones that can differ. - The counterpart note in `load_canister_snapshot` justified dropping the cap with `prepaid - refund == prepaid - refund.min(prepaid)`, which reads as the removed lexicographic `min`, for which it does not hold. The cap that `refund_unused_execution_cycles` applies is part-wise, and the identity holds in each part unconditionally, so the note now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three points from review: - The type-safety paragraph claimed that the generics and phantom data enforce that arithmetic is performed on amounts created for the same `CyclesUseCase` *and* `CanisterCyclesCostSchedule`. Only the former is a type parameter: `new` folds the cost schedule into the real part and does not retain it, so nothing stops amounts created under different cost schedules from being combined. That is precisely the case the note on ordering is about, so the two statements contradicted each other. - A test pins both documented identities on two amounts whose parts are oppositely ordered, which a lexicographic ordering of the pair would decide on the real parts alone: an `Instructions` amount of 5 under the normal cost schedule, `(5, 5)`, and one of 10 under the free cost schedule, `(0, 10)`. Their difference is `(5, 0)` and their part-wise minimum, `x - (x - y)`, is `(0, 5)`. - The note on the cap in `refund_unused_execution_cycles` said its pricing inputs are the ones in effect now. That is not so for an update call or an install, where the caller passes the same `subnet_cycles_config` it prepaid with; a response execution is the case whose prepayment was made in an earlier round and can therefore have been priced with a different subnet size. Subnet growth can also only price the refund above the prepayment, not necessarily do so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The one genuine cap, in `refund_unused_execution_cycles`, was written as `x - (x - y)`, which is the component-wise minimum of `x` and `y` because both subtractions saturate. It is now a named `CompoundCycles::component_wise_min`. The identity is correct but needed explaining wherever it appeared, and it appeared twice: at the cap itself and in the `load_canister_snapshot` comment arguing that no cap is needed there. Both explanations were misread during review, once as the removed lexicographic `min`. A named operation states the intent at the call site, and the two comments now refer to it rather than re-deriving it. The type note keeps the remaining identity, which is what makes the other three sites need no cap at all: `x - y` already equals `x - x.component_wise_min(y)`, since subtraction saturates part by part. The test pins that alongside the new operation, on the same two amounts whose components are oppositely ordered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`load_canister_snapshot` recomputes the refund that `refund_unused_execution_cycles` derives from the identical formula, in order to record the net charge where it survives a canister state rollback. It relied on the cap being redundant once the result is subtracted from the prepayment. It now applies the same `component_wise_min` cap, so the two agree by construction rather than by an identity the reader has to verify, and stay in agreement if that cap ever changes. The comment on the cap in `refund_unused_execution_cycles` loses its walkthrough of the four pricing inputs, keeping only the reason the cap is not vacuous, now that `component_wise_min` names what it does. "Part by part" is spelled out as the real and the nominal part throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reason given for the cap in `refund_unused_execution_cycles` was one of several ways the prepayment and the refund can be priced differently, so naming it invited being read as the only one. The cap holds regardless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cycles prepaid for a response execution are adjusted to the canister's current Wasm execution mode before the callback is executed, so that a canister upgraded from Wasm32 to Wasm64 (or vice versa) across a call is charged exactly as if it had performed the call in the Wasm execution mode in which the response is executed. That adjustment compared the prepayment to the requirement on their real parts, which made it a no-op under the free cost schedule: the real part of an `Instructions` amount is zero under that schedule, so both comparisons saw 0 against 0 and left the nominal part at the amount prepaid for the Wasm execution mode the canister had when it performed the call. The refund of the cycles for the unused instructions is computed in the canister's current Wasm execution mode and clamped to the prepayment, so the consumed cycles metrics were then misreported in both directions. With a 1e9 instruction limit and 1e6 instructions executed by the callback: - Wasm32 at the call, Wasm64 at the response: the refund exceeds the prepayment and is clamped to it, so the whole response execution, including the fixed per-message execution fee, disappears from the metrics: 0 is reported instead of 7_000_000; - Wasm64 at the call, Wasm32 at the response: 1_006_000_000 is reported instead of 6_000_000, an over-report by the difference of the two prepayments for the full instruction limit, i.e. one that scales with the instruction limit rather than with the instructions actually executed. Both comparisons are now performed on the nominal parts. Under the normal cost schedule the real and the nominal parts coincide, hence no cycle amount and no metric changes there. Under the free cost schedule the real part of the missing prepayment is zero, so topping it up cannot fail and only the metrics move. Both functions document that comparing a single part is sound only because the prepayment recorded in the callback and the requirement derived at response time carry the same cost schedule, which holds as long as the cost schedule of a subnet cannot change once the subnet exists. `settle_prepayment_for_unexecuted_response` is left untouched: the fixed per-message execution fee does not depend on the Wasm execution mode and the real parts it compares are equal, so it already charges and reports that fee in both cost schedules and in all four combinations of Wasm execution modes. The added test pins this down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The note on the assumption that the cost schedule of a subnet never changes attributed the zero refund to `refund_unused_execution_cycles` clamping the refund to the prepayment. That clamp is in fact inactive in this scenario: the refund is derived with the cost schedule in effect when the response is executed, and `CompoundCycles::new` gives an `Instructions` amount a zero real part under the free cost schedule, so the refund already has a zero real part before it is clamped. The clamp then compares a real part of zero against the non-zero real part of the prepayment and returns the refund unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
052efc0 to
f26f08c
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The failure test does not exercise the mixed shortfall/excess case needed to verify atomicity.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
…nges The cycles accounting for a response execution compares a prepayment recorded when the call was performed against a requirement derived when the response is executed. Those two amounts carry the cost schedule in effect at their respective times, so the comparison in `adjust_prepayment_for_response_execution` was only sound because a subnet keeps the cost schedule it was created with. That comparison is now gone: the function withdraws `required - prepaid` and refunds `prepaid - required` unconditionally. Both subtractions saturate part by part, so each of the real and the nominal part is topped up or refunded on its own and for either part at most one of the two is non-zero. The withdrawal stays first so that a failing withdrawal leaves the canister state unchanged, which both callers rely on. `refund_excess_prepayment_for_response_execution` existed only to hold the second branch and is removed. No cycle amount and no metric changes under a fixed cost schedule: with the two schedules equal, the real and the nominal parts agree on the ordering that was previously consulted, and the withdrawal that is now also attempted for a prepayment that already suffices requests a zero real amount, which is always available against a zero threshold and is recorded nowhere. The response execution tests range over the cost schedule at the call and at the response independently, i.e. over 16 combinations instead of 8. They pin down that a canister ends up with exactly the prepayment that the cost schedule in effect at response time requires: it gets its whole real prepayment back if its subnet switched to the free cost schedule across the call, and it pays the real requirement if the subnet switched the other way round. A response that is not executed at all is still charged at most what was prepaid, part by part, as its prepayment is never topped up. A new test pins down that a failing adjustment leaves the balance and both consumed cycles metrics untouched, in a setting where the prepayment falls short of the requirement in the real part while exceeding it in the nominal one, so that a refund performed before the failing withdrawal would show up in those metrics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1ebeb34 to
2ca09e3
Compare
`adjust_prepayment_for_response_execution` withdraws `required - prepaid` unconditionally, i.e. nothing at all when the requirement does not exceed the prepayment in the real part. Pin that down on a canister that spent its whole balance on the prepayment: any withdrawal attempted there would fail. One of the two settings has the nominal part of the prepayment fall short of the requirement while its real part is in excess, i.e. the nominal part is topped up while the real one is refunded. `adjust_prepayment_for_response_execution_matches_response_execution_setting` covers both settings as well, but it leaves the canister cycles to spare, so a withdrawal attempted here would succeed there and go unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`response_execution_consumed_cycles_match_response_execution_setting` ran the very sequence of `adjust_prepayment_for_response_execution_matches_response_execution_setting` and then refunded the cycles for the instructions the callback did not execute, so the latter's assertions are a checkpoint of the former. Merge the two and assert at both points at which the cycles the canister has paid are determined, naming the checkpoint in each assert message. Comparing against the balance the canister had before it prepaid also states the property more directly than relating the balances before and after the adjustment did: the canister has paid exactly the adjusted prepayment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Summary `CompoundCycles` pairs a **real** amount, which moves the canister's cycles balance, with a **nominal** one, which feeds the consumed cycles metrics. It derived `Ord`, which orders lexicographically, i.e. by the real part first, so a comparison ignored the nominal parts whenever the real parts differed. There is no meaningful order on a pair of independently accounted amounts, and that one is actively wrong for what the cycles accounting in `ic-cycles-account-manager` compares: an amount recorded when a call is performed against one derived when its response is executed. Under the free cost schedule the real part of an amount made free is zero however large its nominal part, so the amount made free always compares as the smaller one. The derive is removed, so such a comparison is now a compile error, and the type documents why it has no ordering. This also corrects a claim in the same doc comment: the generics enforce the same `CyclesUseCase`, not the same cost schedule, which is not part of the type at all. # The four `min` calls | site | before | after | | --- | --- | --- | | `settle_prepayment_for_unexecuted_response` | `p - base_fee.min(p)` | `p - base_fee` | | `refund_for_response_transmission` | `p - cost.min(p)` | `p - cost` | | `refund_unused_execution_cycles` | `refund.min(p)` | `refund.component_wise_min(p)` | | `CanisterManager::load_canister_snapshot` | `p - cost.min(p)` | `p - cost.component_wise_min(p)` | Subtraction saturates in both parts, so `x - y` already equals `x - x.component_wise_min(y)`: capping a cost before subtracting it from that same prepayment was redundant. Where a cap is wanted, `component_wise_min` replaces the ordering with the minimum of the real parts paired with the minimum of the nominal parts. `load_canister_snapshot` keeps its redundant cap so that it stays identical to the `refund_unused_execution_cycles` refund it recomputes. # Motivation Hardening rather than a fix. Under a single cost schedule the two parts of an amount agree on every ordering previously consulted, so the lexicographic tie-break was never reached: no cycles balance and no consumed cycles metric changes. Removing the derive keeps it out of reach as the accounting changes. Concretely, were the two amounts ever to carry different cost schedules, the removed ordering would produce: - `settle_prepayment_for_unexecuted_response`: for a prepayment made under the free cost schedule and settled under the normal one, the ordering picks the whole prepayment as the charge. The consumed cycles counter then reports the nominal prepayment for the entire instruction limit — for a callback that never ran — instead of the fixed per-message execution fee. - `refund_for_response_transmission`: same shape, same direction. Nothing is refunded and the whole nominal prepayment is reported as consumed. - `refund_unused_execution_cycles`: the cap is decided by the real parts, so it need not bound the nominal one at all. For a prepayment made under the normal cost schedule and refunded under the free one, it selects the refund and leaves that refund's nominal part uncapped: exceeding the prepayment there trips the debug assertion in `refund_cycles`, and saturates both metrics in a release build. The other way round it selects the whole prepayment, refunding all of it and reporting the response as having consumed nothing. None of the three moves a cycles balance: wherever the two orderings disagree, one of the amounts carries the free cost schedule and hence a zero real part, so only the nominal parts, i.e. the consumed cycles metrics, can differ. One comparison of this kind is left after this PR, in `adjust_prepayment_for_response_execution`, which weighs the prepayment for a response against the requirement derived when that response is executed. It is likewise sound only while both carry the same cost schedule. dfinity#11431 removes it. # Note dfinity#11431 builds on this and currently contains these hunks too. It will be rebased onto this PR, so review this one on its own. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both call sites of the adjustment go on to settle the *unadjusted* prepayment when it fails, since the `ResponseHelper` method wrapping it records the adjusted prepayment only on success. Name them and say what would go wrong, rather than just stating that they rely on the property. Also state what makes the opposite direction unable to fail: the withdrawal is of `required - prepaid`, whose real part saturates at zero when the requirement does not exceed the prepayment there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The implementation correctly updates both cycle components and the tests comprehensively cover successful, failing, and unexecuted-response paths.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The function's doc comment presented it as a consequence of performing the withdrawal before the refund, and did not mention it at all where the error is documented. State the requirement first and the order as the means, in both the function's and the test's doc comment, without spelling out what the callers do with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The implementation preserves failure atomicity and the expanded tests cover all relevant schedule and execution-mode combinations.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Summary
The cycles prepaid for a response execution are adjusted to the canister's current
Wasm execution mode before the callback is executed (#11414), so that a canister
upgraded from Wasm32 to Wasm64 (or vice versa) across a call is charged exactly as
if it had performed the call in the Wasm execution mode in which the response is
executed.
That adjustment compared the prepayment to the requirement on their real parts,
which made it a no-op under the free cost schedule: the real part of an
Instructionsamount is zero under that schedule, so both comparisons saw0against
0and left the nominal part at the amount prepaid for the Wasm executionmode the canister had when it performed the call.
refund_unused_execution_cyclesthen computes the refund in the canister's currentWasm execution mode and clamps it component-wise to the prepayment (#11529), so the
cycles reported into the consumed cycles metrics were wrong in both directions.
Impact
With a 1e9 instruction limit and 1e6 instructions executed by the callback, on a
subnet using the free cost schedule:
whole response execution, including the fixed per-message execution fee,
disappears from the metrics.
full instruction limit, i.e. one that scales with the instruction limit rather
than with the instructions actually executed, dwarfing the true value.
Only the metrics are affected: no cycles balance changes, since the real parts are
zero under the free cost schedule to begin with.
Details
The component-wise
CompoundCyclesprimitives this builds on — the part-wisesaturating subtraction and the component-wise minimum — landed in #11529, which is
merged, so this now targets
masterdirectly and the diff is confined toadjust_prepayment_for_response_executionand its tests.adjust_prepayment_for_response_executionno longer compares the prepayment againstthe requirement at all. It withdraws
required - prepaidand refundsprepaid - requiredunconditionally: subtracting twoCompoundCyclessaturates partby part, so each of the real and the nominal part is topped up or refunded on its
own, and for either part at most one of the two is non-zero.
refund_excess_prepayment_for_response_executionexisted only to hold the secondbranch and is removed. A failing adjustment must leave the canister state unchanged —
neither the balance nor the consumed cycles metrics may move — which its callers rely
on; the withdrawal therefore stays first, followed by the refund, which cannot fail.
The doc comment now states that requirement, which it did not spell out before.
Adjusting the two parts separately is what fixes the bug above: the nominal part is
now adjusted under the free cost schedule too, where the real parts of both amounts
are zero. Since nothing real is withdrawn there,
consume_with_threshold_implrequests a zero real amount against a zero threshold and cannot fail, i.e. the
out-of-cycles failure path introduced by #11414 is not reachable under the free cost
schedule, and only the metrics move.
Under the normal cost schedule the real and the nominal parts coincide and the
two saturating subtractions reproduce the two branches exactly: no cycle amount
and no metric changes for any subnet on that schedule.
settle_prepayment_for_unexecuted_response, which settles a response whose callbackis not executed at all, is left untouched: the fixed per-message execution fee does
not depend on the Wasm execution mode, so it already charges and reports that fee
correctly in both cost schedules and in all four combinations of Wasm execution
modes. The added test pins this down.
Dropping the fixed-cost-schedule assumption
Comparing a single part, be it the real or the nominal one, would have been sound
only because the prepayment recorded in the callback and the requirement derived when
the response is executed carry the same cost schedule. That holds today — a
subnet is assigned its cost schedule when it is created (
do_create_subnet) andkeeps it (a subnet split inherits it and splitting a rental subnet is rejected
outright), and
UpdateSubnetPayloadexposes no field to change it — but theaccounting no longer depends on it.
Were the cost schedule of a live subnet allowed to change, a canister now ends up
with exactly the prepayment that the cost schedule in effect at response time
requires: it gets its whole real prepayment back if its subnet switched to the free
cost schedule across the call, and it pays the real requirement if the subnet
switched the other way round.
What a free → normal flip would still forfeit
Only prepayments that are topped up can charge a canister more real cycles than it
prepaid, and the executed-response path is the only one that tops up. The two paths
that deliberately never top up — because the additional withdrawal could fail, and
failing there is worse than under-charging — would charge nothing real at all under
a free → normal flip, since the free prepayment they are capped against has a zero
real part:
settle_prepayment_for_unexecuted_responsechargesmin(prepaid.real, base_fee.real)(fix: remove the ordering on CompoundCycles #11529), which ismin(0, base_fee.real) = 0.The consumed cycles metrics still report the fixed per-message execution fee,
since the nominal parts do not depend on the cost schedule.
refund_for_response_transmissionreturnsprepayment - transmission_cost; the real part saturates at zero (fix: remove the ordering on CompoundCycles #11529), sothe whole (zero) real prepayment is refunded and the canister pays nothing real
for transmitting the response. Again only the nominal part is reported.
Neither is reachable today and neither is introduced here — both follow from the
no-top-up rule these two paths already had, now applied part by part by #11529.
Checked by hand over all four (schedule at the call, schedule at the response)
pairs; there is no case in which cycles are created or destroyed, a
refund_cyclesassertion is violated, or the consumed cycles metrics drift from the nominal cost.
Two further notes:
refund_unused_execution_cyclesnever sees a flip: in the response path it ishanded the adjusted prepayment, and elsewhere both the prepayment and the refund
are derived within one execution under one cost schedule.
PausedResponseExecutioncarriesOriginalContextby value, so a resumed slice replays the adjustment under thecost schedule captured by the first slice.
Testing
In
rs/cycles_account_manager/tests/cycles_account_manager.rs, two tests eachcovering the full matrix of (cost schedule × Wasm execution mode) at the call ×
(cost schedule × Wasm execution mode) at the response, i.e. 16 combinations:
response_execution_cycles_match_response_execution_setting: over the wholeprepay → adjust → refund sequence, both the cycles charged and the consumed cycles
metrics match the cost of executing the response under the cost schedule and in the
Wasm execution mode in which it was executed. Asserted at both points at which the
canister has paid a determined amount: right after the adjustment, where it must
have paid the requirement in both its real and its nominal part, and after the
refund of the instructions the callback did not execute;
settle_prepayment_for_unexecuted_response_charges_only_the_base_fee: a responsewhose callback is not executed costs, and reports, the fixed per-message execution
fee only, and never more than was prepaid.
Plus two tests on the two directions of the adjustment itself:
adjust_prepayment_for_response_execution_leaves_state_unchanged_on_failurepinsdown that requirement: an adjustment failing for lack of cycles leaves the balance
and both consumed cycles metrics untouched. One of its
two settings has the prepayment fall short of the requirement in the real part
while exceeding it in the nominal one, so the excess to be refunded is non-zero:
that is what pins down the order of the withdrawal and the refund. Swapping the
two makes the test fail on the consumed cycles gauge.
adjust_prepayment_for_response_execution_cannot_fail_without_a_real_shortfallisits mirror image: with the requirement below the prepayment in the real part
nothing is withdrawn, so the adjustment succeeds even for a canister that spent
its whole balance on the prepayment. Its second setting has the nominal part of
the prepayment fall short of the requirement while its real part is in excess,
i.e. the nominal part is topped up while the real one is refunded. The matrix test
above covers both settings, but always leaves the canister cycles to spare, so a
withdrawal attempted here would succeed there and go unnoticed: withdrawing
requiredrather thanrequired - prepaid(and refundingprepaidrather thanthe excess) keeps that test green while this one fails.
Reverting
adjust_prepayment_for_response_executionto its previous form makesresponse_execution_cycles_match_response_execution_setting(at itsafter-the-adjustment assertion) and the no-failure test fail, while
settle_prepayment_for_unexecuted_response_charges_only_the_base_feeand thestate-unchanged test still pass: those cover paths that were already correct.
🤖 Generated with Claude Code