Skip to content

fix: adjust the response execution prepayment on its nominal part - #11431

Draft
mraszyk wants to merge 15 commits into
masterfrom
mraszyk/response-execution-nominal-cycles
Draft

fix: adjust the response execution prepayment on its nominal part#11431
mraszyk wants to merge 15 commits into
masterfrom
mraszyk/response-execution-nominal-cycles

Conversation

@mraszyk

@mraszyk mraszyk commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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
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.

refund_unused_execution_cycles then computes the refund in the canister's current
Wasm 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:

Wasm execution mode at the call → at the response reported correct
Wasm32 → Wasm64 0 7 000 000
Wasm64 → Wasm32 1 006 000 000 6 000 000
  • Wasm32 → Wasm64: 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.
  • Wasm64 → Wasm32: 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, 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 CompoundCycles primitives this builds on — the part-wise
saturating subtraction and the component-wise minimum — landed in #11529, which is
merged, so this now targets master directly and the diff is confined to
adjust_prepayment_for_response_execution and its tests.

adjust_prepayment_for_response_execution no longer compares the prepayment against
the requirement at all. It withdraws required - prepaid and refunds
prepaid - required unconditionally: subtracting two CompoundCycles saturates 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.
refund_excess_prepayment_for_response_execution existed only to hold the second
branch 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_impl
requests 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 callback
is 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) and
keeps it (a subnet split inherits it and splitting a rental subnet is rejected
outright), and UpdateSubnetPayload exposes no field to change it — but the
accounting 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:

  • A response whose callback is not executed at all.
    settle_prepayment_for_unexecuted_response charges
    min(prepaid.real, base_fee.real) (fix: remove the ordering on CompoundCycles #11529), which is min(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.
  • The response transmission. refund_for_response_transmission returns
    prepayment - transmission_cost; the real part saturates at zero (fix: remove the ordering on CompoundCycles #11529), so
    the 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_cycles
assertion is violated, or the consumed cycles metrics drift from the nominal cost.
Two further notes:

  • refund_unused_execution_cycles never sees a flip: in the response path it is
    handed the adjusted prepayment, and elsewhere both the prepayment and the refund
    are derived within one execution under one cost schedule.
  • DTS cannot introduce one either: PausedResponseExecution carries
    OriginalContext by value, so a resumed slice replays the adjustment under the
    cost schedule captured by the first slice.

Testing

In rs/cycles_account_manager/tests/cycles_account_manager.rs, two tests each
covering 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, 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 response
    whose 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_failure pins
    down 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_shortfall is
    its 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
    required rather than required - prepaid (and refunding prepaid rather than
    the excess) keeps that test green while this one fails.

Reverting adjust_prepayment_for_response_execution to its previous form makes
response_execution_cycles_match_response_execution_setting (at its
after-the-adjustment assertion) and the no-failure test fail, while
settle_prepayment_for_unexecuted_response_charges_only_the_base_fee and the
state-unchanged test still pass: those cover paths that were already correct.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

@mraszyk
mraszyk marked this pull request as ready for review September 3, 2026 12:11
@mraszyk
mraszyk requested a review from a team as a code owner September 3, 2026 12:11
@zeropath-ai

zeropath-ai Bot commented Sep 3, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 1329ba8.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► rs/cycles_account_manager/src/cycles_account_manager.rs
    Add extensive documentation/comments about prepayment comparison using nominal parts and cost schedules
► rs/cycles_account_manager/tests/cycles_account_manager.rs
    Add tests for prepayment adjustment, response execution costs, and consumed cycles across cost schedules and Wasm modes

/// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should enforce that here then.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread rs/cycles_account_manager/src/cycles_account_manager.rs Outdated
`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>
mraszyk and others added 8 commits September 10, 2026 10:56
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>
@mraszyk
mraszyk force-pushed the mraszyk/response-execution-nominal-cycles branch from 052efc0 to f26f08c Compare September 11, 2026 07:00
@mraszyk
mraszyk changed the base branch from master to mraszyk/compound-cycles-no-ordering September 11, 2026 07:05
@mraszyk
mraszyk requested a balanced review from Copilot September 11, 2026 07:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread rs/cycles_account_manager/tests/cycles_account_manager.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The accounting change is consistent with component-wise cycle semantics and comprehensively tested.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • 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>
@mraszyk
mraszyk force-pushed the mraszyk/response-execution-nominal-cycles branch from 1ebeb34 to 2ca09e3 Compare September 11, 2026 07:30
@mraszyk
mraszyk requested a balanced review from Copilot September 11, 2026 07:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The accounting change is consistent with component-wise cycle semantics and thoroughly tested.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Base automatically changed from mraszyk/compound-cycles-no-ordering to master September 11, 2026 12:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The component-wise accounting is correct and comprehensively covered by focused tests.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

`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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The accounting logic is consistent with component-wise cycle semantics and is thoroughly tested.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

`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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused implementation is correct and thoroughly covers component-wise accounting and failure behavior.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

pull Bot pushed a commit to bit-cook/ic that referenced this pull request Sep 11, 2026
# 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

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.

3 participants