Skip to content

Bill Contracts Reached Through System Contracts for Their Own CPU - #752

Open
heifner wants to merge 8 commits into
masterfrom
feature/receiver-pays-cpu
Open

heifner wants to merge 8 commits into
masterfrom
feature/receiver-pays-cpu

Conversation

@heifner

@heifner heifner commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Rule

Each executed frame (one receiver's run of one action) is billed to one receipt entry.

system(a)   = config::is_system_account(a) && privileged(a)  // a is sysio or sysio.*; the RAM rule's test
has_code(a) = a has contract code as its frame starts

input action i:
   bill entry i, paid by act.payer()
   if act names an explicit payer: every frame below it bills entry i

child c of parent p (p called require_recipient or sent the inline action):
   if system(p.receiver) && !system(c.receiver) && has_code(c.receiver)
         && c.receiver != payer(p.entry):
      bill a new entry after the input entries, paid by c.receiver
      if c is a notification (c.receiver != c.act.account):
         p.entry pays for the new entry's first notify_cpu_stipend microseconds
   else:
      bill p.entry

The RAM rule uses the same system test: a privileged sysio or sysio.* contract may bill RAM to other accounts.

An explicit payer pays for every frame below its action, whoever it is. With sysio as the explicit payer, sysio pays for all of them. Implicit transactions (onblock) follow the explicit-payer rule: every frame bills the input payer, sysio.

Examples:

  • A transfer between plain accounts is unchanged.
  • A contract's transfer handler is billed to the contract past the stipend.
  • When a contract pays a winner, the contract pays for the transfer and for the winner's stipend, and the winner pays for the rest of its handler.
  • sysio.msig::exec into a contract bills that contract from the start.
  • An explicit payer pays for the whole subtree, within max_cpu_usage_ms.

Stipend

  • notify_cpu_stipend is a new chain_config parameter in microseconds, 100 by default at genesis. Governance changes it through setparams, and 0 turns the stipend off. Validation requires notify_cpu_stipend <= max_transaction_cpu_usage; there is no other bound. It is appended as id 17, after max_kv_secondary_key_size, in chain_config, its packing, SHiP and CDT's blockchain_parameters, so no existing id moves.
  • The entry that pays for the notifying frame pays for the notified contract's first notify_cpu_stipend, and the contract pays for the rest. Nothing aborts at the stipend, and no contract needs a handler.
  • A switch gets one stipend, shared by every frame billed to the contract's entry, including the contract's own inline actions.
  • Only notifications get it, since a contract cannot refuse them. An action sent to a contract (sysio.msig::exec, or an inline action from a system contract) bills the contract from the start, as calling it directly does.
  • When the notifying frame bills a contract, for example when a contract's inline transfer notifies another contract, that contract pays the stipend on top of its own CPU.
  • The receipt still has one entry per switch. A contract whose frames stay within the stipend has an entry of 0, and its time is in the paying entry. An entry of 0 bills nothing: the contract's CPU window is neither updated nor checked.
  • Only the node that measures CPU applies the stipend, reading the parameter as the entry starts. Validators bill the receipt's entries as given and never re-measure, so every validator bills the producer's split.
  • Loading a module never counts. Instantiation already paused billing on every runtime; a sys-vm-oc compile on a code-cache miss now pauses it too. A paused interval leaves both the frame's CPU and its deadline, so a contract's first run is not billed for its load. A pause does not move the block's deadline: a load that runs past it stops the transaction with deadline_exception, which a producer retries in a later block.

Receipt

cpu_usage_us keeps its type. After the input entries, it gets one entry per frame where billing switched, in the order those frames started. An entry covers its frame and the frames below it billed to the same contract, past the stipend, plus the stipends that contract pays for. The transaction's CPU is the sum of all entries. Transactions without a switch add no bytes, and no account names are stored.

A validator requires at least one entry per input action before execution. After execution, it bills an entry the receipt leaves out as 0. The receipt it builds then differs from the block's, and block validation rejects the block, in every validation mode. A receipt with more entries than switched frames fails the transaction.

Billing

  • A producer measures each receiver entry as its frames' run time past the stipend. The input entry's action clock and CPU deadline move past that time, so it is left out of the input entry. A stipend paid by an input entry stays in that entry's time; a stipend paid by a receiver entry is added to that entry. The time outside every entry's frames (signature recovery and setup) is spread over the input entries only.
  • A frame billed to a receiver runs for what is left of its stipend, then for the receiver's remaining CPU, never less than none, plus the usual leeway. The transaction deadline still applies. A frame that runs out fails with tx_cpu_usage_exceeded on the account CPU limit. Read-only transactions skip these limits.
  • Receivers pay CPU only: their NET window is neither updated nor checked.
  • Subjectively, a receiver entry is billed to the input action's first authorizer, so that authorizer's pending bill still covers the whole transaction. With payer billing on (disable-subjective-payer-billing=false), the receiver is also billed like any payer, but only once if it is that authorizer. A receiver's subjective bill counts against its frames as a payer's counts against its action.
  • A failed transaction keeps all of its CPU on the input entries, and no receiver is billed for it. That includes a transaction that runs to the end and then fails finalize's CPU checks. finalize bills subjectively only after those checks and the usage record pass, and a failed check undoes the billing, so the controller bills the transaction as a failed one. Its payers are billed the same way; before, such a transaction kept a pending bill on a speculative node and got no subjective bill on a producing one.
  • Receiver limits are read against usage decayed to the pending block, since their windows were not updated at init(). They are read when a receiver's first frame starts, for its deadline, and again from the transaction's final state for the final checks, so CPU granted to the receiver later in the transaction counts.
  • action_trace::cpu_usage_us is filled for each frame where billing switched.

Consensus

A hard change with no protocol feature: the chain has not launched. The new chain_config field changes the genesis and so the chain id, SHiP's chain_config, and setparams in sysio.bios and sysio.system. It also changes the layout of sysio.system's global row, which derives from blockchain_parameters.

Tests

receiver_cpu_billing_tests uses a new cpu_receiver_test contract with sysio.token and sysio.msig. The fixture sets notify_cpu_stipend to 1 ms through setparams, so that a frame inside or past it does not depend on the host's speed. A test that needs a handler past the stipend first measures a loop count that keeps it there, since runtimes differ by orders of magnitude. The suite passes on sys-vm, sys-vm-jit and sys-vm-oc.

checktime_speculative_max_trx_test now runs its contract once before its timings, as its interrupt half already did. This fixes CI run 42879946461, where the test failed on sys-vm-oc in all five Linux jobs. Its first push compiles the contract in sys-vm-oc-forced mode, the only mode that compiles inside a transaction. Master bills that compile against the push's 25 ms limit. This change keeps it unbilled, so that loading or compiling a module is never billed on any runtime, and the push stops at its 500 ms block deadline instead. Five runs each on sys-vm-oc without the warm-up:

Build Result First push Billed
This branch 5 of 5 fail deadline_exception at the 500 ms block deadline after 0.79 to 0.90 s 0.3 to 0.4 ms
This branch with only the compile pause reverted 5 of 5 pass tx_cpu_usage_exceeded after 0.89 to 0.90 s 0.89 to 0.90 s, against a 25 ms limit
master 1775bd2 5 of 5 pass

The stipend's behaviour:

Behaviour Test
Many notifications in one block to a contract that returns at once cost it nothing; its own actions in that block and the next still run many_notifications_in_one_block_cost_the_contract_nothing
A contract with CPU runs past the stipend and is billed only for the rest notified_contract_pays_only_past_the_stipend, stipend_follows_setparams
A contract without the CPU fails the notifying transaction with the usual CPU error, and nothing is applied contract_without_cpu_receives_within_the_stipend (balances unchanged), receiver_over_its_limit_fails_on_account_cpu_limit
A contract's first run loads a large module (tens of milliseconds, seconds on sys-vm-oc): the load leaves its CPU and its deadline, so with no leeway a handler with nothing to do still fits the stipend cold_module_load_takes_none_of_the_stipend
A pause inside a frame, the same on every runtime, in an input action's frame or a notified contract's: it leaves the frame's CPU and deadline, and past the block's deadline the transaction stops there with deadline_exception pause_in_a_frame_is_left_out_of_its_cpu_and_deadline, pause_past_the_block_deadline_stops_at_the_block_deadline
An entry of 0 leaves an over-limit window alone zero_entry_leaves_the_contracts_cpu_window_alone
One stipend, shared by a contract's handler and its own inline action stipend_is_shared_by_the_frames_billed_to_the_contract
A contract that notifies another pays that one's stipend game_pays_the_winners_stipend_and_the_winner_the_rest
msig exec gets no stipend msig_exec_bills_the_called_contract_without_a_stipend
100 µs at genesis; a changed value through setparams; 0 turns it off; above max_transaction_cpu_usage is refused notify_cpu_stipend_starts_at_100_us, stipend_follows_setparams, stipend_above_the_transaction_limit_is_rejected, params_tests
A validator bills a split no node would measure, as written validator_bills_the_producers_split

The suite also covers:

  • plain transfers;
  • an authorizer with no CPU or NET;
  • forwarded notifications;
  • explicit payers and max_cpu_usage_ms;
  • overhead placement;
  • subjective billing with and without payer billing, on speculative and producing nodes, cleared by the including block, for a receiver that authorized the input, and for failures, including a transaction that fails its final CPU checks;
  • a receiver's own subjective bill stopping its frame at the stipend;
  • CPU granted to a receiver later in the same transaction;
  • a privileged versus an unprivileged sysio.* receiver;
  • two inputs with different authorizers;
  • a contract reached twice, with a stipend each time;
  • a receiver that also pays for another input;
  • an explicit payer on one of two inputs;
  • a receiver frame stopped by max_cpu_usage_ms;
  • a greylisted receiver;
  • code set or cleared earlier in the same transaction;
  • read-only transactions;
  • explicitly billed receipts with missing and extra entries;
  • tampered blocks.

Each billing test also checks that the validator's CPU and NET windows match the producer's.

The tester bills its explicit pushes one entry per input action, as before, so a contract billed for its own code is billed nothing there.

Paired changes

Land these together: wire-cdt first, then wire-sysio right after it, then wire-libraries-ts and wire-tools-ts.

Consumers of the receipt and per-action CPU

  • SHiP: the receipt's cpu_usage_us keeps its varuint32[] type; action traces carry no CPU field.
  • trace_api: action traces' optional cpu_usage_us now also appears on receiver frames.
  • wire-libraries-ts sdk-core: get_block_response_receipt.cpu_usage_us is already an array; p2p/Types.ts and chain/Transaction.ts keep a single uint32.
  • wire-hub: the block page sums cpu_usage_us as a scalar, and the transaction page reads the first action's CPU or the receipt as a scalar.
  • wire-tools-ts: no reader of either field.
  • Anything that indexes cpu_usage_us by action index must read only the first total_actions() entries. Keep Subjective CPU Charged to Authorizers of sysio.*-Paid Actions #750's bill_sysio_paid_authorizers requires exactly total_actions() entries; whichever of the two lands second updates it.

Follow-ups

  • ROA policies that pay for deploying code must also grant CPU, since a contract needs CPU for handlers that run past the stipend.
  • Consider restricting sysio.-prefixed names in native newaccount, since the rule keys on the prefix.

When a system contract (privileged, sysio or sysio.*) notifies or inline-calls a contract outside the system accounts, that contract pays the CPU of its own code. Frames below it bill the same contract, so a contract it forwards to or calls is billed to it. An explicit payer on the input action still pays for everything below it.

The receipt's cpu_usage_us holds one entry per input action and then one per frame where billing switched, in execution order, and the transaction's CPU is their sum. A validator derives each entry's account by running the transaction: an entry the receipt leaves out bills nothing, so the receipt it builds differs from the block's and the block is rejected, and an extra entry fails the transaction. A contract billed this way pays no NET.

Change-Id: I076e6c34f81034fe4a456c99c6795a46c1cfd478
The test that leaves a receiver entry out of a receipt now moves that entry's CPU onto the input entry. The input entry alone can be under the CPU minimum, and on fast builds the minimum check rejected the block before the receipt comparison ran.

Change-Id: I79ec207a7bdb550e657ee986cef6535fc97d3989
Comment on lines +734 to +735
if (bill_objectively) {
bill_receiver(e, cpu);

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.

[P1] Remove receiver subjective charges when finalization rejects the transaction

update_billed_cpu_time() runs before the final CPU checks, so trace->except is still empty here when execution succeeds but finalization will fail. Receiver entries are added to accounts_billing and committed to the subjective cache; is_cpu_updated then prevents the controller's exception handler from rebuilding failure billing.

I reproduced this with payer billing enabled on a speculative node: a transfer to game, followed by a cheap action on a zero-CPU contract, fails finalization yet leaves game with pending subjective CPU. These rejected transactions cannot be included in a block to clear their charges, so repeated submissions can throttle an unrelated receiving contract. Please defer publishing receiver subjective charges until the final checks succeed, or roll them back and rebuild input-only failure billing.

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.

Fixed in bbabd2c. finalize now bills the CPU, runs the final checks and records the usage before it bills anything subjectively. A failed check undoes the billing (unbill_cpu), so the controller bills the transaction as a failed one: no receiver entries, CPU on the input entries, and a failure's subjective bill for the payers and the first authorizer. Before, such a transaction also kept its payers' pending bill on a speculative node and billed nothing on a producing one. Test: transaction_failing_its_final_checks_bills_no_receiver, on speculative and producing blocks, with and without payer billing.

Comment thread libraries/chain/transaction_context.cpp Outdated
Comment on lines +834 to +836
accounts_billing.try_emplace(e.account, account_billing{ .cpu_limit_us = e.cpu_limit_us,
.cpu_greylisted = e.cpu_greylisted,
.pays_net = false })

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.

[P2] Refresh receiver CPU limits before final validation

e.cpu_limit_us was captured at frame entry. A measured receiver is inserted into accounts_billing only during update_billed_cpu_time(), after the finalize() loop that refreshes input payers, so this cached limit is used even if later actions granted CPU.

I reproduced this with a short transfer handler on idle with zero initial CPU, followed by a CPU grant in the same transaction: finalization rejects the receiver against its old zero limit. Reversing the action order succeeds. This also prevents a receiver from using the execution leeway to obtain resources during its execution. Please refresh receiver CPU limits from the final transaction state, including pending-block usage decay, before validate_available_account_cpu().

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.

Fixed in bbabd2c. A receiver entry no longer carries its entry-time limit into accounts_billing. finalize reads each receiver's limit from the final state, decayed to the pending block, before validate_available_account_cpu. The limit read as the receiver's first frame starts still sets its frames' deadline, with the leeway. Test: receiver_limit_includes_cpu_granted_in_the_transaction, with the grant after the handler and before it.

finalize billed a successful transaction subjectively before its final CPU checks ran. When a check then failed, the controller could not rebill the transaction, so its pending subjective bill stayed in place, receivers included, although the transaction never reaches a block. finalize now bills the CPU, runs the checks and records the usage, and bills subjectively only after all of them pass. A failed check undoes the billing, and the controller bills the transaction as a failed one: its CPU stays on the input entries and no receiver is billed. Payers of such a transaction now get a failure's subjective bill too, on producing nodes as well.

A contract billed for its own code is checked against its CPU limit as the transaction leaves it, read again from the final state with its usage decayed to the pending block, so CPU granted to it later in the transaction counts. The limit read as its first frame starts still sets the frames' deadline.

New tests cover both, and receiver billing for a privileged versus an unprivileged sysio.* receiver, two inputs with different authorizers, a contract reached twice, a receiver that also pays for another input, an explicit payer on one of two inputs, a receiver frame stopped by max_cpu_usage_ms, a greylisted receiver, and code set or cleared earlier in the same transaction.

Change-Id: Ib8d80437784b4d42228a3dd0feb815bd268fc804
huangminghuang
huangminghuang previously approved these changes Nov 9, 2026

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

Re-reviewed bbabd2c. Both previous findings are fixed; no new actionable findings. The isolated review build passed 30 focused tests and 495 assertions on each of sys-vm and sys-vm-jit, including both original reproductions.

@heifner
heifner marked this pull request as ready for review November 10, 2026 16:19
The entry that pays for a frame now also pays for the first `notify_cpu_stipend` microseconds of a contract the frame notifies. The contract's entry holds only its CPU past that. `notify_cpu_stipend` is a new `chain_config` parameter:
- genesis default 100 µs;
- changed through `setparams`;
- 0 means no stipend;
- at most `max_transaction_cpu_usage`;
- appended as id 17, so no existing id moves.

A switch gets one stipend, shared by every frame billed to the contract's entry. An action sent to a contract, such as through `sysio.msig::exec`, gets none, just as calling the contract directly gets none.

The receipt format is unchanged. A contract whose code stays within the stipend has an entry of 0, and an entry of 0 bills nothing. Only the producer applies the stipend: validators bill the receipt's entries as given.

A notified frame runs for what is left of the stipend, then for the contract's remaining CPU. On sys-vm-oc, a compile on a code-cache miss now pauses billing, as module instantiation already does on the other runtimes, so a contract's first run is not billed for loading its module.

`sysio.system` packs the new parameter. `sysio.bios`, `sysio.system`, `params_test` and `cpu_receiver_test` are rebuilt with the matching wire-cdt, and SHiP's `chain_config` gains the field. `cpu_receiver_test` drops its context-free inline, which master removed, and gains `spinsend:<n>`. The msig test proposes with `sysio.payer`, as master now requires. The trace_api docs say that `cpu_usage_us` also appears on receiver frames. The deep-mind log, the snapshot compatibility files, the consensus blockchain data and the sys-util snapshot info expectations are regenerated.

Change-Id: Iba358c8dec580c897389828eeff54d66f70761d7
huangminghuang
huangminghuang previously approved these changes Nov 10, 2026

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

Re-reviewed e2814b1, including the merge with master, notification CPU stipend, configuration serialization, and optimized-runtime compilation billing. Both earlier findings remain fixed; no new actionable findings. An isolated rebuild of 99 translation units passed all 47 focused receiver CPU billing, parameter, and system-host tests (894 assertions) on each of sys-vm, sys-vm-jit, and sys-vm-oc.

…ys-cpu

Brings in #758, #759, #760 and #761. `sysio.system` is rebuilt with the wire-cdt of #132, so `sysio.system.wasm` carries #761's changes and the new parameter. Every other contract and ABI built with that wire-cdt matches master's byte for byte. `get_cached_descriptor`, which this branch adds to the sys-vm-oc code cache, drops the top-level `const` on its return type, as #758 does for the functions beside it. The deep-mind log, the snapshot compatibility files, the consensus blockchain data and the sys-util snapshot info expectations still pass unchanged.
CI run 42879946461 on e2814b1 failed `checktime_speculative_max_trx_test` on sys-vm-oc in every Linux job: its first push threw `deadline_exception` instead of `tx_cpu_usage_exceeded` from a 25 ms max-transaction-time. That push is the pause contract's first run, which in sys-vm-oc-forced mode compiles the contract, about 0.9 s here. Master bills the compile to the transaction, which then fails on its 25 ms limit. This branch pauses billing for the compile, as instantiation already is, so that loading or compiling a module is never billed on any runtime: the transaction's deadlines move past the pause, but the push's 500 ms block deadline does not, so the push stops there. With only that pause reverted, the push fails on its 25 ms limit as on master, 5 of 5 runs each way. The test now runs the contract once before its timings, through a `warm_pause_contract` helper its interrupt half also uses.

Two tests pause a frame through `substitute_apply`, the same on every runtime, first the input action's frame and then a notified contract's. One checks that the frame's CPU and deadline leave the pause out. The other checks that a pause past the block deadline stops the transaction with `deadline_exception`, which a producer retries.

Change-Id: Id65e27318f3da9204a2d77d36271479f08af525f
CI run 42941122275 failed `stipend_is_shared_by_the_frames_billed_to_the_contract` on asan and ubsan. Its last check compared entries from separate one-frame and two-frame transactions, so a host that slowed one batch could fail it. The test now checks, within each transaction, that the entry leaves out of its two frames' elapsed time less than 1.5 stipends: one stipend plus the time outside the frames, where a stipend per frame would leave out two.

Change-Id: Iecf2452a0cb08c78eace93a9a3b7cd63cbe425bd

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants