Skip to content

Roa: provision a personal policy only for tier-1 node owners - #585

Merged
heifner merged 4 commits into
masterfrom
feat/roa-no-personal-policy-t2-t3
Aug 26, 2026
Merged

Roa: provision a personal policy only for tier-1 node owners#585
heifner merged 4 commits into
masterfrom
feat/roa-no-personal-policy-t2-t3

Conversation

@heifner

@heifner heifner commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

regnodeowner now provisions a personal self-issued policy only for tier-1 node owners. Tiers 2 and 3 are registered with their nodeowners budget, a reslimit row, and the 10% sysio RAM grant, but no allocation of their own.

Why tiers 2 and 3 do not need one

Managing policies costs a node owner nothing. addpolicy, expandpolicy, extendpolicy, and reducepolicy are actions on sysio.roa, so action::payer() resolves to sysio.roa, which carries unlimited CPU and NET. Every row those actions write is emplaced with get_self() as the RAM payer — 21 of the 23 row writes in the contract bill to sysio.roa.

Confirmed directly: a registered node owner reduced to ram = usage, net = 0, cpu = 0 issued a 1/1/1 SYS policy successfully, and its CPU used stayed at 0.

Membership — the nodeowners row — is what confers the ability to issue, not any allocation the owner holds. A tier-2 or tier-3 owner can self-issue on demand against the recorded tier budget.

Why tier 1 keeps it

Tier 1 is the only tier that can call newuser, and newuser's sponsors and sponsorcount rows are the only writes in the contract billed to a node owner instead of to sysio.roa. A tier-1 owner therefore needs RAM headroom before its first sponsorship.

Why it was disproportionate

The personal weights are a flat constant (80 units RAM, 500 each NET/CPU) while tier budgets are a fraction of supply. Tier 1 draws 4% of supply against tier 3's 0.003%, so the identical deduction is roughly three orders of magnitude more significant for a tier-3 owner. Against the launch configuration it consumed 4.77% of a tier-3 allocation versus 0.0036% of a tier-1's, and returning it raises a tier-3 owner's issuable budget by 5.6%.

Behaviour changes

  • Tiers 2 and 3 hold no personal policy, zero NET and zero CPU, and only the newaccount_ram their account was created with.
  • Their nodeowners accounting excludes the personal weights: allocated_bw is zero and allocated_sys equals the 10% grant, leaving the remainder issuable.
  • The sysio RAM grant is unchanged for every tier. Its guard was split from the personal policy's so it is no longer gated behind a policy tiers 2 and 3 never create.
  • increase_reslimit is still called with zero weights, so the reslimit row is created for every tier. That keeps it a complete registry of node owners and preserves the row-equals-quota invariant; the following set_resource_limits is a no-op against what native::newaccount set.

Tests

Adds regnodeowner_personal_policy_is_tier1_only, covering: tier 1 retains its personal policy; tiers 2 and 3 have none; every tier still carries the 10% sysio grant; tier 2/3 hold zero bandwidth and exclude the personal weights from their accounting; and a tier-3 owner can still self-issue via addpolicy, with the resulting quota landing on chain.

Two existing SEC-087 reconcile tests registered at tier 2 and asserted the personal weights stacking on top of a planted policy. Their expectations are updated — the planted policy now survives the reconcile unchanged. What they exist to prove, that a pre-existing reslimit row is reconciled rather than aborting the claim, is unaffected.

unit_test, plugin_test, contracts_unit_test, and the sysio_roa_tests suite were run against the rebuilt contract.

Note

Pre-launch, so no already-registered owners need migrating. This touches sysio.roa alongside the byusername index removal; whichever lands second will need the wasm rebuilt rather than a side picked.

regnodeowner gave every tier a self-issued personal policy of 0.0500 SYS each
for NET and CPU plus 0.0080 SYS of RAM. Tiers 2 and 3 no longer receive one.
They are still registered with their nodeowners budget, a reslimit row, and the
10% sysio RAM grant.

Tiers 2 and 3 have no use for it. A policy action that does not name an explicit
sysio.payer resolves action::payer() to sysio.roa, which carries unlimited
CPU/NET, and every row addpolicy / expandpolicy / extendpolicy / reducepolicy
writes is emplaced with get_self() as the RAM payer -- 21 of the 23 row writes
in the contract bill to sysio.roa. Confirmed directly: a registered node owner
reduced to ram = usage, net = 0, cpu = 0 issued a 1/1/1 SYS policy and its CPU
usage stayed at 0. Membership, not allocation, is what confers the ability to
issue, so a tier-2 or tier-3 owner can self-issue on demand against the recorded
budget.

Tier 1 keeps it because newuser is tier-1 only and its sponsors and sponsorcount
rows are the only writes in the contract billed to a node owner rather than to
sysio.roa, so a tier-1 owner needs RAM headroom before its first sponsorship.

The grant was also disproportionate. The personal weights are a flat constant
while tier budgets are a fraction of supply, so tier 1 draws 4% of supply against
tier 3's 0.003% and the identical deduction lands three orders of magnitude
harder on tier 3. Returning it raises a tier-3 owner's issuable budget by 5.6%.

The sysio grant's guard is split from the personal policy's so it still fires for
every tier rather than sitting behind a policy tiers 2 and 3 never create.
increase_reslimit is still called with zero weights, so the reslimit row is
created for every tier -- keeping it a complete registry and preserving the row
equals quota invariant -- and the following set_resource_limits is a no-op
against what native::newaccount set.

Adds regnodeowner_personal_policy_is_tier1_only. Two existing SEC-087 reconcile
tests register at tier 2 and asserted the personal weights stacking onto a
planted policy; the planted policy now survives the reconcile unchanged and
their expectations are updated. What they exist to prove, that a pre-existing
reslimit row is reconciled rather than aborting the claim, is unaffected.
@heifner
heifner requested review from a team and huangminghuang August 24, 2026 17:13
huangminghuang
huangminghuang previously approved these changes Aug 24, 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.

Reviewed the contract changes, tier accounting, reslimit reconciliation, authorization path, tests, and committed WASM. No actionable findings.

Rewriting the two SEC-087 reconcile tests to expect no personal weights left a
gap. Both register at tier 2, which now passes zero weights into
increase_reslimit, so neither exercises its MODIFY branch with non-zero deltas
any more. No other test registers a node owner over a pre-existing reslimit row
with a non-zero personal allocation -- the tier-1 nodeownreg calls in the suite
are all soft-fail paths (name_invalid, owner_not_account, non_em_key) that never
reach the stacking code.

Adds nodeownreg_tier1_reconcile_stacks_personal_weights: plant a 1/1/1 SYS
policy from NODE_DADDY, then register the account at tier 1 and assert the
planted weights and the personal weights stack to 1.0500 SYS of net/cpu with the
newaccount_ram gift counted once rather than re-added, that the on-chain quota
matches the reconciled row, and that the planted and personal policies coexist.

The increase_reslimit arithmetic itself remains covered from the policy side by
reduce_one_issuer_isolates_other and expand_after_reduce; what was missing was
the registration path, which is where SEC-087 lives.
// Minimal default net/cpu for a tier-1 owner: 0.0500 SYS each. Zero for tiers 2 and 3.
// Adding a zero asset below is a no-op, so the nodeowners totals stay correct for every
// tier without branching the accounting.
asset net_cpu_weight(provision_personal ? 500 : 0, state.total_sys.symbol);

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] Preserve a CPU bootstrap path for tier-2/3 owners

Setting CPU weight for the owner to zero makes addpolicy unreachable on a supported producer configuration where subjective API/P2P billing is enabled and --subjective-account-cpu-allowed-us=0. Objective billing uses sysio.roa, but transaction_context::verify_init_subjective_billing() separately checks the first authorizer; with zero CPU the owner is rejected before addpolicy can grant CPU. Please retain enough bootstrap CPU for one policy action, or provide another path whose admission does not depend on pre-existing owner 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.

The mechanism is real and correctly identified -- verify_init_subjective_billing() does check first-authorizers that are not payers, and a zero-CPU owner reaches it with available = allowance + 0 - 0.

First, a correction to my own evidence. The "a zero-CPU owner can still issue" result quoted in the commit message did not actually cover your scenario: that test used the tester's default billed_cpu_time_us = 2000, which sets explicit_billed_cpu_time and makes verify_init_subjective_billing() return on its first line. It never reached the subjective path. Re-run pushing with billed_cpu_time_us = 0 so the check genuinely engages:

tier-2 owner limits: ram=1144 net=0 cpu=0
subjective allowance = 300000us
target after addpolicy: net=100 cpu=100
issuer still: net=0 cpu=0

At the default allowance the owner is admitted and issues while holding nothing.

On --subjective-account-cpu-allowed-us=0 specifically: it is not a configuration this chain can run under, independent of this change. Every Wire user account holds zero objective CPU by design (native::newaccount sets 0, 0, 0), and an ordinary user is a non-payer first authorizer, so the same check rejects them on the same arithmetic. An ordinary user calling a fully provisioned contract:

allowance=300000 -> ordinary user OK
allowance=0      -> Subjectively terminated trx ... Authorized account alice exceeded
                    subjective CPU limit 0us by 0us with an objective cpu limit of 0us.
allowance restored -> ordinary user OK again

That is the gasless user path, not a node-owner edge case. Under =0 nobody transacts at all, so retaining bootstrap CPU for tiers 2 and 3 would not yield a working chain -- it would only change which account fails first. At any allowance above 0 the bootstrap is not needed. Leaving this as is.

One coverage note that fell out of the investigation: no test using the standard tester helpers exercises subjective billing, since they all pass a non-zero billed CPU. Reaching that path requires pushing with billed_cpu_time_us = 0.

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.

Added coverage for this in 100acf66a2.

zero_cpu_owner_issues_under_subjective_billing registers a tier-2 owner, asserts it holds zero NET and CPU, and issues a policy with subjective billing enabled -- pushed through a new push_subjective_action helper that passes billed_cpu_time_us = 0 so verify_init_subjective_billing() actually runs. It then drives the allowance to zero and asserts the identical push is rejected with Subjectively terminated trx.

That negative half is the point of the test. Without it the positive assertions would pass just as happily if explicit_billed_cpu_time had short-circuited the check -- which is exactly how my original evidence went wrong, so the test proves the gate is reached rather than assuming it.

One clarification on my previous reply: I said no test exercises subjective billing. That was accurate about the standard tester helpers but understated what exists -- subjective_billing_integration_test covers the chain-level case well, including an account created with net_weight and cpu_weight of zero, pushed with explicit_billed_cpu_time false, driven until admission is refused. So the gap was specific to the roa suite, not chain-wide, and I have not duplicated the chain-level coverage.

ROA suite is at 63 cases.

Objective billing is not the whole story for a node owner that holds no CPU.
addpolicy's payer is sysio.roa, but transaction_context::verify_init_subjective_billing()
separately checks first-authorizers that are NOT payers -- which is the issuer --
and admits it on subjective allowance plus objective limit. After this change a
tier-2 or tier-3 owner reaches that check with an objective limit of zero, so the
allowance alone carries it.

Nothing exercised that. The ordinary tester helpers pass DEFAULT_BILLED_CPU_TIME_US,
which sets explicit_billed_cpu_time and makes the check return on its first line, so
the whole admission path was invisible to the roa suite. Adds push_subjective_action,
which pushes with billed_cpu_time_us = 0 so the check actually runs.

zero_cpu_owner_issues_under_subjective_billing registers a tier-2 owner, asserts it
holds zero NET and CPU, and issues a policy through that path with subjective billing
enabled. It then drives the allowance to zero and asserts the identical push is
rejected with "Subjectively terminated trx". That negative half is what proves the
gate was reached rather than skipped -- without it the positive assertions would pass
just as happily if explicit_billed_cpu_time had short-circuited the check, which is
the failure mode this test exists to rule out.

The equivalent chain-level coverage already exists: subjective_billing_integration_test
creates an account with net_weight and cpu_weight of zero, pushes with
explicit_billed_cpu_time false, and drives the allowance down until admission is
refused. This adds the roa-side case that change makes load-bearing.
Picks up #584 (byusername index removal). sysio.roa.hpp merged cleanly -- the
index removal and the regnodeowner tier documentation touch different parts of
the file. sysio.roa.wasm conflicted as a binary and was resolved by rebuilding
contracts_project from the merged source rather than taking either side, so the
committed artifact carries both changes.
@heifner
heifner requested a review from huangminghuang August 24, 2026 22:52

@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 current head 670d406 after the subjective-billing coverage and master merge. The prior P2 is not actionable under Wire resource semantics. Contract logic, regression coverage, rebuilt WASM/ABI, and all required CI checks are clean.

heifner added a commit that referenced this pull request Aug 26, 2026
Each item below was checked against the implementation before the text changed;
the corrections are grouped by what they turn on.

RAM billing

  * setcode bills `code_size * setcode_ram_bytes_multiplier`, and that multiplier
    is 10 -- so the 60 KiB deployment example needed ~614,400 bytes / 0.5908 SYS,
    not ~61,440 / 0.0591, and the contract-plus-ABI footprint is ~0.6 SYS rather
    than "under one tenth". Sizing from the unmultiplied figure would have failed
    the setcode. `setabi` is billed 1x, so the ABI row was already right; the
    one-time 152-byte account_metadata row was missing and is now listed.
  * The `sysio.payer` RAM guarantee holds for unprivileged contracts only.
    `validate_account_ram_deltas` bypasses it when the receiver is privileged AND
    `sysio.`-prefixed -- `privileged_kv_payer_bypass` pins that both are required,
    since equally-privileged `privtest` still fails -- and again for a `sysio`
    payer, or any authorization whose actor is `sysio`.
  * A ported Antelope contract that bills rows to `{user, active}` is rejected
    whatever policy it holds. Provisioning the contract makes it callable; where
    it bills its RAM is a separate and mandatory porting decision.
  * A sponsorship row is 144 bytes (16 key + 16 value + 112), not 296: #584
    dropped the `byusername` index, whose row cost another 136. The
    billed-to-the-contract counterfactual is therefore ~1.09M users, not 530,000.
    Neither RAM pool is a ceiling -- `addpolicy` refuses CPU/NET to a `sysio.`
    account but not RAM.

CPU/NET billing

  * Failed transactions do not consume objective CPU/NET. `add_transaction_usage`
    is reached only from `finalize()`, which a throwing `exec()` never reaches,
    and the session is undone. A retry that lands is billed once.
  * A successful transaction drains block-wide `pending_cpu_usage` /
    `pending_net_usage` as well as the payer's window, so a contract's quota
    bounds the spam without making the blast radius categorically local.
  * Subjective billing excludes `tx_duplicate` and block-level exhaustion, but
    not `tx_cpu_usage_exceeded` / `tx_net_usage_exceeded`; it is CPU-only.
  * A signer's zero limit is consulted once subjective billing is on --
    `verify_init_subjective_billing` folds `get_cpu_limit` in additively -- so it
    contributes nothing rather than failing the transaction. "Never consulted"
    now says "under default billing".

Authorization

  * The permission paired with `sysio.payer` need not be `active`.
    `authorization_manager` accepts any entry whose actor matches the payer and
    whose permission is not `sysio.payer` itself.
  * A `sysio.` prefix does not confer unlimited limits. The system contracts are
    unlimited because a resource-limits row is born `-1` and they were never
    provisioned; `activateroa` gives `sysio.acct` an explicit 0/0.
  * A node owner can self-issue: `addpolicy` has no `require_auth(owner)` and
    registration itself writes a policy with `issuer == owner`. Scoped the claim
    to ordinary accounts.

Policy lifecycle and neighbours

  * `reducepolicy` unwinds CPU and NET fully but caps RAM at the owner's unused
    quota, floored to `bytes_per_unit`, decrementing by what was reclaimed rather
    than requested -- so consumed RAM pins residual weight in the issuer's budget.
    `ram_unused` is account-wide, so co-issuers draw on one shared pool.
  * Tier figures track master, with #585's tier-1-only personal policy and the
    resulting 101.9196 / 2.0385 SYS budgets noted as pending.
  * Antelope sponsorship: `delegatebw` separates `from`/`receiver` and PowerUp
    separates fee `payer`/`receiver`, so neither requires the recipient to hold
    tokens; PowerUp is not stateless, keeping `powup_state` and an order-expiry
    queue. The honest contrast is per-user funding versus one policy per contract.

Also fixed two internal anchors; every link in the file now resolves.

Change-Id: I9bcee3327fc6e9c43aeed6cbdd3a95ab6bf6cc7a
heifner added a commit that referenced this pull request Aug 26, 2026
Thirteen findings, several of which were the same claim stated in more than one
place -- so where the earlier text was already corrected further down, the
duplicate is deleted rather than qualified again. 937 -> 869 lines.

Corrections

  * NET is not just the serialized action. `get_action_billable_size` adds a
    share of the transaction's overhead -- fixed 16 bytes, signatures,
    extensions, header -- apportioned across the actions. So signatures cost
    NET, and batching amortizes the overhead. Both change how a developer sizes.
  * The throughput denominator is `total_cpu_weight` / `total_net_weight`: every
    positive weight, not structurally ROA's. Privileged `setalimits` /
    `setacctcpu` / `setacctnet` can add weight outside ROA, and bootstrap does.
  * Capacity is not policy-only: the 1,144-byte account gift, `giftram`, and the
    privileged setters are separate routes, none open to applications.
  * ROA has no consensus whitelist, but `nodeop` does -- `actor-whitelist` /
    `contract-whitelist` are node-local and can refuse a provisioned contract.
  * The `sysio` policy error means ROA refuses CPU/NET for the prefix; it does
    not mean such accounts are unlimited, and `sysio.acct` is at zero.
  * PowerUp's term is `powerup_days` (30 in the reference contract), not
    inherently daily.
  * A user "never acquires" a policy overstated it -- they need none for
    ordinary contract-paid calls, but can be granted one.
  * The ~157 MB `sysio.roa` allocation is where activation starts it, not fixed.
  * Reference actions/tables are labelled as selected rather than complete.

Deduplicated

  * The signer-is-never-checked absolutes now say "objective", and the caveat
    added last round is deleted -- the subjective-billing section already
    carries the additive-budget explanation in full.
  * The reclaim limitation was stated twice. The full treatment now lives with
    the stacking bullets, where a multi-issuer reader meets it; Capacity
    distribution points at it and no longer promises a clean exit.
  * The setcode and RAM-bypass callouts added last round are cut to the fact and
    the test that pins it.

Tier figures

  Stated at a precision correct both on master and under #585 (~2,718 / ~102 /
  ~2 SYS), so neither PR has to chase the other and the pending-change block is
  gone. #585's tier-1-only change is noted where the grant is described, without
  numbers that would go stale.

Depends on the get_required_keys fix

  The claim that a `sysio.payer` entry works when paired with `owner` or a custom
  permission is true at consensus but was not discoverable through
  /v1/chain/get_required_keys. That is fixed separately and lands first; this
  page assumes it.

Change-Id: Ic6ca4e47c11eeb8c6398d24f249667e187723d93
@heifner
heifner merged commit 28d130a into master Aug 26, 2026
12 checks passed
@heifner
heifner deleted the feat/roa-no-personal-policy-t2-t3 branch August 26, 2026 19:19
heifner added a commit that referenced this pull request Aug 26, 2026
Seven corrections, kept tight -- net +11 lines.

Native newaccount is not closed to everyone: apply_sysio_newaccount accepts it
whenever the declared creator authorized the action and is privileged. Scoped to
ordinary accounts rather than "nobody".

System-account CPU/NET is established, not inherited. Production creates these
accounts after sysio.system and ROA activation, so native newaccount writes
0/0/0 first and setsyscode -> giftram then sets -1 while funding the code RAM.
The conclusion stands; the mechanism described was wrong.

Failures are free only objectively. update_billed_cpu_time records CPU against
the payer and non-exhaustion failures pass that map to subjective_bill_failure,
with disable-subjective-payer-billing defaulting to false -- so failed spam does
consume the contract payer's node-local headroom.

NET sizing was incomplete: get_action_billable_size also adds the matching
context_free_data entry for a context-free action.

reducepolicy checks current_block >= time_block, so reduction is permitted in
the block that equals it. "at or after" in all four places.

Policy-only capacity claims scoped: privileged setalimits/setacctcpu/setacctnet
and the fixed account gift create weights outside ROA, which the document already
acknowledged later and contradicted in the opening invariant and the RAM-market
bullet.

The tier figures now carry a note that they describe current master and that
#585 returns 0.1080 SYS to tier-2 and tier-3 budgets, merging after this PR and
carrying the corresponding edit.
heifner added a commit that referenced this pull request Aug 26, 2026
#585 merged as 28d130a, so the pending note and promised merge order were
backwards. Folded in: the personal policy is tier-1 only, the provisioning table
marks those rows accordingly, tier-3 free budget is ~2.04 SYS (~2.1 MB all-RAM),
and the obsolete 4.77%-vs-0.0036% disparity passage is replaced with why tier 1
keeps it -- newuser's sponsors rows are the only writes billed to an owner.

RAM is freed by deleting or shrinking state, not deleting alone; setcode and
setabi apply a signed new_size - old_size delta.

reducepolicy is callable at or after time_block -- the one occurrence missed in
the previous pass.

Spam bounds the contract's own account quota rather than 'nothing else': every
success it lands also draws block-wide capacity.

Holding a policy is what makes a contract callable by ordinary users, not a
boolean gate -- a provisioned explicit payer can drive one without a policy, and
privileged paths set limits outside ROA.

The newaccount error entry now explains the privileged-creator requirement and
scopes newuser to tier-1 sponsorship rather than offering it as a general remedy.
heifner added a commit that referenced this pull request Aug 27, 2026
Reverts the subjective-billing defaults to current master. #591 is still open,
so documenting its values here would make the page false the moment this lands
first. That PR carries the doc change instead -- the same rule #585 established,
applied in the right direction this time.

Registration's self-issued policy is tier-1 only in the opening claim, matching
the detailed section.

Spam draws on the payer account's aggregate provisioned share -- the sum of every
policy stacked on it -- rather than one policy's slice, while each issuer's
exposure stays bounded by its own grant.

Occupied grant is pinned until the state is deleted or shrunk, propagating the
qualification already made in the resources section.

Self-pay needs CPU/NET allocation, not necessarily a policy: privileged setalimits
paths can establish limits without one, so the example is scoped to an ordinary
account holding only its fixed gift.
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