Roa: provision a personal policy only for tier-1 node owners - #585
Conversation
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.
huangminghuang
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
huangminghuang
left a comment
There was a problem hiding this comment.
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.
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
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
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.
#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.
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.
Summary
regnodeownernow provisions a personal self-issued policy only for tier-1 node owners. Tiers 2 and 3 are registered with theirnodeownersbudget, areslimitrow, and the 10%sysioRAM 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, andreducepolicyare actions onsysio.roa, soaction::payer()resolves tosysio.roa, which carries unlimited CPU and NET. Every row those actions write is emplaced withget_self()as the RAM payer — 21 of the 23 row writes in the contract bill tosysio.roa.Confirmed directly: a registered node owner reduced to
ram = usage, net = 0, cpu = 0issued a 1/1/1 SYS policy successfully, and its CPU used stayed at0.Membership — the
nodeownersrow — 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, andnewuser'ssponsorsandsponsorcountrows are the only writes in the contract billed to a node owner instead of tosysio.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
newaccount_ramtheir account was created with.nodeownersaccounting excludes the personal weights:allocated_bwis zero andallocated_sysequals the 10% grant, leaving the remainder issuable.sysioRAM 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_reslimitis still called with zero weights, so thereslimitrow is created for every tier. That keeps it a complete registry of node owners and preserves the row-equals-quota invariant; the followingset_resource_limitsis a no-op against whatnative::newaccountset.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%sysiogrant; tier 2/3 hold zero bandwidth and exclude the personal weights from their accounting; and a tier-3 owner can still self-issue viaaddpolicy, 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
reslimitrow is reconciled rather than aborting the claim, is unaffected.unit_test,plugin_test,contracts_unit_test, and thesysio_roa_testssuite were run against the rebuilt contract.Note
Pre-launch, so no already-registered owners need migrating. This touches
sysio.roaalongside thebyusernameindex removal; whichever lands second will need the wasm rebuilt rather than a side picked.