From cfdf4ce438310b4a516ca29f746695c97c96a3d5 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Sat, 22 Aug 2026 08:34:26 -0500 Subject: [PATCH 01/26] docs: add ROA resource model overview User-facing documentation for Resource Owner Allocation, covering the payer model, policies, and how CPU/NET/RAM are provisioned on Wire. The payer of an action is chosen explicitly: authorization[0] carrying the reserved sysio.payer permission names the payer, otherwise the payer is the contract being called. Ordinary transactions do not name one, so the contract pays and the signing account is never charged or limit-checked. An account can elect to pay for itself, and then needs its own allocation. Also documents node owner tiers and budgets, multi-issuer policy stacking, the proportional-share formula and where its denominator comes from, the two layers of spam control, and worked scenarios for users, contract deployment, trading, and onboarding. Includes a comparison to EOS staking, REX, and PowerUp for readers arriving from Antelope. Behavioural claims were verified against the implementation: contract-as-payer billing, the failure mode of an unprovisioned contract, RAM-free token transfers, and multi-issuer stacking. --- docs/roa-overview.md | 561 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 docs/roa-overview.md diff --git a/docs/roa-overview.md b/docs/roa-overview.md new file mode 100644 index 0000000000..e8fa843f24 --- /dev/null +++ b/docs/roa-overview.md @@ -0,0 +1,561 @@ +# Resource Owner Allocation (ROA) + +How Wire pays for CPU, NET, and RAM — and why end users don't. + +--- + +## The short version + +On Wire, **the payer is chosen per action, and by default it is the contract, not the caller.** + +If the first authorization on an action carries the reserved `sysio.payer` permission, that actor +is the payer. Otherwise the payer is the contract account being called. Ordinary transactions do +not name a payer, so the contract pays. + +That means when you sign a normal transaction, the CPU time and network bandwidth it consumes are +billed to the *contract account you called* — not to you. Sending tokens, swapping on a DEX, +minting an NFT: the signing account is not charged CPU, NET, or RAM for them, and needs no +resource allocation, stake, or rental. + +An account **can** elect to pay for itself by naming `sysio.payer` — a relayer or an operator that +wants its own dedicated throughput does exactly this. It then needs its own allocation, and fails +without one. See [Explicit self-pay](#explicit-self-pay). + +Resources reach contract accounts through **policies** issued by **node owners**. A node owner +holds a fixed share of the network's resource capacity, determined by their tier, and grants +slices of it to accounts via the `sysio.roa` contract. A contract with a policy works. A contract +without one cannot be called at all. + +--- + +## How this compares to EOS and Antelope + +If you have used EOS, Antelope, or Vaulta, you have seen three answers to the same question — +*how does an account get the right to consume chain resources?* + +**Staking (2018).** You locked tokens with `delegatebw` to get a proportional share of CPU and NET, +and bought RAM outright from a Bancor-curve market with `buyram`. Users had to hold enough token to +stake, understand three resource types, and manage `undelegatebw` timing. A dApp onboarding new +users had to hand each of them staked tokens or build a custodial account layer. RAM price moved +with the market, so contract deployment cost moved with it. + +**REX (2019).** A lending market let token holders rent out their staked CPU/NET for yield instead +of leaving it idle. It improved capital efficiency and added a third system to understand alongside +staking and the RAM market. + +**PowerUp (2021).** Replaced REX rentals with a stateless daily-expiring rental priced off a +utilization curve. Simpler to use — you paid for a day's worth of CPU/NET — but the signer still +pays, still needs the chain's native token, and the price still moves with the utilization curve. + +In all three, the account that signs is the account that is billed. + +On Wire the account that signs is not billed at all. The contract is. It is provisioned once, by a +node owner issuing a policy, and every account that calls it transacts without charge. + +| | EOS staking | REX | PowerUp | **Wire ROA** | +|---|---|---|---|---| +| Who is billed for a transaction | Signer | Signer | Signer | **Called contract** | +| End user needs native token | Yes | Yes | Yes | **No** | +| CPU/NET acquired by | Locking tokens | Renting from a pool | Daily fee on a curve | **A node owner's policy** | +| RAM acquired by | Bancor market purchase | same | same | **A node owner's policy** | +| Price set by | RAM market | Rental market | Utilization curve | **Not set on-chain** | +| Reclaimable by | Unstake (3d) | Sell rex | Expires daily | **`reducepolicy` after `time_block`** | +| Cost to onboard a user | Tokens + stake + RAM | same | same | **None to the user** | + +--- + +## The three resources + +Wire meters the same three resources Antelope does. + +**CPU** — execution time, in microseconds. Metered per action and billed to that action's payer. + +**NET** — transaction size on the wire, in bytes. Billed per action based on its serialized +billable size. + +**RAM** — persistent state: account rows, permissions, contract code, and every table row a +contract writes. Measured in bytes. Unlike CPU and NET, RAM is not a rate — it is an occupancy +level. It is consumed when state is written and released when state is deleted. + +CPU and NET replenish continuously over an averaging window. RAM does not replenish; it is freed +only by deleting the data holding it. + +--- + +## Who pays: the payer model + +This is the part that differs most from Antelope, and it is worth being precise about. + +Every action carries a list of authorizations. Wire adds a reserved permission name, +`sysio.payer`, that may appear as the **first** authorization on an action. The rule is: + +```cpp +// libraries/chain/action.cpp +account_name action::payer() const { + if (!authorization.empty() && authorization[0].permission == config::sysio_payer_name) + return authorization[0].actor; + return account; // the contract being called +} +``` + +The transaction's billing map is keyed on `payer()` and nothing else. An authorizing account that +is not the payer never enters the map, so its CPU and NET are neither charged nor checked. + +| Action authorizations | Payer | Notes | +|---|---|---| +| `{alice, active}` | `sysio.token` (the contract) | The default. Alice pays nothing. | +| `{alice, sysio.payer}, {alice, active}` | `alice` | Explicit self-pay. Alice needs her own allocation. | +| `{alice, sysio.payer}, {alice, active}, {bob, active}` | `alice` | Alice covers the whole action's cost. | + +An explicit payer is not a way to bill a stranger. The protocol requires that the `sysio.payer` +entry sit at index 0, that the same actor also appear with a real permission on that action, and +that the transaction carry signatures satisfying that actor's `active` authority. You can only +volunteer yourself, or someone who co-signs. + +### Why the contract can afford it + +System accounts — anything whose name prefix is `sysio` — carry unlimited resource limits +(`-1`), and `sysio.roa` preserves that: every code path that touches a `sysio.*` account's limits +passes `-1` for CPU and NET, and `addpolicy` refuses to allocate CPU or NET to them at all. So +`sysio.token` transfers, `sysio.msig` proposals, and every other system-contract call are covered +by the system itself. + +A third-party contract is different. It is an ordinary account, it is the payer for every call +into it, and it therefore needs a real allocation — that is exactly what a ROA policy provides. + +### What happens with no policy at all + +An account created on Wire starts at **zero CPU, zero NET**, and 1,144 bytes of RAM — just enough +for its own account and permission rows: + +```cpp +// contracts/sysio.system/src/sysio.system.cpp — native::newaccount +set_resource_limits( new_account_name, 0, 0, 0 ); +transfer_ram( get_self(), new_account_name, sysiosystem::newaccount_ram ); // 1144 bytes +``` + +For a **user** account, those zeros are never consulted. It signs, the contract pays, the +transaction succeeds. + +For a **contract** account, those zeros are fatal. Calling a contract with no policy fails +outright: + +``` +account payloadless net usage is too high: 132 > 0 +``` + +The transaction throws. Nothing is charged to the caller and nothing is charged elsewhere. An +unprovisioned contract is inert until a node owner issues it a policy — a contract either has +budgeted capacity or it does not run. + +--- + +## Policies + +A policy is a grant of resource weight from a node owner (the **issuer**) to an account (the +**owner**), recorded in `sysio.roa`. + +| Field | Meaning | +|---|---| +| `net_weight` | SYS-denominated weight granted for NET | +| `cpu_weight` | SYS-denominated weight granted for CPU | +| `ram_weight` | SYS-denominated weight granted for RAM | +| `bytes_per_unit` | Bytes per 0.0001 SYS, **frozen at the moment the policy was created** | +| `time_block` | Block height before which the policy cannot be reduced or reclaimed | + +RAM weight converts to bytes at `bytes_per_unit`. At the network's launch price of 104 bytes per +0.0001 SYS, **1 SYS of `ram_weight` ≈ 1.04 MB**. + +CPU and NET weight do not convert to a fixed quantity — they buy a proportional share of network +throughput. See [How weight becomes throughput](#how-weight-becomes-throughput). + +### Who can issue one + +Any registered node owner, of any tier. The check is membership, not rank: + +```cpp +require_auth(issuer); +check(nodeowners.contains(node_key), "Only Node Owners can issue policies for this generation."); +``` + +There is no tier gate on `addpolicy`, `expandpolicy`, `extendpolicy`, or `reducepolicy`. A tier-3 +node owner has exactly the same policy powers as a tier-1 node owner. What differs is budget size. + +### Node owner tiers + +| Tier | Share of `total_sys` per owner | Max owners | Aggregate share when full | +|---|---|---|---| +| 1 | 4% | 21 | 84.0% | +| 2 | 0.15% | 84 | 12.6% | +| 3 | 0.003% | 1,000 | 3.0% | + +Registration consumes part of an owner's own budget — a personal RAM allocation, 10% of the tier +allocation set aside into the network RAM pool, and a small CPU/NET allocation for the owner's own +account. Using the launch configuration of 75,496 SYS `total_sys`: + +| Tier | Total allocation | Free to issue after registration | ≈ RAM if spent entirely on RAM | +|---|---|---|---| +| 1 | 3,019.8400 SYS | ~2,717.75 SYS | ~2.8 GB | +| 2 | 113.2440 SYS | ~101.81 SYS | ~106 MB | +| 3 | 2.2649 SYS | ~1.93 SYS | ~2.0 MB | + +Every `addpolicy` and `expandpolicy` checks `total_new_allocation <= node.total_sys - +node.allocated_sys`. A node owner cannot issue more than they hold. + +### The four policy actions + +**`addpolicy`** — create a policy. Fails if this issuer already has one for this owner (use +`expandpolicy`). Weights must be in the core SYS symbol, non-negative, and at least one non-zero. + +**`expandpolicy`** — add weight to an existing policy. Converts RAM at the *policy's* frozen +`bytes_per_unit`, not the current network price. + +**`extendpolicy`** — push `time_block` further out. It can only move forward, never back, and never +to a block already in the past. A policy's term can be lengthened but not shortened. + +**`reducepolicy`** — take weight back. Only callable once `time_block` has passed. Each weight is +capped at the stored policy weight, so an issuer can never withdraw more than they granted. + +### Stacking policies from multiple issuers + +The policy table is scoped per issuer, so the duplicate check is per issuer only. **An account can +hold policies from many node owners at once, and they sum into a single quota.** + +Two node owners each granting `10.0000 SYS` of CPU and NET to the same account produce a combined +on-chain limit of `200000` weight units. Reduce one issuer's policy fully and the account drops to +`100000` — the other issuer's grant is untouched, and they can still expand it independently: + +``` +addpolicy owner_A -> acct 10.0000 SYS cpu/net +addpolicy owner_B -> acct 10.0000 SYS cpu/net + net = 200000 cpu = 200000 + +reducepolicy owner_A (full) net = 100000 cpu = 100000 # B untouched +expandpolicy owner_B +5 SYS net = 150000 cpu = 150000 +``` + +This is how a contract gets provisioned by more than one sponsor: several node owners co-sponsor +the account, each on their own terms, each able to enter or exit without disturbing the others. + +Four things to know when stacking: + +- **Each policy has its own `time_block`.** The lock is per issuer. An account's total capacity is + only as stable as its shortest-committed policy. +- **Each policy freezes its own byte price.** After a network-wide `setbyteprice`, two policies with + identical `ram_weight` can represent different byte counts. +- **RAM reclaim competes.** On `reducepolicy` the reclaim is `min(unused RAM on the whole account, + requested)`. If the account has consumed its RAM, an exiting issuer gets back less than they put + in. CPU and NET always unwind cleanly. +- **The ceiling is network-wide unallocated SYS,** not a per-account cap. There is no limit on how + many policies one account may hold. + +--- + +## How weight becomes throughput + +CPU and NET use the same proportional-share formula Antelope uses. An account's share of the +network's virtual capacity over the averaging window is: + +``` +max_use_in_window = virtual_capacity_in_window × your_weight / total_weight_across_all_accounts +``` + +The denominator is the sum of every account's positive CPU (or NET) weight — that is, the sum of +all weight handed out by ROA policies. Accounts with unlimited (`-1`) limits contribute zero to it. + +The mechanism is unchanged from Antelope. **Only the source of the numerator changed.** In +Antelope the weight came from tokens staked with `delegatebw`. On Wire it comes from a policy a +node owner issued. Nothing about an account's token balance affects its resource share. + +The practical consequence is the same as on Antelope: throughput is a share of a moving total. If +the network's total allocated weight grows and an account's does not, its slice shrinks. This is +what `setbyteprice` is for, and why each policy records the price it was struck at. + +--- + +## Spam control + +If the contract pays, what stops someone calling it in a loop for free? Two things, at two +different layers. + +### The contract's own share is a hard cap + +CPU and NET are rate limits over a window, and the payer's limit is the one that applies. Spam +aimed at a contract consumes that contract's share and nothing else. Once it is exhausted, further +calls fail with `tx_cpu_usage_exceeded` or `tx_net_usage_exceeded` naming the contract, and keep +failing until the window rolls forward. + +That is the structural answer: the blast radius of spamming a contract is that contract. It cannot +spill onto other contracts, other users, or the rest of the network, because it was never drawing +on a shared pool — it was drawing on one policy's slice of the proportional share. The issuer's +exposure is bounded by what they granted and reclaimable via `reducepolicy` once `time_block` +passes. + +This is objective, part of consensus, and applies on every node with no configuration. + +### Subjective billing meters the signer + +The objective rules never look at the signer. `nodeop` can, and that is where a producer +distinguishes a spammer from a legitimate user of the same contract. + +Before executing, `transaction_context::verify_init_subjective_billing` takes the first +authorizers that are **not** payers and checks each one: + +``` +available = subjective-account-cpu-allowed-us + + the account's own objective CPU limit + − its accumulated subjective bill +``` + +For an ordinary account with no policy the objective limit is `0`, so the budget is exactly +`subjective-account-cpu-allowed-us` — **300,000 µs (300 ms) by default**. When `available` reaches +zero the transaction is dropped before execution, with a message of the form: + +``` +Subjectively terminated trx . Authorized account exceeded +subjective CPU limit 300000us by us with an objective cpu limit of 0us. +``` + +`update_billed_cpu_time` feeds it: when an action's first authorizer differs from its payer, the +CPU is recorded against the signer as well, so the signer accrues a running cost even though the +objective bill went to the contract. + +Two properties keep this landing on abusers rather than on ordinary users: + +- **Successful transactions do not accumulate.** A subjective bill is held as `pending_cpu_us` and + removed when the transaction appears in a block. Traffic that lands costs the signer nothing over + time. +- **Failures accumulate and decay slowly.** A transaction that fails or expires moves its bill into + a decaying accumulator whose window is `subjective-account-decay-time-minutes` — 24 hours by + default. Repeatedly submitting transactions that fail is what burns the budget. + +A blunter limiter runs alongside it: `subjective-account-max-failures` (default `3`) per +`subjective-account-max-failures-window-size` blocks (default `1`). An account over the limit has +its transactions rejected outright until the window resets: + +``` +transaction exceeded failure limit for account until