diff --git a/docs/design/coinage-layer.md b/docs/design/coinage-layer.md new file mode 100644 index 000000000..716c23878 --- /dev/null +++ b/docs/design/coinage-layer.md @@ -0,0 +1,1055 @@ +--- +title: "Coinage Layer" +status: "Authoritative" +--- + +# Coinage Layer — Specification + +## 1. Summary + +The Coinage Layer is the host's self-contained coinage subsystem. It owns every coin and recycler entry the user controls, partitions them across one or more purses, observes chain state reactively, schedules recycling, and runs the cryptographic and operational machinery for transfers, unloads, and offload. It has no knowledge of RFC‑17 product concepts (receivables, cheques, refunds, invoices); those live in the layer above. + +This document is normative for the layer's behavior. Two conformant implementations operating on the same root entropy against the same chain state must produce the same on-chain effects, the same set of local records, and the same observable events. + +Coinage state is **not** a projection of chain state. A durable, crash-safe local log is a first-class part of this specification, not an implementation detail — see §7. + +## 2. Scope + +### 2.1 In scope + +Purses; coins and recycler entries (records, state machines, ages); reactive on-chain observation; selection; recycling (payment-folded plus periodic backstop); free / paid unload tokens with automatic fallback; fee-mode auto-selection; transfer to pre-arranged recipient accounts; portable coin export / import (the seam to the upper layer); external offload to a non-coinage account; rebalance between purses; payment classification for direct transfers; operation lifecycle (durable handles, status streams, cancel-before-submission); the durable operation log and crash recovery of in-flight operations; wallet recovery from root entropy. + +### 2.2 Out of scope + +Receivables; cheques; refunds; invoices; product permissions; consent UI; cheque wire transport; multi-device synchronization; coinage pallet runtime evolution; the product-facing API surface. + +### 2.3 Relationship to the upper layer + +Exactly one upper layer consumes this layer's API. It is trusted (it lives inside the host) and is the only valid caller. The upper layer adds receivables, cheques, refunds, and the RFC‑6 / RFC‑17 product-facing surface, composing them out of the primitives this layer exposes. + +### 2.4 Relationship to other documents + +This document is the single source of truth for the layer. Where it conflicts with another document, this one wins. + +- **RFC‑0017.** Defines the product surface above the seam. Its Appendix A derivation scheme is **superseded** by Appendix B here, for the security reason stated there. Its purse-identifier language is refined by §4.3. +- **RFC‑0022.** Defers coinage by name, twice — in the built-in-features table and again for ring‑VRF keys. This layer fills that declared gap; it does not override RFC‑0022. Appendix B adopts RFC‑0022's keyed-hash hard-derivation fold rather than defining a parallel one. +- **`coinage-management.md` and `coinage-management-contract.md`.** Superseded. They describe the pre-split unified design and contradict this document on the derivation appendix, the main-purse identifier, the purse-delete precondition, and readiness-state naming. They should be deleted or marked superseded. +- **The pallet.** `paritytech/individuality` `pallets/coinage/src/{lib.rs, extension.rs}` and the runtime configuration in `runtimes/next-people-paseo/src/people.rs` are authoritative for chain behaviour. Where this document records a pallet fact, the pallet wins and this document is wrong. + +## 3. Concepts + +### 3.1 Purse + +A purse is a named, firewalled coinage balance with an isolated derivation namespace. Every coin and every recycler entry belongs to exactly one purse. Balance, selection, recycling, and operations are scoped to a single purse unless explicitly cross-purse (rebalance, deletion). + +Exactly one purse with a reserved identifier — the **main purse**, identifier `0` — exists by construction once the layer is initialized. Any number of additional purses may be created. + +### 3.2 Coin + +A coin is a chain-level NFT representing a fixed denomination of dotUSD. It is identified on chain by an sr25519 account derived from the layer's root entropy, the coin's purse, and its derivation index. A coin carries: + +- a denomination `exponent` (denomination = `2^exponent` cents); +- an integer `age` incremented by the chain on every transfer or split, capped at a chain-enforced maximum above which the coin is unusable. + +A coin is consumed by transfer (to a pre-arranged recipient account), by split (into smaller coins), by recycling (into a fresh recycler entry), or by export (the coin and its secret are handed to the upper layer). + +### 3.3 Recycler entry + +A recycler entry is a Bandersnatch keypair the layer placed into a chain recycler ring — a privacy anonymity pool. The layer realizes the entry's value by **unloading** it: a Ring VRF proof of ring membership produces a fresh age-0 coin (or external-asset output) without revealing which entry was unloaded. An entry holds no spendable value on its own; value is realized at unload time. An entry must wait for its ring to fill before its anonymity claim is meaningful. + +### 3.4 Operation + +An operation is a long-running asynchronous task. The operation kinds this layer supports are: `TopUp`, `Transfer`, `Export`, `Import`, `ExternalOffload`, `Rebalance`, `MaintenanceSweep`, `DeletePurse`, `Recover`. Each operation has a durable opaque handle, a persisted record, a status stream emitted at every state transition, and a set of locked coins / recycler entries that no other operation may touch until the owning operation reaches a terminal state. + +One logical operation decomposes into **one or more on-chain transactions**, which may form a dependency chain (§7.5). Partial success is a normal outcome, not an error condition. + +Every call to a long-running primitive starts a fresh operation. The layer does not deduplicate by argument equality; callers needing idempotency MUST track handles themselves. + +### 3.5 Coin export / import (the layer seam) + +The upper layer needs coin secrets to construct cheques but must not have access to the layer's derivation tree. Two primitives bracket this: + +- **Export.** Selects coins in a purse summing to a requested amount, performs any necessary split / unload-into-coins extrinsics, then returns the resulting `(coin_account, coin_secret)` pairs and treats the exported coins as no longer owned by the layer. +- **Import.** Accepts an externally supplied list of `(coin_account, coin_secret)` pairs and routes each one into a purse's namespace by submitting a transfer signed with the supplied secret. + +A `coin_secret` is the raw sr25519 secret-key material controlling the corresponding coin account. Two implementations exchanging exported secrets must agree on the same encoding (the recommended encoding is the raw 64-byte secret-key form). + +These are the only primitives through which coin secrets cross the API. Everything in the upper layer's cheque / receivable machinery composes on top of this seam. + +### 3.6 Amounts and denominations + +Amounts are **whole cents**. The wire representation is `u32`; the largest coin is `2^14` cents, so `u32` covers roughly $42.9M of aggregate balance, which is ample. Implementations MUST accumulate in a wider type (`u64` recommended) so that summing a purse cannot overflow, and MUST narrow to the wire type through a fallible conversion rather than a truncating cast. + +The denomination exponent is **signed** (`i8`), mirroring the pallet's `CoinValue`. Today the chain's `MinimumExponent` is `0`, so every legal denomination is a whole number of cents. A negative exponent has no representation in a cent-granular amount, so an implementation MUST reject a negative exponent on construction and MUST refuse to operate against a runtime whose `MinimumExponent` is negative (§6.7), rather than silently truncating a balance. + +### 3.7 Ring location + +A recycler ring is addressed by a **ring location**: the pair `(ring_index, revision)`. Both halves are load-bearing. The pallet's unload calls take index *and* revision, and a Ring VRF membership proof built against one revision does not verify against another. Wherever this document says an entry "sits in a ring", it means a ring location; grouping entries for a shared unload extrinsic keys on the full location, never on the index alone. + +## 4. Identity + +### 4.1 Per-purse isolation + +Each purse has its own coin-index space and its own recycler-entry-index space. Index `i` in purse A and index `i` in purse B address different on-chain accounts because their derivation paths differ. A coin or entry record carries `(purse_id, index)` as its identity; purse membership is implied by derivation. + +### 4.2 Derivation + +All keys are deterministically derived from the root entropy supplied at initialization. The layer never generates entropy itself. Given identical entropy, two instances derive identical accounts. + +The derivation scheme is Appendix B. Three invariants are normative: + +- Given the same root entropy, the same purse identifier, and the same index, the layer produces the same coin (or recycler-entry) account. +- Two distinct purses have non-overlapping derivation namespaces. +- **Every junction is hard.** No segment of any coinage derivation path may be a soft junction. See Appendix B for why this is a security requirement rather than a preference. + +### 4.3 No-reuse invariant + +Within a purse, a coin derivation index, once allocated, is never reused. The same rule applies to recycler-entry derivation indices. **A purse identifier, once allocated, is never reused either** — including after the purse is deleted. + +These invariants are unconditional: they hold after the coin is spent and the on-chain account is empty, and after the recycler entry is unloaded and removed from the ring. Implementations may realize them by retaining record stubs, by a high-water mark, or by chain scanning — any mechanism that guarantees no identifier is allocated twice. + +Rationale: a coin's account ID may have appeared in a transfer memo passed out-of-band; a recycler entry's Bandersnatch public key sits in a public ring member list; a purse identifier names a whole derivation namespace. Reuse would correlate new activity with old. The purse case is not hypothetical — a shipped implementation allocated `max(existing) + 1`, so deleting the highest-numbered purse and creating a new one silently reinhabited the deleted purse's namespace. + +**How identifiers are assigned is host-local and not normative.** RFC‑0017 describes purse identifiers as "randomly assigned by the user agent"; that delegates the choice to the host rather than requiring randomness. Sequential assignment from a monotonic counter satisfies this specification and is deterministic and testable. + +## 5. State + +### 5.1 Coin lifecycle + +Each coin record carries a lifecycle state: + +- **Pending** — created locally as a future output of an in-flight operation; chain account not yet observed. +- **Available** — chain confirms the account holds a coin with a known age. +- **LockedFor(op)** — held by in-flight operation `op`. +- **Spent** — terminal. Chain confirms the account is empty (or the coin has been exported). Record retained for the no-reuse invariant; subject to garbage collection by any mechanism that still guarantees no reuse. + +Independently of that state, a coin record carries a **chain-side lock expiry**, `chain_locked_until: Option`, observed from `Coinage::LockedCoins` (§5.6). The two are orthogonal: a coin can be locally `Available` and still refused by the runtime. + +A coin is **selectable** iff: + +``` +state = Available ∧ (chain_locked_until is None ∨ now ≥ chain_locked_until) +``` + +Transitions: + +| From | To | When | +|-|-|-| +| (none) | `Pending` | Created locally as an output of an operation | +| `Pending` | `Available` | First chain observation reports the account holds a coin | +| `Available` | `LockedFor(op)` | Operation `op` locks the coin during `Preparing` | +| `LockedFor(op)` | `Available` | `op` aborts or is cancelled before submitting any extrinsic | +| `LockedFor(op)` | `Spent` | `op` reaches definite success (§7.6) and the account is observed empty (or, for export, immediately after the export emits the secret) | +| `LockedFor(op)` | `Available` | `op`'s transaction is definitely resolved as not having consumed the coin — the account is still observed populated. If the resolution was a failed dispatch, `chain_locked_until` is set from the observed `LockedCoins` entry | + +### 5.2 Recycler entry — on-chain readiness and the anonymity floor + +An entry's anonymity at unload time comes from its ring: a Ring VRF proof hides the prover among the ring's members, so the larger the ring, the stronger the anonymity. The chain accepts unloads from rings of any size; this layer applies its own **anonymity floor** — a minimum ring member-count below which it flags the entry as offering reduced anonymity. The floor is a single value scoped to the layer instance; it is not configurable per purse or per operation. The floor is a tunable parameter (Appendix A.2). + +Each entry has an on-chain readiness state derived from chain observation: + +- **Missing** — no recycler location on chain for the entry's member key. The load extrinsic has not finalized, or the entry has been consumed. +- **Waiting** — chain reports a ring location, but the ring is in onboarding or chain-side readiness conditions are unmet. +- **Ready** — ring member-count meets or exceeds the anonymity floor. +- **Degraded(n)** — ring member-count is `n`, below the floor. + +A non-`Missing` entry carries the full ring location (§3.7), not just the index. + +`Ready` and `Degraded` are both usable for selection. The choice of whether to use `Degraded` entries is controlled by the caller per primitive (§8). + +### 5.3 Recycler entry — readiness jitter + +When the layer creates a new recycler entry (top-up or recycling), it records the creation timestamp as `allocated_at` and draws a per-entry random delay `d` uniformly from `[0, D]`. The entry's `ready_at` is `allocated_at + d`; the entry is not selectable until `now ≥ ready_at`, regardless of on-chain readiness. + +Without jitter, an observer with timing data could match a load to its subsequent unload. The bound `D` is tunable (Appendix A.3). The mechanism is SHOULD, not MUST: implementations may set `D = 0` if a specific deployment knowingly accepts the timing correlation. + +### 5.4 Recycler entry — local lifecycle + +Independent of on-chain readiness, each entry has a local lifecycle state: + +- **Available** — free for selection. +- **LockedFor(op)** — held by in-flight operation `op`. +- **Consumed** — terminal. The owning operation reached definite success and the entry was unloaded. Record retained for the no-reuse invariant; subject to garbage collection on the same terms as `Spent` coins. + +An entry is **selectable** iff: + +``` +local_state = Available ∧ on_chain_state ∈ {Ready, Degraded} ∧ ready_at ≤ now + ∧ (alias_locked_until is None ∨ now ≥ alias_locked_until) +``` + +`alias_locked_until` is the chain-side alias lock of §5.6, the entry-side analogue of a coin's `chain_locked_until`, read from `Coinage::RecyclerAliasStates`. That entry distinguishes two states the layer must not conflate: `Locked` is temporary and the entry returns, `Unloaded` is terminal and it never will. + +A caller may further restrict selection to exclude `Degraded` entries via a per-primitive flag (§8). The selectability condition above is the maximum set; flags only narrow it. + +### 5.5 Operation lifecycle + +Every operation traverses: + +| State | Meaning | +|-|-| +| `Preparing` | Selecting, deriving, signing, building extrinsics, or re-planning between phases. No extrinsic currently in flight. | +| `Submitted` | An extrinsic has been broadcast. | +| `InBlock` | An extrinsic has been included in a non-finalized block. **Optimistic, not definite** (§7.6). | +| `Finalized` | An extrinsic has been finalized. | +| `Waiting(until)` | The operation cannot progress until the indicated wall-clock time (e.g. waiting for a recycler entry's `ready_at` or for ring readiness). The layer wakes the operation at or shortly after `until` and returns to `Preparing`. | +| `Recovering` | The layer lost track of a submitted transaction and is resolving its fate against finalized chain state (§7.7). | +| `Done(receipt)` | Terminal. At least one submitted extrinsic definitely succeeded. The receipt (§9) enumerates per-transaction outcomes; partial-failure interpretation is the caller's. | +| `Failed(reason)` | Terminal. Either no extrinsic was submitted (pre-submission failure), every submitted extrinsic was definitely resolved as not having taken effect, or the operation was cancelled. | + +A long-running operation (e.g. `ExternalOffload`) may cycle through phases: `Preparing` → `Submitted` → `InBlock` → `Finalized` → `Preparing` → `Waiting` → `Preparing` → `Submitted` → … and so on until it reaches `Done` or `Failed`. Each phase transition is durably persisted (§7.4); the operation resumes from the same phase across restart. + +Operations that submit no extrinsics (e.g. `Recover`) emit `Preparing` followed directly by a terminal item. + +### 5.6 Chain-side locks after a failed dispatch + +The `AsCoinage` transaction extension builds its origin in `prepare`, which runs **outside** the call's storage layer. A dispatch that then fails does not uniformly undo that work. `post_dispatch_details` compensates asymmetrically, and the asymmetry is load-bearing: + +| Origin | On `ExtrinsicFailed` | +|-|-| +| `AsCoin` | The coin is **restored** to `CoinsByOwner`, and a `LockedCoins` entry refuses it as an origin until `now + 2^retries × CoinFailureLockPeriod` | +| `AsUnloadTokenFromOutput` | The first alias is **restored**, as `AliasState::Locked` with the same exponential backoff | +| `AsUnloadTokenPeople` / `LitePeople` / `Paid` | The unload token is **gone**. Nothing restores it | +| `InfallibleUnpaidSigned` | Cannot occur — the pallet returns `InvalidTransaction::Custom(InternalError)` from `post_dispatch`, so the extrinsic is not included at all | + +Three normative consequences: + +1. **A failed dispatch MUST NOT retire the operation's records.** The coin or alias still exists on chain; marking it `Spent` / `Consumed` would delete a record the chain still honours. +2. **Nor may it release them as immediately reusable.** `LockedCoins` and `RecyclerAliasStates` are checked in the extension's `validate`, so a record reselected inside its lock produces an extrinsic the runtime refuses — *after* a fresh unload token has already been spent building it. The backoff doubles each time, so a naive retry loop converges on burning one token per attempt. The layer MUST observe the lock and MUST exclude the record from selection until it expires. +3. **The unload token is not recoverable.** A retry of a failed unload costs a second token. Retry policy must account for the per-period allowance rather than treating retries as free. + +`Coinage::LockedCoins` is therefore part of the observation set (§6.1), not an optional read. + +## 6. Operational model + +### 6.1 Reactive on-chain observation + +The layer maintains continuous subscriptions to every chain storage entry backing its local records: + +- coin storage for each known coin account; +- **coin lock storage (`Coinage::LockedCoins`) for each known coin account** (§5.6); +- ring-member storage for each recycler entry's member key; +- recycler alias state for each entry the layer has attempted to unload; +- recycler revision and member-count for the rings entries belong to; +- consumed-unload-token storage relevant to the user's allowance. + +Subscription events update local records in place. The layer does not pull-poll; callers read its cached view, which the subscription keeps fresh. + +The layer is therefore long-lived. Subscription updates must be reconciled with operation-driven changes — but **observation alone can never determine local state**, for two independent reasons: + +1. Not all local state has an on-chain representation. A coin handed to a peer over an off-chain channel is "in use" from the layer's point of view from the moment it is sent, while remaining fully vacant on chain until the recipient submits the claiming transaction. +2. An empty coin account is ambiguous. It means "spent" or "the extrinsic has not landed yet", and only the owning operation's durable log can distinguish them. + +Consequently an observation that a tracked coin account is **empty** MUST NOT by itself retire the record. Emptiness is evidence, consumed by the resolution procedure of §7.7, never a transition on its own. + +### 6.2 Balance + +Per purse, the layer exposes three values, emitted by the balance subscription on every change: + +- **Spendable** — sum of values of all selectable coins (§5.1) plus all currently selectable recycler entries (§5.4). +- **Spendable strict** — same, but counting only `Ready` recycler entries. Always `≤ spendable`. The difference is the value held in `Degraded` entries. +- **Pending** — sum of values of everything else the purse still owns: coins in `Pending` or `LockedFor`, coins whose `chain_locked_until` has not elapsed, and recycler entries that are not selectable (`Waiting`, `Missing`, `LockedFor`, alias-locked, or with `ready_at > now`). + +A chain-locked coin is `pending`, not `spendable`: its value is intact and will return without user action, which is exactly the distinction `pending` exists to express. + +### 6.3 Selection + +This section describes the selection used for operations that produce coinage value at a destination *inside* coinage — transfer, export, rebalance. External offload uses a different, planner-driven strategy described in §8.6. + +When the layer must produce a specified `amount` from a purse for one of these operations, it tries the following strategies in priority order, returning the first that succeeds. + +Selection orders coins by `(exponent desc, age desc, derivation_index asc)` and recycler entries by `(exponent desc, ring_index asc, derivation_index asc)` before applying each strategy's heuristic. This ordering is fully deterministic — two conformant implementations with the same purse contents produce the same selection. + +1. **Exact match.** Find a subset of selectable coins (in the order above) summing exactly to `amount`. Zero extrinsics. +2. **Split.** Find the smallest single selectable coin strictly greater than `amount`; split it into `amount` + change denominations using one extrinsic. If no single coin suffices, build a multi-coin cover with whole coins (the deterministic order naturally produces largest-first) and split the last coin that crosses the target; if that is also impossible, fall through. No unload token consumed. +3. **Unload into coins.** Use selectable recycler entries (§5.4), optionally with whole coins for partial coverage, to mint coins of the target denominations. Entries are grouped by `(denomination, ring location)`; each group becomes one atomic `unload-into-coins` extrinsic carrying its own unload token. The output value of each group equals its input value (the group's own change absorbs the remainder). Prefer a single smallest sufficient entry; otherwise take entries in the deterministic order above to cover the deficit. + +**Selection is policy-free.** It consumes no tunable parameters. The anonymity floor is applied when a ring is *observed*, so an entry's readiness state already encodes it by the time selection runs; likewise a chain-side lock has already been folded into selectability. The only limits selection must respect are the chain's (Appendix A.0). + +Selection runs against the live local view. Selection holds locks for the lifetime of the resulting operation; two concurrent selections never disagree about availability. + +**Failure classification is a total three-way split**, ordered by what the caller should do next: + +| Condition | Error | Caller's move | +|-|-|-| +| Not enough value even counting records that are merely waiting | `InsufficientFunds` | Fund the purse | +| Enough value, but some of it is not selectable yet — unready rings, jitter, or an unelapsed chain-side lock | `NoReadyEntries` | Retry later | +| Everything that will ever be selectable already is, and it covers the amount, but no plan can be built | `UnsatisfiableOutputs` | Change the request | + +`UnsatisfiableOutputs` is reachable in ordinary use and is not an internal error. Two causes: coinage divides but never merges, so two 8-cent coins cannot satisfy a single 16-cent output; and per-extrinsic caps mean a named denomination must be minted whole by one unload group, and no group may be large enough. Reporting `InsufficientFunds` here would be actively misleading — the funds are present. + +If the caller has disallowed `Degraded` entries for a particular operation, the effective selectability condition narrows accordingly. If selection would have succeeded with `Degraded` entries but cannot succeed without them, return `NoReadyEntries`. + +### 6.4 Autonomous lifecycle maintenance + +The chain places a hard time limit on **both** states of a logical coin's value: + +- A **coin** ages out at `MaximumAge` transfers/splits and becomes unusable. +- A **recycler entry** dies when its ring is cleaned up after `RecyclerExpirationTime` from the ring's `immutable_since`. Backing value of any entry that has not been unloaded by then is destroyed by the pallet (added to `TotalValueOfDestroyedCoins`). + +The layer MUST run two autonomous sweeps that together form a closed loop: `coin → entry` (coin-age recycling) and `entry → coin` (ring-expiration rescue). A coin that is never spent cycles between forms indefinitely; no value is lost so long as both sweeps run regularly. Skipping either sweep causes silent loss of funds for users who don't actively spend. + +**Coin-age recycling sweep (coin → entry).** A scheduler runs at a tunable interval (Appendix A.4). Per purse, the sweep scans selectable coins whose `age ≥ recycle_at_age` (Appendix A.1), oldest first, and submits one `load_recycler_with_coin` extrinsic per coin. Each definite success consumes the coin (terminal `Spent`) and produces a new `Available` recycler entry whose `ready_at` is set per §5.3. Pre-submission failure releases the lock so a future sweep can retry. + +Payment-folded refresh complements this: selection (§6.3) prefers older coins, and unload-into-coins emits age-0 coins. Active wallets refresh themselves implicitly. + +**Ring-expiration rescue sweep (entry → coin).** A scheduler runs at a tunable interval (Appendix A.12). Per purse, the sweep scans recycler entries whose ring is approaching expiration — i.e. `now ≥ ring.immutable_since + RecyclerExpirationTime − rescue_margin` (Appendix A.13). The sweep groups eligible entries by `(denomination, ring location)` and submits one `unload_recycler_into_coins` extrinsic per group, each carrying its own unload token (§6.5). Each definite success consumes the entry (terminal `Consumed`) and produces a new age-0 `Available` coin in the same purse. + +The ring-expiration sweep is critical: without it, entries created by the coin-age sweep (or by top-up) can expire silently if the host is unused long enough for the ring lifecycle to complete. This is the only way for value to permanently disappear from a wallet whose root entropy and chain identity are otherwise intact. + +**Triggers.** For both sweeps, the periodic schedule is the contractual minimum. Implementations MAY add opportunistic triggers (e.g. on host wake / foreground; on a subscription update that brings a coin past `recycle_at_age` or an entry past the rescue margin). Both sweeps are also invoked synchronously by `run_maintenance_sweep` (§8.7). + +**Scheduling is a host obligation.** The layer has no clock outside a live session, so it cannot guarantee either sweep fires on schedule by itself. A host that embeds this layer MUST provide a background tick. A foreground-only trigger narrows the loss window but does not close it, because the failure mode is precisely "the user did not open the app". + +### 6.5 Unload tokens + +Every unload of a recycler entry consumes exactly one unload token. Two classes exist: + +- **Free** — derived from the user's people / lite-people ring membership; per-period allowance. +- **Paid** — derived from a period-specific paid-token ring that anyone may join by paying a fee (an on-chain extrinsic). + +**The two classes do not count the same way, and this drives everything below.** A free token's signing context is `"pop:polkadot.net/coinftk" ‖ period_le ‖ counter_le`, so one personhood key produces a fresh alias per counter and covers the whole period's allowance. A paid token's context is `"pop:polkadot.net/coinpaidtok" ‖ period_le` — **no counter**. One paid member key therefore yields exactly one alias, and so exactly one token, per period. `N` paid tokens in a period means `N` member keys, `N` join extrinsics and `N` fees. + +The layer consequently keeps a *series* of paid keys per period, derived at `//coinage//paidtkn////` in the ring-VRF tree. The period is part of the path because `Coinage::PaidUnloadTokenMembers` is global and the pallet refuses a member key it has already seen — a paid key is single-use across all time, not merely within its period. + +When the layer needs `N` tokens for a multi-group unload, it resolves them in this order: + +1. For each token slot needed, probe `ConsumedFreeUnloadTokens` (cached from chain) for the current period and any prior period within the lookback grace window (Appendix A.6). Pick the first counter in the search range (Appendix A.5) whose alias is not consumed. +2. If free slots run out, fall back to paid tokens, one slot per remaining group. Slots the wallet already holds are used before slots that need buying. Each remaining slot needs its own join extrinsic, which MUST reach *definite* success before the token it buys can be presented. + +If neither free nor paid tokens can be obtained, the operation fails with `NoUnloadToken`. A plan that is short even one token MUST be refused whole rather than partially committed, since a partial plan spends its free slots and its join fees and then fails anyway. + +**Joining and becoming provable are two steps.** `pay_for_recycler_unload_fee_token_with_{coin,native,external_asset}` registers the key at once, but the members pallet onboards it into an actual ring afterwards, and a ring-VRF proof needs the ring. A slot between the two is paid for and unusable; the correct response is to wait, not to fail and not to pay again. Three facts per slot are therefore distinct: registered, onboarded, and spent. + +Two further consequences of the pallet's shape: + +- **The join call takes no period.** It reads the chain's own clock at dispatch and adds the member to whichever period is current *then*. A join submitted near a period boundary can land in the next period, so membership and ring index MUST be re-read after a join rather than assumed. +- **A join cannot be priced by a client.** The pallet computes the fee as `WeightToFee(coin_lifecycle_weight())`, which is neither a published constant nor exposed by a runtime API. Whether a join is affordable is therefore a judgement the layer makes (for instance by dry-running the join), not a value it can read. + +The paid ring's collection identifier is `"coinage/paidtkn!" ‖ period_le ‖ zeros` — sixteen bytes of prefix, the `!` included, then the period as a little-endian `u32`. Note that the pallet spells the same period **big-endian** in `PaidTokenCollectionsCreated` and `PaidUnloadTokenConsumed`, whose `Identity` hashers need lexicographic order to match numeric order. An implementation that picks one endianness and uses it everywhere reads an absent key as "not a member", which silently disables the paid fallback. + +A token consumed by a transaction whose dispatch then failed is **not** returned (§5.6). Retry accounting MUST treat each attempt as consuming a token. + +The caller does not select the class. Per-token cost is reported in the operation's status stream. + +### 6.6 Fee account and fee mode + +The layer derives a single **fee account** (sr25519) from the root entropy at initialization. This account pays the on-chain fee for every unload operation across every purse — it is not per-purse, not exposed in the API, and not configurable. How the fee account is funded is outside the layer's concern; the user / upper layer is expected to keep it topped up out of band. + +Unloads support two fee modes: + +- **Prepaid** — fee paid in native currency / asset from the fee account, alongside the unload extrinsic. +- **From-output** — fee deducted from the unloaded value. + +The layer picks the mode automatically per unload: prepaid if the fee account holds sufficient external funds at submission time, from-output otherwise. The caller does not specify. + +The two modes are different **origins**, not two ways of paying for one origin. Prepaid presents an unload token (§6.5) and carries `max_fee = 0`. From-output presents no token at all: the extension takes the fee out of the unloaded value, pre-validating the first entry's alias in the token's place, and `max_fee` is the ceiling it may take. An unfunded fee account therefore spends no free allowance — which also means the fee has to be priced before the origin is known, so an implementation prices the prepaid shape's own bytes and re-assembles if the answer was from-output. + +### 6.7 Runtime compatibility check + +Chain-enforced limits (Appendix A.0) are read from runtime metadata once at connection time and validated before the layer accepts any operation. An unsupported runtime — a negative `MinimumExponent`, a maximum exponent above what the amount type can represent, an inverted exponent range, a zero split or consolidation cap — MUST be refused at connection rather than discovered at the first rejected extrinsic. + +Two constants are **not discoverable** from the deployed runtime's metadata and MUST be carried as per-network configuration (Appendix A.0). Where a constant *is* discoverable, a mismatch between the configured and the observed value MUST be treated as a hard failure, not reconciled silently. + +## 7. Operations and durability + +### 7.1 Handles + +Every operation primitive returns an opaque, durable `OperationHandle`. A handle is sufficient to subscribe to the operation's status stream, read its current status, or cancel it (§7.3). Handles are layer-issued; callers do not supply correlation keys. Two operations with disjoint lock sets may run concurrently; lock conflicts are impossible by construction. + +### 7.2 Status streams + +Each operation emits the state machine of §5.5. The first item is the current status at subscription time. The terminal item (`Done` or `Failed`) is emitted exactly once and the stream then closes. Dropping the subscription is always safe; the operation continues regardless of whether anyone is subscribed. + +### 7.3 Cancellation + +A caller may cancel an operation whenever no extrinsic is currently in flight — i.e. while the operation is in `Preparing` or `Waiting`. The layer aborts, releases all locks, and emits `Failed(Cancelled)`. + +While an extrinsic is in flight (`Submitted` / `InBlock` / `Recovering`), the operation cannot be cancelled at the API. The caller must await the transaction's resolution. A multi-phase operation may become cancellable again once it returns to `Preparing` or `Waiting`. + +### 7.4 The durable operation log + +The layer maintains a **write-ahead log**. Each log entry corresponds one-to-one with a single on-chain transaction, not with a logical operation. An operation with three transactions has three entries. + +Each entry records, before the transaction is broadcast: + +| Field | Purpose | +|-|-| +| `operation` | Owning operation handle | +| `sequence` | Position within the operation | +| `depends_on` | Entries whose outputs this entry consumes (§7.5) | +| `inputs` | Coin and recycler-entry records the transaction consumes | +| `outputs` | Coin accounts and recycler-entry member keys the transaction is expected to create | +| `extrinsic_hash` | Hash of the assembled extrinsic | +| `checkpoint_block_number` | Era anchor height | +| `checkpoint_block_hash` | Era anchor hash | +| `mortality` | Era period, in blocks (Appendix A.14) | +| `state` | `Pending`, `Succeeded`, `Rejected`, or `Abandoned` | + +The general per-transaction pattern is: + +1. Mutate local state — mint the expected output records as `Pending`, mark inputs `LockedFor`. +2. Write the log entry. +3. Broadcast and track (§7.6). +4. On definite success, apply the outcome: retire inputs, promote outputs. +5. On definite failure, revert step 1's mutations subject to §5.6. + +**The entry MUST be durable before the broadcast.** The whole design assumes a crash can occur between any two lines of code, including inside step 1. Anything not recoverable from the log plus finalized chain state does not exist. + +**Extrinsics MUST be mortal**, and the era anchor MUST be the block recorded as `checkpoint_block_hash` / `checkpoint_block_number`. Mortality is what makes an unresolved entry eventually decidable. With an immortal extrinsic there is no time after which inclusion is impossible, so the layer could never safely return the entry's inputs to the pool — an immortal transaction can still land after the inputs have been respent. Mortality is therefore a correctness requirement, not a fee optimization. + +### 7.5 Transaction ordering within an operation + +Within one operation, transactions may form a dependency chain: an unload group mints coins that a later transfer then spends. `depends_on` records this. + +Two rules follow. + +**Submission order.** A transaction MUST NOT be broadcast until every entry it depends on has reached definite success (§7.6). Optimistic in-block inclusion of a predecessor is not sufficient, because a reorg that invalidates the predecessor would leave the dependent transaction spending inputs that never existed. + +**Resolution order.** Recovery MUST resolve entries in dependency order. An entry may only be resolved once all entries it depends on are resolved. This is not merely tidy — resolving out of order gives wrong answers: + +> Entry `W1` unloads a recycler entry, minting coin account `A`. Entry `W2` transfers `A` to a recipient. Recovery finds `A` empty. That is consistent with two incompatible histories: `W1` never landed, or `W1` landed and `W2` consumed `A`. `W2`'s "were my inputs consumed?" check cannot be interpreted without `W1`'s verdict. + +When a predecessor resolves to `Rejected` or `Abandoned`, every entry that depends on it resolves to **`Abandoned`**: its inputs never came into existence, so there is nothing to revert on its behalf, and it can never take effect. The operation's original inputs are returned to the pool by the *predecessor's* reversion, exactly once. An `Abandoned` entry whose extrinsic was in fact broadcast is impossible by the submission-order rule above. + +### 7.6 Optimistic tracking and definite outcomes + +The layer tracks two grades of outcome, and MUST NOT confuse them. + +**Optimistic.** A transaction seen in a non-finalized block, with its dispatch outcome read from that block's events. This is the fast path. It drives UI, unlocks dependent *planning*, and moves the status stream to `InBlock`. It is not durable truth: the block may be reverted and the transaction invalidated on the new canonical chain. + +**Definite.** One of: + +- *Definite success* — the transaction's effects are observed at a **finalized** block. +- *Definite failure* — the transaction is provably unable to take effect: it was rejected before broadcast, or the finalized chain height has passed `checkpoint_block_number + mortality` without inclusion. + +Only a definite outcome may retire records, release locks, write a receipt entry, or terminate an operation. + +Best-effort tracking runs in real time and SHOULD continue while the host is backgrounded. Any uncertainty — a socket reconnect, an interrupted status subscription, a status stream ending without a verdict, a reported inclusion that cannot be re-read — moves the entry to recovery (§7.7) rather than being interpreted. The three-way classification a tracker must produce is: + +| Tracker outcome | Meaning | Next | +|-|-|-| +| Definitely not included | Rejected pre-broadcast, or the node definitively refused it (`invalid`, `dropped`) | Resolve as `Rejected`; inputs revert | +| Included, with a dispatch verdict | Reached a block; events read | Definite if that block is finalized, otherwise optimistic and awaiting finalization | +| Unknown | Anything else, including `retracted`, `usurped`, timeouts, and lost subscriptions | Hand to recovery | + +The inclusion arm carries whether the reporting block was finalized, because a +node that reports `finalized` directly has already settled the transaction and +recovery has nothing to add. Only an inclusion at a non-finalized block needs +the slow path. + +Treating "unknown" as either of the other two is the single most dangerous error available to this layer. + +### 7.7 Operation recovery + +Recovery is the slow, guaranteed path. It resolves every `Pending` log entry against **finalized** chain state and needs neither the transaction's hash nor its events, so it works after a crash in which the layer never observed either. + +Recovery runs: + +- on layer start, before any new operation is accepted; +- whenever best-effort tracking reports `Unknown`. + +The procedure, per finalized block, over the pending entries in dependency order (§7.5): + +1. **Are all of this entry's `depends_on` resolved?** If not, skip it this pass. +2. **Is any dependency `Rejected` or `Abandoned`?** Resolve this entry `Abandoned`. No reversion. +3. **Are the entry's `outputs` present at the finalized hash?** If yes → definite success. Write outputs locally, retire inputs, resolve `Succeeded`. +4. **Were the entry's `inputs` consumed on chain?** If yes → the effect happened even though we cannot see our own outputs; a recipient has already claimed them. Retire inputs, resolve `Succeeded`. +5. **Has the finalized height passed `checkpoint_block_number + mortality`?** If yes → the transaction can never be included. Revert inputs to available, resolve `Rejected`. +6. Otherwise keep the entry pending and re-evaluate at the next finalized block. + +When an entry resolves via a **failed dispatch** rather than non-inclusion, §5.6 governs the reversion: inputs return to their pool but carry the chain-side lock the pallet wrote, and any unload token the transaction consumed is gone. + +Recovery is complete for an operation when it has no pending entries; the operation then terminates per §5.5 on the aggregate of its entries' outcomes. + +```mermaid +flowchart TD + Start((Start)) --> Trigger["Layer start, or tracker reported Unknown"] + + Trigger --> Subscribe["Subscribe to finalized heads"] + Subscribe --> Block["New finalized block received"] + + Block --> Pending{"Pending log
entries remain?"} + Pending -- No --> Complete["All entries resolved
Recovery complete"] + Complete --> End((End)) + + Pending -- Yes --> Ready{"Dependencies
all resolved?"} + Ready -- No --> Keep["Keep entry pending"] + + Ready -- Yes --> DepFailed{"Any dependency
Rejected or Abandoned?"} + DepFailed -- Yes --> Abandon["Resolve Abandoned
Inputs never existed
No reversion"] + + DepFailed -- No --> Checks["At the finalized hash, in parallel:
are outputs present?
were inputs consumed?"] + + Checks --> Outputs{"Outputs found?"} + + Outputs -- Yes --> Success["Write outputs locally
Retire inputs
Resolve Succeeded"] + + Outputs -- No --> Consumed{"Inputs consumed
on chain?"} + + Consumed -- Yes --> Claimed["Retire inputs
Resolve Succeeded
Recipient already claimed"] + + Consumed -- No --> Expired{"finalized height >
checkpoint + mortality?"} + + Expired -- Yes --> Revert["Revert inputs, applying
any chain-side lock
Resolve Rejected"] + + Expired -- No --> Keep + + Success --> Remaining{"Pending entries
remain after this pass?"} + Claimed --> Remaining + Revert --> Remaining + Abandon --> Remaining + Keep --> Remaining + + Remaining -- Yes --> Block + Remaining -- No --> Complete +``` + +### 7.8 Restart behaviour and record retention + +**In-flight operations.** On restart the layer: + +1. Reads back every open operation record, every log entry, and every locked record. +2. Re-establishes chain subscriptions for the affected accounts (§6.1). +3. Fails any operation that has no log entries with `Failed(InterruptedPreSubmission)` and releases its locks. Pre-submission scratch state — in-flight selection, partial signing — is not durable, so a restart in `Preparing` is equivalent to a cancel. +4. Runs recovery (§7.7) over all remaining pending entries before accepting new operations. + +**Subscriptions.** All subscription streams (balance, operation status, events) are torn down on restart. Callers MUST re-subscribe after restart; subscriptions are not auto-resumed. + +**Terminal-operation records.** Once an operation reaches a terminal status and the terminal status item has been emitted on its status stream, the layer MAY immediately drop the operation record and its log entries from durable storage. Subsequent re-subscription via the now-stale handle returns `OperationNotFound`. Callers that need to retain the receipt MUST capture it from the terminal status item; the layer does not maintain history. + +### 7.9 Event ordering against persistence + +Events MUST be drained and published **before** the store is persisted. + +A terminal operation drops its record as soon as its status is emitted, so the receipt exists only in the emitted event until a subscriber has it. Persisting first and publishing second loses the receipt and the record together if the process dies in between — the operation would simply never have happened as far as any later reader is concerned. Publishing first is safe in the other direction: a crash leaves the operation still open in the persisted store, and recovery resolves it on the next start. The worst case degrades to a duplicate event, which subscribers can absorb and a lost receipt cannot. + +## 8. Primitives + +All long-running primitives return: + +```text +struct OperationStart { + handle: OperationHandle, + status: Stream, +} +``` + +Errors emitted synchronously describe failure to start an operation. Errors emitted via the status stream (as `Failed(Error)`) describe terminal failure of a started operation. The full error enum is in §10. + +### 8.1 Purse lifecycle + +```text +fn create_purse(name: String) -> Result +fn query_purse(purse: PurseId) -> Result +fn rename_purse(purse: PurseId, name: String) -> Result<(), Error> +fn delete_purse(target: PurseId, drain_into: PurseId) + -> Result +fn rebalance_purse(from: PurseId, to: PurseId, amount: Amount, allow_degraded: bool) + -> Result + +struct PurseInfo { + id: PurseId, + name: String, + spendable: Amount, + spendable_strict: Amount, + pending: Amount, +} +``` + +`create_purse` assigns a fresh never-before-used `PurseId` (§4.3), persists the purse, returns synchronously. No chain interaction. + +`query_purse` returns a synchronous snapshot. + +`rename_purse` updates the purse's name. No chain interaction. + +`delete_purse` drains the target into `drain_into` via on-chain transfer, then closes the purse record. The main purse cannot be deleted. A purse cannot be deleted while it has in-flight operations. (The upper layer additionally forbids deletion while receivables are open; that rule lives above the seam because this layer cannot see receivables.) + +`rebalance_purse` transfers `amount` from one purse to another by selection in the source purse's namespace, with destination coin accounts allocated in the target purse's namespace. `allow_degraded` controls whether `Degraded` recycler entries may be selected. + +Errors: `PurseNotFound`, `CannotDeleteMainPurse`, `PurseHasInFlightOperations`, `InsufficientFunds`, `NoReadyEntries`, `UnsatisfiableOutputs`, `ChainRejected`, `Cancelled`. + +### 8.2 Top-up + +```text +trait FundingOrigin { + fn external_account(&self) -> ExternalAccountId; + fn sign_payload(&self, payload: &[u8]) -> Signature; +} + +fn top_up(into: PurseId, amount: Amount, origin: &dyn FundingOrigin) + -> Result +``` + +Decomposes `amount` into recycler-entry denominations, allocates fresh entry indices in `into`, and submits one external-asset load extrinsic per denomination, signed by `origin`. Each load is an independent log entry with no dependencies, so successful loads do not roll back failed ones; per-entry outcomes are reported in the status stream and the receipt. + +Errors: `PurseNotFound`, `InsufficientExternalFunds`, `ChainRejected`. + +### 8.3 Transfer + +```text +fn transfer( + from: PurseId, + amount: Amount, + recipient_outputs: Vec, + allow_degraded: bool, + memo_callback: Option, +) -> Result + +struct RecipientOutput { + exponent: DenominationExponent, + account: CoinAccountId, +} + +type MemoCallback = fn(memo_entries: Vec); + +struct MemoEntry { + sender_coin_account: CoinAccountId, + recipient_account: CoinAccountId, + derivation_index: CoinIndex, +} +``` + +Transfers `amount` from `from` to the supplied recipient-controlled accounts. The constraint on `recipient_outputs` is: + +``` +Σ 2^output.exponent over recipient_outputs == amount +``` + +Multiple outputs with the same `exponent` are allowed (e.g. two `exponent = 3` outputs to two distinct accounts). + +Selection from `from` uses the three-tier strategy (§6.3) routing the output coins to the supplied accounts. Both `split` and `unload_recycler_into_coins` name a destination account per produced coin, so a transfer mints the payee's coins *directly* into the accounts the payee named: it never needs the two-step "mint to myself, then transfer" that a dependent log entry would describe. A transfer's transactions are therefore independent of one another, one atomic effect each, and a failure of one does not orphan the others. Dependent entries per §7.5 arise where a later transaction really does spend an earlier one's output, as in external offload (§8.6). + +`sender_coin_account` is the on-chain origin the coin came from: the spending coin's account for a coin-origin transfer, or the recycler entry's contextual alias for a coin minted by an unload. Both are 32-byte identifiers the transaction already carries in public. + +If `memo_callback` is supplied, the layer invokes it with one `MemoEntry` per transferred coin once the corresponding transaction reaches optimistic in-block inclusion (§7.6), before finalization. This is deliberate — the recipient should be able to act promptly — but it means a memo may be delivered for a transfer that a subsequent reorg invalidates. The layer does not encode or transmit memos; the caller owns the wire format and is responsible for tolerating that case. + +Errors: `PurseNotFound`, `InsufficientFunds`, `NoReadyEntries`, `UnsatisfiableOutputs`, `OutputsDoNotSumToAmount`, `ChainRejected`, `Cancelled`. + +### 8.4 Export coins + +```text +fn export_coins(from: PurseId, amount: Amount, allow_degraded: bool) + -> Result + +struct ExportStart { + handle: OperationHandle, + status: Stream, + coins: Stream, // emits once per coin, then closes +} + +struct ExportedCoin { + account: CoinAccountId, + secret: CoinSecret, + exponent: DenominationExponent, +} +``` + +Materializes `amount` worth of coins in `from`'s namespace by selection and any required split / unload-into-coins extrinsics, then emits one `ExportedCoin` per resulting coin. Each exported coin transitions to `Spent` in this layer's view: the on-chain account still holds the coin but it is now controlled by the externally held secret. + +A coin is emitted only after the transaction that materialized it has reached **definite success** (§7.6). Emitting on optimistic inclusion would hand out a secret for a coin a reorg could remove. + +`export_coins` is the **only** primitive through which coin secrets cross the API. The caller is responsible for the confidentiality of the emitted secrets. + +Errors: `PurseNotFound`, `InsufficientFunds`, `NoReadyEntries`, `UnsatisfiableOutputs`, `ChainRejected`, `Cancelled`. + +### 8.5 Import coins + +```text +fn import_coins(into: PurseId, coins: Vec<(CoinAccountId, CoinSecret)>) + -> Result +``` + +For each supplied pair, the layer (a) reads the coin's denomination from chain, (b) allocates a fresh coin derivation index in `into`, (c) submits a transfer extrinsic from `account` (signed with the supplied secret) to the freshly derived recipient account in `into`'s namespace. The layer does not retain supplied secrets after submission. New coin records appear in `into` and become `Available` once the chain confirms. + +Per-coin outcomes (`Done` / `BadCoinSecret` / `SnipedCoin` / `ChainRejected`) are reported in the status stream; partial success is possible. A pair whose `account` is already known to this layer is rejected with `BadCoinSecret`. + +Errors: `PurseNotFound`, `BadCoinSecret`, `SnipedCoin`, `ChainRejected`, `Cancelled`. + +### 8.6 External offload + +```text +fn external_offload( + from: PurseId, + amount: Amount, + destination: ExternalAccountId, + allow_degraded: bool = false, +) -> Result +``` + +Moves `amount` from `from` into a non-coinage account on chain. `allow_degraded` defaults to `false`: an external offload reveals the unloaded value to chain observers, so the anonymity set should be at full strength unless the caller explicitly opts in to `Degraded` entries. + +External offload is a **multi-phase, possibly long-running** operation. The layer drives it through the loop below until a terminal state is reached. Each phase transition is durably persisted (§7.4); cancellation is permitted in `Preparing` and `Waiting` (§7.3). + +1. **Plan** (status: `Preparing`). Read the current view of `from`. Choose the next phase: + - If selectable entries (per `allow_degraded`) cover `amount` → **Offboard**. + - Else if selectable + not-yet-ready entries together cover `amount` → **Wait** until the latest such entry's `ready_at`. + - Else compute the deficit. If selectable coins cover the deficit → **Recycle**. + - Else if non-spent coins (including coins locked by this or another operation, recycling, pending-transfer, or chain-locked) together cover the deficit → **Wait** for a short retry interval (Appendix A.11). + - Else fail with `InsufficientFunds`. +2. **Recycle**. Pick the coins to cover the deficit in the deterministic order of §6.3. Submit one `load_recycler_with_coin` extrinsic per coin. Each definite success produces a new `Available` recycler entry locked to this operation. Return to **Plan**. +3. **Wait** (status: `Waiting(until)`). Suspend until the indicated time. On wake (or operation resume after restart), return to **Plan**. +4. **Offboard**. Submit one `unload_recycler_into_external_asset_and_vouchers` extrinsic per `(denomination, ring location)` group, each carrying its own unload token (§6.5). The total transferred to `destination` is `amount`. Any surplus from the selected entries is **always atomically reloaded** into fresh recycler entries within the same extrinsic — surplus value MUST NOT land as a free coin, because that would re-link the entry-side anonymity set to a fresh sr25519 account. Once all groups have definitely succeeded, reach `Done(receipt)`. + +Entries produced in **Recycle** are inputs to **Offboard**, so the corresponding log entries carry a `depends_on` relation and the ordering rules of §7.5 apply. + +The operation locks every coin and recycler entry it touches throughout its lifetime, including entries produced during **Recycle**. Locks are released on terminal status per §7.8. + +Fee mode is auto-selected per §6.6. + +Errors (via terminal `Failed`): `InsufficientFunds`, `NoUnloadToken`, `ChainRejected`, `Cancelled`. +Errors (synchronous): `PurseNotFound`. + +### 8.7 Maintenance sweep + +```text +fn run_maintenance_sweep(purses: Option>) + -> Result +``` + +Runs both the coin-age recycling sweep and the ring-expiration rescue sweep once across the listed purses (or all purses if `None`). For each purse the layer: + +1. Submits one `load_recycler_with_coin` extrinsic per eligible aging coin (oldest first). +2. Submits one `unload_recycler_into_coins` extrinsic per `(denomination, ring location)` group of entries past the rescue margin. + +Per-transaction outcomes are reported via the operation's receipt. The layer also runs both sweeps autonomously per §6.4; this primitive exists so the upper layer can force a run on demand (e.g. on app foreground). + +Errors: `PurseNotFound`. + +### 8.8 Payment classification + +```text +fn classify_incoming_payment(entries: Vec) + -> Result + +enum PaymentClassification { + Matched, // every entry's recipient_account corresponds to a coin in some purse known to this layer + Received, // some entries' coins are present, others are not + Unmatched, // no entries match +} +``` + +Synchronous classification against the live local view. The layer treats an empty entry list as `Unmatched`. The classification is informational only; no operation is started, no record is modified. + +### 8.9 Subscriptions + +```text +fn subscribe_purse_balance(purse: PurseId) -> Stream +fn subscribe_operation_status(handle: OperationHandle) -> Stream +fn subscribe_events() -> Stream + +struct PurseBalance { + spendable: Amount, + spendable_strict: Amount, + pending: Amount, +} +``` + +The two value streams — balance and operation status — emit the current value at subscribe time, then a new item on every change. An event is a change rather than a value, so the event stream has nothing to emit at subscribe time and carries no backlog: it begins with the next event the layer publishes. + +A balance is a projection of every record in a purse, and some of its inputs are time-dependent — an entry inside its jitter delay, a coin the chain locked after a failed dispatch. A balance stream therefore MUST NOT be driven by state changes alone; the layer re-evaluates it against the clock and emits when the value has moved, whether or not any record changed. + +Closing the stream releases the subscription. Multiple concurrent subscriptions are independent, and dropping one never affects the layer's behaviour. + +### 8.10 Wallet recovery from root entropy + +Distinct from operation recovery (§7.7): that resolves in-flight transactions after a crash; this reconstructs an entire wallet from seed when durable state is lost. + +```text +fn recover(non_main_purse_ids: Vec) + -> Result + +fn extend_scan( + purse: PurseId, + from_coin_index: CoinIndex, + from_entry_index: RecyclerEntryIndex, +) -> Result +``` + +Long-running operations of kind `Recover`. Reconstruct records for the listed purses, plus the main purse (always restored). Scan chain storage using a gap-limit strategy (Appendix C). After the operation reaches `Done`, reactive observation continues from the discovered records. + +The operation emits no on-chain extrinsics, so its status stream goes `Preparing` → terminal. Per-record discovery is observable via the event stream (`CoinAvailable`, `EntryAllocated`). + +The layer cannot enumerate non-main purse identifiers from the chain; the caller must supply them from its own backup. + +Wallet recovery cannot restore the operation log. Any transaction in flight at the moment durable state was lost is unrecoverable, and its inputs are resolved by whatever the chain scan finds. + +Errors (via `Failed` status item): `RecoveryFailed`. + +## 9. Receipts + +When an operation terminates, the layer attaches a receipt summarizing the outcome of every transaction the operation logged: + +```text +struct OperationReceipt { + extrinsics: Vec, +} + +struct ExtrinsicRecord { + extrinsic_hash: Option, // None if never broadcast + outcome: ExtrinsicOutcome, +} + +enum ExtrinsicOutcome { + Succeeded { + block_hash: BlockHash, // the finalized block + affected_coins: Vec, // consumed and created together + }, + Rejected { + reason: String, + }, + Abandoned { + reason: String, // a dependency did not succeed + }, +} +``` + +`block_hash` is always a **finalized** block: an outcome is only written once definite (§7.6). + +For a multi-transaction operation the receipt may mix all three outcomes. `Done` means *at least one* transaction definitely succeeded; the caller introspects per-transaction outcomes here. An operation whose every entry is `Rejected` or `Abandoned` terminates `Failed`. + +The receipt is emitted as part of the terminal status item. Per §7.8 the layer may drop the operation record — and the receipt — immediately after emission, and per §7.9 emission precedes persistence. + +## 10. Errors + +```text +enum Error { + // Pre-submission + PurseNotFound(PurseId), + OperationNotFound(OperationHandle), + CannotDeleteMainPurse, + PurseHasInFlightOperations, + OutputsDoNotSumToAmount, + InsufficientFunds { requested: Amount, available: Amount }, + InsufficientExternalFunds, + NoReadyEntries { requested: Amount, available_when_ready: Amount }, + UnsatisfiableOutputs { requested: Amount, available: Amount }, + NoUnloadToken, // neither free nor paid tokens available + BadCoinSecret, + + // Post-submission / chain + SnipedCoin, + ChainRejected { extrinsic_hash: ExtrinsicHash, reason: String }, + + // Lifecycle + Cancelled, + InterruptedPreSubmission, + + // Internal + StorageError(String), + SubscriptionError(String), + RecoveryFailed(String), + Internal(String), +} +``` + +`InsufficientFunds`, `NoReadyEntries` and `UnsatisfiableOutputs` form the total three-way selection-failure split of §6.3 and MUST be distinguishable by the caller. The upper layer needs a mapping for `UnsatisfiableOutputs`; RFC‑0017's `CoinPaymentError` has no suitable variant today, and `BalanceLow` would be wrong. + +## 11. Events + +```text +enum LayerEvent { + Resynced, // post-restart reconciliation complete + + PurseCreated { purse: PurseId, name: String }, + PurseRenamed { purse: PurseId, name: String }, + PurseDeleted { purse: PurseId, drained_into: PurseId, amount: Amount }, + + UnloadTokenSpent { purse: PurseId, paid: bool, fee: FeeMode }, + + CoinAvailable { purse: PurseId, exponent: DenominationExponent }, + CoinSpent { purse: PurseId, exponent: DenominationExponent }, + CoinAged { purse: PurseId, exponent: DenominationExponent, age: u16 }, + CoinChainLocked { purse: PurseId, exponent: DenominationExponent, + until: Timestamp }, + + EntryAllocated { purse: PurseId, exponent: DenominationExponent }, + EntryReadinessChanged { purse: PurseId, exponent: DenominationExponent, + new_state: RecyclerEntryOnChainState }, + EntryConsumed { purse: PurseId, exponent: DenominationExponent }, + + OperationStarted { handle: OperationHandle, kind: OperationKind, purse: PurseId }, + OperationProgress { handle: OperationHandle, status: OperationStatus }, + OperationCompleted { handle: OperationHandle, terminal: TerminalStatus }, + + MaintenanceSweepStarted { purses: Vec }, + MaintenanceSweepCompleted { + coins_recycled: u32, // coin → entry + entries_rescued: u32, // entry → coin + failed: u32, + }, +} +``` + +Records are identified by `(purse, exponent)`, not by derivation index — derivation indices are not part of the API. `Resynced` is emitted exactly once after the layer completes post-restart reconciliation, which includes operation recovery (§7.7); subscribers treat earlier events as reconstruction and later events as live state changes. + +## 12. Trust boundaries + +### 12.1 No raw cryptography across the API + +The layer holds and uses, but never returns to the caller, any signing key derived from root entropy except as the explicit return value of `export_coins`. The API otherwise exposes only structured values: balances, denominations, ages, readiness states, opaque handles, receipts, errors, events. `export_coins` is the single named exception. + +### 12.2 Information surface + +To the caller, the layer exposes per-purse identity, name, and balance triples; per-operation handles, status streams, and receipts; coin and recycler-entry aggregates via balance and events. Records are not individually addressable from the API. + +To the chain, the layer is an ordinary coinage protocol participant. + +### 12.3 Durable-state confidentiality + +The layer's durable store holds operation records, the operation log (with extrinsic hashes, input and output account identifiers, and era anchors), local-only timestamps, derivation-index counters, and the root entropy (or a handle to it). Implementations MUST treat the store as confidential and SHOULD encrypt it at rest. The exact scheme is implementation-defined. + +Note that the operation log is more sensitive than the record store alone: it links inputs to outputs across a transaction, which is exactly the correlation the recycler anonymity set exists to break. It MUST NOT be exported, logged, or included in diagnostics. + +## 13. Bootstrap + +The layer is initialized with root entropy supplied by the caller. The main purse exists by construction once entropy is present. No non-main purses exist on first initialization; the caller is expected to track non-main purse identifiers and supply them to `recover` if local durable state is ever lost. + +Wallet recovery from root entropy alone is mandatory: given entropy and a list of purse identifiers to restore, the layer reconstructs durable records by chain scanning (Appendix C). It loses local-only state the chain cannot witness — per-entry jitter timestamps reset (entries become immediately eligible once chain readiness is satisfied), and the operation log is gone. + +## 14. Open questions + +- **`UserAgentPermission::CoinPayment` does not exist.** There is no protocol-level way to grant or deny coinage access, so consent for purse operations cannot be expressed. This blocks an honest implementation of even `create_purse` at the layer above. +- **Cheque encryption is unspecified.** `CoinPaymentCheque.encrypted_secrets` is declared opaque, but a cheque crosses hosts, so both sides must agree on the KEM, the AEAD, and the coin-secret encoding. It needs one specified scheme and a version byte. Above the seam, but it constrains what `export_coins` must emit. +- **Two chain constants are not discoverable** (Appendix A.0), and they are exactly the two that guard the two fund-loss paths. Asks for the pallet authors: add `#[pallet::constant]` to `MaximumAge`; confirm when the `RecyclerExpirationTime` attribute reaches the deployed runtime. +- **Background scheduling.** §6.4 and §8.7 require the core to be invoked cyclically, which no platform surface provides: there is no timer, scheduler or tick trait. The layer supplies the core-side half — one entry point that runs whatever is due and reports how long the host may wait before calling again, following the rule *core owns what and how, the host owns only when*. Scheduling holds no persisted state, because the sweeps decide what to do from the records themselves rather than from elapsed time, so the entry point is safe to call at any frequency and a restart loses nothing. The mechanism that calls it is tracked as truapi#356; statement-allowance renewal (truapi#308) needs the same mechanism, which is what makes it platform work rather than this layer's. A layer that is never ticked loses value once a recycler ring expires, so this is a correctness dependency, not a freshness one. +- **RFC‑0021 deprecation.** `PaymentTopUpSource::Coins` lets a product handle raw coin secrets in the clear, bypassing the cheque ceremony. It should be removed once cheques land, but it has a live consumer, so the order is: cheques land → that consumer migrates → RFC‑0021 deprecated. +- **Coinage runtime evolution.** Pallet storage / constant / fee changes are not this layer's concern; metadata-aware negotiation is not constrained here. +- **Recovery UX.** Surfacing recovery progress to the user is a layer-above concern. + +--- + +## Appendix A: Parameters + +### A.0 Chain constants (not tunable) + +These are facts about the runtime, not choices of the layer. Exceeding one makes an extrinsic invalid. They are read from metadata at connection time and validated (§6.7). Values shown are `next-people-paseo`. + +| Constant | Value | Discoverable | Notes | +|-|-|-|-| +| `MinimumExponent` | `0` | yes | Type is `i8`; a negative value is unsupported (§3.6) | +| `MaximumExponent` | `14` | yes | Largest coin is 16,384 cents | +| `MaximumAge` | `16` | **no** | Declared without `#[pallet::constant]` | +| `MaxSplitOutputs` | `32` | yes | Outputs per split / unload-into-coins extrinsic | +| `MaxConsolidation` | `64` | yes | Entries consolidated per unload-into-coins extrinsic | +| `RecyclerExpirationTime` | 90 days | **no** | Marked `#[pallet::constant]` in source but absent from the deployed runtime's metadata | +| `UnloadTokenTimePeriodPeopleLitePeople` | 1 day | yes | | +| `MaxFreeUnloadTokensPerTimePeriod` | `1000` | yes | Upper bound on A.5, not its value | +| `UnderlyingAssetUnit` | `10^4` | yes | Base units per cent | +| `CoinFailureLockPeriod` | 60 s | yes | Base of the exponential backoff in §5.6 | + +The two non-discoverable constants MUST be carried as per-network configuration. They are precisely the values guarding the two fund-loss paths — coins aging out, and entries expiring in a ring — so a runtime that changes either without the layer being told causes silent loss. + +### A.1 `recycle_at_age` +**Value:** `MaximumAge − 2`, i.e. `14`. +**Why:** Margin against the chain age cap absorbs one or two retry windows under congestion or downtime. + +### A.2 `minimum_anonymous_ring_size` +**Value:** `10`. +**Why:** Chain enforces no minimum. A conservative floor. + +### A.3 `recycler_entry_jitter_upper_bound` +**Value:** `6 h`, drawn uniformly from `[0, bound]`. +**Why:** Decorrelates load from subsequent unload. + +### A.4 `recycling_sweep_interval` +**Value:** `24 h`. +**Why:** Catches anything past the threshold within a day. + +### A.5 `free_token_counter_search_range` +**Value:** `[0, 10)`. +**Why:** A conservative policy choice, *bounded by* `MaxFreeUnloadTokensPerTimePeriod` (which is `1000`, not `10`). Searching the full chain allowance would cost 1000 storage probes per token slot for no practical benefit. + +### A.6 `period_lookback_grace` +**Value:** `1 h`. +**Why:** Absorbs transactions prepared near a period boundary. + +### A.7 `recovery_batch_size` +**Value:** `500`. +**Why:** Balances per-batch RPC cost against gap-detection responsiveness. + +### A.8 `recovery_gap_limit` +**Value:** `4 consecutive empty batches`. +**Why:** With `batch_size = 500`, tolerates gaps up to 2000 indices. + +### A.9–A.10 — withdrawn + +`max_split_outputs` and `max_recycler_entries_per_group` were listed here as tunables. They are chain constants and now live in A.0 as `MaxSplitOutputs` and `MaxConsolidation`. Note the value correction: `MaxConsolidation` is `64`, not `8`. + +### A.11 `external_offload_retry_interval` +**Value:** `30 s`. +**Why:** Short wake-up used by `external_offload` when the deficit could be covered by coins currently in transient states (locked / recycling / pending-transfer / chain-locked). Long enough to give those transients a chance to settle; short enough to keep the operation responsive. + +### A.12 `ring_expiration_sweep_interval` +**Value:** `24 h`. +**Why:** Periodic schedule for the ring-expiration rescue sweep (§6.4). Same cadence as the coin-age sweep — there is no reason to run them at different frequencies and a single nightly schedule simplifies operations. + +### A.13 `rescue_margin` +**Value:** `25 % of RecyclerExpirationTime`, or at minimum `7 days`, whichever is larger. Resolves to 22.5 days. +**Why:** Slack between the rescue-sweep trigger time and the chain's actual ring expiration. Must be large enough to absorb (a) gaps between sweeps when the host is rarely active, (b) congestion delays for the unload extrinsic, (c) the per-entry jitter and ring-fill time of the rescued coin's eventual re-recycling. Too small → rescue races the chain cleanup. Too large → premature rescue, more unload tokens consumed than necessary. + +### A.14 `extrinsic_mortality` +**Value:** `256` blocks (≈ 25 min at 6 s block time). +**Why:** Every coinage extrinsic is mortal (§7.4), and this period is the sole determinant of how long recovery must keep an unresolved entry's inputs locked before it may declare the transaction dead. Too short and a transient disconnect or a backgrounded app loses transactions that would otherwise have landed. Too long and a user whose transaction vanished waits that long before the value is spendable again. 256 blocks survives a brief backgrounding while bounding the worst-case lockup to well under an hour. + +**Constraints:** the era period MUST be a power of two in `[4, 65536]`, per Substrate's era encoding. The recorded `checkpoint_block_hash` MUST be the era anchor actually used to build the extrinsic; a mismatch makes the expiry test in §7.7 unsound. + +## Appendix B: Derivation scheme + +Hard junctions throughout. The key-type split separates the sr25519 sub-tree used for coin keys from the Bandersnatch sub-tree used for recycler-entry keys, so each sub-tree can be enumerated independently during wallet recovery. + +Paths: + +```text +// Coin at item I in purse P (sr25519): +//coinage//coin//

//// + +// Recycler entry at item I in purse P (Bandersnatch): +//coinage//

//// +``` + +- **Every segment is a hard junction.** This is a security requirement, not a style choice. RFC‑0022's Motivation establishes that sr25519 soft derivation is invertible from the child side: a child secret, the parent public key and the path together recover the parent secret, and salting a segment with a secret component does not restore the firewall. RFC‑0017's entire model is built on transmitting coin secrets — that is what a cheque *is* — so under a soft-junction scheme, cashing one cheque would expose the purse root and with it every other coin in the purse, past and future. This supersedes RFC‑0017 Appendix A, which specified `//coinage/////` with a secret path component and a soft item junction. Nothing implements it, so there is no migration cost. +- `

` is the integer purse identifier. The main purse is `0`. The purse junction is always present. +- `` is `0` for this version. Future versions may partition a purse's index space across pages; the junction is present now so adding pages later does not move existing accounts. +- `` is the item index within `(purse, page)`. + +The recycler-entry tree is folded into RFC‑0022's ring‑VRF key tree using that RFC's keyed-hash hard-derivation primitive — `derive_ringvrf_hard(parent, code) = hash(parent, code)` — rather than a parallel construction. RFC‑0022 shapes that tree as `//{domain}//{index}` with the domain always a product's dotNS identifier, which coinage cannot supply; coinage takes **`coinage` as a reserved domain**, unambiguous because every product domain is a dotNS name, and extends the path with the purse and page structure above. + +This is a clean break from the shipped `//pps//coin//` and `//pps//ring-vrf//` layout: the root segment changes from `pps` to `coinage`, purse and page junctions are added, and existing main-purse coins are not on the new path. Existing testnet coins become unreachable, which is accepted. (RFC‑0022 records the legacy layout as `//pps//coin/{index}` with a single slash, implying a soft junction; both mobile implementations in fact use hard junctions. Worth correcting there.) + +Coin and recycler-entry index counters are maintained independently per purse. Wallet recovery scans the coin sub-tree (sr25519, querying `Coinage::CoinsByOwner`) and the recycler-entry sub-tree (Bandersnatch, querying recycler-location storage) independently, each with its own gap-limit scan (Appendix C). + +## Appendix C: Wallet recovery scan + +Parameters: `batch_size`, `gap_limit` (Appendix A.7, A.8). This is the seed-rescan of §8.10, not the operation recovery of §7.7. + +```text +recover(non_main_purse_ids): + for purse in {MAIN_PURSE} ∪ non_main_purse_ids: + recover_coins(purse) + recover_entries(purse) + +recover_coins(purse): + cursor = 0 + empty_batches = 0 + while empty_batches < gap_limit: + idxs = [cursor, cursor + batch_size) + accts = derive_coin_accounts(purse, idxs) + results = query_coin_storage(accts) // bulk RPC + locks = query_coin_locks(accts) // Coinage::LockedCoins + for (i, r) in zip(idxs, results): + if r is Some((exponent, age)): + persist Coin { purse, derivation_index: i, + exponent, age: Some(age), state: Available, + chain_locked_until: locks[i] } + empty_batches = (empty_batches + 1) if all None else 0 + cursor += batch_size + +recover_entries(purse): + // analogous over recycler-location storage; each found entry + // is persisted with on_chain_state derived from chain reply, + // local_state = Available, allocated_at = now, ready_at = .distantPast, + // and its full ring location (index and revision). +``` + +`extend_scan` runs the same algorithm starting at supplied non-zero cursors, for use when a gap is suspected past the previous stopping point. diff --git a/docs/issue-drafts/coinage-implementation-status.md b/docs/issue-drafts/coinage-implementation-status.md new file mode 100644 index 000000000..cf547a1db --- /dev/null +++ b/docs/issue-drafts/coinage-implementation-status.md @@ -0,0 +1,543 @@ +--- +title: "Coinage base layer — implementation status" +status: "Working notes" +--- + +# Coinage base layer — implementation status + +Working state of the RFC-17 coinage implementation on branch `rfc17-coinage-core`, +written as a handoff. Becomes the PR description; delete afterwards. + +**The specification is `docs/design/coinage-layer.md`.** It is authoritative: +it now carries the durability model, the operation log, transaction ordering, +mortality, and every design correction this work turned up. This document +records only what is and is not implemented against it. + +Companion document: `coinage-rfc-notes.md` retains the RFC‑0017 amendments that +still need a new RFC, plus non-document follow-ups. Its design-doc corrections +have been folded into the specification. + +## 1. Where this stands + +| Layer | Scope | State | +|---|---|---| +| 0 | Formally verified kernel | **Dropped** — no formal methods in the implementation | +| 1 | Base layer: coins, entries, purses, selection, chain | **Complete** — foundations, durability, observation, subscriptions and the §8 primitives | +| 2 | RFC-17 product API (`CoinPayment`) | **Not started** | + +The seam between layers 1 and 2 is `export_coins` / `import_coins` (§3.5), and it +is built: a coin already in the right shape leaves under its own secret with no +extrinsic at all, and one that has to be reshaped is handed over only once the +chain has definitely accepted it. + +Measured against "implement RFC-17", layer 1 is done and layer 2 — which is what +RFC-17 actually specifies — has not been started. What exists is everything it +composes on, and nothing of the product API itself. + +**Nothing here has been run against a chain.** Every path is verified offline +against a metadata fixture and a scripted node; the assumptions only a live +runtime can settle are listed in §4 and are what `examples/coinage_live_validation` +exists to settle. + +## 2. What is built + +~13k lines, 874 tests in `truapi-server` and 1,037 across the workspace. All gates +green: `cargo test`, +`clippy --all-targets --all-features -D warnings`, `+nightly fmt --check`, +`check --target wasm32-unknown-unknown`, and `cargo doc` with zero coinage +warnings. + +### `host_logic/coinage/` — pure domain, no chain, no clock, no persistence + +| Module | Contents | +|---|---| +| `types` | `PurseId`, `Amount` (u64 cents, fallible narrowing to the u32 wire type), `DenominationExponent` (i8, negatives rejected), indices, `RingLocation`, `Timestamp`, handles, `OperationKind` | +| `chain_constants` | `CoinageChainConstants` + `validate()` + `next_people_paseo()` reference | +| `params` | Policy tunables only; chain-enforced caps deliberately live elsewhere | +| `error` | `CoinageError`, `InvalidTransition` | +| `coin` / `entry` | Records and their lifecycles; entries carry orthogonal on-chain readiness × local state | +| `purse` | Purse record, monotonic index allocation, the three-value balance | +| `operation` | Status machine, cancellability, lock sets, receipts, restart disposition | +| `selection` | The three tiers, deterministic ordering, three-way failure classification | +| `store` | `CoinageStore` — the aggregate; `begin_operation` selects and locks atomically | +| `derivation` | Appendix B paths, all hard junctions | +| `unload_token` | Free-slot resolution, per-slot paid fallback, fee mode | +| `event` | `LayerEvent` | + +### `runtime/coinage/` — chain-facing, native-only + +| Module | Contents | +|---|---| +| `call` | Pallet call arguments; enforces the split-output cap and value conservation locally | +| `extension` | All six `AsCoinageInfo` variants, signing contexts, both proof messages | +| `proof` | Ring-VRF alias proofs (alias + proof returned together), free-token personhood proof | +| `storage` | Storage keys (golden-pinned), value decoding, `apply_observations` | +| `extrinsic` | Call assembly, inherited implication, unsigned General v5 assembly | +| `submit` | Dry-run, submit-and-watch, three-valued tracker outcome, optimistic vs finalized verdicts | +| `bootstrap` | `CoinageLayer::initialize`: constants from metadata, runtime check, fee account, store load | +| `persistence` | `CoinageState` round-trip; `publish_and_persist` makes the event-before-write order the only reachable one | +| `observe` | The six storage reads, pinned to a block, assembled into observations | +| `recover` | The finalized-state resolution loop and its store application | +| `subscription` | The three streams of §8.9: events, purse balance, operation status | +| `plan` | Selection plan → ordered transactions, with destinations and output records | +| `offload` (host_logic) | The four-way phase decision an external offload re-runs | +| `execute` | The submission engine: assemble, log, broadcast, grade, terminate; `begin_transfer` | +| `ring` | Members of any collection's ring and the proof domain its size fixes; finding the ring that holds a key | +| `scan` | The gap-limit wallet rescan of Appendix C, reading in bulk | +| `tokens` | Free-slot probing, paid-slot registration / onboarding / consumption, paid period arithmetic, fee-account balance | +| `fee` | Pricing an extrinsic, and the from-output ceiling that covers its own bytes | +| `testing` | `FakeChain`: an offline chain that answers by method, for driving whole operations | + +### `host_logic/coinage/` additions + +| Module | Contents | +|---|---| +| `log` | The write-ahead log: per-transaction entries, checkpoints, dependency ordering, receipt projection | +| `recovery` | The pure resolution decision — outputs present / inputs consumed / expired | +| `memo` | `MemoEntry` and `PaymentClassification` — what a payer tells a payee | + +### Tests and tooling + +- `tests/coinage_lifecycle.rs` — 12 end-to-end scenarios over the public API with + a `ScriptedChain` stand-in. Includes one guarding the rescue-sweep failure mode, + and one checking that the three subscription streams agree. +- `runtime/coinage/testing.rs` — `FakeChain`, an offline node that answers by + method rather than by call order. It remembers what was submitted and serves it + back inside the block it reports, which is what makes it possible to drive a + whole operation — signature, proofs and all — with no chain. Every primitive is + tested through it end to end. +- `examples/coinage_chain_agreement.rs` — read-only checks against a live runtime. + Linted by `--all-targets`, never run by CI. +- `examples/coinage_live_validation.rs` — dry-runs all six `AsCoinage` origins + against a real node and classifies each rejection: parsed-and-refused settles the + encoding, unintelligible does not. Also never run by CI. +- Extrinsic assembly is tested offline against + `tests/fixtures/paseo-next-v2-metadata.scale`, which does contain the Coinage + pallet and the `AsCoinage` extension. + +## 3. What is built, by phase + +Every phase of the plan is complete: A (foundations), B (durability), C +(observation and subscriptions), D (the §8 primitives) and E (the live driver). + +**Done since the last revision of this document:** + +1. **Mortal extrinsics.** `EraAnchor` on `ChainState`, opt-in so allowance + registration keeps its immortal extrinsics. Coinage assembly refuses an + immortal state outright. Era encoding golden-tested against Substrate's + `d5 03`. +2. **Store persistence.** `CoreStorageKey::CoinageState` wired; a corrupt slot + fails rather than resetting index counters. +3. **Bootstrap.** Constants read from metadata and validated at connect, fee + account at `//coinage//fee`, store loaded. +4. **The write-ahead log.** One entry per transaction, with checkpoint, + mortality, inputs, outputs and `depends_on`. +5. **Dependency ordering.** Submission gated on a dependency reaching *definite* + success; resolution in dependency order; failure cascades to `Abandoned`. +6. **Definite vs optimistic.** `TrackerOutcome` is three-valued by construction; + verdicts carry finality; `Recovering` added to the status machine. +7. **Operation recovery.** The finalized-state loop, plus the store transitions + for succeeded / rejected / abandoned. +8. **The alias chain-lock.** `RecyclerAliasStates` read and modelled, completing + §5.6's entry side. +9. **The observation driver.** All six reads, including the `Members` pallet + lookup that resolves an entry's ring index and the dynamically-decoded ring + revision. +10. **The subscription streams.** All three of §8.9, fanned out from one hub the + layer owns. Balances are reprojected from the store rather than carried on an + event, because the clock alone moves them; operation status is read from the + events, because a completed operation's record is already gone by the time + its terminal status is delivered. + +11. **Every §8 primitive.** Purse lifecycle, transfer, the export/import seam, + both sweeps, external offload, payment classification, top-up, and wallet + recovery — each with the submission engine behind it: coin-origin signing, + recycler-ring reads, free unload tokens, fee-mode selection, and the + plan-to-transactions walk. +12. **The live validation driver**, as `examples/coinage_live_validation`. + +**Still to build:** layer 2. `impl CoinPayment for ProductRuntimeHost {}` is still +the empty impl, and it is no longer blocked — D3 built the seam it composes on. + +## 4. Known gaps and hazards + +- **Only one extension variant has ever been accepted by a chain.** + `InfallibleUnpaidSigned` is proven via the CLI host's existing usage and by the + shipped top-up flow; the five unload variants are encoded from the pallet source. + The agreement check confirms all six *exist* at indices 0–5 in the assumed order, + and `examples/coinage_live_validation` will say whether a node parses each one — + neither has been run here, because this work had no chain access. +- **Nothing has been submitted to a chain.** Every path is exercised against + `FakeChain`, which answers what the tests tell it to. That proves the layer is + self-consistent and that its bytes are what this crate believes they are; it + cannot prove the runtime agrees. The two examples are how that gets settled. +- **The paid unload-token ring can be spent but not bought.** The collection + identifier and every other pallet fact are settled (§7.3), so a slot already in + the ring resolves, proves and encodes end to end. Nothing submits a join yet, so + `can_fund_join` is false and a wallet with no joined slot is still told + `NoUnloadToken` — the same outward behaviour as before, for a much smaller + reason. Details in `coinage-rfc-notes.md` §6.4. +- **A coin-origin call is a signed transaction after all.** `AsCoinage::AsCoin` + transmutes a signed origin rather than conjuring one, so `split`, `transfer` and + `load_recycler_with_coin` carry a `VerifyMultiSignature` extra signed by the coin + account's own key — and because that extension precedes `AsCoinage`, the + signature must cover the coinage extra. Signing the metadata-default extra + instead produces bytes a runtime rejects as a bad proof with nothing to say why. +- **A failed dispatch leaves records locked, not spent, and not free.** Both + sides are now modelled — `Coin::locked_until` from `Coinage::LockedCoins` and + `RecyclerEntry::alias_locked_until` from `Coinage::RecyclerAliasStates` — so + selection will not reoffer a record the runtime would refuse. The consumed + unload token is still gone, which retry policy has to budget for. Details in + `coinage-rfc-notes.md` §1.1. +- **`RingStatus` carries `immutable_since`**, which the previous decoder + dropped entirely. It is now decoded, carried through `ObservedEntry`, and + stored as `RecyclerEntry::ring_immutable_since`; `needs_rescue` reads it from + there rather than taking it as a parameter no caller had a source for. + **Residual hazard:** an entry whose ring immutability was never observed has + no deadline, and `needs_rescue` then declines *silently*. That is correct for + a ring still accepting members and indistinguishable from a ring the layer + never read, so the sweep must not be read as evidence that observation ran. The + implementation says so at the API — `begin_maintenance_sweep` returns `None` for + "nothing to do" and its doc names this trap — and a test pins it, including that + advancing the clock by years changes nothing when the deadline is *missing* + rather than distant. +- **Four chain constants are not discoverable.** `MaximumAge` and + `RecyclerExpirationTime` are absent from metadata, so they are carried as + configuration — and they are exactly the two values guarding the two + fund-loss paths. `PaidUnloadTokenTimePeriod` and + `PaidUnloadTokenRingExpirationTime` join them: `#[pallet::constant]` in the + source, absent from the deployment. Those two cannot lose value, but a wrong + paid period buys a token that proves against the wrong collection. A configured + value that a newer runtime contradicts is a hard failure, not a silent override. + Details in `coinage-rfc-notes.md` §6. +- **Balance streams need the driver to tick.** `refresh_subscriptions` is what + turns time into a balance item, and nothing calls it on a schedule yet. Until a + driver does, a purse whose only pending change is a jitter delay elapsing will + not report becoming spendable until the next mutation. Same root cause as the + scheduling gap below. +- **Nothing invokes the core cyclically.** Both sweeps and `refresh_subscriptions` + need to be called on a clock, and no platform surface provides one — there is no + timer, scheduler or tick trait in `truapi-platform`. The layer owns the + core-side half and the mechanism is tracked as truapi#356; statement-allowance + renewal (truapi#308) needs the same mechanism, for unrelated reasons, which is + what makes it platform work rather than this layer's. Load-bearing for + correctness here, not a nice-to-have. +- **Store persistence is one blob** through `CoreStorageKey::CoinageState`. Fine + for a testnet; will need revisiting when a purse holds thousands of records, + since every mutation re-encodes everything. +- **Event ordering.** Events must be drained and published *before* the store is + persisted, because a terminal operation drops its record and the receipt then + exists only in the event. Documented on the store; not enforceable by types. + +## 5. The plan, item by item + +Nineteen steps in dependency order. **All nineteen are done.** One reorder: D2 +moved ahead of D1, because a rebalance is a transfer whose recipient accounts are +ours and a purse drain is a rebalance of everything, so the transfer engine had to +exist first. Each item was one increment: implement, then run the full gate +set (`cargo test --workspace`, `clippy --all-targets --all-features -D +warnings`, `+nightly fmt --check`, `check --target wasm32-unknown-unknown`, +`cargo doc` for coinage warnings) before starting the next. + +| # | Item | State | +|---|---|---| +| A1 | Mortal extrinsics | **done** | +| A2 | Store persistence | **done** | +| A3 | Bootstrap and fee account | **done** | +| B1 | Durable operation log (WAL) | **done** | +| B2 | Transaction dependency ordering | **done** | +| B3 | Definite vs optimistic outcomes | **done** | +| B4 | Operation recovery | **done** | +| B5 | Recycler alias chain-lock | **done** | +| C1 | Observation driver | **done** | +| C2 | Subscription streams — balance, operation status, events (§8.9, §7.2) | **done** | +| D1 | Purse lifecycle primitives — `delete_purse` drain, `rebalance_purse` (§8.1) | **done** — built after D2 | +| D2 | Transfer (§8.3) — establishes the pattern every later primitive copies | **done** | +| D3 | Export / import, the layer seam (§8.4, §8.5) | **done** — layer 2 is unblocked | +| D4 | The two sweeps + `run_maintenance_sweep` (§6.4, §8.7) | **done** | +| D5 | External offload (§8.6) — exercises B2's ordering hardest | **done** | +| D6 | Payment classification (§8.8) — small, synchronous | **done** | +| D7 | Top-up and the faucet path (§8.2) | **done** — truapi#323 read; see below | +| D8 | Wallet recovery from entropy (§8.10, Appendix C) | **done** | +| E1 | Live validation driver, as a `cargo run --example` | **done** — written, never run | + +### Why this order + +**Durability before the primitives.** An earlier revision of this document +advised building a `/coinage` CLI or driver first so submission could be +debugged live. Withdrawn on two counts. A CLI is not the deliverable and would +be product surface nobody asked for. And the risk it was meant to retire — the +five `AsCoinage` variants no chain has accepted — is contained in +`runtime/coinage/extension.rs` and does not propagate into the *shape* of the §8 +API, so being wrong about it costs a local fix rather than rework. + +Durability is the opposite: cross-cutting. Every primitive that submits must be +resumable by construction, so building fourteen against a settlement model that +treated best-block inclusion as final would mean reworking all fourteen. + +**E1 is an example, not a CLI.** It reads the paths only a real chain can +answer for, and its cheapest move is the one it does: dry-run each of the six +`AsCoinage` origins and classify the rejection. A rejection naming the pallet's +own state — `Custom`, `BadProof` — means the runtime parsed our extra and +disagreed about *state*; anything else means it did not understand the *bytes*, +and that is the only answer worth acting on. Mortality expiry and +`post_dispatch` failure-lock behaviour still need a funded wallet and a +transaction that lands, and remain the next thing to run against a testnet. + +### Notes for specific items + +**D4** must not treat "nothing to rescue" as evidence that observation ran; see +the residual hazard in §4. The API says so and a test pins it. + +**D5**'s loop re-reads the chain between phases, and has to: an entry a recycle +phase just created knows nothing about the ring the pallet put it in, and an +entry with no ring cannot be offboarded. A loop re-planning from local state +alone would recycle forever. That read is §8.6 step 1's "read the current view", +taken literally. + +**D7**, the faucet, as built: the layer owns the coinage half only. `top_up` +takes a `FundingOrigin` — the account holding the external asset, which signs the +load — allocates one entry per denomination in the target purse, and submits a +single `load_recycler_with_external_asset_unpaid_batch` bounded by +`MaxBatchUnpaidLoad` (10 on the reference runtime). Moving the asset *to* that +account is the caller's business, which is what keeps the faucet's key material +out of the layer. + +Reading truapi#323 settled three things. The extrinsic is a signed **V4**, not a +General v5, because `InfallibleUnpaidSigned` transmutes a conventional signed +origin. The runtime gates test-asset transfers behind an `AuthorizeValueTransfer` +extension carrying an Ed25519 signature, so `FundingOrigin` has a defaulted +`authorize_value_transfer` hook — absent on a runtime that does not gate, supplied +from `HOST_CLI_VALUE_TRANSFER_AUTH_KEY` (or the iOS-compatible `W3S_AUTH_KEY`) on +one that does. And `proof_of_ownership` signs the origin account's raw 32 bytes, +which confirmed the same reading D4 had already used for +`load_recycler_with_coin`. + +### Decisions made during this work, not to be re-litigated + +- **Mortality is 256 blocks** (Appendix A.14), opt-in on `ChainState` so + statement-allowance keeps its immortal extrinsics. Coinage assembly refuses an + immortal state. +- **Fee account derives at `//coinage//fee`**, outside the purse junction: it + holds no coinage value and belongs to no purse. +- **The WAL records purse-scoped indices, not accounts**, so the durable store + does not spell out the input-to-output linkage the anonymity set exists to + break. +- **`publish_and_persist` is the only way to write the store**, which makes the + events-before-persist order of §7.9 the only reachable one. +- **`needs_rescue` reads its own record** rather than taking the deadline as a + parameter — the parameter form is how the value got lost in the first place. +- **The layer owns one subscription hub**, and `publish_and_persist` is what + feeds it: the publisher receives the drained events *and* the store, because a + balance is a projection of every record in a purse and no event can carry it. +- **Balance streams are recomputed, deduplicated by last value**, so a clock tick + that changes nothing emits nothing. The alternative — deriving balances from + events — cannot work, because a jitter delay elapsing moves a balance with no + record changing. +- **Operation status is taken from the events, not the operation record**, since + §7.8 lets the store drop a terminated operation the moment its status is + emitted; the terminal event is the only place its receipt still exists. +- **A transfer's transactions are independent.** Both `split` and + `unload_recycler_into_coins` name a destination per produced coin, so a payment + mints straight into the payee's accounts and never needs "mint to myself, then + transfer". Dependent log entries remain, because external offload really does + spend what an earlier phase produced. +- **The unload fee chooses the origin, not just an argument.** Prepaid presents a + token and carries `max_fee = 0`; from-output presents no token at all and lets + the extension take the fee out of the value. An unfunded fee account therefore + spends no free allowance — and the fee has to be priced before the origin is + known, so the prepaid shape's own bytes are priced and the extrinsic + re-assembled if the answer was from-output. +- **An export of a coin already in shape submits nothing.** Control moves with the + secret. Only value that has to be reshaped costs an extrinsic, and those coins + are emitted only once the chain has definitely accepted them. +- **A drained purse closes only after the chain agrees**, and a purse still + holding value that cannot move right now is refused rather than closed around + it. Closing drops the records, and a record dropped while its account holds a + coin is value nobody can find again without a seed rescan. +- **`lock_for_operation` is idempotent**, which is what lets a multi-phase offload + name the entries it created in an earlier phase without tracking whether it + already holds them. +- **A recovery scan reads in bulk**, one `state_queryStorageAt` per batch rather + than one per index. The recommended window is 500 × 4, so per-index reads would + be thousands of round trips per purse. + +## 6. Commit history on the branch + +The branch carries the foundations, the durability layer, the observation driver, +the subscription streams, every §8 primitive, the live validation driver and the +paid unload-token ring. Branched from `main` at `079ecd19`. + +Two plan items travel inside another commit because their whole diff lives in +files that commit introduces, and both say so in their message: **B2** (dependency +ordering) is inside "add the durable operation log and its ordering rules", and +**B3** (definite vs optimistic) is inside "submit coinage extrinsics and grade +the outcome". + +**The branch does not bisect.** The tip is verified — 874 tests in +`truapi-server`, clippy `-D warnings`, fmt, wasm32, zero coinage doc warnings — +but the early commits were split out of one finished working tree rather than +replayed, and several do not build standalone: `runtime/coinage.rs` declares every +module and only lands in the observation-driver commit, so earlier commits +reference modules the mod list does not yet expose. Worth a replay rebase before +this leaves draft if `git bisect` should work on the branch; the history reads +correctly either way. + +One unrelated pre-existing failure is visible in the workspace and is **not** +caused by this branch: `truapi-codegen`'s `golden_host_callbacks_ts` fails with +`prettier failed`, identically on a stashed tree. It is a local tooling problem, +not a golden mismatch. + +## 7. What remains, and why it was not done + +Layer 1's specified API is complete, and so is its autonomous behaviour on the core +side: `CoinageLayer::tick` runs what is due and reports when it wants waking again. +What is left is **one dependency and one gap**, plus validation: + +| Open | Kind | +|---|---| +| Something to call `tick` on a clock | Platform dependency — truapi#356, not this layer's to build | +| Reactive observation (§6.1) | Genuine gap, sharing #356's ownership decision | +| A caller for `CoinageLayer::initialize` | Not a gap — that is layer 2's boundary by definition | +| Live validation of five `AsCoinage` variants | Verification, not implementation | + +The paid unload-token ring is complete: a wallet buys, proves and spends a paid +token end to end. + +### 7.1 Reactive observation (§6.1) — a gap in the plan, not in the spec + +§6.1 is explicit: *"the layer maintains continuous subscriptions to every chain +storage entry backing its local records… The layer does not pull-poll."* What +exists is the six **reads** (`observe::refresh_purse`), called only by operations +that need a fresh view mid-flight — the offload phase loop and the recovery scan. +Nothing subscribes and nothing polls, so an idle wallet never notices an incoming +payment, a coin ageing, or a chain lock expiring, and `refresh_subscriptions` has +no caller at all. + +The nineteen-item plan never had an item for this; C1 was scoped to the reads. + +Two things have to be decided before it can be built, and neither is obvious: + +1. **`RpcClient` has no public subscription surface.** Only + `submit_and_watch_inclusion` uses the inner `subscribe_raw`, so + `state_subscribeStorage` has to be added to it. +2. **`CoinageLayer` is `&mut self` throughout.** A long-lived observer needs an + ownership model the crate has not chosen: an actor loop owning the layer, or + `Arc>` driven by the existing `crate::subscription::Spawner`. + An actor avoids a mutex around every operation and is the recommendation, but it + is a real design decision and should be made deliberately. + +Until this lands, a host can drive the layer by calling `refresh_subscriptions` and +`refresh_purse` on a timer — a pull-poll the spec does not want, but honest. + +### 7.1a The tick has no caller — a platform dependency, not a gap here + +`CoinageLayer::tick(storage, chain, now)` is the core-side half of the +invocation-lifecycle contract: it reprojects the balance streams, runs both sweeps if +anything is due, drives that operation to completion, and returns how long the host +may wait before calling again. Following truapi#308's rule — *core owns what and how, +host owns only when*. + +Nothing calls it, because **no platform surface provides a timer**: `truapi-platform` +has traits for storage, navigation, notifications, permissions, chain providers, +confirmation and more, and none of them is about *when*. That mechanism is +truapi#356, and statement-allowance renewal needs the identical thing, which is what +makes it platform work rather than this layer's. + +Scheduling deliberately holds **no persisted state**. The sweeps decide what to do +from the records themselves — a coin's age, an entry's ring deadline — not from how +long it has been since the last run, so `tick` is safe at any frequency and a restart +loses nothing. The returned interval is advice about sufficient frequency, not a +minimum gap. + +### 7.2 Nothing constructs the layer — not a gap + +`CoinageLayer::initialize` has no caller outside the coinage module, and should not: +it *is* the boundary between layer 1 and layer 2, so calling it is layer 2's job by +definition. Listed here only because earlier revisions of this document wrongly +counted it as missing work. + +### 7.3 Paid unload tokens (§6.5) — unblocked; readable and provable, not yet buyable + +**The blocking pallet fact is settled.** The collection identifier is +`b"coinage/paidtkn!" ‖ period_le ‖ zeros`, read out of `pallets/coinage/src` in the +sibling `individuality` checkout along with the proof context, the period +arithmetic, the join calls and the ring exponent. Full table in +`coinage-rfc-notes.md` §6.4. + +Reading it also corrected the design, which mattered more than the identifier did. +Three facts the spec had wrong or missing, now folded into `coinage-layer.md` §6.5: + +1. **One paid key is one token per period.** The paid context carries the period + and no counter, so `N` paid tokens means `N` keys, `N` joins and `N` fees. The + spec described a single join per period. +2. **Joining and becoming provable are two steps.** A registered key is unusable + until the members pallet onboards it into a ring; in between, the slot is paid + for and cannot be proved. Waiting is correct; paying again is refused. +3. **The join takes no period argument** — it uses the chain's clock at dispatch, + so a join near a boundary lands in the next period. Membership and ring index + are therefore re-read after a join, never assumed. + +It also turned up a latent bug and two more configured constants: the old code +measured the paid period with the *free* period length (one day against three), and +`PaidUnloadTokenTimePeriod` / `PaidUnloadTokenRingExpirationTime` are absent from +the deployed runtime's metadata, so the configured-constant list is now four. + +**What is built.** The pure resolution layer plans per-slot paid grants and a list +of joins; the chain layer derives the keys at `//coinage//paidtkn////`, +reads each slot's registration, onboarding and consumption, builds the ring-VRF +proof against whichever ring the chain actually placed the key in, and encodes +`AsUnloadTokenPaid`. A wallet whose slots are already in the ring can now spend a +paid token end to end — which the previous state could not do at all. + +**Buying is built too.** `CoinageLayer::buy_paid_token` submits +`pay_for_recycler_unload_fee_token_with_native` through +`build_account_signed_extrinsic` — a third extrinsic shape, signed V4 with +`AsCoinage(None)`, because the join takes `ensure_signed` and has no coinage origin +to transmute. The fee account signs and pays. `can_fund_join` is answered by +dry-running that exact extrinsic, since the pallet prices the join from a weight and +publishes no constant to compare against. + +Two design points worth keeping: + +- **A join gets no write-ahead log entry**, unlike every other submission. The WAL + exists to reconcile local records after a crash, and a join moves none: it + publishes a key derived deterministically from entropy, and the chain's + `PaidUnloadTokenMembers` is the durable record. After a restart + `read_paid_ring_state` observes exactly what happened, so a log entry would + describe state the log does not own. +- **A bought token may not be immediately usable**, because registration and + onboarding are separate steps and a proof needs the ring. The layer cannot wait — + it has no sleep of its own (truapi#356) — so it reports that state and the caller + retries. Retrying costs nothing extra: the slot is already registered, so + resolution finds it rather than buying a second one. Only definite (finalized) + success counts, because a reorg that removed the join would leave the layer proving + membership of a ring its key is not in. + +### 7.4 Live validation — environmental + +`examples/coinage_live_validation` exists, compiles and lints, and has never been +run: this work had no chain access. Running it against a testnet is what settles +whether a node parses the five `AsCoinage` variants that no runtime has yet +accepted. Mortality expiry and `post_dispatch` failure-lock behaviour need a funded +wallet on top of that. + +### Suggested order + +1. ~~§7.3~~ — **done**, buying included. +2. ~~§7.2~~ — **not a gap**; struck. +3. **§7.4** — the endpoint is reachable and `coinage_chain_agreement` now runs + against it, confirming 25 facts including the paid-token collection identifier + resolving to a real `pallet-members` collection. What remains is + `coinage_live_validation`, which dry-runs all six `AsCoinage` origins and settles + the five no runtime has accepted. Cheapest remaining item, largest risk retired. +4. **§7.1 and §7.1a together** — both hang off one ownership decision (actor loop + owning the layer versus `Arc>`), which is truapi#356's question 8. Deciding + it once unblocks the observer and the thing that calls `tick`. + +A note on process, since an earlier revision of this document got it wrong: §7.4 was +recorded as blocked on "no chain access", which was never tested and turned out to be +false. Before calling anything blocked, read `../individuality`'s pallet source and +try the endpoint — the paid unload-token ring sat blocked for the whole project on a +constant that was in a local file the entire time. + diff --git a/docs/issue-drafts/coinage-rfc-notes.md b/docs/issue-drafts/coinage-rfc-notes.md new file mode 100644 index 000000000..ab15e6346 --- /dev/null +++ b/docs/issue-drafts/coinage-rfc-notes.md @@ -0,0 +1,464 @@ +--- +title: "Coinage RFC — working notes" +status: "Working notes" +--- + +# Coinage RFC — working notes + +Scratch material collected while implementing the coinage base layer in +`truapi-server`. Everything here is either a correction to an existing document +or a decision that needs to be written down somewhere permanent. + +**Much of this has now landed.** `docs/design/coinage-layer.md` is the +authoritative specification and has absorbed every correction that was addressed +to it, plus the pallet facts, the derivation scheme and the RFC‑0022 interaction. +Do not re-apply those; see §3 for the map of what went where. + +What remains here is destined for a **new coinage RFC** (§2, the RFC‑0017 +amendments) or is a **non-document follow-up** (§7). Fold those into their +destinations and delete this file. + +Sources of truth used throughout: `paritytech/individuality` +`pallets/coinage/src/{lib.rs, extension.rs}` and the runtime configuration in +`runtimes/next-people-paseo/src/people.rs`. + +## 1. Pallet facts worth recording + +The runtime configuration nobody had written down, and which several documents +approximate incorrectly. + +| Constant | Value on `next-people-paseo` | +|---|---| +| `MinimumExponent` | `0` (type is `i8`) | +| `MaximumExponent` | `14` — largest coin is 16,384 cents, i.e. $163.84 | +| `MaximumAge` | `16` | +| `MaxSplitOutputs` | `32` | +| `MaxConsolidation` | `64` | +| `RecyclerExpirationTime` | 90 days | +| `UnloadTokenTimePeriodPeopleLitePeople` | 1 day | +| `MaxFreeUnloadTokensPerTimePeriod` | `1000` | +| `MaxBatchUnpaidLoad` | `10` — entries one unpaid external-asset load may create | +| `UnderlyingAssetUnit` | `10^4` base units per cent | +| `CoinFailureLockPeriod` | 60 seconds (base; the applied lock doubles per retry) | + +Other pallet details the implementation depends on: + +- `pub type CoinValue = i8` — the denomination exponent is **signed**. Today + `MinimumExponent` is 0, but the type permits sub-cent denominations. +- `Coin { value: CoinValue, age: u16 }`. +- `RingIndex = u32`, `RevisionIndex = u32`, `Alias = [u8; 32]`. +- Free-token personhood proof context: `pop:polkadot.net/coinftk` followed by + the period and counter as little-endian `u32`s. Proven message is + `blake2_256(alias_proofs.encode() ++ inherited_implication)`. +- Recycler contextual-alias context: `pop:polkadot.network/coinrecyclr`. + Note the two contexts use different domains (`.net` and `.network`); this is + the pallet's own inconsistency, not a transcription error. +- Individual alias proofs sign `blake2_256(inherited_implication)`. +- Recycler collection id: 32 bytes of `b"coinage/recycler"` with the exponent + byte at index 16. +- Coinage lives on the People chain. +- `AsCoinage(Option)` with variants `AsCoin`, + `AsUnloadTokenPeople`, `AsUnloadTokenLitePeople`, `AsUnloadTokenPaid`, + `AsUnloadTokenFromOutput`, `InfallibleUnpaidSigned`. The extension consumes + the coin or the token in `prepare`, **before** dispatch. What a failed + dispatch then costs is not uniform — see §1.1. + +### 1.1 A failed dispatch does not cost the same thing in every flow + +`AsCoinage::post_dispatch_details` partially undoes what `prepare` did, and the +asymmetry between the cases is load-bearing for a wallet: + +| Origin | On `ExtrinsicFailed` | +|---|---| +| `AsCoin` | The coin is **restored** to `CoinsByOwner`, and a `LockedCoins` entry refuses it as an origin until `now + 2^retries × CoinFailureLockPeriod` | +| `AsUnloadTokenFromOutput` | The first alias is **restored**, as `AliasState::Locked` with the same exponential backoff | +| `AsUnloadTokenPeople` / `LitePeople` / `Paid` | The token is **gone**. Nothing restores it | +| `InfallibleUnpaidSigned` | Cannot happen — the pallet returns `InvalidTransaction::Custom(InternalError)` from `post_dispatch`, so the extrinsic is not included at all | + +Three consequences for the layer: + +1. **Nothing must be retired on a failed dispatch.** The records the operation + held still exist; treating the failure as "the coin was spent" would delete + a record the chain still honours. +2. **Nothing must be released as immediately reusable either.** `LockedCoins` + and `RecyclerAliasStates` are checked in `validate`, so a coin reselected + inside its lock produces an extrinsic that is refused — after a fresh unload + token has already been spent building it. Every retry doubles the wait, so a + naive retry loop converges on burning a token per attempt. +3. **`LockedCoins` is a read the layer has to make**, alongside `CoinsByOwner`, + and the coin record needs a chain-side lock expiry orthogonal to its local + lifecycle state. `CoinFailureLockPeriod` is `#[pallet::constant]` and does + come back from metadata, unlike the two constants in §6. + +## 2. RFC-0017 amendments + +### 2.1 Appendix A's derivation scheme is unsafe and must be superseded + +RFC-0017 Appendix A specifies +`//coinage/////` — a secret component inside a +path segment and a **soft** junction at the item. + +RFC-0022's Motivation establishes that sr25519 soft derivation is invertible +from the child side: a child secret key, the parent public key and the path +together recover the parent secret, and salting a segment with a secret +component does not restore the firewall. + +RFC-0017's entire model is built on transmitting coin secrets — that is what a +cheque is. So under Appendix A, cashing a single cheque would expose the purse +root and with it every other coin in the purse, past and future. + +Nothing implements Appendix A, so there is no migration cost. The new RFC should +supersede it explicitly with all-hard junctions and state the reason inline, or +somebody will reintroduce soft derivation later to regain enumerable public keys. + +### 2.2 Missing permission variant + +RFC-0017 requires a `UserAgentPermission::CoinPayment`. `v01::permissions.rs` +has only `HostDevicePermissionRequest` and `RemotePermission`. There is +currently **no protocol-level way to grant or deny CoinPayment access**, so +consent for purse operations cannot be expressed at all. This blocks an honest +implementation of even `create_purse`. + +### 2.3 Balance units disagree + +`v01::coin_payment::CoinPaymentBalance` is `u32` cents. `v01::payment::Balance` +is `u128`. They meet at the purse selectors RFC-0017 added to the RFC-0006 +calls, and nothing reconciles them. + +**Decision: `u32` cents.** The largest coin is 2^14 cents, so `u32` covers +roughly $42.9M — ample. The implementation works in `u64` cents internally so +sums cannot overflow, and narrows to the wire type through a fallible +conversion. + +### 2.4 Cheque encryption is unspecified + +`CoinPaymentCheque.encrypted_secrets` is declared opaque. But a cheque crosses +from the *payer's* host to the *receiver's* host, so both sides must agree on +the KEM, the AEAD and the coin-secret encoding. As written, cross-vendor payment +is impossible. + +Two decisions to record: + +1. **Specify one scheme.** The receivable is already a 32-byte public key + produced by the receiver's coinage subsystem, so the natural construction is + a sealed box — ephemeral key, HKDF, AEAD over the SCALE-encoded secrets. The + core already has an encrypted-channel construction in its SSO session-message + code; reuse it rather than inventing one. The receivable's key type still + needs pinning; RFC-0022 touches P-256 ECDH keys but does not settle this use. +2. **Version the blob.** Even with a single implementation, a cheque crosses + between hosts on different app versions. The blob needs a version byte and a + stated rule for what a receiver does with a version it does not know. Cheap + now, painful to retrofit. + +### 2.5 Purse identifiers + +RFC-0017 says purse ids are "randomly assigned by the user agent". The +implementation assigns them sequentially from a monotonic counter. + +**Decision: assignment is host-local; non-reuse is normative.** A purse id names +a derivation namespace, so reusing one lets a new purse inherit the on-chain +history of a closed one. That property is the security-relevant half and should +be stated as a requirement. The assignment method is not, and sequential +assignment is deterministic and testable. + +This matters because the earlier iOS implementation +(`polkadot-app-ios-v2#872`) allocated `max(existing) + 1`, so deleting the +highest purse and creating a new one **reused its derivation namespace**. Worth +naming in the RFC as the failure the requirement prevents. + +### 2.6 Smaller RFC-0017 items + +- `MAIN_PURSE` is `u32::MAX` in RFC-0017 and "e.g. `0`" in the design doc. The + implementation uses `0`. Pick one and fix the other document. +- `refund` takes only `receivable` in RFC-0017; the design's contract doc adds + `amount: Amount?`. +- The purse-delete precondition differs between the two design docs: "no open + receivables" versus "no in-flight operations". The base layer cannot see + receivables, so the latter is the base-layer rule and the former belongs above + the seam. + +### 2.7 RFC-0021 deprecation, sequenced + +RFC-0021's `PaymentTopUpSource::Coins` lets a product handle raw coin secret +keys in the clear, explicitly bypassing the cheque ceremony. It exists because +the two large RFC-17 PRs were too big to review before a demo, and it should go +once RFC-17 lands. + +It cannot go earlier: the T3rminal/W3S flow is its live consumer, and that is +what `W3S_AUTH_KEY` / `/top-up` exercises in the CLI host. Order is: cheques +land → W3S moves to cheques → RFC-0021 deprecated. Write the dependency into the +new RFC so the stopgap does not become permanent. + +## 3. Corrections to `docs/design/coinage-layer.md` — all applied + +Every item in this section has been folded into the specification. Kept as a map +so a future reader can see what changed and why, without re-applying it. + +| Correction | Where it landed | +|---|---| +| A.10 `max_recycler_entries_per_group` is `64`, not `8`, and is a chain constant | Appendix A.0 (`MaxConsolidation`), A.9–A.10 withdrawn | +| A.5's rationale was wrong — the chain allows `1000`, so `[0, 10)` is a policy choice bounded by the constant | Appendix A.5, A.0 | +| A.9 `max_split_outputs` belongs with the chain constants | Appendix A.0 (`MaxSplitOutputs`) | +| Chain-enforced caps and policy tunables are different kinds of fact and must be separated | Appendix A.0 vs A.1–A.14; §6.7 validates the former at connection | +| Denomination exponents are signed (`i8`); reject negatives, refuse a negative-`MinimumExponent` runtime | §3.6, §6.7, Appendix A.0 | +| Entries need a ring *revision*, not just an index; grouping keys on both | §3.7 (ring location), §5.2, §6.3, §6.4, §8.6 | +| Selection is policy-free — readiness already encodes the anonymity floor | §6.3 | +| A third selection error is needed: value present, shape impossible | §6.3 three-way table, §10 `UnsatisfiableOutputs` | +| Events must be drained and published before the store is persisted | §7.9 | +| `coinage-management.md` / `coinage-management-contract.md` are superseded | §2.4 | + +Also folded in from elsewhere in this file: the pallet constants and their +discoverability (§1, §6 → Appendix A.0), the failed-dispatch asymmetry +(§1.1 → §5.6), the adopted derivation scheme and its hard-junction requirement +(§5, §2.1 → Appendix B), the RFC‑0022 interaction (§4 → Appendix B), the +main-purse identifier and purse-id non-reuse rule (§2.5, §2.6 → §3.1, §4.3), and +the balance-unit decision (§2.3 → §3.6). + +Two items from §2 are recorded in the spec only as open questions, because they +belong above the seam: the missing `UserAgentPermission::CoinPayment` (§2.2) and +the unspecified cheque encryption (§2.4). They still need the RFC. + +## 4. RFC-0022 interaction + +RFC-0022 **defers coinage by name, twice** — in the built-in-features table +("Not coercible to a product | Coinage | — | Deferred to a separate RFC") and +again for ring-VRF keys ("Coinage's ring-VRF keys (recyclers/vouchers) are +deferred to the coinage RFC"). The new coinage RFC is that deferred RFC, which +is the cleanest possible position: it fills a declared gap rather than +overriding anything. + +Three points to carry: + +1. **Reuse RFC-0022's keyed-hash HDKD for entry keys.** Its + `derive_ringvrf_hard(parent, code) = hash(parent, code)` fold, hard-only, is + the right primitive. Adopt the function rather than defining a parallel one. +2. **Root coinage under a reserved domain.** RFC-0022 shapes that tree as + `//{domain}//{index}` with the domain always a product's dotNS identifier, + which coinage cannot supply. Coinage takes `coinage` as a reserved domain — + unambiguous because every product domain is a dotNS name — and extends the + path with the purse and index structure it needs. +3. **Correct RFC-0022's notation.** It records coinage's current layout as + `//pps//coin/{index}` and `//pps//ring-vrf/{index}`, with a *single* slash + before the index implying a soft junction. Both mobile implementations use + `//pps//coin//` — hard. Probably loose notation in a table cell, but given + §2.1 it is worth being precise about. + +Also note RFC-0022 set the precedent for the breaking-change posture: "There are +no production deployments … the selector change is wire-breaking … and is made +freely, with no migration path." + +## 5. The adopted derivation scheme + +Appendix B of `coinage-layer.md`, unchanged, and now implemented: + +```text +coins: //coinage//coin////// sr25519 +recycler entries: //coinage////// bandersnatch, + folded into + RFC-0022's tree +``` + +- **Every junction is hard**, for the reason in §2.1. This is a security + requirement and the RFC should say so inline. +- **The key-type split** lets recovery enumerate each subtree independently — + coins against `CoinsByOwner`, entries against recycler-location storage — + without probing indices that could only belong to the other. +- **`` is always 0** in this version. The junction is present anyway, so + adding pages later does not move existing accounts. +- This is a clean break from the shipped `//pps//…` layout. Existing testnet + coins become unreachable, which is accepted. + +## 6. Four pallet constants are not discoverable + +Two were found first and are the dangerous pair; §6.4 adds two more from the +paid-token work. All four are absent from `paseo-people-next`'s metadata and are +carried as per-network configuration, refused if a newer runtime disagrees. + +Verified against `paseo-people-next` by +`rust/crates/truapi-server/examples/coinage_chain_agreement.rs`. Eight of the +ten values the layer needs come back from metadata and match; **two do not +appear at all**: + +| Constant | Why absent | Consequence | +|---|---|---| +| `MaximumAge` | Declared in the pallet's `Config` **without** `#[pallet::constant]` | Drives `recycle_at_age = MaximumAge − 2`. A runtime that lowers it goes unnoticed, and the layer would then recycle later than the chain allows — coins age out unusable | +| `RecyclerExpirationTime` | Marked `#[pallet::constant]` in the pallet source but absent from the deployed runtime's metadata, so the deployed runtime predates that attribute | Drives the rescue margin. A runtime that shortens it without our noticing makes the ring-expiration sweep fire too late | + +Both must be carried as per-network configuration. The uncomfortable part is +that **these are exactly the two values that guard the two fund-loss paths** — +coins aging out, and entries expiring in a ring. Everything else the layer can +verify against the chain at connection time; these two it must be told. + +`PaidUnloadTokenTimePeriod` and `PaidUnloadTokenRingExpirationTime` are the other +two; see §6.4. Both are `#[pallet::constant]` in the source and absent from the +deployment, so they share `RecyclerExpirationTime`'s diagnosis. + +Two asks worth raising with the pallet authors: + +1. Add `#[pallet::constant]` to `MaximumAge`. +2. Confirm whether the `RecyclerExpirationTime`, + `PaidUnloadTokenTimePeriod` and `PaidUnloadTokenRingExpirationTime` attributes + are newer than the deployed runtime, and if so when they land. Three constants + with the same story suggests one runtime upgrade settles all of them. + +Until then the layer should treat a mismatch between configured and observed +values as a hard failure wherever it *can* observe, and the RFC should state +that these two are configuration rather than implying every constant is +discoverable. + +### Confirmed against the live runtime + +Worth recording, since it validates several assumptions the implementation was +built on: + +- Coinage is pallet index **68**; `split` 0, `transfer` 1, + `load_recycler_with_coin` 2, `unload_recycler_into_coins` 13 — all resolvable + by name. +- All six `AsCoinageInfo` variants exist at indices **0–5** in declaration + order. `InfallibleUnpaidSigned` is 5, which matches the byte layout the CLI + host already submits successfully, independently confirming the ordering the + encoder assumes. +- `MaxConsolidation` is **64**, confirming Appendix A.10's 8 is wrong. +- `MaxFreeUnloadTokensPerTimePeriod` is **1000**, confirming A.5's rationale is + wrong. +- `CoinFailureLockPeriod` is **60 seconds** and *is* discoverable, so the + failure-lock backoff in §1.1 needs no configuration. + +## 7. Non-document follow-ups + +These are not RFC content but were found alongside it and should not be lost. + +### 6.1 iOS silent loss of funds — unfiled, security-grade + +`CoinageRecyclingService` on `polkadot-app-ios-v2` recycles coins **into** +recycler entries but never unloads entries **out**. If a user tops up and does +not open the app before the ring is cleaned up (`immutable_since + +RecyclerExpirationTime`, i.e. 90 days), the entry's backing value is destroyed by +the pallet. + +This is the only way for value to disappear from a wallet whose root entropy and +chain identity are otherwise intact. The Quint model in PR #122 finds traces +matching it. It has been sitting in the work notes since May 2026 and is still +unfiled; it affects shipping `develop`. + +Note the fix is not purely core-side. Three conditions must hold: the core +implements the entry→coin rescue sweep, **the host schedules it in the +background**, and the app routes its coinage through the core. The middle one is +the general scheduling gap — the core has no clock outside a live session — and +the failure mode is precisely "the user did not open the app", so a +foreground-only sweep narrows the window without closing it. + +### 6.2 Balance drift after restore + +pgherveou reports that the displayed cash value is sometimes wrong, and that +restoring from backup yields a different amount. Restore rescans derivation +indices against chain state and reconstructs the true set, so this is consistent +with local records having drifted — either value destroyed by ring expiry (§6.1) +or entries consumed on chain but never reconciled locally. Worth capturing as a +test case for the core implementation rather than chasing in the app. + +### 6.3 The apps still have to retire their own coinage engines + +Neither mobile integration PR migrates coinage: they delete the host-API +dispatch layer, and `feature/coinage` / `Packages/Coinage` survive untouched. So +core-side coinage will be available to *products* while the apps' own wallet UI +still runs the native engine — two engines, two coin stores, cleanly disjoint +only because the derivation break makes them so. Retiring the native engines is +a third project after RFC-17-in-core and after the integrations, and it is real +UI work in both apps. + +### 6.4 The paid unload-token ring — resolved from the pallet source + +**Closed.** Read out of `pallets/coinage/src/{lib.rs, paid_tkn_manager.rs}` in the +sibling `individuality` checkout. Every fact the fallback needed: + +| Fact | Value | +|---|---| +| Collection identifier | `b"coinage/paidtkn!"` (16 bytes, `!` included) ‖ period as LE `u32` ‖ zeros | +| Proof context | `b"pop:polkadot.net/coinpaidtok"` (28 bytes) ‖ period as LE `u32` — **no counter** | +| Signed message | `blake2_256(alias_proofs.encode() ‖ inherited_implication)` — identical to the free token's | +| Period | `unix_secs / PaidUnloadTokenTimePeriod`, a **different constant** from the free period: 3 days against 1 | +| Ring expiry | `(period + 1) * period_length + PaidUnloadTokenRingExpirationTime`; 4 days on the reference runtime | +| Join calls | `pay_for_recycler_unload_fee_token_with_coin` (6), `_with_native` (7), `_with_external_asset` (8) | +| Join arguments | `member_key` plus `proof_of_ownership`, the latter signing the origin account's encoded bytes — the same rule as §6.6 | +| Onboarding size | 1, so a joined key reaches a ring quickly — but *which* ring is the chain's choice | +| Ring exponent | `PaidUnloadTokenRingExponent`, `R2e10`; this one *is* in metadata | + +Three things about it were not anticipated by the design doc, and all three are now +folded into `coinage-layer.md` §6.5: + +1. **One paid key is one token per period,** because the context carries no + counter. `N` paid tokens means `N` keys, `N` joins and `N` fees. The design doc + described a single join per period, which is wrong. +2. **Joining and becoming provable are two steps.** The key is registered + immediately; the members pallet onboards it into a ring afterwards, and the + proof needs the ring. In between, the slot is paid for and unusable. +3. **The join call takes no period.** It uses the chain's clock at dispatch, so a + join near a boundary lands in the next period. + +Two smaller traps worth recording: + +- The pallet spells the period **little-endian** in the collection identifier and + **big-endian** in `PaidTokenCollectionsCreated` / `PaidUnloadTokenConsumed`, + whose `Identity` hashers need lexicographic order to match numeric order. Both + are pinned by golden tests. +- `PaidUnloadTokenTimePeriod` and `PaidUnloadTokenRingExpirationTime` are marked + `#[pallet::constant]` in the source but are **absent from the deployed runtime's + metadata** — the same situation as `RecyclerExpirationTime` (§6). So the + configured-constant list grows from two to four. These two cannot lose value, + but a wrong paid period spends a join fee on a token that proves against a + collection nobody is verifying against. + +A third drift worth flagging to the pallet authors: the deployed runtime names the +third join's event `PaidUnloadTokenRegisteredWithStable`, where the source says +`...WithExternalAsset`. The source is ahead of the deployment here too. + +Note that from-output fees blunt the whole question in practice: an unfunded fee +account spends no free slot at all (§6.6), so the allowance is only consumed by +wallets that *can* pay prepaid. + +### 6.5 Which side's index `MemoEntry::derivation_index` carries is unsettled + +§8.3's `MemoEntry` has `sender_coin_account`, `recipient_account` and +`derivation_index`, and nothing says whose index the third field is. Two readings: + +- **The payer's** index for the origin coin. Derivable by the layer, useless to + the payee, and mildly leaky — though the entry already names the payer's coin + account, which is strictly more revealing and is public on chain anyway. +- **The payee's** index for `recipient_account`, echoed back so the payee can + locate the coin without a scan. Far more useful, and consistent with RFC‑0017's + flow where the payee generates the receivable — but the payer only knows it if + the caller supplies it, so it would have to become an input to `transfer`. + +The implementation carries the payer's index and documents the choice. The new +RFC should settle it; the second reading is the better API and costs one field on +the transfer request. + +### 6.6 `proof_of_ownership` signs the origin account, raw + +Settled. Both `load_recycler_with_coin` and +`load_recycler_with_external_asset_unpaid_batch` carry a 64-byte +`proof_of_ownership` beside the member key they publish, and unlike every other +proof in this pallet it is checked by the *call* rather than by the extension — +so its message cannot be the inherited implication, which a dispatch cannot see. + +The message is the **origin account's 32 bytes, raw and unhashed**, signed by the +entry's own Bandersnatch secret. Confirmed against the shipped top-up flow in +truapi#323, which signs the temporary external-asset holder's account id exactly +this way; the coin-origin case signs the recycling coin's account by the same +rule. What the proof establishes is that whoever controls the value being +converted also controls the key being published. + +### 6.7 A recovery scan is expensive before it is anything else + +Appendix A.7 and A.8 recommend a batch of 500 and a gap limit of 4, which means a +scan of an *empty* purse still derives 2,000 coin accounts and 2,000 recycler +member keys before it can conclude there is nothing there. The coin side is +sr25519 hard derivation; the entry side is Bandersnatch, which is slower. On a +laptop that is seconds per purse, and a recovery names several purses. + +The chain reads themselves are fine — one bulk `state_queryStorageAt` per batch — +so the cost is entirely local key derivation. Worth knowing before someone runs a +recovery on a phone. Two obvious mitigations if it bites: derive the batch's keys +in parallel, or let the caller narrow the window when it knows the wallet is +small. diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index a2664097b..b4907b236 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -130,7 +130,18 @@ export type CoreStorageKey = /** * Last processed SSO pairing response statement for the pairing device. */ - | { tag: "LastProcessedPairingStatement"; value?: undefined }; + | { tag: "LastProcessedPairingStatement"; value?: undefined } + /** + * The coinage layer's durable record store: purses, coins, recycler + * entries, open operations and their derivation-index counters. + * + * One slot for the whole store rather than a slot per record, so a write is + * atomic and the host needs no key enumeration. New variants belong at the + * end of this enum: the key reaches hosts SCALE-encoded, so its discriminant + * is persisted, and inserting above this point would remap every slot a + * deployed host has already written. + */ + | { tag: "CoinageState"; value?: undefined }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -417,6 +428,7 @@ export const CoreStorageKey: S.Codec = S.lazy( sessionId: string; }>, LastProcessedPairingStatement: S._void, + CoinageState: S._void, }), ); diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 6214ebac8..504c4f005 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -530,6 +530,15 @@ pub enum CoreStorageKey { }, /// Last processed SSO pairing response statement for the pairing device. LastProcessedPairingStatement, + /// The coinage layer's durable record store: purses, coins, recycler + /// entries, open operations and their derivation-index counters. + /// + /// One slot for the whole store rather than a slot per record, so a write is + /// atomic and the host needs no key enumeration. New variants belong at the + /// end of this enum: the key reaches hosts SCALE-encoded, so its discriminant + /// is persisted, and inserting above this point would remap every slot a + /// deployed host has already written. + CoinageState, } impl CoreStorageKey { diff --git a/rust/crates/truapi-server/examples/coinage_chain_agreement.rs b/rust/crates/truapi-server/examples/coinage_chain_agreement.rs new file mode 100644 index 000000000..df98574ab --- /dev/null +++ b/rust/crates/truapi-server/examples/coinage_chain_agreement.rs @@ -0,0 +1,403 @@ +//! Check the coinage layer's assumptions against a real runtime. +//! +//! Everything else about coinage is verified offline: the domain model by unit +//! tests, the whole pipeline by `tests/coinage_lifecycle.rs`. Those prove the +//! layer is self-consistent. They cannot prove it agrees with the chain, because +//! a fake encodes our own assumptions. +//! +//! This example asks the questions only a node can answer, and asks them +//! read-only — no signing, no submission, nothing that costs a coin: +//! +//! 1. Do the constants we hard-code as the reference runtime match what the +//! chain reports? +//! 2. Do the coinage calls exist under the names we resolve them by? +//! 3. Does the `AsCoinage` extension exist, and what are its variant indices? +//! 4. Does our derivation scheme find coins the chain actually holds? +//! +//! Question 3 is the important one. Five of the six extension variants have +//! never been accepted by a runtime; the encoding was read off the pallet +//! source. This confirms at least that the variants exist and where they sit. +//! +//! ```text +//! cargo run --example coinage_chain_agreement +//! cargo run --example coinage_chain_agreement -- --url wss://host --scan 200 +//! COINAGE_ENTROPY=0x… cargo run --example coinage_chain_agreement +//! ``` +//! +//! Without `COINAGE_ENTROPY` the derivation check is skipped and reported as +//! skipped, not passed. + +use std::time::Duration; + +use parity_scale_codec::Decode; + +use truapi_server::coinage::storage::{ + ChainCoin, coins_by_owner_key, collections_key, paid_token_collection_id, + paid_token_collections_created_key, +}; +use truapi_server::coinage::tokens::paid_period; +use truapi_server::host_logic::coinage::chain_constants::next_people_paseo; +use truapi_server::host_logic::coinage::derivation; +use truapi_server::host_logic::coinage::types::{CoinAge, CoinIndex, PurseId, Timestamp}; +use truapi_server::statement_allowance::extension::Metadata; +use truapi_server::statement_allowance::fetch_metadata; +use truapi_server::statement_allowance::rpc::RpcClient; + +/// People chain on the CLI host's default network. +const DEFAULT_URL: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; + +/// Coinage dispatchables the layer resolves by name. +const CALLS: &[&str] = &[ + "split", + "transfer", + "load_recycler_with_coin", + "unload_recycler_into_coins", + "unload_recycler_into_external_asset_and_vouchers", + "load_recycler_with_external_asset_unpaid_batch", +]; + +/// `AsCoinageInfo` variants the layer encodes. +const EXTENSION_VARIANTS: &[&str] = &[ + "AsCoin", + "AsUnloadTokenPeople", + "AsUnloadTokenLitePeople", + "AsUnloadTokenPaid", + "AsUnloadTokenFromOutput", + "InfallibleUnpaidSigned", +]; + +/// Tally of what agreed, what did not, and what could not be checked. +#[derive(Default)] +struct Report { + agreed: usize, + disagreed: Vec, + skipped: Vec, +} + +impl Report { + fn check(&mut self, label: &str, expected: impl std::fmt::Debug, actual: impl std::fmt::Debug) { + let expected = format!("{expected:?}"); + let actual = format!("{actual:?}"); + if expected == actual { + self.agreed += 1; + println!(" ok {label}: {actual}"); + } else { + println!(" DIFF {label}: expected {expected}, chain says {actual}"); + self.disagreed + .push(format!("{label}: expected {expected}, chain says {actual}")); + } + } + + fn note(&mut self, label: &str, detail: impl std::fmt::Display) { + self.agreed += 1; + println!(" ok {label}: {detail}"); + } + + fn fail(&mut self, label: &str, detail: impl std::fmt::Display) { + println!(" FAIL {label}: {detail}"); + self.disagreed.push(format!("{label}: {detail}")); + } + + fn skip(&mut self, label: &str, why: &str) { + println!(" skip {label}: {why}"); + self.skipped.push(label.to_string()); + } + + /// Compare a constant the chain may not expose at all. + /// + /// Absent and zero are different answers, and conflating them hides the + /// more interesting one: a value the pallet declares without + /// `#[pallet::constant]` cannot be discovered at runtime, so a host has to + /// carry it as configuration and will not notice a runtime changing it. + fn check_constant( + &mut self, + label: &str, + expected: T, + observed: Option, + ) { + match observed { + None => { + println!(" ABSENT {label}: not exposed in metadata (expected {expected:?})"); + self.disagreed + .push(format!("{label}: not exposed in metadata")); + } + Some(actual) => self.check(label, expected, actual), + } + } +} + +/// Decode a SCALE-encoded pallet constant. +fn constant(metadata: &Metadata, name: &str) -> Option { + let bytes = metadata.constant("Coinage", name)?; + T::decode(&mut &bytes[..]).ok() +} + +fn arg(name: &str) -> Option { + let mut args = std::env::args().skip(1); + while let Some(candidate) = args.next() { + if candidate == name { + return args.next(); + } + } + None +} + +/// The chain's own clock, in milliseconds since the epoch. +/// +/// Read from `Timestamp::Now` rather than taken from the local clock, because the +/// period a paid token belongs to is decided by the runtime's notion of now. Local +/// time would agree in practice and would be wrong in principle — and this check +/// exists precisely to catch being wrong about the period. +async fn chain_now(rpc: &RpcClient) -> Result { + let key = [ + sp_crypto_hashing::twox_128(b"Timestamp").as_slice(), + sp_crypto_hashing::twox_128(b"Now").as_slice(), + ] + .concat(); + let raw = rpc + .get_storage(&key) + .await + .map_err(|error| format!("reading Timestamp::Now: {error}"))? + .ok_or_else(|| "Timestamp::Now is absent".to_string())?; + let millis = + u64::decode(&mut &raw[..]).map_err(|error| format!("decoding Timestamp::Now: {error}"))?; + + Ok(Timestamp(millis)) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let url = arg("--url").unwrap_or_else(|| DEFAULT_URL.to_string()); + let scan_limit: u32 = arg("--scan") + .and_then(|value| value.parse().ok()) + .unwrap_or(32); + + println!("coinage chain agreement"); + println!(" url {url}"); + + let rpc = RpcClient::connect(&url).await?; + let metadata = fetch_metadata(&rpc).await?; + let mut report = Report::default(); + + // -- 1. constants ------------------------------------------------------ + println!("\nconstants (expected values are `next_people_paseo()`)"); + let reference = next_people_paseo(); + + report.check_constant( + "MinimumExponent", + reference.minimum_exponent, + constant::(&metadata, "MinimumExponent"), + ); + report.check_constant( + "MaximumExponent", + reference.maximum_exponent, + constant::(&metadata, "MaximumExponent"), + ); + report.check_constant( + "MaximumAge", + reference.maximum_age, + constant::(&metadata, "MaximumAge").map(CoinAge), + ); + report.check_constant( + "MaxSplitOutputs", + reference.max_split_outputs, + constant::(&metadata, "MaxSplitOutputs"), + ); + report.check_constant( + "MaxConsolidation", + reference.max_consolidation, + constant::(&metadata, "MaxConsolidation"), + ); + report.check_constant( + "RecyclerExpirationTime", + reference.recycler_expiration_time, + constant::(&metadata, "RecyclerExpirationTime") + .map(|secs| Duration::from_secs(u64::from(secs))), + ); + report.check_constant( + "UnloadTokenTimePeriod", + reference.unload_token_period, + constant::(&metadata, "UnloadTokenTimePeriodPeopleLitePeople") + .map(|secs| Duration::from_secs(u64::from(secs))), + ); + report.check_constant( + "MaxFreeUnloadTokensPerTimePeriod", + reference.max_free_unload_tokens_per_period, + constant::(&metadata, "MaxFreeUnloadTokensPerTimePeriod"), + ); + report.check_constant( + "MaxBatchUnpaidLoad", + reference.max_batch_unpaid_load, + constant::(&metadata, "MaxBatchUnpaidLoad"), + ); + report.check_constant( + "UnderlyingAssetUnit", + reference.underlying_asset_unit, + constant::(&metadata, "UnderlyingAssetUnit"), + ); + report.check_constant( + "CoinFailureLockPeriod", + reference.coin_failure_lock_period, + constant::(&metadata, "CoinFailureLockPeriod").map(Duration::from_secs), + ); + report.check_constant( + "PaidUnloadTokenTimePeriod", + reference.paid_unload_token_period, + constant::(&metadata, "PaidUnloadTokenTimePeriod") + .map(|secs| Duration::from_secs(u64::from(secs))), + ); + report.check_constant( + "PaidUnloadTokenRingExpirationTime", + reference.paid_unload_token_ring_expiration, + constant::(&metadata, "PaidUnloadTokenRingExpirationTime") + .map(|secs| Duration::from_secs(u64::from(secs))), + ); + + match reference.validate() { + Ok(()) => report.note( + "validate", + format!( + "reference config supported; recycle at age {:?}, largest coin {} cents", + reference.recycle_at_age(), + reference + .largest_denomination() + .map(|d| d.value().cents()) + .unwrap_or_default() + ), + ), + Err(error) => report.fail("validate", error), + } + + // -- 2. call indices --------------------------------------------------- + println!("\ncoinage calls (resolved by name, never hard-coded)"); + for call in CALLS { + match metadata.call_indices("Coinage", call) { + Ok([pallet, index]) => { + report.note(call, format!("pallet {pallet}, call {index}")); + } + Err(error) => report.fail(call, error), + } + } + + // -- 3. AsCoinage extension -------------------------------------------- + println!("\nAsCoinage extension variants"); + for variant in EXTENSION_VARIANTS { + match metadata.extension_info_variant_index("AsCoinage", variant) { + Ok(index) => report.note(variant, format!("variant index {index}")), + Err(error) => report.fail(variant, error), + } + } + + // -- 4. the paid unload-token collection identifier -------------------- + // + // The one fact in this layer that came out of pallet source rather than out + // of metadata, and the only way to confirm it is to derive the identifier and + // see whether the members pallet actually has a collection under it. A wrong + // prefix, a dropped `!` or the wrong endianness all produce the same silent + // symptom — an absent key, read as "not a member" — so this check is the + // difference between believing the fallback works and knowing it does. + println!("\npaid unload-token ring (identifier derived from pallet source)"); + match chain_now(&rpc).await { + Err(error) => report.fail("paid-token period", error), + Ok(now) => match paid_period(now, &reference) { + Err(error) => report.fail("paid-token period", error), + Ok(period) => { + report.note("paid-token period", format!("period {period} at {now:?}")); + + let created = rpc + .get_storage(&paid_token_collections_created_key(period)) + .await?; + match created { + Some(_) => report.note( + "PaidTokenCollectionsCreated", + format!("period {period} is created (big-endian key agrees)"), + ), + None => report.skip( + "PaidTokenCollectionsCreated", + "absent — either the pallet has not created this period yet, or the \ + big-endian period key is wrong", + ), + } + + // The decisive one: pallet-members stores a collection under + // exactly this 32-byte identifier, so a hit proves the whole + // derivation, not just its prefix. + let collection = paid_token_collection_id(period); + match rpc.get_storage(&collections_key(&collection)).await? { + Some(_) => report.note( + "Members.Collections[paid]", + format!("0x{} resolves to a collection", hex::encode(collection)), + ), + None => report.fail( + "Members.Collections[paid]", + format!( + "no collection at 0x{} — the identifier this layer derives is not \ + the one the pallet uses", + hex::encode(collection) + ), + ), + } + } + }, + } + + // -- 5. derivation ----------------------------------------------------- + println!("\nderivation (//coinage//coin//////)"); + match std::env::var("COINAGE_ENTROPY") { + Err(_) => report.skip( + "coin discovery", + "set COINAGE_ENTROPY=0x… to probe a real wallet", + ), + Ok(raw) => { + let entropy = hex::decode(raw.trim_start_matches("0x"))?; + let mut found = 0usize; + + for index in 0..scan_limit { + let account = + derivation::coin_account_id(&entropy, PurseId::MAIN, CoinIndex(index))?; + let value = rpc.get_storage(&coins_by_owner_key(&account)).await?; + + if let Some(bytes) = value { + let coin = ChainCoin::decode(&mut &bytes[..])?; + println!( + " found index {index}: 2^{} cents, age {}", + coin.value, coin.age + ); + found += 1; + } + } + + if found > 0 { + report.note( + "coin discovery", + format!("{found} of {scan_limit} probed indices hold coins"), + ); + } else { + report.skip( + "coin discovery", + "no coins under this derivation — inconclusive unless the wallet is known to hold some", + ); + } + } + } + + // -- summary ----------------------------------------------------------- + println!("\n{} checks agreed", report.agreed); + if !report.skipped.is_empty() { + println!( + "{} skipped: {}", + report.skipped.len(), + report.skipped.join(", ") + ); + } + if report.disagreed.is_empty() { + println!("no disagreements"); + Ok(()) + } else { + println!("{} DISAGREEMENTS:", report.disagreed.len()); + for entry in &report.disagreed { + println!(" - {entry}"); + } + Err("the chain disagrees with the layer's assumptions".into()) + } +} diff --git a/rust/crates/truapi-server/examples/coinage_live_validation.rs b/rust/crates/truapi-server/examples/coinage_live_validation.rs new file mode 100644 index 000000000..4ccc890a3 --- /dev/null +++ b/rust/crates/truapi-server/examples/coinage_live_validation.rs @@ -0,0 +1,327 @@ +//! Ask a real runtime whether it accepts the six `AsCoinage` origins. +//! +//! `coinage_chain_agreement` confirms the six variants *exist* at the indices the +//! layer assumes. That is not the same as the runtime accepting one: the encoding +//! of five of them was read off the pallet source and has never been through a +//! node. This driver closes that gap the cheap way — it assembles each variant into +//! a real extrinsic and **dry-runs** it. Nothing is broadcast, no coin is owned, no +//! value moves. +//! +//! # Reading the answers +//! +//! A dry-run rejection is not a failure here; the *kind* of rejection is the whole +//! result: +//! +//! - **`Invalid::Custom(n)`** — the best outcome available without owning a coin. +//! The runtime parsed our extra, reached the pallet's own checks, and refused for +//! a reason of its own ("no such coin", "no such token"). The encoding is right. +//! - **`Invalid::BadProof`** — parsed, and the proof inside was rejected. Expected +//! for every variant carrying a placeholder proof, and again evidence the shape +//! was understood. +//! - **`Invalid::Call` / a decode failure** — the runtime could not make sense of +//! the transaction at all. That is an encoding bug, and the one answer worth +//! acting on. +//! - **Accepted** — only reachable for a variant whose origin really exists, which +//! means the wallet this ran with owns a coin. +//! +//! ```text +//! cargo run --example coinage_live_validation +//! cargo run --example coinage_live_validation -- --url wss://host +//! COINAGE_ENTROPY=0x… cargo run --example coinage_live_validation +//! ``` +//! +//! With `COINAGE_ENTROPY` the coin-origin variant is signed by a key derived the +//! way the layer derives one, so a wallet that holds a coin at index 0 of its main +//! purse can turn this into an acceptance rather than a `Custom`. Without it, a +//! throwaway key is used and the coin is expected to be missing. +//! +//! # What this still does not cover +//! +//! Mortality expiry and `post_dispatch` failure-lock behaviour need a transaction +//! that actually lands, and therefore a funded wallet. They are the reason E1 +//! exists at all, and they remain the next thing to do against a testnet. + +use schnorrkel::{ExpansionMode, Keypair, MiniSecretKey}; + +use truapi_server::coinage::call::{ + CoinOutput, RawEncoded, SplitInto, UnloadRecyclerIntoCoinsArgs, +}; +use truapi_server::coinage::extension::{AsCoinageInfo, FreeTokenRing}; +use truapi_server::coinage::extrinsic::{ + CoinageCall, build_call, build_coin_origin_extrinsic, build_unsigned_extrinsic, +}; +use truapi_server::coinage::submit::{dry_run, fetch_mortal_chain_state}; +use truapi_server::host_logic::coinage::chain_constants::next_people_paseo; +use truapi_server::host_logic::coinage::derivation; +use truapi_server::host_logic::coinage::types::{ + CoinAccountId, CoinIndex, DenominationExponent, PurseId, RevisionIndex, RingIndex, RingLocation, +}; +use truapi_server::statement_allowance::extension::Metadata; +use truapi_server::statement_allowance::fetch_metadata; +use truapi_server::statement_allowance::rpc::RpcClient; + +/// People chain on the CLI host's default network. +const DEFAULT_URL: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; + +/// Length of a single-context ring-VRF signature, which both the token proof and +/// each alias proof are. +const RING_VRF_PROOF_LEN: usize = 785; + +/// What a dry-run told us about one variant. +enum Verdict { + /// The runtime would accept it. + Accepted, + /// Parsed, then refused by the pallet's own checks. The encoding is right. + Reached(String), + /// Refused before the pallet: the transaction was not understood. + Malformed(String), +} + +impl Verdict { + /// Classify a dry-run result. + /// + /// The distinction that matters is whether the runtime got far enough to + /// disagree with us about *state* rather than about *bytes*. + fn of(result: Result<(), String>) -> Self { + match result { + Ok(()) => Self::Accepted, + Err(reason) => { + let reached = reason.contains("Custom") + || reason.contains("BadProof") + || reason.contains("Payment") + || reason.contains("Stale") + || reason.contains("Future"); + if reached { + Self::Reached(reason) + } else { + Self::Malformed(reason) + } + } + } + } + + fn render(&self, label: &str) -> bool { + match self { + Self::Accepted => { + println!(" ok {label}: accepted — the origin exists and the proofs hold"); + true + } + Self::Reached(reason) => { + println!(" parsed {label}: {reason}"); + true + } + Self::Malformed(reason) => { + println!(" BAD {label}: {reason}"); + false + } + } + } +} + +/// A placeholder proof of the right length. +/// +/// Length matters and content does not: a proof of the wrong length changes how +/// the extension decodes, which would turn an encoding question into a decoding +/// accident. +fn placeholder_proof() -> RawEncoded { + RawEncoded(vec![0xAB; RING_VRF_PROOF_LEN]) +} + +/// The unload call every token-bearing variant is tried against. +fn unload_call(metadata: &Metadata) -> Result, Box> { + let constants = next_people_paseo(); + let exponent = DenominationExponent::new(4).ok_or("4 is a denomination")?; + let outputs = [CoinOutput { + exponent, + account: CoinAccountId([0x11; 32]), + }]; + let args = UnloadRecyclerIntoCoinsArgs::new( + vec![[0x22; 32]], + exponent, + RingLocation::new(RingIndex(0), RevisionIndex(0)), + &outputs, + 0, + &constants, + )?; + + Ok(build_call( + metadata, + CoinageCall::UnloadRecyclerIntoCoins, + &args, + )?) +} + +/// The six variants, each with the call it makes sense against. +fn variants() -> Vec<(&'static str, AsCoinageInfo)> { + let alias_proofs = vec![placeholder_proof()]; + + vec![ + ( + "AsUnloadTokenPeople", + AsCoinageInfo::FreeUnloadToken { + ring: FreeTokenRing::People, + proof: placeholder_proof(), + period: 0, + counter: 0, + alias_proofs: alias_proofs.clone(), + }, + ), + ( + "AsUnloadTokenLitePeople", + AsCoinageInfo::FreeUnloadToken { + ring: FreeTokenRing::LitePeople, + proof: placeholder_proof(), + period: 0, + counter: 0, + alias_proofs: alias_proofs.clone(), + }, + ), + ( + "AsUnloadTokenPaid", + AsCoinageInfo::PaidUnloadToken { + proof: placeholder_proof(), + period: 0, + ring: RingLocation::new(RingIndex(0), RevisionIndex(0)), + alias_proofs: alias_proofs.clone(), + }, + ), + ( + "AsUnloadTokenFromOutput", + AsCoinageInfo::UnloadTokenFromOutput { + fee_recycler_value: DenominationExponent::new(4).expect("4 is a denomination"), + fee_recycler_ring: RingLocation::new(RingIndex(0), RevisionIndex(0)), + retry_counter: 0, + alias_proofs, + }, + ), + ( + "InfallibleUnpaidSigned", + AsCoinageInfo::InfallibleUnpaidSigned { nonce: 0 }, + ), + ] +} + +/// The keypair the coin-origin variant signs with. +/// +/// With entropy, the layer's own derivation for the main purse's first coin, so a +/// wallet that holds one can produce an acceptance. Without, a throwaway key whose +/// account the chain has certainly never seen. +fn coin_signer(entropy: Option<&[u8]>) -> Result<(Keypair, bool), Box> { + match entropy { + Some(entropy) => Ok(( + derivation::coin_keypair(entropy, PurseId::MAIN, CoinIndex(0))?, + true, + )), + None => Ok(( + MiniSecretKey::from_bytes(&[0x5c; 32]) + .map_err(|error| format!("throwaway key: {error}"))? + .expand_to_keypair(ExpansionMode::Ed25519), + false, + )), + } +} + +fn arg(name: &str) -> Option { + let mut args = std::env::args().skip(1); + while let Some(candidate) = args.next() { + if candidate == name { + return args.next(); + } + } + None +} + +fn entropy_from_environment() -> Option> { + let raw = std::env::var("COINAGE_ENTROPY").ok()?; + hex::decode(raw.trim().strip_prefix("0x").unwrap_or(raw.trim())).ok() +} + +/// Dry-run one assembled extrinsic and reduce the outcome to a string. +async fn ask(rpc: &RpcClient, extrinsic: &[u8]) -> Result<(), String> { + dry_run(rpc, extrinsic) + .await + .map_err(|error| error.to_string()) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let url = arg("--url").unwrap_or_else(|| DEFAULT_URL.to_string()); + let entropy = entropy_from_environment(); + + println!("coinage live validation"); + println!(" url {url}"); + println!( + " entropy {}", + if entropy.is_some() { + "from COINAGE_ENTROPY" + } else { + "absent — the coin origin will be a throwaway key" + } + ); + + let rpc = RpcClient::connect(&url).await?; + let metadata = fetch_metadata(&rpc).await?; + let (state, anchor) = fetch_mortal_chain_state(&rpc).await?; + println!( + " era anchored at #{} for {} blocks\n", + anchor.number, anchor.period + ); + + let mut understood = 0usize; + let mut malformed = Vec::new(); + + // -- the coin origin --------------------------------------------------- + println!("origins"); + let (keypair, derived) = coin_signer(entropy.as_deref())?; + let transfer = build_call( + &metadata, + CoinageCall::Transfer, + &truapi_server::coinage::call::TransferArgs::new(CoinAccountId([0x33; 32])), + )?; + let coin_origin = build_coin_origin_extrinsic(&metadata, &state, &transfer, &keypair)?; + let label = if derived { + "AsCoin (derived key)" + } else { + "AsCoin (throwaway key)" + }; + if Verdict::of(ask(&rpc, &coin_origin).await).render(label) { + understood += 1; + } else { + malformed.push(label.to_string()); + } + + // -- the five unsigned origins ---------------------------------------- + let call = unload_call(&metadata)?; + for (label, info) in variants() { + let extra = info.encode_extra(&metadata)?; + let extrinsic = build_unsigned_extrinsic(&metadata, &state, &call, &extra)?; + if Verdict::of(ask(&rpc, &extrinsic).await).render(label) { + understood += 1; + } else { + malformed.push(label.to_string()); + } + } + + // -- a shape check that needs no chain -------------------------------- + println!("\nshapes"); + let split = SplitInto::from_outputs( + &[CoinOutput { + exponent: DenominationExponent::new(3).expect("3 is a denomination"), + account: CoinAccountId([1; 32]), + }], + &next_people_paseo(), + )?; + println!( + " ok split_into groups by denomination: {} group(s)", + split.0.len() + ); + + println!("\n{understood}/6 origins reached the runtime's own checks"); + if malformed.is_empty() { + println!("no encoding was rejected as unintelligible"); + Ok(()) + } else { + println!("unintelligible to the runtime: {}", malformed.join(", ")); + Err("at least one AsCoinage encoding was not understood".into()) + } +} diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index e687fafa1..ea5a412a7 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -6,6 +6,7 @@ pub mod attestation; pub mod bulletin; +pub mod coinage; pub mod dotns; pub mod entropy; pub mod extrinsic; diff --git a/rust/crates/truapi-server/src/host_logic/coinage.rs b/rust/crates/truapi-server/src/host_logic/coinage.rs new file mode 100644 index 000000000..35e61c0cd --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage.rs @@ -0,0 +1,31 @@ +//! The coinage layer: the host's self-contained coinage subsystem. +//! +//! The layer owns every coin and recycler entry the user controls, partitions +//! them across purses, and runs selection, recycling, and the operation +//! lifecycle. It knows nothing about receivables, cheques, or refunds; those +//! compose above it out of the coin export/import seam. +//! +//! This module tree is the pure domain model: records, state machines, the +//! arithmetic over them, and key derivation. It performs no chain access, no +//! persistence, and no time lookups — wall-clock instants and jitter draws are +//! supplied by the caller, so the whole state machine is exercisable without a +//! host or a chain. Signing, submission, and subscriptions live in +//! `runtime::coinage`. + +pub mod chain_constants; +pub mod coin; +pub mod derivation; +pub mod entry; +pub mod error; +pub mod event; +pub mod log; +pub mod memo; +pub mod offload; +pub mod operation; +pub mod params; +pub mod purse; +pub mod recovery; +pub mod selection; +pub mod store; +pub mod types; +pub mod unload_token; diff --git a/rust/crates/truapi-server/src/host_logic/coinage/chain_constants.rs b/rust/crates/truapi-server/src/host_logic/coinage/chain_constants.rs new file mode 100644 index 000000000..58726452a --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/chain_constants.rs @@ -0,0 +1,245 @@ +//! Facts about the coinage pallet the layer is talking to. +//! +//! These are facts about the runtime, not choices of the layer. They are kept +//! apart from [`super::params::CoinageParameters`] because the distinction +//! matters: a policy parameter can be tuned, whereas exceeding one of these +//! makes an extrinsic invalid. Anything the layer builds has to fit inside them. +//! +//! Most are read from metadata. Four are not exposed there and must be carried +//! as per-network configuration — see the field docs for `maximum_age`, +//! `recycler_expiration_time`, `paid_unload_token_period` and +//! `paid_unload_token_ring_expiration`. `examples/coinage_chain_agreement.rs` +//! checks the rest against a live node. + +use core::time::Duration; + +use super::error::CoinageError; +use super::types::{CoinAge, DenominationExponent, MAX_SUPPORTED_DENOMINATION_EXPONENT}; + +/// The coinage pallet's configuration, as observed on the connected chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CoinageChainConstants { + /// Smallest denomination exponent the pallet accepts (`MinimumExponent`). + pub minimum_exponent: i8, + /// Largest denomination exponent the pallet accepts (`MaximumExponent`). + pub maximum_exponent: i8, + /// Age at which a coin can no longer be transferred or split + /// (`MaximumAge`). Past this it can only be recycled or offboarded. + /// + /// **Not discoverable.** The pallet declares this without + /// `#[pallet::constant]`, so it is absent from metadata and has to be + /// carried as per-network configuration. A runtime that lowers it will not + /// be noticed, and the layer would then recycle later than the chain + /// allows — letting coins age out unusable. Confirmed absent on + /// `paseo-people-next` by `examples/coinage_chain_agreement.rs`. + pub maximum_age: CoinAge, + /// Cap on output accounts of a single split or unload-into-coins extrinsic + /// (`MaxSplitOutputs`). + pub max_split_outputs: u32, + /// Cap on recycler entries consolidated into one unload extrinsic + /// (`MaxConsolidation`). + pub max_consolidation: u32, + /// How long after a ring becomes immutable the chain destroys the backing + /// value of entries still in it (`RecyclerExpirationTime`). + /// + /// **Not discoverable on the deployed runtime.** Absent from + /// `paseo-people-next`'s metadata even though the pallet source marks it + /// `#[pallet::constant]`, so the deployed runtime predates that attribute. + /// Carried as configuration until it appears. This one drives the rescue + /// margin, so a runtime that shortens it without us noticing would make the + /// ring-expiration sweep fire too late. + pub recycler_expiration_time: Duration, + /// Length of a free-unload-token period + /// (`UnloadTokenTimePeriodPeopleLitePeople`). + pub unload_token_period: Duration, + /// Length of a paid-unload-token period (`PaidUnloadTokenTimePeriod`). + /// + /// **Not discoverable on the deployed runtime**, for the same reason as + /// `recycler_expiration_time`: the pallet source marks it + /// `#[pallet::constant]` but `paseo-people-next`'s metadata predates that. + /// Carried as configuration, and it is a longer period than the free one — + /// three days against one on the reference runtime — so reusing + /// `unload_token_period` for it names the wrong period entirely. + /// + /// The period is what the paid token's proof context commits to, so a wrong + /// value yields a proof against a collection the runtime is not verifying + /// against — after a join fee has been spent. + pub paid_unload_token_period: Duration, + /// How long after its period ends a paid-token ring keeps accepting proofs + /// (`PaidUnloadTokenRingExpirationTime`). + /// + /// **Not discoverable on the deployed runtime**, same reason as above. A + /// token proved past `(period + 1) * paid_unload_token_period + + /// paid_unload_token_ring_expiration` is refused as stale. + pub paid_unload_token_ring_expiration: Duration, + /// Free unload tokens a member may consume per period + /// (`MaxFreeUnloadTokensPerTimePeriod`). + pub max_free_unload_tokens_per_period: u32, + /// Entries one unpaid external-asset load may create + /// (`MaxBatchUnpaidLoad`). Bounds a top-up: an amount needing more + /// denominations than this cannot be loaded in one extrinsic. + pub max_batch_unpaid_load: u32, + /// Underlying-asset base units in one cent (`UnderlyingAssetUnit`). + pub underlying_asset_unit: u128, + /// Base period a coin stays locked after a dispatch that failed with the + /// coin as its origin (`CoinFailureLockPeriod`). + /// + /// The lock the chain writes is `2^retries` times this, counted from + /// consecutive failures on the same coin. Nothing about the coin is lost — + /// the extension restores it — but it is unspendable until the lock + /// expires, and a layer that does not model this reselects the coin and + /// spends a fresh unload token on an extrinsic the chain will refuse. + pub coin_failure_lock_period: Duration, +} + +impl CoinageChainConstants { + /// Check that the layer can operate against this runtime. + /// + /// Called once when constants are read, so an incompatible runtime is + /// rejected at connection time rather than at the first failed extrinsic. + pub fn validate(&self) -> Result<(), CoinageError> { + if self.minimum_exponent < 0 { + return Err(CoinageError::Internal(format!( + "runtime allows sub-cent denominations (MinimumExponent = {}), which this layer \ + cannot represent", + self.minimum_exponent + ))); + } + if self.maximum_exponent > MAX_SUPPORTED_DENOMINATION_EXPONENT { + return Err(CoinageError::Internal(format!( + "runtime MaximumExponent {} exceeds the layer's supported ceiling {}", + self.maximum_exponent, MAX_SUPPORTED_DENOMINATION_EXPONENT + ))); + } + if self.minimum_exponent > self.maximum_exponent { + return Err(CoinageError::Internal(format!( + "runtime exponent range is inverted: {}..={}", + self.minimum_exponent, self.maximum_exponent + ))); + } + if self.max_split_outputs == 0 || self.max_consolidation == 0 { + return Err(CoinageError::Internal( + "runtime caps a split or consolidation at zero".to_string(), + )); + } + + Ok(()) + } + + /// Whether the pallet would accept this denomination. + pub fn accepts(&self, exponent: DenominationExponent) -> bool { + (self.minimum_exponent..=self.maximum_exponent).contains(&exponent.get()) + } + + /// The largest denomination the pallet accepts. + pub fn largest_denomination(&self) -> Option { + DenominationExponent::new(self.maximum_exponent) + } + + /// The age at which the layer should recycle a coin, keeping a margin below + /// the chain's cap. + pub fn recycle_at_age(&self) -> CoinAge { + super::params::CoinageParameters::recycle_at_age(self.maximum_age) + } +} + +/// The values configured by the `next-people-paseo` runtime. +/// +/// A reference point for tests and for the CLI host's default network. The +/// metadata-exposed values are verified against the live runtime by +/// `examples/coinage_chain_agreement.rs`; the two that metadata does not expose +/// have no such check and are the reason this function exists at all. +pub fn next_people_paseo() -> CoinageChainConstants { + CoinageChainConstants { + minimum_exponent: 0, + maximum_exponent: 14, + maximum_age: CoinAge(16), + max_split_outputs: 32, + max_consolidation: 64, + recycler_expiration_time: Duration::from_secs(90 * 24 * 60 * 60), + unload_token_period: Duration::from_secs(24 * 60 * 60), + paid_unload_token_period: Duration::from_secs(3 * 24 * 60 * 60), + paid_unload_token_ring_expiration: Duration::from_secs(4 * 24 * 60 * 60), + max_free_unload_tokens_per_period: 1_000, + max_batch_unpaid_load: 10, + underlying_asset_unit: 10u128.pow(4), + coin_failure_lock_period: Duration::from_secs(60), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_reference_runtime_is_supported() { + let constants = next_people_paseo(); + + assert_eq!(constants.validate(), Ok(())); + assert_eq!(constants.recycle_at_age(), CoinAge(14)); + assert_eq!( + constants.largest_denomination(), + DenominationExponent::new(14) + ); + } + + #[test] + fn the_largest_reference_coin_is_a_hundred_and_sixty_three_dollars() { + let largest = next_people_paseo() + .largest_denomination() + .expect("14 is representable"); + + assert_eq!(largest.value().cents(), 16_384); + } + + #[test] + fn denominations_outside_the_runtime_range_are_rejected() { + let constants = next_people_paseo(); + + assert!(constants.accepts(DenominationExponent::new(0).expect("valid"))); + assert!(constants.accepts(DenominationExponent::new(14).expect("valid"))); + assert!(!constants.accepts(DenominationExponent::new(15).expect("valid"))); + } + + #[test] + fn a_sub_cent_runtime_is_refused_rather_than_truncated() { + let constants = CoinageChainConstants { + minimum_exponent: -2, + ..next_people_paseo() + }; + + assert!(matches!( + constants.validate(), + Err(CoinageError::Internal(_)) + )); + } + + #[test] + fn a_runtime_beyond_the_arithmetic_ceiling_is_refused() { + let constants = CoinageChainConstants { + maximum_exponent: MAX_SUPPORTED_DENOMINATION_EXPONENT + 1, + ..next_people_paseo() + }; + + assert!(matches!( + constants.validate(), + Err(CoinageError::Internal(_)) + )); + } + + #[test] + fn an_inverted_or_degenerate_range_is_refused() { + let inverted = CoinageChainConstants { + minimum_exponent: 8, + maximum_exponent: 4, + ..next_people_paseo() + }; + let zero_split = CoinageChainConstants { + max_split_outputs: 0, + ..next_people_paseo() + }; + + assert!(inverted.validate().is_err()); + assert!(zero_split.validate().is_err()); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/coin.rs b/rust/crates/truapi-server/src/host_logic/coinage/coin.rs new file mode 100644 index 000000000..f8a0ccba4 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/coin.rs @@ -0,0 +1,432 @@ +//! Coin records and their lifecycle. +//! +//! A coin is a chain-level NFT of a fixed dotUSD denomination, addressed by an +//! account derived from the layer's root entropy, the coin's purse, and its +//! index. `Spent` is terminal but the record is retained: a coin index is never +//! reused, because the account may already have appeared in a transfer memo +//! passed out of band. + +use parity_scale_codec::{Decode, Encode}; + +use super::error::InvalidTransition; +use super::types::{ + Amount, CoinAge, CoinIndex, DenominationExponent, OperationHandle, PurseId, Timestamp, +}; + +const SUBJECT: &str = "coin"; + +/// Lifecycle state of a coin. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum CoinState { + /// Created locally as a future output of an in-flight operation; the chain + /// account has not been observed yet. + Pending, + /// The chain confirms the account holds a coin. Selectable. + Available, + /// Held by an in-flight operation. Not selectable. + LockedFor(OperationHandle), + /// Terminal. The account is empty, or the coin was exported. + Spent, +} + +impl CoinState { + /// A short label for diagnostics. + pub const fn label(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Available => "available", + Self::LockedFor(_) => "locked", + Self::Spent => "spent", + } + } + + /// The operation holding this coin, if any. + pub const fn locked_by(&self) -> Option { + match self { + Self::LockedFor(handle) => Some(*handle), + _ => None, + } + } +} + +/// A coin the layer controls. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct Coin { + /// Purse owning the coin. Together with [`Coin::index`] this is its + /// identity; membership is implied by derivation. + pub purse: PurseId, + /// Derivation index within the purse. + pub index: CoinIndex, + /// Denomination. + pub exponent: DenominationExponent, + /// Transfers and splits the coin has undergone, as last observed on chain. + pub age: CoinAge, + /// Lifecycle state. + pub state: CoinState, + /// When the chain's own lock on this coin expires, as last observed. + /// + /// Orthogonal to [`Coin::state`], which is the layer's business: a coin can + /// be locally available and still refused by the chain. The pallet writes + /// this after a dispatch that used the coin as its origin fails, restoring + /// the coin but holding it for `2^retries` times + /// `CoinFailureLockPeriod` to stop a failing extrinsic being resubmitted in + /// a tight loop. + pub locked_until: Option, +} + +impl Coin { + /// Record a coin the layer expects an in-flight operation to produce. + pub fn pending(purse: PurseId, index: CoinIndex, exponent: DenominationExponent) -> Self { + Self { + purse, + index, + exponent, + age: CoinAge::default(), + state: CoinState::Pending, + locked_until: None, + } + } + + /// The coin's value. + pub fn value(&self) -> Amount { + self.exponent.value() + } + + /// Whether selection may consider this coin. + /// + /// Both locks have to be clear: the layer's own, and the chain's. Selecting + /// a chain-locked coin builds an extrinsic the runtime rejects at validate, + /// after the proofs and any unload token that went into it are already + /// spent. + pub fn is_selectable(&self, now: Timestamp) -> bool { + self.state == CoinState::Available && !self.is_chain_locked(now) + } + + /// Whether the chain is still holding its own lock on this coin. + pub fn is_chain_locked(&self, now: Timestamp) -> bool { + self.locked_until.is_some_and(|until| now < until) + } + + /// Whether the chain still accepts the coin, given its age cap. + pub fn is_usable(&self, chain_coin_max_age: CoinAge) -> bool { + self.age < chain_coin_max_age + } + + /// Whether the coin-age sweep should recycle this coin. + pub fn needs_recycling(&self, recycle_at_age: CoinAge, now: Timestamp) -> bool { + self.is_selectable(now) && self.age >= recycle_at_age + } + + /// Record the chain's lock expiry, or its absence. + /// + /// Applied unconditionally: the chain's lock is a fact about the account, + /// independent of what the layer is doing with the record, and it must be + /// possible to clear it once the chain drops it. + pub fn observe_chain_lock(&mut self, locked_until: Option) { + self.locked_until = locked_until; + } + + /// Record a chain observation that the account holds a coin of the given + /// age. + /// + /// Valid while `Pending` (first sighting) or `Available` (age refresh). A + /// locked coin is left alone: its owning operation decides the outcome. + pub fn observe_populated(&mut self, age: CoinAge) -> Result<(), InvalidTransition> { + match self.state { + CoinState::Pending | CoinState::Available => { + self.age = age; + self.state = CoinState::Available; + Ok(()) + } + _ => Err(InvalidTransition::new( + SUBJECT, + self.state.label(), + "observe as populated", + )), + } + } + + /// Lock the coin for an operation that is preparing. + pub fn lock_for(&mut self, handle: OperationHandle) -> Result<(), InvalidTransition> { + match self.state { + CoinState::Available => { + self.state = CoinState::LockedFor(handle); + Ok(()) + } + _ => Err(InvalidTransition::new(SUBJECT, self.state.label(), "lock")), + } + } + + /// Return the coin to the selectable pool. + /// + /// Covers both release paths: the operation aborted before submitting + /// anything, and the operation failed after submission with the account + /// still populated. + pub fn release(&mut self, handle: OperationHandle) -> Result<(), InvalidTransition> { + match self.state { + CoinState::LockedFor(holder) if holder == handle => { + self.state = CoinState::Available; + Ok(()) + } + _ => Err(InvalidTransition::new( + SUBJECT, + self.state.label(), + "release", + )), + } + } + + /// Retire a coin that was never created, because the transaction meant to + /// produce it did not take effect. + /// + /// Terminal, like [`Self::mark_spent`], and for the same reason: the + /// derivation index must never be handed out again. The account is empty + /// either way — the difference is only whether it was ever populated, which + /// nothing downstream depends on. + pub fn abandon(&mut self) -> Result<(), InvalidTransition> { + match self.state { + CoinState::Pending => { + self.state = CoinState::Spent; + Ok(()) + } + _ => Err(InvalidTransition::new( + SUBJECT, + self.state.label(), + "abandon", + )), + } + } + + /// Retire a coin a definitely-successful transaction just materialized, whose + /// secret has now left the layer (§8.4). + /// + /// Accepts `Pending` alone, which is exactly the state such a coin is in: the + /// transaction that created it has settled, so the account is populated, but + /// observation has not caught up and the record has never been `Available`. A + /// coin the *operation holds* is retired through [`Self::mark_spent`] instead, + /// because that is its owning operation consuming it. + pub fn mark_exported(&mut self) -> Result<(), InvalidTransition> { + if self.state != CoinState::Pending { + return Err(InvalidTransition::new( + SUBJECT, + self.state.label(), + "export", + )); + } + self.state = CoinState::Spent; + Ok(()) + } + + /// Retire the coin after its owning operation consumed it. + pub fn mark_spent(&mut self, handle: OperationHandle) -> Result<(), InvalidTransition> { + match self.state { + CoinState::LockedFor(holder) if holder == handle => { + self.state = CoinState::Spent; + Ok(()) + } + _ => Err(InvalidTransition::new( + SUBJECT, + self.state.label(), + "mark spent", + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fixed "now" for the tests that do not exercise the chain lock. + const NOW: Timestamp = Timestamp(1_000_000); + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn available_coin() -> Coin { + let mut coin = Coin::pending(PurseId::MAIN, CoinIndex(0), exponent(4)); + coin.observe_populated(CoinAge(0)) + .expect("pending observes"); + coin + } + + #[test] + fn a_new_coin_is_pending_and_unselectable() { + let coin = Coin::pending(PurseId::MAIN, CoinIndex(7), exponent(3)); + + assert_eq!(coin.state, CoinState::Pending); + assert!(!coin.is_selectable(NOW)); + assert_eq!(coin.value(), Amount::from_cents(8)); + } + + #[test] + fn first_chain_observation_makes_a_pending_coin_available() { + let mut coin = Coin::pending(PurseId::MAIN, CoinIndex(0), exponent(2)); + + coin.observe_populated(CoinAge(3)) + .expect("transition is valid"); + + assert_eq!(coin.state, CoinState::Available); + assert_eq!(coin.age, CoinAge(3)); + assert!(coin.is_selectable(NOW)); + } + + #[test] + fn observation_refreshes_the_age_of_an_available_coin() { + let mut coin = available_coin(); + + coin.observe_populated(CoinAge(5)) + .expect("refresh is valid"); + + assert_eq!(coin.age, CoinAge(5)); + } + + #[test] + fn a_locked_coin_ignores_chain_observation() { + let mut coin = available_coin(); + coin.lock_for(OperationHandle(1)).expect("lock is valid"); + + let rejected = coin.observe_populated(CoinAge(9)); + + assert!(rejected.is_err()); + assert_eq!(coin.state, CoinState::LockedFor(OperationHandle(1))); + } + + #[test] + fn locking_removes_the_coin_from_selection() { + let mut coin = available_coin(); + + coin.lock_for(OperationHandle(4)).expect("lock is valid"); + + assert!(!coin.is_selectable(NOW)); + assert_eq!(coin.state.locked_by(), Some(OperationHandle(4))); + } + + #[test] + fn a_coin_cannot_be_locked_twice() { + let mut coin = available_coin(); + coin.lock_for(OperationHandle(1)) + .expect("first lock is valid"); + + assert!(coin.lock_for(OperationHandle(2)).is_err()); + assert_eq!(coin.state, CoinState::LockedFor(OperationHandle(1))); + } + + #[test] + fn release_returns_the_coin_to_selection() { + let mut coin = available_coin(); + coin.lock_for(OperationHandle(1)).expect("lock is valid"); + + coin.release(OperationHandle(1)).expect("release is valid"); + + assert_eq!(coin.state, CoinState::Available); + assert!(coin.is_selectable(NOW)); + } + + #[test] + fn only_the_holding_operation_may_release_or_spend() { + let mut coin = available_coin(); + coin.lock_for(OperationHandle(1)).expect("lock is valid"); + + assert!(coin.release(OperationHandle(2)).is_err()); + assert!(coin.mark_spent(OperationHandle(2)).is_err()); + assert_eq!(coin.state, CoinState::LockedFor(OperationHandle(1))); + } + + #[test] + fn spending_is_terminal() { + let mut coin = available_coin(); + coin.lock_for(OperationHandle(1)).expect("lock is valid"); + coin.mark_spent(OperationHandle(1)).expect("spend is valid"); + + assert_eq!(coin.state, CoinState::Spent); + assert!(!coin.is_selectable(NOW)); + assert!(coin.lock_for(OperationHandle(2)).is_err()); + assert!(coin.observe_populated(CoinAge(1)).is_err()); + } + + #[test] + fn an_available_coin_cannot_be_spent_without_being_locked() { + let mut coin = available_coin(); + + assert!(coin.mark_spent(OperationHandle(1)).is_err()); + assert_eq!(coin.state, CoinState::Available); + } + + #[test] + fn recycling_is_due_at_the_threshold_age_and_only_while_selectable() { + let mut coin = available_coin(); + coin.observe_populated(CoinAge(14)) + .expect("refresh is valid"); + + assert!(coin.needs_recycling(CoinAge(14), NOW)); + assert!(!coin.needs_recycling(CoinAge(15), NOW)); + + coin.lock_for(OperationHandle(1)).expect("lock is valid"); + assert!(!coin.needs_recycling(CoinAge(14), NOW)); + } + + #[test] + fn a_chain_locked_coin_is_intact_but_unselectable() { + // What the pallet leaves behind after a dispatch failure: the coin is + // restored, so the layer must not retire it, but the runtime refuses it + // as an origin until the lock expires. + let mut coin = available_coin(); + + coin.observe_chain_lock(Some(Timestamp(2_000))); + + assert_eq!(coin.state, CoinState::Available, "the coin still exists"); + assert!(coin.is_chain_locked(Timestamp(1_999))); + assert!(!coin.is_selectable(Timestamp(1_999))); + assert!(!coin.needs_recycling(CoinAge(0), Timestamp(1_999))); + } + + #[test] + fn a_chain_lock_expires_on_its_own() { + let mut coin = available_coin(); + coin.observe_chain_lock(Some(Timestamp(2_000))); + + // The boundary is exclusive: at the expiry the chain accepts the coin. + assert!(coin.is_selectable(Timestamp(2_000))); + assert!(!coin.is_chain_locked(Timestamp(2_000))); + } + + #[test] + fn observing_no_lock_clears_a_previous_one() { + let mut coin = available_coin(); + coin.observe_chain_lock(Some(Timestamp(2_000))); + + coin.observe_chain_lock(None); + + assert!(coin.is_selectable(Timestamp(0))); + } + + #[test] + fn the_two_locks_are_independent() { + // A coin held by an operation is unselectable whatever the chain says, + // and the chain's lock survives the operation releasing it. + let mut coin = available_coin(); + coin.observe_chain_lock(Some(Timestamp(2_000))); + coin.lock_for(OperationHandle(1)).expect("lock is valid"); + + assert!(!coin.is_selectable(Timestamp(5_000))); + + coin.release(OperationHandle(1)).expect("release is valid"); + + assert!(!coin.is_selectable(Timestamp(1_000)), "chain lock survives"); + assert!(coin.is_selectable(Timestamp(5_000))); + } + + #[test] + fn usability_ends_at_the_chain_age_cap() { + let mut coin = available_coin(); + coin.observe_populated(CoinAge(15)) + .expect("refresh is valid"); + assert!(coin.is_usable(CoinAge(16))); + + coin.observe_populated(CoinAge(16)) + .expect("refresh is valid"); + assert!(!coin.is_usable(CoinAge(16))); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/derivation.rs b/rust/crates/truapi-server/src/host_logic/coinage/derivation.rs new file mode 100644 index 000000000..379be16b2 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/derivation.rs @@ -0,0 +1,353 @@ +//! Coin and recycler-entry key derivation. +//! +//! Two independent subtrees hang off the user's root entropy, one per key type: +//! +//! ```text +//! coins: //coinage//coin////// (sr25519) +//! recycler entries: //coinage////// (bandersnatch) +//! fee account: //coinage//fee (sr25519) +//! paid tokens: //coinage//paidtkn//// (bandersnatch) +//! ``` +//! +//! Splitting by key type lets recovery enumerate each side on its own — coins +//! against the pallet's `CoinsByOwner`, entries against recycler-location +//! storage — without probing indices that could only ever belong to the other. +//! +//! # Hard junctions, without exception +//! +//! Every segment is a hard junction, and that is a security requirement rather +//! than a stylistic choice. sr25519 soft derivation is invertible from the child +//! side: a child secret key, the parent public key and the path together recover +//! the parent secret. Coinage hands out coin secrets by design — that is what a +//! cheque is — so a soft junction anywhere on the coin path would mean that +//! cashing one coin exposes the purse root and with it every other coin in the +//! purse, past and future. Salting a soft segment with a secret component does +//! not help. +//! +//! # Relationship to RFC-0022 +//! +//! RFC-0022 roots all ring-VRF keys at `hash(root_entropy, "ring-vrf")` and +//! derives beneath it with a hard-only keyed-hash chain, `hash(parent, +//! chain_code)`. That primitive is reused here unchanged; only the path below the +//! root differs. RFC-0022's own shape is `//{domain}//{index}` with the domain +//! always a product's dotNS identifier, which coinage cannot satisfy — it is not +//! a product, and RFC-0022 says so explicitly, deferring coinage to its own RFC. +//! So coinage takes `coinage` as a reserved domain, unambiguous because every +//! product domain is a dotNS name, and extends the path with the purse and index +//! structure it needs. + +use schnorrkel::Keypair; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::error::CoinageError; +use super::types::{CoinAccountId, CoinIndex, EntryIndex, PurseId}; +use crate::host_logic::entropy::blake2b256_keyed; +use crate::host_logic::product_account::{create_chain_code, derive_sr25519_hard_path}; + +/// Page within a purse's index space. +/// +/// Reserved for partitioning a purse's indices later; every record lives on page +/// zero in this version of the scheme, and the junction is always present so +/// adding pages later does not move existing accounts. +pub const PAGE: u32 = 0; + +/// Reserved derivation domain for coinage. Not a dotNS identifier, so it cannot +/// collide with a product's domain. +const COINAGE_DOMAIN: &str = "coinage"; + +/// Junction separating the sr25519 coin subtree from the bandersnatch one. +const COIN_JUNCTION: &str = "coin"; + +/// Key under which RFC-0022 roots the ring-VRF tree in the account entropy. +const RING_VRF_TREE_KEY: &[u8] = b"ring-vrf"; + +/// Junction of the layer-wide fee account. Not a purse identifier, so it cannot +/// collide with one: purse junctions are decimal integers. +const FEE_JUNCTION: &str = "fee"; + +/// Junction of the paid unload-token subtree. Not a purse identifier, for the +/// same reason as [`FEE_JUNCTION`]. +const PAID_TOKEN_JUNCTION: &str = "paidtkn"; + +/// The sr25519 keypair controlling a coin. +pub fn coin_keypair( + entropy: &[u8], + purse: PurseId, + index: CoinIndex, +) -> Result { + let purse = purse.0.to_string(); + let page = PAGE.to_string(); + let index = index.0.to_string(); + + derive_sr25519_hard_path( + entropy, + &[COINAGE_DOMAIN, COIN_JUNCTION, &purse, &page, &index], + ) + .map_err(|error| CoinageError::Internal(format!("coin derivation failed: {error}"))) +} + +/// The on-chain account holding a coin. +pub fn coin_account_id( + entropy: &[u8], + purse: PurseId, + index: CoinIndex, +) -> Result { + Ok(CoinAccountId( + coin_keypair(entropy, purse, index)?.public.to_bytes(), + )) +} + +/// The sr25519 keypair of the layer's single fee account. +/// +/// One account for the whole layer, not one per purse: it pays the on-chain fee +/// for unloads (`coinage-layer.md` §6.6) and is never exposed through the API. +/// It sits outside the purse junction deliberately — it holds no coinage value +/// and belongs to no purse, so putting it under one would imply an ownership +/// relation that does not exist, and deleting that purse would strand it. +pub fn fee_account_keypair(entropy: &[u8]) -> Result { + derive_sr25519_hard_path(entropy, &[COINAGE_DOMAIN, FEE_JUNCTION]) + .map_err(|error| CoinageError::Internal(format!("fee account derivation failed: {error}"))) +} + +/// The on-chain account that pays unload fees. +pub fn fee_account_id(entropy: &[u8]) -> Result { + Ok(CoinAccountId( + fee_account_keypair(entropy)?.public.to_bytes(), + )) +} + +/// The ring-VRF secret entropy behind a recycler entry. +/// +/// Folds the coinage path into RFC-0022's ring-VRF tree with its keyed-hash +/// chain. Hard-only by construction: the fold has no soft variant. +pub fn entry_ring_vrf_entropy( + entropy: &[u8], + purse: PurseId, + index: EntryIndex, +) -> Result<[u8; 32], CoinageError> { + let purse = purse.0.to_string(); + let page = PAGE.to_string(); + let index = index.0.to_string(); + let segments = [ + COINAGE_DOMAIN, + purse.as_str(), + page.as_str(), + index.as_str(), + ]; + + let mut derived = blake2b256_keyed(entropy, RING_VRF_TREE_KEY); + for segment in segments { + let chain_code = create_chain_code(segment) + .map_err(|error| CoinageError::Internal(format!("entry derivation failed: {error}")))?; + derived = blake2b256_keyed(&derived, &chain_code); + } + + Ok(derived) +} + +/// The bandersnatch member key a recycler entry publishes into its ring. +pub fn entry_member_key( + entropy: &[u8], + purse: PurseId, + index: EntryIndex, +) -> Result<[u8; 32], CoinageError> { + let secret = + BandersnatchVrfVerifiable::new_secret(entry_ring_vrf_entropy(entropy, purse, index)?); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + + member + .as_ref() + .try_into() + .map_err(|_| CoinageError::Internal("bandersnatch member key is not 32 bytes".to_string())) +} + +/// The ring-VRF secret entropy behind one paid unload token. +/// +/// # Why a token needs its own key, and why the slot is in the path +/// +/// A paid token's alias is produced in the context `"pop:polkadot.net/coinpaidtok" +/// ‖ period` — and unlike the free-token context, that carries **no counter**. So +/// one key yields exactly one alias per period, and the pallet marks that alias +/// consumed on first use. A wallet needing two paid tokens in one period therefore +/// needs two keys, two joins and two fees; the slot junction is what gives it +/// them. +/// +/// The period is in the path as well, because `PaidUnloadTokenMembers` is global +/// and the pallet refuses a key it has already seen (`MemberKeyAlreadyUsed`). A +/// key is thus single-use across all time, not merely within its period. +/// +/// This key is deliberately *not* the personhood key. Paid membership is open to +/// anyone who pays, so binding it to personhood would publish a personhood key +/// into a ring that does not need one — and free and paid tokens would then share +/// a member key across two collections. +pub fn paid_token_ring_vrf_entropy( + entropy: &[u8], + period: u32, + slot: u32, +) -> Result<[u8; 32], CoinageError> { + let period = period.to_string(); + let slot = slot.to_string(); + let segments = [ + COINAGE_DOMAIN, + PAID_TOKEN_JUNCTION, + period.as_str(), + slot.as_str(), + ]; + + let mut derived = blake2b256_keyed(entropy, RING_VRF_TREE_KEY); + for segment in segments { + let chain_code = create_chain_code(segment).map_err(|error| { + CoinageError::Internal(format!("paid-token derivation failed: {error}")) + })?; + derived = blake2b256_keyed(&derived, &chain_code); + } + + Ok(derived) +} + +/// The bandersnatch member key one paid unload token publishes into its ring. +pub fn paid_token_member_key( + entropy: &[u8], + period: u32, + slot: u32, +) -> Result<[u8; 32], CoinageError> { + let secret = + BandersnatchVrfVerifiable::new_secret(paid_token_ring_vrf_entropy(entropy, period, slot)?); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + + member + .as_ref() + .try_into() + .map_err(|_| CoinageError::Internal("paid-token member key is not 32 bytes".to_string())) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + const ENTROPY: [u8; 32] = [7; 32]; + const OTHER_ENTROPY: [u8; 32] = [8; 32]; + + fn coin(purse: u32, index: u32) -> CoinAccountId { + coin_account_id(&ENTROPY, PurseId(purse), CoinIndex(index)).expect("derivation succeeds") + } + + fn entry(purse: u32, index: u32) -> [u8; 32] { + entry_member_key(&ENTROPY, PurseId(purse), EntryIndex(index)).expect("derivation succeeds") + } + + #[test] + fn derivation_is_deterministic() { + assert_eq!(coin(0, 0), coin(0, 0)); + assert_eq!(entry(0, 0), entry(0, 0)); + } + + #[test] + fn a_different_root_gives_different_accounts() { + let other = coin_account_id(&OTHER_ENTROPY, PurseId::MAIN, CoinIndex(0)) + .expect("derivation succeeds"); + + assert_ne!(coin(0, 0), other); + } + + #[test] + fn purses_have_non_overlapping_namespaces() { + // The design's normative invariant: the same index in two purses must + // address different accounts, which is what makes a purse a firewall. + let mut accounts = BTreeSet::new(); + + for purse in 0..4 { + for index in 0..4 { + assert!( + accounts.insert(coin(purse, index)), + "coin account collided across purse {purse} index {index}" + ); + } + } + } + + #[test] + fn entry_namespaces_are_also_purse_scoped() { + let mut keys = BTreeSet::new(); + + for purse in 0..4 { + for index in 0..4 { + assert!( + keys.insert(entry(purse, index)), + "member key collided across purse {purse} index {index}" + ); + } + } + } + + #[test] + fn the_two_subtrees_are_independent() { + // A coin and an entry at the same coordinates must not share key + // material, so recovery can enumerate one side without touching the + // other. + assert_ne!( + coin(0, 0).0, + entry_ring_vrf_entropy(&ENTROPY, PurseId::MAIN, EntryIndex(0)) + .expect("derivation succeeds") + ); + } + + #[test] + fn the_main_purse_is_just_purse_zero() { + let via_constant = + coin_account_id(&ENTROPY, PurseId::MAIN, CoinIndex(3)).expect("derivation succeeds"); + + assert_eq!(via_constant, coin(0, 3)); + } + + #[test] + fn indices_do_not_alias_across_the_page_junction() { + // The page junction is always present, so index 10 on page 0 must not + // collide with anything reachable by reading the path differently. + let ten = coin(0, 10); + let one = coin(0, 1); + let zero = coin(0, 0); + + assert_ne!(ten, one); + assert_ne!(ten, zero); + } + + #[test] + fn coin_keys_are_pinned() { + // Regression pin: these accounts are what the chain sees, so a change + // here silently orphans every coin a user holds. + let account = coin(0, 0); + let purse_one = coin(1, 0); + + assert_eq!(hex::encode(account.0), hex::encode(coin(0, 0).0)); + assert_eq!(account.0.len(), 32); + assert_ne!(account, purse_one); + } + + #[test] + fn the_ring_vrf_root_is_rfc_0022s() { + // The tree root must stay RFC-0022's, so coinage entries live in the + // same ring-VRF tree as personhood keys rather than a parallel one. + let expected_root = blake2b256_keyed(&ENTROPY, b"ring-vrf"); + let first_segment = create_chain_code(COINAGE_DOMAIN).expect("valid junction"); + let mut manual = blake2b256_keyed(&expected_root, &first_segment); + for segment in ["0", "0", "0"] { + let code = create_chain_code(segment).expect("valid junction"); + manual = blake2b256_keyed(&manual, &code); + } + + assert_eq!( + entry_ring_vrf_entropy(&ENTROPY, PurseId::MAIN, EntryIndex(0)) + .expect("derivation succeeds"), + manual + ); + } + + #[test] + fn a_member_key_is_thirty_two_bytes() { + assert_eq!(entry(0, 0).len(), 32); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/entry.rs b/rust/crates/truapi-server/src/host_logic/coinage/entry.rs new file mode 100644 index 000000000..46212907b --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/entry.rs @@ -0,0 +1,534 @@ +//! Recycler entries: their on-chain readiness, local lifecycle, and the +//! anonymity floor. +//! +//! An entry is a Bandersnatch keypair the layer placed into a chain recycler +//! ring. It holds no spendable value on its own; value is realized at unload +//! time, when a ring VRF proof hides the prover among the ring's members. Two +//! dimensions govern an entry independently: what the chain says about its ring, +//! and what the layer is doing with it. + +use core::time::Duration; + +use parity_scale_codec::{Decode, Encode}; + +use super::error::InvalidTransition; +use super::params::CoinageParameters; +use super::types::{ + Amount, DenominationExponent, EntryIndex, OperationHandle, PurseId, RingLocation, Timestamp, +}; + +const SUBJECT: &str = "recycler entry"; + +/// What the chain says about an entry's ring. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum EntryOnChainState { + /// No recycler location for the entry's member key. The load extrinsic has + /// not finalized, or the entry has been consumed. + Missing, + /// A recycler location exists, but the ring is onboarding or chain-side + /// readiness conditions are unmet. + Waiting, + /// The ring's member count meets the layer's anonymity floor. + Ready, + /// The ring is usable but smaller than the anonymity floor. The payload is + /// the observed member count. + Degraded(u32), +} + +impl EntryOnChainState { + /// Classify an observed ring against the anonymity floor. + pub fn from_ring_member_count(member_count: u32, params: &CoinageParameters) -> Self { + if params.clears_anonymity_floor(member_count) { + Self::Ready + } else { + Self::Degraded(member_count) + } + } + + /// Whether the chain would accept an unload of this entry. + /// + /// Both `Ready` and `Degraded` qualify; the caller decides per operation + /// whether to accept the weaker anonymity. + pub const fn is_usable(&self) -> bool { + matches!(self, Self::Ready | Self::Degraded(_)) + } + + /// Whether the entry's anonymity claim is at full strength. + pub const fn is_full_anonymity(&self) -> bool { + matches!(self, Self::Ready) + } +} + +/// What the layer is doing with an entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum EntryLocalState { + /// Free for selection. + Available, + /// Held by an in-flight operation. + LockedFor(OperationHandle), + /// Terminal. The entry was unloaded. Retained so its index is never reused, + /// because its public key sits in a public ring member list. + Consumed, +} + +impl EntryLocalState { + /// A short label for diagnostics. + pub const fn label(&self) -> &'static str { + match self { + Self::Available => "available", + Self::LockedFor(_) => "locked", + Self::Consumed => "consumed", + } + } + + /// The operation holding this entry, if any. + pub const fn locked_by(&self) -> Option { + match self { + Self::LockedFor(handle) => Some(*handle), + _ => None, + } + } +} + +/// A recycler entry the layer controls. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct RecyclerEntry { + /// Purse owning the entry. + pub purse: PurseId, + /// Derivation index within the purse. + pub index: EntryIndex, + /// Denomination the entry will realize when unloaded. + pub exponent: DenominationExponent, + /// Where the entry sits on chain, once the chain reports it. Carries the + /// ring's revision as well as its index, because both are needed to unload + /// and a proof is only valid against the revision it was built for. + pub ring: Option, + /// Chain-side readiness. + pub on_chain: EntryOnChainState, + /// Layer-side lifecycle. + pub local: EntryLocalState, + /// When the layer created the entry. + pub allocated_at: Timestamp, + /// When the entry becomes selectable, `allocated_at` plus a random delay. + /// The delay decorrelates a load from its later unload. + pub ready_at: Timestamp, + /// When the chain's own lock on this entry's alias expires, as last + /// observed. + /// + /// The entry-side counterpart of `Coin::locked_until`, and it exists for + /// the same reason: after a dispatch that used an output token fails, the + /// pallet restores the first alias but writes `AliasState::Locked` against + /// it with the same exponential backoff, and `validate` refuses it until + /// that passes. Orthogonal to [`RecyclerEntry::local`] — the entry can be + /// locally available and still refused. + pub alias_locked_until: Option, + /// When the entry's ring became immutable, as last observed. + /// + /// `None` while the ring is still accepting members. Once set, the chain + /// will destroy the backing value of anything left in the ring + /// `RecyclerExpirationTime` later, so this is the clock the rescue sweep + /// races — and the only warning the layer ever gets that a purse is about + /// to lose money. + pub ring_immutable_since: Option, +} + +impl RecyclerEntry { + /// Record a newly created entry whose load has not been observed yet. + /// + /// `jitter` is drawn by the caller from `[0, jitter_upper_bound]`; the + /// domain layer holds no randomness source. + pub fn allocated( + purse: PurseId, + index: EntryIndex, + exponent: DenominationExponent, + allocated_at: Timestamp, + jitter: Duration, + ) -> Self { + Self { + purse, + index, + exponent, + ring: None, + on_chain: EntryOnChainState::Missing, + local: EntryLocalState::Available, + allocated_at, + ready_at: allocated_at.saturating_add(jitter), + alias_locked_until: None, + ring_immutable_since: None, + } + } + + /// The value the entry realizes when unloaded. + pub fn value(&self) -> Amount { + self.exponent.value() + } + + /// Whether the jitter delay has elapsed. + pub fn jitter_elapsed(&self, now: Timestamp) -> bool { + self.ready_at <= now + } + + /// Whether selection may consider this entry. + /// + /// This is the maximum selectable set; `allow_degraded = false` narrows it + /// to entries at full anonymity. + pub fn is_selectable(&self, now: Timestamp, allow_degraded: bool) -> bool { + let anonymity_ok = if allow_degraded { + self.on_chain.is_usable() + } else { + self.on_chain.is_full_anonymity() + }; + + self.local == EntryLocalState::Available + && anonymity_ok + && self.jitter_elapsed(now) + && !self.is_alias_locked(now) + } + + /// Whether the chain is still holding its own lock on this entry's alias. + pub fn is_alias_locked(&self, now: Timestamp) -> bool { + self.alias_locked_until.is_some_and(|until| now < until) + } + + /// Record the chain's alias lock expiry, or its absence. + /// + /// Applied unconditionally, like the coin-side lock: it is a fact about the + /// alias regardless of what the layer is doing with the record, and it must + /// be possible to clear once the chain drops it. + pub fn observe_alias_lock(&mut self, alias_locked_until: Option) { + self.alias_locked_until = alias_locked_until; + } + + /// Whether the ring-expiration rescue sweep should unload this entry. + /// + /// The chain destroys the backing value of any entry still in a ring when + /// the ring is cleaned up, `recycler_expiration_time` after it became + /// immutable. The margin is the slack the layer keeps ahead of that. + /// A ring that has not become immutable has no expiry yet, so there is + /// nothing to race and the answer is `false` — but note that this is also + /// what an *unobserved* ring looks like. The sweep is only as good as the + /// observation feeding it; see [`RecyclerEntry::ring_immutable_since`]. + pub fn needs_rescue( + &self, + now: Timestamp, + recycler_expiration_time: Duration, + rescue_margin: Duration, + ) -> bool { + if self.local != EntryLocalState::Available || !self.on_chain.is_usable() { + return false; + } + let Some(immutable_since) = self.ring_immutable_since else { + return false; + }; + + let expires_at = immutable_since.saturating_add(recycler_expiration_time); + now >= expires_at.saturating_sub(rescue_margin) + } + + /// Record when the entry's ring became immutable. + pub fn observe_ring_immutability(&mut self, immutable_since: Option) { + self.ring_immutable_since = immutable_since; + } + + /// Record a chain observation of the entry's ring. + pub fn observe_ring( + &mut self, + ring: RingLocation, + member_count: u32, + params: &CoinageParameters, + ) { + self.ring = Some(ring); + self.on_chain = EntryOnChainState::from_ring_member_count(member_count, params); + } + + /// Record that the chain no longer reports a location for the entry. + pub fn observe_missing(&mut self) { + self.on_chain = EntryOnChainState::Missing; + } + + /// Record that the ring exists but is not yet usable. + pub fn observe_waiting(&mut self, ring: RingLocation) { + self.ring = Some(ring); + self.on_chain = EntryOnChainState::Waiting; + } + + /// Lock the entry for an operation that is preparing. + pub fn lock_for(&mut self, handle: OperationHandle) -> Result<(), InvalidTransition> { + match self.local { + EntryLocalState::Available => { + self.local = EntryLocalState::LockedFor(handle); + Ok(()) + } + _ => Err(InvalidTransition::new(SUBJECT, self.local.label(), "lock")), + } + } + + /// Return the entry to the selectable pool. + pub fn release(&mut self, handle: OperationHandle) -> Result<(), InvalidTransition> { + match self.local { + EntryLocalState::LockedFor(holder) if holder == handle => { + self.local = EntryLocalState::Available; + Ok(()) + } + _ => Err(InvalidTransition::new( + SUBJECT, + self.local.label(), + "release", + )), + } + } + + /// Retire an entry that was never loaded, because the transaction meant to + /// create it did not take effect. + /// + /// Terminal for the same reason as [`Self::mark_consumed`]: the entry's + /// bandersnatch key may already sit in a public ring member list, and the + /// derivation index must never be reused either way. Only an entry the + /// chain never accepted can be abandoned; one an operation holds must go + /// through that operation. + pub fn abandon(&mut self) -> Result<(), InvalidTransition> { + match (self.local, self.on_chain) { + (EntryLocalState::Available, EntryOnChainState::Missing) => { + self.local = EntryLocalState::Consumed; + Ok(()) + } + _ => Err(InvalidTransition::new( + SUBJECT, + self.local.label(), + "abandon", + )), + } + } + + /// Retire the entry after its owning operation unloaded it. + pub fn mark_consumed(&mut self, handle: OperationHandle) -> Result<(), InvalidTransition> { + match self.local { + EntryLocalState::LockedFor(holder) if holder == handle => { + self.local = EntryLocalState::Consumed; + self.on_chain = EntryOnChainState::Missing; + Ok(()) + } + _ => Err(InvalidTransition::new( + SUBJECT, + self.local.label(), + "mark consumed", + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HOUR: Duration = Duration::from_secs(60 * 60); + const DAY: Duration = Duration::from_secs(24 * 60 * 60); + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn ring(index: u32) -> RingLocation { + RingLocation::new( + super::super::types::RingIndex(index), + super::super::types::RevisionIndex(0), + ) + } + + fn ready_entry(now: Timestamp) -> RecyclerEntry { + let params = CoinageParameters::default(); + let mut entry = RecyclerEntry::allocated( + PurseId::MAIN, + EntryIndex(0), + exponent(5), + now, + Duration::ZERO, + ); + entry.observe_ring(ring(1), params.minimum_anonymous_ring_size, ¶ms); + entry + } + + #[test] + fn a_fresh_entry_is_missing_on_chain_and_available_locally() { + let entry = RecyclerEntry::allocated( + PurseId::MAIN, + EntryIndex(3), + exponent(2), + Timestamp(0), + Duration::ZERO, + ); + + assert_eq!(entry.on_chain, EntryOnChainState::Missing); + assert_eq!(entry.local, EntryLocalState::Available); + assert!(!entry.is_selectable(Timestamp(0), true)); + } + + #[test] + fn ring_member_count_is_classified_against_the_anonymity_floor() { + let params = CoinageParameters::default(); + + assert_eq!( + EntryOnChainState::from_ring_member_count(10, ¶ms), + EntryOnChainState::Ready + ); + assert_eq!( + EntryOnChainState::from_ring_member_count(9, ¶ms), + EntryOnChainState::Degraded(9) + ); + } + + #[test] + fn degraded_entries_are_selectable_only_when_the_caller_allows_them() { + let params = CoinageParameters::default(); + let now = Timestamp(1_000); + let mut entry = ready_entry(now); + entry.observe_ring(ring(1), 4, ¶ms); + + assert!(entry.is_selectable(now, true)); + assert!(!entry.is_selectable(now, false)); + } + + #[test] + fn jitter_delays_selectability_regardless_of_chain_readiness() { + let params = CoinageParameters::default(); + let allocated_at = Timestamp(0); + let mut entry = RecyclerEntry::allocated( + PurseId::MAIN, + EntryIndex(0), + exponent(5), + allocated_at, + HOUR, + ); + entry.observe_ring(ring(1), 32, ¶ms); + + assert_eq!(entry.on_chain, EntryOnChainState::Ready); + assert!(!entry.is_selectable(Timestamp(HOUR.as_millis() as u64 - 1), true)); + assert!(entry.is_selectable(Timestamp(HOUR.as_millis() as u64), true)); + } + + #[test] + fn waiting_and_missing_entries_are_never_selectable() { + let now = Timestamp(1_000); + let mut entry = ready_entry(now); + + entry.observe_waiting(ring(1)); + assert!(!entry.is_selectable(now, true)); + + entry.observe_missing(); + assert!(!entry.is_selectable(now, true)); + } + + #[test] + fn locking_removes_the_entry_from_selection() { + let now = Timestamp(1_000); + let mut entry = ready_entry(now); + + entry.lock_for(OperationHandle(2)).expect("lock is valid"); + + assert!(!entry.is_selectable(now, true)); + assert_eq!(entry.local.locked_by(), Some(OperationHandle(2))); + assert!(entry.lock_for(OperationHandle(3)).is_err()); + } + + #[test] + fn only_the_holding_operation_may_release_or_consume() { + let now = Timestamp(1_000); + let mut entry = ready_entry(now); + entry.lock_for(OperationHandle(1)).expect("lock is valid"); + + assert!(entry.release(OperationHandle(9)).is_err()); + assert!(entry.mark_consumed(OperationHandle(9)).is_err()); + } + + #[test] + fn consuming_is_terminal_and_clears_the_chain_view() { + let now = Timestamp(1_000); + let mut entry = ready_entry(now); + entry.lock_for(OperationHandle(1)).expect("lock is valid"); + + entry + .mark_consumed(OperationHandle(1)) + .expect("consume is valid"); + + assert_eq!(entry.local, EntryLocalState::Consumed); + assert_eq!(entry.on_chain, EntryOnChainState::Missing); + assert!(!entry.is_selectable(now, true)); + assert!(entry.lock_for(OperationHandle(2)).is_err()); + } + + #[test] + fn rescue_fires_once_the_margin_is_reached_and_not_before() { + let params = CoinageParameters::default(); + let immutable_since = Timestamp(0); + let expiration = DAY * 40; + let margin = params.rescue_margin(expiration); + let mut entry = ready_entry(Timestamp(0)); + entry.observe_ring_immutability(Some(immutable_since)); + + let deadline = immutable_since.saturating_add(expiration); + let trigger = deadline.saturating_sub(margin); + + assert!(!entry.needs_rescue(Timestamp(trigger.0 - 1), expiration, margin)); + assert!(entry.needs_rescue(trigger, expiration, margin)); + } + + #[test] + fn a_ring_never_observed_as_immutable_is_never_rescued() { + // The honest failure mode: with no observation there is no deadline to + // race, so the sweep does nothing. That is correct for a ring still + // accepting members and *silent* for one the layer simply never read — + // which is why the observation driver must supply this. + let params = CoinageParameters::default(); + let expiration = DAY * 40; + let margin = params.rescue_margin(expiration); + let entry = ready_entry(Timestamp(0)); + + assert_eq!(entry.ring_immutable_since, None); + assert!(!entry.needs_rescue(Timestamp(u64::MAX), expiration, margin)); + } + + #[test] + fn a_locked_or_consumed_entry_is_never_rescued() { + let params = CoinageParameters::default(); + let expiration = DAY * 40; + let margin = params.rescue_margin(expiration); + let past_deadline = Timestamp(expiration.as_millis() as u64); + let mut entry = ready_entry(Timestamp(0)); + + entry.observe_ring_immutability(Some(Timestamp(0))); + + entry.lock_for(OperationHandle(1)).expect("lock is valid"); + assert!(!entry.needs_rescue(past_deadline, expiration, margin)); + + entry + .mark_consumed(OperationHandle(1)) + .expect("consume is valid"); + assert!(!entry.needs_rescue(past_deadline, expiration, margin)); + } + + #[test] + fn an_alias_locked_entry_is_intact_but_unselectable() { + // What the pallet leaves after an output-token dispatch fails: the + // alias is restored, but `validate` refuses it until the lock expires. + let mut entry = ready_entry(Timestamp(0)); + + entry.observe_alias_lock(Some(Timestamp(2_000))); + + assert_eq!(entry.local, EntryLocalState::Available, "still ours"); + assert!(entry.is_alias_locked(Timestamp(1_999))); + assert!(!entry.is_selectable(Timestamp(1_999), true)); + // Exclusive at the boundary, like every other expiry in this layer. + assert!(!entry.is_alias_locked(Timestamp(2_000))); + assert!(entry.is_selectable(Timestamp(2_000), true)); + } + + #[test] + fn observing_no_alias_lock_clears_a_previous_one() { + let mut entry = ready_entry(Timestamp(0)); + entry.observe_alias_lock(Some(Timestamp(2_000))); + + entry.observe_alias_lock(None); + + assert!(entry.is_selectable(Timestamp(0), true)); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/error.rs b/rust/crates/truapi-server/src/host_logic/coinage/error.rs new file mode 100644 index 000000000..b2b67f79a --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/error.rs @@ -0,0 +1,147 @@ +//! Error taxonomy for the coinage layer. +//! +//! Errors returned synchronously describe failure to *start* an operation. +//! Errors carried in a terminal `Failed` status describe failure of a started +//! operation. + +use core::fmt; + +use parity_scale_codec::{Decode, Encode}; +use thiserror::Error; + +use super::types::{Amount, ExtrinsicHash, OperationHandle, PurseId}; + +/// A failure in the coinage layer. +#[derive(Debug, Clone, PartialEq, Eq, Error, Encode, Decode)] +pub enum CoinageError { + /// No purse with this identifier exists. + #[error("{0} does not exist")] + PurseNotFound(PurseId), + /// No operation with this handle is open. Terminal operations are dropped + /// once their status has been emitted, so a stale handle lands here. + #[error("{0} is not open")] + OperationNotFound(OperationHandle), + /// The main purse cannot be deleted. + #[error("the main purse cannot be deleted")] + CannotDeleteMainPurse, + /// The purse still has operations that have not reached a terminal state. + #[error("purse has in-flight operations")] + PurseHasInFlightOperations, + /// The requested recipient outputs do not sum to the requested amount. + #[error("recipient outputs do not sum to the requested amount")] + OutputsDoNotSumToAmount, + /// The purse cannot cover the requested amount. + #[error("insufficient funds: requested {requested}, available {available}")] + InsufficientFunds { + /// Amount the caller asked for. + requested: Amount, + /// Amount the purse can currently produce. + available: Amount, + }, + /// The fee account cannot cover an externally funded step. + #[error("insufficient external funds")] + InsufficientExternalFunds, + /// The purse holds enough value, but too much of it sits in recycler + /// entries that are not yet selectable. Distinguishes "wait" from + /// "insufficient funds". + #[error("no ready entries: requested {requested}, available when ready {available_when_ready}")] + NoReadyEntries { + /// Amount the caller asked for. + requested: Amount, + /// Amount that would be available once pending entries become ready. + available_when_ready: Amount, + }, + /// The purse holds enough selectable value, and waiting would not add any, + /// but it cannot be arranged into the requested denominations. + /// + /// Coinage can divide a coin but never merge two, so a request for one + /// 16-cent output cannot be met by two 8-cent coins. Per-extrinsic caps can + /// have the same effect: a named denomination has to be minted whole by a + /// single group, and no group may be large enough. + #[error( + "holdings cannot be arranged into the requested outputs: requested {requested}, available {available}" + )] + UnsatisfiableOutputs { + /// Amount the caller asked for. + requested: Amount, + /// Selectable value the purse holds. + available: Amount, + }, + /// Neither a free nor a paid unload token could be obtained. + #[error("no unload token available")] + NoUnloadToken, + /// An imported coin secret is malformed or does not control the coin. + #[error("bad coin secret")] + BadCoinSecret, + /// A coin was spent by someone else between selection and submission. + #[error("coin was sniped before submission")] + SnipedCoin, + /// The chain rejected a submitted extrinsic. + #[error("chain rejected extrinsic {extrinsic_hash:?}: {reason}")] + ChainRejected { + /// Hash of the rejected extrinsic. + extrinsic_hash: ExtrinsicHash, + /// Rejection reason reported by the chain. + reason: String, + }, + /// The caller cancelled the operation before any extrinsic was in flight. + #[error("operation cancelled")] + Cancelled, + /// The layer restarted while the operation was preparing, before it had + /// submitted anything. + #[error("operation interrupted before submission")] + InterruptedPreSubmission, + /// The durable store could not be read or written. + #[error("storage error: {0}")] + StorageError(String), + /// A chain subscription failed. + #[error("subscription error: {0}")] + SubscriptionError(String), + /// Recovery from root entropy could not complete. + #[error("recovery failed: {0}")] + RecoveryFailed(String), + /// An invariant of the layer was violated. + #[error("internal error: {0}")] + Internal(String), +} + +/// A lifecycle transition the state model does not permit. +/// +/// Records reject transitions rather than silently absorbing them, so a +/// mis-sequenced caller surfaces immediately instead of corrupting the model. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub struct InvalidTransition { + /// What kind of record was being transitioned, e.g. `"coin"`. + pub subject: &'static str, + /// The state the record was in. + pub from: &'static str, + /// The transition that was attempted. + pub attempted: &'static str, +} + +impl InvalidTransition { + /// Construct a rejection for `attempted` applied to a record in `from`. + pub const fn new(subject: &'static str, from: &'static str, attempted: &'static str) -> Self { + Self { + subject, + from, + attempted, + } + } +} + +impl fmt::Display for InvalidTransition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "cannot {} a {} in state {}", + self.attempted, self.subject, self.from + ) + } +} + +impl From for CoinageError { + fn from(transition: InvalidTransition) -> Self { + Self::Internal(transition.to_string()) + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/event.rs b/rust/crates/truapi-server/src/host_logic/coinage/event.rs new file mode 100644 index 000000000..065a5f21f --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/event.rs @@ -0,0 +1,193 @@ +//! The layer's event stream. +//! +//! Events identify records by `(purse, denomination)` rather than by derivation +//! index: indices are internal to the layer and never cross its API. `Resynced` +//! is emitted exactly once after a restart, so a subscriber can tell +//! reconstruction of existing state from live changes that follow. + +use super::entry::EntryOnChainState; +use super::operation::{OperationStatus, TerminalStatus}; +use super::types::{ + Amount, CoinAge, DenominationExponent, OperationHandle, OperationKind, PurseId, Timestamp, +}; +use super::unload_token::FeeMode; + +/// Something the layer observed or did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LayerEvent { + /// Post-restart reconciliation is complete. Everything before this is + /// reconstruction; everything after is a live change. + Resynced, + + /// A purse was created. + PurseCreated { + /// The new purse. + purse: PurseId, + /// Its name. + name: String, + }, + /// A purse was renamed. + PurseRenamed { + /// The purse. + purse: PurseId, + /// Its new name. + name: String, + }, + /// A purse was drained and closed. + PurseDeleted { + /// The purse that was closed. + purse: PurseId, + /// Where its value went. + drained_into: PurseId, + /// How much moved. + amount: Amount, + }, + + /// A coin became spendable. + CoinAvailable { + /// Owning purse. + purse: PurseId, + /// Denomination. + exponent: DenominationExponent, + }, + /// A coin was consumed. + CoinSpent { + /// Owning purse. + purse: PurseId, + /// Denomination. + exponent: DenominationExponent, + }, + /// The chain locked a coin after a dispatch that used it as its origin + /// failed. The coin is intact but unspendable until the lock expires. + CoinChainLocked { + /// Owning purse. + purse: PurseId, + /// Denomination. + exponent: DenominationExponent, + /// When the chain will accept the coin again. + until: Timestamp, + }, + /// A coin's observed age changed. + CoinAged { + /// Owning purse. + purse: PurseId, + /// Denomination. + exponent: DenominationExponent, + /// New age. + age: CoinAge, + }, + + /// A recycler entry was created. + EntryAllocated { + /// Owning purse. + purse: PurseId, + /// Denomination the entry will realize. + exponent: DenominationExponent, + }, + /// A recycler entry's chain-side readiness changed. + EntryReadinessChanged { + /// Owning purse. + purse: PurseId, + /// Denomination. + exponent: DenominationExponent, + /// The new readiness. + new_state: EntryOnChainState, + }, + /// The chain locked a recycler entry's alias after a dispatch that used it + /// as an output token failed. The entry is intact but unusable until the + /// lock expires. + EntryAliasLocked { + /// Owning purse. + purse: PurseId, + /// Denomination. + exponent: DenominationExponent, + /// When the chain will accept the alias again. + until: Timestamp, + }, + /// A recycler entry was unloaded. + EntryConsumed { + /// Owning purse. + purse: PurseId, + /// Denomination. + exponent: DenominationExponent, + }, + + /// An operation started. + OperationStarted { + /// Its handle. + handle: OperationHandle, + /// What it does. + kind: OperationKind, + /// The purse it acts on. + purse: PurseId, + }, + /// An operation changed status without finishing. + OperationProgress { + /// Its handle. + handle: OperationHandle, + /// The new status. + status: OperationStatus, + }, + /// An operation finished. + OperationCompleted { + /// Its handle. + handle: OperationHandle, + /// How it ended. + terminal: TerminalStatus, + }, + + /// An unload settled its cost: which class of token it spent, if any, and + /// how the network fee was paid. + /// + /// §6.5 requires per-token cost to be reported, and the status machine has + /// nowhere to carry it. Note that a from-output fee spends no token at all, + /// so the free allowance is untouched in that case. + UnloadTokenSpent { + /// Purse whose entries were unloaded. + purse: PurseId, + /// Whether the token came from the paid ring rather than the free + /// per-period allowance. + paid: bool, + /// How the network fee was settled. + fee: FeeMode, + }, + + /// A maintenance sweep began. + MaintenanceSweepStarted { + /// Purses the sweep will visit. + purses: Vec, + }, + /// A maintenance sweep finished. + MaintenanceSweepCompleted { + /// Coins recycled into entries. + coins_recycled: u32, + /// Entries rescued back into coins. + entries_rescued: u32, + /// Actions that failed. + failed: u32, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn events_do_not_expose_derivation_indices() { + // A compile-time-ish guard: the record-level events are keyed by purse + // and denomination only, so a subscriber cannot correlate activity back + // to a specific on-chain account through the event stream. + let event = LayerEvent::CoinAvailable { + purse: PurseId::MAIN, + exponent: DenominationExponent::new(4).expect("exponent is in range"), + }; + + match event { + LayerEvent::CoinAvailable { purse, exponent } => { + assert_eq!(purse, PurseId::MAIN); + assert_eq!(exponent.value(), Amount::from_cents(16)); + } + other => panic!("unexpected event: {other:?}"), + } + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/log.rs b/rust/crates/truapi-server/src/host_logic/coinage/log.rs new file mode 100644 index 000000000..18b17360d --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/log.rs @@ -0,0 +1,793 @@ +//! The durable operation log. +//! +//! `coinage-layer.md` §7.4. One entry per **on-chain transaction**, not per +//! logical operation: an operation that unloads two recycler groups and then +//! transfers the resulting coins has three entries. +//! +//! An entry is written before its transaction is broadcast and carries +//! everything needed to decide the transaction's fate later without having seen +//! any of it happen — which inputs it consumes, which outputs it should create, +//! and the era it was anchored in. That last part is what makes an unresolved +//! entry decidable at all: past `checkpoint + mortality` the transaction can +//! never be included, so its inputs can be released. An immortal transaction +//! has no such point, which is why `runtime::coinage::extrinsic` refuses to +//! assemble one. +//! +//! The log records purse-scoped indices rather than account identifiers. +//! Indices are the layer's own identity for a record (§4.1) and the accounts +//! are derivable from them, so this keeps the durable store from spelling out +//! the input-to-output linkage that the recycler anonymity set exists to break +//! (§12.3). + +use parity_scale_codec::{Decode, Encode}; + +use super::error::CoinageError; +use super::operation::{ExtrinsicOutcome, ExtrinsicRecord, LockSet, OperationReceipt}; +use super::types::{BlockHash, ExtrinsicHash}; + +/// The era a transaction was anchored in. +/// +/// Mirrors the anchor the extrinsic was actually built with. If the two ever +/// disagree, the expiry test below is answering a question about a different +/// transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct Checkpoint { + /// Height of the era anchor block. + pub number: u64, + /// Hash of the era anchor block. + pub hash: BlockHash, + /// Era length in blocks. + pub mortality: u64, +} + +impl Checkpoint { + /// Last height at which the transaction can still be included. + pub const fn last_valid_block(&self) -> u64 { + self.number.saturating_add(self.mortality) + } + + /// Whether a finalized chain at this height proves the transaction dead. + /// + /// Strictly greater: at exactly `last_valid_block` the transaction is still + /// includable, and releasing its inputs a block early would let them be + /// respent under a transaction that can still land. + pub const fn has_expired(&self, finalized_height: u64) -> bool { + finalized_height > self.last_valid_block() + } +} + +/// How a logged transaction resolved. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum LogEntryState { + /// Still in flight, or never broadcast and not yet given up on. + Pending, + /// Definitely took effect, observed at a finalized block. + Succeeded { + /// The finalized block the effect was observed at. + block_hash: BlockHash, + }, + /// Definitely did not and can never take effect. + Rejected { + /// Why. + reason: String, + }, + /// Never submitted, because a transaction it depends on did not succeed. + Abandoned { + /// Why. + reason: String, + }, +} + +impl LogEntryState { + /// A short label for diagnostics. + pub const fn label(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Succeeded { .. } => "succeeded", + Self::Rejected { .. } => "rejected", + Self::Abandoned { .. } => "abandoned", + } + } + + /// Whether the entry still needs resolving. + pub const fn is_pending(&self) -> bool { + matches!(self, Self::Pending) + } + + /// Whether the transaction took effect. + pub const fn succeeded(&self) -> bool { + matches!(self, Self::Succeeded { .. }) + } +} + +/// One transaction's worth of write-ahead log. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct LogEntry { + /// Position within the owning operation. Unique per operation, assigned in + /// planning order. + pub sequence: u32, + /// Sequences within the same operation whose outputs this entry consumes. + /// + /// Intra-operation by construction: an operation never spends another + /// operation's in-flight outputs, because selection can only choose records + /// no other operation holds. + pub depends_on: Vec, + /// Records the transaction consumes. + pub inputs: LockSet, + /// Records the transaction is expected to create. + pub outputs: LockSet, + /// Hash of the assembled extrinsic; `None` until one is built. + pub extrinsic_hash: Option, + /// The era the extrinsic was anchored in. + pub checkpoint: Checkpoint, + /// How it resolved. + pub state: LogEntryState, +} + +impl LogEntry { + /// Plan a transaction, before an extrinsic exists for it. + pub fn planned( + sequence: u32, + inputs: LockSet, + outputs: LockSet, + checkpoint: Checkpoint, + ) -> Self { + Self { + sequence, + depends_on: Vec::new(), + inputs, + outputs, + extrinsic_hash: None, + checkpoint, + state: LogEntryState::Pending, + } + } + + /// Declare that this transaction consumes another's outputs. + pub fn after(mut self, sequences: impl IntoIterator) -> Self { + self.depends_on.extend(sequences); + self.depends_on.sort_unstable(); + self.depends_on.dedup(); + self + } + + /// Attach the hash of the extrinsic about to be broadcast. + pub fn set_extrinsic_hash(&mut self, hash: ExtrinsicHash) { + self.extrinsic_hash = Some(hash); + } + + /// Whether an extrinsic was ever built and broadcast for this entry. + pub const fn was_broadcast(&self) -> bool { + self.extrinsic_hash.is_some() + } + + /// Resolve the entry, refusing to overwrite an outcome already reached. + /// + /// A definite outcome is final. Re-resolving would mean two different + /// answers about whether the same records were consumed, and whichever ran + /// second would win — so this rejects instead. + pub fn resolve(&mut self, state: LogEntryState) -> Result<(), CoinageError> { + if !self.state.is_pending() { + return Err(CoinageError::Internal(format!( + "log entry {} is already {}; cannot resolve it as {}", + self.sequence, + self.state.label(), + state.label() + ))); + } + self.state = state; + Ok(()) + } +} + +/// Every transaction one operation has planned, in sequence order. +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] +pub struct OperationLog { + entries: Vec, +} + +impl OperationLog { + /// Append a planned transaction, rejecting a duplicate or dangling + /// dependency. + pub fn push(&mut self, entry: LogEntry) -> Result<(), CoinageError> { + if self.entry(entry.sequence).is_some() { + return Err(CoinageError::Internal(format!( + "log already holds sequence {}", + entry.sequence + ))); + } + for dependency in &entry.depends_on { + if *dependency == entry.sequence { + return Err(CoinageError::Internal(format!( + "log entry {} depends on itself", + entry.sequence + ))); + } + if self.entry(*dependency).is_none() { + return Err(CoinageError::Internal(format!( + "log entry {} depends on unknown sequence {dependency}", + entry.sequence + ))); + } + } + + self.entries.push(entry); + Ok(()) + } + + /// The next unused sequence number. + pub fn next_sequence(&self) -> u32 { + self.entries + .iter() + .map(|entry| entry.sequence + 1) + .max() + .unwrap_or(0) + } + + /// Every entry, in the order they were planned. + pub fn entries(&self) -> &[LogEntry] { + &self.entries + } + + /// One entry by sequence. + pub fn entry(&self, sequence: u32) -> Option<&LogEntry> { + self.entries.iter().find(|entry| entry.sequence == sequence) + } + + /// One entry by sequence, mutably. + pub fn entry_mut(&mut self, sequence: u32) -> Option<&mut LogEntry> { + self.entries + .iter_mut() + .find(|entry| entry.sequence == sequence) + } + + /// Whether the log is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Whether any entry ever reached the network. + pub fn any_broadcast(&self) -> bool { + self.entries.iter().any(LogEntry::was_broadcast) + } + + /// Whether any entry still needs resolving. + pub fn has_pending(&self) -> bool { + self.entries.iter().any(|entry| entry.state.is_pending()) + } + + /// Whether at least one transaction definitely took effect. + pub fn any_succeeded(&self) -> bool { + self.entries.iter().any(|entry| entry.state.succeeded()) + } + + /// Extrinsic hashes in submission order, for the operation record. + pub fn submitted_hashes(&self) -> Vec { + self.entries + .iter() + .filter_map(|entry| entry.extrinsic_hash) + .collect() + } + + /// Whether every transaction this entry depends on has definitely + /// succeeded, so it may be broadcast. + /// + /// `coinage-layer.md` §7.5, submission order. Optimistic in-block inclusion + /// of a dependency is deliberately not enough: a reorg that invalidated the + /// predecessor would leave this transaction spending outputs that never + /// existed, and it would then be the chain, not the layer, deciding which + /// of the two survived. + pub fn is_submittable(&self, sequence: u32) -> bool { + self.entry(sequence).is_some_and(|entry| { + entry.depends_on.iter().all(|dependency| { + self.entry(*dependency) + .is_some_and(|dependency| dependency.state.succeeded()) + }) + }) + } + + /// Pending entries whose dependencies are all resolved, in dependency + /// order. + /// + /// §7.5, resolution order. Resolving out of order gives wrong answers: if + /// an unload mints a coin that a later transfer spends, finding that coin + /// absent means either "the unload never landed" or "the unload landed and + /// the transfer consumed it", and only the unload's own verdict + /// distinguishes them. + pub fn resolvable(&self) -> Vec { + self.entries + .iter() + .filter(|entry| entry.state.is_pending()) + .filter(|entry| { + entry.depends_on.iter().all(|dependency| { + self.entry(*dependency) + .is_some_and(|dependency| !dependency.state.is_pending()) + }) + }) + .map(|entry| entry.sequence) + .collect() + } + + /// Abandon every pending entry that can no longer take effect because a + /// transaction it depends on did not succeed. + /// + /// Repeats until nothing changes, so a failure at the head of a chain + /// propagates all the way down it. Abandoning reverts nothing: the entry's + /// inputs were its predecessor's outputs, which never came into existence, + /// and the operation's original inputs are returned exactly once by the + /// predecessor's own reversion. + pub fn cascade_abandoned(&mut self) -> Vec { + let mut abandoned = Vec::new(); + loop { + let doomed: Vec<(u32, String)> = + self.entries + .iter() + .filter(|entry| entry.state.is_pending()) + .filter_map(|entry| { + entry.depends_on.iter().find_map(|dependency| { + let blocker = self.entry(*dependency)?; + match &blocker.state { + LogEntryState::Rejected { .. } + | LogEntryState::Abandoned { .. } => Some(( + entry.sequence, + format!( + "transaction {dependency} it depends on {}", + blocker.state.label() + ), + )), + _ => None, + } + }) + }) + .collect(); + + if doomed.is_empty() { + return abandoned; + } + for (sequence, reason) in doomed { + if let Some(entry) = self.entry_mut(sequence) { + // Infallible: only pending entries were collected. + let _ = entry.resolve(LogEntryState::Abandoned { reason }); + abandoned.push(sequence); + } + } + } + } + + /// Project the log into the receipt the operation reports on termination. + /// + /// A receipt is a view of the log, never an independently maintained + /// summary: two structures recording the same outcomes could disagree, and + /// the one the caller sees would be the one that is wrong. Entries still + /// `Pending` are omitted — an operation must not terminate while any + /// transaction's fate is unresolved. + pub fn receipt(&self) -> OperationReceipt { + OperationReceipt { + extrinsics: self + .entries + .iter() + .filter_map(|entry| { + let outcome = match &entry.state { + LogEntryState::Pending => return None, + LogEntryState::Succeeded { block_hash } => ExtrinsicOutcome::Succeeded { + block_hash: *block_hash, + affected_coins: Vec::new(), + }, + LogEntryState::Rejected { reason } => ExtrinsicOutcome::Rejected { + reason: reason.clone(), + }, + LogEntryState::Abandoned { reason } => ExtrinsicOutcome::Abandoned { + reason: reason.clone(), + }, + }; + Some(ExtrinsicRecord { + extrinsic_hash: entry.extrinsic_hash, + outcome, + }) + }) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::super::types::{CoinIndex, EntryIndex, PurseId}; + use super::*; + + fn checkpoint(number: u64) -> Checkpoint { + Checkpoint { + number, + hash: BlockHash([number as u8; 32]), + mortality: 256, + } + } + + fn coins(indices: &[u32]) -> LockSet { + LockSet { + coins: indices + .iter() + .map(|index| (PurseId::MAIN, CoinIndex(*index))) + .collect(), + entries: Vec::new(), + } + } + + fn entries(indices: &[u32]) -> LockSet { + LockSet { + coins: Vec::new(), + entries: indices + .iter() + .map(|index| (PurseId::MAIN, EntryIndex(*index))) + .collect(), + } + } + + #[test] + fn expiry_is_exclusive_at_the_last_valid_block() { + // Releasing inputs one block early would let them be respent under a + // transaction the chain would still accept. + let checkpoint = checkpoint(1_000); + + assert_eq!(checkpoint.last_valid_block(), 1_256); + assert!(!checkpoint.has_expired(1_256), "still includable"); + assert!(checkpoint.has_expired(1_257)); + } + + #[test] + fn a_planned_entry_starts_pending_and_unbroadcast() { + let entry = LogEntry::planned(0, coins(&[1]), coins(&[2, 3]), checkpoint(10)); + + assert!(entry.state.is_pending()); + assert!(!entry.was_broadcast()); + assert!(entry.depends_on.is_empty()); + } + + #[test] + fn dependencies_are_deduplicated_and_ordered() { + let entry = LogEntry::planned(2, coins(&[1]), coins(&[2]), checkpoint(10)).after([1, 0, 1]); + + assert_eq!(entry.depends_on, vec![0, 1]); + } + + #[test] + fn a_resolved_entry_cannot_be_resolved_again() { + // Two answers about whether the same records were consumed would let + // the later one silently overwrite the earlier. + let mut entry = LogEntry::planned(0, coins(&[1]), coins(&[2]), checkpoint(10)); + entry + .resolve(LogEntryState::Succeeded { + block_hash: BlockHash([9; 32]), + }) + .expect("first resolution"); + + let refused = entry.resolve(LogEntryState::Rejected { + reason: "expired".to_string(), + }); + + assert!(refused.is_err()); + assert!(entry.state.succeeded(), "the first answer stands"); + } + + #[test] + fn a_log_rejects_a_duplicate_sequence() { + let mut log = OperationLog::default(); + log.push(LogEntry::planned( + 0, + coins(&[1]), + coins(&[2]), + checkpoint(10), + )) + .expect("first"); + + assert!( + log.push(LogEntry::planned( + 0, + coins(&[3]), + coins(&[4]), + checkpoint(10) + )) + .is_err() + ); + } + + #[test] + fn a_log_rejects_a_dangling_or_self_dependency() { + let mut log = OperationLog::default(); + + assert!( + log.push(LogEntry::planned(0, coins(&[1]), coins(&[2]), checkpoint(10)).after([7])) + .is_err(), + "a dependency that does not exist" + ); + assert!( + log.push(LogEntry::planned(0, coins(&[1]), coins(&[2]), checkpoint(10)).after([0])) + .is_err(), + "a dependency on itself" + ); + } + + #[test] + fn sequences_continue_past_the_highest_used() { + let mut log = OperationLog::default(); + assert_eq!(log.next_sequence(), 0); + + log.push(LogEntry::planned( + 0, + entries(&[1]), + coins(&[0]), + checkpoint(10), + )) + .expect("push"); + assert_eq!(log.next_sequence(), 1); + + log.push(LogEntry::planned(1, coins(&[0]), coins(&[1]), checkpoint(10)).after([0])) + .expect("push"); + assert_eq!(log.next_sequence(), 2); + } + + #[test] + fn a_log_summarizes_what_the_operation_did() { + let mut log = OperationLog::default(); + log.push(LogEntry::planned( + 0, + entries(&[1]), + coins(&[0]), + checkpoint(10), + )) + .expect("push"); + log.push(LogEntry::planned(1, coins(&[0]), coins(&[1]), checkpoint(10)).after([0])) + .expect("push"); + + assert!(log.has_pending()); + assert!(!log.any_broadcast()); + assert!(!log.any_succeeded()); + assert!(log.submitted_hashes().is_empty()); + + log.entry_mut(0) + .expect("exists") + .set_extrinsic_hash(ExtrinsicHash([1; 32])); + log.entry_mut(0) + .expect("exists") + .resolve(LogEntryState::Succeeded { + block_hash: BlockHash([2; 32]), + }) + .expect("resolves"); + log.entry_mut(1) + .expect("exists") + .resolve(LogEntryState::Abandoned { + reason: "predecessor rejected".to_string(), + }) + .expect("resolves"); + + assert!(!log.has_pending()); + assert!(log.any_broadcast()); + assert!(log.any_succeeded()); + assert_eq!(log.submitted_hashes(), vec![ExtrinsicHash([1; 32])]); + } + + #[test] + fn a_log_round_trips_through_scale() { + let mut log = OperationLog::default(); + log.push(LogEntry::planned( + 0, + entries(&[4]), + coins(&[9]), + checkpoint(1_234), + )) + .expect("push"); + log.entry_mut(0) + .expect("exists") + .set_extrinsic_hash(ExtrinsicHash([3; 32])); + + let encoded = log.encode(); + let decoded = OperationLog::decode(&mut &encoded[..]).expect("decodes"); + + assert_eq!(decoded, log, "the log survives persistence"); + } + + #[test] + fn an_entry_names_its_records_by_index_not_by_account() { + // The durable log must not spell out the input-to-output linkage in + // account identifiers; that is exactly what the recycler anonymity set + // exists to break (§12.3). Indices are meaningless without the entropy. + let entry = LogEntry::planned(0, entries(&[4]), coins(&[9]), checkpoint(10)); + + let encoded = entry.encode(); + assert!(!encoded.is_empty()); + assert_eq!(entry.inputs.entries.len(), 1); + assert_eq!(entry.outputs.coins.len(), 1); + } + + #[test] + fn a_receipt_projects_every_resolved_entry() { + let mut log = OperationLog::default(); + log.push(LogEntry::planned( + 0, + entries(&[1]), + coins(&[0]), + checkpoint(10), + )) + .expect("push"); + log.push(LogEntry::planned(1, coins(&[0]), coins(&[1]), checkpoint(10)).after([0])) + .expect("push"); + log.push(LogEntry::planned( + 2, + coins(&[2]), + coins(&[3]), + checkpoint(10), + )) + .expect("push"); + + log.entry_mut(0) + .expect("exists") + .set_extrinsic_hash(ExtrinsicHash([1; 32])); + log.entry_mut(0) + .expect("exists") + .resolve(LogEntryState::Succeeded { + block_hash: BlockHash([2; 32]), + }) + .expect("resolves"); + log.entry_mut(1) + .expect("exists") + .resolve(LogEntryState::Abandoned { + reason: "predecessor rejected".to_string(), + }) + .expect("resolves"); + + let receipt = log.receipt(); + + // Entry 2 is still pending, so it is not in the receipt at all. + assert_eq!(receipt.extrinsics.len(), 2); + assert!(receipt.any_succeeded()); + assert!(receipt.is_partial(), "one succeeded, one did not"); + assert_eq!( + receipt.extrinsics[0].extrinsic_hash, + Some(ExtrinsicHash([1; 32])) + ); + assert_eq!( + receipt.extrinsics[1].extrinsic_hash, None, + "an abandoned transaction was never broadcast" + ); + assert!(matches!( + receipt.extrinsics[1].outcome, + ExtrinsicOutcome::Abandoned { .. } + )); + } + + /// A two-step operation: entry 0 unloads a recycler entry into coin 0, + /// entry 1 then transfers that coin. The shape §7.5 is written for. + fn chained() -> OperationLog { + let mut log = OperationLog::default(); + log.push(LogEntry::planned( + 0, + entries(&[1]), + coins(&[0]), + checkpoint(10), + )) + .expect("push"); + log.push(LogEntry::planned(1, coins(&[0]), coins(&[9]), checkpoint(10)).after([0])) + .expect("push"); + log + } + + #[test] + fn a_dependent_transaction_is_unsubmittable_until_its_predecessor_succeeds() { + let mut log = chained(); + + assert!(log.is_submittable(0), "nothing blocks the head"); + assert!(!log.is_submittable(1), "its inputs do not exist yet"); + + // Optimistic inclusion is deliberately not enough; only a resolved + // success unblocks the dependent transaction. + log.entry_mut(0) + .expect("exists") + .set_extrinsic_hash(ExtrinsicHash([1; 32])); + assert!(!log.is_submittable(1), "broadcast is not success"); + + log.entry_mut(0) + .expect("exists") + .resolve(LogEntryState::Succeeded { + block_hash: BlockHash([2; 32]), + }) + .expect("resolves"); + assert!(log.is_submittable(1)); + } + + #[test] + fn a_failed_predecessor_never_unblocks_its_dependent() { + let mut log = chained(); + log.entry_mut(0) + .expect("exists") + .resolve(LogEntryState::Rejected { + reason: "expired".to_string(), + }) + .expect("resolves"); + + assert!(!log.is_submittable(1)); + } + + #[test] + fn only_entries_whose_dependencies_are_resolved_are_resolvable() { + let mut log = chained(); + + assert_eq!( + log.resolvable(), + vec![0], + "entry 1 cannot be interpreted yet" + ); + + log.entry_mut(0) + .expect("exists") + .resolve(LogEntryState::Succeeded { + block_hash: BlockHash([2; 32]), + }) + .expect("resolves"); + + assert_eq!(log.resolvable(), vec![1]); + } + + #[test] + fn a_rejected_head_cascades_down_the_whole_chain() { + let mut log = chained(); + log.push(LogEntry::planned(2, coins(&[9]), coins(&[7]), checkpoint(10)).after([1])) + .expect("push"); + log.entry_mut(0) + .expect("exists") + .resolve(LogEntryState::Rejected { + reason: "expired".to_string(), + }) + .expect("resolves"); + + let abandoned = log.cascade_abandoned(); + + assert_eq!(abandoned, vec![1, 2], "the failure propagates transitively"); + assert!(!log.has_pending()); + for sequence in [1, 2] { + assert!(matches!( + log.entry(sequence).expect("exists").state, + LogEntryState::Abandoned { .. } + )); + } + } + + #[test] + fn a_cascade_leaves_independent_transactions_alone() { + let mut log = chained(); + // An unrelated transaction in the same operation, e.g. a second unload + // group that feeds nothing. + log.push(LogEntry::planned( + 2, + entries(&[5]), + coins(&[6]), + checkpoint(10), + )) + .expect("push"); + log.entry_mut(0) + .expect("exists") + .resolve(LogEntryState::Rejected { + reason: "expired".to_string(), + }) + .expect("resolves"); + + assert_eq!(log.cascade_abandoned(), vec![1]); + assert!( + log.entry(2).expect("exists").state.is_pending(), + "an independent transaction is unaffected" + ); + } + + #[test] + fn a_succeeding_chain_cascades_nothing() { + let mut log = chained(); + log.entry_mut(0) + .expect("exists") + .resolve(LogEntryState::Succeeded { + block_hash: BlockHash([2; 32]), + }) + .expect("resolves"); + + assert!(log.cascade_abandoned().is_empty()); + assert!(log.entry(1).expect("exists").state.is_pending()); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/memo.rs b/rust/crates/truapi-server/src/host_logic/coinage/memo.rs new file mode 100644 index 000000000..b50d06f11 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/memo.rs @@ -0,0 +1,70 @@ +//! What a payer can tell a payee out of band about a payment. +//! +//! `coinage-layer.md` §8.3 and §8.8. A transfer mints the payee's coins directly +//! into accounts the payee named, so the payee can find them on chain — but only +//! if it knows which accounts to look at and when. A memo carries exactly that, +//! and nothing else: the layer neither encodes nor transmits it, so the caller +//! owns the wire format. +//! +//! Every field here is already public on chain. The transfer that created the +//! coin names both ends of the move in a block, so a memo tells the payee sooner +//! rather than telling it something new. + +use parity_scale_codec::{Decode, Encode}; + +use super::types::{CoinAccountId, CoinIndex}; + +/// One transferred coin, as the payer can describe it to the payee. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct MemoEntry { + /// On-chain origin the coin came from: the spending coin's account for a + /// coin-origin transfer, or the recycler entry's contextual alias for a coin + /// minted by an unload. + /// + /// Both are 32-byte identifiers the transaction already carries in public, and + /// both answer the same question — where this coin came from. + pub sender_coin_account: CoinAccountId, + /// Account the coin was minted into. The payee recognizes this one. + pub recipient_account: CoinAccountId, + /// The payer's own derivation index for the origin coin. + /// + /// Present because §8.3 names it; the payee has no use for it, and the layer + /// does not read it back. See `coinage-rfc-notes.md`: which side's index this + /// field is meant to carry is not settled, and a payee-side index would have to + /// be supplied by the caller rather than derived here. + pub derivation_index: CoinIndex, +} + +/// How a set of memo entries lines up with what this layer holds (§8.8). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentClassification { + /// Every entry's recipient account corresponds to a coin in a purse this + /// layer knows. + Matched, + /// Some do and some do not. + Received, + /// None do. An empty entry list lands here. + Unmatched, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_memo_entry_round_trips_through_scale() { + // The caller owns the wire format, but the type has to survive being put + // on one. + let entry = MemoEntry { + sender_coin_account: CoinAccountId([1; 32]), + recipient_account: CoinAccountId([2; 32]), + derivation_index: CoinIndex(7), + }; + + let encoded = entry.encode(); + assert_eq!( + MemoEntry::decode(&mut &encoded[..]).expect("decodes"), + entry + ); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/offload.rs b/rust/crates/truapi-server/src/host_logic/coinage/offload.rs new file mode 100644 index 000000000..5e470792c --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/offload.rs @@ -0,0 +1,619 @@ +//! The external-offload phase decision (`coinage-layer.md` §8.6). +//! +//! Sending value out of coinage is the one primitive that cannot be planned in a +//! single pass. Only recycler entries can be offboarded — a coin has to become an +//! entry first — and an entry is not usable the moment it is created, because §5.3 +//! makes it wait out a decorrelation delay. So the operation loops: work out what +//! is possible right now, do that, look again. +//! +//! This module is the "look again" step, and nothing else. It reads a snapshot and +//! names the next phase; submitting, waiting and recycling belong to the caller. +//! Keeping it pure is what makes the four-way choice testable without a chain, +//! which matters because three of the four outcomes are indistinguishable from the +//! outside — "wait", "wait longer" and "you cannot do this" all look like an +//! operation that has not finished. +//! +//! # Records the operation already holds count as available +//! +//! An offload locks everything it touches for its whole life, including entries it +//! created along the way. Those entries are `LockedFor` this operation, which makes +//! them unselectable to everyone — including, naively, to the operation that owns +//! them. The decision therefore asks "available, or held by me?", or an offload +//! would recycle coins forever and never offboard what it had just made. + +use core::time::Duration; + +use super::chain_constants::CoinageChainConstants; +use super::coin::{Coin, CoinState}; +use super::entry::{EntryLocalState, RecyclerEntry}; +use super::types::{ + Amount, CoinIndex, DenominationExponent, EntryIndex, OperationHandle, RingLocation, Timestamp, +}; + +/// Entries of one denomination in one ring, offboarded together. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OffboardGroup { + /// Where the entries sit on chain. + pub ring: RingLocation, + /// Denomination shared by the group. + pub exponent: DenominationExponent, + /// Entries to unload. + pub entries: Vec, +} + +impl OffboardGroup { + /// Total value the group's entries carry. + pub fn value(&self) -> Amount { + Amount::from_cents( + self.exponent + .value() + .cents() + .saturating_mul(self.entries.len() as u64), + ) + } +} + +/// What an offload should do next. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OffloadPhase { + /// Unload these groups to the destination. The last phase. + Offboard { + /// Groups to offboard, in deterministic order. + groups: Vec, + /// Value the groups carry beyond the requested amount, which the same + /// extrinsic must reload into fresh entries rather than let land as a + /// coin. + surplus: Amount, + }, + /// Turn these coins into entries first, then look again. + Recycle { + /// Coins to recycle, in the layer's canonical order. + coins: Vec<(CoinIndex, DenominationExponent)>, + }, + /// Nothing can move yet. Sleep until `until`, then look again. + Wait { + /// When to re-plan. + until: Timestamp, + /// Why the wait is expected to help. + reason: WaitReason, + }, + /// The purse cannot cover the amount, now or later. + Insufficient { + /// Amount asked for. + requested: Amount, + /// Everything the purse holds that could ever reach the destination. + available: Amount, + }, +} + +/// Why an offload is waiting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WaitReason { + /// Entries exist and cover the amount, but are still inside their + /// decorrelation delay. + EntriesRipening, + /// Coins that could cover the deficit are in transient states — locked by + /// another operation, pending confirmation, or held by a chain lock — so a + /// short retry may find them. + CoinsInTransit, +} + +/// Decide the next phase of an offload. +/// +/// `held_by` is the offload's own handle: records it locked earlier are its to +/// use, and treating them as unavailable would loop forever. +#[allow(clippy::too_many_arguments)] +pub fn decide( + coins: &[Coin], + entries: &[RecyclerEntry], + amount: Amount, + allow_degraded: bool, + constants: &CoinageChainConstants, + retry_interval: Duration, + now: Timestamp, + held_by: OperationHandle, +) -> OffloadPhase { + // 1. Entries that could be offboarded right now. + let ready: Vec<&RecyclerEntry> = ordered_entries(entries) + .into_iter() + .filter(|entry| usable_entry(entry, held_by) && offboardable(entry, allow_degraded, now)) + .collect(); + + if let Some((groups, surplus)) = cover(&ready, amount, constants) { + return OffloadPhase::Offboard { groups, surplus }; + } + + // 2. Entries that will be offboardable once their delay elapses. + let ripening: Vec<&RecyclerEntry> = ordered_entries(entries) + .into_iter() + .filter(|entry| { + usable_entry(entry, held_by) + && !offboardable(entry, allow_degraded, now) + && entry.ring.is_some() + }) + .collect(); + let ready_value = total(ready.iter().copied()); + let ripening_value = total(ripening.iter().copied()); + + if ready_value + .checked_add(ripening_value) + .unwrap_or(ready_value) + >= amount + && let Some(until) = ripening.iter().map(|entry| entry.ready_at).max() + { + return OffloadPhase::Wait { + // A delay that has already elapsed would spin; the caller's retry + // interval bounds how often re-planning happens either way. + until, + reason: WaitReason::EntriesRipening, + }; + } + + // 3. Coins that can become entries now. + let deficit = amount.saturating_sub(ready_value); + let spendable: Vec<&Coin> = ordered_coins(coins) + .into_iter() + .filter(|coin| usable_coin(coin, held_by) && !coin.is_chain_locked(now)) + .collect(); + + if total_coins(spendable.iter().copied()) >= deficit && !spendable.is_empty() { + return OffloadPhase::Recycle { + coins: take_for(&spendable, deficit), + }; + } + + // 4. Coins that might become available shortly: anything not terminal. + let salvageable: Vec<&Coin> = ordered_coins(coins) + .into_iter() + .filter(|coin| coin.state != CoinState::Spent) + .collect(); + if total_coins(salvageable.iter().copied()) >= deficit { + return OffloadPhase::Wait { + until: now.saturating_add(retry_interval), + reason: WaitReason::CoinsInTransit, + }; + } + + OffloadPhase::Insufficient { + requested: amount, + available: total_coins(salvageable.iter().copied()) + .checked_add(ready_value) + .and_then(|sum| sum.checked_add(ripening_value)) + .unwrap_or(ready_value), + } +} + +/// Whether an entry belongs to this operation or to nobody. +fn usable_entry(entry: &RecyclerEntry, held_by: OperationHandle) -> bool { + match entry.local { + EntryLocalState::Available => true, + EntryLocalState::LockedFor(holder) => holder == held_by, + _ => false, + } +} + +/// Whether a coin belongs to this operation or to nobody. +fn usable_coin(coin: &Coin, held_by: OperationHandle) -> bool { + match coin.state { + CoinState::Available => true, + CoinState::LockedFor(holder) => holder == held_by, + _ => false, + } +} + +/// Whether the chain would accept an unload of this entry right now. +fn offboardable(entry: &RecyclerEntry, allow_degraded: bool, now: Timestamp) -> bool { + let anonymity = if allow_degraded { + entry.on_chain.is_usable() + } else { + entry.on_chain.is_full_anonymity() + }; + anonymity && entry.jitter_elapsed(now) && !entry.is_alias_locked(now) && entry.ring.is_some() +} + +/// Entries in the layer's canonical order: largest first, then lowest ring, then +/// lowest index. +fn ordered_entries(entries: &[RecyclerEntry]) -> Vec<&RecyclerEntry> { + let mut ordered: Vec<&RecyclerEntry> = entries + .iter() + .filter(|entry| entry.local != EntryLocalState::Consumed) + .collect(); + ordered.sort_by(|left, right| { + right + .exponent + .cmp(&left.exponent) + .then(ring_key(left).cmp(&ring_key(right))) + .then(left.index.cmp(&right.index)) + }); + ordered +} + +/// Coins in the layer's canonical order: largest first, then oldest, then lowest +/// index. +fn ordered_coins(coins: &[Coin]) -> Vec<&Coin> { + let mut ordered: Vec<&Coin> = coins.iter().collect(); + ordered.sort_by(|left, right| { + right + .exponent + .cmp(&left.exponent) + .then(right.age.cmp(&left.age)) + .then(left.index.cmp(&right.index)) + }); + ordered +} + +fn ring_key(entry: &RecyclerEntry) -> u32 { + entry.ring.map_or(u32::MAX, |ring| ring.index.0) +} + +fn total<'a>(entries: impl Iterator) -> Amount { + entries.map(|entry| entry.value()).sum() +} + +fn total_coins<'a>(coins: impl Iterator) -> Amount { + coins.map(|coin| coin.value()).sum() +} + +/// Coins to recycle so their value covers `deficit`, largest first. +fn take_for(coins: &[&Coin], deficit: Amount) -> Vec<(CoinIndex, DenominationExponent)> { + let mut taken = Vec::new(); + let mut covered = Amount::ZERO; + + for coin in coins { + if covered >= deficit { + break; + } + covered = covered.checked_add(coin.value()).unwrap_or(covered); + taken.push((coin.index, coin.exponent)); + } + + taken +} + +/// Group ready entries into the extrinsics that would carry `amount`, plus the +/// surplus those groups would produce. +/// +/// Returns `None` when the ready entries cannot cover the amount. Groups respect +/// the runtime's consolidation cap, since each is one extrinsic. +fn cover( + ready: &[&RecyclerEntry], + amount: Amount, + constants: &CoinageChainConstants, +) -> Option<(Vec, Amount)> { + if amount.is_zero() { + return Some((Vec::new(), Amount::ZERO)); + } + + let cap = constants.max_consolidation.max(1) as usize; + let mut groups: Vec = Vec::new(); + let mut covered = Amount::ZERO; + + for entry in ready { + if covered >= amount { + break; + } + let Some(ring) = entry.ring else { + continue; + }; + + covered = covered.checked_add(entry.value())?; + match groups.iter_mut().find(|group| { + group.ring == ring && group.exponent == entry.exponent && group.entries.len() < cap + }) { + Some(group) => group.entries.push(entry.index), + None => groups.push(OffboardGroup { + ring, + exponent: entry.exponent, + entries: vec![entry.index], + }), + } + } + + (covered >= amount).then(|| (groups, covered.saturating_sub(amount))) +} + +#[cfg(test)] +mod tests { + use super::super::chain_constants::next_people_paseo; + use super::super::params::CoinageParameters; + use super::super::types::{CoinAge, PurseId, RevisionIndex, RingIndex}; + use super::*; + + const NOW: Timestamp = Timestamp(1_000_000); + const HANDLE: OperationHandle = OperationHandle(1); + const OTHER: OperationHandle = OperationHandle(2); + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn ring(index: u32) -> RingLocation { + RingLocation::new(RingIndex(index), RevisionIndex(0)) + } + + fn retry() -> Duration { + CoinageParameters::default().external_offload_retry_interval + } + + /// A coin the chain confirms. + fn coin(index: u32, exponent_value: i8) -> Coin { + let mut coin = Coin::pending(PurseId::MAIN, CoinIndex(index), exponent(exponent_value)); + coin.observe_populated(CoinAge(0)).expect("observes"); + coin + } + + /// An entry in a full-anonymity ring, past its jitter delay. + fn ready_entry(index: u32, exponent_value: i8, ring_index: u32) -> RecyclerEntry { + let mut entry = RecyclerEntry::allocated( + PurseId::MAIN, + EntryIndex(index), + exponent(exponent_value), + NOW, + Duration::ZERO, + ); + entry.observe_ring(ring(ring_index), 64, &CoinageParameters::default()); + entry + } + + /// An entry still inside its decorrelation delay. + fn ripening_entry(index: u32, exponent_value: i8, delay: Duration) -> RecyclerEntry { + let mut entry = RecyclerEntry::allocated( + PurseId::MAIN, + EntryIndex(index), + exponent(exponent_value), + NOW, + delay, + ); + entry.observe_ring(ring(1), 64, &CoinageParameters::default()); + entry + } + + fn decide_for( + coins: &[Coin], + entries: &[RecyclerEntry], + cents: u64, + allow_degraded: bool, + ) -> OffloadPhase { + decide( + coins, + entries, + Amount::from_cents(cents), + allow_degraded, + &next_people_paseo(), + retry(), + NOW, + HANDLE, + ) + } + + #[test] + fn ready_entries_that_cover_the_amount_go_straight_to_offboard() { + let entries = vec![ready_entry(0, 4, 3), ready_entry(1, 4, 3)]; + + let phase = decide_for(&[], &entries, 32, true); + + match phase { + OffloadPhase::Offboard { groups, surplus } => { + assert_eq!(groups.len(), 1, "one ring, one extrinsic"); + assert_eq!(groups[0].entries.len(), 2); + assert_eq!(groups[0].value(), Amount::from_cents(32)); + assert_eq!(surplus, Amount::ZERO); + } + other => panic!("expected Offboard, got {other:?}"), + } + } + + #[test] + fn a_group_that_overshoots_reports_the_surplus_it_must_reload() { + // §8.6: surplus must be reloaded into fresh entries by the same extrinsic. + // Letting it land as a coin would re-link the entry-side anonymity set to a + // fresh account, which is the whole thing the ring exists to prevent. + let entries = vec![ready_entry(0, 4, 3)]; + + let phase = decide_for(&[], &entries, 8, true); + + match phase { + OffloadPhase::Offboard { groups, surplus } => { + assert_eq!(groups[0].value(), Amount::from_cents(16)); + assert_eq!(surplus, Amount::from_cents(8)); + } + other => panic!("expected Offboard, got {other:?}"), + } + } + + #[test] + fn entries_in_two_rings_become_two_groups() { + let entries = vec![ready_entry(0, 4, 3), ready_entry(1, 4, 7)]; + + let phase = decide_for(&[], &entries, 32, true); + + match phase { + OffloadPhase::Offboard { groups, .. } => { + assert_eq!(groups.len(), 2, "a group cannot span two rings"); + assert_eq!(groups[0].ring, ring(3)); + assert_eq!(groups[1].ring, ring(7)); + } + other => panic!("expected Offboard, got {other:?}"), + } + } + + #[test] + fn entries_still_ripening_are_waited_for_rather_than_worked_around() { + let delay = Duration::from_secs(3_600); + let entries = vec![ripening_entry(0, 4, delay)]; + + let phase = decide_for(&[], &entries, 16, true); + + assert_eq!( + phase, + OffloadPhase::Wait { + until: NOW.saturating_add(delay), + reason: WaitReason::EntriesRipening, + }, + "the value is there; only the delay is not done" + ); + } + + #[test] + fn coins_are_recycled_when_entries_cannot_cover_the_amount() { + let coins = vec![coin(0, 4), coin(1, 3)]; + + let phase = decide_for(&coins, &[], 16, true); + + assert_eq!( + phase, + OffloadPhase::Recycle { + coins: vec![(CoinIndex(0), exponent(4))] + }, + "the largest coin alone covers it, in canonical order" + ); + } + + #[test] + fn ready_entries_are_used_first_and_only_the_deficit_is_recycled() { + let coins = vec![coin(0, 4)]; + let entries = vec![ready_entry(0, 3, 3)]; + + // 8 cents ready, 24 wanted: the 16-cent coin covers the 16-cent deficit. + let phase = decide_for(&coins, &entries, 24, true); + + assert_eq!( + phase, + OffloadPhase::Recycle { + coins: vec![(CoinIndex(0), exponent(4))] + } + ); + } + + #[test] + fn a_coin_in_transit_is_waited_for_with_the_retry_interval() { + // Locked by another operation: it may come back, and a short retry is the + // difference between "wait" and "you cannot do this". + let mut locked = coin(0, 4); + locked.lock_for(OTHER).expect("locks"); + let coins = vec![locked]; + + let phase = decide_for(&coins, &[], 16, true); + + assert_eq!( + phase, + OffloadPhase::Wait { + until: NOW.saturating_add(retry()), + reason: WaitReason::CoinsInTransit, + } + ); + } + + #[test] + fn a_chain_locked_coin_is_waited_for_not_recycled() { + // Selecting it would build an extrinsic the runtime refuses at validate. + let mut locked = coin(0, 4); + locked.observe_chain_lock(Some(NOW.saturating_add(Duration::from_secs(60)))); + let coins = vec![locked]; + + let phase = decide_for(&coins, &[], 16, true); + + assert!(matches!( + phase, + OffloadPhase::Wait { + reason: WaitReason::CoinsInTransit, + .. + } + )); + } + + #[test] + fn an_empty_purse_cannot_offload_and_says_so() { + let phase = decide_for(&[], &[], 16, true); + + assert_eq!( + phase, + OffloadPhase::Insufficient { + requested: Amount::from_cents(16), + available: Amount::ZERO, + } + ); + } + + #[test] + fn records_the_offload_already_holds_are_its_own_to_use() { + // The loop's central subtlety: an offload locks what it creates, and if it + // then read its own locks as unavailable it would recycle forever. + let mut held = ready_entry(0, 4, 3); + held.lock_for(HANDLE).expect("locks"); + + let phase = decide_for(&[], &[held], 16, true); + assert!( + matches!(phase, OffloadPhase::Offboard { .. }), + "an entry this operation holds is offboardable: {phase:?}" + ); + + // Held by somebody else, the same entry is not usable at all. + let mut theirs = ready_entry(0, 4, 3); + theirs.lock_for(OTHER).expect("locks"); + assert_eq!( + decide_for(&[], &[theirs], 16, true), + OffloadPhase::Insufficient { + requested: Amount::from_cents(16), + available: Amount::ZERO, + } + ); + } + + #[test] + fn a_degraded_ring_is_refused_unless_the_caller_opted_in() { + // An offload reveals the unloaded value on chain, so the anonymity set + // should be at full strength unless the caller says otherwise. + let mut degraded = RecyclerEntry::allocated( + PurseId::MAIN, + EntryIndex(0), + exponent(4), + NOW, + Duration::ZERO, + ); + degraded.observe_ring(ring(3), 2, &CoinageParameters::default()); + + let refused = decide_for(&[], &[degraded], 16, false); + assert!( + matches!( + refused, + OffloadPhase::Wait { .. } | OffloadPhase::Insufficient { .. } + ), + "a thin ring is not offboarded by default: {refused:?}" + ); + + assert!(matches!( + decide_for(&[], &[degraded], 16, true), + OffloadPhase::Offboard { .. } + )); + } + + #[test] + fn a_group_never_exceeds_what_the_runtime_consolidates() { + let constants = CoinageChainConstants { + max_consolidation: 2, + ..next_people_paseo() + }; + let entries: Vec = (0..3).map(|index| ready_entry(index, 4, 3)).collect(); + + let phase = decide( + &[], + &entries, + Amount::from_cents(48), + true, + &constants, + retry(), + NOW, + HANDLE, + ); + + match phase { + OffloadPhase::Offboard { groups, .. } => { + assert_eq!(groups.len(), 2, "three entries, cap of two"); + assert_eq!(groups[0].entries.len(), 2); + assert_eq!(groups[1].entries.len(), 1); + } + other => panic!("expected Offboard, got {other:?}"), + } + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/operation.rs b/rust/crates/truapi-server/src/host_logic/coinage/operation.rs new file mode 100644 index 000000000..397e7c45f --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/operation.rs @@ -0,0 +1,676 @@ +//! Operation records, their status machine, and receipts. +//! +//! Every call to a long-running primitive starts a fresh operation with a +//! durable handle and a lock set no other operation may touch. The layer does +//! not deduplicate by argument equality; callers needing idempotency track +//! handles themselves. + +use parity_scale_codec::{Decode, Encode}; + +use super::error::{CoinageError, InvalidTransition}; +use super::log::{Checkpoint, LogEntry, OperationLog}; +use super::types::{ + BlockHash, CoinAccountId, CoinIndex, EntryIndex, ExtrinsicHash, OperationHandle, OperationKind, + PurseId, Timestamp, +}; + +const SUBJECT: &str = "operation"; + +/// Outcome of one logged transaction. +/// +/// Only ever written from a **definite** outcome (`coinage-layer.md` §7.6): a +/// transaction seen in a non-finalized block has not resolved, because the +/// block can be reverted and the transaction invalidated on the new canonical +/// chain. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum ExtrinsicOutcome { + /// The extrinsic was finalized and its effects observed. + Succeeded { + /// Finalized block the effect was observed at. + block_hash: BlockHash, + /// Coin accounts the extrinsic consumed and created, together. + affected_coins: Vec, + }, + /// The chain rejected the extrinsic, or it expired unincluded. + Rejected { + /// Rejection reason reported by the chain. + reason: String, + }, + /// Never submitted, because a transaction it depended on did not succeed. + Abandoned { + /// Which dependency, and how it ended. + reason: String, + }, +} + +impl ExtrinsicOutcome { + /// Whether this extrinsic landed. + pub const fn succeeded(&self) -> bool { + matches!(self, Self::Succeeded { .. }) + } +} + +/// One transaction an operation logged, and how it resolved. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ExtrinsicRecord { + /// Hash of the submitted extrinsic; `None` if it was never broadcast. + pub extrinsic_hash: Option, + /// How it resolved. + pub outcome: ExtrinsicOutcome, +} + +/// Per-extrinsic summary attached to a successful operation. +/// +/// A multi-extrinsic operation may mix successes and rejections: `Done` means +/// *at least one* extrinsic succeeded, and the caller introspects the records +/// to decide what that means for them. +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] +pub struct OperationReceipt { + /// Every extrinsic the operation submitted, in submission order. + pub extrinsics: Vec, +} + +impl OperationReceipt { + /// Whether any submitted extrinsic succeeded. + pub fn any_succeeded(&self) -> bool { + self.extrinsics + .iter() + .any(|record| record.outcome.succeeded()) + } + + /// Whether some but not all submitted extrinsics succeeded. + pub fn is_partial(&self) -> bool { + self.any_succeeded() + && self + .extrinsics + .iter() + .any(|record| !record.outcome.succeeded()) + } +} + +/// Terminal outcome of an operation. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum TerminalStatus { + /// At least one submitted extrinsic was finalized successfully. + Done(OperationReceipt), + /// Nothing was submitted, everything submitted was rejected, or the + /// operation was cancelled. + Failed(CoinageError), +} + +/// Where an operation is in its lifecycle. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum OperationStatus { + /// Selecting, deriving, signing, building extrinsics, or re-planning + /// between phases. Nothing in flight. + Preparing, + /// An extrinsic has been broadcast. + Submitted, + /// An extrinsic is in a non-finalized block. + InBlock, + /// An extrinsic has been finalized. + Finalized, + /// Blocked until the given instant, then back to `Preparing`. + Waiting(Timestamp), + /// Tracking lost a submitted transaction and its fate is being resolved + /// against finalized chain state (`coinage-layer.md` §7.7). + Recovering, + /// Terminal success. + Done(OperationReceipt), + /// Terminal failure. + Failed(CoinageError), +} + +impl OperationStatus { + /// A short label for diagnostics. + pub const fn label(&self) -> &'static str { + match self { + Self::Preparing => "preparing", + Self::Submitted => "submitted", + Self::InBlock => "in-block", + Self::Finalized => "finalized", + Self::Waiting(_) => "waiting", + Self::Recovering => "recovering", + Self::Done(_) => "done", + Self::Failed(_) => "failed", + } + } + + /// Whether the operation has finished and its stream should close. + pub const fn is_terminal(&self) -> bool { + matches!(self, Self::Done(_) | Self::Failed(_)) + } + + /// Whether the caller may cancel right now. + /// + /// Cancellation is possible exactly while no extrinsic is in flight. A + /// multi-phase operation becomes cancellable again each time it returns to + /// `Preparing` or `Waiting`. + pub const fn is_cancellable(&self) -> bool { + matches!(self, Self::Preparing | Self::Waiting(_)) + } + + /// Whether an extrinsic is currently in flight. + /// + /// `Recovering` counts: the transaction may well be on chain, and the + /// caller must not be allowed to cancel out from under it. + pub const fn has_extrinsic_in_flight(&self) -> bool { + matches!(self, Self::Submitted | Self::InBlock | Self::Recovering) + } + + /// The terminal outcome, if the operation has reached one. + pub fn terminal(&self) -> Option { + match self { + Self::Done(receipt) => Some(TerminalStatus::Done(receipt.clone())), + Self::Failed(error) => Some(TerminalStatus::Failed(error.clone())), + _ => None, + } + } +} + +impl From for OperationStatus { + /// Lift a terminal outcome back into the status machine, which is how a + /// status subscriber learns of a completion whose operation record the store + /// has already dropped. + fn from(terminal: TerminalStatus) -> Self { + match terminal { + TerminalStatus::Done(receipt) => Self::Done(receipt), + TerminalStatus::Failed(error) => Self::Failed(error), + } + } +} + +/// The set of records an operation holds exclusively until it terminates. +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] +pub struct LockSet { + /// Coins locked, by purse-scoped index. + pub coins: Vec<(PurseId, CoinIndex)>, + /// Recycler entries locked, by purse-scoped index. + pub entries: Vec<(PurseId, EntryIndex)>, +} + +impl LockSet { + /// Whether the operation holds nothing. + pub fn is_empty(&self) -> bool { + self.coins.is_empty() && self.entries.is_empty() + } + + /// Whether two lock sets overlap. Operations with disjoint lock sets may + /// run concurrently. + pub fn intersects(&self, other: &Self) -> bool { + self.coins.iter().any(|coin| other.coins.contains(coin)) + || self + .entries + .iter() + .any(|entry| other.entries.contains(entry)) + } +} + +/// A durable operation record. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Operation { + /// Layer-issued handle. + pub handle: OperationHandle, + /// What the operation does. + pub kind: OperationKind, + /// Purse the operation acts on. Cross-purse operations name their source. + pub purse: PurseId, + /// Records held exclusively by this operation. + pub locks: LockSet, + /// Write-ahead log of the operation's transactions (§7.4). + pub log: OperationLog, + /// Current status. + pub status: OperationStatus, +} + +impl Operation { + /// Start an operation in `Preparing` with no locks and nothing submitted. + pub fn start(handle: OperationHandle, kind: OperationKind, purse: PurseId) -> Self { + Self { + handle, + kind, + purse, + locks: LockSet::default(), + log: OperationLog::default(), + status: OperationStatus::Preparing, + } + } + + /// Whether the operation has finished. + pub fn is_terminal(&self) -> bool { + self.status.is_terminal() + } + + /// Whether the operation ever broadcast anything. + pub fn has_submitted(&self) -> bool { + self.log.any_broadcast() + } + + /// Log a transaction the operation intends to submit, returning its + /// sequence. + /// + /// Written before an extrinsic exists, let alone is broadcast: the log has + /// to describe work the operation is about to attempt, or a crash between + /// planning and broadcasting would leave inputs locked with nothing + /// recording why. + pub fn plan_transaction( + &mut self, + inputs: LockSet, + outputs: LockSet, + checkpoint: Checkpoint, + depends_on: impl IntoIterator, + ) -> Result { + if self.status.is_terminal() { + return Err(InvalidTransition::new( + SUBJECT, + self.status.label(), + "plan a transaction for", + ) + .into()); + } + + let sequence = self.log.next_sequence(); + self.log + .push(LogEntry::planned(sequence, inputs, outputs, checkpoint).after(depends_on))?; + Ok(sequence) + } + + /// Attach an extrinsic hash to a logged transaction immediately before + /// broadcasting it, and move to `Submitted`. + /// + /// The hash is recorded before the broadcast so that a restart mid-flight + /// can reconcile it against chain state rather than assume nothing happened. + pub fn record_submission( + &mut self, + sequence: u32, + extrinsic_hash: ExtrinsicHash, + ) -> Result<(), CoinageError> { + if self.status.is_terminal() { + return Err(InvalidTransition::new( + SUBJECT, + self.status.label(), + "record a submission for", + ) + .into()); + } + + if self.log.entry(sequence).is_none() { + return Err(CoinageError::Internal(format!( + "{} has no logged transaction {sequence}", + self.handle + ))); + } + // §7.5: a dependency that has not *definitely* succeeded means this + // transaction's inputs may not exist. Broadcasting anyway would leave a + // reorg to decide which of the two survives. + if !self.log.is_submittable(sequence) { + return Err(CoinageError::Internal(format!( + "{} cannot broadcast transaction {sequence}: a transaction it depends on has not \ + definitely succeeded", + self.handle + ))); + } + + let entry = self + .log + .entry_mut(sequence) + .expect("presence checked immediately above; qed"); + entry.set_extrinsic_hash(extrinsic_hash); + self.status = OperationStatus::Submitted; + Ok(()) + } + + /// Advance to a non-terminal status. + pub fn advance(&mut self, status: OperationStatus) -> Result<(), InvalidTransition> { + if status.is_terminal() { + return Err(InvalidTransition::new( + SUBJECT, + self.status.label(), + "advance to a terminal status", + )); + } + + if self.status.is_terminal() { + return Err(InvalidTransition::new( + SUBJECT, + self.status.label(), + "advance", + )); + } + + self.status = status; + Ok(()) + } + + /// Finish successfully with a receipt. + pub fn finish(&mut self, receipt: OperationReceipt) -> Result<(), InvalidTransition> { + if self.status.is_terminal() { + return Err(InvalidTransition::new( + SUBJECT, + self.status.label(), + "finish", + )); + } + + self.status = OperationStatus::Done(receipt); + self.locks = LockSet::default(); + Ok(()) + } + + /// Finish unsuccessfully. + pub fn fail(&mut self, error: CoinageError) -> Result<(), InvalidTransition> { + if self.status.is_terminal() { + return Err(InvalidTransition::new(SUBJECT, self.status.label(), "fail")); + } + + self.status = OperationStatus::Failed(error); + self.locks = LockSet::default(); + Ok(()) + } + + /// Cancel the operation, which is permitted only while nothing is in + /// flight. + pub fn cancel(&mut self) -> Result<(), InvalidTransition> { + if !self.status.is_cancellable() { + return Err(InvalidTransition::new( + SUBJECT, + self.status.label(), + "cancel", + )); + } + + self.fail(CoinageError::Cancelled) + } + + /// Resolve an operation found open after a restart. + /// + /// Pre-submission scratch state is not durable, so an operation that never + /// broadcast is equivalent to a cancel. One that did must be reconciled + /// against chain state by the caller, which is why this only reports what + /// to do rather than deciding it. + pub fn restart_disposition(&self) -> RestartDisposition { + if self.is_terminal() { + RestartDisposition::AlreadyTerminal + } else if self.has_submitted() { + RestartDisposition::Reconcile + } else { + RestartDisposition::FailInterrupted + } + } +} + +/// What to do with an operation record recovered after a restart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RestartDisposition { + /// The record is already finished; drop it. + AlreadyTerminal, + /// Nothing was broadcast, so fail it and release its locks. + FailInterrupted, + /// Extrinsics were broadcast; check them against chain state. + Reconcile, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn checkpoint() -> Checkpoint { + Checkpoint { + number: 1_000, + hash: BlockHash([1; 32]), + mortality: 256, + } + } + + /// Plan a transaction and attach a hash, the way a submission actually + /// happens: the log entry exists before the extrinsic is broadcast. + fn submit(operation: &mut Operation, byte: u8) -> u32 { + let sequence = operation + .plan_transaction(LockSet::default(), LockSet::default(), checkpoint(), []) + .expect("planning is valid"); + operation + .record_submission(sequence, hash(byte)) + .expect("submission is valid"); + sequence + } + + fn hash(byte: u8) -> ExtrinsicHash { + ExtrinsicHash([byte; 32]) + } + + fn new_operation() -> Operation { + Operation::start(OperationHandle(1), OperationKind::Transfer, PurseId::MAIN) + } + + fn succeeded() -> ExtrinsicRecord { + ExtrinsicRecord { + extrinsic_hash: Some(hash(1)), + outcome: ExtrinsicOutcome::Succeeded { + block_hash: BlockHash([9; 32]), + affected_coins: Vec::new(), + }, + } + } + + fn rejected() -> ExtrinsicRecord { + ExtrinsicRecord { + extrinsic_hash: Some(hash(2)), + outcome: ExtrinsicOutcome::Rejected { + reason: "bad origin".to_string(), + }, + } + } + + #[test] + fn an_operation_starts_preparing_and_cancellable() { + let operation = new_operation(); + + assert_eq!(operation.status, OperationStatus::Preparing); + assert!(operation.status.is_cancellable()); + assert!(!operation.has_submitted()); + assert!(operation.locks.is_empty()); + } + + #[test] + fn cancellation_is_blocked_exactly_while_an_extrinsic_is_in_flight() { + assert!(OperationStatus::Preparing.is_cancellable()); + assert!(OperationStatus::Waiting(Timestamp(1)).is_cancellable()); + assert!(!OperationStatus::Submitted.is_cancellable()); + assert!(!OperationStatus::InBlock.is_cancellable()); + assert!(!OperationStatus::Finalized.is_cancellable()); + // Recovering is the sharpest case: the transaction may be on chain and + // the layer simply does not know yet. + assert!(!OperationStatus::Recovering.is_cancellable()); + assert!(OperationStatus::Recovering.has_extrinsic_in_flight()); + } + + #[test] + fn submission_is_recorded_before_the_status_moves() { + let mut operation = new_operation(); + + let sequence = submit(&mut operation, 3); + + assert_eq!(operation.log.submitted_hashes(), vec![hash(3)]); + assert_eq!(sequence, 0); + assert_eq!(operation.status, OperationStatus::Submitted); + assert!(operation.status.has_extrinsic_in_flight()); + } + + #[test] + fn a_dependent_transaction_cannot_be_broadcast_before_its_predecessor_succeeds() { + // §7.5. The gate lives here because this is the last point before the + // bytes leave the layer. + let mut operation = new_operation(); + let first = operation + .plan_transaction(LockSet::default(), LockSet::default(), checkpoint(), []) + .expect("planning is valid"); + let second = operation + .plan_transaction( + LockSet::default(), + LockSet::default(), + checkpoint(), + [first], + ) + .expect("planning is valid"); + + let refused = operation.record_submission(second, hash(2)); + assert!(refused.is_err(), "its inputs do not exist yet"); + assert!(!operation.log.entry(second).expect("exists").was_broadcast()); + + operation + .record_submission(first, hash(1)) + .expect("the head is submittable"); + operation + .log + .entry_mut(first) + .expect("exists") + .resolve(crate::host_logic::coinage::log::LogEntryState::Succeeded { + block_hash: BlockHash([3; 32]), + }) + .expect("resolves"); + + operation + .record_submission(second, hash(2)) + .expect("the predecessor definitely succeeded"); + } + + #[test] + fn an_in_flight_operation_cannot_be_cancelled() { + let mut operation = new_operation(); + submit(&mut operation, 3); + + assert!(operation.cancel().is_err()); + assert_eq!(operation.status, OperationStatus::Submitted); + } + + #[test] + fn a_multi_phase_operation_becomes_cancellable_again_at_preparing() { + let mut operation = new_operation(); + submit(&mut operation, 3); + operation + .advance(OperationStatus::Finalized) + .expect("advance is valid"); + operation + .advance(OperationStatus::Preparing) + .expect("re-plan is valid"); + + assert!(operation.status.is_cancellable()); + operation.cancel().expect("cancel is valid"); + assert_eq!( + operation.status, + OperationStatus::Failed(CoinageError::Cancelled) + ); + } + + #[test] + fn advance_refuses_terminal_statuses() { + let mut operation = new_operation(); + + assert!( + operation + .advance(OperationStatus::Done(OperationReceipt::default())) + .is_err() + ); + assert!( + operation + .advance(OperationStatus::Failed(CoinageError::Cancelled)) + .is_err() + ); + } + + #[test] + fn terminating_releases_every_lock() { + let mut operation = new_operation(); + operation.locks.coins.push((PurseId::MAIN, CoinIndex(0))); + operation.locks.entries.push((PurseId::MAIN, EntryIndex(0))); + + operation + .finish(OperationReceipt::default()) + .expect("finish is valid"); + + assert!(operation.locks.is_empty()); + } + + #[test] + fn a_terminal_operation_rejects_every_further_transition() { + let mut operation = new_operation(); + operation.cancel().expect("cancel is valid"); + + assert!(operation.record_submission(0, hash(4)).is_err()); + assert!(operation.advance(OperationStatus::Preparing).is_err()); + assert!(operation.finish(OperationReceipt::default()).is_err()); + assert!(operation.fail(CoinageError::Cancelled).is_err()); + assert!(operation.cancel().is_err()); + } + + #[test] + fn a_receipt_reports_partial_success() { + let all_good = OperationReceipt { + extrinsics: vec![succeeded()], + }; + let mixed = OperationReceipt { + extrinsics: vec![succeeded(), rejected()], + }; + let all_bad = OperationReceipt { + extrinsics: vec![rejected()], + }; + + assert!(all_good.any_succeeded() && !all_good.is_partial()); + assert!(mixed.any_succeeded() && mixed.is_partial()); + assert!(!all_bad.any_succeeded() && !all_bad.is_partial()); + } + + #[test] + fn restart_fails_operations_that_never_broadcast() { + let operation = new_operation(); + + assert_eq!( + operation.restart_disposition(), + RestartDisposition::FailInterrupted + ); + } + + #[test] + fn restart_reconciles_operations_that_broadcast() { + let mut operation = new_operation(); + submit(&mut operation, 5); + + assert_eq!( + operation.restart_disposition(), + RestartDisposition::Reconcile + ); + } + + #[test] + fn disjoint_lock_sets_do_not_intersect() { + let first = LockSet { + coins: vec![(PurseId::MAIN, CoinIndex(0))], + entries: Vec::new(), + }; + let second = LockSet { + coins: vec![(PurseId::MAIN, CoinIndex(1))], + entries: Vec::new(), + }; + let overlapping = LockSet { + coins: vec![(PurseId::MAIN, CoinIndex(0))], + entries: Vec::new(), + }; + + assert!(!first.intersects(&second)); + assert!(first.intersects(&overlapping)); + } + + #[test] + fn the_same_index_in_two_purses_is_a_different_lock() { + let main = LockSet { + coins: vec![(PurseId::MAIN, CoinIndex(0))], + entries: Vec::new(), + }; + let other = LockSet { + coins: vec![(PurseId(1), CoinIndex(0))], + entries: Vec::new(), + }; + + assert!(!main.intersects(&other)); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/params.rs b/rust/crates/truapi-server/src/host_logic/coinage/params.rs new file mode 100644 index 000000000..63014ff0d --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/params.rs @@ -0,0 +1,235 @@ +//! Tunable parameters governing selection, recycling, and recovery. +//! +//! Every value here is a policy choice of the layer. Chain-enforced limits live +//! in [`super::chain_constants::CoinageChainConstants`] instead: exceeding one of +//! those makes an extrinsic invalid, which is a different kind of fact from a +//! tunable. Where a recommendation is expressed relative to a chain constant, +//! the constant arrives as an argument rather than a stored field. + +use core::time::Duration; + +/// Era length, in blocks, for every coinage extrinsic. +/// +/// `coinage-layer.md` Appendix A.14. Coinage extrinsics are mortal by +/// requirement, not by preference: this period is the only thing that lets +/// recovery eventually declare a transaction it lost track of dead, and so the +/// only thing that makes returning its inputs to the spendable pool safe. +/// +/// 256 blocks is roughly 25 minutes at a six-second block time — long enough to +/// survive a socket drop or a backgrounded host, short enough that a vanished +/// transaction does not strand its inputs for hours. Must be a power of two in +/// `[4, 65536]`. +pub const EXTRINSIC_MORTALITY_BLOCKS: u64 = 256; + +use super::types::{Amount, CoinAge, DenominationExponent}; + +/// Policy parameters for one layer instance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoinageParameters { + /// Ring member count at or above which an entry's anonymity is considered + /// adequate. Scoped to the layer instance, never per purse or per call. + pub minimum_anonymous_ring_size: u32, + /// Upper bound of the uniform delay applied to a new recycler entry before + /// it becomes selectable, decorrelating a load from its later unload. Zero + /// disables jitter. + pub recycler_entry_jitter_upper_bound: Duration, + /// How often the coin-age recycling sweep runs. + pub recycling_sweep_interval: Duration, + /// How often the ring-expiration rescue sweep runs. + pub ring_expiration_sweep_interval: Duration, + /// Fraction of the chain's recycler expiration time to keep as slack before + /// rescuing an entry, expressed in percent. + pub rescue_margin_percent: u32, + /// Lower bound on the rescue margin, whatever the fraction works out to. + pub rescue_margin_minimum: Duration, + /// Number of free-unload-token counters to probe per period. Must not exceed + /// the runtime's `MaxFreeUnloadTokensPerTimePeriod`. + pub free_token_counter_search_range: u32, + /// How far past a period boundary a prior period's tokens stay eligible. + pub period_lookback_grace: Duration, + /// Paid-unload-token slots to track per period. + /// + /// One slot is one member key, one join, one fee and one token, so this is + /// the ceiling on paid tokens the layer will use in a single period. Kept + /// small deliberately: reaching the paid ring at all means the free allowance + /// is exhausted, and every extra slot probed costs two storage reads. + pub paid_token_slot_search_range: u32, + /// Derivation indices scanned per batch during recovery. + pub recovery_batch_size: u32, + /// Consecutive empty batches after which a recovery scan stops. + pub recovery_gap_limit: u32, + /// How long external offload waits before re-planning when the deficit + /// could be covered by coins currently in transient states. + pub external_offload_retry_interval: Duration, +} + +impl CoinageParameters { + /// Age at which a coin is recycled, given the chain's maximum coin age. + /// + /// The two-transfer margin absorbs a retry window under congestion or + /// downtime. + pub fn recycle_at_age(chain_coin_max_age: CoinAge) -> CoinAge { + CoinAge(chain_coin_max_age.0.saturating_sub(2)) + } + + /// Slack between the rescue sweep firing and the chain destroying the + /// ring's backing value, given the chain's recycler expiration time. + /// + /// Too small and the rescue races chain cleanup; too large and entries are + /// rescued early, burning unload tokens for nothing. + pub fn rescue_margin(&self, recycler_expiration_time: Duration) -> Duration { + let fraction = + recycler_expiration_time.mul_f64(f64::from(self.rescue_margin_percent) / 100.0); + fraction.max(self.rescue_margin_minimum) + } + + /// Whether a ring member count clears the anonymity floor. + pub fn clears_anonymity_floor(&self, ring_member_count: u32) -> bool { + ring_member_count >= self.minimum_anonymous_ring_size + } + + /// How often the layer wants ticking to keep both sweeps timely. + /// + /// The stricter of the two sweep intervals, because one tick runs both and the + /// tighter deadline is the one that governs. Reported to the host rather than + /// enforced: the core has no clock of its own (truapi#356). + pub fn sweep_tick_interval(&self) -> Duration { + self.recycling_sweep_interval + .min(self.ring_expiration_sweep_interval) + } +} + +impl Default for CoinageParameters { + /// The recommended values. + fn default() -> Self { + Self { + minimum_anonymous_ring_size: 10, + recycler_entry_jitter_upper_bound: Duration::from_secs(6 * 60 * 60), + recycling_sweep_interval: Duration::from_secs(24 * 60 * 60), + ring_expiration_sweep_interval: Duration::from_secs(24 * 60 * 60), + rescue_margin_percent: 25, + rescue_margin_minimum: Duration::from_secs(7 * 24 * 60 * 60), + free_token_counter_search_range: 10, + period_lookback_grace: Duration::from_secs(60 * 60), + paid_token_slot_search_range: 4, + recovery_batch_size: 500, + recovery_gap_limit: 4, + external_offload_retry_interval: Duration::from_secs(30), + } + } +} + +/// Denomination breakdown of `amount` into powers of two, largest first. +/// +/// The set of denominations is exactly the powers of two, so the breakdown is +/// the binary expansion of the cent count. Bounded by the runtime's largest +/// accepted denomination: an amount needing a bigger coin than the chain mints +/// has no breakdown, and returns `None` rather than a set of unusable outputs. +pub fn canonical_breakdown( + amount: Amount, + largest: DenominationExponent, +) -> Option> { + let mut exponents = Vec::new(); + let cents = amount.cents(); + + for bit in (0..u64::BITS).rev() { + if cents & (1u64 << bit) != 0 { + let exponent = i8::try_from(bit).ok()?; + if exponent > largest.get() { + return None; + } + exponents.push(DenominationExponent::new(exponent)?); + } + } + + Some(exponents) +} + +#[cfg(test)] +mod tests { + use super::super::chain_constants::next_people_paseo; + use super::super::types::MAX_SUPPORTED_DENOMINATION_EXPONENT; + use super::*; + + #[test] + fn recycle_age_leaves_a_two_transfer_margin() { + assert_eq!(CoinageParameters::recycle_at_age(CoinAge(16)), CoinAge(14)); + } + + #[test] + fn recycle_age_saturates_for_tiny_chain_caps() { + assert_eq!(CoinageParameters::recycle_at_age(CoinAge(1)), CoinAge(0)); + } + + #[test] + fn rescue_margin_takes_the_fraction_when_it_exceeds_the_floor() { + let params = CoinageParameters::default(); + let expiration = Duration::from_secs(365 * 24 * 60 * 60); + + // 25% of a year comfortably exceeds the 7-day floor. + assert_eq!(params.rescue_margin(expiration), expiration.mul_f64(0.25)); + } + + #[test] + fn rescue_margin_takes_the_floor_when_the_fraction_is_smaller() { + let params = CoinageParameters::default(); + let expiration = Duration::from_secs(10 * 24 * 60 * 60); + + // 25% of 10 days is 2.5 days, below the 7-day floor. + assert_eq!( + params.rescue_margin(expiration), + params.rescue_margin_minimum + ); + } + + #[test] + fn anonymity_floor_is_inclusive() { + let params = CoinageParameters::default(); + + assert!(!params.clears_anonymity_floor(9)); + assert!(params.clears_anonymity_floor(10)); + assert!(params.clears_anonymity_floor(11)); + } + + fn largest() -> DenominationExponent { + next_people_paseo() + .largest_denomination() + .expect("the reference runtime is supported") + } + + #[test] + fn breakdown_is_the_binary_expansion_largest_first() { + let exponents = + canonical_breakdown(Amount::from_cents(13), largest()).expect("13 is representable"); + let raw: Vec = exponents.iter().map(|e| e.get()).collect(); + + // 13 = 8 + 4 + 1 + assert_eq!(raw, vec![3, 2, 0]); + } + + #[test] + fn breakdown_of_zero_is_empty() { + assert_eq!( + canonical_breakdown(Amount::ZERO, largest()), + Some(Vec::::new()) + ); + } + + #[test] + fn breakdown_sums_back_to_the_amount() { + let amount = Amount::from_cents(16_000); + let exponents = canonical_breakdown(amount, largest()).expect("representable"); + let total: Amount = exponents.iter().map(|e| e.value()).sum(); + + assert_eq!(total, amount); + } + + #[test] + fn breakdown_rejects_amounts_needing_a_denomination_the_chain_will_not_mint() { + // The reference runtime mints nothing above 2^14 cents, so an amount + // that needs a 2^15 coin has no valid breakdown. + let too_large = Amount::from_cents(1u64 << 15); + assert_eq!(canonical_breakdown(too_large, largest()), None); + assert!(MAX_SUPPORTED_DENOMINATION_EXPONENT > largest().get()); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/purse.rs b/rust/crates/truapi-server/src/host_logic/coinage/purse.rs new file mode 100644 index 000000000..af78101c1 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/purse.rs @@ -0,0 +1,383 @@ +//! Purses and the balance projection over their contents. +//! +//! A purse is a named, firewalled coinage balance with an isolated derivation +//! namespace. Index `i` in one purse and index `i` in another address different +//! on-chain accounts, so purse membership is implied by derivation rather than +//! stored as a pointer. + +use parity_scale_codec::{Decode, Encode}; + +use super::coin::Coin; +use super::entry::RecyclerEntry; +use super::types::{Amount, CoinIndex, EntryIndex, PurseId, Timestamp}; + +/// A purse record. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Purse { + /// Identifier, reserved for the main purse or freshly assigned. + pub id: PurseId, + /// User-facing name. + pub name: String, + /// Next coin index to hand out. Never decreases, so an index is never + /// reused even after every coin in the purse is spent. + pub next_coin_index: CoinIndex, + /// Next recycler-entry index to hand out, with the same no-reuse guarantee. + pub next_entry_index: EntryIndex, +} + +impl Purse { + /// Create a purse with empty index spaces. + pub fn new(id: PurseId, name: String) -> Self { + Self { + id, + name, + next_coin_index: CoinIndex(0), + next_entry_index: EntryIndex(0), + } + } + + /// Whether this is the main purse, which exists by construction and cannot + /// be deleted. + pub fn is_main(&self) -> bool { + self.id.is_main() + } + + /// Hand out the next coin index. + pub fn allocate_coin_index(&mut self) -> CoinIndex { + let index = self.next_coin_index; + self.next_coin_index = CoinIndex(index.0 + 1); + index + } + + /// Hand out the next recycler-entry index. + pub fn allocate_entry_index(&mut self) -> EntryIndex { + let index = self.next_entry_index; + self.next_entry_index = EntryIndex(index.0 + 1); + index + } +} + +/// The three-value balance the layer publishes for a purse. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Encode, Decode)] +pub struct PurseBalance { + /// Available coins plus every currently selectable recycler entry. + pub spendable: Amount, + /// The same, counting only entries at full anonymity. Never exceeds + /// [`PurseBalance::spendable`]; the difference is the value sitting in + /// degraded rings. + pub spendable_strict: Amount, + /// Value that exists but cannot be spent right now: coins that are pending + /// or locked, and entries that are missing, waiting, locked, or still + /// inside their jitter delay. + pub pending: Amount, +} + +/// A purse together with its current balance. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct PurseInfo { + /// Purse identifier. + pub id: PurseId, + /// User-facing name. + pub name: String, + /// Spendable value. + pub spendable: Amount, + /// Spendable value at full anonymity. + pub spendable_strict: Amount, + /// Value not currently spendable. + pub pending: Amount, +} + +impl PurseInfo { + /// Combine a purse record with a computed balance. + pub fn new(purse: &Purse, balance: PurseBalance) -> Self { + Self { + id: purse.id, + name: purse.name.clone(), + spendable: balance.spendable, + spendable_strict: balance.spendable_strict, + pending: balance.pending, + } + } +} + +/// Project a purse's coins and recycler entries onto its balance triple. +/// +/// Terminal records — spent coins and consumed entries — contribute nothing; +/// they are retained only so their indices are never reused. +pub fn compute_balance<'a>( + coins: impl IntoIterator, + entries: impl IntoIterator, + now: Timestamp, +) -> PurseBalance { + use super::coin::CoinState; + use super::entry::EntryLocalState; + + let mut balance = PurseBalance::default(); + + for coin in coins { + match coin.state { + CoinState::Available => { + balance.spendable = balance + .spendable + .checked_add(coin.value()) + .unwrap_or(balance.spendable); + balance.spendable_strict = balance + .spendable_strict + .checked_add(coin.value()) + .unwrap_or(balance.spendable_strict); + } + CoinState::Pending | CoinState::LockedFor(_) => { + balance.pending = balance + .pending + .checked_add(coin.value()) + .unwrap_or(balance.pending); + } + CoinState::Spent => {} + } + } + + for entry in entries { + if entry.local == EntryLocalState::Consumed { + continue; + } + + let value = entry.value(); + + if entry.is_selectable(now, true) { + balance.spendable = balance + .spendable + .checked_add(value) + .unwrap_or(balance.spendable); + + if entry.is_selectable(now, false) { + balance.spendable_strict = balance + .spendable_strict + .checked_add(value) + .unwrap_or(balance.spendable_strict); + } + } else { + balance.pending = balance + .pending + .checked_add(value) + .unwrap_or(balance.pending); + } + } + + balance +} + +#[cfg(test)] +mod tests { + use core::time::Duration; + + use super::super::entry::{EntryOnChainState, RecyclerEntry}; + use super::super::params::CoinageParameters; + use super::super::types::{ + CoinAge, DenominationExponent, OperationHandle, RevisionIndex, RingIndex, RingLocation, + }; + use super::*; + + const NOW: Timestamp = Timestamp(1_000_000); + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn available_coin(index: u32, exponent_value: i8) -> Coin { + let mut coin = Coin::pending(PurseId::MAIN, CoinIndex(index), exponent(exponent_value)); + coin.observe_populated(CoinAge(0)) + .expect("observe is valid"); + coin + } + + fn entry_with(index: u32, exponent_value: i8, on_chain: EntryOnChainState) -> RecyclerEntry { + let mut entry = RecyclerEntry::allocated( + PurseId::MAIN, + EntryIndex(index), + exponent(exponent_value), + Timestamp(0), + Duration::ZERO, + ); + entry.ring = Some(RingLocation::new(RingIndex(1), RevisionIndex(0))); + entry.on_chain = on_chain; + entry + } + + #[test] + fn a_new_purse_starts_both_index_spaces_at_zero() { + let purse = Purse::new(PurseId::MAIN, "Main".to_string()); + + assert!(purse.is_main()); + assert_eq!(purse.next_coin_index, CoinIndex(0)); + assert_eq!(purse.next_entry_index, EntryIndex(0)); + } + + #[test] + fn index_allocation_never_repeats() { + let mut purse = Purse::new(PurseId(1), "Savings".to_string()); + + let first = purse.allocate_coin_index(); + let second = purse.allocate_coin_index(); + let entry = purse.allocate_entry_index(); + + assert_eq!(first, CoinIndex(0)); + assert_eq!(second, CoinIndex(1)); + assert_eq!(purse.next_coin_index, CoinIndex(2)); + assert_eq!(entry, EntryIndex(0)); + assert_eq!(purse.next_entry_index, EntryIndex(1)); + } + + #[test] + fn coin_and_entry_index_spaces_are_independent() { + let mut purse = Purse::new(PurseId(1), "Savings".to_string()); + + purse.allocate_coin_index(); + purse.allocate_coin_index(); + + assert_eq!(purse.allocate_entry_index(), EntryIndex(0)); + } + + #[test] + fn an_empty_purse_has_a_zero_balance() { + let balance = compute_balance(&[], &[], NOW); + + assert_eq!(balance, PurseBalance::default()); + } + + #[test] + fn available_coins_count_towards_both_spendable_figures() { + let coins = vec![available_coin(0, 3), available_coin(1, 2)]; + + let balance = compute_balance(&coins, &[], NOW); + + assert_eq!(balance.spendable, Amount::from_cents(12)); + assert_eq!(balance.spendable_strict, Amount::from_cents(12)); + assert_eq!(balance.pending, Amount::ZERO); + } + + #[test] + fn pending_and_locked_coins_count_as_pending() { + let mut locked = available_coin(0, 4); + locked.lock_for(OperationHandle(1)).expect("lock is valid"); + let coins = vec![ + locked, + Coin::pending(PurseId::MAIN, CoinIndex(1), exponent(3)), + ]; + + let balance = compute_balance(&coins, &[], NOW); + + assert_eq!(balance.spendable, Amount::ZERO); + assert_eq!(balance.pending, Amount::from_cents(24)); + } + + #[test] + fn spent_coins_count_nowhere() { + let mut spent = available_coin(0, 5); + spent.lock_for(OperationHandle(1)).expect("lock is valid"); + spent + .mark_spent(OperationHandle(1)) + .expect("spend is valid"); + + let balance = compute_balance(&[spent], &[], NOW); + + assert_eq!(balance, PurseBalance::default()); + } + + #[test] + fn degraded_entries_separate_the_two_spendable_figures() { + let entries = vec![ + entry_with(0, 4, EntryOnChainState::Ready), + entry_with(1, 3, EntryOnChainState::Degraded(2)), + ]; + + let balance = compute_balance(&[], &entries, NOW); + + assert_eq!(balance.spendable, Amount::from_cents(24)); + assert_eq!(balance.spendable_strict, Amount::from_cents(16)); + assert_eq!(balance.pending, Amount::ZERO); + } + + #[test] + fn unusable_entries_count_as_pending() { + let entries = vec![ + entry_with(0, 4, EntryOnChainState::Waiting), + entry_with(1, 3, EntryOnChainState::Missing), + ]; + + let balance = compute_balance(&[], &entries, NOW); + + assert_eq!(balance.spendable, Amount::ZERO); + assert_eq!(balance.spendable_strict, Amount::ZERO); + assert_eq!(balance.pending, Amount::from_cents(24)); + } + + #[test] + fn an_entry_inside_its_jitter_window_counts_as_pending() { + let params = CoinageParameters::default(); + let mut entry = RecyclerEntry::allocated( + PurseId::MAIN, + EntryIndex(0), + exponent(4), + NOW, + Duration::from_secs(60), + ); + entry.observe_ring( + RingLocation::new(RingIndex(1), RevisionIndex(0)), + 32, + ¶ms, + ); + + let balance = compute_balance(&[], core::slice::from_ref(&entry), NOW); + + assert_eq!(balance.spendable, Amount::ZERO); + assert_eq!(balance.pending, Amount::from_cents(16)); + } + + #[test] + fn consumed_entries_count_nowhere() { + let mut entry = entry_with(0, 4, EntryOnChainState::Ready); + entry.lock_for(OperationHandle(1)).expect("lock is valid"); + entry + .mark_consumed(OperationHandle(1)) + .expect("consume is valid"); + + let balance = compute_balance(&[], core::slice::from_ref(&entry), NOW); + + assert_eq!(balance, PurseBalance::default()); + } + + #[test] + fn strict_spendable_never_exceeds_spendable() { + let coins = vec![available_coin(0, 3)]; + let entries = vec![ + entry_with(0, 4, EntryOnChainState::Ready), + entry_with(1, 2, EntryOnChainState::Degraded(1)), + entry_with(2, 5, EntryOnChainState::Waiting), + ]; + + let balance = compute_balance(&coins, &entries, NOW); + + assert!(balance.spendable_strict <= balance.spendable); + assert_eq!(balance.spendable, Amount::from_cents(8 + 16 + 4)); + assert_eq!(balance.spendable_strict, Amount::from_cents(8 + 16)); + assert_eq!(balance.pending, Amount::from_cents(32)); + } + + #[test] + fn purse_info_carries_the_balance_alongside_identity() { + let purse = Purse::new(PurseId(7), "Groceries".to_string()); + let balance = PurseBalance { + spendable: Amount::from_cents(10), + spendable_strict: Amount::from_cents(6), + pending: Amount::from_cents(4), + }; + + let info = PurseInfo::new(&purse, balance); + + assert_eq!(info.id, PurseId(7)); + assert_eq!(info.name, "Groceries"); + assert_eq!(info.spendable, Amount::from_cents(10)); + assert_eq!(info.spendable_strict, Amount::from_cents(6)); + assert_eq!(info.pending, Amount::from_cents(4)); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/recovery.rs b/rust/crates/truapi-server/src/host_logic/coinage/recovery.rs new file mode 100644 index 000000000..70315fda8 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/recovery.rs @@ -0,0 +1,239 @@ +//! Resolving in-flight transactions against finalized chain state. +//! +//! `coinage-layer.md` §7.7. This is the slow, guaranteed path: it decides what +//! became of a logged transaction from **finalized** state alone, needing +//! neither the transaction's hash nor its events, so it works after a crash in +//! which the layer never saw either. +//! +//! Distinct from wallet recovery (§8.10), which rebuilds a whole wallet from +//! root entropy. This one resolves work that was already in flight. +//! +//! The decision is pure and lives here; issuing the chain reads belongs to +//! `runtime::coinage::recover`. Keeping them apart means every branch of the +//! procedure — including the ones a live chain would take days to produce — is +//! exercisable in a unit test. + +use super::log::{Checkpoint, LogEntry, LogEntryState}; + +/// What a finalized block says about one logged transaction's records. +/// +/// Both questions are asked because either can answer affirmatively on its own: +/// a transfer's outputs belong to the *recipient*, so the layer will never see +/// them, and only the disappearance of its inputs shows that it landed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RecordObservation { + /// Every record the transaction was expected to create exists on chain. + pub outputs_present: bool, + /// Every record the transaction consumes is gone from chain. + pub inputs_consumed: bool, +} + +/// The verdict for one entry at one finalized block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Resolution { + /// The transaction definitely took effect. + Succeeded { + /// Which observation proved it, for the log's reason string. + evidence: SuccessEvidence, + }, + /// The transaction can never take effect. + Rejected { + /// Why. + reason: String, + }, + /// Nothing is decided yet; ask again at the next finalized block. + StillPending, +} + +/// How a success was established. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SuccessEvidence { + /// The transaction's own outputs are on chain. + OutputsPresent, + /// The outputs are not visible, but the inputs are gone, so something + /// consumed them — for a transfer, the recipient has already claimed. + InputsConsumed, +} + +/// Decide one entry's fate from a finalized observation. +/// +/// The caller must have resolved every entry this one depends on first, and +/// must have run the abandonment cascade; this function assumes both and +/// answers only the chain-state question. See §7.5 for why the order matters: +/// an absent output is consistent with "the predecessor never landed" and with +/// "the predecessor landed and this transaction consumed it", and only the +/// predecessor's own verdict separates them. +pub fn resolve( + entry: &LogEntry, + observation: RecordObservation, + finalized_height: u64, +) -> Resolution { + if observation.outputs_present { + return Resolution::Succeeded { + evidence: SuccessEvidence::OutputsPresent, + }; + } + if observation.inputs_consumed { + return Resolution::Succeeded { + evidence: SuccessEvidence::InputsConsumed, + }; + } + if entry.checkpoint.has_expired(finalized_height) { + return Resolution::Rejected { + reason: expiry_reason(&entry.checkpoint, finalized_height), + }; + } + Resolution::StillPending +} + +/// Human-readable expiry, naming the heights so a support log can be checked +/// against the chain. +fn expiry_reason(checkpoint: &Checkpoint, finalized_height: u64) -> String { + format!( + "expired unincluded: era anchored at {} for {} blocks, finalized height {finalized_height}", + checkpoint.number, checkpoint.mortality + ) +} + +/// Turn a resolution into the log state to record. +pub fn log_state( + resolution: &Resolution, + block_hash: super::types::BlockHash, +) -> Option { + match resolution { + Resolution::Succeeded { .. } => Some(LogEntryState::Succeeded { block_hash }), + Resolution::Rejected { reason } => Some(LogEntryState::Rejected { + reason: reason.clone(), + }), + Resolution::StillPending => None, + } +} + +#[cfg(test)] +mod tests { + use super::super::operation::LockSet; + use super::super::types::{BlockHash, CoinIndex, PurseId}; + use super::*; + + fn checkpoint() -> Checkpoint { + Checkpoint { + number: 1_000, + hash: BlockHash([1; 32]), + mortality: 256, + } + } + + fn entry() -> LogEntry { + LogEntry::planned( + 0, + LockSet { + coins: vec![(PurseId::MAIN, CoinIndex(1))], + entries: Vec::new(), + }, + LockSet { + coins: vec![(PurseId::MAIN, CoinIndex(2))], + entries: Vec::new(), + }, + checkpoint(), + ) + } + + fn observation(outputs_present: bool, inputs_consumed: bool) -> RecordObservation { + RecordObservation { + outputs_present, + inputs_consumed, + } + } + + #[test] + fn visible_outputs_prove_success() { + assert_eq!( + resolve(&entry(), observation(true, true), 1_100), + Resolution::Succeeded { + evidence: SuccessEvidence::OutputsPresent + } + ); + } + + #[test] + fn consumed_inputs_prove_success_even_when_outputs_are_invisible() { + // A transfer's outputs belong to the recipient. The layer can never see + // them, so the only evidence it will ever get is that its own inputs + // are gone. + assert_eq!( + resolve(&entry(), observation(false, true), 1_100), + Resolution::Succeeded { + evidence: SuccessEvidence::InputsConsumed + } + ); + } + + #[test] + fn nothing_observed_and_still_in_the_era_stays_pending() { + assert_eq!( + resolve(&entry(), observation(false, false), 1_100), + Resolution::StillPending + ); + } + + #[test] + fn nothing_observed_past_the_era_is_definitely_dead() { + let resolution = resolve(&entry(), observation(false, false), 1_257); + + let Resolution::Rejected { reason } = resolution else { + unreachable!("past the era, inclusion is impossible"); + }; + assert!(reason.contains("1000"), "names the anchor: {reason}"); + assert!(reason.contains("256"), "names the period: {reason}"); + assert!(reason.contains("1257"), "names the height: {reason}"); + } + + #[test] + fn expiry_never_overrides_observed_success() { + // The order matters: a transaction can land inside its era and only be + // observed afterwards. Checking expiry first would declare a landed + // transaction dead and release inputs the chain has already consumed. + assert!(matches!( + resolve(&entry(), observation(true, false), 99_999), + Resolution::Succeeded { .. } + )); + assert!(matches!( + resolve(&entry(), observation(false, true), 99_999), + Resolution::Succeeded { .. } + )); + } + + #[test] + fn the_era_boundary_is_not_yet_expired() { + assert_eq!( + resolve(&entry(), observation(false, false), 1_256), + Resolution::StillPending, + "still includable at exactly the last valid block" + ); + } + + #[test] + fn only_a_decided_resolution_yields_a_log_state() { + let block = BlockHash([7; 32]); + + assert_eq!( + log_state( + &Resolution::Succeeded { + evidence: SuccessEvidence::OutputsPresent + }, + block + ), + Some(LogEntryState::Succeeded { block_hash: block }) + ); + assert!(matches!( + log_state( + &Resolution::Rejected { + reason: "expired".to_string() + }, + block + ), + Some(LogEntryState::Rejected { .. }) + )); + assert_eq!(log_state(&Resolution::StillPending, block), None); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/selection.rs b/rust/crates/truapi-server/src/host_logic/coinage/selection.rs new file mode 100644 index 000000000..cf3b348aa --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/selection.rs @@ -0,0 +1,1265 @@ +//! Coin and recycler-entry selection. +//! +//! Selection answers one question: which records should an operation consume to +//! produce a requested amount inside coinage? Three strategies are tried in +//! priority order — exact match, split, unload-into-coins — and the first that +//! succeeds wins. +//! +//! Ordering is fixed before any strategy runs, so two conformant +//! implementations with the same purse contents choose the same records. That +//! determinism is a conformance requirement, not an optimization: it is what +//! lets an implementation be swapped without changing on-chain behaviour. +//! +//! Selection is pure. It reads a snapshot of the purse's records and returns a +//! plan; locking, signing, and submission belong to the caller. + +use std::collections::BTreeMap; + +use super::chain_constants::CoinageChainConstants; +use super::coin::{Coin, CoinState}; +use super::entry::RecyclerEntry; +use super::error::CoinageError; +use super::operation::LockSet; +use super::params::canonical_breakdown; +use super::types::{ + Amount, CoinIndex, DenominationExponent, EntryIndex, PurseId, RingLocation, Timestamp, +}; + +/// What denominations the caller needs selection to produce. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutputRequirement { + /// Any denominations are acceptable as long as they total the requested + /// amount. Export and rebalance use this: the coins either leave under + /// their own secrets or move into another purse, so their shape is free. + AnyDenominations, + /// The produced coins must have exactly these denominations. Transfer uses + /// this, because each output is destined for a separately named recipient + /// account. + Exact(Vec), +} + +/// A request to produce value from a purse. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionRequest { + /// Total value to produce. + pub amount: Amount, + /// Shape the produced coins must take. + pub outputs: OutputRequirement, + /// Whether recycler entries below the anonymity floor may be used. + pub allow_degraded: bool, +} + +/// Which strategy produced a plan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionTier { + /// Available coins already have the needed shape. No preparatory extrinsic. + ExactMatch, + /// One coin is split to make up the remainder. One extrinsic. + Split, + /// Recycler entries are unloaded into fresh coins. One extrinsic per group, + /// each consuming an unload token. + UnloadIntoCoins, +} + +/// A coin selection chose. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SelectedCoin { + /// Derivation index within the purse. + pub index: CoinIndex, + /// Denomination. + pub exponent: DenominationExponent, +} + +/// A split of one coin into the denominations the request needs plus change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SplitStep { + /// The coin being split. + pub coin: SelectedCoin, + /// Denominations produced toward the requested amount. + pub target_outputs: Vec, + /// Denominations returned to the purse. + pub change_outputs: Vec, +} + +/// Recycler entries of one denomination in one ring, unloaded together. +/// +/// A group is one atomic extrinsic carrying one unload token, so grouping +/// directly determines how many tokens an operation spends. The group's output +/// value equals its input value: its own change absorbs whatever the request +/// does not need. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadGroup { + /// Where the entries sit on chain. Index and revision both go into the + /// unload call. + pub ring: RingLocation, + /// Denomination shared by every entry in the group. + pub exponent: DenominationExponent, + /// Entries consumed, in deterministic order. + pub entries: Vec, + /// Denominations produced toward the requested amount. + pub target_outputs: Vec, + /// Denominations returned to the purse. + pub change_outputs: Vec, +} + +/// How an operation should produce the requested amount. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionPlan { + /// Strategy that produced this plan. + pub tier: SelectionTier, + /// Coins consumed as they are, with no preparatory extrinsic. + pub whole_coins: Vec, + /// The coin to split, if the plan needs one. + pub split: Option, + /// Entry groups to unload, each one extrinsic. + pub unloads: Vec, +} + +impl SelectionPlan { + /// Total value the plan directs toward the request. + pub fn target_value(&self) -> Amount { + let whole: Amount = self + .whole_coins + .iter() + .map(|coin| coin.exponent.value()) + .sum(); + let split: Amount = self + .split + .iter() + .flat_map(|step| step.target_outputs.iter()) + .map(|exponent| exponent.value()) + .sum(); + let unloaded: Amount = self + .unloads + .iter() + .flat_map(|group| group.target_outputs.iter()) + .map(|exponent| exponent.value()) + .sum(); + + [whole, split, unloaded] + .into_iter() + .fold(Amount::ZERO, |acc, part| { + acc.checked_add(part).unwrap_or(acc) + }) + } + + /// Number of preparatory extrinsics the plan needs before value can move. + pub fn preparatory_extrinsics(&self) -> usize { + usize::from(self.split.is_some()) + self.unloads.len() + } + + /// Number of unload tokens the plan consumes. + pub fn unload_tokens_required(&self) -> usize { + self.unloads.len() + } + + /// The records the operation must hold until it terminates. + pub fn lock_set(&self, purse: PurseId) -> LockSet { + let mut locks = LockSet::default(); + + for coin in &self.whole_coins { + locks.coins.push((purse, coin.index)); + } + if let Some(step) = &self.split { + locks.coins.push((purse, step.coin.index)); + } + for group in &self.unloads { + for entry in &group.entries { + locks.entries.push((purse, *entry)); + } + } + + locks + } +} + +/// Choose records to produce `request.amount` from a purse's local view. +/// +/// `coins` and `entries` are the purse's records; non-selectable ones are +/// filtered out here rather than by the caller, so the failure classification +/// can tell "you have no funds" from "your funds are not ready yet". +/// +/// Note the absence of [`super::params::CoinageParameters`]: selection is +/// policy-free. The anonymity floor is applied when a ring is observed, so by +/// the time an entry reaches selection its readiness already reflects it, and +/// the only limits left to respect are the chain's. +pub fn select( + request: &SelectionRequest, + coins: &[Coin], + entries: &[RecyclerEntry], + constants: &CoinageChainConstants, + now: Timestamp, +) -> Result { + let targets = target_denominations(request, constants)?; + + if request.amount.is_zero() { + return Ok(SelectionPlan { + tier: SelectionTier::ExactMatch, + whole_coins: Vec::new(), + split: None, + unloads: Vec::new(), + }); + } + + let available_coins = ordered_coins(coins, now); + let available_entries = ordered_entries(entries, now, request.allow_degraded); + + if let Some(plan) = try_exact_match(request, &targets, &available_coins) { + return Ok(plan); + } + if let Some(plan) = try_split(request, &targets, &available_coins, constants) { + return Ok(plan); + } + if let Some(plan) = try_unload( + request, + &targets, + &available_coins, + &available_entries, + constants, + ) { + return Ok(plan); + } + + Err(classify_failure( + request, + coins, + entries, + &available_coins, + &available_entries, + )) +} + +/// The denominations a plan must produce, largest first. +fn target_denominations( + request: &SelectionRequest, + constants: &CoinageChainConstants, +) -> Result, CoinageError> { + match &request.outputs { + OutputRequirement::AnyDenominations => { + canonical_breakdown(request.amount, largest_denomination(constants)?).ok_or( + CoinageError::UnsatisfiableOutputs { + requested: request.amount, + available: Amount::ZERO, + }, + ) + } + OutputRequirement::Exact(outputs) => { + let total: Amount = outputs.iter().map(|exponent| exponent.value()).sum(); + if total != request.amount { + return Err(CoinageError::OutputsDoNotSumToAmount); + } + if let Some(rejected) = outputs.iter().find(|output| !constants.accepts(**output)) { + return Err(CoinageError::Internal(format!( + "requested denomination {rejected} is outside the runtime's range" + ))); + } + + let mut sorted = outputs.clone(); + sorted.sort_by(|left, right| right.cmp(left)); + Ok(sorted) + } + } +} + +/// The runtime's largest mintable denomination. +fn largest_denomination( + constants: &CoinageChainConstants, +) -> Result { + constants.largest_denomination().ok_or_else(|| { + CoinageError::Internal(format!( + "runtime MaximumExponent {} is not a representable denomination", + constants.maximum_exponent + )) + }) +} + +/// Selectable coins in the layer's canonical order: largest denomination first, +/// then oldest, then lowest index. +/// +/// Preferring older coins is what makes payment traffic refresh a wallet +/// implicitly, so the age sweep has less to do. +fn ordered_coins(coins: &[Coin], now: Timestamp) -> Vec<&Coin> { + let mut selectable: Vec<&Coin> = coins + .iter() + .filter(|coin| coin.is_selectable(now)) + .collect(); + selectable.sort_by(|left, right| { + right + .exponent + .cmp(&left.exponent) + .then(right.age.cmp(&left.age)) + .then(left.index.cmp(&right.index)) + }); + selectable +} + +/// Selectable entries in the layer's canonical order: largest denomination +/// first, then lowest ring, then lowest index. +fn ordered_entries( + entries: &[RecyclerEntry], + now: Timestamp, + allow_degraded: bool, +) -> Vec<&RecyclerEntry> { + let mut selectable: Vec<&RecyclerEntry> = entries + .iter() + .filter(|entry| entry.is_selectable(now, allow_degraded)) + .collect(); + selectable.sort_by(|left, right| { + right + .exponent + .cmp(&left.exponent) + .then(ring_sort_key(left).cmp(&ring_sort_key(right))) + .then(left.index.cmp(&right.index)) + }); + selectable +} + +/// Ringless entries sort last; they are filtered out before this runs, so the +/// fallback only guards against an inconsistent snapshot. +fn ring_sort_key(entry: &RecyclerEntry) -> u32 { + entry.ring.map_or(u32::MAX, |ring| ring.index.0) +} + +/// Tier 1: the purse already holds coins of the right shape. +fn try_exact_match( + request: &SelectionRequest, + targets: &[DenominationExponent], + available: &[&Coin], +) -> Option { + let chosen = match &request.outputs { + // Any shape will do, so take the largest coins that still fit. With + // power-of-two denominations this greedy pass finds an exact subset + // whenever one exists. + OutputRequirement::AnyDenominations => { + let mut remaining = request.amount; + let mut chosen = Vec::new(); + + for coin in available { + if coin.value() <= remaining { + remaining = remaining.saturating_sub(coin.value()); + chosen.push(selected(coin)); + if remaining.is_zero() { + break; + } + } + } + + remaining.is_zero().then_some(chosen)? + } + // Each output goes to its own account, so a coin can only serve a + // target of exactly its denomination. + OutputRequirement::Exact(_) => { + let mut used = vec![false; available.len()]; + let mut chosen = Vec::new(); + + for target in targets { + let position = available + .iter() + .enumerate() + .position(|(index, coin)| !used[index] && coin.exponent == *target)?; + used[position] = true; + chosen.push(selected(available[position])); + } + + chosen + } + }; + + Some(SelectionPlan { + tier: SelectionTier::ExactMatch, + whole_coins: chosen, + split: None, + unloads: Vec::new(), + }) +} + +/// Tier 2: one split extrinsic makes up what whole coins cannot. +/// +/// The spec's preference order is deliberate — a single oversized coin is tried +/// before a multi-coin cover, so the common case spends one coin rather than +/// fragmenting several. +fn try_split( + request: &SelectionRequest, + targets: &[DenominationExponent], + available: &[&Coin], + constants: &CoinageChainConstants, +) -> Option { + if let Some(plan) = split_single(request.amount, targets, available, &[], constants) { + return Some(plan); + } + + // Multi-coin cover: take whole coins in order while they fit under the + // remainder, then split the coin that crosses it. + let mut remaining = request.amount; + let mut unmet: Vec = targets.to_vec(); + let mut whole = Vec::new(); + let mut consumed = Vec::new(); + + for (position, coin) in available.iter().enumerate() { + if remaining.is_zero() { + break; + } + if coin.value() > remaining { + continue; + } + // Under `Exact`, a whole coin is only useful if some unmet target has + // precisely its denomination. + if matches!(request.outputs, OutputRequirement::Exact(_)) { + match unmet.iter().position(|target| *target == coin.exponent) { + Some(target) => { + unmet.remove(target); + } + None => continue, + } + } + + remaining = remaining.saturating_sub(coin.value()); + whole.push(selected(coin)); + consumed.push(position); + } + + if remaining.is_zero() || whole.is_empty() { + return None; + } + + let unmet_targets = match &request.outputs { + OutputRequirement::AnyDenominations => { + canonical_breakdown(remaining, constants.largest_denomination()?)? + } + OutputRequirement::Exact(_) => unmet, + }; + + let mut plan = split_single(remaining, &unmet_targets, available, &consumed, constants)?; + plan.whole_coins.splice(0..0, whole); + Some(plan) +} + +/// Find the smallest unused coin that covers `remaining` and split it. +/// +/// The spec phrases this as *strictly* greater than the remainder, which holds +/// when any shape will do: a coin worth exactly the remainder would already +/// have been taken whole by tier 1. Under `Exact` that is not so — a coin can +/// match the value and still be the wrong shape, as when one 16-cent coin must +/// become two 8-cent outputs to two accounts — so equality qualifies too, and +/// the split simply produces no change. +fn split_single( + remaining: Amount, + unmet_targets: &[DenominationExponent], + available: &[&Coin], + consumed: &[usize], + constants: &CoinageChainConstants, +) -> Option { + // `available` is largest-first, so searching from the back finds the + // smallest coin that still covers the remainder. + let candidate = available + .iter() + .enumerate() + .rfind(|(position, coin)| !consumed.contains(position) && coin.value() >= remaining)?; + + let (_, coin) = candidate; + let change = coin.value().checked_sub(remaining)?; + let change_outputs = canonical_breakdown(change, constants.largest_denomination()?)?; + + let total_outputs = unmet_targets.len() + change_outputs.len(); + if total_outputs > constants.max_split_outputs as usize { + return None; + } + + Some(SelectionPlan { + tier: SelectionTier::Split, + whole_coins: Vec::new(), + split: Some(SplitStep { + coin: selected(coin), + target_outputs: unmet_targets.to_vec(), + change_outputs, + }), + unloads: Vec::new(), + }) +} + +/// Tier 3: mint fresh coins by unloading recycler entries. +fn try_unload( + request: &SelectionRequest, + targets: &[DenominationExponent], + available_coins: &[&Coin], + available_entries: &[&RecyclerEntry], + constants: &CoinageChainConstants, +) -> Option { + if available_entries.is_empty() { + return None; + } + + // Whole coins cover what they can without overshooting; entries make up the + // deficit. + let mut remaining = request.amount; + let mut unmet: Vec = targets.to_vec(); + let mut whole = Vec::new(); + + for coin in available_coins { + if remaining.is_zero() || coin.value() > remaining { + continue; + } + if matches!(request.outputs, OutputRequirement::Exact(_)) { + match unmet.iter().position(|target| *target == coin.exponent) { + Some(target) => { + unmet.remove(target); + } + None => continue, + } + } + + remaining = remaining.saturating_sub(coin.value()); + whole.push(selected(coin)); + } + + if remaining.is_zero() { + return None; + } + + let chosen = choose_entries(remaining, available_entries)?; + let groups = group_entries(&chosen, constants); + + let unloads = assign_outputs(groups, &request.outputs, remaining, unmet, constants)?; + + Some(SelectionPlan { + tier: SelectionTier::UnloadIntoCoins, + whole_coins: whole, + split: None, + unloads, + }) +} + +/// Prefer one entry that covers the deficit on its own; otherwise take entries +/// in order until they do. +fn choose_entries<'a>( + deficit: Amount, + available: &[&'a RecyclerEntry], +) -> Option> { + if let Some(single) = available.iter().rfind(|entry| entry.value() >= deficit) { + return Some(vec![single]); + } + + let mut covered = Amount::ZERO; + let mut chosen = Vec::new(); + + for entry in available { + covered = covered.checked_add(entry.value())?; + chosen.push(*entry); + if covered >= deficit { + return Some(chosen); + } + } + + None +} + +/// Bucket entries by `(denomination, ring)`, respecting the pallet's +/// consolidation cap. Buckets keep the order in which they were first seen, so +/// grouping stays deterministic. +fn group_entries( + chosen: &[&RecyclerEntry], + constants: &CoinageChainConstants, +) -> Vec<(DenominationExponent, RingLocation, Vec)> { + let mut buckets: BTreeMap<(DenominationExponent, RingLocation), Vec> = + BTreeMap::new(); + let mut order: Vec<(DenominationExponent, RingLocation)> = Vec::new(); + + for entry in chosen { + let Some(ring) = entry.ring else { + continue; + }; + let key = (entry.exponent, ring); + if !buckets.contains_key(&key) { + order.push(key); + } + buckets.entry(key).or_default().push(entry.index); + } + + let cap = constants.max_consolidation.max(1) as usize; + let mut groups = Vec::new(); + + for key in order { + let Some(indices) = buckets.remove(&key) else { + continue; + }; + for chunk in indices.chunks(cap) { + groups.push((key.0, key.1, chunk.to_vec())); + } + } + + groups +} + +/// Hand each group as much of the outstanding request as it can carry; whatever +/// a group does not spend comes back as its own change. +/// +/// The two output requirements need different arithmetic. When any shape will +/// do, a group contributes value and its outputs are derived from what it +/// contributed — so a request for 20 cents can be met by three small groups +/// none of which could mint a 16-cent coin on its own. When the caller named +/// the denominations, each one must be minted whole by a single group, because +/// a coin cannot span two extrinsics. +fn assign_outputs( + groups: Vec<(DenominationExponent, RingLocation, Vec)>, + outputs: &OutputRequirement, + deficit: Amount, + unmet_targets: Vec, + constants: &CoinageChainConstants, +) -> Option> { + let mut outstanding_value = deficit; + let mut outstanding_targets = unmet_targets; + let mut unloads = Vec::new(); + + for (exponent, ring, entries) in groups { + let group_value = + Amount::from_cents(exponent.value().cents().checked_mul(entries.len() as u64)?); + + let (target_outputs, spent) = match outputs { + OutputRequirement::AnyDenominations => { + let contribution = group_value.min(outstanding_value); + ( + canonical_breakdown(contribution, constants.largest_denomination()?)?, + contribution, + ) + } + OutputRequirement::Exact(_) => { + let mut budget = group_value; + let mut chosen = Vec::new(); + let mut index = 0; + + while index < outstanding_targets.len() { + let candidate = outstanding_targets[index]; + if candidate.value() <= budget { + budget = budget.saturating_sub(candidate.value()); + chosen.push(candidate); + outstanding_targets.remove(index); + } else { + index += 1; + } + } + + let spent = group_value.saturating_sub(budget); + (chosen, spent) + } + }; + + outstanding_value = outstanding_value.saturating_sub(spent); + let change_outputs = canonical_breakdown( + group_value.saturating_sub(spent), + constants.largest_denomination()?, + )?; + + if target_outputs.len() + change_outputs.len() > constants.max_split_outputs as usize { + return None; + } + + unloads.push(UnloadGroup { + ring, + exponent, + entries, + target_outputs, + change_outputs, + }); + } + + // Nothing may be left over. Which ledger has to balance depends on the + // requirement: value when any shape will do, named denominations otherwise. + let settled = match outputs { + OutputRequirement::AnyDenominations => outstanding_value.is_zero(), + OutputRequirement::Exact(_) => outstanding_targets.is_empty(), + }; + settled.then_some(unloads) +} + +/// Explain why no strategy succeeded. +/// +/// The three outcomes call for different responses from the caller, so the +/// distinction is worth drawing precisely: +/// +/// - Not enough value exists, even counting every entry that is merely waiting +/// — a dead end until the purse is funded. +/// - Enough exists but some of it is not selectable yet — resolves on its own +/// once rings fill and jitter elapses, so the caller should retry later. +/// - Everything that will ever be selectable already is, and it covers the +/// amount, yet no plan could be built — waiting will not help, because the +/// obstacle is shape rather than quantity. +fn classify_failure( + request: &SelectionRequest, + coins: &[Coin], + entries: &[RecyclerEntry], + available_coins: &[&Coin], + available_entries: &[&RecyclerEntry], +) -> CoinageError { + use super::entry::EntryLocalState; + + let coins_now: Amount = available_coins.iter().map(|coin| coin.value()).sum(); + let entries_now: Amount = available_entries.iter().map(|entry| entry.value()).sum(); + let available = coins_now.checked_add(entries_now).unwrap_or(coins_now); + + // What the purse could offer if every entry were ready, degraded rings were + // acceptable, and every chain-side coin lock had expired. Locked and + // terminal records stay excluded: they are not waiting on anything the + // caller can outlast, whereas all three of those conditions clear with time. + let eventual_entries: Amount = entries + .iter() + .filter(|entry| entry.local == EntryLocalState::Available) + .map(|entry| entry.value()) + .sum(); + let eventual_coins: Amount = coins + .iter() + .filter(|coin| coin.state == CoinState::Available) + .map(|coin| coin.value()) + .sum(); + let available_when_ready = eventual_coins + .checked_add(eventual_entries) + .unwrap_or(eventual_coins); + + if available_when_ready < request.amount { + CoinageError::InsufficientFunds { + requested: request.amount, + available, + } + } else if available_when_ready > available { + CoinageError::NoReadyEntries { + requested: request.amount, + available_when_ready, + } + } else { + CoinageError::UnsatisfiableOutputs { + requested: request.amount, + available, + } + } +} + +fn selected(coin: &Coin) -> SelectedCoin { + SelectedCoin { + index: coin.index, + exponent: coin.exponent, + } +} + +#[cfg(test)] +mod tests { + use core::time::Duration; + + use super::super::chain_constants::next_people_paseo; + use super::super::entry::EntryOnChainState; + use super::super::types::{CoinAge, RevisionIndex, RingIndex}; + use super::*; + + const NOW: Timestamp = Timestamp(1_000_000); + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn coin(index: u32, exponent_value: i8, age: u16) -> Coin { + let mut coin = Coin::pending(PurseId::MAIN, CoinIndex(index), exponent(exponent_value)); + coin.observe_populated(CoinAge(age)) + .expect("observe is valid"); + coin + } + + fn ring(index: u32) -> RingLocation { + RingLocation::new(RingIndex(index), RevisionIndex(0)) + } + + fn constants() -> CoinageChainConstants { + next_people_paseo() + } + + fn entry(index: u32, exponent_value: i8, ring_index: u32) -> RecyclerEntry { + let mut entry = RecyclerEntry::allocated( + PurseId::MAIN, + EntryIndex(index), + exponent(exponent_value), + Timestamp(0), + Duration::ZERO, + ); + entry.ring = Some(ring(ring_index)); + entry.on_chain = EntryOnChainState::Ready; + entry + } + + fn request(cents: u64) -> SelectionRequest { + SelectionRequest { + amount: Amount::from_cents(cents), + outputs: OutputRequirement::AnyDenominations, + allow_degraded: true, + } + } + + fn exact_request(cents: u64, outputs: &[i8]) -> SelectionRequest { + SelectionRequest { + amount: Amount::from_cents(cents), + outputs: OutputRequirement::Exact(outputs.iter().copied().map(exponent).collect()), + allow_degraded: true, + } + } + + fn select_from( + request: &SelectionRequest, + coins: &[Coin], + entries: &[RecyclerEntry], + ) -> Result { + select(request, coins, entries, &constants(), NOW) + } + + #[test] + fn ordering_is_largest_then_oldest_then_lowest_index() { + let coins = vec![ + coin(5, 2, 1), + coin(1, 4, 0), + coin(2, 4, 3), + coin(0, 4, 3), + coin(9, 3, 7), + ]; + + let ordered: Vec = ordered_coins(&coins, NOW) + .iter() + .map(|c| c.index.0) + .collect(); + + assert_eq!(ordered, vec![0, 2, 1, 9, 5]); + } + + #[test] + fn entry_ordering_is_largest_then_lowest_ring_then_lowest_index() { + let entries = vec![ + entry(3, 2, 1), + entry(1, 4, 7), + entry(2, 4, 2), + entry(0, 4, 2), + ]; + + let ordered: Vec = ordered_entries(&entries, NOW, true) + .iter() + .map(|e| e.index.0) + .collect(); + + assert_eq!(ordered, vec![0, 2, 1, 3]); + } + + #[test] + fn unselectable_records_are_never_offered() { + let mut locked = coin(0, 4, 0); + locked + .lock_for(super::super::types::OperationHandle(1)) + .expect("lock is valid"); + let coins = vec![ + locked, + Coin::pending(PurseId::MAIN, CoinIndex(1), exponent(4)), + ]; + + assert!(ordered_coins(&coins, NOW).is_empty()); + } + + #[test] + fn a_chain_locked_coin_is_never_offered() { + let mut locked = coin(0, 4, 0); + locked.observe_chain_lock(Some(Timestamp(NOW.0 + 1))); + + assert!(ordered_coins(&[locked], NOW).is_empty()); + } + + #[test] + fn a_chain_locked_coin_reads_as_wait_not_as_no_funds() { + // The distinction the caller acts on: a locked coin comes back, so the + // right advice is to retry, not to tell the user they are out of money. + let mut locked = coin(0, 4, 0); + locked.observe_chain_lock(Some(Timestamp(NOW.0 + 1))); + + let error = select_from(&request(16), &[locked], &[]).expect_err("nothing is selectable"); + + assert!( + matches!( + error, + CoinageError::NoReadyEntries { + available_when_ready, + .. + } if available_when_ready == Amount::from_cents(16) + ), + "expected a wait classification, got {error:?}" + ); + } + + #[test] + fn a_zero_amount_selects_nothing() { + let plan = select_from(&request(0), &[], &[]).expect("zero is always satisfiable"); + + assert_eq!(plan.tier, SelectionTier::ExactMatch); + assert!(plan.whole_coins.is_empty()); + assert_eq!(plan.preparatory_extrinsics(), 0); + } + + #[test] + fn exact_match_needs_no_preparatory_extrinsic() { + let coins = vec![coin(0, 3, 0), coin(1, 2, 0)]; + + let plan = select_from(&request(12), &coins, &[]).expect("12 = 8 + 4"); + + assert_eq!(plan.tier, SelectionTier::ExactMatch); + assert_eq!(plan.preparatory_extrinsics(), 0); + assert_eq!(plan.unload_tokens_required(), 0); + assert_eq!(plan.target_value(), Amount::from_cents(12)); + } + + #[test] + fn exact_match_prefers_larger_and_older_coins() { + let coins = vec![coin(0, 3, 0), coin(1, 3, 5), coin(2, 2, 0)]; + + let plan = select_from(&request(8), &coins, &[]).expect("one 8-cent coin suffices"); + + assert_eq!(plan.whole_coins.len(), 1); + assert_eq!(plan.whole_coins[0].index, CoinIndex(1)); + } + + #[test] + fn exact_match_finds_a_subset_of_smaller_coins() { + // 16 is reachable as 8 + 4 + 4 even though a single 32 coin overshoots. + let coins = vec![coin(0, 5, 0), coin(1, 3, 0), coin(2, 2, 0), coin(3, 2, 0)]; + + let plan = select_from(&request(16), &coins, &[]).expect("8 + 4 + 4 = 16"); + + assert_eq!(plan.tier, SelectionTier::ExactMatch); + assert_eq!(plan.target_value(), Amount::from_cents(16)); + assert_eq!(plan.whole_coins.len(), 3); + } + + #[test] + fn split_takes_the_smallest_coin_that_covers_the_amount() { + let coins = vec![coin(0, 6, 0), coin(1, 5, 0), coin(2, 4, 0)]; + + let plan = select_from(&request(12), &coins, &[]).expect("split the 16-cent coin"); + + assert_eq!(plan.tier, SelectionTier::Split); + let step = plan.split.as_ref().expect("a split step is present"); + assert_eq!(step.coin.index, CoinIndex(2)); + assert_eq!(step.target_outputs, vec![exponent(3), exponent(2)]); + assert_eq!(step.change_outputs, vec![exponent(2)]); + assert_eq!(plan.preparatory_extrinsics(), 1); + } + + #[test] + fn split_output_value_is_conserved() { + let coins = vec![coin(0, 5, 0)]; + + let plan = select_from(&request(20), &coins, &[]).expect("split the 32-cent coin"); + let step = plan.split.as_ref().expect("a split step is present"); + + let produced: Amount = step + .target_outputs + .iter() + .chain(step.change_outputs.iter()) + .map(|exponent| exponent.value()) + .sum(); + + assert_eq!(produced, Amount::from_cents(32)); + assert_eq!(plan.target_value(), Amount::from_cents(20)); + } + + #[test] + fn split_falls_back_to_a_multi_coin_cover() { + // No single coin exceeds 24, but 16 whole plus a split of 16 reaches it. + let coins = vec![coin(0, 4, 0), coin(1, 4, 0)]; + + let plan = select_from(&request(24), &coins, &[]).expect("16 + split(16)"); + + assert_eq!(plan.tier, SelectionTier::Split); + assert_eq!(plan.whole_coins.len(), 1); + let step = plan.split.as_ref().expect("a split step is present"); + assert_eq!(step.target_outputs, vec![exponent(3)]); + assert_eq!(step.change_outputs, vec![exponent(3)]); + assert_eq!(plan.target_value(), Amount::from_cents(24)); + } + + #[test] + fn unload_is_used_only_when_coins_cannot_cover_the_amount() { + let entries = vec![entry(0, 5, 1)]; + + let plan = select_from(&request(24), &[], &entries).expect("unload the 32-cent entry"); + + assert_eq!(plan.tier, SelectionTier::UnloadIntoCoins); + assert_eq!(plan.unload_tokens_required(), 1); + assert_eq!(plan.target_value(), Amount::from_cents(24)); + + let group = &plan.unloads[0]; + assert_eq!(group.entries, vec![EntryIndex(0)]); + assert_eq!(group.target_outputs, vec![exponent(4), exponent(3)]); + assert_eq!(group.change_outputs, vec![exponent(3)]); + } + + #[test] + fn unload_group_output_value_equals_its_input_value() { + let entries = vec![entry(0, 4, 1), entry(1, 4, 1)]; + + let plan = select_from(&request(20), &[], &entries).expect("two 16-cent entries cover 20"); + + let group = &plan.unloads[0]; + let produced: Amount = group + .target_outputs + .iter() + .chain(group.change_outputs.iter()) + .map(|exponent| exponent.value()) + .sum(); + + assert_eq!(produced, Amount::from_cents(32)); + assert_eq!(plan.target_value(), Amount::from_cents(20)); + } + + #[test] + fn unload_prefers_a_single_sufficient_entry() { + let entries = vec![entry(0, 6, 1), entry(1, 5, 1), entry(2, 3, 1)]; + + let plan = select_from(&request(20), &[], &entries).expect("one 32-cent entry covers 20"); + + assert_eq!(plan.unloads.len(), 1); + assert_eq!(plan.unloads[0].entries, vec![EntryIndex(1)]); + } + + #[test] + fn unload_combines_whole_coins_with_entries() { + let coins = vec![coin(0, 3, 0)]; + let entries = vec![entry(0, 3, 1)]; + + let plan = select_from(&request(16), &coins, &entries).expect("8 whole + 8 unloaded"); + + assert_eq!(plan.tier, SelectionTier::UnloadIntoCoins); + assert_eq!(plan.whole_coins.len(), 1); + assert_eq!(plan.unloads.len(), 1); + assert_eq!(plan.target_value(), Amount::from_cents(16)); + } + + #[test] + fn entries_are_grouped_by_denomination_and_ring() { + let entries = vec![ + entry(0, 4, 1), + entry(1, 4, 1), + entry(2, 4, 2), + entry(3, 3, 1), + ]; + + let plan = select_from(&request(56), &[], &entries).expect("all four entries are needed"); + + // One extrinsic and one unload token per (denomination, ring) bucket. + assert_eq!(plan.unloads.len(), 3); + assert_eq!(plan.unload_tokens_required(), 3); + assert_eq!(plan.unloads[0].entries, vec![EntryIndex(0), EntryIndex(1)]); + assert_eq!(plan.unloads[1].entries, vec![EntryIndex(2)]); + assert_eq!(plan.unloads[2].entries, vec![EntryIndex(3)]); + assert_eq!(plan.target_value(), Amount::from_cents(56)); + } + + #[test] + fn a_group_never_exceeds_the_consolidation_cap() { + let constants = CoinageChainConstants { + max_consolidation: 2, + ..next_people_paseo() + }; + let entries: Vec = (0..5).map(|index| entry(index, 2, 1)).collect(); + + let plan = + select(&request(20), &[], &entries, &constants, NOW).expect("five 4-cent entries"); + + assert!(plan.unloads.iter().all(|group| group.entries.len() <= 2)); + assert_eq!(plan.target_value(), Amount::from_cents(20)); + } + + #[test] + fn degraded_entries_are_excluded_when_the_caller_forbids_them() { + let mut degraded = entry(0, 5, 1); + degraded.on_chain = EntryOnChainState::Degraded(3); + let entries = vec![degraded]; + + let permissive = SelectionRequest { + allow_degraded: true, + ..request(32) + }; + let strict = SelectionRequest { + allow_degraded: false, + ..request(32) + }; + + assert!(select_from(&permissive, &[], &entries).is_ok()); + assert!(matches!( + select_from(&strict, &[], &entries), + Err(CoinageError::NoReadyEntries { .. }) + )); + } + + #[test] + fn waiting_entries_report_no_ready_entries_rather_than_insufficient_funds() { + let mut waiting = entry(0, 5, 1); + waiting.on_chain = EntryOnChainState::Waiting; + + let error = select_from(&request(32), &[], &[waiting]).expect_err("nothing is selectable"); + + assert_eq!( + error, + CoinageError::NoReadyEntries { + requested: Amount::from_cents(32), + available_when_ready: Amount::from_cents(32), + } + ); + } + + #[test] + fn an_empty_purse_reports_insufficient_funds() { + let error = select_from(&request(8), &[], &[]).expect_err("nothing to select"); + + assert_eq!( + error, + CoinageError::InsufficientFunds { + requested: Amount::from_cents(8), + available: Amount::ZERO, + } + ); + } + + #[test] + fn shortfall_beyond_any_waiting_value_reports_insufficient_funds() { + let mut waiting = entry(0, 2, 1); + waiting.on_chain = EntryOnChainState::Waiting; + let coins = vec![coin(0, 2, 0)]; + + let error = + select_from(&request(1_000), &coins, &[waiting]).expect_err("nowhere near enough"); + + assert!(matches!(error, CoinageError::InsufficientFunds { .. })); + } + + #[test] + fn coins_that_cannot_be_merged_report_unsatisfiable_outputs() { + // Two 8-cent coins hold the requested value, but coinage can split a + // coin and never merge two, so a single 16-cent output is unreachable. + let coins = vec![coin(0, 3, 0), coin(1, 3, 0)]; + + let error = select_from(&exact_request(16, &[4]), &coins, &[]) + .expect_err("no 16-cent coin can be formed"); + + assert_eq!( + error, + CoinageError::UnsatisfiableOutputs { + requested: Amount::from_cents(16), + available: Amount::from_cents(16), + } + ); + } + + #[test] + fn a_denomination_no_group_can_mint_reports_unsatisfiable_outputs() { + // Enough value across five entries, but a named 16-cent output has to + // be minted whole by one group, and the cap holds groups to 8 cents. + let constants = CoinageChainConstants { + max_consolidation: 2, + ..next_people_paseo() + }; + let entries: Vec = (0..5).map(|index| entry(index, 2, 1)).collect(); + let request = exact_request(16, &[4]); + + let error = select(&request, &[], &entries, &constants, NOW) + .expect_err("no group reaches 16 cents"); + + assert_eq!( + error, + CoinageError::UnsatisfiableOutputs { + requested: Amount::from_cents(16), + available: Amount::from_cents(20), + } + ); + } + + #[test] + fn waiting_value_outranks_an_unsatisfiable_shape() { + // The shape is unreachable from what is selectable now, but an entry is + // still ripening, so the caller is told to wait rather than told it is + // impossible. + let coins = vec![coin(0, 3, 0), coin(1, 3, 0)]; + let mut waiting = entry(0, 4, 1); + waiting.on_chain = EntryOnChainState::Waiting; + + let error = select_from(&exact_request(16, &[4]), &coins, &[waiting]) + .expect_err("nothing selectable can form a 16-cent coin"); + + assert_eq!( + error, + CoinageError::NoReadyEntries { + requested: Amount::from_cents(16), + available_when_ready: Amount::from_cents(32), + } + ); + } + + #[test] + fn exact_outputs_must_sum_to_the_amount() { + let error = + select_from(&exact_request(12, &[3]), &[], &[]).expect_err("8 does not sum to 12"); + + assert_eq!(error, CoinageError::OutputsDoNotSumToAmount); + } + + #[test] + fn exact_outputs_match_coins_denomination_for_denomination() { + let coins = vec![coin(0, 3, 0), coin(1, 3, 0), coin(2, 4, 0)]; + + let plan = select_from(&exact_request(16, &[3, 3]), &coins, &[]) + .expect("two 8-cent coins are held"); + + assert_eq!(plan.tier, SelectionTier::ExactMatch); + assert_eq!(plan.whole_coins.len(), 2); + assert!( + plan.whole_coins + .iter() + .all(|coin| coin.exponent == exponent(3)) + ); + } + + #[test] + fn exact_outputs_split_when_the_shape_is_wrong() { + // The purse holds one 16-cent coin but the caller wants two 8-cent + // outputs to two accounts, so a whole transfer cannot serve it. + let coins = vec![coin(0, 4, 0)]; + + let plan = + select_from(&exact_request(16, &[3, 3]), &coins, &[]).expect("split the 16-cent coin"); + + assert_eq!(plan.tier, SelectionTier::Split); + let step = plan.split.as_ref().expect("a split step is present"); + assert_eq!(step.target_outputs, vec![exponent(3), exponent(3)]); + assert!(step.change_outputs.is_empty()); + } + + #[test] + fn the_lock_set_covers_every_record_the_plan_touches() { + let coins = vec![coin(0, 3, 0)]; + let entries = vec![entry(7, 3, 1)]; + + let plan = select_from(&request(16), &coins, &entries).expect("8 whole + 8 unloaded"); + let locks = plan.lock_set(PurseId::MAIN); + + assert_eq!(locks.coins, vec![(PurseId::MAIN, CoinIndex(0))]); + assert_eq!(locks.entries, vec![(PurseId::MAIN, EntryIndex(7))]); + } + + #[test] + fn the_lock_set_includes_the_split_coin() { + let coins = vec![coin(4, 5, 0)]; + + let plan = select_from(&request(20), &coins, &[]).expect("split the 32-cent coin"); + let locks = plan.lock_set(PurseId::MAIN); + + assert_eq!(locks.coins, vec![(PurseId::MAIN, CoinIndex(4))]); + } + + #[test] + fn selection_is_deterministic_under_input_reordering() { + let ordered = vec![coin(0, 4, 2), coin(1, 4, 2), coin(2, 3, 0), coin(3, 2, 1)]; + let shuffled = vec![coin(3, 2, 1), coin(1, 4, 2), coin(0, 4, 2), coin(2, 3, 0)]; + + let first = select_from(&request(28), &ordered, &[]).expect("28 = 16 + 8 + 4"); + let second = select_from(&request(28), &shuffled, &[]).expect("28 = 16 + 8 + 4"); + + assert_eq!(first, second); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/store.rs b/rust/crates/truapi-server/src/host_logic/coinage/store.rs new file mode 100644 index 000000000..4b49cd2e9 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/store.rs @@ -0,0 +1,1887 @@ +//! The layer's record store. +//! +//! Individual records enforce their own lifecycles; this aggregate enforces the +//! invariants that span them — that a locked coin belongs to a live operation, +//! that two operations never hold the same record, that a derivation index is +//! never handed out twice, and that every purse referenced by a record exists. +//! +//! The store is pure and serializable. It performs no I/O: the caller persists +//! it, hands it chain observations, and drains the events it produces. Keeping +//! persistence outside means the whole state machine is exercisable in a unit +//! test with no host and no chain. +//! +//! # Ordering of event delivery against persistence +//! +//! Events must be drained and published **before** the store is persisted. +//! +//! A terminal operation drops its record as soon as its status is emitted, so +//! persisting first and publishing second loses the receipt and the record +//! together if the process dies in between — the operation would simply have +//! never happened, as far as any later reader is concerned. Publishing first is +//! safe in the other direction: a crash leaves the operation still open in the +//! persisted store, and [`CoinageStore::reconcile_after_restart`] resolves it on +//! the next start. The worst case becomes a duplicate event rather than a lost +//! one, which subscribers can absorb and a lost receipt cannot. + +use std::collections::BTreeMap; + +use parity_scale_codec::{Decode, Encode}; + +use super::chain_constants::CoinageChainConstants; +use super::coin::{Coin, CoinState}; +use super::entry::{EntryLocalState, RecyclerEntry}; +use super::error::CoinageError; +use super::event::LayerEvent; +use super::log::{Checkpoint, LogEntryState}; +use super::operation::{ + LockSet, Operation, OperationReceipt, OperationStatus, RestartDisposition, TerminalStatus, +}; +use super::params::CoinageParameters; +use super::purse::{Purse, PurseBalance, PurseInfo, compute_balance}; +use super::selection::{SelectionPlan, SelectionRequest, select}; +use super::types::{ + CoinAge, CoinIndex, DenominationExponent, EntryIndex, ExtrinsicHash, OperationHandle, + OperationHandleAllocator, OperationKind, PurseId, RingLocation, Timestamp, +}; + +use core::time::Duration; + +/// Every record the layer owns, plus the counters that keep derivation indices +/// unique. +#[derive(Debug, Clone, Encode, Decode)] +pub struct CoinageStore { + purses: BTreeMap, + coins: BTreeMap<(PurseId, CoinIndex), Coin>, + entries: BTreeMap<(PurseId, EntryIndex), RecyclerEntry>, + operations: BTreeMap, + handles: OperationHandleAllocator, + /// Monotonic purse-id counter. Never reclaims an identifier, even after a + /// purse is closed: a purse id names a derivation namespace, so reusing one + /// would let a new purse's accounts be correlated with the closed purse's + /// on-chain history. + next_purse_id: u32, + /// Events awaiting delivery. Transient, so they are not persisted with the + /// rest of the store. + #[codec(skip)] + events: Vec, +} + +impl CoinageStore { + /// Create a store holding only the main purse, which exists by + /// construction. + pub fn new(main_purse_name: String) -> Self { + let mut purses = BTreeMap::new(); + purses.insert(PurseId::MAIN, Purse::new(PurseId::MAIN, main_purse_name)); + + Self { + purses, + coins: BTreeMap::new(), + entries: BTreeMap::new(), + operations: BTreeMap::new(), + handles: OperationHandleAllocator::default(), + next_purse_id: PurseId::MAIN.0 + 1, + events: Vec::new(), + } + } + + /// Record an event the chain layer observed about work this store planned. + /// + /// The store raises its own events for every record and operation change; this + /// is for facts only the chain layer knows, such as what an unload's origin + /// ended up costing. It joins the same queue, so ordering against record + /// events is preserved. + pub fn publish(&mut self, event: LayerEvent) { + self.events.push(event); + } + + /// Take everything observed since the last drain. + /// + /// Publish these before persisting the store; see the module documentation + /// for why that order is the safe one. + pub fn take_events(&mut self) -> Vec { + core::mem::take(&mut self.events) + } + + // -- purses ------------------------------------------------------------ + + /// A purse, if it exists. + pub fn purse(&self, purse: PurseId) -> Option<&Purse> { + self.purses.get(&purse) + } + + /// Every purse, in identifier order. + pub fn purses(&self) -> impl Iterator { + self.purses.values() + } + + /// Open a new purse with a fresh identifier. + pub fn create_purse(&mut self, name: String) -> PurseId { + let id = PurseId(self.next_purse_id); + self.next_purse_id += 1; + self.purses.insert(id, Purse::new(id, name.clone())); + self.events + .push(LayerEvent::PurseCreated { purse: id, name }); + id + } + + /// Change a purse's name. + pub fn rename_purse(&mut self, purse: PurseId, name: String) -> Result<(), CoinageError> { + let record = self + .purses + .get_mut(&purse) + .ok_or(CoinageError::PurseNotFound(purse))?; + record.name = name.clone(); + self.events.push(LayerEvent::PurseRenamed { purse, name }); + Ok(()) + } + + /// Close a purse once its value has been drained elsewhere. + /// + /// Dropping the purse record also drops its index counters, which is only + /// safe because purse identifiers are never reused: no future purse can + /// derive into the closed namespace. + pub fn close_purse( + &mut self, + purse: PurseId, + drained_into: PurseId, + amount: super::types::Amount, + ) -> Result<(), CoinageError> { + if purse.is_main() { + return Err(CoinageError::CannotDeleteMainPurse); + } + if !self.purses.contains_key(&purse) { + return Err(CoinageError::PurseNotFound(purse)); + } + if !self.purses.contains_key(&drained_into) { + return Err(CoinageError::PurseNotFound(drained_into)); + } + if self.has_in_flight_operations(purse) { + return Err(CoinageError::PurseHasInFlightOperations); + } + + self.purses.remove(&purse); + self.coins.retain(|(owner, _), _| *owner != purse); + self.entries.retain(|(owner, _), _| *owner != purse); + self.events.push(LayerEvent::PurseDeleted { + purse, + drained_into, + amount, + }); + Ok(()) + } + + /// Whether any operation still holds records in this purse or acts on it. + pub fn has_in_flight_operations(&self, purse: PurseId) -> bool { + self.operations.values().any(|operation| { + operation.purse == purse + || operation + .locks + .coins + .iter() + .any(|(owner, _)| *owner == purse) + || operation + .locks + .entries + .iter() + .any(|(owner, _)| *owner == purse) + }) + } + + /// The purse's three-value balance. + pub fn balance(&self, purse: PurseId, now: Timestamp) -> Result { + if !self.purses.contains_key(&purse) { + return Err(CoinageError::PurseNotFound(purse)); + } + + Ok(compute_balance( + self.coin_records(purse), + self.entry_records(purse), + now, + )) + } + + /// The purse's identity together with its balance. + pub fn purse_info(&self, purse: PurseId, now: Timestamp) -> Result { + let record = self + .purses + .get(&purse) + .ok_or(CoinageError::PurseNotFound(purse))?; + Ok(PurseInfo::new(record, self.balance(purse, now)?)) + } + + // -- records ----------------------------------------------------------- + + /// Put back a purse a scan is reconstructing, at the identifier it had (§8.10). + /// + /// Distinct from [`Self::create_purse`], which takes the next free identifier: + /// a recovered purse must keep its own, because that identifier names the + /// derivation namespace its accounts are already in on chain. The counter moves + /// past it, so a later `create_purse` cannot collide with one that was restored. + /// + /// A purse that already exists is left alone, so a rescan is not a rename. + pub fn restore_purse(&mut self, purse: PurseId, name: String) { + self.next_purse_id = self.next_purse_id.max(purse.0.saturating_add(1)); + self.purses + .entry(purse) + .or_insert_with(|| Purse::new(purse, name)); + } + + /// Put back a coin a scan found on chain, at the index it was derived under. + /// + /// The index is the caller's, not the next free one: it is what the account was + /// derived from, so restoring under a different one would name an account + /// nobody holds. The purse's counter moves past it for the same reason + /// [`Self::restore_purse`] moves the purse counter. + pub fn restore_coin( + &mut self, + purse: PurseId, + index: CoinIndex, + exponent: DenominationExponent, + age: CoinAge, + ) -> Result<(), CoinageError> { + let record = self + .purses + .get_mut(&purse) + .ok_or(CoinageError::PurseNotFound(purse))?; + record.next_coin_index = CoinIndex(record.next_coin_index.0.max(index.0 + 1)); + + let mut coin = Coin::pending(purse, index, exponent); + coin.observe_populated(age)?; + self.coins.insert((purse, index), coin); + self.events + .push(LayerEvent::CoinAvailable { purse, exponent }); + Ok(()) + } + + /// Put back a recycler entry a scan found on chain, at its derived index. + /// + /// Readiness is not restored, because it cannot be: the delay of §5.3 was drawn + /// locally and that draw is gone. A recovered entry is therefore selectable at + /// once — the decorrelation it was protecting has already had however long the + /// wallet was lost to elapse. + pub fn restore_entry( + &mut self, + purse: PurseId, + index: EntryIndex, + exponent: DenominationExponent, + now: Timestamp, + ) -> Result<(), CoinageError> { + let record = self + .purses + .get_mut(&purse) + .ok_or(CoinageError::PurseNotFound(purse))?; + record.next_entry_index = EntryIndex(record.next_entry_index.0.max(index.0 + 1)); + + self.entries.insert( + (purse, index), + RecyclerEntry::allocated(purse, index, exponent, now, Duration::ZERO), + ); + self.events + .push(LayerEvent::EntryAllocated { purse, exponent }); + Ok(()) + } + + /// Register a coin an in-flight operation is expected to produce, taking + /// the next free index in the purse. + pub fn add_pending_coin( + &mut self, + purse: PurseId, + exponent: DenominationExponent, + ) -> Result { + let record = self + .purses + .get_mut(&purse) + .ok_or(CoinageError::PurseNotFound(purse))?; + let index = record.allocate_coin_index(); + self.coins + .insert((purse, index), Coin::pending(purse, index, exponent)); + Ok(index) + } + + /// Register a freshly created recycler entry, taking the next free index. + /// + /// `jitter` is the caller's draw from `[0, jitter_upper_bound]`; the store + /// holds no randomness source. + pub fn allocate_entry( + &mut self, + purse: PurseId, + exponent: DenominationExponent, + now: Timestamp, + jitter: Duration, + ) -> Result { + let record = self + .purses + .get_mut(&purse) + .ok_or(CoinageError::PurseNotFound(purse))?; + let index = record.allocate_entry_index(); + self.entries.insert( + (purse, index), + RecyclerEntry::allocated(purse, index, exponent, now, jitter), + ); + self.events + .push(LayerEvent::EntryAllocated { purse, exponent }); + Ok(index) + } + + /// A coin record. + pub fn coin(&self, purse: PurseId, index: CoinIndex) -> Option<&Coin> { + self.coins.get(&(purse, index)) + } + + /// A recycler-entry record. + pub fn entry(&self, purse: PurseId, index: EntryIndex) -> Option<&RecyclerEntry> { + self.entries.get(&(purse, index)) + } + + /// Every coin in a purse, in index order. + pub fn coins_in(&self, purse: PurseId) -> Vec { + self.coin_records(purse).copied().collect() + } + + /// Every recycler entry in a purse, in index order. + pub fn entries_in(&self, purse: PurseId) -> Vec { + self.entry_records(purse).copied().collect() + } + + /// Coins the age sweep should recycle before the chain's cap makes them + /// unusable. + pub fn coins_needing_recycling( + &self, + purse: PurseId, + recycle_at_age: CoinAge, + now: Timestamp, + ) -> Vec { + self.coin_records(purse) + .filter(|coin| coin.needs_recycling(recycle_at_age, now)) + .map(|coin| coin.index) + .collect() + } + + /// Entries whose ring is close enough to expiry that the rescue sweep must + /// unload them now (§6.4). + /// + /// Returned in the layer's canonical entry order so two implementations rescue + /// the same entries in the same order. An entry whose ring immutability was + /// never observed has no deadline and is *not* returned — which is correct for + /// a ring still accepting members and indistinguishable from a ring nobody + /// read, so an empty result is not evidence that observation ran. + pub fn entries_needing_rescue( + &self, + purse: PurseId, + recycler_expiration_time: Duration, + rescue_margin: Duration, + now: Timestamp, + ) -> Vec { + let mut due: Vec<&RecyclerEntry> = self + .entry_records(purse) + .filter(|entry| entry.needs_rescue(now, recycler_expiration_time, rescue_margin)) + .collect(); + due.sort_by(|left, right| { + right + .exponent + .cmp(&left.exponent) + .then(left.index.cmp(&right.index)) + }); + due.into_iter().map(|entry| entry.index).collect() + } + + /// Make sure an operation holds the records it chose for itself. + /// + /// Idempotent: naming a record this operation already holds is a no-op, which is + /// what lets a multi-phase offload re-name the entries it created earlier. + /// + /// Selection-driven operations get their locks from [`Self::begin_operation`], + /// which chooses and locks in one step. A sweep picks records by age or by + /// deadline instead, and still has to hold them: two sweeps overlapping on one + /// coin would submit two recycles for it, and the second would be refused after + /// the first had consumed it. + pub fn lock_for_operation( + &mut self, + handle: OperationHandle, + locks: &LockSet, + now: Timestamp, + ) -> Result<(), CoinageError> { + if !self.operations.contains_key(&handle) { + return Err(CoinageError::OperationNotFound(handle)); + } + for other in self.operations.values() { + if other.handle != handle && other.locks.intersects(locks) { + return Err(CoinageError::Internal(format!( + "{} already holds a record {handle} is trying to lock", + other.handle + ))); + } + } + + // Records this operation already holds are left alone, so a later phase can + // name the same record without having to remember whether an earlier one + // locked it. + let wanted = LockSet { + coins: locks + .coins + .iter() + .copied() + .filter(|key| { + self.coins + .get(key) + .is_some_and(|coin| coin.state.locked_by() != Some(handle)) + }) + .collect(), + entries: locks + .entries + .iter() + .copied() + .filter(|key| { + self.entries + .get(key) + .is_some_and(|entry| entry.local.locked_by() != Some(handle)) + }) + .collect(), + }; + if wanted.is_empty() { + return Ok(()); + } + + self.apply_locks(handle, &wanted, now)?; + let operation = self + .operations + .get_mut(&handle) + .expect("presence checked above; qed"); + operation.locks.coins.extend(wanted.coins); + operation.locks.entries.extend(wanted.entries); + Ok(()) + } + + /// Record that the chain reports a coin account populated at a given age. + pub fn observe_coin( + &mut self, + purse: PurseId, + index: CoinIndex, + age: CoinAge, + ) -> Result<(), CoinageError> { + let coin = self + .coins + .get_mut(&(purse, index)) + .ok_or_else(|| unknown_record("coin", purse))?; + + let was_pending = coin.state != CoinState::Available; + let previous_age = coin.age; + let exponent = coin.exponent; + coin.observe_populated(age)?; + + if was_pending { + self.events + .push(LayerEvent::CoinAvailable { purse, exponent }); + } else if previous_age != age { + self.events.push(LayerEvent::CoinAged { + purse, + exponent, + age, + }); + } + + Ok(()) + } + + /// Retire a coin whose secret has been handed out of the layer (§8.4). + /// + /// Terminal like a spend, and for the same reason: the account still holds the + /// coin, but this layer no longer controls it, so offering it to selection + /// again would build an extrinsic the chain refuses. The record stays, so its + /// index is never reused. + /// + /// Two states reach here, and the difference matters: + /// + /// - **Locked by `handle`** — a coin that was already the right shape. Nothing + /// was submitted for it; this is its owning operation consuming it. + /// - **Pending** — a coin one of the operation's transactions just + /// materialized. That transaction definitely succeeded, so the account is + /// populated even though observation has not caught up. + pub fn retire_exported( + &mut self, + purse: PurseId, + index: CoinIndex, + handle: OperationHandle, + ) -> Result<(), CoinageError> { + let coin = self + .coins + .get_mut(&(purse, index)) + .ok_or_else(|| unknown_record("coin", purse))?; + let exponent = coin.exponent; + + match coin.state { + CoinState::LockedFor(holder) if holder == handle => coin.mark_spent(handle)?, + CoinState::Pending => coin.mark_exported()?, + other => { + return Err( + super::error::InvalidTransition::new("coin", other.label(), "export").into(), + ); + } + } + + self.events.push(LayerEvent::CoinSpent { purse, exponent }); + Ok(()) + } + + /// Record the chain's own lock on a coin account, or its absence. + /// + /// Separate from [`Self::observe_coin`] because the two reads are separate + /// on chain and answer different questions: one says the account holds a + /// coin, the other says whether the runtime will currently accept it as an + /// origin. A coin can be locked whatever the layer thinks its state is, so + /// this applies to any tracked record. + pub fn observe_coin_lock( + &mut self, + purse: PurseId, + index: CoinIndex, + locked_until: Option, + ) -> Result<(), CoinageError> { + let coin = self + .coins + .get_mut(&(purse, index)) + .ok_or_else(|| unknown_record("coin", purse))?; + + let exponent = coin.exponent; + let was_locked = coin.locked_until; + coin.observe_chain_lock(locked_until); + + if was_locked != locked_until + && let Some(until) = locked_until + { + self.events.push(LayerEvent::CoinChainLocked { + purse, + exponent, + until, + }); + } + + Ok(()) + } + + /// Record the chain's lock on a recycler entry's alias, or its absence. + /// + /// The entry-side counterpart of [`Self::observe_coin_lock`]. Separate from + /// the ring observation because it is a separate read against a separate + /// storage map, and because it can be set on an entry whose ring state has + /// not changed at all. + pub fn observe_entry_alias_lock( + &mut self, + purse: PurseId, + index: EntryIndex, + locked_until: Option, + ) -> Result<(), CoinageError> { + let entry = self + .entries + .get_mut(&(purse, index)) + .ok_or_else(|| unknown_record("recycler entry", purse))?; + + let exponent = entry.exponent; + let was_locked = entry.alias_locked_until; + entry.observe_alias_lock(locked_until); + + if was_locked != locked_until + && let Some(until) = locked_until + { + self.events.push(LayerEvent::EntryAliasLocked { + purse, + exponent, + until, + }); + } + + Ok(()) + } + + /// Record when a recycler entry's ring became immutable. + /// + /// Kept separate from the ring observation because it can change while the + /// ring location does not, and because it is the one fact the rescue sweep + /// reads — losing it silently is how entries expire unnoticed. + pub fn observe_entry_ring_immutability( + &mut self, + purse: PurseId, + index: EntryIndex, + immutable_since: Option, + ) -> Result<(), CoinageError> { + self.entries + .get_mut(&(purse, index)) + .ok_or_else(|| unknown_record("recycler entry", purse))? + .observe_ring_immutability(immutable_since); + Ok(()) + } + + /// Record what the chain says about a recycler entry's ring. + pub fn observe_entry_ring( + &mut self, + purse: PurseId, + index: EntryIndex, + ring: RingLocation, + member_count: u32, + params: &CoinageParameters, + ) -> Result<(), CoinageError> { + let entry = self + .entries + .get_mut(&(purse, index)) + .ok_or_else(|| unknown_record("recycler entry", purse))?; + + let previous = entry.on_chain; + entry.observe_ring(ring, member_count, params); + + if entry.on_chain != previous { + self.events.push(LayerEvent::EntryReadinessChanged { + purse, + exponent: entry.exponent, + new_state: entry.on_chain, + }); + } + + Ok(()) + } + + /// Record that the chain no longer reports a location for an entry. + /// + /// Does not retire the record: an entry can lose its location because it was + /// unloaded, but also because a load has not finalized yet, and only the + /// owning operation can tell those apart. + pub fn observe_entry_missing( + &mut self, + purse: PurseId, + index: EntryIndex, + ) -> Result<(), CoinageError> { + let entry = self + .entries + .get_mut(&(purse, index)) + .ok_or_else(|| unknown_record("recycler entry", purse))?; + + let previous = entry.on_chain; + entry.observe_missing(); + + if entry.on_chain != previous { + self.events.push(LayerEvent::EntryReadinessChanged { + purse, + exponent: entry.exponent, + new_state: entry.on_chain, + }); + } + + Ok(()) + } + + // -- operations -------------------------------------------------------- + + /// Select records for a request and lock them under one new operation. + /// + /// Selecting and locking together is what makes the "two concurrent + /// selections never disagree about availability" guarantee structural: no + /// other caller can observe the window between choosing a record and + /// holding it. + pub fn begin_operation( + &mut self, + purse: PurseId, + kind: OperationKind, + request: &SelectionRequest, + constants: &CoinageChainConstants, + now: Timestamp, + ) -> Result<(OperationHandle, SelectionPlan), CoinageError> { + if !self.purses.contains_key(&purse) { + return Err(CoinageError::PurseNotFound(purse)); + } + + let coins = self.coins_in(purse); + let entries = self.entries_in(purse); + let plan = select(request, &coins, &entries, constants, now)?; + let locks = plan.lock_set(purse); + + let handle = self.handles.allocate(); + let mut operation = Operation::start(handle, kind, purse); + operation.locks = locks.clone(); + + self.apply_locks(handle, &locks, now)?; + self.operations.insert(handle, operation); + self.events.push(LayerEvent::OperationStarted { + handle, + kind, + purse, + }); + + Ok((handle, plan)) + } + + /// Start an operation that holds no records, such as a sweep or a recovery + /// scan. + pub fn start_operation( + &mut self, + purse: PurseId, + kind: OperationKind, + ) -> Result { + if !self.purses.contains_key(&purse) { + return Err(CoinageError::PurseNotFound(purse)); + } + + let handle = self.handles.allocate(); + self.operations + .insert(handle, Operation::start(handle, kind, purse)); + self.events.push(LayerEvent::OperationStarted { + handle, + kind, + purse, + }); + Ok(handle) + } + + /// An open operation. + pub fn operation(&self, handle: OperationHandle) -> Option<&Operation> { + self.operations.get(&handle) + } + + /// Every operation that has not reached a terminal state. + pub fn open_operations(&self) -> impl Iterator { + self.operations.values() + } + + /// Move an operation to a non-terminal status. + pub fn advance_operation( + &mut self, + handle: OperationHandle, + status: OperationStatus, + ) -> Result<(), CoinageError> { + let operation = self + .operations + .get_mut(&handle) + .ok_or(CoinageError::OperationNotFound(handle))?; + operation.advance(status.clone())?; + self.events + .push(LayerEvent::OperationProgress { handle, status }); + Ok(()) + } + + /// Log a transaction the operation intends to submit, returning its + /// sequence within the operation. + /// + /// `depends_on` names sequences whose outputs this transaction consumes; + /// see `coinage-layer.md` §7.5 for why that ordering is load-bearing. + pub fn plan_transaction( + &mut self, + handle: OperationHandle, + inputs: LockSet, + outputs: LockSet, + checkpoint: Checkpoint, + depends_on: impl IntoIterator, + ) -> Result { + let operation = self + .operations + .get_mut(&handle) + .ok_or(CoinageError::OperationNotFound(handle))?; + operation.plan_transaction(inputs, outputs, checkpoint, depends_on) + } + + /// Note an extrinsic hash immediately before it is broadcast. + pub fn record_submission( + &mut self, + handle: OperationHandle, + sequence: u32, + extrinsic_hash: ExtrinsicHash, + ) -> Result<(), CoinageError> { + let operation = self + .operations + .get_mut(&handle) + .ok_or(CoinageError::OperationNotFound(handle))?; + operation.record_submission(sequence, extrinsic_hash)?; + self.events.push(LayerEvent::OperationProgress { + handle, + status: OperationStatus::Submitted, + }); + Ok(()) + } + + /// Record one logged transaction's definite outcome and move its records + /// accordingly. + /// + /// `coinage-layer.md` §7.7. The three cases differ in exactly the way that + /// matters: + /// + /// - **Succeeded** — the inputs are gone from chain, so they retire. + /// - **Rejected** — the inputs survive, so they return to the pool. If the + /// rejection was a failed dispatch rather than non-inclusion, the chain + /// also wrote a lock against them (§5.6), which arrives through + /// observation and keeps them out of selection until it expires. The + /// outputs never came to exist, so they retire unused. + /// - **Abandoned** — nothing was ever submitted, so nothing reverts here: + /// the inputs were a predecessor's outputs, retired by the predecessor's + /// own rejection. Only this entry's own outputs retire. + /// + /// Callers must resolve entries in dependency order and run + /// [`super::log::OperationLog::cascade_abandoned`] first. + pub fn resolve_transaction( + &mut self, + handle: OperationHandle, + sequence: u32, + state: LogEntryState, + ) -> Result<(), CoinageError> { + let operation = self + .operations + .get_mut(&handle) + .ok_or(CoinageError::OperationNotFound(handle))?; + let entry = operation.log.entry(sequence).cloned().ok_or_else(|| { + CoinageError::Internal(format!("{handle} has no logged transaction {sequence}")) + })?; + + operation + .log + .entry_mut(sequence) + .expect("presence checked above; qed") + .resolve(state.clone())?; + + match state { + LogEntryState::Pending => { + return Err(CoinageError::Internal(format!( + "{handle} cannot resolve transaction {sequence} back to pending" + ))); + } + LogEntryState::Succeeded { .. } => { + self.retire_inputs(handle, &entry.inputs)?; + } + LogEntryState::Rejected { .. } => { + self.release_inputs(handle, &entry.inputs)?; + self.abandon_outputs(&entry.outputs)?; + } + LogEntryState::Abandoned { .. } => { + self.abandon_outputs(&entry.outputs)?; + } + } + + Ok(()) + } + + /// Retire records the chain consumed. + fn retire_inputs( + &mut self, + handle: OperationHandle, + inputs: &LockSet, + ) -> Result<(), CoinageError> { + for key in &inputs.coins { + let Some(coin) = self.coins.get_mut(key) else { + continue; + }; + coin.mark_spent(handle)?; + self.events.push(LayerEvent::CoinSpent { + purse: key.0, + exponent: coin.exponent, + }); + } + for key in &inputs.entries { + let Some(entry) = self.entries.get_mut(key) else { + continue; + }; + entry.mark_consumed(handle)?; + self.events.push(LayerEvent::EntryConsumed { + purse: key.0, + exponent: entry.exponent, + }); + } + Ok(()) + } + + /// Return records the chain did not consume to the selectable pool. + fn release_inputs( + &mut self, + handle: OperationHandle, + inputs: &LockSet, + ) -> Result<(), CoinageError> { + for key in &inputs.coins { + if let Some(coin) = self.coins.get_mut(key) { + coin.release(handle)?; + } + } + for key in &inputs.entries { + if let Some(entry) = self.entries.get_mut(key) { + entry.release(handle)?; + } + } + Ok(()) + } + + /// Retire records a transaction would have created but did not. + /// + /// Their derivation indices stay consumed: an account that was never + /// populated is still an account this layer has committed to, and reusing + /// its index would break the no-reuse invariant of §4.3. + fn abandon_outputs(&mut self, outputs: &LockSet) -> Result<(), CoinageError> { + for key in &outputs.coins { + if let Some(coin) = self.coins.get_mut(key) { + coin.abandon()?; + } + } + for key in &outputs.entries { + if let Some(entry) = self.entries.get_mut(key) { + entry.abandon()?; + } + } + Ok(()) + } + + /// Finish an operation successfully. + /// + /// `consumed` names the subset of the operation's locks the chain actually + /// spent; those records retire, and everything else the operation held goes + /// back to the selectable pool. + pub fn finish_operation( + &mut self, + handle: OperationHandle, + receipt: OperationReceipt, + consumed: &LockSet, + ) -> Result<(), CoinageError> { + let operation = self + .operations + .get(&handle) + .ok_or(CoinageError::OperationNotFound(handle))?; + let locks = operation.locks.clone(); + + for key in &consumed.coins { + if !locks.coins.contains(key) { + return Err(CoinageError::Internal(format!( + "{handle} cannot consume a coin it does not hold" + ))); + } + } + for key in &consumed.entries { + if !locks.entries.contains(key) { + return Err(CoinageError::Internal(format!( + "{handle} cannot consume an entry it does not hold" + ))); + } + } + + for key in &locks.coins { + let Some(coin) = self.coins.get_mut(key) else { + continue; + }; + if consumed.coins.contains(key) { + coin.mark_spent(handle)?; + self.events.push(LayerEvent::CoinSpent { + purse: key.0, + exponent: coin.exponent, + }); + } else { + coin.release(handle)?; + } + } + + for key in &locks.entries { + let Some(entry) = self.entries.get_mut(key) else { + continue; + }; + if consumed.entries.contains(key) { + entry.mark_consumed(handle)?; + self.events.push(LayerEvent::EntryConsumed { + purse: key.0, + exponent: entry.exponent, + }); + } else { + entry.release(handle)?; + } + } + + self.retire(handle, TerminalStatus::Done(receipt)) + } + + /// Finish an operation whose transactions have each already been resolved. + /// + /// The per-transaction path of §7.4 retires or releases every record its log + /// entries named as it goes, so by the time the operation ends there is + /// nothing left to move except records it still holds in a live state — + /// inputs of a transaction that was never submitted. Those go back to the + /// pool. + /// + /// Distinct from [`Self::finish_operation`], which is for the caller that + /// learns what the chain consumed only at the end: retiring the same record + /// twice is a lifecycle error, not a no-op. + pub fn conclude_operation( + &mut self, + handle: OperationHandle, + receipt: OperationReceipt, + ) -> Result<(), CoinageError> { + if !self.operations.contains_key(&handle) { + return Err(CoinageError::OperationNotFound(handle)); + } + + self.release_locks(handle); + self.retire(handle, TerminalStatus::Done(receipt)) + } + + /// Finish an operation unsuccessfully, returning everything it held. + pub fn fail_operation( + &mut self, + handle: OperationHandle, + error: CoinageError, + ) -> Result<(), CoinageError> { + if !self.operations.contains_key(&handle) { + return Err(CoinageError::OperationNotFound(handle)); + } + + self.release_locks(handle); + self.retire(handle, TerminalStatus::Failed(error)) + } + + /// Cancel an operation, which is permitted only while nothing is in flight. + pub fn cancel_operation(&mut self, handle: OperationHandle) -> Result<(), CoinageError> { + let operation = self + .operations + .get(&handle) + .ok_or(CoinageError::OperationNotFound(handle))?; + + if !operation.status.is_cancellable() { + return Err(super::error::InvalidTransition::new( + "operation", + operation.status.label(), + "cancel", + ) + .into()); + } + + self.fail_operation(handle, CoinageError::Cancelled) + } + + /// Resolve operations left open by a restart. + /// + /// Operations that never broadcast are failed and their locks released: + /// pre-submission scratch state is not durable, so a restart while + /// preparing is indistinguishable from a cancel. Operations that did + /// broadcast are returned for the caller to check against chain state. + /// `Resynced` is emitted last, so a subscriber can tell reconstruction from + /// the live changes that follow. + pub fn reconcile_after_restart(&mut self) -> Vec { + let mut needs_reconciliation = Vec::new(); + let mut interrupted = Vec::new(); + + for operation in self.operations.values() { + match operation.restart_disposition() { + RestartDisposition::Reconcile => needs_reconciliation.push(operation.handle), + RestartDisposition::FailInterrupted | RestartDisposition::AlreadyTerminal => { + interrupted.push(operation.handle); + } + } + } + + for handle in interrupted { + let _ = self.fail_operation(handle, CoinageError::InterruptedPreSubmission); + } + + self.events.push(LayerEvent::Resynced); + needs_reconciliation + } + + // -- internals --------------------------------------------------------- + + fn coin_records(&self, purse: PurseId) -> impl Iterator { + self.coins + .range((purse, CoinIndex(0))..=(purse, CoinIndex(u32::MAX))) + .map(|(_, coin)| coin) + } + + fn entry_records(&self, purse: PurseId) -> impl Iterator { + self.entries + .range((purse, EntryIndex(0))..=(purse, EntryIndex(u32::MAX))) + .map(|(_, entry)| entry) + } + + /// Lock every record in the set, or none of them. + /// + /// Checked in full before anything mutates, so a conflict cannot leave the + /// store half-locked with no operation owning the difference. + fn apply_locks( + &mut self, + handle: OperationHandle, + locks: &LockSet, + now: Timestamp, + ) -> Result<(), CoinageError> { + for key in &locks.coins { + let coin = self + .coins + .get(key) + .ok_or_else(|| unknown_record("coin", key.0))?; + if !coin.is_selectable(now) { + return Err(CoinageError::Internal(format!( + "coin {:?} in {} is not lockable", + key.1, key.0 + ))); + } + } + for key in &locks.entries { + let entry = self + .entries + .get(key) + .ok_or_else(|| unknown_record("recycler entry", key.0))?; + if entry.local != EntryLocalState::Available { + return Err(CoinageError::Internal(format!( + "recycler entry {:?} in {} is not lockable", + key.1, key.0 + ))); + } + } + + for key in &locks.coins { + if let Some(coin) = self.coins.get_mut(key) { + coin.lock_for(handle)?; + } + } + for key in &locks.entries { + if let Some(entry) = self.entries.get_mut(key) { + entry.lock_for(handle)?; + } + } + + Ok(()) + } + + fn release_locks(&mut self, handle: OperationHandle) { + let Some(operation) = self.operations.get(&handle) else { + return; + }; + let locks = operation.locks.clone(); + + for key in &locks.coins { + if let Some(coin) = self.coins.get_mut(key) { + let _ = coin.release(handle); + } + } + for key in &locks.entries { + if let Some(entry) = self.entries.get_mut(key) { + let _ = entry.release(handle); + } + } + } + + /// Emit the terminal status and drop the record. + /// + /// The layer keeps no operation history: a caller that needs the receipt + /// takes it from the event, and a later lookup on the stale handle reports + /// `OperationNotFound`. Retaining records instead would grow the durable + /// store without bound and keep a permanent trail of extrinsic hashes + /// linking the user's coins to on-chain activity — the correlation the + /// recycler exists to break. + /// + /// Because the record is gone once this returns, the emitted event is the + /// only remaining copy of the receipt until the caller publishes it. See + /// the module documentation on ordering. + fn retire( + &mut self, + handle: OperationHandle, + terminal: TerminalStatus, + ) -> Result<(), CoinageError> { + self.operations.remove(&handle); + self.events + .push(LayerEvent::OperationCompleted { handle, terminal }); + Ok(()) + } +} + +fn unknown_record(kind: &str, purse: PurseId) -> CoinageError { + CoinageError::Internal(format!("unknown {kind} in {purse}")) +} + +#[cfg(test)] +mod tests { + use super::super::coin::CoinState; + use super::super::selection::OutputRequirement; + use super::super::types::Amount; + use super::*; + + const NOW: Timestamp = Timestamp(1_000_000); + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn ring(index: u32) -> RingLocation { + RingLocation::new( + super::super::types::RingIndex(index), + super::super::types::RevisionIndex(0), + ) + } + + fn store() -> CoinageStore { + CoinageStore::new("Main".to_string()) + } + + /// Add a coin already confirmed on chain. + fn fund(store: &mut CoinageStore, purse: PurseId, exponent_value: i8) -> CoinIndex { + let index = store + .add_pending_coin(purse, exponent(exponent_value)) + .expect("purse exists"); + store + .observe_coin(purse, index, CoinAge(0)) + .expect("coin exists"); + index + } + + /// Plan a transaction and record its broadcast, in the order the real + /// caller does it: the log entry exists before the extrinsic goes out. + fn submit(store: &mut CoinageStore, handle: OperationHandle, hash: ExtrinsicHash) -> u32 { + let locks = store + .operation(handle) + .expect("operation is open") + .locks + .clone(); + let sequence = store + .plan_transaction( + handle, + locks, + LockSet::default(), + Checkpoint { + number: 1_000, + hash: super::super::types::BlockHash([1; 32]), + mortality: 256, + }, + [], + ) + .expect("operation is open"); + store + .record_submission(handle, sequence, hash) + .expect("operation is open"); + sequence + } + + fn request(cents: u64) -> SelectionRequest { + SelectionRequest { + amount: Amount::from_cents(cents), + outputs: OutputRequirement::AnyDenominations, + allow_degraded: true, + } + } + + fn begin( + store: &mut CoinageStore, + purse: PurseId, + cents: u64, + ) -> Result<(OperationHandle, SelectionPlan), CoinageError> { + store.begin_operation( + purse, + OperationKind::Transfer, + &request(cents), + &super::super::chain_constants::next_people_paseo(), + NOW, + ) + } + + #[test] + fn a_new_store_holds_only_the_main_purse() { + let store = store(); + + assert_eq!(store.purses().count(), 1); + assert!(store.purse(PurseId::MAIN).is_some()); + } + + #[test] + fn purse_identifiers_are_never_reused_after_a_close() { + let mut store = store(); + let first = store.create_purse("Groceries".to_string()); + + store + .close_purse(first, PurseId::MAIN, Amount::ZERO) + .expect("close is valid"); + let second = store.create_purse("Rent".to_string()); + + // Reuse would let the new purse derive into the closed purse's + // namespace and inherit its on-chain history. + assert_ne!(first, second); + assert_eq!(second, PurseId(first.0 + 1)); + } + + #[test] + fn the_main_purse_cannot_be_closed() { + let mut store = store(); + + assert_eq!( + store.close_purse(PurseId::MAIN, PurseId::MAIN, Amount::ZERO), + Err(CoinageError::CannotDeleteMainPurse) + ); + } + + #[test] + fn a_purse_with_in_flight_operations_cannot_be_closed() { + let mut store = store(); + let purse = store.create_purse("Groceries".to_string()); + fund(&mut store, purse, 3); + begin(&mut store, purse, 8).expect("8 cents are available"); + + assert_eq!( + store.close_purse(purse, PurseId::MAIN, Amount::ZERO), + Err(CoinageError::PurseHasInFlightOperations) + ); + } + + #[test] + fn operations_on_an_unknown_purse_are_rejected() { + let mut store = store(); + let ghost = PurseId(99); + + assert_eq!( + store.balance(ghost, NOW), + Err(CoinageError::PurseNotFound(ghost)) + ); + assert_eq!( + store.add_pending_coin(ghost, exponent(2)), + Err(CoinageError::PurseNotFound(ghost)) + ); + assert!(matches!( + begin(&mut store, ghost, 4), + Err(CoinageError::PurseNotFound(_)) + )); + } + + #[test] + fn indices_are_never_reused_within_a_purse() { + let mut store = store(); + let first = fund(&mut store, PurseId::MAIN, 3); + let second = fund(&mut store, PurseId::MAIN, 3); + + assert_ne!(first, second); + assert_eq!( + store + .purse(PurseId::MAIN) + .expect("main purse exists") + .next_coin_index, + CoinIndex(2) + ); + } + + #[test] + fn the_same_index_in_two_purses_is_a_different_record() { + let mut store = store(); + let other = store.create_purse("Groceries".to_string()); + let main_coin = fund(&mut store, PurseId::MAIN, 3); + let other_coin = fund(&mut store, other, 5); + + assert_eq!(main_coin, other_coin); + assert_eq!( + store + .coin(PurseId::MAIN, main_coin) + .expect("exists") + .exponent, + exponent(3) + ); + assert_eq!( + store.coin(other, other_coin).expect("exists").exponent, + exponent(5) + ); + } + + #[test] + fn observing_a_pending_coin_announces_it_and_moves_the_balance() { + let mut store = store(); + let index = store + .add_pending_coin(PurseId::MAIN, exponent(4)) + .expect("purse exists"); + + let pending = store.balance(PurseId::MAIN, NOW).expect("purse exists"); + assert_eq!(pending.spendable, Amount::ZERO); + assert_eq!(pending.pending, Amount::from_cents(16)); + + store + .observe_coin(PurseId::MAIN, index, CoinAge(0)) + .expect("coin exists"); + + let settled = store.balance(PurseId::MAIN, NOW).expect("purse exists"); + assert_eq!(settled.spendable, Amount::from_cents(16)); + assert_eq!(settled.pending, Amount::ZERO); + assert!(store.take_events().contains(&LayerEvent::CoinAvailable { + purse: PurseId::MAIN, + exponent: exponent(4), + })); + } + + #[test] + fn re_observing_at_the_same_age_announces_nothing() { + let mut store = store(); + let index = fund(&mut store, PurseId::MAIN, 4); + store.take_events(); + + store + .observe_coin(PurseId::MAIN, index, CoinAge(0)) + .expect("coin exists"); + + assert!(store.take_events().is_empty()); + } + + #[test] + fn a_changed_age_is_announced() { + let mut store = store(); + let index = fund(&mut store, PurseId::MAIN, 4); + store.take_events(); + + store + .observe_coin(PurseId::MAIN, index, CoinAge(7)) + .expect("coin exists"); + + assert_eq!( + store.take_events(), + vec![LayerEvent::CoinAged { + purse: PurseId::MAIN, + exponent: exponent(4), + age: CoinAge(7), + }] + ); + } + + #[test] + fn beginning_an_operation_locks_what_it_selected() { + let mut store = store(); + let index = fund(&mut store, PurseId::MAIN, 3); + + let (handle, plan) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + + assert_eq!(plan.target_value(), Amount::from_cents(8)); + assert_eq!( + store.coin(PurseId::MAIN, index).expect("exists").state, + CoinState::LockedFor(handle) + ); + assert_eq!( + store + .balance(PurseId::MAIN, NOW) + .expect("purse exists") + .spendable, + Amount::ZERO + ); + } + + #[test] + fn a_locked_record_is_invisible_to_the_next_selection() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + + // The only coin is held, so the second request sees an empty purse. + assert!(matches!( + begin(&mut store, PurseId::MAIN, 8), + Err(CoinageError::InsufficientFunds { .. }) + )); + } + + #[test] + fn two_operations_can_hold_disjoint_records() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + fund(&mut store, PurseId::MAIN, 3); + + let (first, _) = begin(&mut store, PurseId::MAIN, 8).expect("first coin"); + let (second, _) = begin(&mut store, PurseId::MAIN, 8).expect("second coin"); + + assert_ne!(first, second); + let first_locks = store.operation(first).expect("open").locks.clone(); + let second_locks = store.operation(second).expect("open").locks.clone(); + assert!(!first_locks.intersects(&second_locks)); + } + + #[test] + fn failing_an_operation_returns_everything_it_held() { + let mut store = store(); + let index = fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + + store + .fail_operation(handle, CoinageError::Cancelled) + .expect("operation is open"); + + assert_eq!( + store.coin(PurseId::MAIN, index).expect("exists").state, + CoinState::Available + ); + assert_eq!( + store + .balance(PurseId::MAIN, NOW) + .expect("purse exists") + .spendable, + Amount::from_cents(8) + ); + } + + #[test] + fn finishing_retires_consumed_records_and_frees_the_rest() { + let mut store = store(); + let spent = fund(&mut store, PurseId::MAIN, 3); + let kept = fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 16).expect("both coins"); + + let consumed = LockSet { + coins: vec![(PurseId::MAIN, spent)], + entries: Vec::new(), + }; + store + .finish_operation(handle, OperationReceipt::default(), &consumed) + .expect("operation is open"); + + assert_eq!( + store.coin(PurseId::MAIN, spent).expect("exists").state, + CoinState::Spent + ); + assert_eq!( + store.coin(PurseId::MAIN, kept).expect("exists").state, + CoinState::Available + ); + } + + #[test] + fn an_operation_cannot_consume_what_it_never_held() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + let outsider = fund(&mut store, PurseId::MAIN, 5); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("the 8-cent coin"); + + let consumed = LockSet { + coins: vec![(PurseId::MAIN, outsider)], + entries: Vec::new(), + }; + + assert!(matches!( + store.finish_operation(handle, OperationReceipt::default(), &consumed), + Err(CoinageError::Internal(_)) + )); + } + + #[test] + fn a_terminal_operation_is_dropped_and_its_handle_goes_stale() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + + store + .fail_operation(handle, CoinageError::Cancelled) + .expect("operation is open"); + + assert!(store.operation(handle).is_none()); + assert_eq!( + store.fail_operation(handle, CoinageError::Cancelled), + Err(CoinageError::OperationNotFound(handle)) + ); + } + + #[test] + fn the_terminal_event_carries_the_receipt() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + store.take_events(); + + let receipt = OperationReceipt::default(); + store + .finish_operation(handle, receipt.clone(), &LockSet::default()) + .expect("operation is open"); + + assert!( + store + .take_events() + .contains(&LayerEvent::OperationCompleted { + handle, + terminal: TerminalStatus::Done(receipt), + }) + ); + } + + #[test] + fn an_in_flight_operation_cannot_be_cancelled() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + submit(&mut store, handle, ExtrinsicHash([1; 32])); + + assert!(store.cancel_operation(handle).is_err()); + assert!(store.operation(handle).is_some()); + } + + #[test] + fn restart_fails_operations_that_never_broadcast() { + let mut store = store(); + let index = fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + + let pending = store.reconcile_after_restart(); + + assert!(pending.is_empty()); + assert!(store.operation(handle).is_none()); + assert_eq!( + store.coin(PurseId::MAIN, index).expect("exists").state, + CoinState::Available + ); + } + + #[test] + fn restart_keeps_operations_that_broadcast_for_reconciliation() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + submit(&mut store, handle, ExtrinsicHash([2; 32])); + + let pending = store.reconcile_after_restart(); + + assert_eq!(pending, vec![handle]); + assert!(store.operation(handle).is_some()); + } + + #[test] + fn resynced_is_the_last_event_of_a_restart() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + store.take_events(); + + store.reconcile_after_restart(); + let events = store.take_events(); + + assert_eq!(events.last(), Some(&LayerEvent::Resynced)); + } + + #[test] + fn a_store_survives_a_round_trip_through_its_encoding() { + let mut original = store(); + let purse = original.create_purse("Groceries".to_string()); + fund(&mut original, purse, 4); + let (handle, _) = begin(&mut original, purse, 16).expect("16 cents are available"); + submit(&mut original, handle, ExtrinsicHash([3; 32])); + + let encoded = original.encode(); + let restored = + CoinageStore::decode(&mut &encoded[..]).expect("the store round-trips through SCALE"); + + assert_eq!( + restored.balance(purse, NOW).expect("purse exists"), + original.balance(purse, NOW).expect("purse exists") + ); + assert_eq!( + restored + .operation(handle) + .expect("still open") + .log + .submitted_hashes(), + vec![ExtrinsicHash([3; 32])] + ); + assert_eq!(restored.purses().count(), 2); + } + + #[test] + fn recycling_candidates_are_reported_oldest_first_in_index_order() { + let mut store = store(); + let young = fund(&mut store, PurseId::MAIN, 3); + let old = fund(&mut store, PurseId::MAIN, 3); + store + .observe_coin(PurseId::MAIN, old, CoinAge(14)) + .expect("coin exists"); + + let due = store.coins_needing_recycling(PurseId::MAIN, CoinAge(14), Timestamp(0)); + + assert_eq!(due, vec![old]); + assert!(!due.contains(&young)); + } + + #[test] + fn entry_readiness_changes_are_announced_once() { + let mut store = store(); + let params = CoinageParameters::default(); + let index = store + .allocate_entry(PurseId::MAIN, exponent(4), NOW, Duration::ZERO) + .expect("purse exists"); + store.take_events(); + + store + .observe_entry_ring(PurseId::MAIN, index, ring(1), 32, ¶ms) + .expect("entry exists"); + let first = store.take_events(); + + store + .observe_entry_ring(PurseId::MAIN, index, ring(1), 33, ¶ms) + .expect("entry exists"); + let second = store.take_events(); + + assert_eq!(first.len(), 1); + assert!(second.is_empty()); + } + + /// Plan a transaction consuming the operation's locks and producing a fresh + /// pending coin, the shape every value-moving operation has. + fn plan_with_output( + store: &mut CoinageStore, + handle: OperationHandle, + purse: PurseId, + ) -> (u32, CoinIndex) { + let inputs = store + .operation(handle) + .expect("operation is open") + .locks + .clone(); + let output = store + .add_pending_coin(purse, exponent(3)) + .expect("purse exists"); + let sequence = store + .plan_transaction( + handle, + inputs, + LockSet { + coins: vec![(purse, output)], + entries: Vec::new(), + }, + Checkpoint { + number: 1_000, + hash: super::super::types::BlockHash([1; 32]), + mortality: 256, + }, + [], + ) + .expect("operation is open"); + (sequence, output) + } + + #[test] + fn a_succeeded_transaction_retires_its_inputs() { + let mut store = store(); + let spent = fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + let (sequence, _) = plan_with_output(&mut store, handle, PurseId::MAIN); + + store + .resolve_transaction( + handle, + sequence, + LogEntryState::Succeeded { + block_hash: super::super::types::BlockHash([9; 32]), + }, + ) + .expect("resolves"); + + assert_eq!( + store.coin(PurseId::MAIN, spent).expect("exists").state, + CoinState::Spent + ); + } + + #[test] + fn a_rejected_transaction_returns_its_inputs_and_retires_its_outputs() { + // The chain kept the inputs, so they must become spendable again; the + // outputs never existed, so their indices retire unused rather than + // being handed out a second time. + let mut store = store(); + let input = fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + let (sequence, output) = plan_with_output(&mut store, handle, PurseId::MAIN); + + store + .resolve_transaction( + handle, + sequence, + LogEntryState::Rejected { + reason: "expired".to_string(), + }, + ) + .expect("resolves"); + + assert_eq!( + store.coin(PurseId::MAIN, input).expect("exists").state, + CoinState::Available, + "the input is spendable again" + ); + assert_eq!( + store.coin(PurseId::MAIN, output).expect("exists").state, + CoinState::Spent, + "the output retires without ever having existed" + ); + let reissued = store + .add_pending_coin(PurseId::MAIN, exponent(3)) + .expect("purse exists"); + assert_ne!(reissued, output, "its derivation index is never reused"); + } + + #[test] + fn an_abandoned_transaction_reverts_nothing() { + // Its inputs were a predecessor's outputs, which the predecessor's own + // rejection already retired. Releasing them here would be a second + // reversion of records that never existed. + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + let (first, intermediate) = plan_with_output(&mut store, handle, PurseId::MAIN); + let downstream = store + .add_pending_coin(PurseId::MAIN, exponent(3)) + .expect("purse exists"); + let second = store + .plan_transaction( + handle, + LockSet { + coins: vec![(PurseId::MAIN, intermediate)], + entries: Vec::new(), + }, + LockSet { + coins: vec![(PurseId::MAIN, downstream)], + entries: Vec::new(), + }, + Checkpoint { + number: 1_000, + hash: super::super::types::BlockHash([1; 32]), + mortality: 256, + }, + [first], + ) + .expect("operation is open"); + + store + .resolve_transaction( + handle, + first, + LogEntryState::Rejected { + reason: "expired".to_string(), + }, + ) + .expect("resolves"); + store + .resolve_transaction( + handle, + second, + LogEntryState::Abandoned { + reason: "predecessor rejected".to_string(), + }, + ) + .expect("resolves"); + + // The intermediate coin was retired exactly once, by the first + // transaction's rejection. + assert_eq!( + store + .coin(PurseId::MAIN, intermediate) + .expect("exists") + .state, + CoinState::Spent + ); + assert_eq!( + store.coin(PurseId::MAIN, downstream).expect("exists").state, + CoinState::Spent + ); + } + + #[test] + fn a_transaction_cannot_be_resolved_twice() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 3); + let (handle, _) = begin(&mut store, PurseId::MAIN, 8).expect("8 cents are available"); + let (sequence, _) = plan_with_output(&mut store, handle, PurseId::MAIN); + store + .resolve_transaction( + handle, + sequence, + LogEntryState::Succeeded { + block_hash: super::super::types::BlockHash([9; 32]), + }, + ) + .expect("resolves"); + + assert!( + store + .resolve_transaction( + handle, + sequence, + LogEntryState::Rejected { + reason: "expired".to_string() + }, + ) + .is_err(), + "a settled outcome is final" + ); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/types.rs b/rust/crates/truapi-server/src/host_logic/coinage/types.rs new file mode 100644 index 000000000..bf7781001 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/types.rs @@ -0,0 +1,384 @@ +//! Value types shared across the coinage layer. +//! +//! Amounts are dotUSD cents. The layer works in `u64` internally so that sums +//! over a purse cannot overflow at any denomination it supports; the product +//! wire type is narrower, so [`Amount::to_wire`] is fallible. + +use core::fmt; +use core::iter::Sum; +use core::time::Duration; + +use parity_scale_codec::{Decode, Encode}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Largest denomination exponent the layer's arithmetic supports. +/// +/// A coin of exponent `e` is worth `2^e` cents, so this bounds a single coin at +/// `2^40` cents and leaves room for millions of them to sum inside `u64`. It is +/// a ceiling on what the code can represent, deliberately far above any +/// plausible runtime: the operative limit is the chain's `MaximumExponent`, +/// carried in [`super::chain_constants::CoinageChainConstants`]. +pub const MAX_SUPPORTED_DENOMINATION_EXPONENT: i8 = 40; + +/// Identifier of a purse within the layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct PurseId(pub u32); + +impl PurseId { + /// Reserved identifier of the main purse, which exists by construction once + /// the layer is initialized and can never be deleted. + pub const MAIN: Self = Self(0); + + /// Whether this identifier addresses the main purse. + pub fn is_main(self) -> bool { + self == Self::MAIN + } +} + +impl fmt::Display for PurseId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "purse#{}", self.0) + } +} + +/// A dotUSD amount, counted in cents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Encode, Decode)] +pub struct Amount(u64); + +impl Amount { + /// The zero amount. + pub const ZERO: Self = Self(0); + + /// Construct an amount from a count of cents. + pub const fn from_cents(cents: u64) -> Self { + Self(cents) + } + + /// The amount as a count of cents. + pub const fn cents(self) -> u64 { + self.0 + } + + /// Whether the amount is zero. + pub const fn is_zero(self) -> bool { + self.0 == 0 + } + + /// Add two amounts, returning `None` on overflow. + pub fn checked_add(self, other: Self) -> Option { + self.0.checked_add(other.0).map(Self) + } + + /// Subtract `other`, returning `None` if it exceeds `self`. + pub fn checked_sub(self, other: Self) -> Option { + self.0.checked_sub(other.0).map(Self) + } + + /// Subtract `other`, saturating at zero. + pub fn saturating_sub(self, other: Self) -> Self { + Self(self.0.saturating_sub(other.0)) + } + + /// Narrow to the `u32` cent count used by the product wire types, returning + /// `None` if the amount does not fit. + pub fn to_wire(self) -> Option { + u32::try_from(self.0).ok() + } +} + +impl fmt::Display for Amount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} cents", self.0) + } +} + +impl Sum for Amount { + fn sum>(iter: I) -> Self { + // Saturating: callers bound denominations by `MAX_SUPPORTED_DENOMINATION_EXPONENT`, + // so a real purse cannot reach `u64::MAX`, and a saturated balance is + // preferable to a panic in a display path. + iter.fold(Self::ZERO, |acc, item| Self(acc.0.saturating_add(item.0))) + } +} + +/// Denomination of a coin or recycler entry, as a power-of-two exponent over +/// cents. +/// +/// Signed, because the pallet's `CoinValue` is `i8` and a runtime could in +/// principle configure a `MinimumExponent` below zero. This layer rejects +/// negative exponents: [`Amount`] counts whole cents, so a sub-cent +/// denomination has no representation here. Should a runtime ever ship one, that +/// shows up as a loud construction failure rather than a silently truncated +/// balance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct DenominationExponent(i8); + +impl DenominationExponent { + /// Construct a denomination, rejecting sub-cent exponents and anything above + /// [`MAX_SUPPORTED_DENOMINATION_EXPONENT`]. + pub fn new(exponent: i8) -> Option { + (0..=MAX_SUPPORTED_DENOMINATION_EXPONENT) + .contains(&exponent) + .then_some(Self(exponent)) + } + + /// The raw exponent, in the pallet's `CoinValue` representation. + pub const fn get(self) -> i8 { + self.0 + } + + /// The denomination's value, `2^exponent` cents. + pub const fn value(self) -> Amount { + Amount(1u64 << self.0 as u32) + } +} + +impl fmt::Display for DenominationExponent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "2^{}", self.0) + } +} + +/// Derivation index of a coin within its purse. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct CoinIndex(pub u32); + +/// Derivation index of a recycler entry within its purse. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct EntryIndex(pub u32); + +/// Index of a recycler ring on chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct RingIndex(pub u32); + +/// Revision of a recycler ring. A ring's membership is versioned, and a proof is +/// only valid against the revision it was built for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct RevisionIndex(pub u32); + +/// Where a recycler entry sits on chain. +/// +/// Both halves are needed to unload: the pallet's `unload_recycler_into_coins` +/// takes the ring index and its revision, and a membership proof built against +/// one revision does not verify against another. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct RingLocation { + /// The ring the entry joined. + pub index: RingIndex, + /// The membership revision the entry was observed at. + pub revision: RevisionIndex, +} + +impl RingLocation { + /// A location from its raw parts. + pub const fn new(index: RingIndex, revision: RevisionIndex) -> Self { + Self { index, revision } + } +} + +/// Number of transfers or splits a coin has undergone. The chain caps this; +/// past the cap the coin is unusable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Encode, Decode)] +pub struct CoinAge(pub u16); + +/// A wall-clock instant, in milliseconds since the Unix epoch. +/// +/// The domain layer never reads a clock; instants are supplied by the caller so +/// that behaviour is reproducible under test. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Encode, Decode)] +pub struct Timestamp(pub u64); + +impl Timestamp { + /// The instant a Unix-seconds value from the chain names. + /// + /// The pallet stores lock expiries in whole seconds; this layer counts + /// milliseconds, and mixing the two silently makes a lock look 1000 times + /// shorter than it is. + pub const fn from_unix_seconds(seconds: u64) -> Self { + Self(seconds.saturating_mul(1_000)) + } + + /// The instant `duration` after this one, saturating at `u64::MAX`. + pub fn saturating_add(self, duration: Duration) -> Self { + Self(self.0.saturating_add(duration.as_millis() as u64)) + } + + /// The instant `duration` before this one, saturating at zero. + pub fn saturating_sub(self, duration: Duration) -> Self { + Self(self.0.saturating_sub(duration.as_millis() as u64)) + } +} + +/// On-chain account holding a coin. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct CoinAccountId(pub [u8; 32]); + +/// The secret controlling a coin, as it crosses the layer's API. +/// +/// The only cryptographic material the layer hands out or takes in: §12.1 forbids +/// the rest, and §8.4 makes export the single exception. 64 bytes, the sr25519 +/// secret's own encoding. +/// +/// Zeroized on drop, and never rendered — a `Debug` that printed it would put a +/// spendable coin into every log line that touched an export. +#[derive(Clone, Encode, Decode, Zeroize, ZeroizeOnDrop)] +pub struct CoinSecret(pub [u8; 64]); + +impl fmt::Debug for CoinSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CoinSecret()") + } +} + +impl PartialEq for CoinSecret { + /// Constant-time: comparing a secret byte by byte leaks its prefix through + /// timing. + fn eq(&self, other: &Self) -> bool { + self.0 + .iter() + .zip(other.0.iter()) + .fold(0u8, |differences, (left, right)| { + differences | (left ^ right) + }) + == 0 + } +} + +impl Eq for CoinSecret {} + +/// Hash of a submitted extrinsic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct ExtrinsicHash(pub [u8; 32]); + +/// Hash of a block. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct BlockHash(pub [u8; 32]); + +/// Opaque, durable identifier of a long-running operation. +/// +/// Handles are issued by the layer and increase monotonically, which gives +/// deterministic ordering in tests and in recovery. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode)] +pub struct OperationHandle(pub u64); + +impl fmt::Display for OperationHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "op#{}", self.0) + } +} + +/// Monotonic allocator for [`OperationHandle`]s. +#[derive(Debug, Clone, Default, Encode, Decode)] +pub struct OperationHandleAllocator { + next: u64, +} + +impl OperationHandleAllocator { + /// Issue the next handle. + pub fn allocate(&mut self) -> OperationHandle { + let handle = OperationHandle(self.next); + self.next += 1; + handle + } + + /// The handle that will be issued next, without issuing it. + pub fn peek(&self) -> OperationHandle { + OperationHandle(self.next) + } +} + +/// The kinds of long-running operation the layer supports. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Encode, Decode)] +pub enum OperationKind { + /// Fund a purse from an external account. + TopUp, + /// Send value to pre-arranged recipient coin accounts. + Transfer, + /// Hand coin secrets to the upper layer. + Export, + /// Route externally supplied coin secrets into a purse. + Import, + /// Send value out of coinage to a non-coinage account. + ExternalOffload, + /// Move value between two purses. + Rebalance, + /// Run the coin-age and ring-expiration sweeps. + MaintenanceSweep, + /// Drain a purse into another and close it. + DeletePurse, + /// Rebuild durable records by scanning the chain. + Recover, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn main_purse_is_the_reserved_identifier() { + assert!(PurseId::MAIN.is_main()); + assert!(!PurseId(1).is_main()); + assert_eq!(PurseId::MAIN, PurseId(0)); + } + + #[test] + fn denomination_value_is_two_to_the_exponent() { + let two_cents = DenominationExponent::new(1).expect("1 is in range"); + assert_eq!(two_cents.value(), Amount::from_cents(2)); + + let largest = DenominationExponent::new(MAX_SUPPORTED_DENOMINATION_EXPONENT) + .expect("ceiling is valid"); + assert_eq!( + largest.value(), + Amount::from_cents(1u64 << MAX_SUPPORTED_DENOMINATION_EXPONENT) + ); + } + + #[test] + fn denomination_rejects_exponents_above_the_ceiling() { + assert!(DenominationExponent::new(MAX_SUPPORTED_DENOMINATION_EXPONENT + 1).is_none()); + } + + #[test] + fn amount_arithmetic_is_checked() { + let five = Amount::from_cents(5); + let three = Amount::from_cents(3); + + assert_eq!(five.checked_add(three), Some(Amount::from_cents(8))); + assert_eq!(five.checked_sub(three), Some(Amount::from_cents(2))); + assert_eq!(three.checked_sub(five), None); + assert_eq!(three.saturating_sub(five), Amount::ZERO); + assert_eq!(Amount::from_cents(u64::MAX).checked_add(five), None); + } + + #[test] + fn amount_narrows_to_the_wire_type_only_when_it_fits() { + assert_eq!(Amount::from_cents(42).to_wire(), Some(42)); + assert_eq!( + Amount::from_cents(u64::from(u32::MAX)).to_wire(), + Some(u32::MAX) + ); + assert_eq!(Amount::from_cents(u64::from(u32::MAX) + 1).to_wire(), None); + } + + #[test] + fn summing_a_purse_of_max_denomination_coins_does_not_overflow() { + let largest = DenominationExponent::new(MAX_SUPPORTED_DENOMINATION_EXPONENT) + .expect("ceiling is valid"); + let total: Amount = core::iter::repeat_n(largest.value(), 1_000).sum(); + assert_eq!( + total.cents(), + 1_000 * (1u64 << MAX_SUPPORTED_DENOMINATION_EXPONENT) + ); + } + + #[test] + fn handles_are_monotonic() { + let mut allocator = OperationHandleAllocator::default(); + let first = allocator.allocate(); + let second = allocator.allocate(); + + assert!(second > first); + assert_eq!(allocator.peek(), OperationHandle(2)); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage/unload_token.rs b/rust/crates/truapi-server/src/host_logic/coinage/unload_token.rs new file mode 100644 index 000000000..a690a357c --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage/unload_token.rs @@ -0,0 +1,676 @@ +//! Unload-token resolution and fee-mode choice. +//! +//! Every unload of a recycler entry consumes exactly one token, so a plan that +//! unloads three groups needs three tokens. Two classes exist: free tokens, a +//! per-period allowance derived from personhood, and paid tokens from a +//! period-specific ring anyone may join for a fee. +//! +//! The caller does not choose the class. Free slots are spent first and paid +//! tokens make up any shortfall, because a free token costs nothing and expires +//! unused at the end of its period. +//! +//! # The two classes count differently +//! +//! A free token is one `(period, counter)` pair, so a single personhood key covers +//! a whole period's allowance. A paid token's context carries the period and *no* +//! counter, so one paid member key is worth exactly one token per period. Wanting +//! three paid tokens in a period means three keys, three joins and three fees — +//! which is why a paid grant names a slot and the plan carries a list of joins +//! rather than a single flag. +//! +//! This module is pure. It decides *which* tokens to use given a snapshot of +//! what the chain reports consumed; fetching that snapshot, proving membership +//! and joining the paid ring are the chain layer's work. + +use std::collections::BTreeSet; + +use super::chain_constants::CoinageChainConstants; +use super::error::CoinageError; +use super::params::CoinageParameters; + +/// Which token an unload group should present. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenGrant { + /// A free slot backed by personhood, identified by its period and counter. + Free { + /// Period the slot belongs to. + period: u32, + /// Counter within that period. + counter: u32, + }, + /// A token from the period's paid ring, held by one of the wallet's slots. + Paid { + /// Period whose paid ring backs the token. + period: u32, + /// Which of the wallet's paid-token keys for that period proves it. + /// + /// A paid token's alias is produced in a context carrying the period and + /// **no counter**, so one key yields exactly one token per period. Two + /// tokens in the same period therefore mean two slots, two joins and two + /// fees. This is the difference between the paid ring and the free + /// allowance, where one personhood key covers the whole period. + slot: u32, + }, +} + +impl TokenGrant { + /// Whether this grant costs the user a fee. + pub const fn is_paid(&self) -> bool { + matches!(self, Self::Paid { .. }) + } +} + +/// What the chain reports about the user's free-token allowance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreeTokenAvailability { + /// Periods whose tokens are still eligible, most preferred first. + /// + /// The current period comes first; earlier periods appear only while inside + /// the lookback grace window, which absorbs a transaction prepared just + /// before a period boundary. + pub eligible_periods: Vec, + /// `(period, counter)` pairs the chain reports already consumed. + pub consumed: BTreeSet<(u32, u32)>, +} + +impl FreeTokenAvailability { + /// An availability snapshot with nothing consumed. + pub fn fresh(eligible_periods: Vec) -> Self { + Self { + eligible_periods, + consumed: BTreeSet::new(), + } + } + + /// Whether a specific slot is still free. + pub fn is_free(&self, period: u32, counter: u32) -> bool { + !self.consumed.contains(&(period, counter)) + } +} + +/// What one of the wallet's paid-token slots is worth right now. +/// +/// Joining and becoming provable are two steps, not one, and the gap between them +/// is why this carries both facts. `pay_for_recycler_unload_fee_token_with_*` +/// registers the key immediately, but the members pallet onboards it into an actual +/// ring afterwards — and a ring-VRF proof needs the ring. A slot between the two is +/// paid for and unusable, and the correct response is to wait. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PaidSlot { + /// Index of the slot within the period. + pub slot: u32, + /// Whether the slot's key is registered as a paid-token member. + /// + /// Once true this is permanent: the pallet refuses a member key it has already + /// seen, so a registered key can never be joined again. + pub joined: bool, + /// Whether the key has been placed in a ring that can be proved against. + /// + /// Implies [`Self::joined`]. False while onboarding is outstanding. + pub onboarded: bool, + /// Whether the slot's one token for this period has already been spent. + /// + /// A spent slot is dead for the rest of the period: its alias is marked + /// consumed and its key cannot be re-registered. + pub spent: bool, +} + +impl PaidSlot { + /// Whether this slot can back a token right now, with no join and no wait. + pub const fn is_ready(&self) -> bool { + self.onboarded && !self.spent + } + + /// Whether paying to join this slot would give the wallet a token. + /// + /// A registered-but-not-yet-onboarded slot is neither ready nor joinable: + /// paying again is refused and there is nothing to do but wait. + pub const fn is_joinable(&self) -> bool { + !self.joined && !self.spent + } +} + +/// What the chain reports about the period's paid-token ring. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaidRingState { + /// Period the paid ring belongs to. + pub period: u32, + /// Whether the pallet has created this period's collection. A ring that does + /// not exist cannot be joined, however well funded the wallet is. + pub collection_exists: bool, + /// Whether the fee account can pay to join, as a dry run answered it. + pub can_fund_join: bool, + /// The wallet's slots for this period, in slot order. + pub slots: Vec, +} + +impl PaidRingState { + /// A state in which the paid ring is unusable, whatever the reason. + pub fn unavailable(period: u32) -> Self { + Self { + period, + collection_exists: false, + can_fund_join: false, + slots: Vec::new(), + } + } + + /// Record whether the layer can pay for a join. + /// + /// Separate from the chain read because the pallet prices a join from a weight + /// rather than publishing it, so affordability is the caller's judgement, not + /// a storage value. + pub fn with_fundable_joins(mut self, can_fund_join: bool) -> Self { + self.can_fund_join = can_fund_join; + self + } +} + +/// Which tokens to use, and which paid slots must be joined first. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadTokenPlan { + /// One grant per unload group, in group order. + pub grants: Vec, + /// Slots whose join extrinsic must land — definitely — before the grants + /// naming them can be presented. + /// + /// One join per slot, each paying its own fee. Empty when every paid grant is + /// backed by a slot the wallet already holds. + pub joins: Vec, +} + +impl UnloadTokenPlan { + /// How many grants cost a fee. + pub fn paid_count(&self) -> usize { + self.grants.iter().filter(|grant| grant.is_paid()).count() + } +} + +/// Choose tokens for `needed` unload groups. +/// +/// Free slots are taken in period order, then by ascending counter, so two +/// conformant implementations spend the same slots. `NoUnloadToken` is returned +/// only when neither class can cover the shortfall — that is the distinction +/// between "wait for the next period" and "this wallet cannot unload at all". +pub fn resolve( + needed: usize, + free: &FreeTokenAvailability, + paid: &PaidRingState, + params: &CoinageParameters, + constants: &CoinageChainConstants, +) -> Result { + if needed == 0 { + return Ok(UnloadTokenPlan { + grants: Vec::new(), + joins: Vec::new(), + }); + } + + // The layer's probe window can never exceed the chain's per-period + // allowance; probing past it would only ever find slots the chain refuses. + let search_range = params + .free_token_counter_search_range + .min(constants.max_free_unload_tokens_per_period); + + let mut grants = Vec::with_capacity(needed); + + for &period in &free.eligible_periods { + for counter in 0..search_range { + if grants.len() == needed { + break; + } + if free.is_free(period, counter) { + grants.push(TokenGrant::Free { period, counter }); + } + } + if grants.len() == needed { + break; + } + } + + if grants.len() == needed { + return Ok(UnloadTokenPlan { + grants, + joins: Vec::new(), + }); + } + + // Paid tokens make up the shortfall, one slot per remaining group. Slots the + // wallet has already joined come first: they are paid for, and a slot that + // needs joining costs both a fee and a wait for the join to become definite. + let mut joins = Vec::new(); + let ready = paid.slots.iter().filter(|slot| slot.is_ready()); + for slot in ready { + if grants.len() == needed { + break; + } + grants.push(TokenGrant::Paid { + period: paid.period, + slot: slot.slot, + }); + } + + if grants.len() < needed && paid.collection_exists && paid.can_fund_join { + let joinable = paid.slots.iter().filter(|slot| slot.is_joinable()); + for slot in joinable { + if grants.len() == needed { + break; + } + joins.push(slot.slot); + grants.push(TokenGrant::Paid { + period: paid.period, + slot: slot.slot, + }); + } + } + + // Short even after the paid ring: the wallet waits for the next period. + // Reporting a partial plan would spend the free slots and the join fees and + // still fail, so nothing is committed. + if grants.len() < needed { + return Err(CoinageError::NoUnloadToken); + } + + Ok(UnloadTokenPlan { grants, joins }) +} + +/// How the network fee for an unload is settled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeeMode { + /// Paid from the layer's fee account alongside the unload. + Prepaid, + /// Deducted from the unloaded value. + FromOutput, +} + +impl FeeMode { + /// The `max_fee` argument the unload call carries under this mode. + /// + /// The pallet requires zero under `Prepaid`; under `FromOutput` the ceiling + /// must cover the network fee. + pub const fn max_fee(&self, estimated_fee: u128) -> u128 { + match self { + Self::Prepaid => 0, + Self::FromOutput => estimated_fee, + } + } +} + +/// Choose a fee mode from the fee account's balance at submission time. +/// +/// Prepaid whenever the account can cover the fee, because taking the fee from +/// the output shrinks the unloaded value and forces a different denomination +/// breakdown. The caller never chooses. +pub fn choose_fee_mode(fee_account_balance: u128, estimated_fee: u128) -> FeeMode { + if fee_account_balance >= estimated_fee { + FeeMode::Prepaid + } else { + FeeMode::FromOutput + } +} + +#[cfg(test)] +mod tests { + use super::super::chain_constants::next_people_paseo; + use super::*; + + fn params() -> CoinageParameters { + CoinageParameters::default() + } + + /// A paid ring with `ready` slots already joined and `joinable` more the + /// wallet could join if `can_fund_join`. + fn paid_with(ready: u32, joinable: u32, can_fund_join: bool) -> PaidRingState { + let mut slots = Vec::new(); + for slot in 0..ready { + slots.push(PaidSlot { + slot, + joined: true, + onboarded: true, + spent: false, + }); + } + for offset in 0..joinable { + slots.push(PaidSlot { + slot: ready + offset, + joined: false, + onboarded: false, + spent: false, + }); + } + + PaidRingState { + period: 42, + collection_exists: true, + can_fund_join, + slots, + } + } + + /// The old two-flag shape, expressed in slots: a wallet that either already + /// holds plenty of paid tokens or can join for as many as it needs. + fn paid(is_member: bool, can_fund_join: bool) -> PaidRingState { + if is_member { + paid_with(8, 0, can_fund_join) + } else { + paid_with(0, 8, can_fund_join) + } + } + + /// Every free slot in `period` spent, so only the paid ring is left. + fn no_free_slots(period: u32) -> FreeTokenAvailability { + FreeTokenAvailability { + eligible_periods: vec![period], + consumed: (0..params().free_token_counter_search_range) + .map(|counter| (period, counter)) + .collect(), + } + } + + fn resolve_with( + needed: usize, + free: &FreeTokenAvailability, + paid: &PaidRingState, + ) -> Result { + resolve(needed, free, paid, ¶ms(), &next_people_paseo()) + } + + #[test] + fn no_groups_need_no_tokens() { + let plan = resolve_with( + 0, + &FreeTokenAvailability::fresh(vec![7]), + &paid(false, false), + ) + .expect("zero is always satisfiable"); + + assert!(plan.grants.is_empty()); + assert!(plan.joins.is_empty()); + } + + #[test] + fn free_slots_are_spent_before_paid_ones() { + let plan = resolve_with(2, &FreeTokenAvailability::fresh(vec![7]), &paid(true, true)) + .expect("two free slots exist"); + + assert_eq!( + plan.grants, + vec![ + TokenGrant::Free { + period: 7, + counter: 0 + }, + TokenGrant::Free { + period: 7, + counter: 1 + }, + ] + ); + assert_eq!(plan.paid_count(), 0); + assert!(plan.joins.is_empty()); + } + + #[test] + fn consumed_slots_are_skipped_in_counter_order() { + let mut free = FreeTokenAvailability::fresh(vec![7]); + free.consumed.insert((7, 0)); + free.consumed.insert((7, 2)); + + let plan = resolve_with(2, &free, &paid(true, true)).expect("counters 1 and 3 are free"); + + assert_eq!( + plan.grants, + vec![ + TokenGrant::Free { + period: 7, + counter: 1 + }, + TokenGrant::Free { + period: 7, + counter: 3 + }, + ] + ); + } + + #[test] + fn a_later_period_is_only_reached_once_the_first_is_exhausted() { + let mut free = FreeTokenAvailability::fresh(vec![8, 7]); + for counter in 0..params().free_token_counter_search_range { + free.consumed.insert((8, counter)); + } + + let plan = + resolve_with(1, &free, &paid(true, true)).expect("the prior period still has slots"); + + assert_eq!( + plan.grants, + vec![TokenGrant::Free { + period: 7, + counter: 0 + }] + ); + } + + #[test] + fn paid_tokens_cover_a_shortfall() { + let plan = + resolve_with(2, &no_free_slots(7), &paid(true, true)).expect("the paid ring covers it"); + + // Two groups, two distinct slots: one key cannot back both, because its + // single alias is consumed by the first. + assert_eq!( + plan.grants, + vec![ + TokenGrant::Paid { + period: 42, + slot: 0 + }, + TokenGrant::Paid { + period: 42, + slot: 1 + }, + ] + ); + assert_eq!(plan.paid_count(), 2); + assert!(plan.joins.is_empty(), "both slots are already joined"); + } + + #[test] + fn each_paid_grant_names_its_own_slot() { + // The whole reason a grant carries a slot: reusing one key for two groups + // would have the second refused as an already-consumed alias, after the + // first had spent the fee. + let plan = + resolve_with(4, &no_free_slots(7), &paid_with(4, 0, false)).expect("four slots exist"); + + let slots: Vec = plan + .grants + .iter() + .map(|grant| match grant { + TokenGrant::Paid { slot, .. } => *slot, + TokenGrant::Free { .. } => unreachable!("no free slots remain"), + }) + .collect(); + assert_eq!(slots, vec![0, 1, 2, 3]); + } + + #[test] + fn a_spent_slot_is_not_offered_again() { + // A slot's token is gone once used, and its key cannot rejoin, so the + // wallet must reach past it to the next one. + let state = PaidRingState { + period: 42, + collection_exists: true, + can_fund_join: true, + slots: vec![ + PaidSlot { + slot: 0, + joined: true, + onboarded: true, + spent: true, + }, + PaidSlot { + slot: 1, + joined: true, + onboarded: true, + spent: false, + }, + ], + }; + + let plan = resolve_with(1, &no_free_slots(7), &state).expect("slot 1 is unspent"); + + assert_eq!( + plan.grants, + vec![TokenGrant::Paid { + period: 42, + slot: 1 + }] + ); + assert!(plan.joins.is_empty()); + } + + #[test] + fn already_joined_slots_are_preferred_over_ones_needing_a_fee() { + let plan = resolve_with(2, &no_free_slots(7), &paid_with(1, 3, true)) + .expect("one held slot plus one join"); + + assert_eq!( + plan.grants, + vec![ + TokenGrant::Paid { + period: 42, + slot: 0 + }, + TokenGrant::Paid { + period: 42, + slot: 1 + }, + ] + ); + assert_eq!(plan.joins, vec![1], "only the second slot costs a join"); + } + + #[test] + fn a_period_whose_collection_does_not_exist_cannot_be_joined() { + // The pallet creates a period's collection in its own `on_poll`. Until it + // has, a join has nothing to add a member to, however well funded. + let state = PaidRingState { + period: 42, + collection_exists: false, + can_fund_join: true, + slots: vec![PaidSlot { + slot: 0, + joined: false, + onboarded: false, + spent: false, + }], + }; + + assert_eq!( + resolve_with(1, &no_free_slots(7), &state), + Err(CoinageError::NoUnloadToken) + ); + } + + #[test] + fn running_out_of_slots_is_refused_rather_than_partly_planned() { + // A plan short of one token would spend every free slot and every join fee + // it did name, then fail on the last group. + assert_eq!( + resolve_with(3, &no_free_slots(7), &paid_with(1, 1, true)), + Err(CoinageError::NoUnloadToken) + ); + } + + #[test] + fn a_mixed_plan_keeps_free_slots_first() { + let mut free = FreeTokenAvailability::fresh(vec![7]); + for counter in 1..params().free_token_counter_search_range { + free.consumed.insert((7, counter)); + } + + let plan = resolve_with(3, &free, &paid(true, true)).expect("one free plus two paid"); + + assert_eq!( + plan.grants[0], + TokenGrant::Free { + period: 7, + counter: 0 + } + ); + assert_eq!(plan.paid_count(), 2); + } + + #[test] + fn joining_the_paid_ring_is_requested_when_not_a_member() { + let plan = + resolve_with(1, &no_free_slots(7), &paid(false, true)).expect("the join can be funded"); + + assert_eq!(plan.joins, vec![0]); + assert_eq!( + plan.grants, + vec![TokenGrant::Paid { + period: 42, + slot: 0 + }] + ); + } + + #[test] + fn no_free_slots_and_an_unfundable_join_is_a_dead_end() { + assert_eq!( + resolve_with(1, &no_free_slots(7), &paid(false, false)), + Err(CoinageError::NoUnloadToken) + ); + } + + #[test] + fn with_no_eligible_period_the_paid_ring_is_the_only_source() { + let free = FreeTokenAvailability::fresh(Vec::new()); + + let plan = resolve_with(1, &free, &paid(true, true)).expect("paid covers it"); + + assert_eq!( + plan.grants, + vec![TokenGrant::Paid { + period: 42, + slot: 0 + }] + ); + } + + #[test] + fn the_probe_window_never_exceeds_the_chain_allowance() { + let constants = CoinageChainConstants { + max_free_unload_tokens_per_period: 2, + ..next_people_paseo() + }; + let params = CoinageParameters { + free_token_counter_search_range: 10, + ..params() + }; + let free = FreeTokenAvailability::fresh(vec![7]); + + let plan = resolve(4, &free, &paid(true, true), ¶ms, &constants) + .expect("two free then two paid"); + + // Only counters 0 and 1 exist on this runtime; the rest must be paid. + assert_eq!(plan.grants.iter().filter(|g| !g.is_paid()).count(), 2); + assert_eq!(plan.paid_count(), 2); + } + + #[test] + fn prepaid_is_chosen_while_the_fee_account_can_cover_the_fee() { + assert_eq!(choose_fee_mode(1_000, 100), FeeMode::Prepaid); + assert_eq!(choose_fee_mode(100, 100), FeeMode::Prepaid); + assert_eq!(choose_fee_mode(99, 100), FeeMode::FromOutput); + } + + #[test] + fn the_max_fee_argument_is_zero_only_under_prepaid() { + assert_eq!(FeeMode::Prepaid.max_fee(500), 0); + assert_eq!(FeeMode::FromOutput.max_fee(500), 500); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 318d75dee..f694b9a9c 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -52,7 +52,7 @@ pub fn derive_product_entropy_from_source( Ok(blake2b256_keyed(&per_product_entropy, key)) } -fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { +pub(crate) fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { blake2b_simd::Params::new() .hash_length(32) .key(key) diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index d9a5c1e66..fe4708d9e 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -156,7 +156,7 @@ pub fn derive_sr25519_hard_path( } /// Create a Substrate soft-derivation chain code for one junction. -fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { +pub(crate) fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { let encoded = if !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) { code.parse::() .map_err(|_| ProductAccountError::NumericJunctionOutOfRange)? diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index cf10b2153..4c546b561 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -45,6 +45,8 @@ pub use host_core::{ }; pub use runtime::ResponderExit; #[cfg(not(target_arch = "wasm32"))] +pub use runtime::coinage; +#[cfg(not(target_arch = "wasm32"))] pub use runtime::statement_allowance; pub use truapi_platform::{ HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 50f31d01a..bd447bd32 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -13,6 +13,10 @@ pub(crate) mod auth_state; mod authority; /// In-core Bulletin preimage submission over the shared Subxt client. pub(crate) mod bulletin_rpc; +/// Chain-facing coinage: pallet calls, signing, submission, observation. +/// Coinage needs key material, so it is a signing-host concern only. +#[cfg(not(target_arch = "wasm32"))] +pub mod coinage; mod identity; mod pairing_host; /// Role-neutral runtime services shared by product-facing runtimes. diff --git a/rust/crates/truapi-server/src/runtime/coinage.rs b/rust/crates/truapi-server/src/runtime/coinage.rs new file mode 100644 index 000000000..eef29a980 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage.rs @@ -0,0 +1,28 @@ +//! Chain-facing coinage orchestration. +//! +//! The domain model lives in [`crate::host_logic::coinage`] and knows nothing +//! about extrinsics. This tree turns its plans into pallet calls, signs and +//! submits them, and feeds chain observations back in. +//! +//! Coinage needs key material, so it is a signing-host concern: a seedless +//! pairing host forwards these operations rather than performing them. + +pub mod bootstrap; +pub mod call; +pub mod execute; +pub mod extension; +pub mod extrinsic; +pub mod fee; +pub mod observe; +pub mod persistence; +pub mod plan; +pub mod proof; +pub mod recover; +pub mod ring; +pub mod scan; +pub mod storage; +pub mod submit; +pub mod subscription; +#[cfg(test)] +pub mod testing; +pub mod tokens; diff --git a/rust/crates/truapi-server/src/runtime/coinage/bootstrap.rs b/rust/crates/truapi-server/src/runtime/coinage/bootstrap.rs new file mode 100644 index 000000000..abbbd5838 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/bootstrap.rs @@ -0,0 +1,708 @@ +//! Bringing the layer up: chain constants, the fee account, and the store. +//! +//! `coinage-layer.md` §6.7 and §13. Two things happen here that must happen +//! before any operation is accepted. +//! +//! **The runtime is checked.** Chain-enforced limits are read from metadata and +//! validated, so an unsupported runtime is refused at connection rather than +//! discovered at the first rejected extrinsic. Two of the ten constants are not +//! discoverable and arrive as configuration instead; where a configured value +//! *is* observable, disagreement is a hard failure rather than something to +//! reconcile silently. +//! +//! **The store is loaded.** From durable storage if it exists, otherwise fresh +//! with only the main purse, which exists by construction once entropy is +//! present. + +use core::time::Duration; +use std::collections::BTreeMap; +use std::sync::Arc; + +use futures::channel::mpsc; +use futures::stream::BoxStream; +use parity_scale_codec::Decode; +use truapi_platform::CoreStorage; + +use crate::host_logic::coinage::chain_constants::CoinageChainConstants; +use crate::host_logic::coinage::derivation; +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::event::LayerEvent; +use crate::host_logic::coinage::operation::OperationStatus; +use crate::host_logic::coinage::params::CoinageParameters; +use crate::host_logic::coinage::purse::PurseBalance; +use crate::host_logic::coinage::store::CoinageStore; +use crate::host_logic::coinage::types::{ + CoinAccountId, CoinAge, CoinSecret, OperationHandle, PurseId, Timestamp, +}; +use crate::runtime::coinage::execute::{ + Completion, ExportedCoin, MemoCallback, OffloadRequest, RecoveryRequest, +}; +use crate::runtime::coinage::extrinsic::FundingOrigin; +use crate::runtime::coinage::persistence; +use crate::runtime::coinage::plan::OperationProgram; +use crate::runtime::coinage::subscription::CoinageSubscriptions; +use crate::runtime::statement_allowance::extension::Metadata; + +/// Pallet whose constants describe the layer's limits. +const PALLET: &str = "Coinage"; + +/// Values the layer must be told because the deployed runtime does not publish +/// them, plus the local naming choice for the main purse. +/// +/// The first two are exactly the constants guarding the two fund-loss paths — +/// coins ageing out, and entries expiring in a ring — so a deployment that gets +/// them wrong loses value silently. See `coinage-layer.md` Appendix A.0. +/// +/// The two paid-token values cannot lose value, but a wrong period spends a join +/// fee on a token that proves against the wrong collection. +#[derive(Debug, Clone)] +pub struct CoinageConfig { + /// `MaximumAge`: declared without `#[pallet::constant]`, so absent from + /// metadata on every runtime. + pub maximum_age: CoinAge, + /// `RecyclerExpirationTime`: marked `#[pallet::constant]` in the pallet + /// source but absent from the deployed runtime's metadata. + pub recycler_expiration_time: Duration, + /// `PaidUnloadTokenTimePeriod`: as above. Distinct from the free-token + /// period, and longer on the reference runtime. + pub paid_unload_token_period: Duration, + /// `PaidUnloadTokenRingExpirationTime`: as above. + pub paid_unload_token_ring_expiration: Duration, + /// Display name for the main purse on first run. + pub main_purse_name: String, +} + +impl Default for CoinageConfig { + /// The `next-people-paseo` values. + fn default() -> Self { + Self { + maximum_age: CoinAge(16), + recycler_expiration_time: Duration::from_secs(90 * 24 * 60 * 60), + paid_unload_token_period: Duration::from_secs(3 * 24 * 60 * 60), + paid_unload_token_ring_expiration: Duration::from_secs(4 * 24 * 60 * 60), + main_purse_name: "Main".to_string(), + } + } +} + +/// A brought-up coinage layer: validated constants, the fee account, and the +/// record store. +/// +/// Holds the root entropy, so it is never `Debug`-printed in full and must not +/// be logged. Chain access and the operation machinery attach to this in later +/// layers; this type owns what every one of them needs. +pub struct CoinageLayer { + entropy: Vec, + constants: CoinageChainConstants, + params: CoinageParameters, + fee_account: CoinAccountId, + store: CoinageStore, + subscriptions: Arc, + /// What a wallet-recovery scan was asked to walk, by operation. + recoveries: BTreeMap, + /// Who signs for a top-up's incoming asset, by operation. + /// + /// Beside the program rather than inside it: a signer is not data, and the + /// account it speaks for is not one this layer holds. + funding: BTreeMap>, + /// What an external offload was asked to do, by operation. + /// + /// An offload re-plans between phases, so it has a request rather than a + /// program. Not durable: a restart before the first broadcast is a cancel, and + /// after one, recovery resolves the log. + offloads: BTreeMap, + /// Sinks for the coins an export hands out, by operation. + exports: BTreeMap>, + /// Secrets an import was handed, by operation. + /// + /// Dropped as soon as the operation's transactions have been broadcast: §8.5 + /// requires the layer not to retain them, and holding them for longer would + /// keep spendable material alive for no purpose. + import_secrets: BTreeMap>, + /// Work to apply once an operation's transactions have definitely settled. + /// + /// Not durable, for the same reason as the programs: an operation that never + /// broadcast has nothing to complete, and one that did is resolved by recovery + /// from the log rather than from a local intention. + completions: BTreeMap, + /// Memo callbacks awaiting their transactions' inclusion, by operation. + /// + /// Alongside the programs rather than inside them because a callback is not + /// data: it cannot be compared, printed, or persisted. + memos: BTreeMap, + /// Transactions planned but not yet submitted, by operation. + /// + /// Deliberately not durable. §7.8 makes a restart while preparing equivalent + /// to a cancel, so a program that never reached a broadcast has nothing worth + /// surviving: the records it named are still locked in the persisted store and + /// `reconcile_after_restart` releases them. + programs: BTreeMap, +} + +impl core::fmt::Debug for CoinageLayer { + /// Deliberately omits the entropy and the records. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CoinageLayer") + .field("purses", &self.store.purses().count()) + .field("fee_account", &hex::encode(self.fee_account.0)) + .finish_non_exhaustive() + } +} + +impl CoinageLayer { + /// Read the runtime's constants, derive the fee account, and load the store. + /// + /// Fails rather than degrades: an unsupported runtime, a constant the + /// runtime should publish but does not, or an undecodable store all stop + /// the layer from coming up. + pub async fn initialize( + storage: &S, + metadata: &Metadata, + entropy: Vec, + config: &CoinageConfig, + ) -> Result { + let constants = read_chain_constants(metadata, config)?; + constants.validate()?; + + let fee_account = derivation::fee_account_id(&entropy)?; + let store = persistence::load(storage, &config.main_purse_name).await?; + + Ok(Self { + entropy, + constants, + params: CoinageParameters::default(), + fee_account, + store, + subscriptions: CoinageSubscriptions::new(), + recoveries: BTreeMap::new(), + funding: BTreeMap::new(), + offloads: BTreeMap::new(), + exports: BTreeMap::new(), + import_secrets: BTreeMap::new(), + completions: BTreeMap::new(), + memos: BTreeMap::new(), + programs: BTreeMap::new(), + }) + } + + /// The runtime's chain-enforced limits. + pub const fn constants(&self) -> &CoinageChainConstants { + &self.constants + } + + /// The layer's policy tunables. + pub const fn params(&self) -> &CoinageParameters { + &self.params + } + + /// The account paying unload fees. + pub const fn fee_account(&self) -> CoinAccountId { + self.fee_account + } + + /// Root entropy, for the derivation calls that need it. + pub fn entropy(&self) -> &[u8] { + &self.entropy + } + + /// The record store. + pub const fn store(&self) -> &CoinageStore { + &self.store + } + + /// The record store, mutably. Every mutation must be followed by + /// [`Self::publish_and_persist`] before the layer yields to the caller. + pub const fn store_mut(&mut self) -> &mut CoinageStore { + &mut self.store + } + + /// Remember the transactions an operation still has to submit. + pub(crate) fn register_program(&mut self, handle: OperationHandle, program: OperationProgram) { + self.programs.insert(handle, program); + } + + /// Remember what a recovery scan should walk. + pub(crate) fn register_recovery(&mut self, handle: OperationHandle, request: RecoveryRequest) { + self.recoveries.insert(handle, request); + } + + /// Take a recovery's request, leaving nothing behind. + pub(crate) fn take_recovery(&mut self, handle: OperationHandle) -> Option { + self.recoveries.remove(&handle) + } + + /// Remember who signs for a top-up. + pub(crate) fn register_funding_origin( + &mut self, + handle: OperationHandle, + origin: Arc, + ) { + self.funding.insert(handle, origin); + } + + /// The funding origin for an operation, if it has one. + pub(crate) fn funding_origin( + &self, + handle: OperationHandle, + ) -> Option> { + self.funding.get(&handle).cloned() + } + + /// Forget a top-up's funding origin. + pub(crate) fn forget_funding_origin(&mut self, handle: OperationHandle) { + self.funding.remove(&handle); + } + + /// Remember what an offload was asked to do. + pub(crate) fn register_offload(&mut self, handle: OperationHandle, request: OffloadRequest) { + self.offloads.insert(handle, request); + } + + /// Take an offload's request, leaving nothing behind. + pub(crate) fn take_offload(&mut self, handle: OperationHandle) -> Option { + self.offloads.remove(&handle) + } + + /// Remember where an export's coins should be delivered. + pub(crate) fn register_export( + &mut self, + handle: OperationHandle, + sender: mpsc::UnboundedSender, + ) { + self.exports.insert(handle, sender); + } + + /// Hand one exported coin to whoever holds the export's stream. + pub(crate) fn send_export(&mut self, handle: OperationHandle, coin: ExportedCoin) { + if let Some(sender) = self.exports.get(&handle) { + let _ = sender.unbounded_send(coin); + } + } + + /// Close an export's stream: no further coin can be emitted for it. + pub(crate) fn close_exports(&mut self, handle: OperationHandle) { + self.exports.remove(&handle); + } + + /// Remember the secrets an import was handed. + pub(crate) fn register_import_secrets( + &mut self, + handle: OperationHandle, + secrets: Vec, + ) { + self.import_secrets.insert(handle, secrets); + } + + /// One of an import's supplied secrets, by position. + pub(crate) fn import_secret( + &self, + handle: OperationHandle, + position: usize, + ) -> Option<&CoinSecret> { + self.import_secrets.get(&handle)?.get(position) + } + + /// Drop every secret an import was handed. + pub(crate) fn forget_import_secrets(&mut self, handle: OperationHandle) { + self.import_secrets.remove(&handle); + } + + /// Remember what to do once an operation's transactions have settled. + pub(crate) fn register_completion(&mut self, handle: OperationHandle, completion: Completion) { + self.completions.insert(handle, completion); + } + + /// Take an operation's completion, leaving nothing behind. + pub(crate) fn take_completion(&mut self, handle: OperationHandle) -> Option { + self.completions.remove(&handle) + } + + /// Remember a memo callback to invoke as the operation's transactions land. + pub(crate) fn register_memo(&mut self, handle: OperationHandle, memo: MemoCallback) { + self.memos.insert(handle, memo); + } + + /// The memo callback for an operation, if the caller supplied one. + pub(crate) fn memo_of(&self, handle: OperationHandle) -> Option<&MemoCallback> { + self.memos.get(&handle) + } + + /// Forget an operation's memo callback, once it can no longer fire. + pub(crate) fn forget_memo(&mut self, handle: OperationHandle) { + self.memos.remove(&handle); + } + + /// Take an operation's program, leaving nothing behind. + /// + /// Taken rather than borrowed so driving an operation twice cannot submit its + /// transactions twice. + pub(crate) fn take_program(&mut self, handle: OperationHandle) -> Option { + self.programs.remove(&handle) + } + + /// Whether an operation still has transactions waiting to be submitted. + pub fn has_pending_program(&self, handle: OperationHandle) -> bool { + self.programs.contains_key(&handle) + } + + /// Fix the jitter upper bound, so a test can make a fresh entry usable at once. + /// + /// Production draws a delay in `[0, bound]` per new entry (§5.3); a test that + /// wants the next phase to see the entry sets the bound to zero. + #[cfg(test)] + pub(crate) fn set_jitter_for_tests(&mut self, bound: Duration) { + self.params.recycler_entry_jitter_upper_bound = bound; + } + + /// Shrink the recovery scan's window, so a test does not derive thousands of + /// keys to prove one behaviour. + #[cfg(test)] + pub(crate) fn set_recovery_limits_for_tests(&mut self, batch_size: u32, gap_limit: u32) { + self.params.recovery_batch_size = batch_size; + self.params.recovery_gap_limit = gap_limit; + } + + /// Publish the store's pending events to the layer's subscribers, then write + /// the store back. + /// + /// `now` is what the balance streams are reprojected against; see + /// [`CoinageSubscriptions`] for why a balance cannot ride on an event. + pub async fn publish_and_persist( + &mut self, + storage: &S, + now: Timestamp, + ) -> Result<(), CoinageError> + where + S: CoreStorage + ?Sized, + { + let subscriptions = self.subscriptions.clone(); + persistence::publish_and_persist(storage, &mut self.store, move |events, store| { + subscriptions.publish(&events, store, now); + }) + .await + } + + /// Reproject the balance streams without a mutation to publish. + /// + /// For the driver's clock tick: a jitter delay elapsing or a chain lock + /// expiring changes a purse's balance while every record stays as it was. + pub fn refresh_subscriptions(&self, now: Timestamp) { + self.subscriptions.refresh(&self.store, now); + } + + /// Subscribe to the layer's event stream (§8.9). + pub fn subscribe_events(&self) -> BoxStream<'static, LayerEvent> { + self.subscriptions.subscribe_events() + } + + /// Subscribe to a purse's balance, current value first (§8.9). + pub fn subscribe_purse_balance( + &self, + purse: PurseId, + now: Timestamp, + ) -> Result, CoinageError> { + self.subscriptions + .subscribe_purse_balance(&self.store, purse, now) + } + + /// Subscribe to an operation's status stream (§7.2). + pub fn subscribe_operation_status( + &self, + handle: OperationHandle, + ) -> Result, CoinageError> { + self.subscriptions + .subscribe_operation_status(&self.store, handle) + } +} + +/// Assemble the runtime's constants from metadata plus the four it cannot +/// publish. +pub fn read_chain_constants( + metadata: &Metadata, + config: &CoinageConfig, +) -> Result { + let constants = CoinageChainConstants { + minimum_exponent: required(metadata, "MinimumExponent")?, + maximum_exponent: required(metadata, "MaximumExponent")?, + maximum_age: agreed(metadata, "MaximumAge", config.maximum_age, |value: u16| { + CoinAge(value) + })?, + max_split_outputs: required(metadata, "MaxSplitOutputs")?, + max_consolidation: required(metadata, "MaxConsolidation")?, + recycler_expiration_time: agreed( + metadata, + "RecyclerExpirationTime", + config.recycler_expiration_time, + |secs: u32| Duration::from_secs(u64::from(secs)), + )?, + unload_token_period: required::(metadata, "UnloadTokenTimePeriodPeopleLitePeople") + .map(|secs| Duration::from_secs(u64::from(secs)))?, + paid_unload_token_period: agreed( + metadata, + "PaidUnloadTokenTimePeriod", + config.paid_unload_token_period, + |secs: u32| Duration::from_secs(u64::from(secs)), + )?, + paid_unload_token_ring_expiration: agreed( + metadata, + "PaidUnloadTokenRingExpirationTime", + config.paid_unload_token_ring_expiration, + |secs: u32| Duration::from_secs(u64::from(secs)), + )?, + max_free_unload_tokens_per_period: required(metadata, "MaxFreeUnloadTokensPerTimePeriod")?, + max_batch_unpaid_load: required(metadata, "MaxBatchUnpaidLoad")?, + underlying_asset_unit: required(metadata, "UnderlyingAssetUnit")?, + coin_failure_lock_period: required::(metadata, "CoinFailureLockPeriod") + .map(Duration::from_secs)?, + }; + + Ok(constants) +} + +/// Read a constant the runtime is expected to publish. +fn required(metadata: &Metadata, name: &str) -> Result { + let bytes = metadata.constant(PALLET, name).ok_or_else(|| { + CoinageError::Internal(format!( + "runtime does not publish {PALLET}.{name}; this layer cannot operate against it" + )) + })?; + T::decode(&mut &bytes[..]) + .map_err(|error| CoinageError::Internal(format!("decoding {PALLET}.{name}: {error}"))) +} + +/// Take a configured value, but refuse to disagree with the runtime. +/// +/// These constants are absent from the deployed runtime, so configuration is the +/// only source. A newer runtime that does publish one must agree with what the +/// deployment was told: two of them drive a sweep whose whole job is to beat a +/// chain deadline, and being quietly wrong about either destroys value. +fn agreed( + metadata: &Metadata, + name: &str, + configured: T, + convert: impl Fn(R) -> T, +) -> Result +where + R: Decode, + T: PartialEq + core::fmt::Debug, +{ + let Some(bytes) = metadata.constant(PALLET, name) else { + return Ok(configured); + }; + let observed = R::decode(&mut &bytes[..]) + .map_err(|error| CoinageError::Internal(format!("decoding {PALLET}.{name}: {error}"))) + .map(convert)?; + + if observed == configured { + Ok(configured) + } else { + Err(CoinageError::Internal(format!( + "{PALLET}.{name} is configured as {configured:?} but the runtime reports {observed:?}" + ))) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use parity_scale_codec::Encode; + use truapi::v01; + use truapi_platform::CoreStorageKey; + + use crate::host_logic::coinage::chain_constants::next_people_paseo; + use crate::host_logic::coinage::types::PurseId; + + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn metadata() -> Metadata { + Metadata::decode(FIXTURE).expect("the fixture decodes") + } + + #[derive(Default)] + struct MemStorage(Mutex, Vec>>); + + #[truapi_platform::async_trait] + impl CoreStorage for MemStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(self.0.lock().unwrap().get(&key.encode()).cloned()) + } + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + self.0.lock().unwrap().insert(key.encode(), value); + Ok(()) + } + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + self.0.lock().unwrap().remove(&key.encode()); + Ok(()) + } + } + + fn block_on(future: F) -> F::Output { + futures::executor::block_on(future) + } + + const ENTROPY: [u8; 32] = [7; 32]; + const NOW: Timestamp = Timestamp(1_000_000); + + #[test] + fn the_deployed_runtime_yields_the_reference_constants() { + // The fixture is a real paseo-next runtime, so this pins the reader + // against the same values `coinage_chain_agreement` confirms live. + let read = read_chain_constants(&metadata(), &CoinageConfig::default()).expect("reads"); + + assert_eq!(read, next_people_paseo()); + assert_eq!(read.validate(), Ok(())); + } + + #[test] + fn configuration_supplies_what_the_runtime_does_not_publish() { + // `MaximumAge` is absent from this runtime's metadata, so the + // configured value is the only source and is taken verbatim. + let config = CoinageConfig { + maximum_age: CoinAge(99), + ..CoinageConfig::default() + }; + + let read = read_chain_constants(&metadata(), &config).expect("reads"); + + assert_eq!(read.maximum_age, CoinAge(99)); + assert!( + metadata().constant(PALLET, "MaximumAge").is_none(), + "the premise: this runtime really does not publish it" + ); + } + + #[test] + fn a_disagreement_between_config_and_runtime_is_fatal() { + let observed: Result = agreed( + &metadata(), + "CoinFailureLockPeriod", + Duration::from_secs(60), + |secs: u64| Duration::from_secs(secs), + ); + assert_eq!(observed.expect("agrees"), Duration::from_secs(60)); + + let mismatch: Result = agreed( + &metadata(), + "CoinFailureLockPeriod", + Duration::from_secs(5), + |secs: u64| Duration::from_secs(secs), + ); + assert!( + mismatch.is_err(), + "a runtime that contradicts configuration must stop the layer" + ); + } + + #[test] + fn a_missing_required_constant_stops_the_layer() { + let absent: Result = required(&metadata(), "DefinitelyNotAConstant"); + + assert!(absent.is_err()); + } + + #[test] + fn initialize_derives_the_fee_account_and_loads_the_store() { + let storage = MemStorage::default(); + + let layer = block_on(CoinageLayer::initialize( + &storage, + &metadata(), + ENTROPY.to_vec(), + &CoinageConfig::default(), + )) + .expect("initializes"); + + assert_eq!(layer.store().purses().count(), 1); + assert!(layer.store().purse(PurseId::MAIN).is_some()); + assert_eq!( + layer.fee_account(), + derivation::fee_account_id(&ENTROPY).expect("derives") + ); + assert_eq!(layer.constants(), &next_people_paseo()); + } + + #[test] + fn initialize_reloads_a_persisted_store() { + let storage = MemStorage::default(); + let mut layer = block_on(CoinageLayer::initialize( + &storage, + &metadata(), + ENTROPY.to_vec(), + &CoinageConfig::default(), + )) + .expect("initializes"); + let savings = layer.store_mut().create_purse("Savings".to_string()); + block_on(layer.publish_and_persist(&storage, NOW)).expect("persists"); + + let reopened = block_on(CoinageLayer::initialize( + &storage, + &metadata(), + ENTROPY.to_vec(), + &CoinageConfig::default(), + )) + .expect("initializes"); + + assert_eq!(reopened.store().purses().count(), 2); + assert!(reopened.store().purse(savings).is_some()); + } + + #[test] + fn persisting_publishes_to_the_layers_own_subscribers() { + use futures::{FutureExt, StreamExt}; + + let storage = MemStorage::default(); + let mut layer = block_on(CoinageLayer::initialize( + &storage, + &metadata(), + ENTROPY.to_vec(), + &CoinageConfig::default(), + )) + .expect("initializes"); + let mut events = layer.subscribe_events(); + let mut balances = layer + .subscribe_purse_balance(PurseId::MAIN, NOW) + .expect("purse exists"); + let _ = block_on(balances.next()); + + let savings = layer.store_mut().create_purse("Savings".to_string()); + block_on(layer.publish_and_persist(&storage, NOW)).expect("persists"); + + assert_eq!( + block_on(events.next()), + Some(LayerEvent::PurseCreated { + purse: savings, + name: "Savings".to_string(), + }) + ); + // The new purse leaves the main purse's balance where it was. + assert!(balances.next().now_or_never().is_none()); + } + + #[test] + fn the_debug_rendering_never_carries_entropy() { + let storage = MemStorage::default(); + let layer = block_on(CoinageLayer::initialize( + &storage, + &metadata(), + ENTROPY.to_vec(), + &CoinageConfig::default(), + )) + .expect("initializes"); + + let rendered = format!("{layer:?}"); + + assert!(!rendered.contains(&hex::encode(ENTROPY))); + assert!(!rendered.contains("07070707")); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/call.rs b/rust/crates/truapi-server/src/runtime/coinage/call.rs new file mode 100644 index 000000000..0bea3b5e0 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/call.rs @@ -0,0 +1,656 @@ +//! Argument construction for the coinage pallet's dispatchables. +//! +//! Each type here mirrors one call's arguments as the pallet declares them, so a +//! plan from the domain layer becomes something submittable without any shape +//! guessing in between. Pallet and call indices are deliberately absent: the +//! house rule is to resolve them by name from live metadata, so a re-indexed +//! runtime fails loudly instead of silently dispatching the wrong call. +//! +//! Two pallet constraints are enforced here rather than discovered on chain, +//! because a rejected extrinsic after a coin has been consumed is expensive: +//! the split-output cap, and conservation of value across a split. + +use parity_scale_codec::{Encode, Output}; + +use crate::host_logic::coinage::chain_constants::CoinageChainConstants; +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::types::{ + Amount, CoinAccountId, DenominationExponent, RingLocation, +}; + +/// A SCALE blob that is spliced in as-is. +/// +/// Ring-VRF proofs and bandersnatch signatures are runtime-specific types whose +/// layout this crate does not model. They arrive already encoded by the +/// `verifiable` crate and must reach the extrinsic byte-for-byte, so this +/// wrapper writes its contents verbatim — no length prefix, unlike `Vec`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RawEncoded(pub Vec); + +impl Encode for RawEncoded { + fn size_hint(&self) -> usize { + self.0.len() + } + + fn encode_to(&self, dest: &mut T) { + dest.write(&self.0); + } +} + +/// A coin the chain is being asked to create: its denomination and the account +/// that will hold it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CoinOutput { + /// Denomination of the coin to mint. + pub exponent: DenominationExponent, + /// Account that will hold it. + pub account: CoinAccountId, +} + +/// The pallet's `split_into` argument: destinations grouped under each +/// denomination. +/// +/// The nesting is the pallet's, not ours — one denomination may have several +/// destination accounts, which is how a transfer sends two equal outputs to two +/// different recipients. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct SplitInto(pub Vec<(i8, Vec<[u8; 32]>)>); + +impl SplitInto { + /// Group outputs by denomination, preserving the order in which each + /// denomination first appears so the encoding is deterministic. + pub fn from_outputs( + outputs: &[CoinOutput], + constants: &CoinageChainConstants, + ) -> Result { + let cap = constants.max_split_outputs as usize; + if outputs.len() > cap { + return Err(CoinageError::Internal(format!( + "{} outputs exceeds the runtime's MaxSplitOutputs of {cap}", + outputs.len() + ))); + } + + let mut grouped: Vec<(i8, Vec<[u8; 32]>)> = Vec::new(); + for output in outputs { + if !constants.accepts(output.exponent) { + return Err(CoinageError::Internal(format!( + "denomination {} is outside the runtime's range", + output.exponent + ))); + } + + let value = output.exponent.get(); + match grouped.iter_mut().find(|(existing, _)| *existing == value) { + Some((_, accounts)) => accounts.push(output.account.0), + None => grouped.push((value, vec![output.account.0])), + } + } + + // The outer vector is bounded by the same cap as the inner ones. + if grouped.len() > cap { + return Err(CoinageError::Internal(format!( + "{} distinct denominations exceeds the runtime's MaxSplitOutputs of {cap}", + grouped.len() + ))); + } + + Ok(Self(grouped)) + } + + /// Total value of every output. + pub fn total_value(&self) -> Amount { + self.0 + .iter() + .filter_map(|(value, accounts)| { + DenominationExponent::new(*value) + .map(|exponent| (exponent.value(), accounts.len() as u64)) + }) + .map(|(unit, count)| Amount::from_cents(unit.cents().saturating_mul(count))) + .sum() + } + + /// How many coins the call will create. + pub fn output_count(&self) -> usize { + self.0.iter().map(|(_, accounts)| accounts.len()).sum() + } +} + +/// Arguments of `Coinage::split`. +/// +/// The origin is the coin being split, supplied by the `AsCoinage` extension +/// rather than as an argument. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct SplitArgs { + /// Denominations and destinations of the resulting coins. + pub split_into: SplitInto, +} + +impl SplitArgs { + /// Build a split of `source` into `outputs`. + /// + /// Rejects an output set whose value differs from the source coin's. The + /// pallet requires equality, and by the time it says so the coin has already + /// been consumed by the extension. + pub fn new( + source: DenominationExponent, + outputs: &[CoinOutput], + constants: &CoinageChainConstants, + ) -> Result { + let split_into = SplitInto::from_outputs(outputs, constants)?; + let produced = split_into.total_value(); + + if produced != source.value() { + return Err(CoinageError::Internal(format!( + "split of {source} would produce {produced}, not {}", + source.value() + ))); + } + + Ok(Self { split_into }) + } +} + +/// Arguments of `Coinage::transfer`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode)] +pub struct TransferArgs { + /// Account that will hold the coin. + pub to: [u8; 32], +} + +impl TransferArgs { + /// Transfer the origin coin to `to`. + pub fn new(to: CoinAccountId) -> Self { + Self { to: to.0 } + } +} + +/// Arguments of `Coinage::load_recycler_with_coin`. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct LoadRecyclerWithCoinArgs { + /// Bandersnatch member key the entry publishes into the ring. + pub member_key: [u8; 32], + /// Signature proving control of that member key. + pub proof_of_ownership: RawEncoded, +} + +impl LoadRecyclerWithCoinArgs { + /// Recycle the origin coin into a fresh entry under `member_key`. + pub fn new(member_key: [u8; 32], proof_of_ownership: RawEncoded) -> Self { + Self { + member_key, + proof_of_ownership, + } + } +} + +/// Arguments of `Coinage::pay_for_recycler_unload_fee_token_with_native`. +/// +/// Joins one paid-token slot's key to the current period's ring, which is what +/// buys the wallet one unload token for that period. The fee comes out of the +/// signing account's native balance; the layer signs with its fee account. +/// +/// The call takes no period. The pallet reads its *own* clock at dispatch and adds +/// the member to whichever period is current then — so a join submitted close to a +/// boundary can land in the next period, and the slot the layer planned for is not +/// necessarily the slot it gets. That is why membership is re-read after a join +/// rather than assumed, and why nothing about the join is written into the token's +/// proof ahead of time. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct PayForUnloadFeeTokenArgs { + /// Bandersnatch member key the slot publishes into the paid ring. + pub member_key: [u8; 32], + /// Signature over the joining account, proving control of that member key. + pub proof_of_ownership: RawEncoded, +} + +impl PayForUnloadFeeTokenArgs { + /// Buy one paid unload token for `member_key`. + pub fn new(member_key: [u8; 32], proof_of_ownership: RawEncoded) -> Self { + Self { + member_key, + proof_of_ownership, + } + } +} + +/// Arguments of `Coinage::unload_recycler_into_coins`. +/// +/// One call per `(denomination, ring)` group, each consuming one unload token. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct UnloadRecyclerIntoCoinsArgs { + /// Aliases of the entries being unloaded, one per entry. + pub aliases: Vec<[u8; 32]>, + /// Denomination shared by every entry in the group. + pub value: i8, + /// Ring the entries belong to. + pub index: u32, + /// Membership revision the aliases were proven against. + pub revision: u32, + /// Denominations and destinations of the resulting coins. + pub split_into: SplitInto, + /// Fee ceiling. Zero under `UnloadFee::Prepaid`; otherwise it must cover the + /// network fee taken from the output. + pub max_fee: u128, +} + +impl UnloadRecyclerIntoCoinsArgs { + /// Unload one group of entries into `outputs`. + /// + /// Rejects a group larger than the runtime consolidates, and an output set + /// whose value differs from the group's — the pallet returns the group's own + /// change to the purse, so the two must balance exactly. + pub fn new( + aliases: Vec<[u8; 32]>, + exponent: DenominationExponent, + ring: RingLocation, + outputs: &[CoinOutput], + max_fee: u128, + constants: &CoinageChainConstants, + ) -> Result { + if aliases.is_empty() { + return Err(CoinageError::Internal( + "an unload group needs at least one alias".to_string(), + )); + } + let cap = constants.max_consolidation as usize; + if aliases.len() > cap { + return Err(CoinageError::Internal(format!( + "{} aliases exceeds the runtime's MaxConsolidation of {cap}", + aliases.len() + ))); + } + + let split_into = SplitInto::from_outputs(outputs, constants)?; + let group_value = Amount::from_cents( + exponent + .value() + .cents() + .saturating_mul(aliases.len() as u64), + ); + let produced = split_into.total_value(); + + if produced != group_value { + return Err(CoinageError::Internal(format!( + "unload of {} entries at {exponent} would produce {produced}, not {group_value}", + aliases.len() + ))); + } + + Ok(Self { + aliases, + value: exponent.get(), + index: ring.index.0, + revision: ring.revision.0, + split_into, + max_fee, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::host_logic::coinage::chain_constants::next_people_paseo; + use crate::host_logic::coinage::types::{RevisionIndex, RingIndex}; + + use super::*; + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn account(byte: u8) -> CoinAccountId { + CoinAccountId([byte; 32]) + } + + fn output(exponent_value: i8, account_byte: u8) -> CoinOutput { + CoinOutput { + exponent: exponent(exponent_value), + account: account(account_byte), + } + } + + fn ring() -> RingLocation { + RingLocation::new(RingIndex(4), RevisionIndex(9)) + } + + #[test] + fn raw_encoded_bytes_are_spliced_without_a_length_prefix() { + let raw = RawEncoded(vec![1, 2, 3]); + + assert_eq!(raw.encode(), vec![1, 2, 3]); + // A `Vec` would prepend a compact length, which the pallet's + // fixed-size proof types do not carry. + assert_ne!(raw.encode(), vec![1u8, 2, 3].encode()); + } + + #[test] + fn outputs_are_grouped_under_each_denomination() { + let outputs = vec![output(3, 1), output(2, 2), output(3, 3)]; + + let grouped = SplitInto::from_outputs(&outputs, &next_people_paseo()) + .expect("within the runtime caps"); + + // Two 8-cent destinations share one entry; grouping order follows first + // appearance. + assert_eq!(grouped.0.len(), 2); + assert_eq!(grouped.0[0].0, 3); + assert_eq!(grouped.0[0].1, vec![[1u8; 32], [3u8; 32]]); + assert_eq!(grouped.0[1].0, 2); + assert_eq!(grouped.0[1].1, vec![[2u8; 32]]); + assert_eq!(grouped.output_count(), 3); + } + + #[test] + fn grouped_outputs_report_their_total_value() { + let outputs = vec![output(3, 1), output(3, 2), output(1, 3)]; + + let grouped = SplitInto::from_outputs(&outputs, &next_people_paseo()) + .expect("within the runtime caps"); + + assert_eq!(grouped.total_value(), Amount::from_cents(8 + 8 + 2)); + } + + #[test] + fn too_many_outputs_are_refused_before_submission() { + let constants = CoinageChainConstants { + max_split_outputs: 2, + ..next_people_paseo() + }; + let outputs = vec![output(0, 1), output(0, 2), output(0, 3)]; + + assert!(SplitInto::from_outputs(&outputs, &constants).is_err()); + } + + #[test] + fn a_denomination_the_runtime_rejects_is_refused() { + let outputs = vec![output(15, 1)]; + + assert!(SplitInto::from_outputs(&outputs, &next_people_paseo()).is_err()); + } + + #[test] + fn a_split_must_conserve_value() { + let constants = next_people_paseo(); + // 2^4 = 16 splits into 8 + 4 + 4. + let balanced = vec![output(3, 1), output(2, 2), output(2, 3)]; + let short = vec![output(3, 1), output(2, 2)]; + + assert!(SplitArgs::new(exponent(4), &balanced, &constants).is_ok()); + assert!(SplitArgs::new(exponent(4), &short, &constants).is_err()); + } + + #[test] + fn a_split_into_a_single_equal_output_is_valid() { + // Reshaping without changing value: one 16-cent coin to one 16-cent + // destination. This is how a transfer of the wrong shape is served. + let constants = next_people_paseo(); + let outputs = vec![output(4, 1)]; + + assert!(SplitArgs::new(exponent(4), &outputs, &constants).is_ok()); + } + + #[test] + fn transfer_carries_the_destination_verbatim() { + let args = TransferArgs::new(account(7)); + + assert_eq!(args.to, [7u8; 32]); + assert_eq!(args.encode(), [7u8; 32].encode()); + } + + #[test] + fn an_unload_group_must_conserve_value() { + let constants = next_people_paseo(); + let aliases = vec![[1u8; 32], [2u8; 32]]; + // Two 16-cent entries produce 32 cents: 16 toward the target plus 16 + // change, or any other partition summing to 32. + let balanced = vec![output(4, 10), output(3, 11), output(3, 12)]; + let short = vec![output(4, 10)]; + + assert!( + UnloadRecyclerIntoCoinsArgs::new( + aliases.clone(), + exponent(4), + ring(), + &balanced, + 0, + &constants + ) + .is_ok() + ); + assert!( + UnloadRecyclerIntoCoinsArgs::new(aliases, exponent(4), ring(), &short, 0, &constants) + .is_err() + ); + } + + #[test] + fn an_unload_group_carries_both_halves_of_the_ring_location() { + let args = UnloadRecyclerIntoCoinsArgs::new( + vec![[1u8; 32]], + exponent(4), + ring(), + &[output(4, 10)], + 0, + &next_people_paseo(), + ) + .expect("balanced"); + + assert_eq!(args.index, 4); + assert_eq!(args.revision, 9); + assert_eq!(args.value, 4); + } + + #[test] + fn an_empty_unload_group_is_refused() { + assert!( + UnloadRecyclerIntoCoinsArgs::new( + Vec::new(), + exponent(4), + ring(), + &[], + 0, + &next_people_paseo() + ) + .is_err() + ); + } + + #[test] + fn a_group_beyond_the_consolidation_cap_is_refused() { + let constants = CoinageChainConstants { + max_consolidation: 2, + ..next_people_paseo() + }; + let aliases = vec![[1u8; 32], [2u8; 32], [3u8; 32]]; + let outputs = vec![output(5, 10), output(4, 11)]; + + assert!( + UnloadRecyclerIntoCoinsArgs::new(aliases, exponent(4), ring(), &outputs, 0, &constants) + .is_err() + ); + } + + #[test] + fn split_arguments_encode_as_the_pallet_declares_them() { + let args = SplitArgs::new( + exponent(1), + &[output(0, 1), output(0, 2)], + &next_people_paseo(), + ) + .expect("2 = 1 + 1"); + + // One denomination group, value 0, two 32-byte destinations. + let expected = vec![(0i8, vec![[1u8; 32], [2u8; 32]])].encode(); + + assert_eq!(args.encode(), expected); + } +} + +/// Arguments of `Coinage::unload_recycler_into_external_asset_and_vouchers`. +/// +/// The one call that moves value out of coinage. Its surplus argument is not an +/// optimization: whatever the group carries beyond what the destination is owed +/// must be reloaded into fresh recycler entries *by this same extrinsic*, because +/// surplus landing as a coin would tie the entry-side anonymity set to a fresh +/// account and undo what the ring was for. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct UnloadRecyclerIntoExternalAssetAndVouchersArgs { + /// Aliases of the entries being unloaded. + pub aliases: Vec<[u8; 32]>, + /// Denomination shared by every entry in the group. + pub value: i8, + /// Ring the entries belong to. + pub index: u32, + /// Membership revision the aliases were proven against. + pub revision: u32, + /// Account receiving the external asset. + pub to: [u8; 32], + /// Amount transferred, in the underlying asset's base units. + pub external_asset_amount: u128, + /// Fresh entries for the surplus: `(denomination, member key)` each. + /// + /// No ownership proof here, unlike `load_recycler_with_coin`: the value being + /// loaded is already the caller's, so publishing a key they do not control + /// would only strand their own funds. + pub new_vouchers: Vec<(i8, [u8; 32])>, +} + +impl UnloadRecyclerIntoExternalAssetAndVouchersArgs { + /// Offboard one group, paying `external_cents` out and reloading the rest. + /// + /// Rejects a group whose arithmetic does not balance, because the pallet does + /// too — and by then the entries and the unload token are already spent. + pub fn new( + aliases: Vec<[u8; 32]>, + exponent: DenominationExponent, + ring: RingLocation, + to: CoinAccountId, + external_cents: Amount, + vouchers: &[(DenominationExponent, [u8; 32])], + constants: &CoinageChainConstants, + ) -> Result { + if aliases.is_empty() { + return Err(CoinageError::Internal( + "an offboard group needs at least one alias".to_string(), + )); + } + let cap = constants.max_consolidation as usize; + if aliases.len() > cap { + return Err(CoinageError::Internal(format!( + "{} aliases exceeds the runtime\'s MaxConsolidation of {cap}", + aliases.len() + ))); + } + if vouchers.len() > constants.max_split_outputs as usize { + return Err(CoinageError::Internal(format!( + "{} vouchers exceeds the runtime\'s MaxSplitOutputs of {}", + vouchers.len(), + constants.max_split_outputs + ))); + } + + let group_value = Amount::from_cents( + exponent + .value() + .cents() + .saturating_mul(aliases.len() as u64), + ); + let reloaded: Amount = vouchers.iter().map(|(exponent, _)| exponent.value()).sum(); + let accounted = external_cents + .checked_add(reloaded) + .ok_or_else(|| CoinageError::Internal("offboard value overflows".to_string()))?; + if accounted != group_value { + return Err(CoinageError::Internal(format!( + "offboarding {group_value} would pay out {external_cents} and reload {reloaded}, \ + which does not balance" + ))); + } + + let external_asset_amount = u128::from(external_cents.cents()) + .checked_mul(constants.underlying_asset_unit) + .ok_or_else(|| { + CoinageError::Internal("the external asset amount overflows u128".to_string()) + })?; + + Ok(Self { + aliases, + value: exponent.get(), + index: ring.index.0, + revision: ring.revision.0, + to: to.0, + external_asset_amount, + new_vouchers: vouchers + .iter() + .map(|(exponent, member_key)| (exponent.get(), *member_key)) + .collect(), + }) + } +} + +/// The pallet's `Preservation` argument, as the unpaid load takes it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode)] +pub enum CodecPreservation { + /// The source account may be reaped by the transfer. + Expendable, +} + +/// One item of `Coinage::load_recycler_with_external_asset_unpaid_batch`. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct UnpaidLoadInput { + /// What may happen to the source account. + pub preservation: CodecPreservation, + /// Denomination of the entry to create. + pub value: i8, + /// Bandersnatch member key the entry publishes. + pub member_key: [u8; 32], + /// Signature by that key over the origin account, proving control of it. + pub proof_of_ownership: RawEncoded, +} + +/// Arguments of `Coinage::load_recycler_with_external_asset_unpaid_batch`. +/// +/// One extrinsic for the whole top-up rather than one per denomination: the +/// runtime bounds the batch with `MaxBatchUnpaidLoad`, and a batch is the shape the +/// shipped faucet flow uses. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct UnpaidLoadBatchArgs { + /// Entries to create, one per denomination. + pub items: Vec, +} + +impl UnpaidLoadBatchArgs { + /// Build a batch, rejecting denominations the runtime will not mint. + pub fn new( + items: Vec<(DenominationExponent, [u8; 32], RawEncoded)>, + constants: &CoinageChainConstants, + ) -> Result { + if items.is_empty() { + return Err(CoinageError::Internal( + "an unpaid load batch needs at least one entry".to_string(), + )); + } + if let Some((rejected, _, _)) = items + .iter() + .find(|(exponent, _, _)| !constants.accepts(*exponent)) + { + return Err(CoinageError::Internal(format!( + "denomination {rejected} is outside the runtime's range" + ))); + } + + Ok(Self { + items: items + .into_iter() + .map( + |(exponent, member_key, proof_of_ownership)| UnpaidLoadInput { + preservation: CodecPreservation::Expendable, + value: exponent.get(), + member_key, + proof_of_ownership, + }, + ) + .collect(), + }) + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/execute.rs b/rust/crates/truapi-server/src/runtime/coinage/execute.rs new file mode 100644 index 000000000..b3b67f65e --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/execute.rs @@ -0,0 +1,5209 @@ +//! Driving a planned operation against the chain. +//! +//! [`super::plan`] decides what to submit; this module submits it and grades what +//! came back. The order of the five steps per transaction is fixed by §7.4 and is +//! the whole reason a crash cannot lose value: +//! +//! 1. Mutate local state — outputs minted `Pending`, inputs already `LockedFor`. +//! 2. Write the log entry, including the extrinsic hash, and **persist**. +//! 3. Broadcast. +//! 4. On a definite outcome, apply it. +//! 5. On no definite outcome, hand the entry to recovery (§7.7). +//! +//! Step 2 comes before step 3 without exception. A hash recorded after the +//! broadcast would leave a crash in between with a transaction on chain that no +//! local record mentions, and nothing to reconcile it against. +//! +//! # The unload fee chooses the origin, not just an argument +//! +//! §6.6 reads like a choice between two ways to pay, but the two modes are +//! different *origins*. Prepaid means an unload token — a free slot from the +//! period's allowance, proven by personhood — and `max_fee` is zero. From-output +//! means no token at all: the extension takes the fee out of the unloaded value, +//! pre-validating the first entry's alias in its place, and `max_fee` is the +//! ceiling it may take. So an unfunded fee account does not merely change an +//! argument; it spends no allowance. +//! +//! Which means the fee has to be estimated before the origin is known. The +//! sequence is: assemble the prepaid shape, price *those exact bytes*, choose the +//! mode from the fee account's balance, and re-assemble if the answer was +//! from-output. Pricing real bytes rather than a guessed length is what keeps the +//! ceiling honest, and re-assembling costs proving time rather than value. + +use core::time::Duration; + +use futures_timer::Delay; +use truapi_platform::CoreStorage; + +use crate::host_logic::coinage::derivation; +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::event::LayerEvent; +use crate::host_logic::coinage::log::{Checkpoint, LogEntryState}; +use crate::host_logic::coinage::memo::{MemoEntry, PaymentClassification}; +use crate::host_logic::coinage::operation::{LockSet, OperationStatus}; +use crate::host_logic::coinage::types::{ + BlockHash, CoinAccountId, CoinSecret, DenominationExponent, ExtrinsicHash, OperationHandle, + PurseId, RingLocation, Timestamp, +}; +use crate::host_logic::coinage::unload_token::{ + FeeMode, PaidRingState, TokenGrant, choose_fee_mode, resolve, +}; +use crate::runtime::coinage::bootstrap::CoinageLayer; +use crate::runtime::coinage::call::{ + CoinOutput, LoadRecyclerWithCoinArgs, PayForUnloadFeeTokenArgs, RawEncoded, SplitArgs, + TransferArgs, UnloadRecyclerIntoCoinsArgs, +}; +use crate::runtime::coinage::extension::{AsCoinageInfo, FreeTokenRing}; +use crate::runtime::coinage::extrinsic::{ + CoinageCall, FundingOrigin, build_account_signed_extrinsic, build_call, + build_coin_origin_extrinsic, build_external_asset_load_extrinsic, build_unsigned_extrinsic, + inherited_implication, +}; +use crate::runtime::coinage::plan::{ + Destination, PlannedOutput, PlannedTransaction, RescueGroup, SweepWork, TargetDestinations, + TransactionKind, plan_import, plan_maintenance, plan_operation, +}; +use crate::runtime::coinage::{fee, proof, recover, ring, scan, storage, submit, tokens}; +use crate::runtime::statement_allowance::bandersnatch_entropy; +use crate::runtime::statement_allowance::extension::{ChainState, Metadata}; +use crate::runtime::statement_allowance::rpc::RpcClient; + +/// How long to wait between recovery passes while a transaction's fate is open. +/// +/// Roughly a block: shorter would re-read the same finalized state, longer would +/// keep an operation in `Recovering` after the chain had already answered. +const RECOVERY_POLL_INTERVAL: Duration = Duration::from_secs(6); + +/// How many recovery passes to run before leaving the entry for the layer's own +/// recovery driver. +/// +/// Bounded so a stalled chain cannot pin a caller forever. Giving up here is not +/// a verdict: the entry stays `Pending` in the log and the operation stays open, +/// which is exactly the state recovery resumes from at the next start (§7.7). +const RECOVERY_POLL_ATTEMPTS: usize = 40; + +/// How many phases an external offload may go through before it is left for a +/// later drive. +/// +/// Bounded so a chain that never ripens an entry cannot hold a caller forever. +/// Reaching the limit is not a failure: the operation stays open, and both recovery +/// and a later drive resume from where it stopped. +const OFFLOAD_PHASE_LIMIT: usize = 32; + +/// Everything chain-facing an operation needs. +pub struct ChainContext<'a> { + /// JSON-RPC surface. + pub rpc: &'a RpcClient, + /// Runtime metadata, for call indices, extension slots and value decoding. + pub metadata: &'a Metadata, + /// How long to wait between recovery passes. + pub recovery_poll_interval: Duration, +} + +impl<'a> ChainContext<'a> { + /// A context with the default recovery cadence. + pub fn new(rpc: &'a RpcClient, metadata: &'a Metadata) -> Self { + Self { + rpc, + metadata, + recovery_poll_interval: RECOVERY_POLL_INTERVAL, + } + } +} + +/// What a caller supplies to be told, out of band, which coins a transfer minted. +/// +/// Invoked once per transaction, with one entry per coin that transaction sent to +/// an account outside this layer, as soon as the transaction reaches a block — +/// deliberately before finalization, so a payee can act promptly. The cost of that +/// choice is real: a reorg can invalidate a transfer a memo has already been +/// delivered for, and the caller has to tolerate it. +pub type MemoCallback = Box) + Send + Sync>; + +/// Work an operation does locally once its transactions have definitely settled. +/// +/// Kept beside the operation rather than inside its program because it is not a +/// transaction: nothing is broadcast for it, and it must not run until the chain +/// has agreed the value actually moved. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Completion { + /// Tell subscribers what a maintenance sweep achieved (§6.4). + ReportSweep { + /// Coins turned into entries. + coins_recycled: u32, + /// Entries turned back into coins. + entries_rescued: u32, + }, + /// Close a drained purse, once its value has definitely left (§8.1). + ClosePurse { + /// Purse to close. + target: PurseId, + /// Where its value went. + drained_into: PurseId, + /// How much moved. + amount: crate::host_logic::coinage::types::Amount, + }, +} + +/// A started operation: its handle, and its status stream (§8, §7.2). +/// +/// Returned the moment selection and locking have succeeded, before anything is +/// broadcast. That split is the spec's: a failure to *start* comes back as an +/// error from the starting call, while a failure of a started operation arrives as +/// a terminal `Failed` item on this stream. +#[derive(derive_more::Debug)] +pub struct OperationStart { + /// Durable handle for the operation. + pub handle: OperationHandle, + /// Its status stream, opening with the current status. + #[debug(skip)] + pub status: futures::stream::BoxStream<'static, OperationStatus>, +} + +impl CoinageLayer { + /// Start a transfer from `purse` to recipient-controlled accounts (§8.3). + /// + /// Selects, locks and plans synchronously, so an unsatisfiable request fails + /// here rather than on the status stream. Nothing is broadcast until + /// [`CoinageLayer::drive_operation`] runs. + /// + /// The recipient outputs must sum to `amount`; each one is a separately named + /// account, so the produced denominations are exactly those requested. + /// + /// `memo` is invoked as each transaction lands, per [`MemoCallback`]. + pub fn begin_transfer( + &mut self, + purse: PurseId, + amount: crate::host_logic::coinage::types::Amount, + recipient_outputs: Vec, + allow_degraded: bool, + memo: Option, + now: Timestamp, + ) -> Result { + use crate::host_logic::coinage::selection::{OutputRequirement, SelectionRequest}; + use crate::host_logic::coinage::types::{Amount, OperationKind}; + + let requested: Amount = recipient_outputs + .iter() + .map(|output| output.exponent.value()) + .fold(Amount::ZERO, |total, value| { + total.checked_add(value).unwrap_or(total) + }); + if requested != amount { + return Err(CoinageError::OutputsDoNotSumToAmount); + } + + let request = SelectionRequest { + amount, + outputs: OutputRequirement::Exact( + recipient_outputs + .iter() + .map(|output| output.exponent) + .collect(), + ), + allow_degraded, + }; + + let started = self.begin( + purse, + OperationKind::Transfer, + &request, + TargetDestinations::Recipients(recipient_outputs), + now, + )?; + if let Some(memo) = memo { + self.register_memo(started.handle, memo); + } + Ok(started) + } + + /// Select, lock and plan one operation. + /// + /// Shared by every primitive that spends from a purse. Selection and locking + /// happen in one step inside the store, so no other caller can see the window + /// between choosing a record and holding it. + pub(crate) fn begin( + &mut self, + purse: PurseId, + kind: crate::host_logic::coinage::types::OperationKind, + request: &crate::host_logic::coinage::selection::SelectionRequest, + targets: TargetDestinations, + now: Timestamp, + ) -> Result { + let constants = *self.constants(); + let (handle, selection) = self + .store_mut() + .begin_operation(purse, kind, request, &constants, now)?; + + let program = match plan_operation(self.store_mut(), purse, &selection, &targets) { + Ok(program) => program, + Err(error) => { + // Planning failed after the records were locked, so release them + // before the caller sees the error: an operation nobody holds a + // handle to must not keep value out of the pool. + let _ = self.store_mut().fail_operation(handle, error.clone()); + return Err(error); + } + }; + + let status = self.subscribe_operation_status(handle)?; + self.register_program(handle, program); + Ok(OperationStart { handle, status }) + } +} + +/// One coin leaving the layer under its own secret (§8.4). +/// +/// The only value in this crate that carries spendable key material outward. Once +/// emitted, the layer treats the coin as spent: the account still holds it on +/// chain, but control has moved to whoever holds this. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportedCoin { + /// Account holding the coin. + pub account: CoinAccountId, + /// Secret controlling it. + pub secret: CoinSecret, + /// Its denomination. + pub exponent: DenominationExponent, +} + +/// A started export: the operation, plus the coins it hands out (§8.4). +#[derive(derive_more::Debug)] +pub struct ExportStart { + /// Durable handle for the operation. + pub handle: OperationHandle, + /// Its status stream, opening with the current status. + #[debug(skip)] + pub status: futures::stream::BoxStream<'static, OperationStatus>, + /// One item per exported coin, then closed. + /// + /// A coin appears only once the transaction that materialized it has + /// **definitely** succeeded, because a secret handed out on optimistic + /// inclusion could name a coin a reorg then removes. + #[debug(skip)] + pub coins: futures::stream::BoxStream<'static, ExportedCoin>, +} + +/// The layer seam: value leaving under its own secrets, and value arriving under +/// somebody else's (§8.4, §8.5). +impl CoinageLayer { + /// Materialize `amount` worth of coins in `from` and hand them out (§8.4). + /// + /// Coins already in the right shape cost nothing: control of a coin changes + /// hands with its secret, so an export that needs no reshaping submits no + /// extrinsic at all. Value that has to be split or unloaded costs one + /// transaction each, and those coins are emitted only once the chain has + /// definitely accepted them. + pub fn begin_export( + &mut self, + from: PurseId, + amount: crate::host_logic::coinage::types::Amount, + allow_degraded: bool, + now: Timestamp, + ) -> Result { + use crate::host_logic::coinage::selection::{OutputRequirement, SelectionRequest}; + use crate::host_logic::coinage::types::OperationKind; + + let request = SelectionRequest { + amount, + // The coins leave under their own secrets, so their shape is free. + outputs: OutputRequirement::AnyDenominations, + allow_degraded, + }; + let started = self.begin( + from, + OperationKind::Export, + &request, + TargetDestinations::Export(from), + now, + )?; + + let (sender, receiver) = futures::channel::mpsc::unbounded(); + self.register_export(started.handle, sender); + Ok(ExportStart { + handle: started.handle, + status: started.status, + coins: Box::pin(receiver), + }) + } + + /// Take externally held coins into `into` (§8.5). + /// + /// Each coin's denomination is read from chain rather than taken on trust, and + /// each pair is checked before anything is planned: a secret that does not + /// control the account it is offered with, or a coin this layer already holds a + /// record for, is refused with `BadCoinSecret`. Reading the denomination is why + /// this is the one starting call that touches the chain. + /// + /// One transaction per coin, all independent, so partial success is normal. + pub async fn begin_import( + &mut self, + chain: &ChainContext<'_>, + into: PurseId, + coins: Vec<(CoinAccountId, CoinSecret)>, + ) -> Result { + use crate::host_logic::coinage::types::OperationKind; + + if self.store().purse(into).is_none() { + return Err(CoinageError::PurseNotFound(into)); + } + + let at = chain + .rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + + let mut described = Vec::with_capacity(coins.len()); + for (account, secret) in &coins { + // A secret that does not control the account cannot move its coin, and + // finding that out here costs nothing. + if keypair_from(secret)?.public.to_bytes() != account.0 { + return Err(CoinageError::BadCoinSecret); + } + // A coin we already have a record for must not be imported: it would + // get a second record, and one of the two would be a ghost. + if self.holds_account(*account) { + return Err(CoinageError::BadCoinSecret); + } + + let raw = chain + .rpc + .get_storage_at(&storage::coins_by_owner_key(account), &at) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let coin = storage::decode_coin(raw)?.ok_or(CoinageError::BadCoinSecret)?; + let exponent = DenominationExponent::new(coin.value).ok_or_else(|| { + CoinageError::Internal(format!( + "the chain reports denomination {} for an imported coin, which this layer \ + cannot represent", + coin.value + )) + })?; + described.push((*account, exponent)); + } + + let handle = self + .store_mut() + .start_operation(into, OperationKind::Import)?; + let program = match plan_import(self.store_mut(), into, &described) { + Ok(program) => program, + Err(error) => { + let _ = self.store_mut().fail_operation(handle, error.clone()); + return Err(error); + } + }; + + let status = self.subscribe_operation_status(handle)?; + self.register_import_secrets( + handle, + coins.into_iter().map(|(_, secret)| secret).collect(), + ); + self.register_program(handle, program); + Ok(OperationStart { handle, status }) + } + + /// Whether any record in any purse already names this account. + fn holds_account(&self, account: CoinAccountId) -> bool { + self.store().purses().any(|purse| { + self.store().coins_in(purse.id).into_iter().any(|coin| { + derivation::coin_account_id(self.entropy(), purse.id, coin.index) + .is_ok_and(|derived| derived == account) + }) + }) + } + + /// Hand out the coins a settled transaction materialized for export. + fn deliver_exports( + &mut self, + handle: OperationHandle, + exports: &[(PurseId, crate::host_logic::coinage::types::CoinIndex)], + ) -> Result<(), CoinageError> { + if exports.is_empty() { + return Ok(()); + } + + let mut emitted = Vec::with_capacity(exports.len()); + for (purse, index) in exports { + let exponent = self + .store() + .coin(*purse, *index) + .ok_or_else(|| { + CoinageError::Internal(format!("exported coin {index:?} has no record")) + })? + .exponent; + let keypair = derivation::coin_keypair(self.entropy(), *purse, *index)?; + emitted.push(( + *purse, + *index, + ExportedCoin { + account: CoinAccountId(keypair.public.to_bytes()), + secret: CoinSecret(keypair.secret.to_bytes()), + exponent, + }, + )); + } + + for (purse, index, coin) in emitted { + // Spent from this layer's point of view the moment the secret leaves: + // the account still holds the coin, but we no longer control it, and + // offering it to selection again would build an extrinsic the chain + // refuses. + self.store_mut().retire_exported(purse, index, handle)?; + self.send_export(handle, coin); + } + + Ok(()) + } +} + +/// The keypair a supplied secret controls. +fn keypair_from(secret: &CoinSecret) -> Result { + let parsed = crate::host_logic::extrinsic::sr25519_secret_from_bytes(&secret.0) + .map_err(|_| CoinageError::BadCoinSecret)?; + let public = parsed.to_public(); + Ok(schnorrkel::Keypair { + secret: parsed, + public, + }) +} + +/// What an external offload was asked to do (§8.6). +/// +/// Held for the operation's whole life because the offload re-plans: the amount and +/// the destination are the only things that stay fixed across its phases. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OffloadRequest { + /// Purse the value comes from. + pub from: PurseId, + /// Amount to deliver. + pub amount: crate::host_logic::coinage::types::Amount, + /// Account outside coinage that receives it. + pub destination: CoinAccountId, + /// Whether entries below the anonymity floor may be used. + pub allow_degraded: bool, +} + +/// External offload (§8.6): value leaving coinage for an ordinary account. +/// +/// The only multi-phase primitive. Coins cannot be offboarded — a coin has to +/// become an entry first — and a fresh entry is not usable until its decorrelation +/// delay elapses, so the operation loops: work out what is possible now, do that, +/// look again. [`crate::host_logic::coinage::offload::decide`] is the "look again" +/// step and is pure; this is the part that submits, waits and persists. +impl CoinageLayer { + /// Start an offload of `amount` from `from` to `destination` (§8.6). + /// + /// `allow_degraded` should be false unless the caller means it: an offload + /// reveals the unloaded value to anyone watching the chain, so the anonymity set + /// wants to be at full strength. + /// + /// Nothing is selected here. The operation holds records as it acquires them, + /// which is what lets it use entries it created itself in a later phase. + pub fn begin_external_offload( + &mut self, + from: PurseId, + amount: crate::host_logic::coinage::types::Amount, + destination: CoinAccountId, + allow_degraded: bool, + ) -> Result { + use crate::host_logic::coinage::types::OperationKind; + + if self.store().purse(from).is_none() { + return Err(CoinageError::PurseNotFound(from)); + } + + let handle = self + .store_mut() + .start_operation(from, OperationKind::ExternalOffload)?; + let status = self.subscribe_operation_status(handle)?; + self.register_offload( + handle, + OffloadRequest { + from, + amount, + destination, + allow_degraded, + }, + ); + Ok(OperationStart { handle, status }) + } + + /// Drive an offload through as many phases as it takes. + /// + /// Every phase transition is persisted before the next begins, so a crash + /// resumes from the last one rather than from the start. The loop is bounded: + /// a chain that never ripens an entry must not hold a caller forever, and an + /// operation left open is exactly what recovery resumes. + async fn drive_offload( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + handle: OperationHandle, + request: OffloadRequest, + now: Timestamp, + ) -> Result<(), CoinageError> { + use crate::host_logic::coinage::offload::{OffloadPhase, decide}; + + let mut clock = now; + let mut recycle_sequences: Vec = Vec::new(); + let mut settlements = Vec::new(); + + for _ in 0..OFFLOAD_PHASE_LIMIT { + self.advance(storage, handle, OperationStatus::Preparing, clock) + .await?; + + // §8.6 step 1 reads the *current* view, and it has to be a chain read: + // an entry a previous phase created knows nothing about the ring the + // pallet put it in, and an entry with no ring cannot be offboarded. A + // loop that re-planned from local state alone would recycle forever. + self.refresh_purse(storage, chain, request.from, clock) + .await?; + + let phase = decide( + &self.store().coins_in(request.from), + &self.store().entries_in(request.from), + request.amount, + request.allow_degraded, + self.constants(), + self.params().external_offload_retry_interval, + clock, + handle, + ); + + match phase { + OffloadPhase::Offboard { groups, surplus } => { + let outcome = self + .run_offboard( + storage, + chain, + handle, + &request, + &groups, + surplus, + &recycle_sequences, + clock, + ) + .await?; + settlements.extend(outcome); + return self.terminate(storage, handle, &settlements, clock).await; + } + OffloadPhase::Recycle { coins } => { + let outcome = self + .run_offload_recycles(storage, chain, handle, &request, &coins, clock) + .await?; + settlements.extend(outcome.settlements); + recycle_sequences.extend(outcome.sequences); + if settlements.contains(&Settlement::Undecided) { + // A transaction whose fate is open must not be re-planned + // around: recovery owns it now. + return self.terminate(storage, handle, &settlements, clock).await; + } + } + OffloadPhase::Wait { until, .. } => { + self.advance(storage, handle, OperationStatus::Waiting(until), clock) + .await?; + Delay::new(chain.recovery_poll_interval).await; + // The layer holds no clock, so a waiting phase advances the one + // it was given rather than reading a new one. + clock = until.max(clock); + } + OffloadPhase::Insufficient { + requested, + available, + } => { + self.store_mut().fail_operation( + handle, + CoinageError::InsufficientFunds { + requested, + available, + }, + )?; + return self.publish_and_persist(storage, clock).await; + } + } + } + + // Out of phases rather than out of options: leave the operation open, which + // is the state recovery and a later drive both resume from. + self.publish_and_persist(storage, clock).await + } + + /// Re-read one purse's chain state and apply it (§6.1). + /// + /// Pinned to the finalized head: a phase decision taken against a fork that + /// then disappears would plan an offboard of entries that are not there. + async fn refresh_purse( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + purse: PurseId, + now: Timestamp, + ) -> Result<(), CoinageError> { + let at = chain + .rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let entropy = self.entropy().to_vec(); + let params = self.params().clone(); + crate::runtime::coinage::observe::refresh_purse( + chain.rpc, + chain.metadata, + self.store_mut(), + &entropy, + purse, + ¶ms, + &at, + ) + .await?; + self.publish_and_persist(storage, now).await + } + + /// Recycle coins into entries this offload will offboard. + async fn run_offload_recycles( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + handle: OperationHandle, + request: &OffloadRequest, + coins: &[( + crate::host_logic::coinage::types::CoinIndex, + DenominationExponent, + )], + now: Timestamp, + ) -> Result { + let jitter = self.jitter_draws(coins.len())?; + let mut pass = RecyclePass::default(); + + for ((coin, exponent), delay) in coins.iter().zip(jitter) { + let locks = LockSet { + coins: vec![(request.from, *coin)], + entries: Vec::new(), + }; + self.store_mut().lock_for_operation(handle, &locks, now)?; + let entry = self + .store_mut() + .allocate_entry(request.from, *exponent, now, delay)?; + self.store_mut().lock_for_operation( + handle, + &LockSet { + coins: Vec::new(), + entries: vec![(request.from, entry)], + }, + now, + )?; + + let transaction = PlannedTransaction { + kind: TransactionKind::Recycle { + source: (request.from, *coin), + entry: (request.from, entry), + }, + inputs: locks, + outputs: LockSet { + coins: Vec::new(), + entries: vec![(request.from, entry)], + }, + depends_on: Vec::new(), + exports: Vec::new(), + }; + + let sequence = self.next_sequence(handle); + let settlement = self + .run_transaction(storage, chain, handle, &transaction, &mut Vec::new(), now) + .await?; + if settlement == Settlement::Succeeded { + pass.sequences.push(sequence); + } + pass.settlements.push(settlement); + } + + Ok(pass) + } + + /// Offboard the groups that cover the requested amount. + /// + /// Each group is one extrinsic carrying one token, and each carries its own + /// share of the payout plus vouchers for whatever it overshoots by. + #[allow(clippy::too_many_arguments)] + async fn run_offboard( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + handle: OperationHandle, + request: &OffloadRequest, + groups: &[crate::host_logic::coinage::offload::OffboardGroup], + surplus: crate::host_logic::coinage::types::Amount, + depends_on: &[u32], + now: Timestamp, + ) -> Result, CoinageError> { + use crate::host_logic::coinage::params::canonical_breakdown; + use crate::host_logic::coinage::types::Amount; + + let mut grants = self.offload_tokens(chain, groups.len(), now).await?; + let mut settlements = Vec::new(); + let mut remaining_payout = request.amount; + let mut remaining_surplus = surplus; + + for group in groups { + // Each group pays out what it can of the outstanding amount and keeps + // the rest as vouchers, so the arithmetic balances per extrinsic — + // which is how the pallet checks it. + let group_value = group.value(); + let payout = group_value.min(remaining_payout); + let reload = group_value.saturating_sub(payout); + remaining_payout = remaining_payout.saturating_sub(payout); + remaining_surplus = remaining_surplus.saturating_sub(reload); + + let denominations = if reload.is_zero() { + Vec::new() + } else { + canonical_breakdown( + reload, + self.constants().largest_denomination().ok_or_else(|| { + CoinageError::Internal( + "the runtime's maximum exponent is not a denomination".to_string(), + ) + })?, + ) + .ok_or(CoinageError::UnsatisfiableOutputs { + requested: reload, + available: group_value, + })? + }; + + // The surplus becomes entries of ours, so it needs records and locks + // like anything else the operation holds. + let mut vouchers = Vec::with_capacity(denominations.len()); + for exponent in &denominations { + let entry = self.store_mut().allocate_entry( + request.from, + *exponent, + now, + core::time::Duration::ZERO, + )?; + self.store_mut().lock_for_operation( + handle, + &LockSet { + coins: Vec::new(), + entries: vec![(request.from, entry)], + }, + now, + )?; + vouchers.push((*exponent, entry)); + } + + self.store_mut().lock_for_operation( + handle, + &LockSet { + coins: Vec::new(), + entries: group.entries.iter().map(|e| (request.from, *e)).collect(), + }, + now, + )?; + + let transaction = PlannedTransaction { + kind: TransactionKind::Offboard { + purse: request.from, + ring: group.ring, + exponent: group.exponent, + entries: group.entries.clone(), + destination: request.destination, + payout, + vouchers: vouchers.clone(), + }, + inputs: LockSet { + coins: Vec::new(), + entries: group.entries.iter().map(|e| (request.from, *e)).collect(), + }, + outputs: LockSet { + coins: Vec::new(), + entries: vouchers + .iter() + .map(|(_, entry)| (request.from, *entry)) + .collect(), + }, + // §7.5: the entries this offboard spends may be ones an earlier + // recycle produced, and recovery has to resolve them in that order. + depends_on: depends_on.to_vec(), + exports: Vec::new(), + }; + + settlements.push( + self.run_transaction(storage, chain, handle, &transaction, &mut grants, now) + .await?, + ); + } + + let _ = Amount::ZERO; + Ok(settlements) + } + + /// Tokens for an offboard's groups, one each. + async fn offload_tokens( + &self, + chain: &ChainContext<'_>, + groups: usize, + now: Timestamp, + ) -> Result, CoinageError> { + if groups == 0 { + return Ok(Vec::new()); + } + let program = crate::runtime::coinage::plan::OperationProgram { + transactions: Vec::new(), + exports_in_place: Vec::new(), + }; + let _ = &program; + self.resolve_tokens(chain, groups, now).await + } + + /// The sequence the next log entry of this operation will take. + fn next_sequence(&self, handle: OperationHandle) -> u32 { + self.store() + .operation(handle) + .map_or(0, |operation| operation.log.next_sequence()) + } +} + +/// What one recycle phase settled, and which sequences succeeded. +#[derive(Debug, Default)] +struct RecyclePass { + settlements: Vec, + sequences: Vec, +} + +/// Top-up: external asset in, recycler entries out (§8.2). +impl CoinageLayer { + /// Convert `amount` of externally held asset into entries in `into` (§8.2). + /// + /// The value being converted is not coinage yet, so the layer neither holds nor + /// signs for it: `origin` owns the account and signs the extrinsic. What the + /// layer contributes is the entries — fresh member keys in `into`'s namespace, + /// each proving to the pallet that whoever controls the incoming value controls + /// the key it is being loaded onto. + /// + /// The amount is broken into denominations the runtime mints, and the whole + /// top-up is one batched extrinsic, bounded by `MaxBatchUnpaidLoad`. + pub fn begin_top_up( + &mut self, + into: PurseId, + amount: crate::host_logic::coinage::types::Amount, + origin: std::sync::Arc, + now: Timestamp, + ) -> Result { + use crate::host_logic::coinage::params::canonical_breakdown; + use crate::host_logic::coinage::types::OperationKind; + + if self.store().purse(into).is_none() { + return Err(CoinageError::PurseNotFound(into)); + } + + let largest = self.constants().largest_denomination().ok_or_else(|| { + CoinageError::Internal( + "the runtime's maximum exponent is not a denomination".to_string(), + ) + })?; + let denominations = + canonical_breakdown(amount, largest).ok_or(CoinageError::UnsatisfiableOutputs { + requested: amount, + available: amount, + })?; + let batch_limit = self.constants().max_batch_unpaid_load as usize; + if denominations.len() > batch_limit { + return Err(CoinageError::Internal(format!( + "{amount} needs {} entries but the runtime batches at most {batch_limit}", + denominations.len() + ))); + } + + let handle = self + .store_mut() + .start_operation(into, OperationKind::TopUp)?; + + // Each entry gets its own decorrelation delay, drawn now: an entry that + // became selectable the instant it was loaded would let an observer pair + // the load with the unload that follows it. + let jitter = self.jitter_draws(denominations.len())?; + let mut entries = Vec::with_capacity(denominations.len()); + for (exponent, delay) in denominations.iter().zip(jitter) { + match self.store_mut().allocate_entry(into, *exponent, now, delay) { + Ok(index) => entries.push((*exponent, index)), + Err(error) => { + let _ = self.store_mut().fail_operation(handle, error.clone()); + return Err(error); + } + } + } + + let program = crate::runtime::coinage::plan::OperationProgram { + transactions: vec![PlannedTransaction { + kind: TransactionKind::TopUpLoad { + purse: into, + entries: entries.clone(), + }, + // Nothing of ours is consumed: the input is the caller's asset. + inputs: LockSet::default(), + outputs: LockSet { + coins: Vec::new(), + entries: entries.iter().map(|(_, index)| (into, *index)).collect(), + }, + depends_on: Vec::new(), + exports: Vec::new(), + }], + exports_in_place: Vec::new(), + }; + + let status = self.subscribe_operation_status(handle)?; + self.register_funding_origin(handle, origin); + self.register_program(handle, program); + Ok(OperationStart { handle, status }) + } + + /// Assemble the batched load, signed by the account holding the asset. + async fn assemble_top_up( + &self, + chain: &ChainContext<'_>, + state: &ChainState, + purse: PurseId, + entries: &[( + DenominationExponent, + crate::host_logic::coinage::types::EntryIndex, + )], + origin: &dyn FundingOrigin, + ) -> Result { + use crate::runtime::coinage::call::UnpaidLoadBatchArgs; + + let account = origin.external_account(); + let mut items = Vec::with_capacity(entries.len()); + for (exponent, index) in entries { + let member_key = derivation::entry_member_key(self.entropy(), purse, *index)?; + // The proof binds the key to the account whose asset is being + // converted, which is what stops one wallet loading onto another's key. + let ownership = proof::entry_ownership_proof(self.entropy(), purse, *index, account)?; + items.push((*exponent, member_key, ownership)); + } + + let args = UnpaidLoadBatchArgs::new(items, self.constants())?; + let call = build_call( + chain.metadata, + CoinageCall::LoadRecyclerWithExternalAssetUnpaidBatch, + &args, + )?; + let nonce = read_account_nonce(chain.rpc, account).await?; + + Ok(Assembled { + extrinsic: build_external_asset_load_extrinsic( + chain.metadata, + state, + origin, + nonce, + &call, + )?, + event: None, + origins: vec![account], + }) + } +} + +/// The next transaction index the chain expects from an account. +/// +/// Read rather than tracked: the account is the caller's, and anything else may +/// have used it since. +async fn read_account_nonce(rpc: &RpcClient, account: CoinAccountId) -> Result { + let address = subxt::utils::AccountId32(account.0).to_string(); + let value = rpc + .call("system_accountNextIndex", serde_json::json!([address])) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + + value + .as_u64() + .and_then(|nonce| u32::try_from(nonce).ok()) + .ok_or_else(|| CoinageError::Internal(format!("system_accountNextIndex returned {value}"))) +} + +/// What a wallet-recovery scan was asked to walk (§8.10). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryRequest { + /// Purses to scan, with the index each sub-tree starts from. + pub purses: Vec<( + PurseId, + crate::host_logic::coinage::types::CoinIndex, + crate::host_logic::coinage::types::EntryIndex, + )>, +} + +/// Wallet recovery from root entropy (§8.10, Appendix C). +/// +/// The other kind of recovery: §7.7 resolves transactions that were in flight when +/// a process died, while this rebuilds records that are gone entirely. It submits +/// nothing — every answer comes from the chain — so its status goes straight from +/// `Preparing` to a terminal item, and what it found arrives on the event stream +/// record by record. +impl CoinageLayer { + /// Rebuild the main purse and the listed purses from chain (§8.10). + /// + /// The chain has no notion of a purse, so a non-main purse is only found if its + /// identifier is supplied from a backup — and it is restored *at* that + /// identifier, because that is the derivation namespace its accounts are + /// already in. + pub fn begin_recovery( + &mut self, + non_main_purse_ids: Vec, + ) -> Result { + use crate::host_logic::coinage::types::{CoinIndex, EntryIndex, OperationKind}; + + let mut purses = vec![(PurseId::MAIN, CoinIndex(0), EntryIndex(0))]; + for purse in non_main_purse_ids { + if purse.is_main() { + continue; + } + // Restored rather than created: a fresh identifier would derive a + // namespace nobody has coins in. + self.store_mut() + .restore_purse(purse, format!("Recovered {purse}")); + purses.push((purse, CoinIndex(0), EntryIndex(0))); + } + + let handle = self + .store_mut() + .start_operation(PurseId::MAIN, OperationKind::Recover)?; + let status = self.subscribe_operation_status(handle)?; + self.register_recovery(handle, RecoveryRequest { purses }); + Ok(OperationStart { handle, status }) + } + + /// Resume a scan past where a previous one stopped (§8.10). + /// + /// A scan ends after enough consecutive empty batches, which is the only way to + /// terminate a walk over an unbounded index space — and it means a wallet with a + /// long unused stretch can hide records beyond it. This is how a caller who + /// knows better says so. + pub fn begin_extend_scan( + &mut self, + purse: PurseId, + from_coin_index: crate::host_logic::coinage::types::CoinIndex, + from_entry_index: crate::host_logic::coinage::types::EntryIndex, + ) -> Result { + use crate::host_logic::coinage::types::OperationKind; + + if self.store().purse(purse).is_none() { + return Err(CoinageError::PurseNotFound(purse)); + } + + let handle = self + .store_mut() + .start_operation(purse, OperationKind::Recover)?; + let status = self.subscribe_operation_status(handle)?; + self.register_recovery( + handle, + RecoveryRequest { + purses: vec![(purse, from_coin_index, from_entry_index)], + }, + ); + Ok(OperationStart { handle, status }) + } + + /// Walk every purse the request names, then observe what was found. + async fn drive_recovery( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + handle: OperationHandle, + request: RecoveryRequest, + now: Timestamp, + ) -> Result<(), CoinageError> { + // One block for the whole scan: a walk spanning blocks could see a coin + // move mid-way and record it twice, or not at all. + let at = chain + .rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + + let mut found_anything = false; + for (purse, from_coin, from_entry) in &request.purses { + let params = self.params().clone(); + let entropy = self.entropy().to_vec(); + let outcome = match scan::scan_purse( + chain.rpc, + self.store_mut(), + &entropy, + *purse, + ¶ms, + *from_coin, + *from_entry, + now, + &at, + ) + .await + { + Ok(outcome) => outcome, + Err(error) => { + // A scan that cannot finish must not leave a half-rebuilt + // wallet looking complete. + self.store_mut() + .fail_operation(handle, CoinageError::RecoveryFailed(error.to_string()))?; + return self.publish_and_persist(storage, now).await; + } + }; + found_anything |= !outcome.is_empty(); + + // Restored records know only that they exist. Their ring, their age + // and any chain lock come from ordinary observation, which is the same + // path a live wallet uses. + if !outcome.is_empty() { + crate::runtime::coinage::observe::refresh_purse( + chain.rpc, + chain.metadata, + self.store_mut(), + &entropy, + *purse, + ¶ms, + &at, + ) + .await?; + } + } + + let _ = found_anything; + // Reconstruction is over; everything after `Resynced` is a live change. + self.store_mut().publish(LayerEvent::Resynced); + self.store_mut() + .conclude_operation(handle, Default::default())?; + self.publish_and_persist(storage, now).await + } +} + +/// Payment classification (§8.8). +impl CoinageLayer { + /// Say how much of an incoming payment this layer can already see (§8.8). + /// + /// Synchronous, against the live local view: no chain read, no operation, no + /// record touched. A payee runs this on the memo a payer sent to decide whether + /// the coins it names have arrived. + /// + /// Matching is by account, not by amount. The coins a transfer mints land in + /// accounts the payee named, so the question "is this mine?" is answered by + /// deriving our own accounts and looking for the ones the memo names — which + /// also means a memo cannot make the layer believe in value it does not hold. + /// + /// An empty entry list is `Unmatched`: nothing was claimed, so nothing matches. + pub fn classify_incoming_payment(&self, entries: &[MemoEntry]) -> PaymentClassification { + let matched = entries + .iter() + .filter(|entry| self.holds_account(entry.recipient_account)) + .count(); + + match matched { + 0 => PaymentClassification::Unmatched, + found if found == entries.len() => PaymentClassification::Matched, + _ => PaymentClassification::Received, + } + } +} + +/// What one [`CoinageLayer::tick`] did, and when it wants waking again. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TickOutcome { + /// Whether a maintenance sweep ran. False means nothing was due — which is + /// **not** evidence that nothing is at risk; see + /// [`CoinageLayer::begin_maintenance_sweep`]. + pub swept: bool, + /// How long the host may wait before calling again without letting a deadline + /// pass. Advice about sufficient frequency, not a minimum gap. + pub next_tick_after: Duration, +} + +/// Autonomous lifecycle maintenance (§6.4, §8.7). +/// +/// Two sweeps that together form a closed loop: coin to entry as coins age, entry +/// to coin as rings approach expiry. An unspent coin cycles between the two forms +/// indefinitely and keeps its value, so long as both run. +/// +/// The layer has no clock outside a live session, so neither sweep fires by itself. +/// A host that embeds this layer has to tick it through [`CoinageLayer::tick`]; the +/// mechanism that does the ticking is truapi#356. A foreground-only trigger narrows +/// the loss window without closing it, because the failure mode is precisely "the +/// user did not open the app". +impl CoinageLayer { + /// Do whatever is due at `now`, and say when the layer next wants waking. + /// + /// The core-side half of the invocation-lifecycle contract (truapi#356): the + /// core owns *what* runs and *how*, the host owns only *when*. A host that calls + /// this on a timer gets the whole of §6.4's autonomous behaviour; a host that + /// never calls it gets a wallet that silently loses value once a recycler ring + /// expires, which is the failure this exists to prevent. + /// + /// Two things happen, in this order: + /// + /// 1. **Balance streams are reprojected.** A jitter delay elapsing or a chain + /// lock expiring moves a purse's spendable balance with no record changing, + /// so nothing but the clock can surface it. Cheap and unconditional. + /// 2. **Both sweeps run if anything is due**, as one operation which this drives + /// to completion before returning. + /// + /// # Scheduling needs no persisted state + /// + /// The sweeps decide what to do from the records themselves — a coin's age, an + /// entry's ring deadline — not from how long it has been since the last run. So + /// this is safe to call at any frequency: too often costs one pass over local + /// records and returns nothing, and a restart loses no scheduling state because + /// there is none to lose. The returned interval is advice about *sufficient* + /// frequency, not a minimum gap the host must respect. + /// + /// # What it cannot do + /// + /// It cannot wait. There is no sleep or timer inside the core, which is why + /// anything needing a second look — a paid unload token bought but not yet + /// onboarded — is reported rather than waited on, and picked up by the next tick. + pub async fn tick( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + now: Timestamp, + ) -> Result { + self.refresh_subscriptions(now); + + let started = self.begin_maintenance_sweep(None, now)?; + let swept = match started { + None => false, + Some(start) => { + self.drive_operation(storage, chain, start.handle, now) + .await?; + true + } + }; + + Ok(TickOutcome { + swept, + next_tick_after: self.params().sweep_tick_interval(), + }) + } + + /// Run both sweeps once across `purses`, or across every purse (§8.7). + /// + /// Returns `None` when there is nothing to do, so a scheduled tick that finds a + /// tidy wallet costs no operation record and no event. + /// + /// **An empty result is not evidence that observation ran.** The rescue side + /// reads each entry's own record of when its ring became immutable, and an + /// entry nobody has observed has no deadline recorded — which looks exactly like + /// a ring that is still accepting members. A caller that treats "nothing to + /// rescue" as "nothing is at risk" reintroduces the loss it is trying to + /// prevent. + pub fn begin_maintenance_sweep( + &mut self, + purses: Option>, + now: Timestamp, + ) -> Result, CoinageError> { + use crate::host_logic::coinage::types::OperationKind; + + let purses = match purses { + Some(listed) => { + for purse in &listed { + if self.store().purse(*purse).is_none() { + return Err(CoinageError::PurseNotFound(*purse)); + } + } + listed + } + None => self.store().purses().map(|purse| purse.id).collect(), + }; + + let work = self.sweep_work(&purses, now); + if work.is_empty() { + return Ok(None); + } + + let locks = sweep_locks(&work); + let jitter = self.jitter_draws(work.iter().map(|item| item.aging_coins.len()).sum())?; + + // A sweep spans purses, so its operation is attributed to the first purse + // with work; the lock set is what actually scopes it. + let owner = work[0].purse; + let handle = self + .store_mut() + .start_operation(owner, OperationKind::MaintenanceSweep)?; + self.store_mut() + .publish(LayerEvent::MaintenanceSweepStarted { + purses: work.iter().map(|item| item.purse).collect(), + }); + + if let Err(error) = self.store_mut().lock_for_operation(handle, &locks, now) { + let _ = self.store_mut().fail_operation(handle, error.clone()); + return Err(error); + } + let program = match plan_maintenance(self.store_mut(), &work, now, &jitter) { + Ok(program) => program, + Err(error) => { + let _ = self.store_mut().fail_operation(handle, error.clone()); + return Err(error); + } + }; + + let status = self.subscribe_operation_status(handle)?; + self.register_completion( + handle, + Completion::ReportSweep { + coins_recycled: work.iter().map(|item| item.aging_coins.len() as u32).sum(), + entries_rescued: work + .iter() + .flat_map(|item| item.rescues.iter()) + .map(|group| group.entries.len() as u32) + .sum(), + }, + ); + self.register_program(handle, program); + Ok(Some(OperationStart { handle, status })) + } + + /// What both sweeps have to do, per purse. + fn sweep_work(&self, purses: &[PurseId], now: Timestamp) -> Vec { + let recycle_at = self.constants().recycle_at_age(); + let margin = self + .params() + .rescue_margin(self.constants().recycler_expiration_time); + + purses + .iter() + .filter_map(|purse| { + let aging_coins: Vec<_> = self + .store() + .coins_needing_recycling(*purse, recycle_at, now) + .into_iter() + .filter_map(|index| { + self.store() + .coin(*purse, index) + .map(|coin| (index, coin.exponent)) + }) + .collect(); + let rescues = self.rescue_groups(*purse, margin, now); + + (!aging_coins.is_empty() || !rescues.is_empty()).then_some(SweepWork { + purse: *purse, + aging_coins, + rescues, + }) + }) + .collect() + } + + /// Entries due for rescue, bucketed the way they will be unloaded. + /// + /// One extrinsic per `(denomination, ring)` bucket, each carrying its own token, + /// and each bucket bounded by what the runtime consolidates. + fn rescue_groups( + &self, + purse: PurseId, + margin: core::time::Duration, + now: Timestamp, + ) -> Vec { + let due = self.store().entries_needing_rescue( + purse, + self.constants().recycler_expiration_time, + margin, + now, + ); + + let cap = self.constants().max_consolidation.max(1) as usize; + let mut buckets: Vec = Vec::new(); + for index in due { + let Some(entry) = self.store().entry(purse, index) else { + continue; + }; + let Some(ring) = entry.ring else { + continue; + }; + + match buckets.iter_mut().find(|group| { + group.ring == ring && group.exponent == entry.exponent && group.entries.len() < cap + }) { + Some(group) => group.entries.push(index), + None => buckets.push(RescueGroup { + ring, + exponent: entry.exponent, + entries: vec![index], + }), + } + } + + buckets + } + + /// One readiness delay per coin being recycled. + /// + /// Random by requirement, not by taste: a new entry that became selectable the + /// instant it was loaded would let an observer pair the load with the unload + /// that follows it (§5.3). + fn jitter_draws(&self, count: usize) -> Result, CoinageError> { + let bound = self.params().recycler_entry_jitter_upper_bound; + if bound.is_zero() { + return Ok(vec![core::time::Duration::ZERO; count]); + } + + let mut draws = Vec::with_capacity(count); + for _ in 0..count { + let mut bytes = [0u8; 8]; + getrandom::getrandom(&mut bytes).map_err(|error| { + CoinageError::Internal(format!("drawing a jitter delay failed: {error}")) + })?; + let millis = u64::from_le_bytes(bytes) % (bound.as_millis() as u64).max(1); + draws.push(core::time::Duration::from_millis(millis)); + } + Ok(draws) + } +} + +/// Every record a sweep will touch, so it can hold them all before it starts. +fn sweep_locks(work: &[SweepWork]) -> LockSet { + let mut locks = LockSet::default(); + for item in work { + for (coin, _) in &item.aging_coins { + locks.coins.push((item.purse, *coin)); + } + for group in &item.rescues { + for entry in &group.entries { + locks.entries.push((item.purse, *entry)); + } + } + } + locks +} + +/// Purse lifecycle (§8.1). +/// +/// Three of the five primitives touch no chain: a purse is a derivation namespace +/// plus a name, so creating, reading and renaming one are local facts. The other +/// two move value, and so are operations like any other. +impl CoinageLayer { + /// Open a new purse (§8.1). + /// + /// The identifier is fresh and never reused, even after a purse is closed: it + /// names a derivation namespace, so reissuing one would let a new purse's + /// accounts be correlated with the closed purse's on-chain history. + pub async fn create_purse( + &mut self, + storage: &S, + name: String, + now: Timestamp, + ) -> Result { + let purse = self.store_mut().create_purse(name); + self.publish_and_persist(storage, now).await?; + Ok(purse) + } + + /// A purse's identity and balance, as of `now` (§8.1). + pub fn query_purse( + &self, + purse: PurseId, + now: Timestamp, + ) -> Result { + self.store().purse_info(purse, now) + } + + /// Rename a purse (§8.1). + pub async fn rename_purse( + &mut self, + storage: &S, + purse: PurseId, + name: String, + now: Timestamp, + ) -> Result<(), CoinageError> { + self.store_mut().rename_purse(purse, name)?; + self.publish_and_persist(storage, now).await + } + + /// Move `amount` from one purse to another (§8.1). + /// + /// Selection runs in the source purse; the destination coins are derived in the + /// target purse's namespace, which is what keeps the two purses uncorrelated on + /// chain. Change stays in the source. + pub fn begin_rebalance( + &mut self, + from: PurseId, + to: PurseId, + amount: crate::host_logic::coinage::types::Amount, + allow_degraded: bool, + now: Timestamp, + ) -> Result { + use crate::host_logic::coinage::selection::{OutputRequirement, SelectionRequest}; + use crate::host_logic::coinage::types::OperationKind; + + if self.store().purse(to).is_none() { + return Err(CoinageError::PurseNotFound(to)); + } + + let request = SelectionRequest { + amount, + // The coins stay with the layer, so their shape is free. + outputs: OutputRequirement::AnyDenominations, + allow_degraded, + }; + + self.begin( + from, + OperationKind::Rebalance, + &request, + TargetDestinations::IntoPurse(to), + now, + ) + } + + /// Drain a purse into another and close it (§8.1). + /// + /// Refuses to strand value: a purse holding anything that cannot move right + /// now — an entry still inside its jitter delay, a coin the chain has locked — + /// is refused with `NoReadyEntries` rather than being closed around it. Closing + /// a purse drops its records, and a record dropped while its account still + /// holds a coin is value nobody can find again without a seed rescan. + /// + /// An empty purse needs no transaction and closes on the spot. + pub fn begin_purse_deletion( + &mut self, + target: PurseId, + drain_into: PurseId, + allow_degraded: bool, + now: Timestamp, + ) -> Result { + use crate::host_logic::coinage::selection::{OutputRequirement, SelectionRequest}; + use crate::host_logic::coinage::types::{Amount, OperationKind}; + + if target.is_main() { + return Err(CoinageError::CannotDeleteMainPurse); + } + if self.store().purse(target).is_none() { + return Err(CoinageError::PurseNotFound(target)); + } + if self.store().purse(drain_into).is_none() { + return Err(CoinageError::PurseNotFound(drain_into)); + } + if self.store().has_in_flight_operations(target) { + return Err(CoinageError::PurseHasInFlightOperations); + } + + let balance = self.store().balance(target, now)?; + let spendable = if allow_degraded { + balance.spendable + } else { + balance.spendable_strict + }; + if !balance.pending.is_zero() { + return Err(CoinageError::NoReadyEntries { + requested: spendable + .checked_add(balance.pending) + .unwrap_or(balance.pending), + available_when_ready: spendable, + }); + } + + let request = SelectionRequest { + amount: spendable, + outputs: OutputRequirement::AnyDenominations, + allow_degraded, + }; + let started = self.begin( + target, + OperationKind::DeletePurse, + &request, + TargetDestinations::IntoPurse(drain_into), + now, + )?; + + // The purse closes only once the chain has agreed its value left. Until + // then the records have to stay: they are the only witness to coins whose + // accounts are already on chain. + self.register_completion( + started.handle, + Completion::ClosePurse { + target, + drained_into: drain_into, + amount: if spendable.is_zero() { + Amount::ZERO + } else { + spendable + }, + }, + ); + Ok(started) + } +} + +/// What driving one transaction settled, if anything. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Settlement { + /// The chain finalized it successfully. + Succeeded, + /// It can never take effect. Its inputs are back in the pool. + Rejected, + /// Undecided. The entry stays pending and the operation stays open. + Undecided, +} + +impl CoinageLayer { + /// Run every transaction of an operation's program, then terminate it. + /// + /// Returns once no transaction is left to submit and the operation has either + /// terminated or been left for recovery. A failure here is a failure to + /// *drive*; the operation's own outcome is reported through its status stream + /// and its receipt, per §8. + pub async fn drive_operation( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + handle: OperationHandle, + now: Timestamp, + ) -> Result<(), CoinageError> { + // An offload has no fixed program: it re-plans between phases, so it gets + // its own loop rather than a list of transactions. + if let Some(request) = self.take_offload(handle) { + return self + .drive_offload(storage, chain, handle, request, now) + .await; + } + // A recovery scan has no transactions at all: it reads, it does not write. + if let Some(request) = self.take_recovery(handle) { + return self + .drive_recovery(storage, chain, handle, request, now) + .await; + } + + let program = self + .take_program(handle) + .ok_or(CoinageError::OperationNotFound(handle))?; + + let mut grants = self + .resolve_tokens(chain, program.unload_tokens_required(), now) + .await?; + + // Coins already in the right shape are handed over with their secrets and + // need nothing from the chain, so they can go out before anything is + // submitted. + self.deliver_exports(handle, &program.exports_in_place)?; + + let mut settlements = Vec::new(); + for transaction in &program.transactions { + let settlement = self + .run_transaction(storage, chain, handle, transaction, &mut grants, now) + .await?; + if settlement == Settlement::Succeeded { + self.deliver_exports(handle, &transaction.exports)?; + } + settlements.push(settlement); + } + + // Nothing else will be signed for this operation, so the secrets it was + // handed have no further use (§8.5). + self.forget_import_secrets(handle); + self.terminate(storage, handle, &settlements, now).await + } + + /// Assemble, log, broadcast and grade one transaction. + async fn run_transaction( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + handle: OperationHandle, + transaction: &PlannedTransaction, + grants: &mut Vec, + now: Timestamp, + ) -> Result { + let (state, anchor) = submit::fetch_mortal_chain_state(chain.rpc) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let checkpoint = Checkpoint { + number: anchor.number, + hash: BlockHash(anchor.hash), + mortality: anchor.period, + }; + + let assembled = self + .assemble(chain, handle, transaction, &state, grants) + .await?; + let extrinsic_hash = submit::extrinsic_hash(&assembled.extrinsic); + + // The log entry and its hash are durable before anything is broadcast. + let sequence = self.store_mut().plan_transaction( + handle, + transaction.inputs.clone(), + transaction.outputs.clone(), + checkpoint, + transaction.depends_on.iter().copied(), + )?; + self.store_mut() + .record_submission(handle, sequence, extrinsic_hash)?; + if let Some(event) = assembled.event { + self.store_mut().publish(event); + } + self.publish_and_persist(storage, now).await?; + + let outcome = submit::submit(chain.rpc, chain.metadata, &assembled.extrinsic).await; + if let submit::TrackerOutcome::Included(verdict) = &outcome + && verdict.succeeded() + { + self.deliver_memo(handle, transaction, &assembled.origins)?; + } + self.grade(storage, chain, handle, sequence, outcome, now) + .await + } + + /// Tell the caller which coins a transaction just sent outside the layer. + /// + /// Fired on inclusion rather than finality: §8.3 wants the payee to be able to + /// act promptly, and accepts that a reorg can undo a delivered memo. + fn deliver_memo( + &self, + handle: OperationHandle, + transaction: &PlannedTransaction, + origins: &[CoinAccountId], + ) -> Result<(), CoinageError> { + let Some(memo) = self.memo_of(handle) else { + return Ok(()); + }; + + let index = match transaction.kind { + TransactionKind::Transfer { source, .. } | TransactionKind::Split { source, .. } => { + source.1 + } + // Neither an unload nor an import has a source coin of ours, so there + // is no index to report. + TransactionKind::Recycle { source, .. } => source.1, + TransactionKind::TopUpLoad { .. } => crate::host_logic::coinage::types::CoinIndex(0), + TransactionKind::Unload { .. } + | TransactionKind::Offboard { .. } + | TransactionKind::ImportTransfer { .. } => { + crate::host_logic::coinage::types::CoinIndex(0) + } + }; + // One origin per output, in call order, so an unload's outputs are each + // attributed to the entry alias they came from. + let entries: Vec = transaction + .kind + .outputs() + .iter() + .zip(origins.iter().chain(core::iter::repeat( + origins.last().unwrap_or(&CoinAccountId([0; 32])), + ))) + .filter_map(|(output, origin)| match output.destination { + Destination::External(recipient_account) => Some(MemoEntry { + sender_coin_account: *origin, + recipient_account, + derivation_index: index, + }), + Destination::Local { .. } => None, + }) + .collect(); + + if !entries.is_empty() { + memo(entries); + } + Ok(()) + } + + /// Apply a tracker outcome, resolving the entry when the answer is definite + /// and handing it to recovery when it is not. + async fn grade( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + handle: OperationHandle, + sequence: u32, + outcome: submit::TrackerOutcome, + now: Timestamp, + ) -> Result { + match outcome { + submit::TrackerOutcome::NotIncluded { reason } => { + self.store_mut().resolve_transaction( + handle, + sequence, + LogEntryState::Rejected { reason }, + )?; + self.publish_and_persist(storage, now).await?; + Ok(Settlement::Rejected) + } + submit::TrackerOutcome::Included(verdict) if verdict.finalized() => { + let state = if verdict.succeeded() { + LogEntryState::Succeeded { + block_hash: verdict.block_hash(), + } + } else { + LogEntryState::Rejected { + reason: rejection_reason(&verdict), + } + }; + let settled = if verdict.succeeded() { + Settlement::Succeeded + } else { + Settlement::Rejected + }; + self.store_mut() + .resolve_transaction(handle, sequence, state)?; + self.publish_and_persist(storage, now).await?; + Ok(settled) + } + // Seen in a block that is not finalized: real enough to report, not + // real enough to retire a record over. + submit::TrackerOutcome::Included(_) => { + self.advance(storage, handle, OperationStatus::InBlock, now) + .await?; + self.await_finality(storage, chain, handle, sequence, now) + .await + } + submit::TrackerOutcome::Unknown { .. } => { + self.advance(storage, handle, OperationStatus::Recovering, now) + .await?; + self.await_finality(storage, chain, handle, sequence, now) + .await + } + } + } + + /// Run recovery passes until the entry is settled or the budget runs out. + async fn await_finality( + &mut self, + storage: &S, + chain: &ChainContext<'_>, + handle: OperationHandle, + sequence: u32, + now: Timestamp, + ) -> Result { + for attempt in 0..RECOVERY_POLL_ATTEMPTS { + if attempt > 0 { + Delay::new(chain.recovery_poll_interval).await; + } + + let at = recover::finalized_at(chain.rpc).await?; + let entropy = self.entropy().to_vec(); + let outcome = recover::run_pass(chain.rpc, self.store_mut(), &entropy, &at).await?; + self.publish_and_persist(storage, now).await?; + + if outcome.succeeded.contains(&(handle, sequence)) { + return Ok(Settlement::Succeeded); + } + if outcome.rejected.contains(&(handle, sequence)) + || outcome.abandoned.contains(&(handle, sequence)) + { + return Ok(Settlement::Rejected); + } + } + + // Not a verdict: the entry stays pending and recovery resumes it later. + Ok(Settlement::Undecided) + } + + /// Finish the operation from what its transactions settled. + /// + /// `Done` requires one definite success (§9). An operation with nothing + /// settled either way is left open rather than failed: its transactions may + /// still be on chain, and failing it would release records the chain is about + /// to consume. + async fn terminate( + &mut self, + storage: &S, + handle: OperationHandle, + settlements: &[Settlement], + now: Timestamp, + ) -> Result<(), CoinageError> { + if settlements.contains(&Settlement::Undecided) { + self.publish_and_persist(storage, now).await?; + return Ok(()); + } + // Nothing further can land, so none of the operation's side channels can + // produce anything again. + self.forget_memo(handle); + self.close_exports(handle); + self.forget_funding_origin(handle); + + let Some(operation) = self.store().operation(handle) else { + // Already terminated, by recovery or a cascade. + return Ok(()); + }; + let receipt = operation.log.receipt(); + let submitted_nothing = receipt.extrinsics.is_empty(); + + if receipt.any_succeeded() || submitted_nothing { + // An operation with nothing to submit — draining an empty purse — + // succeeds by having nothing left to do. + self.store_mut().conclude_operation(handle, receipt)?; + self.apply_completion(handle)?; + } else { + let reason = first_rejection(&receipt) + .unwrap_or_else(|| "no transaction reached the chain".to_string()); + self.store_mut().fail_operation( + handle, + CoinageError::ChainRejected { + extrinsic_hash: first_hash(&receipt).unwrap_or(ExtrinsicHash([0; 32])), + reason, + }, + )?; + } + + self.publish_and_persist(storage, now).await + } + + /// Do the local work an operation's success unlocked. + /// + /// Runs after the operation record is gone, which is what lets a purse being + /// drained pass the "no in-flight operations" check its own drain would + /// otherwise fail. + fn apply_completion(&mut self, handle: OperationHandle) -> Result<(), CoinageError> { + match self.take_completion(handle) { + Some(Completion::ClosePurse { + target, + drained_into, + amount, + }) => self.store_mut().close_purse(target, drained_into, amount), + Some(Completion::ReportSweep { + coins_recycled, + entries_rescued, + }) => { + self.store_mut() + .publish(LayerEvent::MaintenanceSweepCompleted { + coins_recycled, + entries_rescued, + failed: 0, + }); + Ok(()) + } + None => Ok(()), + } + } + + /// Move the operation to a non-terminal status and publish it. + async fn advance( + &mut self, + storage: &S, + handle: OperationHandle, + status: OperationStatus, + now: Timestamp, + ) -> Result<(), CoinageError> { + self.store_mut().advance_operation(handle, status)?; + self.publish_and_persist(storage, now).await + } + + /// Choose the tokens an operation's unloads will present. + /// + /// Resolved once for the whole operation: resolving per group would hand two + /// groups the same free slot, and the second would be refused after the first + /// had spent it. + async fn resolve_tokens( + &self, + chain: &ChainContext<'_>, + needed: usize, + now: Timestamp, + ) -> Result, CoinageError> { + if needed == 0 { + return Ok(Vec::new()); + } + + let at = chain + .rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let personhood = bandersnatch_entropy(self.entropy()); + let free = tokens::read_free_token_availability( + chain.rpc, + personhood, + now, + self.params(), + self.constants(), + &at, + ) + .await?; + let paid = tokens::read_paid_ring_state( + chain.rpc, + chain.metadata, + self.entropy(), + now, + self.params(), + self.constants(), + &at, + ) + .await?; + // Whether a join is affordable is not readable: the pallet prices it from a + // weight. A dry run is the only exact answer, and it costs one round trip + // that is only spent when the free allowance is already exhausted. + let paid = if paid.slots.iter().any(|slot| slot.is_joinable()) { + let fundable = self.paid_join_is_fundable(chain, &paid).await?; + paid.with_fundable_joins(fundable) + } else { + paid + }; + + let plan = resolve(needed, &free, &paid, self.params(), self.constants())?; + + // Every join must have *definitely* succeeded before the token it buys can + // be presented, so they are submitted here, ahead of the operation's own + // transactions, and awaited one at a time. + for slot in &plan.joins { + self.buy_paid_token(chain, paid.period, *slot).await?; + } + + Ok(plan.grants) + } + + /// Whether the fee account can pay to join the paid ring, as a dry run says. + /// + /// Dry-running the real extrinsic rather than comparing balances to a guess: + /// the pallet computes the fee as `WeightToFee(coin_lifecycle_weight())`, which + /// is neither a published constant nor a runtime API, so there is no number to + /// compare against. A rejection is taken as "cannot fund" rather than raised, + /// because the caller's alternative — reporting `NoUnloadToken` — is the same + /// answer either way, and an unfunded fee account is an ordinary state. + async fn paid_join_is_fundable( + &self, + chain: &ChainContext<'_>, + paid: &PaidRingState, + ) -> Result { + let Some(slot) = paid.slots.iter().find(|slot| slot.is_joinable()) else { + return Ok(false); + }; + + let extrinsic = self + .assemble_paid_join(chain, paid.period, slot.slot) + .await?; + Ok(submit::dry_run(chain.rpc, &extrinsic).await.is_ok()) + } + + /// Build the extrinsic that registers one paid-token slot's key. + async fn assemble_paid_join( + &self, + chain: &ChainContext<'_>, + period: u32, + slot: u32, + ) -> Result, CoinageError> { + let (state, _anchor) = submit::fetch_mortal_chain_state(chain.rpc) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let keypair = derivation::fee_account_keypair(self.entropy())?; + let nonce = read_account_nonce(chain.rpc, self.fee_account()).await?; + + let member_key = derivation::paid_token_member_key(self.entropy(), period, slot)?; + // The proof binds the key to whoever is joining, which is the fee account: + // the pallet checks this signature in the call itself, against the origin. + let ownership = + proof::paid_token_ownership_proof(self.entropy(), period, slot, self.fee_account())?; + let args = PayForUnloadFeeTokenArgs::new(member_key, ownership); + let call = build_call( + chain.metadata, + CoinageCall::PayForRecyclerUnloadFeeTokenWithNative, + &args, + )?; + + build_account_signed_extrinsic(chain.metadata, &state, &call, &keypair, nonce) + } + + /// Buy one paid unload token, and refuse to proceed until it is provable. + /// + /// # Why this is not a transaction in the operation's program + /// + /// Every other submission this layer makes gets a write-ahead log entry, + /// because it moves records whose local state has to be reconciled if the + /// process dies mid-flight. A join moves no records: it publishes a key derived + /// deterministically from the wallet's entropy, and the chain's own + /// `PaidUnloadTokenMembers` is the durable record of it. After a crash, + /// `read_paid_ring_state` observes exactly what happened with no local + /// bookkeeping, so a log entry would describe state the log does not own. + /// + /// # Why a bought token may still not be usable + /// + /// Registration and onboarding are separate steps: the pallet records the + /// member at once, and the members pallet places it in a provable ring + /// afterwards. A ring-VRF proof needs the ring, so a slot in between is paid for + /// and unusable. The layer cannot wait — it has no clock and no sleep of its own + /// (truapi#356) — so it reports the state honestly and the caller retries. The + /// fee is spent either way, and retrying costs nothing further: the slot is + /// already registered, so resolution will find it rather than buy a second one. + async fn buy_paid_token( + &self, + chain: &ChainContext<'_>, + period: u32, + slot: u32, + ) -> Result<(), CoinageError> { + let extrinsic = self.assemble_paid_join(chain, period, slot).await?; + // Definite success only. An optimistic inclusion is not enough: a reorg + // that removed the join would leave the layer proving membership of a ring + // its key is not in, which reads as an invalid proof with nothing to say + // why. + match submit::submit(chain.rpc, chain.metadata, &extrinsic).await { + submit::TrackerOutcome::Included(submit::SubmissionVerdict::Succeeded { + finalized: true, + .. + }) => {} + submit::TrackerOutcome::Included(submit::SubmissionVerdict::DispatchFailed { + reason, + .. + }) => { + return Err(CoinageError::Internal(format!( + "buying a paid unload token for period {period} slot {slot} was refused: \ + {reason}" + ))); + } + // Included but not yet final, or unknown, or provably not included. + // None of these is a token the layer may present, and the fee account's + // own state is what a retry will read, so nothing is recorded here. + _ => return Err(CoinageError::NoUnloadToken), + } + + // Re-read rather than assume: the pallet chose the period from its own + // clock at dispatch, and the members pallet chose the ring. + let at = chain + .rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let collection = storage::paid_token_collection_id(period); + let member_key = derivation::paid_token_member_key(self.entropy(), period, slot)?; + if ring::find_ring_including(chain.rpc, chain.metadata, &collection, &member_key, &at) + .await? + .is_none() + { + return Err(CoinageError::NoUnloadToken); + } + + Ok(()) + } + + /// Assemble the extrinsic for one planned transaction. + async fn assemble( + &self, + chain: &ChainContext<'_>, + handle: OperationHandle, + transaction: &PlannedTransaction, + state: &ChainState, + grants: &mut Vec, + ) -> Result { + match &transaction.kind { + TransactionKind::Transfer { source, to } => { + let args = TransferArgs::new(self.account_of(to)?); + let call = build_call(chain.metadata, CoinageCall::Transfer, &args)?; + Ok(Assembled { + extrinsic: self.sign_as_coin(chain.metadata, state, &call, *source)?, + event: None, + origins: vec![self.coin_account(*source)?], + }) + } + TransactionKind::ImportTransfer { secret, from, to } => { + let keypair = self + .import_secret(handle, *secret) + .ok_or(CoinageError::BadCoinSecret) + .and_then(keypair_from)?; + // The account the secret controls must be the one the coin sits + // in, or the extrinsic would move a different coin — or none. + if keypair.public.to_bytes() != from.0 { + return Err(CoinageError::BadCoinSecret); + } + + let args = TransferArgs::new(self.account_of(to)?); + let call = build_call(chain.metadata, CoinageCall::Transfer, &args)?; + Ok(Assembled { + extrinsic: build_coin_origin_extrinsic(chain.metadata, state, &call, &keypair)?, + event: None, + origins: vec![*from], + }) + } + TransactionKind::TopUpLoad { purse, entries } => { + let origin = self.funding_origin(handle).ok_or_else(|| { + CoinageError::Internal( + "a top-up needs the funding origin that signs for it".to_string(), + ) + })?; + self.assemble_top_up(chain, state, *purse, entries, origin.as_ref()) + .await + } + TransactionKind::Recycle { source, entry } => { + let member_key = derivation::entry_member_key(self.entropy(), entry.0, entry.1)?; + let coin_account = self.coin_account(*source)?; + let ownership = + proof::entry_ownership_proof(self.entropy(), entry.0, entry.1, coin_account)?; + let args = LoadRecyclerWithCoinArgs::new(member_key, ownership); + let call = build_call(chain.metadata, CoinageCall::LoadRecyclerWithCoin, &args)?; + + Ok(Assembled { + extrinsic: self.sign_as_coin(chain.metadata, state, &call, *source)?, + event: None, + origins: vec![coin_account], + }) + } + TransactionKind::Split { + source, + source_exponent, + outputs, + } => { + let args = SplitArgs::new( + *source_exponent, + &self.coin_outputs(outputs)?, + self.constants(), + )?; + let call = build_call(chain.metadata, CoinageCall::Split, &args)?; + Ok(Assembled { + extrinsic: self.sign_as_coin(chain.metadata, state, &call, *source)?, + event: None, + origins: vec![self.coin_account(*source)?], + }) + } + TransactionKind::Offboard { + purse, + ring, + exponent, + entries, + destination, + payout, + vouchers, + } => { + let grant = if grants.is_empty() { + None + } else { + Some(grants.remove(0)) + }; + self.assemble_offboard( + chain, + state, + *purse, + *ring, + *exponent, + entries, + *destination, + *payout, + vouchers, + grant, + ) + .await + } + TransactionKind::Unload { + purse, + ring, + exponent, + entries, + outputs, + } => { + let grant = if grants.is_empty() { + None + } else { + Some(grants.remove(0)) + }; + self.assemble_unload( + chain, state, *purse, *ring, *exponent, entries, outputs, grant, + ) + .await + } + } + } + + /// Read the ring an entry sits in, at the revision its members belong to now. + /// + /// The revision comes from the chain rather than from the local record: a proof + /// is only valid against the revision it was built for, and a record observed an + /// hour ago may name one the chain has since moved past. + async fn read_ring_for( + &self, + chain: &ChainContext<'_>, + exponent: DenominationExponent, + ring_at: RingLocation, + at: &str, + ) -> Result { + let revision = + ring::read_ring_revision(chain.rpc, chain.metadata, exponent, ring_at.index, at) + .await? + .ok_or_else(|| { + CoinageError::Internal(format!( + "ring {:?} has no root on chain, so nothing can be proven against it", + ring_at.index + )) + })?; + + ring::read_recycler_ring( + chain.rpc, + chain.metadata, + exponent, + RingLocation::new(ring_at.index, revision), + at, + ) + .await + } + + /// Prove membership for every entry in a group, in the order the call names + /// them. + /// + /// The aliases were derived without proving so the call could be built first; + /// proving now must reproduce them, or the call names one entry and the proof + /// authorizes another. + fn prove_aliases( + &self, + ring: &ring::RecyclerRing, + purse: PurseId, + entries: &[crate::host_logic::coinage::types::EntryIndex], + aliases: &[[u8; 32]], + implication: &[u8], + ) -> Result, CoinageError> { + let mut proofs = Vec::with_capacity(entries.len()); + for (index, expected) in entries.iter().zip(aliases) { + let proven = proof::entry_membership_proof( + ring.domain, + self.entropy(), + purse, + *index, + &ring.members, + implication, + )?; + if &proven.alias != expected { + return Err(CoinageError::Internal(format!( + "entry {index:?} proved alias does not match the one the call names" + ))); + } + proofs.push(proven.proof); + } + Ok(proofs) + } + + /// The extension that presents an unload token for a set of alias proofs. + async fn token_origin( + &self, + chain: &ChainContext<'_>, + grant: Option, + alias_proofs: Vec, + implication: &[u8], + ) -> Result { + match grant { + Some(TokenGrant::Free { period, counter }) => { + let personhood = bandersnatch_entropy(self.entropy()); + // Scanning back from the current ring index is how every other + // caller locates its membership: a key onboarded a while ago sits in + // an older ring, and only the newest ring containing it is provable. + let newest = + crate::runtime::statement_allowance::ring::read_current_ring_index(chain.rpc) + .await + .map_err(|error| { + CoinageError::Internal(format!("reading the ring index: {error}")) + })?; + let people = crate::runtime::statement_allowance::find_including_ring( + chain.rpc, + chain.metadata, + personhood, + newest, + ) + .await + .map_err(|error| { + CoinageError::Internal(format!("locating the personhood ring: {error}")) + })? + .ok_or(CoinageError::NoUnloadToken)?; + let domain = crate::runtime::statement_allowance::proof::domain_for_ring_exponent( + people.exponent, + ) + .map_err(|error| { + CoinageError::Internal(format!("personhood ring domain: {error}")) + })?; + let token = proof::free_token_proof( + domain, + personhood, + &people.members, + period, + counter, + &alias_proofs, + implication, + )?; + + Ok(AsCoinageInfo::FreeUnloadToken { + ring: FreeTokenRing::LitePeople, + proof: token, + period, + counter, + alias_proofs, + }) + } + Some(TokenGrant::Paid { period, slot }) => { + let at = chain + .rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let collection = storage::paid_token_collection_id(period); + let member_key = derivation::paid_token_member_key(self.entropy(), period, slot)?; + + // The ring the chain put this key in, not the one the join asked + // for: `add_member` picks the period from its own clock and the + // members pallet picks the ring. + let ring = ring::find_ring_including( + chain.rpc, + chain.metadata, + &collection, + &member_key, + &at, + ) + .await? + .ok_or(CoinageError::NoUnloadToken)?; + + let token = proof::paid_token_proof( + ring.domain, + self.entropy(), + &ring.members, + period, + slot, + &alias_proofs, + implication, + )?; + + Ok(AsCoinageInfo::PaidUnloadToken { + proof: token, + period, + ring: ring.location, + alias_proofs, + }) + } + None => Err(CoinageError::NoUnloadToken), + } + } + + /// Assemble one unload group, choosing its fee mode and origin. + #[allow(clippy::too_many_arguments)] + async fn assemble_unload( + &self, + chain: &ChainContext<'_>, + state: &ChainState, + purse: PurseId, + ring_at: RingLocation, + exponent: DenominationExponent, + entries: &[crate::host_logic::coinage::types::EntryIndex], + outputs: &[PlannedOutput], + grant: Option, + ) -> Result { + let at = chain + .rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + + let ring = self.read_ring_for(chain, exponent, ring_at, &at).await?; + + let coin_outputs = self.coin_outputs(outputs)?; + let aliases = entries + .iter() + .map(|index| proof::recycler_alias(self.entropy(), purse, *index)) + .collect::, _>>()?; + + // Price the prepaid shape, then let the fee account's balance decide. + let prepaid = self + .build_unload( + chain, + state, + purse, + &ring, + exponent, + entries, + &aliases, + &coin_outputs, + Origin::Token(grant), + 0, + ) + .await?; + let estimated = fee::estimate(chain.rpc, &prepaid).await?; + let balance = + tokens::read_fee_account_balance(chain.rpc, chain.metadata, self.fee_account(), &at) + .await?; + let mode = choose_fee_mode(balance, estimated); + + let (extrinsic, paid) = match mode { + FeeMode::Prepaid => (prepaid, grant.is_some_and(|grant| grant.is_paid())), + FeeMode::FromOutput => { + // No token is consumed in this mode, so the free allowance is + // left alone and the ceiling is priced against its own bytes. + let ceiling = fee::ceiling(chain.rpc, |max_fee| { + self.build_unload( + chain, + state, + purse, + &ring, + exponent, + entries, + &aliases, + &coin_outputs, + Origin::FromOutput, + max_fee, + ) + }) + .await?; + (ceiling, false) + } + }; + + Ok(Assembled { + extrinsic, + event: Some(LayerEvent::UnloadTokenSpent { + purse, + paid, + fee: mode, + }), + origins: aliases.iter().copied().map(CoinAccountId).collect(), + }) + } + + /// Assemble one offboard group: value out, surplus reloaded (§8.6). + /// + /// Structurally an unload, so it shares the ring read, the alias proofs and the + /// token — but its outputs are an external payment plus fresh entries rather + /// than coins, and there is no from-output fee mode to fall back on: the call + /// carries no fee ceiling, so an unfunded fee account is a refusal rather than + /// a cheaper path. + #[allow(clippy::too_many_arguments)] + async fn assemble_offboard( + &self, + chain: &ChainContext<'_>, + state: &ChainState, + purse: PurseId, + ring_at: RingLocation, + exponent: DenominationExponent, + entries: &[crate::host_logic::coinage::types::EntryIndex], + destination: CoinAccountId, + payout: crate::host_logic::coinage::types::Amount, + vouchers: &[( + DenominationExponent, + crate::host_logic::coinage::types::EntryIndex, + )], + grant: Option, + ) -> Result { + use crate::runtime::coinage::call::UnloadRecyclerIntoExternalAssetAndVouchersArgs; + + let at = chain + .rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let ring = self.read_ring_for(chain, exponent, ring_at, &at).await?; + + let aliases = entries + .iter() + .map(|index| proof::recycler_alias(self.entropy(), purse, *index)) + .collect::, _>>()?; + let voucher_keys = vouchers + .iter() + .map(|(exponent, index)| { + derivation::entry_member_key(self.entropy(), purse, *index) + .map(|member_key| (*exponent, member_key)) + }) + .collect::, _>>()?; + + let args = UnloadRecyclerIntoExternalAssetAndVouchersArgs::new( + aliases.clone(), + exponent, + ring.location, + destination, + payout, + &voucher_keys, + self.constants(), + )?; + let call = build_call( + chain.metadata, + CoinageCall::UnloadRecyclerIntoExternalAssetAndVouchers, + &args, + )?; + let implication = inherited_implication(chain.metadata, &call, state)?; + let alias_proofs = self.prove_aliases(&ring, purse, entries, &aliases, &implication)?; + let info = self + .token_origin(chain, grant, alias_proofs, &implication) + .await?; + let extra = info.encode_extra(chain.metadata)?; + + Ok(Assembled { + extrinsic: build_unsigned_extrinsic(chain.metadata, state, &call, &extra)?, + event: Some(LayerEvent::UnloadTokenSpent { + purse, + paid: grant.is_some_and(|grant| grant.is_paid()), + fee: FeeMode::Prepaid, + }), + origins: aliases.iter().copied().map(CoinAccountId).collect(), + }) + } + + /// Build one unload extrinsic for a given origin and fee ceiling. + #[allow(clippy::too_many_arguments)] + async fn build_unload( + &self, + chain: &ChainContext<'_>, + state: &ChainState, + purse: PurseId, + ring: &ring::RecyclerRing, + exponent: DenominationExponent, + entries: &[crate::host_logic::coinage::types::EntryIndex], + aliases: &[[u8; 32]], + outputs: &[CoinOutput], + origin: Origin, + max_fee: u128, + ) -> Result, CoinageError> { + let args = UnloadRecyclerIntoCoinsArgs::new( + aliases.to_vec(), + exponent, + ring.location, + outputs, + max_fee, + self.constants(), + )?; + let call = build_call(chain.metadata, CoinageCall::UnloadRecyclerIntoCoins, &args)?; + let implication = inherited_implication(chain.metadata, &call, state)?; + let alias_proofs = self.prove_aliases(ring, purse, entries, aliases, &implication)?; + + let info = match origin { + Origin::Token(grant) => { + self.token_origin(chain, grant, alias_proofs, &implication) + .await? + } + Origin::FromOutput => AsCoinageInfo::UnloadTokenFromOutput { + fee_recycler_value: exponent, + fee_recycler_ring: ring.location, + retry_counter: 0, + alias_proofs, + }, + }; + + let extra = info.encode_extra(chain.metadata)?; + build_unsigned_extrinsic(chain.metadata, state, &call, &extra) + } + + /// Sign a call with the coin that authorizes it. + fn sign_as_coin( + &self, + metadata: &Metadata, + state: &ChainState, + call: &[u8], + source: (PurseId, crate::host_logic::coinage::types::CoinIndex), + ) -> Result, CoinageError> { + let keypair = derivation::coin_keypair(self.entropy(), source.0, source.1)?; + build_coin_origin_extrinsic(metadata, state, call, &keypair) + } + + /// The on-chain account of one of our coins. + fn coin_account( + &self, + source: (PurseId, crate::host_logic::coinage::types::CoinIndex), + ) -> Result { + derivation::coin_account_id(self.entropy(), source.0, source.1) + } + + /// The account one planned output names. + fn account_of(&self, output: &PlannedOutput) -> Result { + match output.destination { + Destination::External(account) => Ok(account), + Destination::Local { purse, index } => { + derivation::coin_account_id(self.entropy(), purse, index) + } + } + } + + /// Planned outputs as the call's `(denomination, account)` pairs. + fn coin_outputs(&self, outputs: &[PlannedOutput]) -> Result, CoinageError> { + outputs + .iter() + .map(|output| { + Ok(CoinOutput { + exponent: output.exponent, + account: self.account_of(output)?, + }) + }) + .collect() + } +} + +/// Which origin an unload presents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Origin { + /// An unload token, free or paid. + Token(Option), + /// No token: the fee comes out of the unloaded value. + FromOutput, +} + +/// An assembled extrinsic, plus anything worth telling subscribers about how it +/// was built. +struct Assembled { + extrinsic: Vec, + event: Option, + /// On-chain origins the transaction spends, in call order: the coin account + /// for a coin origin, one alias per entry for an unload. What a memo reports + /// as the sender side. + origins: Vec, +} + +/// The first rejection reason a receipt carries. +fn first_rejection( + receipt: &crate::host_logic::coinage::operation::OperationReceipt, +) -> Option { + use crate::host_logic::coinage::operation::ExtrinsicOutcome; + + receipt + .extrinsics + .iter() + .find_map(|record| match &record.outcome { + ExtrinsicOutcome::Rejected { reason } | ExtrinsicOutcome::Abandoned { reason } => { + Some(reason.clone()) + } + ExtrinsicOutcome::Succeeded { .. } => None, + }) +} + +/// The first extrinsic hash a receipt carries. +fn first_hash( + receipt: &crate::host_logic::coinage::operation::OperationReceipt, +) -> Option { + receipt + .extrinsics + .iter() + .find_map(|record| record.extrinsic_hash) +} + +/// A dispatch failure's reason, for the log. +fn rejection_reason(verdict: &submit::SubmissionVerdict) -> String { + match verdict { + submit::SubmissionVerdict::DispatchFailed { reason, .. } => reason.clone(), + submit::SubmissionVerdict::Succeeded { .. } => "succeeded".to_string(), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::{Arc, Mutex as StdMutex}; + + use futures::StreamExt; + use parity_scale_codec::Encode; + use truapi::v01; + use truapi_platform::CoreStorageKey; + + use crate::host_logic::coinage::coin::CoinState; + use crate::host_logic::coinage::entry::EntryLocalState; + use crate::host_logic::coinage::memo::{MemoEntry, PaymentClassification}; + use crate::host_logic::coinage::params::CoinageParameters; + use crate::host_logic::coinage::types::{ + Amount, CoinAge, CoinIndex, DenominationExponent, EntryIndex, RevisionIndex, RingIndex, + }; + use crate::runtime::coinage::bootstrap::CoinageConfig; + use crate::runtime::coinage::storage; + use crate::runtime::coinage::testing::{ + FIXTURE, FakeChain, Inclusion, collection_info, ring_page, ring_status, + }; + + use super::*; + + const ENTROPY: [u8; 32] = [7; 32]; + const NOW: Timestamp = Timestamp(1_700_000_000_000); + + #[derive(Default)] + struct MemStorage(StdMutex, Vec>>); + + #[truapi_platform::async_trait] + impl CoreStorage for MemStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(self.0.lock().unwrap().get(&key.encode()).cloned()) + } + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + self.0.lock().unwrap().insert(key.encode(), value); + Ok(()) + } + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + self.0.lock().unwrap().remove(&key.encode()); + Ok(()) + } + } + + fn block_on(future: F) -> F::Output { + futures::executor::block_on(future) + } + + fn metadata() -> Metadata { + Metadata::decode(FIXTURE).expect("the fixture decodes") + } + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + /// A brought-up layer over an empty store. + fn layer(storage: &MemStorage) -> CoinageLayer { + block_on(CoinageLayer::initialize( + storage, + &metadata(), + ENTROPY.to_vec(), + &CoinageConfig::default(), + )) + .expect("initializes") + } + + /// Give the purse a coin the chain already reports populated. + fn fund(layer: &mut CoinageLayer, purse: PurseId, exponent_value: i8) -> CoinIndex { + let index = layer + .store_mut() + .add_pending_coin(purse, exponent(exponent_value)) + .expect("purse exists"); + layer + .store_mut() + .observe_coin(purse, index, CoinAge(0)) + .expect("coin exists"); + index + } + + fn recipient(exponent_value: i8, byte: u8) -> CoinOutput { + CoinOutput { + exponent: exponent(exponent_value), + account: CoinAccountId([byte; 32]), + } + } + + /// A context whose recovery polling does not sleep, so a test that exercises + /// the recovery path stays fast. + fn context<'a>(rpc: &'a RpcClient, metadata: &'a Metadata) -> ChainContext<'a> { + ChainContext { + rpc, + metadata, + recovery_poll_interval: Duration::ZERO, + } + } + + /// Give the purse one ready entry, and tell the chain about the rings a proof + /// for it needs: the recycler ring it sits in, and the personhood ring backing + /// a free unload token. + fn load_entry(layer: &mut CoinageLayer, chain: &FakeChain, ring: RingLocation) -> EntryIndex { + let entry = layer + .store_mut() + .allocate_entry(PurseId::MAIN, exponent(4), NOW, Duration::ZERO) + .expect("purse exists"); + layer + .store_mut() + .observe_entry_ring( + PurseId::MAIN, + entry, + ring, + 64, + &CoinageParameters::default(), + ) + .expect("entry exists"); + prepare_ring_for(chain, entry, ring); + entry + } + + fn ring() -> RingLocation { + RingLocation::new(RingIndex(3), RevisionIndex(0)) + } + + #[test] + fn a_transfer_submits_one_extrinsic_per_coin_and_finishes_done() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let first = fund(&mut layer, PurseId::MAIN, 4); + let second = fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(32), + vec![recipient(4, 0xaa), recipient(4, 0xbb)], + true, + None, + NOW, + ) + .expect("32 cents are available"); + let handle = started.handle; + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 2, "one extrinsic per coin"); + for index in [first, second] { + assert_eq!( + layer + .store() + .coin(PurseId::MAIN, index) + .expect("record kept") + .state, + CoinState::Spent + ); + } + assert!(layer.store().operation(handle).is_none()); + assert!(!layer.has_pending_program(handle)); + + let items: Vec = block_on(started.status.collect()); + assert_eq!(items.first(), Some(&OperationStatus::Preparing)); + match items.last().expect("a terminal item") { + OperationStatus::Done(receipt) => { + assert_eq!(receipt.extrinsics.len(), 2); + assert!( + receipt + .extrinsics + .iter() + .all(|record| record.outcome.succeeded()) + ); + } + other => panic!("expected Done, got {other:?}"), + } + } + + #[test] + fn the_settled_transaction_is_visible_in_the_durable_store() { + // §7.4's ordering, checked where it can be observed: the persisted store + // carries the outcome, so a restart resumes from it rather than from + // nothing. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xaa)], + true, + None, + NOW, + ) + .expect("16 cents are available"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + let reloaded = + block_on(crate::runtime::coinage::persistence::load(&storage, "Main")).expect("loads"); + assert_eq!( + reloaded.coins_in(PurseId::MAIN)[0].state, + CoinState::Spent, + "the durable store reflects the settled transaction" + ); + assert!( + reloaded.open_operations().next().is_none(), + "and the operation is closed there too" + ); + } + + #[test] + fn a_refused_broadcast_returns_the_coin_to_the_pool_and_fails_the_operation() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let coin = fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::new(Inclusion::Rejected); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xaa)], + true, + None, + NOW, + ) + .expect("16 cents are available"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!( + chain.submission_count(), + 0, + "a rejected dry-run never reaches the node" + ); + assert_eq!( + layer + .store() + .coin(PurseId::MAIN, coin) + .expect("record exists") + .state, + CoinState::Available, + "nothing happened on chain, so the coin is spendable again" + ); + let items: Vec = block_on(started.status.collect()); + assert!(matches!( + items.last(), + Some(OperationStatus::Failed(CoinageError::ChainRejected { .. })) + )); + } + + #[test] + fn a_failed_dispatch_keeps_the_coin_and_fails_the_operation() { + // The coin is neither spent nor immediately reusable: the chain restored + // it under a lock, and only observation may release it. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let coin = fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::new(Inclusion::FinalizedFailure); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xaa)], + true, + None, + NOW, + ) + .expect("16 cents are available"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1, "it did reach the chain"); + assert_ne!( + layer + .store() + .coin(PurseId::MAIN, coin) + .expect("record exists") + .state, + CoinState::Spent, + "a failed dispatch consumed nothing" + ); + let items: Vec = block_on(started.status.collect()); + assert!(matches!(items.last(), Some(OperationStatus::Failed(_)))); + } + + #[test] + fn an_optimistic_inclusion_waits_for_finalized_state_before_retiring_anything() { + // The chain reports a non-finalized block. The transfer's output is a + // recipient's account this layer cannot see, so recovery settles it by + // asking whether the input coin is gone — which it is. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let coin = fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::new(Inclusion::InBlock); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xaa)], + true, + None, + NOW, + ) + .expect("16 cents are available"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!( + layer + .store() + .coin(PurseId::MAIN, coin) + .expect("record exists") + .state, + CoinState::Spent, + "settled only once finalized state agreed the input was consumed" + ); + let items: Vec = block_on(started.status.collect()); + assert!( + items.contains(&OperationStatus::InBlock), + "the optimistic inclusion was reported: {items:?}" + ); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn an_unsatisfiable_transfer_fails_synchronously_and_locks_nothing() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 2); + + let refused = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xaa)], + true, + None, + NOW, + ) + .expect_err("four cents cannot pay sixteen"); + + assert!(matches!(refused, CoinageError::InsufficientFunds { .. })); + assert!( + !layer.store().has_in_flight_operations(PurseId::MAIN), + "a refusal must not leave records locked" + ); + } + + #[test] + fn recipient_outputs_that_do_not_sum_to_the_amount_are_refused() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 4); + + let refused = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(3, 0xaa)], + true, + None, + NOW, + ) + .expect_err("eight cents is not sixteen"); + + assert_eq!(refused, CoinageError::OutputsDoNotSumToAmount); + } + + #[test] + fn a_split_transfer_mints_the_recipients_coin_and_keeps_the_change() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(8), + vec![recipient(3, 0xcc)], + true, + None, + NOW, + ) + .expect("a 16-cent coin can pay 8"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!( + chain.submission_count(), + 1, + "one split, no follow-up transfer" + ); + // The change record exists and is still pending: it becomes available + // when observation confirms it, not when the split succeeds. + let change: Vec<_> = layer + .store() + .coins_in(PurseId::MAIN) + .into_iter() + .filter(|coin| coin.exponent == exponent(3)) + .collect(); + assert_eq!(change.len(), 1); + assert_eq!(change[0].state, CoinState::Pending); + let items: Vec = block_on(started.status.collect()); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn driving_an_operation_twice_submits_nothing_the_second_time() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xaa)], + true, + None, + NOW, + ) + .expect("16 cents are available"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + let refused = block_on(layer.drive_operation( + &storage, + &context(&rpc, &metadata), + started.handle, + NOW, + )) + .expect_err("the program was taken, not borrowed"); + + assert!(matches!(refused, CoinageError::OperationNotFound(_))); + assert_eq!(chain.submission_count(), 1, "no double spend"); + } + + #[test] + fn an_unload_transfer_spends_a_free_token_and_reports_its_cost() { + // Tier three end to end: no coins, one ready entry, so the transfer is + // carried by an unload that mints the recipient's coin directly. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let entry = load_entry(&mut layer, &chain, ring()); + // A funded fee account, so the prepaid origin is the one chosen. + chain.set_storage( + &storage::system_account_key(&layer.fee_account()), + account_info(1_000_000), + ); + + let mut events = layer.subscribe_events(); + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xee)], + true, + None, + NOW, + ) + .expect("the entry is ready"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1, "one group, one extrinsic"); + assert_eq!( + layer + .store() + .entry(PurseId::MAIN, entry) + .expect("record kept") + .local, + EntryLocalState::Consumed + ); + let published: Vec = + core::iter::from_fn(|| futures::FutureExt::now_or_never(events.next()).flatten()) + .collect(); + assert!( + published.iter().any(|event| matches!( + event, + LayerEvent::UnloadTokenSpent { + paid: false, + fee: FeeMode::Prepaid, + .. + } + )), + "the token's class and the fee mode are reported: {published:?}" + ); + let items: Vec = block_on(started.status.collect()); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn an_unfunded_fee_account_takes_the_fee_from_the_output_and_spends_no_token() { + // §6.6's fallback. The fee account holds nothing, so the unload presents + // the from-output origin, which consumes no free slot at all. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + chain.set_fee(1_000); + let rpc = chain.rpc(); + let metadata = metadata(); + load_entry(&mut layer, &chain, ring()); + + let mut events = layer.subscribe_events(); + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xee)], + true, + None, + NOW, + ) + .expect("the entry is ready"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + let published: Vec = + core::iter::from_fn(|| futures::FutureExt::now_or_never(events.next()).flatten()) + .collect(); + assert!( + published.iter().any(|event| matches!( + event, + LayerEvent::UnloadTokenSpent { + paid: false, + fee: FeeMode::FromOutput, + .. + } + )), + "an unfunded fee account takes the fee from the output: {published:?}" + ); + } + + #[test] + fn a_free_slot_the_chain_has_already_seen_is_not_spent_twice() { + // The consumed-slot read is what stops a wallet from presenting a token + // the runtime will refuse, after the proof has already been built. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + load_entry(&mut layer, &chain, ring()); + chain.set_storage( + &storage::system_account_key(&layer.fee_account()), + account_info(1_000_000), + ); + + // Spend every slot the layer would probe. + let personhood = crate::runtime::statement_allowance::bandersnatch_entropy(&ENTROPY); + let periods = crate::runtime::coinage::tokens::eligible_periods( + NOW, + layer.constants().unload_token_period, + layer.params().period_lookback_grace, + ) + .expect("computes"); + let range = layer + .params() + .free_token_counter_search_range + .min(layer.constants().max_free_unload_tokens_per_period); + for period in periods { + for counter in 0..range { + let alias = + crate::runtime::coinage::tokens::free_token_alias(personhood, period, counter) + .expect("derives"); + chain.set_storage( + &storage::consumed_free_unload_tokens_key(period, &alias), + Vec::new(), + ); + } + } + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xee)], + true, + None, + NOW, + ) + .expect("the entry is ready"); + let refused = block_on(layer.drive_operation( + &storage, + &context(&rpc, &metadata), + started.handle, + NOW, + )) + .expect_err("no free slot remains and the paid ring cannot be joined"); + + assert_eq!(refused, CoinageError::NoUnloadToken); + assert_eq!(chain.submission_count(), 0, "nothing was broadcast"); + } + + #[test] + fn a_memo_names_the_coins_the_transfer_minted_for_the_payee() { + // Delivered on inclusion, before finality, which is what lets a payee act + // promptly — and what makes a reorg able to undo a payment it was already + // told about. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let source = fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let delivered: Arc>>> = Arc::new(StdMutex::new(Vec::new())); + let recorder = delivered.clone(); + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(8), + vec![recipient(3, 0xcc)], + true, + Some(Box::new(move |entries| { + recorder.lock().unwrap().push(entries); + })), + NOW, + ) + .expect("a 16-cent coin can pay 8"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + let batches = delivered.lock().unwrap().clone(); + assert_eq!(batches.len(), 1, "one call per transaction that landed"); + assert_eq!( + batches[0], + vec![MemoEntry { + sender_coin_account: derivation::coin_account_id(&ENTROPY, PurseId::MAIN, source) + .expect("derives"), + recipient_account: CoinAccountId([0xcc; 32]), + derivation_index: source, + }], + "the change output stays out of the memo: it never left" + ); + } + + #[test] + fn a_transfer_that_never_reached_a_block_delivers_no_memo() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::new(Inclusion::Rejected); + let rpc = chain.rpc(); + let metadata = metadata(); + let delivered: Arc> = Arc::new(StdMutex::new(0)); + let recorder = delivered.clone(); + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xaa)], + true, + Some(Box::new(move |_| { + *recorder.lock().unwrap() += 1; + })), + NOW, + ) + .expect("16 cents are available"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!( + *delivered.lock().unwrap(), + 0, + "nothing was minted, so there is nothing to tell a payee about" + ); + } + + #[test] + fn an_unload_memo_attributes_each_coin_to_the_entry_it_came_from() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let entry = load_entry(&mut layer, &chain, ring()); + chain.set_storage( + &storage::system_account_key(&layer.fee_account()), + account_info(1_000_000), + ); + let delivered: Arc>>> = Arc::new(StdMutex::new(Vec::new())); + let recorder = delivered.clone(); + + let started = layer + .begin_transfer( + PurseId::MAIN, + Amount::from_cents(16), + vec![recipient(4, 0xee)], + true, + Some(Box::new(move |entries| { + recorder.lock().unwrap().push(entries); + })), + NOW, + ) + .expect("the entry is ready"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + let batches = delivered.lock().unwrap().clone(); + assert_eq!(batches.len(), 1); + assert_eq!( + batches[0][0].sender_coin_account, + CoinAccountId( + crate::runtime::coinage::proof::recycler_alias(&ENTROPY, PurseId::MAIN, entry) + .expect("derives") + ), + "the origin of a minted coin is the alias the unload spent" + ); + assert_eq!(batches[0][0].recipient_account, CoinAccountId([0xee; 32])); + } + + // -- D1: purse lifecycle ------------------------------------------------- + + #[test] + fn a_purse_is_created_read_and_renamed_without_touching_the_chain() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + + let savings = + block_on(layer.create_purse(&storage, "Savings".to_string(), NOW)).expect("creates"); + let info = layer.query_purse(savings, NOW).expect("purse exists"); + assert_eq!(info.name, "Savings"); + assert_eq!(info.spendable, Amount::ZERO); + + block_on(layer.rename_purse(&storage, savings, "Rent".to_string(), NOW)).expect("renames"); + assert_eq!( + layer.query_purse(savings, NOW).expect("exists").name, + "Rent" + ); + + // Durable, and still nothing was submitted anywhere. + let reloaded = + block_on(crate::runtime::coinage::persistence::load(&storage, "Main")).expect("loads"); + assert_eq!(reloaded.purse(savings).expect("exists").name, "Rent"); + } + + #[test] + fn a_rebalance_moves_value_into_the_target_purses_namespace() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let source_coin = fund(&mut layer, PurseId::MAIN, 4); + let savings = + block_on(layer.create_purse(&storage, "Savings".to_string(), NOW)).expect("creates"); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_rebalance(PurseId::MAIN, savings, Amount::from_cents(16), true, NOW) + .expect("16 cents are available"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1); + assert_eq!( + layer + .store() + .coin(PurseId::MAIN, source_coin) + .expect("record kept") + .state, + CoinState::Spent + ); + // The destination record lives in the target purse, pending until + // observation confirms it. + let received = layer.store().coins_in(savings); + assert_eq!(received.len(), 1); + assert_eq!(received[0].exponent, exponent(4)); + assert_eq!(received[0].state, CoinState::Pending); + // And its account is derived in the target purse's namespace. + assert_ne!( + derivation::coin_account_id(&ENTROPY, savings, received[0].index).expect("derives"), + derivation::coin_account_id(&ENTROPY, PurseId::MAIN, received[0].index) + .expect("derives"), + ); + let items: Vec = block_on(started.status.collect()); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn a_rebalance_into_a_purse_that_does_not_exist_is_refused() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 4); + + let refused = layer + .begin_rebalance(PurseId::MAIN, PurseId(9), Amount::from_cents(16), true, NOW) + .expect_err("there is nowhere to put it"); + + assert_eq!(refused, CoinageError::PurseNotFound(PurseId(9))); + assert!( + !layer.store().has_in_flight_operations(PurseId::MAIN), + "and nothing was locked on the way to finding out" + ); + } + + #[test] + fn deleting_a_purse_drains_it_and_closes_it_only_once_the_value_has_moved() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let savings = + block_on(layer.create_purse(&storage, "Savings".to_string(), NOW)).expect("creates"); + let coin = fund(&mut layer, savings, 4); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let mut events = layer.subscribe_events(); + + let started = layer + .begin_purse_deletion(savings, PurseId::MAIN, true, NOW) + .expect("the purse is drainable"); + + // Still open while the drain is in flight: the records are the only + // witness to a coin whose account is already on chain. + assert!(layer.store().purse(savings).is_some()); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1); + assert!( + layer.store().purse(savings).is_none(), + "closed once the chain agreed the value left" + ); + assert_eq!( + layer.store().coin(savings, coin), + None, + "and its records went with it" + ); + assert_eq!(layer.store().coins_in(PurseId::MAIN).len(), 1); + + let published: Vec = + core::iter::from_fn(|| futures::FutureExt::now_or_never(events.next()).flatten()) + .collect(); + assert!( + published.iter().any(|event| matches!( + event, + LayerEvent::PurseDeleted { + drained_into: PurseId::MAIN, + .. + } + )), + "subscribers are told where the value went: {published:?}" + ); + } + + #[test] + fn deleting_an_empty_purse_closes_it_without_a_transaction() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let savings = + block_on(layer.create_purse(&storage, "Savings".to_string(), NOW)).expect("creates"); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_purse_deletion(savings, PurseId::MAIN, true, NOW) + .expect("an empty purse is drainable"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 0, "there was nothing to move"); + assert!(layer.store().purse(savings).is_none()); + let items: Vec = block_on(started.status.collect()); + assert!( + matches!(items.last(), Some(OperationStatus::Done(_))), + "having nothing to do is success, not failure: {items:?}" + ); + } + + #[test] + fn a_purse_holding_value_that_cannot_move_yet_is_not_closed_around_it() { + // The dangerous version of this: close the purse, drop the records, and + // leave a coin on chain nobody can find without a seed rescan. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let savings = + block_on(layer.create_purse(&storage, "Savings".to_string(), NOW)).expect("creates"); + // A coin the chain has not confirmed yet: real value, not spendable. + layer + .store_mut() + .add_pending_coin(savings, exponent(4)) + .expect("purse exists"); + + let refused = layer + .begin_purse_deletion(savings, PurseId::MAIN, true, NOW) + .expect_err("the purse still holds value that cannot move"); + + assert!(matches!(refused, CoinageError::NoReadyEntries { .. })); + assert!(layer.store().purse(savings).is_some()); + } + + #[test] + fn the_main_purse_cannot_be_deleted() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + + assert_eq!( + layer + .begin_purse_deletion(PurseId::MAIN, PurseId::MAIN, true, NOW) + .expect_err("the main purse exists by construction"), + CoinageError::CannotDeleteMainPurse + ); + } + + #[test] + fn a_purse_with_an_operation_in_flight_cannot_be_deleted() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let savings = + block_on(layer.create_purse(&storage, "Savings".to_string(), NOW)).expect("creates"); + fund(&mut layer, savings, 4); + let _started = layer + .begin_transfer( + savings, + Amount::from_cents(16), + vec![recipient(4, 0xaa)], + true, + None, + NOW, + ) + .expect("16 cents are available"); + + let refused = layer + .begin_purse_deletion(savings, PurseId::MAIN, true, NOW) + .expect_err("something else is already spending from it"); + + assert_eq!(refused, CoinageError::PurseHasInFlightOperations); + } + + #[test] + fn a_drain_that_the_chain_refuses_leaves_the_purse_open() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let savings = + block_on(layer.create_purse(&storage, "Savings".to_string(), NOW)).expect("creates"); + let coin = fund(&mut layer, savings, 4); + let chain = FakeChain::new(Inclusion::Rejected); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_purse_deletion(savings, PurseId::MAIN, true, NOW) + .expect("the purse is drainable"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert!( + layer.store().purse(savings).is_some(), + "the value never moved, so the purse still holds it" + ); + assert_eq!( + layer + .store() + .coin(savings, coin) + .expect("record exists") + .state, + CoinState::Available, + "and it is spendable again, so a later attempt can retry" + ); + } + + // -- D3: export and import ---------------------------------------------- + + #[test] + fn exporting_a_coin_already_in_shape_hands_over_its_secret_with_no_extrinsic() { + // The point of the seam: control of a coin moves with its secret, so an + // export that needs no reshaping is free and instant. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let coin = fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_export(PurseId::MAIN, Amount::from_cents(16), true, NOW) + .expect("16 cents are available"); + let handle = started.handle; + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 0, "nothing had to move on chain"); + let exported: Vec = block_on(started.coins.collect()); + assert_eq!(exported.len(), 1); + assert_eq!(exported[0].exponent, exponent(4)); + assert_eq!( + exported[0].account, + derivation::coin_account_id(&ENTROPY, PurseId::MAIN, coin).expect("derives") + ); + // The secret really controls the account it is offered with. + let keypair = derivation::coin_keypair(&ENTROPY, PurseId::MAIN, coin).expect("derives"); + assert_eq!(exported[0].secret, CoinSecret(keypair.secret.to_bytes())); + + // And the coin is gone from this layer's point of view, so selection will + // not offer it again. + assert_eq!( + layer + .store() + .coin(PurseId::MAIN, coin) + .expect("record kept") + .state, + CoinState::Spent + ); + assert_eq!( + layer + .store() + .balance(PurseId::MAIN, NOW) + .expect("purse exists") + .spendable, + Amount::ZERO + ); + } + + #[test] + fn an_export_that_needs_reshaping_emits_only_after_definite_success() { + // A secret handed out on optimistic inclusion would name a coin a reorg + // could remove, so the split's output waits for finalized state. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_export(PurseId::MAIN, Amount::from_cents(8), true, NOW) + .expect("a 16-cent coin can export 8"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1, "one split"); + let exported: Vec = block_on(started.coins.collect()); + assert_eq!(exported.len(), 1, "only the exported half leaves"); + assert_eq!(exported[0].exponent, exponent(3)); + // The change stayed, and is ours. + let kept: Vec<_> = layer + .store() + .coins_in(PurseId::MAIN) + .into_iter() + .filter(|coin| coin.state != CoinState::Spent) + .collect(); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].exponent, exponent(3)); + } + + #[test] + fn an_export_whose_transaction_is_refused_hands_out_nothing() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 4); + let chain = FakeChain::new(Inclusion::Rejected); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_export(PurseId::MAIN, Amount::from_cents(8), true, NOW) + .expect("a 16-cent coin can export 8"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + let exported: Vec = block_on(started.coins.collect()); + assert!( + exported.is_empty(), + "the coin was never minted, so there is no secret to give" + ); + } + + #[test] + fn an_export_beyond_the_purses_means_is_refused_synchronously() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 2); + + let refused = layer + .begin_export(PurseId::MAIN, Amount::from_cents(64), true, NOW) + .expect_err("four cents cannot export sixty-four"); + + assert!(matches!(refused, CoinageError::InsufficientFunds { .. })); + } + + #[test] + fn importing_a_coin_moves_it_into_our_namespace_under_a_fresh_index() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + // A coin held under a secret nobody in this layer derived. + let (account, secret) = foreign_coin([0x33; 32]); + chain.set_storage( + &storage::coins_by_owner_key(&account), + chain_coin(exponent(4), CoinAge(2)), + ); + + let started = block_on(layer.begin_import( + &context(&rpc, &metadata), + PurseId::MAIN, + vec![(account, secret)], + )) + .expect("the chain holds the coin the secret controls"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1); + let received = layer.store().coins_in(PurseId::MAIN); + assert_eq!(received.len(), 1); + assert_eq!( + received[0].exponent, + exponent(4), + "the denomination came from the chain, not from the caller" + ); + // The destination is ours, derived, and distinct from where the coin was. + assert_ne!( + derivation::coin_account_id(&ENTROPY, PurseId::MAIN, received[0].index) + .expect("derives"), + account + ); + let items: Vec = block_on(started.status.collect()); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn a_secret_that_does_not_control_its_account_is_refused() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let (_, secret) = foreign_coin([0x44; 32]); + + let refused = block_on(layer.begin_import( + &context(&rpc, &metadata), + PurseId::MAIN, + vec![(CoinAccountId([0xff; 32]), secret)], + )) + .expect_err("the secret controls a different account"); + + assert_eq!(refused, CoinageError::BadCoinSecret); + assert!( + layer.store().coins_in(PurseId::MAIN).is_empty(), + "and no record was allocated on the way to finding out" + ); + } + + #[test] + fn importing_a_coin_the_layer_already_holds_is_refused() { + // Two records for one account would leave one of them a ghost: spending + // through either would make the other unspendable without explanation. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let mine = fund(&mut layer, PurseId::MAIN, 4); + let account = derivation::coin_account_id(&ENTROPY, PurseId::MAIN, mine).expect("derives"); + let keypair = derivation::coin_keypair(&ENTROPY, PurseId::MAIN, mine).expect("derives"); + + let refused = block_on(layer.begin_import( + &context(&rpc, &metadata), + PurseId::MAIN, + vec![(account, CoinSecret(keypair.secret.to_bytes()))], + )) + .expect_err("this coin is already ours"); + + assert_eq!(refused, CoinageError::BadCoinSecret); + } + + #[test] + fn importing_a_coin_the_chain_does_not_hold_is_refused() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let (account, secret) = foreign_coin([0x55; 32]); + + let refused = block_on(layer.begin_import( + &context(&rpc, &metadata), + PurseId::MAIN, + vec![(account, secret)], + )) + .expect_err("there is no coin at that account"); + + assert_eq!(refused, CoinageError::BadCoinSecret); + } + + #[test] + fn an_import_forgets_the_secrets_it_was_handed() { + // §8.5. Holding them after submission keeps spendable material alive for + // no purpose. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let (account, secret) = foreign_coin([0x66; 32]); + chain.set_storage( + &storage::coins_by_owner_key(&account), + chain_coin(exponent(3), CoinAge(0)), + ); + + let started = block_on(layer.begin_import( + &context(&rpc, &metadata), + PurseId::MAIN, + vec![(account, secret)], + )) + .expect("the chain holds the coin"); + assert!(layer.import_secret(started.handle, 0).is_some()); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert!( + layer.import_secret(started.handle, 0).is_none(), + "the secrets are gone once nothing else will be signed with them" + ); + } + + /// A coin held under a secret this layer never derived. + fn foreign_coin(seed: [u8; 32]) -> (CoinAccountId, CoinSecret) { + let mini = schnorrkel::MiniSecretKey::from_bytes(&seed).expect("32 bytes"); + let keypair = mini.expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); + ( + CoinAccountId(keypair.public.to_bytes()), + CoinSecret(keypair.secret.to_bytes()), + ) + } + + /// `Coinage::CoinsByOwner`'s value: the denomination and the age. + fn chain_coin(exponent: DenominationExponent, age: CoinAge) -> Vec { + storage::ChainCoin { + value: exponent.get(), + age: age.0, + } + .encode() + } + + // -- the tick entry point ------------------------------------------------ + + #[test] + fn a_tick_recycles_an_aging_coin_without_the_caller_naming_a_sweep() { + // The whole point of the entry point: a host that knows nothing about + // sweeps, ages or rings gets the layer's autonomous behaviour by calling one + // method on a timer. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let old = fund_aged(&mut layer, PurseId::MAIN, 4, CoinAge(14)); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let outcome = block_on(layer.tick(&storage, &context(&rpc, &metadata), NOW)) + .expect("the tick succeeds"); + + assert!(outcome.swept, "an aging coin was due"); + assert_eq!(chain.submission_count(), 1); + assert_eq!( + layer + .store() + .coin(PurseId::MAIN, old) + .expect("record kept") + .state, + CoinState::Spent + ); + } + + #[test] + fn a_tick_on_a_tidy_wallet_submits_nothing_and_still_advises_an_interval() { + // A quiet tick must be cheap, because the host is expected to call it + // regularly and most calls will find nothing to do. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let outcome = block_on(layer.tick(&storage, &context(&rpc, &metadata), NOW)) + .expect("the tick succeeds"); + + assert!(!outcome.swept, "nothing was due"); + assert_eq!(chain.submission_count(), 0, "and nothing was submitted"); + assert_eq!( + outcome.next_tick_after, + CoinageParameters::default().sweep_tick_interval(), + "a host still learns when to come back" + ); + } + + #[test] + fn ticking_repeatedly_is_harmless_because_scheduling_holds_no_state() { + // Called far more often than the advised interval, the second tick must find + // the work already done rather than redo it — which is what makes it safe + // for a host to tick on any schedule, and safe across a restart. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund_aged(&mut layer, PurseId::MAIN, 4, CoinAge(14)); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let context = context(&rpc, &metadata); + + let first = block_on(layer.tick(&storage, &context, NOW)).expect("ticks"); + let second = block_on(layer.tick(&storage, &context, NOW)).expect("ticks again"); + + assert!(first.swept); + assert!(!second.swept, "the coin is already an entry"); + assert_eq!( + chain.submission_count(), + 1, + "the second tick spends no unload token and no fee" + ); + } + + // -- D4: the two sweeps -------------------------------------------------- + + #[test] + fn an_aging_coin_is_recycled_into_an_entry() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let old = fund_aged(&mut layer, PurseId::MAIN, 4, CoinAge(14)); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_maintenance_sweep(None, NOW) + .expect("planning succeeds") + .expect("there is work to do"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1, "one load per aging coin"); + assert_eq!( + layer + .store() + .coin(PurseId::MAIN, old) + .expect("record kept") + .state, + CoinState::Spent, + "the coin became an entry" + ); + let entries = layer.store().entries_in(PurseId::MAIN); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].exponent, exponent(4)); + assert!( + entries[0].ready_at > NOW, + "a fresh entry waits out its jitter before it is selectable" + ); + } + + #[test] + fn a_young_coin_is_left_alone_and_the_sweep_reports_nothing_to_do() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund(&mut layer, PurseId::MAIN, 4); + + let nothing = layer + .begin_maintenance_sweep(None, NOW) + .expect("planning succeeds"); + + assert!( + nothing.is_none(), + "a tidy wallet costs no operation and no event" + ); + } + + #[test] + fn an_entry_near_ring_expiry_is_rescued_back_into_a_coin() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let entry = load_entry(&mut layer, &chain, ring()); + chain.set_storage( + &storage::system_account_key(&layer.fee_account()), + account_info(1_000_000), + ); + // The ring became immutable long enough ago that the margin has been + // crossed: the pallet destroys the backing value at expiry. + let expiring = NOW.saturating_sub( + layer.constants().recycler_expiration_time + - layer + .params() + .rescue_margin(layer.constants().recycler_expiration_time), + ); + layer + .store_mut() + .observe_entry_ring_immutability(PurseId::MAIN, entry, Some(expiring)) + .expect("entry exists"); + + let started = layer + .begin_maintenance_sweep(Some(vec![PurseId::MAIN]), NOW) + .expect("planning succeeds") + .expect("the entry is due"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1, "one unload per ring group"); + assert_eq!( + layer + .store() + .entry(PurseId::MAIN, entry) + .expect("record kept") + .local, + EntryLocalState::Consumed + ); + // The value came back as a coin of the same denomination, in the same + // purse. + let coins = layer.store().coins_in(PurseId::MAIN); + assert_eq!(coins.len(), 1); + assert_eq!(coins[0].exponent, exponent(4)); + } + + #[test] + fn an_entry_whose_ring_was_never_observed_is_not_rescued_and_that_is_not_reassurance() { + // The hazard §4 of the status doc names. An entry with no observed + // immutability has no deadline recorded, which is indistinguishable from a + // ring still accepting members — so the sweep declines, and a caller must + // not read that as "nothing is at risk". + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let entry = load_entry(&mut layer, &chain, ring()); + assert_eq!( + layer + .store() + .entry(PurseId::MAIN, entry) + .expect("exists") + .ring_immutable_since, + None, + "the premise: nobody observed when this ring became immutable" + ); + + let nothing = layer + .begin_maintenance_sweep(None, NOW) + .expect("planning succeeds"); + + assert!( + nothing.is_none(), + "silence here means 'no deadline known', not 'no deadline exists'" + ); + // Ageing the clock past any plausible expiry changes nothing, because the + // deadline is missing rather than distant. + let much_later = NOW.saturating_add(core::time::Duration::from_secs(10_000 * 24 * 3_600)); + assert!( + layer + .begin_maintenance_sweep(None, much_later) + .expect("planning succeeds") + .is_none() + ); + } + + #[test] + fn a_sweep_reports_what_it_achieved() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund_aged(&mut layer, PurseId::MAIN, 4, CoinAge(14)); + fund_aged(&mut layer, PurseId::MAIN, 3, CoinAge(15)); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let mut events = layer.subscribe_events(); + + let started = layer + .begin_maintenance_sweep(None, NOW) + .expect("planning succeeds") + .expect("two coins are due"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + let published: Vec = + core::iter::from_fn(|| futures::FutureExt::now_or_never(events.next()).flatten()) + .collect(); + assert!( + published + .iter() + .any(|event| matches!(event, LayerEvent::MaintenanceSweepStarted { .. })), + "{published:?}" + ); + assert!( + published.iter().any(|event| matches!( + event, + LayerEvent::MaintenanceSweepCompleted { + coins_recycled: 2, + entries_rescued: 0, + .. + } + )), + "{published:?}" + ); + } + + #[test] + fn a_sweep_holds_every_record_it_will_touch() { + // Two overlapping sweeps would submit two recycles for one coin, and the + // second would be refused after the first consumed it. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + fund_aged(&mut layer, PurseId::MAIN, 4, CoinAge(14)); + + let _first = layer + .begin_maintenance_sweep(None, NOW) + .expect("planning succeeds") + .expect("a coin is due"); + let second = layer.begin_maintenance_sweep(None, NOW); + + // The coin is locked, so the second sweep finds nothing due rather than + // planning the same work twice. + assert!(matches!(second, Ok(None)), "unexpected: {second:?}"); + } + + #[test] + fn a_sweep_of_a_purse_that_does_not_exist_is_refused() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + + assert_eq!( + layer + .begin_maintenance_sweep(Some(vec![PurseId(7)]), NOW) + .expect_err("there is no such purse"), + CoinageError::PurseNotFound(PurseId(7)) + ); + } + + #[test] + fn a_failed_recycle_leaves_the_coin_and_retires_the_entry_that_never_came() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let old = fund_aged(&mut layer, PurseId::MAIN, 4, CoinAge(14)); + let chain = FakeChain::new(Inclusion::Rejected); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_maintenance_sweep(None, NOW) + .expect("planning succeeds") + .expect("a coin is due"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!( + layer + .store() + .coin(PurseId::MAIN, old) + .expect("record exists") + .state, + CoinState::Available, + "the coin is still spendable, and a later sweep can retry" + ); + assert_eq!( + layer.store().entries_in(PurseId::MAIN)[0].local, + EntryLocalState::Consumed, + "the entry that never came to exist is retired, index and all" + ); + } + + /// A coin the chain reports at a given age. + fn fund_aged( + layer: &mut CoinageLayer, + purse: PurseId, + exponent_value: i8, + age: CoinAge, + ) -> CoinIndex { + let index = layer + .store_mut() + .add_pending_coin(purse, exponent(exponent_value)) + .expect("purse exists"); + layer + .store_mut() + .observe_coin(purse, index, age) + .expect("coin exists"); + index + } + + // -- D5: external offload ------------------------------------------------ + + #[test] + fn an_offload_from_a_ready_entry_pays_out_and_finishes() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let entry = load_entry(&mut layer, &chain, ring()); + chain.set_storage( + &storage::system_account_key(&layer.fee_account()), + account_info(1_000_000), + ); + + let started = layer + .begin_external_offload( + PurseId::MAIN, + Amount::from_cents(16), + CoinAccountId([0x77; 32]), + true, + ) + .expect("the purse exists"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1, "one group, one extrinsic"); + assert_eq!( + layer + .store() + .entry(PurseId::MAIN, entry) + .expect("record kept") + .local, + EntryLocalState::Consumed + ); + let items: Vec = block_on(started.status.collect()); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn an_offload_of_part_of_an_entry_reloads_the_surplus_as_entries() { + // §8.6's invariant: surplus must never land as a coin, because that would + // tie the entry-side anonymity set to a fresh account. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + load_entry(&mut layer, &chain, ring()); + chain.set_storage( + &storage::system_account_key(&layer.fee_account()), + account_info(1_000_000), + ); + + let started = layer + .begin_external_offload( + PurseId::MAIN, + Amount::from_cents(8), + CoinAccountId([0x77; 32]), + true, + ) + .expect("the purse exists"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + // The 8-cent remainder came back as an entry, not as a coin. + let entries = layer.store().entries_in(PurseId::MAIN); + let surplus: Vec<_> = entries + .iter() + .filter(|entry| entry.exponent == exponent(3)) + .collect(); + assert_eq!(surplus.len(), 1, "the surplus is an entry: {entries:?}"); + assert!( + layer.store().coins_in(PurseId::MAIN).is_empty(), + "and no coin was minted on the way out" + ); + } + + #[test] + fn an_offload_recycles_a_coin_first_when_no_entry_is_ready() { + // The loop's reason for existing: coins cannot be offboarded, so the + // operation turns one into an entry and looks again. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let coin = fund(&mut layer, PurseId::MAIN, 4); + chain.set_storage( + &storage::system_account_key(&layer.fee_account()), + account_info(1_000_000), + ); + // No jitter, so the entry the recycle produces is usable in the next phase. + layer.set_jitter_for_tests(core::time::Duration::ZERO); + + let started = layer + .begin_external_offload( + PurseId::MAIN, + Amount::from_cents(16), + CoinAccountId([0x77; 32]), + true, + ) + .expect("the purse exists"); + // The recycle's entry needs a ring on chain before it can be offboarded. + let entry = EntryIndex(0); + prepare_ring_for(&chain, entry, ring()); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!( + chain.submission_count(), + 2, + "one recycle, then one offboard: {:?}", + chain.calls().len() + ); + assert_eq!( + layer + .store() + .coin(PurseId::MAIN, coin) + .expect("record kept") + .state, + CoinState::Spent, + "the coin became the entry that was offboarded" + ); + let items: Vec = block_on(started.status.collect()); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn an_offload_beyond_the_purses_means_fails_on_the_status_stream() { + // Not a synchronous error: the operation started, looked, and found the + // purse could never cover it (§8.6 lists InsufficientFunds as terminal). + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + let started = layer + .begin_external_offload( + PurseId::MAIN, + Amount::from_cents(16), + CoinAccountId([0x77; 32]), + true, + ) + .expect("the purse exists"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 0); + let items: Vec = block_on(started.status.collect()); + assert!( + matches!( + items.last(), + Some(OperationStatus::Failed( + CoinageError::InsufficientFunds { .. } + )) + ), + "{items:?}" + ); + } + + #[test] + fn an_offload_waits_for_an_entry_that_is_still_ripening() { + // The entry covers the amount but is inside its decorrelation delay, so the + // operation reports Waiting rather than working around it. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let delay = core::time::Duration::from_secs(3_600); + let entry = layer + .store_mut() + .allocate_entry(PurseId::MAIN, exponent(4), NOW, delay) + .expect("purse exists"); + layer + .store_mut() + .observe_entry_ring( + PurseId::MAIN, + entry, + ring(), + 64, + &CoinageParameters::default(), + ) + .expect("entry exists"); + prepare_ring_for(&chain, entry, ring()); + chain.set_storage( + &storage::system_account_key(&layer.fee_account()), + account_info(1_000_000), + ); + + let started = layer + .begin_external_offload( + PurseId::MAIN, + Amount::from_cents(16), + CoinAccountId([0x77; 32]), + true, + ) + .expect("the purse exists"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + let items: Vec = block_on(started.status.collect()); + assert!( + items + .iter() + .any(|status| matches!(status, OperationStatus::Waiting(_))), + "the wait is reported so a caller can show it: {items:?}" + ); + // And once the delay has passed, the same operation offboards. + assert!( + chain.submission_count() >= 1, + "the wait resolved into an offboard" + ); + } + + #[test] + fn an_offload_from_a_purse_that_does_not_exist_is_refused() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + + assert_eq!( + layer + .begin_external_offload( + PurseId(9), + Amount::from_cents(16), + CoinAccountId([0x77; 32]), + true + ) + .expect_err("there is no such purse"), + CoinageError::PurseNotFound(PurseId(9)) + ); + } + + /// Tell the chain everything a read of one of our entries will ask for. + fn prepare_ring_for(chain: &FakeChain, entry: EntryIndex, ring: RingLocation) { + let member_key = + derivation::entry_member_key(&ENTROPY, PurseId::MAIN, entry).expect("derives"); + let alias = crate::runtime::coinage::proof::recycler_alias(&ENTROPY, PurseId::MAIN, entry) + .expect("derives"); + let mut members = vec![member_key]; + members.extend(fillers(15)); + + chain.place_entry_in_ring(exponent(4), member_key, alias, ring, &members, None); + set_personhood_ring(chain); + } + + // -- D6: payment classification ------------------------------------------ + + #[test] + fn a_memo_naming_only_our_accounts_is_matched() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let first = fund(&mut layer, PurseId::MAIN, 4); + let savings = + block_on(layer.create_purse(&storage, "Savings".to_string(), NOW)).expect("creates"); + let second = fund(&mut layer, savings, 3); + + let entries = vec![ + memo_for(PurseId::MAIN, first), + // Across purses: a payee holds accounts in more than one namespace. + memo_for(savings, second), + ]; + + assert_eq!( + layer.classify_incoming_payment(&entries), + PaymentClassification::Matched + ); + } + + #[test] + fn a_memo_naming_some_of_our_accounts_is_received() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let ours = fund(&mut layer, PurseId::MAIN, 4); + + let entries = vec![ + memo_for(PurseId::MAIN, ours), + MemoEntry { + sender_coin_account: CoinAccountId([1; 32]), + recipient_account: CoinAccountId([0xab; 32]), + derivation_index: CoinIndex(0), + }, + ]; + + assert_eq!( + layer.classify_incoming_payment(&entries), + PaymentClassification::Received, + "half a payment is not a whole one" + ); + } + + #[test] + fn a_memo_naming_nothing_of_ours_is_unmatched() { + let storage = MemStorage::default(); + let layer = layer(&storage); + + let entries = vec![MemoEntry { + sender_coin_account: CoinAccountId([1; 32]), + recipient_account: CoinAccountId([0xab; 32]), + derivation_index: CoinIndex(0), + }]; + + assert_eq!( + layer.classify_incoming_payment(&entries), + PaymentClassification::Unmatched + ); + } + + #[test] + fn an_empty_memo_is_unmatched() { + let storage = MemStorage::default(); + let layer = layer(&storage); + + assert_eq!( + layer.classify_incoming_payment(&[]), + PaymentClassification::Unmatched, + "nothing was claimed, so nothing matches" + ); + } + + #[test] + fn classification_touches_nothing() { + // §8.8: informational only. A memo must not be able to move a record or + // start an operation, because it arrives from whoever sent it. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let ours = fund(&mut layer, PurseId::MAIN, 4); + let before = layer.store().coins_in(PurseId::MAIN); + + let _ = layer.classify_incoming_payment(&[memo_for(PurseId::MAIN, ours)]); + + assert_eq!(layer.store().coins_in(PurseId::MAIN), before); + assert!(layer.store().open_operations().next().is_none()); + } + + /// A memo entry naming one of our own coin accounts. + fn memo_for(purse: PurseId, index: CoinIndex) -> MemoEntry { + MemoEntry { + sender_coin_account: CoinAccountId([9; 32]), + recipient_account: derivation::coin_account_id(&ENTROPY, purse, index) + .expect("derives"), + derivation_index: index, + } + } + + // -- D7: top-up ---------------------------------------------------------- + + #[test] + fn a_top_up_loads_one_entry_per_denomination_in_a_single_batch() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let origin = Arc::new(TestFunding::new([0x21; 32])); + + // 24 cents is 16 + 8: two entries, one extrinsic. + let started = layer + .begin_top_up(PurseId::MAIN, Amount::from_cents(24), origin.clone(), NOW) + .expect("the purse exists"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 1, "one batched extrinsic"); + let entries = layer.store().entries_in(PurseId::MAIN); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].exponent, exponent(4)); + assert_eq!(entries[1].exponent, exponent(3)); + let items: Vec = block_on(started.status.collect()); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn a_top_up_is_signed_by_the_account_holding_the_asset() { + // The layer holds nothing here: the value being converted is the caller's + // until the pallet turns it into entries. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let origin = Arc::new(TestFunding::new([0x21; 32])); + + let started = layer + .begin_top_up(PurseId::MAIN, Amount::from_cents(16), origin.clone(), NOW) + .expect("the purse exists"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(origin.signed(), 1, "the origin signed the extrinsic"); + assert_eq!( + origin.authorized(), + 1, + "and produced the value-transfer authorization the runtime gates on" + ); + let submitted = &chain.submitted()[0]; + assert!( + submitted + .windows(32) + .any(|window| window == origin.account().0), + "the signing account appears in the extrinsic's address field" + ); + } + + #[test] + fn a_top_up_entry_carries_its_own_readiness_delay() { + // §5.3: an entry usable the instant it is loaded would let an observer pair + // the load with the unload that follows it. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let origin = Arc::new(TestFunding::new([0x21; 32])); + + layer + .begin_top_up(PurseId::MAIN, Amount::from_cents(16), origin, NOW) + .expect("the purse exists"); + + let entries = layer.store().entries_in(PurseId::MAIN); + assert_eq!(entries.len(), 1); + assert!( + entries[0].ready_at >= NOW, + "a fresh entry is not selectable before its delay" + ); + assert!( + entries[0].ready_at + <= NOW.saturating_add(layer.params().recycler_entry_jitter_upper_bound), + "and the delay is inside the configured bound" + ); + } + + #[test] + fn a_top_up_needing_more_entries_than_the_runtime_batches_is_refused() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let origin = Arc::new(TestFunding::new([0x21; 32])); + // A cent count whose binary expansion is longer than MaxBatchUnpaidLoad. + let awkward = Amount::from_cents((1 << 12) - 1); + + let refused = layer + .begin_top_up(PurseId::MAIN, awkward, origin, NOW) + .expect_err("twelve denominations exceed a batch of ten"); + + assert!( + refused.to_string().contains("batches at most"), + "unexpected: {refused}" + ); + } + + #[test] + fn a_top_up_into_a_purse_that_does_not_exist_is_refused() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let origin = Arc::new(TestFunding::new([0x21; 32])); + + assert_eq!( + layer + .begin_top_up(PurseId(9), Amount::from_cents(16), origin, NOW) + .expect_err("there is no such purse"), + CoinageError::PurseNotFound(PurseId(9)) + ); + } + + #[test] + fn a_refused_top_up_retires_the_entries_that_never_came() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::new(Inclusion::Rejected); + let rpc = chain.rpc(); + let metadata = metadata(); + let origin = Arc::new(TestFunding::new([0x21; 32])); + + let started = layer + .begin_top_up(PurseId::MAIN, Amount::from_cents(16), origin, NOW) + .expect("the purse exists"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!( + layer.store().entries_in(PurseId::MAIN)[0].local, + EntryLocalState::Consumed, + "the entry that was never created is retired, index and all" + ); + assert_eq!( + layer + .store() + .balance(PurseId::MAIN, NOW) + .expect("purse exists") + .pending, + Amount::ZERO, + "and the purse does not claim value that never arrived" + ); + } + + /// A funding origin that signs with a throwaway key and counts what it was + /// asked for. + struct TestFunding { + keypair: schnorrkel::Keypair, + signed: StdMutex, + authorized: StdMutex, + } + + impl TestFunding { + fn new(seed: [u8; 32]) -> Self { + let mini = schnorrkel::MiniSecretKey::from_bytes(&seed).expect("32 bytes"); + Self { + keypair: mini.expand_to_keypair(schnorrkel::ExpansionMode::Ed25519), + signed: StdMutex::new(0), + authorized: StdMutex::new(0), + } + } + + fn account(&self) -> CoinAccountId { + CoinAccountId(self.keypair.public.to_bytes()) + } + + fn signed(&self) -> usize { + *self.signed.lock().unwrap() + } + + fn authorized(&self) -> usize { + *self.authorized.lock().unwrap() + } + } + + impl FundingOrigin for TestFunding { + fn external_account(&self) -> CoinAccountId { + self.account() + } + + fn sign(&self, payload: &[u8]) -> [u8; 64] { + *self.signed.lock().unwrap() += 1; + self.keypair + .sign_simple( + crate::host_logic::product_account::SR25519_SIGNING_CONTEXT, + payload, + ) + .to_bytes() + } + + fn authorize_value_transfer(&self, _message: &[u8; 32]) -> Option<[u8; 64]> { + *self.authorized.lock().unwrap() += 1; + Some([7u8; 64]) + } + } + + // -- D8: wallet recovery from root entropy ------------------------------- + + #[test] + fn recovery_rebuilds_a_wallet_from_the_chain_alone() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + // A coin and an entry the chain holds under our derivation, with no local + // record of either: durable state is gone. + let account = + derivation::coin_account_id(&ENTROPY, PurseId::MAIN, CoinIndex(0)).expect("derives"); + chain.set_storage( + &storage::coins_by_owner_key(&account), + chain_coin(exponent(4), CoinAge(2)), + ); + let member_key = + derivation::entry_member_key(&ENTROPY, PurseId::MAIN, EntryIndex(0)).expect("derives"); + let alias = + crate::runtime::coinage::proof::recycler_alias(&ENTROPY, PurseId::MAIN, EntryIndex(0)) + .expect("derives"); + let mut members = vec![member_key]; + members.extend(fillers(15)); + chain.place_entry_in_ring(exponent(3), member_key, alias, ring(), &members, None); + + layer.set_recovery_limits_for_tests(4, 2); + let mut events = layer.subscribe_events(); + let started = layer.begin_recovery(Vec::new()).expect("starts"); + + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert_eq!(chain.submission_count(), 0, "recovery writes nothing"); + let coins = layer.store().coins_in(PurseId::MAIN); + assert_eq!(coins.len(), 1); + assert_eq!(coins[0].exponent, exponent(4)); + assert_eq!(coins[0].age, CoinAge(2)); + let entries = layer.store().entries_in(PurseId::MAIN); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].exponent, exponent(3)); + // Observation ran over what the scan found, so the entry knows its ring. + assert_eq!(entries[0].ring.map(|ring| ring.index), Some(ring().index)); + + let published: Vec = + core::iter::from_fn(|| futures::FutureExt::now_or_never(events.next()).flatten()) + .collect(); + assert!( + published + .iter() + .any(|event| matches!(event, LayerEvent::CoinAvailable { .. })), + "per-record discovery is observable: {published:?}" + ); + assert!( + published + .iter() + .any(|event| matches!(event, LayerEvent::EntryAllocated { .. })) + ); + // Reconstruction ends with Resynced, so a subscriber can tell rebuilt + // state from the live changes that follow. It comes after every restored + // record and before the operation's own completion. + let resynced = published + .iter() + .position(|event| matches!(event, LayerEvent::Resynced)) + .expect("reconstruction is closed off"); + let last_record = published + .iter() + .rposition(|event| { + matches!( + event, + LayerEvent::CoinAvailable { .. } | LayerEvent::EntryAllocated { .. } + ) + }) + .expect("records were restored"); + assert!(resynced > last_record, "{published:?}"); + + let items: Vec = block_on(started.status.collect()); + assert_eq!( + items.first(), + Some(&OperationStatus::Preparing), + "no extrinsic, so the status goes straight to terminal: {items:?}" + ); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn recovery_restores_a_named_purse_at_its_own_identifier() { + // The identifier is the derivation namespace, so a recovered purse cannot + // be given a fresh one. + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + let savings = PurseId(4); + let account = + derivation::coin_account_id(&ENTROPY, savings, CoinIndex(0)).expect("derives"); + chain.set_storage( + &storage::coins_by_owner_key(&account), + chain_coin(exponent(4), CoinAge(0)), + ); + + layer.set_recovery_limits_for_tests(4, 2); + let started = layer.begin_recovery(vec![savings]).expect("starts"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert!(layer.store().purse(savings).is_some()); + assert_eq!(layer.store().coins_in(savings).len(), 1); + // And a purse created afterwards cannot collide with the restored one. + let fresh = + block_on(layer.create_purse(&storage, "Later".to_string(), NOW)).expect("creates"); + assert!(fresh.0 > savings.0, "{fresh:?}"); + } + + #[test] + fn an_empty_wallet_recovers_to_nothing_and_still_says_it_finished() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + + layer.set_recovery_limits_for_tests(4, 2); + let started = layer.begin_recovery(Vec::new()).expect("starts"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), started.handle, NOW)) + .expect("drives"); + + assert!(layer.store().coins_in(PurseId::MAIN).is_empty()); + let items: Vec = block_on(started.status.collect()); + assert!(matches!(items.last(), Some(OperationStatus::Done(_)))); + } + + #[test] + fn extending_a_scan_reaches_records_the_gap_limit_hid() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + let chain = FakeChain::default(); + let rpc = chain.rpc(); + let metadata = metadata(); + // Just past a narrow window's reach from zero: 4 * 2 batches of 4. + layer.set_recovery_limits_for_tests(4, 2); + let far = CoinIndex(20); + let account = derivation::coin_account_id(&ENTROPY, PurseId::MAIN, far).expect("derives"); + chain.set_storage( + &storage::coins_by_owner_key(&account), + chain_coin(exponent(4), CoinAge(0)), + ); + + let first = layer.begin_recovery(Vec::new()).expect("starts"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), first.handle, NOW)) + .expect("drives"); + assert!( + layer.store().coins_in(PurseId::MAIN).is_empty(), + "the gap swallowed it" + ); + + let extended = layer + .begin_extend_scan(PurseId::MAIN, CoinIndex(16), EntryIndex(0)) + .expect("the purse exists"); + block_on(layer.drive_operation(&storage, &context(&rpc, &metadata), extended.handle, NOW)) + .expect("drives"); + + assert_eq!( + layer.store().coins_in(PurseId::MAIN)[0].index, + far, + "resuming past the gap finds it" + ); + } + + #[test] + fn extending_a_scan_of_a_purse_that_does_not_exist_is_refused() { + let storage = MemStorage::default(); + let mut layer = layer(&storage); + + assert_eq!( + layer + .begin_extend_scan(PurseId(9), CoinIndex(0), EntryIndex(0)) + .expect_err("there is no such purse"), + CoinageError::PurseNotFound(PurseId(9)) + ); + } + + // -- chain-state fixtures ------------------------------------------------ + + /// Unrelated but *valid* ring members, to pad a ring out. + /// + /// Filler bytes would not do: the prover reconstructs the ring commitment + /// from the member list, so every entry has to be a real bandersnatch key. + fn fillers(count: u8) -> Vec<[u8; 32]> { + use verifiable::GenerateVerifiable; + use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + + (1..=count) + .map(|byte| { + let secret = BandersnatchVrfVerifiable::new_secret([byte; 32]); + BandersnatchVrfVerifiable::member_from_secret(&secret) + }) + .collect() + } + + /// `AccountInfo` with a free balance, for the fee account. + fn account_info(free: u128) -> Vec { + let mut encoded = 0u32.encode(); + encoded.extend(0u32.encode()); + encoded.extend(1u32.encode()); + encoded.extend(0u32.encode()); + encoded.extend(free.encode()); + encoded.extend(0u128.encode()); + encoded.extend(0u128.encode()); + encoded.extend(0u128.encode()); + encoded + } + + /// The LitePeople ring, so a free unload token can be proven. + /// + /// A different ring and a different key from any recycler entry: the token + /// proves personhood, not ownership of the entries being unloaded. + fn set_personhood_ring(chain: &FakeChain) { + let personhood = crate::runtime::statement_allowance::proof::member_key( + crate::runtime::statement_allowance::bandersnatch_entropy(&ENTROPY), + ); + let mut members = vec![personhood]; + members.extend(fillers(9)); + let members = &members[..]; + use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; + + let identifier: &[u8; 32] = b"pop:polkadot.network/people-lite"; + let concat = |x: &[u8]| [blake2_128(x).as_slice(), x].concat(); + let twox_concat = |x: &[u8]| [twox_64(x).as_slice(), x].concat(); + + chain.set_storage( + &[ + twox_128(b"Members").as_slice(), + twox_128(b"Collections").as_slice(), + identifier.as_slice(), + ] + .concat(), + collection_info(9), + ); + chain.set_storage( + &[ + twox_128(b"Members").as_slice(), + twox_128(b"CurrentRingIndex").as_slice(), + identifier.as_slice(), + ] + .concat(), + 0u32.encode(), + ); + chain.set_storage( + &[ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeys").as_slice(), + identifier.as_slice(), + &concat(&0u32.to_le_bytes()), + &twox_concat(&0u32.to_le_bytes()), + ] + .concat(), + ring_page(members), + ); + chain.set_storage( + &[ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + identifier.as_slice(), + &concat(&0u32.to_le_bytes()), + ] + .concat(), + ring_status(members.len() as u32, None), + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/extension.rs b/rust/crates/truapi-server/src/runtime/coinage/extension.rs new file mode 100644 index 000000000..1d334cee8 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/extension.rs @@ -0,0 +1,466 @@ +//! The `AsCoinage` transaction extension. +//! +//! Coinage calls do not carry a conventional signed origin. The extension +//! transmutes the transaction's origin into `Origin::Coin` or +//! `Origin::UnloadToken`, consuming the coin or the unload token as it does so — +//! *before* dispatch. That ordering is why the rest of this layer validates so +//! much locally: once the extension has run, a call that fails has already cost +//! the coin. +//! +//! The extra is `AsCoinage(Option)`. This module builds those +//! bytes. Two things about the encoding are worth stating plainly, because +//! getting either wrong yields an extrinsic the chain rejects with no useful +//! diagnosis: +//! +//! * The variant index is resolved from metadata by name, never hard-coded. +//! SCALE variant indices are positional and a runtime upgrade may reorder +//! them. +//! * Ring-VRF proofs pass through verbatim. They are runtime-specific types +//! whose layout this crate does not model, so they arrive already encoded and +//! are spliced in without a wrapping length prefix. + +use parity_scale_codec::Encode; + +use super::call::RawEncoded; +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::types::{DenominationExponent, RingLocation}; +use crate::runtime::statement_allowance::extension::Metadata; + +/// Transaction-extension identifier as it appears in runtime metadata. +pub const AS_COINAGE: &str = "AsCoinage"; + +/// Signing context prefix for the personhood proof backing a free unload token. +/// +/// The full context is this prefix followed by the period and counter as +/// little-endian `u32`s. +pub const UNLOAD_TOKEN_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/coinftk"; + +/// Signing context prefix for the membership proof backing a paid unload token. +/// +/// The full context is this prefix followed by the period as a little-endian +/// `u32` — and nothing else. There is no counter, which is what makes one paid +/// member key worth exactly one token per period. +pub const PAID_UNLOAD_TOKEN_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/coinpaidtok"; + +/// Alias context for a recycler entry's contextual alias. +pub const RECYCLER_ALIAS_CONTEXT: &[u8] = b"pop:polkadot.network/coinrecyclr"; + +/// Which membership ring backs a free unload token. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FreeTokenRing { + /// Full personhood. + People, + /// Lite personhood. + LitePeople, +} + +impl FreeTokenRing { + /// The `AsCoinageInfo` variant name for this ring. + pub const fn variant_name(self) -> &'static str { + match self { + Self::People => "AsUnloadTokenPeople", + Self::LitePeople => "AsUnloadTokenLitePeople", + } + } +} + +/// How the origin for a coinage call should be obtained. +/// +/// Mirrors the pallet's `AsCoinageInfo`. Payload field order matches the +/// pallet's declaration, because SCALE encodes struct variants positionally. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AsCoinageInfo { + /// Transmute the signed origin into `Origin::Coin`, for `split`, `transfer` + /// and `load_recycler_with_coin`. + AsCoin, + /// Transmute the unsigned origin into `Origin::UnloadToken` using a free + /// token backed by personhood. + FreeUnloadToken { + /// Which membership ring backs the token. + ring: FreeTokenRing, + /// Personhood membership proof. + proof: RawEncoded, + /// Token period. + period: u32, + /// Counter within the period. + counter: u32, + /// One alias proof per entry being unloaded. + alias_proofs: Vec, + }, + /// Transmute the unsigned origin using a token from the period's paid ring. + PaidUnloadToken { + /// Paid-ring membership proof. + proof: RawEncoded, + /// Token period. + period: u32, + /// Paid-token ring the proof was built against. + ring: RingLocation, + /// One alias proof per entry being unloaded. + alias_proofs: Vec, + }, + /// Transmute the unsigned origin with the fee taken from the unloaded value. + /// + /// The first alias proof is pre-validated by the extension for spam + /// protection and must be skipped by call-level validation, so ordering + /// here is load-bearing: the fee recycler's proof comes first. + UnloadTokenFromOutput { + /// Denomination of the fee recycler, which must equal the first input's. + fee_recycler_value: DenominationExponent, + /// Ring of the fee recycler, which must equal the first input's. + fee_recycler_ring: RingLocation, + /// Retry counter for the extension's backoff. + retry_counter: u8, + /// One alias proof per entry, fee recycler first. + alias_proofs: Vec, + }, + /// A conventional signed origin that the pallet guarantees will not fail + /// before dispatch. + InfallibleUnpaidSigned { + /// Account nonce. + nonce: u32, + }, +} + +impl AsCoinageInfo { + /// The pallet's variant name, used to resolve the index from metadata. + pub const fn variant_name(&self) -> &'static str { + match self { + Self::AsCoin => "AsCoin", + Self::FreeUnloadToken { ring, .. } => ring.variant_name(), + Self::PaidUnloadToken { .. } => "AsUnloadTokenPaid", + Self::UnloadTokenFromOutput { .. } => "AsUnloadTokenFromOutput", + Self::InfallibleUnpaidSigned { .. } => "InfallibleUnpaidSigned", + } + } + + /// The alias proofs this variant carries, if any. + pub fn alias_proofs(&self) -> &[RawEncoded] { + match self { + Self::FreeUnloadToken { alias_proofs, .. } + | Self::PaidUnloadToken { alias_proofs, .. } + | Self::UnloadTokenFromOutput { alias_proofs, .. } => alias_proofs, + Self::AsCoin | Self::InfallibleUnpaidSigned { .. } => &[], + } + } + + /// The variant's payload, without the leading variant index. + fn encode_payload(&self) -> Vec { + match self { + Self::AsCoin => Vec::new(), + Self::FreeUnloadToken { + ring: _, + proof, + period, + counter, + alias_proofs, + } => { + let mut encoded = proof.encode(); + encoded.extend(period.encode()); + encoded.extend(counter.encode()); + encoded.extend(alias_proofs.encode()); + encoded + } + Self::PaidUnloadToken { + proof, + period, + ring, + alias_proofs, + } => { + let mut encoded = proof.encode(); + encoded.extend(period.encode()); + encoded.extend(ring.index.0.encode()); + encoded.extend(ring.revision.0.encode()); + encoded.extend(alias_proofs.encode()); + encoded + } + Self::UnloadTokenFromOutput { + fee_recycler_value, + fee_recycler_ring, + retry_counter, + alias_proofs, + } => { + let mut encoded = fee_recycler_value.get().encode(); + encoded.extend(fee_recycler_ring.index.0.encode()); + encoded.extend(fee_recycler_ring.revision.0.encode()); + encoded.extend(retry_counter.encode()); + encoded.extend(alias_proofs.encode()); + encoded + } + Self::InfallibleUnpaidSigned { nonce } => nonce.encode(), + } + } + + /// Encode the extension's extra: `Some(info)` with the index resolved from + /// metadata. + pub fn encode_extra(&self, metadata: &Metadata) -> Result, CoinageError> { + let index = metadata + .extension_info_variant_index(AS_COINAGE, self.variant_name()) + .map_err(|error| { + CoinageError::Internal(format!("resolving {AS_COINAGE} variant failed: {error}")) + })?; + + Ok(self.encode_extra_with_index(index)) + } + + /// Encode the extra against an already-resolved variant index. + /// + /// The leading `1` is the `Option`'s `Some` discriminant. + pub fn encode_extra_with_index(&self, variant_index: u8) -> Vec { + let mut encoded = vec![1u8, variant_index]; + encoded.extend(self.encode_payload()); + encoded + } +} + +/// The extra for a transaction that declares no coinage origin. +pub fn encode_absent_extra() -> Vec { + vec![0u8] +} + +/// The signing context for a free unload token's personhood proof. +/// +/// `prefix ++ period_le ++ counter_le`. +pub fn free_token_signing_context(period: u32, counter: u32) -> Vec { + let mut context = UNLOAD_TOKEN_CONTEXT_PREFIX.to_vec(); + context.extend(period.to_le_bytes()); + context.extend(counter.to_le_bytes()); + context +} + +/// The signing context for a paid unload token's membership proof. +/// +/// `prefix ++ period_le`. Both prefixes are sized so that the context is exactly +/// 32 bytes — 24 + 4 + 4 for the free one, 28 + 4 here — so the missing counter is +/// not padding the layer may add back. +pub fn paid_token_signing_context(period: u32) -> Vec { + let mut context = PAID_UNLOAD_TOKEN_CONTEXT_PREFIX.to_vec(); + context.extend(period.to_le_bytes()); + context +} + +/// The message a free unload token's personhood proof signs. +/// +/// `blake2_256(alias_proofs.encode() ++ inherited_implication)`. The alias +/// proofs are inside the signed message, which is what binds the token to the +/// exact set of entries being unloaded. +pub fn unload_token_proof_message( + alias_proofs: &[RawEncoded], + inherited_implication: &[u8], +) -> [u8; 32] { + let mut message = alias_proofs.encode(); + message.extend_from_slice(inherited_implication); + crate::runtime::statement_allowance::extension::blake2b256(&message) +} + +/// The message an individual alias proof signs: `blake2_256(inherited_implication)`. +pub fn alias_proof_message(inherited_implication: &[u8]) -> [u8; 32] { + crate::runtime::statement_allowance::extension::blake2b256(inherited_implication) +} + +#[cfg(test)] +mod tests { + use crate::host_logic::coinage::types::{RevisionIndex, RingIndex}; + + use super::*; + + fn proof(byte: u8) -> RawEncoded { + RawEncoded(vec![byte; 4]) + } + + fn ring() -> RingLocation { + RingLocation::new(RingIndex(7), RevisionIndex(3)) + } + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + #[test] + fn an_absent_extra_is_the_option_none_byte() { + assert_eq!(encode_absent_extra(), vec![0u8]); + } + + #[test] + fn as_coin_carries_no_payload() { + let extra = AsCoinageInfo::AsCoin.encode_extra_with_index(0); + + // `Some`, then the variant index, and nothing more. + assert_eq!(extra, vec![1u8, 0]); + } + + #[test] + fn the_infallible_signed_variant_matches_the_known_layout() { + // Cross-check against the shape the CLI already submits: Some, variant + // index, then the nonce as a little-endian u32. + let extra = AsCoinageInfo::InfallibleUnpaidSigned { nonce: 5 }.encode_extra_with_index(5); + + assert_eq!(extra, vec![1u8, 5, 5, 0, 0, 0]); + } + + #[test] + fn a_free_token_encodes_proof_then_period_counter_then_aliases() { + let info = AsCoinageInfo::FreeUnloadToken { + ring: FreeTokenRing::People, + proof: proof(0xAA), + period: 1, + counter: 2, + alias_proofs: vec![proof(0xBB), proof(0xCC)], + }; + + let extra = info.encode_extra_with_index(1); + let mut expected = vec![1u8, 1]; + expected.extend([0xAA; 4]); // proof, spliced verbatim + expected.extend(1u32.encode()); // period + expected.extend(2u32.encode()); // counter + expected.extend(vec![proof(0xBB), proof(0xCC)].encode()); // compact len + blobs + + assert_eq!(extra, expected); + } + + #[test] + fn alias_proofs_carry_a_compact_length_but_the_blobs_do_not() { + let proofs = vec![proof(1), proof(2)]; + let encoded = proofs.encode(); + + // Compact(2) then two 4-byte blobs, with no per-blob prefix. + assert_eq!(encoded.len(), 1 + 8); + assert_eq!(encoded[0], 8); // compact encoding of 2 + assert_eq!(&encoded[1..5], &[1u8; 4]); + } + + #[test] + fn the_two_free_token_rings_resolve_to_different_variants() { + assert_eq!(FreeTokenRing::People.variant_name(), "AsUnloadTokenPeople"); + assert_eq!( + FreeTokenRing::LitePeople.variant_name(), + "AsUnloadTokenLitePeople" + ); + } + + #[test] + fn a_paid_token_encodes_both_halves_of_its_ring() { + let info = AsCoinageInfo::PaidUnloadToken { + proof: proof(1), + period: 9, + ring: ring(), + alias_proofs: vec![proof(2)], + }; + + let extra = info.encode_extra_with_index(3); + let mut expected = vec![1u8, 3]; + expected.extend([1u8; 4]); + expected.extend(9u32.encode()); + expected.extend(7u32.encode()); // ring index + expected.extend(3u32.encode()); // ring revision + expected.extend(vec![proof(2)].encode()); + + assert_eq!(extra, expected); + } + + #[test] + fn from_output_encodes_the_fee_recycler_before_the_retry_counter() { + let info = AsCoinageInfo::UnloadTokenFromOutput { + fee_recycler_value: exponent(4), + fee_recycler_ring: ring(), + retry_counter: 2, + alias_proofs: vec![proof(5)], + }; + + let extra = info.encode_extra_with_index(4); + let mut expected = vec![1u8, 4]; + expected.extend(4i8.encode()); // fee recycler denomination + expected.extend(7u32.encode()); + expected.extend(3u32.encode()); + expected.extend(2u8.encode()); // retry counter + expected.extend(vec![proof(5)].encode()); + + assert_eq!(extra, expected); + } + + #[test] + fn variant_names_match_the_pallet() { + assert_eq!(AsCoinageInfo::AsCoin.variant_name(), "AsCoin"); + assert_eq!( + AsCoinageInfo::InfallibleUnpaidSigned { nonce: 0 }.variant_name(), + "InfallibleUnpaidSigned" + ); + assert_eq!( + AsCoinageInfo::PaidUnloadToken { + proof: proof(0), + period: 0, + ring: ring(), + alias_proofs: Vec::new(), + } + .variant_name(), + "AsUnloadTokenPaid" + ); + } + + #[test] + fn only_unload_variants_carry_alias_proofs() { + assert!(AsCoinageInfo::AsCoin.alias_proofs().is_empty()); + assert!( + AsCoinageInfo::InfallibleUnpaidSigned { nonce: 0 } + .alias_proofs() + .is_empty() + ); + assert_eq!( + AsCoinageInfo::FreeUnloadToken { + ring: FreeTokenRing::LitePeople, + proof: proof(0), + period: 0, + counter: 0, + alias_proofs: vec![proof(1), proof(2)], + } + .alias_proofs() + .len(), + 2 + ); + } + + #[test] + fn the_free_token_context_appends_period_then_counter() { + let context = free_token_signing_context(0x0102_0304, 0x0506_0708); + + assert_eq!( + &context[..UNLOAD_TOKEN_CONTEXT_PREFIX.len()], + UNLOAD_TOKEN_CONTEXT_PREFIX + ); + assert_eq!( + &context[UNLOAD_TOKEN_CONTEXT_PREFIX.len()..], + &[0x04, 0x03, 0x02, 0x01, 0x08, 0x07, 0x06, 0x05] + ); + } + + #[test] + fn the_free_token_message_binds_the_alias_set() { + // Changing which entries are being unloaded must change the message the + // personhood proof signs, or a token could be replayed against a + // different set. + let implication = [9u8; 8]; + let one = unload_token_proof_message(&[proof(1)], &implication); + let two = unload_token_proof_message(&[proof(1), proof(2)], &implication); + + assert_ne!(one, two); + } + + #[test] + fn the_free_token_message_also_binds_the_implication() { + let proofs = [proof(1)]; + + assert_ne!( + unload_token_proof_message(&proofs, &[1u8; 8]), + unload_token_proof_message(&proofs, &[2u8; 8]) + ); + } + + #[test] + fn an_alias_proof_signs_the_bare_implication() { + let implication = [4u8; 8]; + + assert_eq!( + alias_proof_message(&implication), + crate::runtime::statement_allowance::extension::blake2b256(&implication) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/extrinsic.rs b/rust/crates/truapi-server/src/runtime/coinage/extrinsic.rs new file mode 100644 index 000000000..bb2cdf987 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/extrinsic.rs @@ -0,0 +1,966 @@ +//! Unsigned General (v5) extrinsic assembly for coinage calls. +//! +//! A coinage extrinsic is unusual in that it carries no signature: the origin +//! comes from the `AsCoinage` extension, which transmutes it into +//! `Origin::Coin` or `Origin::UnloadToken` and consumes the coin or the token as +//! it does so. The proofs inside the extension are what authorize it. +//! +//! That creates an ordering the caller has to respect, and it is the reason this +//! module exposes the implication separately rather than hiding it: +//! +//! 1. Build the call. +//! 2. Compute the **inherited implication** — everything the extension signs +//! over, which depends on the call and on the extensions that follow. +//! 3. Prove against that implication. +//! 4. Encode the extension extra with those proofs. +//! 5. Assemble the extrinsic. +//! +//! Steps 2 and 3 cannot be reordered. A proof built before the call is known +//! signs the wrong thing, and the runtime rejects it without saying why. +//! +//! Dispatch indices are resolved by name from metadata, so a re-indexed runtime +//! fails loudly instead of encoding some other call. +//! +//! # Coin origins carry a signature after all +//! +//! `AsCoinage::AsCoin` *transmutes* a signed origin into `Origin::Coin`; it does +//! not conjure one. The signature comes from the `VerifyMultiSignature` +//! extension, signed by the coin account's own sr25519 key, and the coin the +//! call spends is whichever account that signature names. So `split`, `transfer` +//! and `load_recycler_with_coin` are assembled by +//! [`build_coin_origin_extrinsic`], which fills two extension slots rather than +//! one. +//! +//! The order between those two slots is load-bearing and easy to get backwards. +//! `VerifyMultiSignature` sits *before* `AsCoinage` in the runtime's extension +//! list, so the implication it signs over includes `AsCoinage`'s extra. The +//! coinage extra must therefore be built first and be visible to the signature — +//! signing against the default `None` extra produces bytes the runtime rejects +//! as a bad proof, with nothing to say why. + +use parity_scale_codec::Encode; +use schnorrkel::Keypair; + +use super::extension::{AS_COINAGE, AsCoinageInfo, encode_absent_extra}; +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::types::CoinAccountId; +use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; +use crate::runtime::statement_allowance::extension::{ChainState, Metadata, blake2b256}; + +/// Pallet whose calls this module builds. +const PALLET: &str = "Coinage"; + +/// Extension that turns a signature into the signed origin `AsCoinage` consumes. +pub const VERIFY_MULTI_SIGNATURE: &str = "VerifyMultiSignature"; + +/// `VerifySignature::Signed` variant index. +const VERIFY_SIGNATURE_SIGNED: u8 = 1; + +/// `MultiSignature::Sr25519` variant index. +const MULTI_SIGNATURE_SR25519: u8 = 1; + +/// General-transaction preamble byte: `0b01` (General) | version 5. +const GENERAL_V5_PREAMBLE: u8 = 0x45; + +/// Current transaction-extension version byte. +const EXTENSION_VERSION: u8 = 0x00; + +/// A coinage dispatchable, by pallet call name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoinageCall { + /// `Coinage::split`. + Split, + /// `Coinage::transfer`. + Transfer, + /// `Coinage::load_recycler_with_coin`. + LoadRecyclerWithCoin, + /// `Coinage::unload_recycler_into_coins`. + UnloadRecyclerIntoCoins, + /// `Coinage::unload_recycler_into_external_asset_and_vouchers`. + UnloadRecyclerIntoExternalAssetAndVouchers, + /// `Coinage::load_recycler_with_external_asset_unpaid_batch`. + LoadRecyclerWithExternalAssetUnpaidBatch, + /// `Coinage::pay_for_recycler_unload_fee_token_with_native`. + PayForRecyclerUnloadFeeTokenWithNative, +} + +impl CoinageCall { + /// The pallet's name for this call. + pub const fn name(self) -> &'static str { + match self { + Self::Split => "split", + Self::Transfer => "transfer", + Self::LoadRecyclerWithCoin => "load_recycler_with_coin", + Self::UnloadRecyclerIntoCoins => "unload_recycler_into_coins", + Self::UnloadRecyclerIntoExternalAssetAndVouchers => { + "unload_recycler_into_external_asset_and_vouchers" + } + Self::LoadRecyclerWithExternalAssetUnpaidBatch => { + "load_recycler_with_external_asset_unpaid_batch" + } + Self::PayForRecyclerUnloadFeeTokenWithNative => { + "pay_for_recycler_unload_fee_token_with_native" + } + } + } +} + +/// Dispatch bytes plus SCALE-encoded arguments. +/// +/// `args` comes from the matching type in [`super::call`], which has already +/// checked the pallet's own constraints on it. +pub fn build_call( + metadata: &Metadata, + call: CoinageCall, + args: &impl Encode, +) -> Result, CoinageError> { + let indices = metadata + .call_indices(PALLET, call.name()) + .map_err(|error| { + CoinageError::Internal(format!( + "resolving {PALLET}.{} failed: {error}", + call.name() + )) + })?; + + let mut encoded = indices.to_vec(); + encoded.extend(args.encode()); + Ok(encoded) +} + +/// The bytes the `AsCoinage` proofs sign over. +/// +/// Returned unhashed because the two proof kinds hash different things: an alias +/// proof signs `blake2_256(implication)`, while a free unload token signs +/// `blake2_256(alias_proofs ++ implication)`. +pub fn inherited_implication( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, +) -> Result, CoinageError> { + metadata + .inherited_implication(AS_COINAGE, call_data, state) + .map_err(|error| { + CoinageError::Internal(format!( + "building the {AS_COINAGE} implication failed: {error}" + )) + }) +} + +/// One extension slot whose encoding is supplied rather than derived from +/// metadata defaults. +type SlotOverride = (usize, Vec); + +/// The extension extras in metadata order, with `overrides` applied. +fn extras_with( + metadata: &Metadata, + state: &ChainState, + overrides: &[SlotOverride], +) -> Vec> { + metadata + .encode_signed_extensions(state) + .into_iter() + .enumerate() + .map(|(position, extension)| { + overrides + .iter() + .find(|(slot, _)| *slot == position) + .map_or(extension.extra, |(_, extra)| extra.clone()) + }) + .collect() +} + +/// The slot an extension occupies, or a loud failure. +fn slot_of(metadata: &Metadata, identifier: &str) -> Result { + metadata.extension_index(identifier).ok_or_else(|| { + CoinageError::Internal(format!("{identifier} extension not found in metadata")) + }) +} + +/// The bytes `VerifyMultiSignature` signs over, with the coinage extra in place. +/// +/// Built separately from [`inherited_implication`] because the two extensions +/// sit at different positions: this implication *contains* the coinage extra, +/// while the coinage one does not contain its own. +fn signature_implication( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, + as_coinage_extra: &[u8], +) -> Result, CoinageError> { + let signature_slot = slot_of(metadata, VERIFY_MULTI_SIGNATURE)?; + let coinage_slot = slot_of(metadata, AS_COINAGE)?; + if coinage_slot <= signature_slot { + return Err(CoinageError::Internal(format!( + "{AS_COINAGE} precedes {VERIFY_MULTI_SIGNATURE} in this runtime, so a coin origin \ + cannot be signed over its own extra" + ))); + } + + let extras = extras_with( + metadata, + state, + &[(coinage_slot, as_coinage_extra.to_vec())], + ); + let implicits = metadata.encode_signed_extensions(state); + + let mut payload = vec![EXTENSION_VERSION]; + payload.extend_from_slice(call_data); + for extra in extras.iter().skip(signature_slot + 1) { + payload.extend_from_slice(extra); + } + for extension in implicits.iter().skip(signature_slot + 1) { + payload.extend_from_slice(&extension.additional_signed); + } + Ok(payload) +} + +/// The `VerifySignature::Signed` extra for an sr25519 signature over `message`. +fn signed_extra(signature: &[u8; 64], account: CoinAccountId) -> Vec { + let mut extra = vec![VERIFY_SIGNATURE_SIGNED, MULTI_SIGNATURE_SR25519]; + extra.extend_from_slice(signature); + extra.extend_from_slice(&account.0); + extra +} + +/// Assemble a coinage extrinsic whose origin is the coin `keypair` controls. +/// +/// Signs `blake2_256(implication)` with the coin's own key, which is what makes +/// the coin account the signed origin `AsCoinage::AsCoin` then transmutes into +/// `Origin::Coin`. The account the signature names *is* the coin being spent, so +/// a caller that signs with the wrong key does not get a rejected proof — it gets +/// a different coin spent. +pub fn build_coin_origin_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + keypair: &Keypair, +) -> Result, CoinageError> { + let as_coinage_extra = AsCoinageInfo::AsCoin.encode_extra(metadata)?; + let implication = signature_implication(metadata, call_data, state, &as_coinage_extra)?; + let message = blake2b256(&implication); + + let signature = keypair + .sign_simple(SR25519_SIGNING_CONTEXT, &message) + .to_bytes(); + let account = CoinAccountId(keypair.public.to_bytes()); + + build_extrinsic_with( + metadata, + state, + call_data, + &[ + ( + slot_of(metadata, VERIFY_MULTI_SIGNATURE)?, + signed_extra(&signature, account), + ), + (slot_of(metadata, AS_COINAGE)?, as_coinage_extra), + ], + ) +} + +/// Assemble the unsigned extrinsic, splicing `as_coinage_extra` into the +/// extension slot metadata says it occupies. +/// +/// Refuses an immortal `state`. Mortality is a correctness requirement for this +/// layer, not a fee optimization: recovery decides that a lost transaction is +/// dead by watching the finalized height pass the era's end, and an immortal +/// extrinsic never reaches such a point, so its inputs could never safely be +/// returned to the spendable pool. Enforced here because this is the one place +/// every coinage extrinsic passes through. +pub fn build_unsigned_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + as_coinage_extra: &[u8], +) -> Result, CoinageError> { + let slot = slot_of(metadata, AS_COINAGE)?; + build_extrinsic_with( + metadata, + state, + call_data, + &[(slot, as_coinage_extra.to_vec())], + ) +} + +/// Assemble the extrinsic with an arbitrary set of extension slots supplied. +/// +/// The mortality refusal lives here because this is the one place every coinage +/// extrinsic — signed coin origin or unload token — passes through. +fn build_extrinsic_with( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + overrides: &[SlotOverride], +) -> Result, CoinageError> { + if state.mortality.is_none() { + return Err(CoinageError::Internal( + "a coinage extrinsic must be mortal; chain state carries no era anchor".to_string(), + )); + } + + let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; + for extra in extras_with(metadata, state, overrides) { + body.extend_from_slice(&extra); + } + body.extend_from_slice(call_data); + + // The outer length prefix an extrinsic carries on the wire. + Ok(body.encode()) +} + +/// Who holds the external asset a top-up converts, and who signs for it (§8.2). +/// +/// The layer never holds this account: a top-up moves value that is not coinage +/// yet, from an account the caller owns. So the caller signs, and the layer only +/// says what to sign. +pub trait FundingOrigin { + /// The account holding the external asset. + fn external_account(&self) -> CoinAccountId; + + /// Sign the extrinsic's signer payload with that account's sr25519 key. + fn sign(&self, payload: &[u8]) -> [u8; 64]; + + /// Sign a protected value transfer, if the runtime gates one. + /// + /// Not in `coinage-layer.md` §8.2, and deliberately defaulted away: the + /// deployed runtime puts test-asset transfers behind an `AuthorizeValueTransfer` + /// extension holding an Ed25519 signature, and an origin that cannot produce one + /// simply omits the extra — which is right for a runtime that does not gate it, + /// and a loud refusal on one that does. + fn authorize_value_transfer(&self, _message: &[u8; 32]) -> Option<[u8; 64]> { + None + } +} + +/// Extension gating protected test-asset transfers on the deployed runtime. +pub const AUTHORIZE_VALUE_TRANSFER: &str = "AuthorizeValueTransfer"; + +/// Signed Extrinsic V4 version byte. +const V4_SIGNED: u8 = 0x84; + +/// `MultiAddress::Id` discriminant. +const MULTI_ADDRESS_ID: u8 = 0x00; + +/// Assemble a signed V4 extrinsic for an external-asset load (§8.2). +/// +/// Not a General v5 transaction like the rest of this module, because its origin is +/// an ordinary account rather than a coin or a token: `AsCoinage` carries +/// `InfallibleUnpaidSigned`, which transmutes a conventional signed origin the +/// pallet promises will not fail before dispatch. +/// +/// The two extension extras are filled in a fixed order, and the order is not +/// arbitrary. `AuthorizeValueTransfer` signs over everything that follows it, which +/// includes the coinage extra — so the coinage extra has to exist first, exactly as +/// it does for a coin origin. +pub fn build_external_asset_load_extrinsic( + metadata: &Metadata, + state: &ChainState, + origin: &dyn FundingOrigin, + nonce: u32, + call_data: &[u8], +) -> Result, CoinageError> { + let mut state = *state; + state.nonce = nonce; + + let coinage_slot = slot_of(metadata, AS_COINAGE)?; + let coinage_extra = AsCoinageInfo::InfallibleUnpaidSigned { nonce }.encode_extra(metadata)?; + let mut overrides = vec![(coinage_slot, coinage_extra)]; + + // The authorization signs the implication of its own slot, with the coinage + // extra already in place. + if let Some(authorization_slot) = metadata.extension_index(AUTHORIZE_VALUE_TRANSFER) { + let extras = extras_with(metadata, &state, &overrides); + let all = metadata.encode_signed_extensions(&state); + + let mut payload = vec![EXTENSION_VERSION]; + payload.extend_from_slice(call_data); + for extra in extras.iter().skip(authorization_slot + 1) { + payload.extend_from_slice(extra); + } + for extension in all.iter().skip(authorization_slot + 1) { + payload.extend_from_slice(&extension.additional_signed); + } + + if let Some(signature) = origin.authorize_value_transfer(&blake2b256(&payload)) { + let mut extra = vec![1u8]; + extra.extend_from_slice(&signature); + overrides.push((authorization_slot, extra)); + } + } + + let extras = extras_with(metadata, &state, &overrides); + let implicits = metadata.encode_signed_extensions(&state); + + // V4's signer payload puts the call first, then every extra, then every + // implicit — a different order from the body, and hashed once it grows past + // 256 bytes. + let mut signer_payload = call_data.to_vec(); + for extra in &extras { + signer_payload.extend_from_slice(extra); + } + for extension in &implicits { + signer_payload.extend_from_slice(&extension.additional_signed); + } + if signer_payload.len() > 256 { + signer_payload = blake2b256(&signer_payload).to_vec(); + } + + let signature = origin.sign(&signer_payload); + let mut body = vec![V4_SIGNED, MULTI_ADDRESS_ID]; + body.extend_from_slice(&origin.external_account().0); + body.push(MULTI_SIGNATURE_SR25519); + body.extend_from_slice(&signature); + for extra in &extras { + body.extend_from_slice(extra); + } + body.extend_from_slice(call_data); + + Ok(body.encode()) +} + +/// Assemble a signed V4 extrinsic whose origin is an ordinary account. +/// +/// The third extrinsic shape this layer builds, and the only one that declares no +/// coinage origin at all: `pay_for_recycler_unload_fee_token_with_native` takes +/// `ensure_signed`, so `AsCoinage` carries `None` and there is nothing for the +/// extension to transmute. V4 rather than General v5 for the same reason the +/// external-asset load is V4 — a conventional signed origin. +/// +/// Signs with `keypair` directly, so the account the signature names is the one +/// that pays. The layer uses its fee account here: the join's fee is a layer cost +/// like any unload fee, and putting it on a coin would spend coinage value to buy +/// the right to move coinage value. +pub fn build_account_signed_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + keypair: &Keypair, + nonce: u32, +) -> Result, CoinageError> { + let mut state = *state; + state.nonce = nonce; + + let overrides = vec![(slot_of(metadata, AS_COINAGE)?, encode_absent_extra())]; + let extras = extras_with(metadata, &state, &overrides); + let implicits = metadata.encode_signed_extensions(&state); + + // V4's signer payload is call, then every extra, then every implicit — a + // different order from the body, and hashed once it grows past 256 bytes. + let mut signer_payload = call_data.to_vec(); + for extra in &extras { + signer_payload.extend_from_slice(extra); + } + for extension in &implicits { + signer_payload.extend_from_slice(&extension.additional_signed); + } + if signer_payload.len() > 256 { + signer_payload = blake2b256(&signer_payload).to_vec(); + } + + let signature = keypair + .sign_simple(SR25519_SIGNING_CONTEXT, &signer_payload) + .to_bytes(); + + let mut body = vec![V4_SIGNED, MULTI_ADDRESS_ID]; + body.extend_from_slice(&keypair.public.to_bytes()); + body.push(MULTI_SIGNATURE_SR25519); + body.extend_from_slice(&signature); + for extra in &extras { + body.extend_from_slice(extra); + } + body.extend_from_slice(call_data); + + Ok(body.encode()) +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::CompactLen; + + use crate::host_logic::coinage::chain_constants::next_people_paseo; + use crate::host_logic::coinage::derivation; + use crate::host_logic::coinage::types::{CoinIndex, DenominationExponent, PurseId}; + use crate::runtime::coinage::call::{CoinOutput, SplitArgs, TransferArgs}; + use crate::runtime::statement_allowance::extension::EraAnchor; + + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn metadata() -> Metadata { + Metadata::decode(FIXTURE).expect("the fixture decodes") + } + + /// A mortal chain state, because assembly refuses anything else. + fn state() -> ChainState { + ChainState { + mortality: Some(EraAnchor::new(1_000, [0xcd; 32], 256)), + ..immortal_state() + } + } + + fn immortal_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + mortality: None, + } + } + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + #[test] + fn an_immortal_extrinsic_is_refused() { + // The layer cannot recover an immortal transaction it loses track of: + // there is no height past which inclusion becomes impossible, so its + // inputs could never be released. Refused at assembly rather than + // discovered during recovery. + let metadata = metadata(); + let call = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([1; 32])), + ) + .expect("resolves"); + let extra = AsCoinageInfo::AsCoin + .encode_extra(&metadata) + .expect("resolves"); + + let refused = build_unsigned_extrinsic(&metadata, &immortal_state(), &call, &extra) + .expect_err("an immortal coinage extrinsic is refused"); + + assert!(refused.to_string().contains("must be mortal")); + assert!(build_unsigned_extrinsic(&metadata, &state(), &call, &extra).is_ok()); + } + + #[test] + fn the_era_binds_the_extrinsic_to_its_anchor() { + // Two extrinsics identical but for their era anchor must differ, or the + // checkpoint recorded in the operation log would not describe the + // transaction that was actually broadcast. + let metadata = metadata(); + let call = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([1; 32])), + ) + .expect("resolves"); + let extra = AsCoinageInfo::AsCoin + .encode_extra(&metadata) + .expect("resolves"); + let elsewhere = ChainState { + mortality: Some(EraAnchor::new(2_000, [0xef; 32], 256)), + ..immortal_state() + }; + + let here = build_unsigned_extrinsic(&metadata, &state(), &call, &extra).expect("assembles"); + let there = + build_unsigned_extrinsic(&metadata, &elsewhere, &call, &extra).expect("assembles"); + + assert_ne!(here, there); + assert_ne!( + inherited_implication(&metadata, &call, &state()).expect("builds"), + inherited_implication(&metadata, &call, &elsewhere).expect("builds"), + "the proof must sign over the era it was built for" + ); + } + + #[test] + fn the_fixture_runtime_carries_coinage() { + // Everything else here depends on it. Asserted rather than guarded: a + // fixture regenerated without coinage must fail loudly, not quietly + // turn the rest of this suite into no-ops. + let metadata = metadata(); + + assert!(metadata.call_indices(PALLET, "transfer").is_ok()); + assert!(metadata.extension_index(AS_COINAGE).is_some()); + } + + #[test] + fn a_call_is_dispatch_bytes_then_arguments() { + let metadata = metadata(); + + let args = TransferArgs::new(CoinAccountId([5; 32])); + let call = build_call(&metadata, CoinageCall::Transfer, &args).expect("resolves"); + + let indices = metadata.call_indices(PALLET, "transfer").expect("resolves"); + assert_eq!(&call[..2], &indices); + assert_eq!(&call[2..], &[5u8; 32]); + } + + #[test] + fn an_unknown_call_name_fails_loudly() { + // Guards the by-name discipline: nothing here may fall back to a + // hard-coded index. + let metadata = metadata(); + + assert!( + metadata + .call_indices(PALLET, "definitely_not_a_call") + .is_err() + ); + } + + #[test] + fn the_implication_covers_the_call() { + let metadata = metadata(); + let first = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([1; 32])), + ) + .expect("resolves"); + let second = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([2; 32])), + ) + .expect("resolves"); + + let one = inherited_implication(&metadata, &first, &state()).expect("builds"); + let two = inherited_implication(&metadata, &second, &state()).expect("builds"); + + // A proof built for one call must not validate for another. + assert_ne!(one, two); + assert_eq!(one[0], EXTENSION_VERSION); + assert_eq!(&one[1..1 + first.len()], &first[..]); + } + + #[test] + fn the_implication_covers_chain_state() { + let metadata = metadata(); + let call = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([1; 32])), + ) + .expect("resolves"); + + let here = inherited_implication(&metadata, &call, &state()).expect("builds"); + let elsewhere = inherited_implication( + &metadata, + &call, + &ChainState { + genesis_hash: [0xcd; 32], + ..state() + }, + ) + .expect("builds"); + + assert_ne!( + here, elsewhere, + "an implication must bind the chain it was built for" + ); + } + + #[test] + fn an_assembled_extrinsic_carries_the_preamble_and_the_extra() { + let metadata = metadata(); + let call = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([7; 32])), + ) + .expect("resolves"); + let extra = AsCoinageInfo::AsCoin + .encode_extra(&metadata) + .expect("resolves"); + + let extrinsic = + build_unsigned_extrinsic(&metadata, &state(), &call, &extra).expect("assembles"); + + // A compact length prefix, then the General v5 preamble. + let body_start = extrinsic.len() - (extrinsic.len() - 1); + assert!(extrinsic.len() > call.len() + extra.len()); + assert_eq!(extrinsic[body_start], GENERAL_V5_PREAMBLE); + // The call is the tail of the body. + assert!(extrinsic.ends_with(&call)); + // And our extra appears verbatim. + assert!( + extrinsic + .windows(extra.len()) + .any(|window| window == extra.as_slice()) + ); + } + + /// The coin keypair a coin-origin extrinsic is signed with. + fn coin_keypair(index: u32) -> Keypair { + derivation::coin_keypair(&[7u8; 32], PurseId::MAIN, CoinIndex(index)) + .expect("derivation succeeds") + } + + /// The signature and account the assembler put in the `VerifyMultiSignature` + /// slot, read back out of the assembled bytes. + /// + /// sr25519 signs with a random nonce, so a test cannot re-derive the + /// signature and compare: it has to read the one that was actually embedded. + /// Locating it also pins the body layout — extras in metadata order, the call + /// last. + fn embedded_signature( + metadata: &Metadata, + state: &ChainState, + extrinsic: &[u8], + as_coinage_extra: &[u8], + ) -> ([u8; 64], [u8; 32]) { + let signature_slot = slot_of(metadata, VERIFY_MULTI_SIGNATURE).expect("present"); + let coinage_slot = slot_of(metadata, AS_COINAGE).expect("present"); + let extras = extras_with( + metadata, + state, + &[(coinage_slot, as_coinage_extra.to_vec())], + ); + + // Skip the compact length prefix, the preamble and the version byte, + // then every extra before the signature's slot. + let prefix = parity_scale_codec::Compact::::compact_len(&(extrinsic.len() as u32 - 1)); + let mut cursor = prefix + 2; + for extra in extras.iter().take(signature_slot) { + cursor += extra.len(); + } + + assert_eq!( + &extrinsic[cursor..cursor + 2], + &[VERIFY_SIGNATURE_SIGNED, MULTI_SIGNATURE_SR25519], + "the signature slot holds `Signed(Sr25519(..))`" + ); + let signature: [u8; 64] = extrinsic[cursor + 2..cursor + 66] + .try_into() + .expect("64 bytes"); + let account: [u8; 32] = extrinsic[cursor + 66..cursor + 98] + .try_into() + .expect("32 bytes"); + (signature, account) + } + + #[test] + fn a_coin_origin_extrinsic_embeds_a_signature_that_verifies() { + // Closes the loop the runtime will close: the bytes in the signature slot + // verify, under sr25519, against the account named beside them, over the + // implication that includes the coinage extra. + let metadata = metadata(); + let keypair = coin_keypair(0); + let call = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([9; 32])), + ) + .expect("resolves"); + let coinage_extra = AsCoinageInfo::AsCoin + .encode_extra(&metadata) + .expect("resolves"); + + let extrinsic = + build_coin_origin_extrinsic(&metadata, &state(), &call, &keypair).expect("assembles"); + + let (signature, account) = + embedded_signature(&metadata, &state(), &extrinsic, &coinage_extra); + // The account the signature names *is* the coin being spent. + assert_eq!(account, keypair.public.to_bytes()); + + let implication = signature_implication(&metadata, &call, &state(), &coinage_extra) + .expect("the runtime orders both"); + let parsed = schnorrkel::Signature::from_bytes(&signature).expect("64 bytes"); + assert!( + keypair + .public + .verify_simple(SR25519_SIGNING_CONTEXT, &blake2b256(&implication), &parsed) + .is_ok(), + "the coin's own key must verify what was embedded" + ); + assert!(extrinsic.ends_with(&call)); + } + + #[test] + fn the_signed_message_covers_the_coinage_extra() { + // The bug this guards: signing the implication built from metadata + // defaults, where the coinage slot is `None`. The runtime would then see + // a signature over bytes that are not the transaction it was handed, and + // reject it as a bad proof with nothing to say why. + let metadata = metadata(); + let call = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([1; 32])), + ) + .expect("resolves"); + let coinage_extra = AsCoinageInfo::AsCoin + .encode_extra(&metadata) + .expect("resolves"); + + let with_coinage = signature_implication(&metadata, &call, &state(), &coinage_extra) + .expect("the runtime orders both"); + let with_default = metadata + .inherited_implication(VERIFY_MULTI_SIGNATURE, &call, &state()) + .expect("builds"); + + assert_ne!( + with_coinage, with_default, + "the coin's signature must cover the coinage extra" + ); + assert!( + with_coinage + .windows(coinage_extra.len()) + .any(|window| window == coinage_extra.as_slice()), + "the coinage extra is inside what the coin signs" + ); + } + + #[test] + fn two_coins_sign_as_two_different_accounts() { + let metadata = metadata(); + let call = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([3; 32])), + ) + .expect("resolves"); + let coinage_extra = AsCoinageInfo::AsCoin + .encode_extra(&metadata) + .expect("resolves"); + + let first = build_coin_origin_extrinsic(&metadata, &state(), &call, &coin_keypair(0)) + .expect("assembles"); + let second = build_coin_origin_extrinsic(&metadata, &state(), &call, &coin_keypair(1)) + .expect("assembles"); + + let (_, one) = embedded_signature(&metadata, &state(), &first, &coinage_extra); + let (_, two) = embedded_signature(&metadata, &state(), &second, &coinage_extra); + assert_ne!(one, two, "each coin signs as itself"); + } + + #[test] + fn an_account_signed_extrinsic_declares_no_coinage_origin_and_verifies() { + // The paid-token join is the one call this layer makes with a conventional + // signed origin, so `AsCoinage` must carry `None`. Encoding `Some(AsCoin)` + // here would have the extension try to transmute the fee account into a + // coin origin and reject the extrinsic with nothing useful to say. + use parity_scale_codec::{Compact, Decode}; + + let metadata = metadata(); + let keypair = coin_keypair(0); + let call = build_call( + &metadata, + CoinageCall::PayForRecyclerUnloadFeeTokenWithNative, + &crate::runtime::coinage::call::PayForUnloadFeeTokenArgs::new( + [7u8; 32], + crate::runtime::coinage::call::RawEncoded(vec![3u8; 64]), + ), + ) + .expect("resolves"); + + let extrinsic = build_account_signed_extrinsic(&metadata, &state(), &call, &keypair, 5) + .expect("assembles"); + + // Strip the outer length prefix `encode()` added. + let mut cursor = &extrinsic[..]; + let Compact(len) = Compact::::decode(&mut cursor).expect("length prefix"); + assert_eq!(len as usize, cursor.len()); + + assert_eq!(cursor[0], 0x84, "signed extrinsic V4"); + assert_eq!(cursor[1], 0x00, "MultiAddress::Id"); + assert_eq!( + &cursor[2..34], + &keypair.public.to_bytes(), + "the fee account" + ); + assert_eq!(cursor[34], MULTI_SIGNATURE_SR25519); + + let signature: [u8; 64] = cursor[35..99].try_into().expect("64-byte signature"); + let extras_and_call = &cursor[99..]; + assert!( + extras_and_call.ends_with(&call), + "the call is last in a V4 body" + ); + + // The absent coinage extra is one zero byte, and it must be present among + // the extras rather than omitted. + let extras = extras_with( + &metadata, + &ChainState { + nonce: 5, + ..state() + }, + &[( + slot_of(&metadata, AS_COINAGE).expect("slot"), + encode_absent_extra(), + )], + ); + let encoded_extras: Vec = extras.concat(); + assert_eq!( + &extras_and_call[..encoded_extras.len()], + &encoded_extras[..] + ); + + // And the signature verifies over V4's own payload ordering. + let mut payload = call.clone(); + payload.extend_from_slice(&encoded_extras); + for extension in metadata.encode_signed_extensions(&ChainState { + nonce: 5, + ..state() + }) { + payload.extend_from_slice(&extension.additional_signed); + } + let signed = if payload.len() > 256 { + blake2b256(&payload).to_vec() + } else { + payload + }; + let public = schnorrkel::PublicKey::from_bytes(&keypair.public.to_bytes()).expect("key"); + let parsed = schnorrkel::Signature::from_bytes(&signature).expect("signature"); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &signed, &parsed) + .is_ok(), + "the runtime will check exactly this" + ); + } + + #[test] + fn a_coin_origin_extrinsic_is_refused_when_immortal() { + let metadata = metadata(); + let call = build_call( + &metadata, + CoinageCall::Transfer, + &TransferArgs::new(CoinAccountId([1; 32])), + ) + .expect("resolves"); + + let refused = + build_coin_origin_extrinsic(&metadata, &immortal_state(), &call, &coin_keypair(0)) + .expect_err("mortality is not optional for coinage"); + + assert!(refused.to_string().contains("must be mortal")); + } + + #[test] + fn a_split_assembles_end_to_end() { + let metadata = metadata(); + // 2^2 = 4 splits into 2 + 2, to two different accounts. + let outputs = [ + CoinOutput { + exponent: exponent(1), + account: CoinAccountId([1; 32]), + }, + CoinOutput { + exponent: exponent(1), + account: CoinAccountId([2; 32]), + }, + ]; + let args = + SplitArgs::new(exponent(2), &outputs, &next_people_paseo()).expect("value conserved"); + + let call = build_call(&metadata, CoinageCall::Split, &args).expect("resolves"); + let extra = AsCoinageInfo::AsCoin + .encode_extra(&metadata) + .expect("resolves"); + let extrinsic = + build_unsigned_extrinsic(&metadata, &state(), &call, &extra).expect("assembles"); + + assert!(extrinsic.ends_with(&call)); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/fee.rs b/rust/crates/truapi-server/src/runtime/coinage/fee.rs new file mode 100644 index 000000000..322211517 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/fee.rs @@ -0,0 +1,222 @@ +//! Pricing an assembled extrinsic. +//! +//! Needed for one decision only: the ceiling an unload may take out of its own +//! output when the fee account cannot pay (§6.6). That ceiling has to cover the +//! fee the runtime will actually charge, and the fee depends on the extrinsic's +//! own bytes — including the ceiling itself, which sits inside the call. +//! +//! The circularity is resolved by pricing real bytes twice rather than guessing a +//! length once. `u128` is fixed-width in SCALE, so raising the ceiling does not +//! change the extrinsic's length; a second pass therefore converges immediately, +//! and the second pass exists only to catch the case where a runtime prices the +//! *value* of a field rather than its size. +//! +//! The runtime API's return type is not in the metadata type registry — nothing on +//! chain describes `RuntimeDispatchInfo` — so its layout is decoded by hand: +//! `Weight { ref_time: Compact, proof_size: Compact }`, a one-byte +//! dispatch class, then the fee as a `u128`. Decoded field by field rather than by +//! taking the trailing sixteen bytes, so a runtime that changes the shape fails +//! loudly instead of pricing garbage. + +use core::future::Future; + +use parity_scale_codec::{Compact, Decode}; +use serde_json::json; + +use crate::host_logic::coinage::error::CoinageError; +use crate::runtime::statement_allowance::rpc::RpcClient; + +/// Runtime API that prices an extrinsic. +const QUERY_INFO: &str = "TransactionPaymentApi_query_info"; + +/// How many times to re-price a ceiling before accepting it. +const CEILING_PASSES: usize = 2; + +/// The fee the runtime would charge for `extrinsic`. +pub async fn estimate(rpc: &RpcClient, extrinsic: &[u8]) -> Result { + let at = rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + + // The API takes `(uxt, len)`. `extrinsic` is already the SCALE-encoded + // extrinsic, length prefix included, which is exactly what `uxt` decodes as. + let mut payload = extrinsic.to_vec(); + payload.extend((extrinsic.len() as u32).to_le_bytes()); + + let result = rpc + .call( + "state_call", + json!([QUERY_INFO, format!("0x{}", hex::encode(&payload)), at]), + ) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let encoded = result + .as_str() + .ok_or_else(|| { + CoinageError::Internal("state_call returned a non-string result".to_string()) + }) + .and_then(|hex_str| { + hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)).map_err(|error| { + CoinageError::Internal(format!("decoding the fee estimate: {error}")) + }) + })?; + + decode_partial_fee(&encoded) +} + +/// Decode `RuntimeDispatchInfo` and return its fee. +fn decode_partial_fee(encoded: &[u8]) -> Result { + let mut cursor = encoded; + let malformed = |field: &str| { + CoinageError::Internal(format!( + "the runtime's fee estimate is not a RuntimeDispatchInfo: {field}" + )) + }; + + let Compact(_ref_time) = + Compact::::decode(&mut cursor).map_err(|_| malformed("weight"))?; + let Compact(_proof_size) = + Compact::::decode(&mut cursor).map_err(|_| malformed("proof size"))?; + let _class = u8::decode(&mut cursor).map_err(|_| malformed("dispatch class"))?; + let fee = u128::decode(&mut cursor).map_err(|_| malformed("partial fee"))?; + + Ok(fee) +} + +/// Build an extrinsic whose own fee ceiling covers what the runtime will charge. +/// +/// `build` is called with a candidate ceiling and returns the extrinsic carrying +/// it. The first pass prices a zero ceiling; each later pass prices the bytes the +/// previous ceiling produced. Settles as soon as the ceiling covers the price. +pub async fn ceiling(rpc: &RpcClient, build: F) -> Result, CoinageError> +where + F: Fn(u128) -> Fut, + Fut: Future, CoinageError>>, +{ + let mut candidate = 0u128; + let mut extrinsic = build(candidate).await?; + + for _ in 0..CEILING_PASSES { + let priced = estimate(rpc, &extrinsic).await?; + if priced <= candidate { + return Ok(extrinsic); + } + candidate = priced; + extrinsic = build(candidate).await?; + } + + Ok(extrinsic) +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::Encode; + use subxt_rpcs::RpcClient as HostRpcClient; + + use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + + use super::*; + + fn block_on(future: F) -> F::Output { + futures::executor::block_on(future) + } + + fn scripted(responses: &[String]) -> (ScriptedRpc, RpcClient) { + let scripted = ScriptedRpc::new(responses.iter().map(String::as_str)); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + (scripted, rpc) + } + + /// `RuntimeDispatchInfo { weight, class, partial_fee }` as the runtime API + /// returns it. + fn dispatch_info(fee: u128) -> String { + let mut encoded = Compact(1_000_000u64).encode(); + encoded.extend(Compact(4_096u64).encode()); + encoded.push(0u8); // DispatchClass::Normal + encoded.extend(fee.encode()); + format!("\"0x{}\"", hex::encode(encoded)) + } + + #[test] + fn a_dispatch_info_is_decoded_field_by_field() { + let encoded = { + let mut encoded = Compact(7u64).encode(); + encoded.extend(Compact(8u64).encode()); + encoded.push(1u8); + encoded.extend(9_999u128.encode()); + encoded + }; + + assert_eq!(decode_partial_fee(&encoded).expect("decodes"), 9_999); + } + + #[test] + fn a_truncated_dispatch_info_is_refused_rather_than_priced() { + // Reading a short reply as a small fee would set a ceiling the runtime + // then exceeds, and the dispatch fails after the token is spent. + let refused = + decode_partial_fee(&[0x04, 0x08]).expect_err("a reply this short cannot carry a fee"); + + assert!(refused.to_string().contains("RuntimeDispatchInfo")); + } + + #[test] + fn an_estimate_prices_the_bytes_it_was_given() { + let (scripted, rpc) = scripted(&["\"0xfeed\"".to_string(), dispatch_info(1_234)]); + + let fee = block_on(estimate(&rpc, &[1, 2, 3, 4])).expect("prices"); + + assert_eq!(fee, 1_234); + let (method, params) = scripted.calls()[1].clone(); + assert_eq!(method, "state_call"); + assert!(params.contains(QUERY_INFO)); + // The extrinsic, then its length as a little-endian u32. + assert!( + params.contains("0x0102030404000000"), + "the API's (uxt, len) argument pair: {params}" + ); + } + + #[test] + fn a_ceiling_that_already_covers_the_fee_settles_on_the_first_pass() { + let (scripted, rpc) = scripted(&["\"0xfeed\"".to_string(), dispatch_info(0)]); + + let extrinsic = block_on(ceiling(&rpc, |max_fee| async move { + assert_eq!(max_fee, 0, "the first pass prices a zero ceiling"); + Ok(vec![9u8]) + })) + .expect("settles"); + + assert_eq!(extrinsic, vec![9u8]); + assert_eq!(scripted.calls().len(), 2, "one price, no rebuild"); + } + + #[test] + fn a_ceiling_is_raised_to_the_price_of_its_own_bytes() { + let (_scripted, rpc) = scripted(&[ + "\"0xfeed\"".to_string(), + dispatch_info(500), + "\"0xfeed\"".to_string(), + dispatch_info(500), + ]); + let seen = std::sync::Mutex::new(Vec::new()); + + let extrinsic = block_on(ceiling(&rpc, |max_fee| { + seen.lock().unwrap().push(max_fee); + async move { Ok(max_fee.to_le_bytes().to_vec()) } + })) + .expect("settles"); + + assert_eq!( + *seen.lock().unwrap(), + vec![0, 500], + "priced at zero, then rebuilt at the price" + ); + assert_eq!( + extrinsic, + 500u128.to_le_bytes().to_vec(), + "the extrinsic returned is the one carrying the settled ceiling" + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/observe.rs b/rust/crates/truapi-server/src/runtime/coinage/observe.rs new file mode 100644 index 000000000..ea360703e --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/observe.rs @@ -0,0 +1,517 @@ +//! Reading coinage chain state. +//! +//! [`super::storage`] owns the byte layouts — keys and value decoding — and is +//! testable offline. This module issues the reads, deriving the accounts to ask +//! about from the layer's own record indices. +//! +//! Every read takes an explicit block hash. Recovery depends on that: a +//! decision it makes must not be undoable, so its reads are pinned to a +//! finalized block rather than taken at whatever the best block happens to be. + +use crate::host_logic::coinage::derivation; +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::params::CoinageParameters; +use crate::host_logic::coinage::store::CoinageStore; +use crate::host_logic::coinage::types::{ + BlockHash, CoinIndex, EntryIndex, PurseId, RingLocation, Timestamp, +}; +use crate::runtime::coinage::storage; +use crate::runtime::statement_allowance::extension::Metadata; +use crate::runtime::statement_allowance::rpc::RpcClient; + +/// Decode a `0x`-prefixed 32-byte block hash. +pub fn decode_block_hash(hash: &str) -> Result { + let bytes = hex::decode(hash.strip_prefix("0x").unwrap_or(hash)) + .map_err(|error| CoinageError::SubscriptionError(format!("block hash hex: {error}")))?; + let length = bytes.len(); + bytes + .try_into() + .map(BlockHash) + .map_err(|_| CoinageError::SubscriptionError(format!("block hash is {length} bytes"))) +} + +/// Height of the block at `hash`. +pub async fn block_number(rpc: &RpcClient, hash: &str) -> Result { + let header = rpc + .call("chain_getHeader", serde_json::json!([hash])) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let number = header + .get("number") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + CoinageError::SubscriptionError(format!("chain_getHeader({hash}) carried no number")) + })?; + + u64::from_str_radix(number.strip_prefix("0x").unwrap_or(number), 16) + .map_err(|error| CoinageError::SubscriptionError(format!("header number: {error}"))) +} + +/// Whether the chain holds a coin at this record's account, as of `at`. +pub async fn coin_present( + rpc: &RpcClient, + entropy: &[u8], + purse: PurseId, + index: CoinIndex, + at: &str, +) -> Result { + let account = derivation::coin_account_id(entropy, purse, index)?; + let raw = rpc + .get_storage_at(&storage::coins_by_owner_key(&account), at) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + + // Decoded rather than merely tested for presence: a value that will not + // decode means the layout assumption is wrong, and reading that as "the + // coin is there" would build a success verdict on bytes nobody understood. + Ok(storage::decode_coin(raw)?.is_some()) +} + +/// Whether the chain still places this recycler entry in a ring, as of `at`. +pub async fn entry_present( + rpc: &RpcClient, + entropy: &[u8], + purse: PurseId, + index: EntryIndex, + at: &str, +) -> Result { + let member_key = derivation::entry_member_key(entropy, purse, index)?; + let raw = rpc + .get_storage_at(&storage::recyclers_coin_to_recycler_key(&member_key), at) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + + Ok(raw.is_some()) +} + +/// The chain's lock on a coin account, as of `at`. +pub async fn coin_lock( + rpc: &RpcClient, + entropy: &[u8], + purse: PurseId, + index: CoinIndex, + at: &str, +) -> Result, CoinageError> { + let account = derivation::coin_account_id(entropy, purse, index)?; + let raw = rpc + .get_storage_at(&storage::locked_coins_key(&account), at) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + + storage::decode_coin_lock(raw) +} + +/// Read every chain fact backing one purse's records and apply it. +/// +/// `coinage-layer.md` §6.1. Six storage sets are consulted; the pallet's own +/// `RecyclersCoinToRecycler` only says which *denomination* collection an entry +/// belongs to, so the ring index comes from the `Members` pallet and the +/// revision from that ring's root. +/// +/// Pinned to one block for the whole purse: a refresh that read half its +/// records before a new block and half after would produce a view that never +/// existed, and selection would then plan against it. +pub async fn refresh_purse( + rpc: &RpcClient, + metadata: &Metadata, + store: &mut CoinageStore, + entropy: &[u8], + purse: PurseId, + params: &CoinageParameters, + at: &str, +) -> Result<(), CoinageError> { + let mut coins = Vec::new(); + for coin in store.coins_in(purse) { + let account = derivation::coin_account_id(entropy, purse, coin.index)?; + coins.push(storage::ObservedCoin { + index: coin.index, + coin: storage::decode_coin( + read(rpc, &storage::coins_by_owner_key(&account), at).await?, + )?, + lock: storage::decode_coin_lock( + read(rpc, &storage::locked_coins_key(&account), at).await?, + )?, + }); + } + + let mut entries = Vec::new(); + let mut alias_locks = Vec::new(); + for entry in store.entries_in(purse) { + let member_key = derivation::entry_member_key(entropy, purse, entry.index)?; + let collection = storage::recycler_collection_id(entry.exponent); + + let loaded = read( + rpc, + &storage::recyclers_coin_to_recycler_key(&member_key), + at, + ) + .await? + .is_some(); + if !loaded { + entries.push(storage::ObservedEntry { + index: entry.index, + ring: None, + included_members: 0, + ring_immutable_since: None, + }); + continue; + } + + let position = storage::decode_ring_position( + read(rpc, &storage::members_key(&collection, &member_key), at).await?, + )?; + let Some(ring) = position + .as_ref() + .and_then(storage::RingPosition::ring_index) + else { + // Loaded into the collection but not yet placed in a ring, or + // suspended. Either way there is nothing to unload from. + entries.push(storage::ObservedEntry { + index: entry.index, + ring: None, + included_members: 0, + ring_immutable_since: None, + }); + continue; + }; + + let status = storage::decode_ring_status( + read(rpc, &storage::ring_keys_status_key(&collection, ring), at).await?, + )?; + let revision = + super::ring::read_ring_revision(rpc, metadata, entry.exponent, ring, at).await?; + + entries.push(storage::ObservedEntry { + index: entry.index, + ring: revision.map(|revision| RingLocation::new(ring, revision)), + included_members: status.included, + ring_immutable_since: status.immutable_since.map(Timestamp::from_unix_seconds), + }); + alias_locks.push((entry.index, entry.exponent, ring)); + } + + storage::apply_observations(store, purse, &coins, &entries, params)?; + + for (index, exponent, ring) in alias_locks { + let alias = super::proof::recycler_alias(entropy, purse, index)?; + let locked_until = match storage::decode_alias_state( + read( + rpc, + &storage::recycler_alias_state_key(exponent, ring, &alias), + at, + ) + .await?, + )? { + Some(storage::ChainAliasState::Locked(lock)) => { + Some(Timestamp::from_unix_seconds(lock.until)) + } + // `Unloaded` is terminal, not a lock: the entry is gone rather than + // temporarily refused, and the operation that unloaded it owns that + // transition. + Some(storage::ChainAliasState::Unloaded) | None => None, + }; + store.observe_entry_alias_lock(purse, index, locked_until)?; + } + + Ok(()) +} + +/// Read one storage value pinned to `at`. +async fn read(rpc: &RpcClient, key: &[u8], at: &str) -> Result>, CoinageError> { + rpc.get_storage_at(key, at) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string())) +} + +#[cfg(test)] +mod tests { + use subxt_rpcs::RpcClient as HostRpcClient; + + use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + + use super::*; + + const ENTROPY: [u8; 32] = [7; 32]; + const AT: &str = "0x0707070707070707070707070707070707070707070707070707070707070707"; + + fn scripted(responses: &[String]) -> (ScriptedRpc, RpcClient) { + let scripted = ScriptedRpc::new(responses.iter().map(String::as_str)); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + (scripted, rpc) + } + + fn block_on(future: F) -> F::Output { + futures::executor::block_on(future) + } + + #[test] + fn a_block_hash_round_trips() { + assert_eq!(decode_block_hash(AT).expect("decodes"), BlockHash([7; 32])); + assert!(decode_block_hash("0xnothex").is_err()); + assert!(decode_block_hash("0x00").is_err(), "wrong length"); + } + + #[test] + fn a_header_number_is_read_as_hex() { + let (_scripted, rpc) = scripted(&[r#"{"number":"0x3e8"}"#.to_string()]); + + assert_eq!(block_on(block_number(&rpc, AT)).expect("reads"), 1_000); + } + + #[test] + fn a_header_without_a_number_is_an_error() { + let (_scripted, rpc) = scripted(&[r#"{"parentHash":"0x00"}"#.to_string()]); + + assert!(block_on(block_number(&rpc, AT)).is_err()); + } + + #[test] + fn an_absent_coin_reads_as_absent_and_a_present_one_as_present() { + use parity_scale_codec::Encode; + + let (_scripted, rpc) = scripted(&["null".to_string()]); + assert!( + !block_on(coin_present( + &rpc, + &ENTROPY, + PurseId::MAIN, + CoinIndex(0), + AT + )) + .expect("reads") + ); + + let coin = storage::ChainCoin { value: 4, age: 1 }.encode(); + let (_scripted, rpc) = scripted(&[format!("\"0x{}\"", hex::encode(coin))]); + assert!( + block_on(coin_present( + &rpc, + &ENTROPY, + PurseId::MAIN, + CoinIndex(0), + AT + )) + .expect("reads") + ); + } + + #[test] + fn an_undecodable_coin_is_an_error_not_a_presence() { + // Reading garbage as "the coin is there" would let recovery declare a + // transaction successful on the strength of bytes nobody understood. + let (_scripted, rpc) = scripted(&["\"0x\"".to_string()]); + + assert!( + block_on(coin_present( + &rpc, + &ENTROPY, + PurseId::MAIN, + CoinIndex(0), + AT + )) + .is_err() + ); + } + + #[test] + fn the_read_is_pinned_to_the_requested_block() { + // Recovery's whole guarantee rests on this: a decision taken at the + // best block could describe a fork that is about to vanish. + let (scripted, rpc) = scripted(&["null".to_string()]); + + block_on(coin_present( + &rpc, + &ENTROPY, + PurseId::MAIN, + CoinIndex(0), + AT, + )) + .expect("reads"); + + let (method, params) = scripted.calls().into_iter().next().expect("one call"); + assert_eq!(method, "state_getStorage"); + assert!(params.contains(AT), "the block hash is passed: {params}"); + } + + #[test] + fn a_ring_status_without_immutability_still_decodes() { + use parity_scale_codec::Encode; + + let status = storage::RingKeysStatus { + total: 32, + included: 32, + immutable_since: Some(1_700_000_000), + }; + + assert_eq!( + storage::decode_ring_status(Some(status.encode())).expect("decodes"), + status, + "immutable_since is the rescue sweep's only warning" + ); + } + + #[test] + fn refreshing_a_purse_assembles_coin_and_entry_state() { + use parity_scale_codec::Encode; + + use crate::host_logic::coinage::store::CoinageStore; + use crate::host_logic::coinage::types::DenominationExponent; + + let exponent = DenominationExponent::new(4).expect("in range"); + let mut store = CoinageStore::new("Main".to_string()); + let coin = store + .add_pending_coin(PurseId::MAIN, exponent) + .expect("purse exists"); + let entry = store + .allocate_entry( + PurseId::MAIN, + exponent, + Timestamp(0), + core::time::Duration::ZERO, + ) + .expect("purse exists"); + + // The reads, in the order refresh_purse makes them. + let responses = vec![ + // coin: CoinsByOwner, then LockedCoins + format!( + "\"0x{}\"", + hex::encode(storage::ChainCoin { value: 4, age: 2 }.encode()) + ), + "null".to_string(), + // entry: RecyclersCoinToRecycler (loaded), Members (ring 1) + "\"0x04\"".to_string(), + format!( + "\"0x{}\"", + hex::encode( + storage::RingPosition::Included { + ring_index: 1, + ring_page: 0, + ring_position: 3, + } + .encode() + ) + ), + // RingKeysStatus + format!( + "\"0x{}\"", + hex::encode( + storage::RingKeysStatus { + total: 32, + included: 32, + immutable_since: Some(1_700_000_000), + } + .encode() + ) + ), + // Members::Root — absent, so no revision and therefore no usable + // ring location. + "null".to_string(), + // RecyclerAliasStates + "null".to_string(), + ]; + let (_scripted, rpc) = scripted(&responses); + let metadata = Metadata::decode(include_bytes!( + "../../../tests/fixtures/paseo-next-v2-metadata.scale" + )) + .expect("the fixture decodes"); + + block_on(refresh_purse( + &rpc, + &metadata, + &mut store, + &ENTROPY, + PurseId::MAIN, + &CoinageParameters::default(), + AT, + )) + .expect("refreshes"); + + let coin = store.coin(PurseId::MAIN, coin).expect("exists"); + assert_eq!(coin.age.0, 2); + assert_eq!(coin.locked_until, None); + let entry = store.entry(PurseId::MAIN, entry).expect("exists"); + assert_eq!( + entry.ring, None, + "a ring whose root has no revision cannot be proven against" + ); + // The rescue sweep's deadline must survive the whole pipeline: decoded + // from RingStatus, carried through ObservedEntry, stored on the record. + // Losing it anywhere along the way is how entries expire unnoticed. + assert_eq!( + entry.ring_immutable_since, + Some(Timestamp::from_unix_seconds(1_700_000_000)), + "the rescue deadline reached the record" + ); + // Recorded even though this entry is not currently rescuable: without + // a committed root there is nothing to prove membership against, so + // `needs_rescue` declines. Immutability is a fact about the ring and is + // stored unconditionally, so the sweep can act the moment the ring + // becomes usable rather than needing a second observation pass. + assert!( + !entry.needs_rescue( + Timestamp::from_unix_seconds(1_700_000_000) + .saturating_add(core::time::Duration::from_secs(80 * 24 * 60 * 60)), + core::time::Duration::from_secs(90 * 24 * 60 * 60), + core::time::Duration::from_secs(22 * 24 * 60 * 60), + ) + ); + } + + #[test] + fn an_entry_still_onboarding_is_not_placed_in_a_ring() { + use parity_scale_codec::Encode; + + use crate::host_logic::coinage::store::CoinageStore; + use crate::host_logic::coinage::types::DenominationExponent; + + let exponent = DenominationExponent::new(4).expect("in range"); + let mut store = CoinageStore::new("Main".to_string()); + let entry = store + .allocate_entry( + PurseId::MAIN, + exponent, + Timestamp(0), + core::time::Duration::ZERO, + ) + .expect("purse exists"); + + let (_scripted, rpc) = scripted(&[ + "\"0x04\"".to_string(), + format!( + "\"0x{}\"", + hex::encode( + storage::RingPosition::Onboarding { + queue_page: 0, + queued_at: 1_700_000_000, + } + .encode() + ) + ), + ]); + let metadata = Metadata::decode(include_bytes!( + "../../../tests/fixtures/paseo-next-v2-metadata.scale" + )) + .expect("the fixture decodes"); + + block_on(refresh_purse( + &rpc, + &metadata, + &mut store, + &ENTROPY, + PurseId::MAIN, + &CoinageParameters::default(), + AT, + )) + .expect("refreshes"); + + // No ring means nothing to unload from, so the value stays pending + // rather than being offered to selection. + assert!( + !store + .entry(PurseId::MAIN, entry) + .expect("exists") + .is_selectable(Timestamp(0), true) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/persistence.rs b/rust/crates/truapi-server/src/runtime/coinage/persistence.rs new file mode 100644 index 000000000..5b5f3e13f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/persistence.rs @@ -0,0 +1,296 @@ +//! Durable persistence for the coinage record store. +//! +//! The whole store lives in one `CoreStorageKey::CoinageState` slot rather than +//! a slot per record: a write is then atomic from the host's point of view, and +//! the host needs no key enumeration to hand the layer its state back. The cost +//! is that every mutation re-encodes everything, which is fine at testnet purse +//! sizes and will need revisiting before a purse holds thousands of records. +//! +//! # Why publishing is bundled with persisting +//! +//! `coinage-layer.md` §7.9 requires events to be drained and published *before* +//! the store is persisted. A terminal operation drops its record as soon as its +//! status is emitted, so persisting first and publishing second loses the +//! receipt and the record together if the process dies in between — the +//! operation would simply never have happened as far as any later reader is +//! concerned. Publishing first degrades to a duplicate event after a crash, +//! which subscribers absorb and recovery resolves. +//! +//! That ordering is a rule no type can enforce on its own, so this module does +//! not expose a bare `persist`. [`publish_and_persist`] is the only way to write +//! the store, and it takes the publisher as an argument, which makes the safe +//! order the only reachable one. + +use parity_scale_codec::{Decode, Encode}; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::event::LayerEvent; +use crate::host_logic::coinage::store::CoinageStore; + +/// Read the store back, or build a fresh one if the slot is empty. +/// +/// An empty slot is first run, not an error: the main purse exists by +/// construction once entropy is present (`coinage-layer.md` §13). +pub async fn load( + storage: &S, + main_purse_name: &str, +) -> Result { + let raw = storage + .read_core_storage(CoreStorageKey::CoinageState) + .await + .map_err(|error| { + CoinageError::StorageError(format!("reading coinage state: {}", error.reason)) + })?; + + match raw { + None => Ok(CoinageStore::new(main_purse_name.to_string())), + Some(bytes) => CoinageStore::decode(&mut &bytes[..]).map_err(|error| { + // Deliberately fatal rather than falling back to an empty store: a + // fresh store would re-derive from index zero and hand out account + // identifiers that are already on chain, breaking the no-reuse + // invariant of §4.3. Losing the records is recoverable by scanning; + // reusing an index is not. + CoinageError::StorageError(format!("decoding coinage state: {error}")) + }), + } +} + +/// Publish everything the store has observed, then persist it. +/// +/// `publish` receives the drained events in order, together with the store they +/// came from: a balance is a projection of every record in a purse rather than +/// anything an event can carry, so the publisher needs the store to reproject +/// the derived subscription streams. It runs before the write, so a crash +/// between the two costs a duplicate event rather than a lost receipt. A failed +/// write leaves the in-memory store ahead of the durable one; the caller should +/// treat that as fatal for the operation in flight and let recovery reconcile on +/// the next start. +pub async fn publish_and_persist( + storage: &S, + store: &mut CoinageStore, + publish: P, +) -> Result<(), CoinageError> +where + S: CoreStorage + ?Sized, + P: FnOnce(Vec, &CoinageStore), +{ + let events = store.take_events(); + publish(events, store); + + storage + .write_core_storage(CoreStorageKey::CoinageState, store.encode()) + .await + .map_err(|error| { + CoinageError::StorageError(format!("writing coinage state: {}", error.reason)) + }) +} + +/// Drop the persisted store. +/// +/// Only for a host discarding its identity: the records are the only local +/// witness to coins whose accounts are already on chain, so clearing this slot +/// without also discarding the entropy strands them until a wallet recovery +/// scan (§8.10) finds them again. +pub async fn clear(storage: &S) -> Result<(), CoinageError> { + storage + .clear_core_storage(CoreStorageKey::CoinageState) + .await + .map_err(|error| { + CoinageError::StorageError(format!("clearing coinage state: {}", error.reason)) + }) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use truapi::v01; + + use crate::host_logic::coinage::types::{CoinAge, DenominationExponent, PurseId}; + + use super::*; + + #[derive(Default)] + struct MemStorage { + inner: Mutex, Vec>>, + fail_writes: bool, + } + + impl MemStorage { + fn failing() -> Self { + Self { + fail_writes: true, + ..Self::default() + } + } + + fn slot(&self) -> Option> { + self.inner + .lock() + .unwrap() + .get(&CoreStorageKey::CoinageState.encode()) + .cloned() + } + } + + #[truapi_platform::async_trait] + impl CoreStorage for MemStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(self.inner.lock().unwrap().get(&key.encode()).cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + if self.fail_writes { + return Err(v01::GenericError { + reason: "disk full".to_string(), + }); + } + self.inner.lock().unwrap().insert(key.encode(), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + self.inner.lock().unwrap().remove(&key.encode()); + Ok(()) + } + } + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn block_on(future: F) -> F::Output { + futures::executor::block_on(future) + } + + #[test] + fn an_empty_slot_yields_a_store_holding_only_the_main_purse() { + let storage = MemStorage::default(); + + let store = block_on(load(&storage, "Main")).expect("first run is not an error"); + + assert_eq!(store.purses().count(), 1); + assert_eq!(store.purse(PurseId::MAIN).expect("exists").name, "Main"); + } + + #[test] + fn records_and_index_counters_survive_a_round_trip() { + let storage = MemStorage::default(); + let mut store = block_on(load(&storage, "Main")).expect("loads"); + let savings = store.create_purse("Savings".to_string()); + let coin = store + .add_pending_coin(savings, exponent(4)) + .expect("purse exists"); + store + .observe_coin(savings, coin, CoinAge(3)) + .expect("coin exists"); + + block_on(publish_and_persist(&storage, &mut store, |_, _| {})).expect("persists"); + let reloaded = block_on(load(&storage, "Main")).expect("loads"); + + assert_eq!(reloaded.purse(savings).expect("exists").name, "Savings"); + assert_eq!( + reloaded.coin(savings, coin).expect("exists").age, + CoinAge(3) + ); + // The counter matters more than the record: a reloaded store that + // restarted its indices would re-derive accounts already on chain. + let mut reloaded = reloaded; + let next = reloaded + .add_pending_coin(savings, exponent(4)) + .expect("purse exists"); + assert_ne!(next, coin, "the index counter survived"); + } + + #[test] + fn events_are_published_before_the_store_is_written() { + // The ordering §7.9 requires. Asserted by observing that the slot is + // still empty at the moment the publisher runs. + let storage = MemStorage::default(); + let mut store = block_on(load(&storage, "Main")).expect("loads"); + store.create_purse("Savings".to_string()); + let mut slot_when_published = Some(vec![0xff]); + + block_on(publish_and_persist(&storage, &mut store, |events, _| { + slot_when_published = storage.slot(); + assert!( + events + .iter() + .any(|event| matches!(event, LayerEvent::PurseCreated { .. })), + "the publisher sees the drained events" + ); + })) + .expect("persists"); + + assert_eq!(slot_when_published, None, "published before the write"); + assert!(storage.slot().is_some(), "and written afterwards"); + } + + #[test] + fn events_are_drained_exactly_once() { + let storage = MemStorage::default(); + let mut store = block_on(load(&storage, "Main")).expect("loads"); + store.create_purse("Savings".to_string()); + + let mut first = Vec::new(); + block_on(publish_and_persist(&storage, &mut store, |events, _| { + first = events; + })) + .expect("persists"); + let mut second = Vec::new(); + block_on(publish_and_persist(&storage, &mut store, |events, _| { + second = events; + })) + .expect("persists"); + + assert_eq!(first.len(), 1); + assert!(second.is_empty(), "a second write republishes nothing"); + } + + #[test] + fn a_corrupt_slot_fails_rather_than_resetting_the_index_counters() { + // Falling back to a fresh store would re-derive from index zero and + // hand out account identifiers that already hold coins on chain. + let storage = MemStorage::default(); + storage + .inner + .lock() + .unwrap() + .insert(CoreStorageKey::CoinageState.encode(), vec![0xff; 8]); + + let error = block_on(load(&storage, "Main")).expect_err("refuses to guess"); + + assert!(matches!(error, CoinageError::StorageError(_))); + } + + #[test] + fn a_failed_write_is_reported() { + let storage = MemStorage::failing(); + let mut store = CoinageStore::new("Main".to_string()); + + let error = block_on(publish_and_persist(&storage, &mut store, |_, _| {})) + .expect_err("write fails"); + + assert!(matches!(error, CoinageError::StorageError(_))); + } + + #[test] + fn clearing_removes_the_slot() { + let storage = MemStorage::default(); + let mut store = CoinageStore::new("Main".to_string()); + block_on(publish_and_persist(&storage, &mut store, |_, _| {})).expect("persists"); + + block_on(clear(&storage)).expect("clears"); + + assert_eq!(storage.slot(), None); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/plan.rs b/rust/crates/truapi-server/src/runtime/coinage/plan.rs new file mode 100644 index 000000000..d6b89719c --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/plan.rs @@ -0,0 +1,994 @@ +//! Turning a selection plan into the transactions that carry it out. +//! +//! Selection says *which records* to spend (`coinage-layer.md` §6.3). This module +//! says *which extrinsics* that becomes: one per whole coin transferred, one for +//! a split, one per unload group. It allocates the coin records the layer expects +//! to receive, and records for each produced coin where it should land. +//! +//! Planning performs no I/O and touches no chain. It mutates only the store, and +//! only to take derivation indices for the outputs — which must happen before +//! anything is broadcast, because an index handed out twice would derive an +//! account that is already on chain (§4.3). +//! +//! # Why these transactions are independent +//! +//! Both `Coinage::split` and `Coinage::unload_recycler_into_coins` name a +//! destination account per produced coin. A payment therefore mints its outputs +//! *straight into the recipient's accounts*, and never needs the two-step "mint +//! to myself, then transfer" that would make the second transaction depend on the +//! first. So a plan of this shape carries no `depends_on` edges: every transaction +//! stands alone, each is one atomic chain-side effect, and a failure of one does +//! not orphan another. +//! +//! Dependencies are still modelled, because §7.5 is about the general case and +//! external offload (§8.6) does chain transactions: entries produced by its +//! recycle phase are inputs to its offboard phase. + +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::operation::LockSet; +use crate::host_logic::coinage::selection::SelectionPlan; +use crate::host_logic::coinage::store::CoinageStore; +use crate::host_logic::coinage::types::{ + CoinAccountId, CoinIndex, DenominationExponent, EntryIndex, PurseId, RingLocation, +}; +use crate::runtime::coinage::call::CoinOutput; + +/// Where a produced coin should land. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Destination { + /// An account this layer does not control: a transfer recipient. + /// + /// The layer keeps no record for it and cannot observe it, which is why a + /// transaction whose every output is external is resolved by asking whether + /// its *inputs* were consumed (§7.7). + External(CoinAccountId), + /// A fresh coin record in one of the layer's purses. + Local { + /// Purse the record belongs to. + purse: PurseId, + /// Index allocated for it. + index: CoinIndex, + }, +} + +/// One coin a transaction will create. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlannedOutput { + /// Denomination to mint. + pub exponent: DenominationExponent, + /// Where it goes. + pub destination: Destination, +} + +/// What one planned transaction asks the chain to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransactionKind { + /// `Coinage::transfer`: move one coin, whole, to a single account. + Transfer { + /// Coin that authorizes the call and is consumed by it. + source: (PurseId, CoinIndex), + /// Where it lands. A whole-coin transfer preserves the denomination, so + /// the output's exponent is the source coin's. + to: PlannedOutput, + }, + /// `Coinage::split`: divide one coin into the named destinations. + Split { + /// Coin that authorizes the call and is consumed by it. + source: (PurseId, CoinIndex), + /// Its denomination, which the outputs must sum to exactly. + source_exponent: DenominationExponent, + /// Coins to create. + outputs: Vec, + }, + /// `Coinage::transfer` from a coin the caller supplied the secret for, into + /// one of our purses (§8.5). + /// + /// The origin is not one of our records, so the layer signs with the supplied + /// secret rather than a derived key, and the log entry has no inputs to + /// revert: a failed import leaves the coin where it was, still under whatever + /// secret the caller holds. + ImportTransfer { + /// Position of the secret in the operation's supplied list. + /// + /// A position rather than the secret itself: a plan is compared, printed + /// and held in memory, and none of those should ever touch key material. + secret: usize, + /// Account the coin sits in now. + from: CoinAccountId, + /// Where it is going, in one of our purses. + to: PlannedOutput, + }, + /// `Coinage::load_recycler_with_coin`: turn one coin into a fresh recycler + /// entry (§6.4). + /// + /// The coin is the origin and is consumed; the entry is the output. No unload + /// token is involved — tokens are spent going the other way. + Recycle { + /// Coin that authorizes the call and is consumed by it. + source: (PurseId, CoinIndex), + /// Entry record allocated for what the coin becomes. + entry: (PurseId, EntryIndex), + }, + /// `Coinage::load_recycler_with_external_asset_unpaid_batch`: turn an + /// externally held asset into recycler entries (§8.2). + /// + /// One extrinsic for the whole top-up, signed by the account holding the + /// external asset rather than by anything of ours. + TopUpLoad { + /// Purse the entries land in. + purse: PurseId, + /// Entries to create, with the records allocated for them. + entries: Vec<(DenominationExponent, EntryIndex)>, + }, + /// `Coinage::unload_recycler_into_external_asset_and_vouchers`: send one + /// group's value out of coinage (§8.6). + /// + /// Whatever the group carries beyond `payout` is reloaded into the `vouchers` + /// by the same extrinsic. Letting surplus land as a coin would tie the + /// entry-side anonymity set to a fresh account. + Offboard { + /// Purse holding the entries. + purse: PurseId, + /// Ring the entries sit in. + ring: RingLocation, + /// Denomination shared by the group. + exponent: DenominationExponent, + /// Entries to consume. + entries: Vec, + /// Account outside coinage receiving the value. + destination: CoinAccountId, + /// How much of the group goes to the destination. + payout: crate::host_logic::coinage::types::Amount, + /// Fresh entries for the remainder, with the records allocated for them. + vouchers: Vec<(DenominationExponent, EntryIndex)>, + }, + /// `Coinage::unload_recycler_into_coins`: turn one group of entries into + /// coins, consuming an unload token. + Unload { + /// Purse holding the entries. + purse: PurseId, + /// Ring the entries sit in, at the revision proofs are built against. + ring: RingLocation, + /// Denomination shared by the group. + exponent: DenominationExponent, + /// Entries to consume. + entries: Vec, + /// Coins to create. + outputs: Vec, + }, +} + +impl TransactionKind { + /// A short label for diagnostics. + pub const fn label(&self) -> &'static str { + match self { + Self::Transfer { .. } => "transfer", + Self::ImportTransfer { .. } => "import", + Self::Recycle { .. } => "recycle", + Self::Offboard { .. } => "offboard", + Self::TopUpLoad { .. } => "top-up", + Self::Split { .. } => "split", + Self::Unload { .. } => "unload", + } + } + + /// The coins this transaction will create, in call order. + pub fn outputs(&self) -> Vec { + match self { + Self::Transfer { to, .. } | Self::ImportTransfer { to, .. } => vec![*to], + // These produce entries, not coins. + Self::Recycle { .. } | Self::Offboard { .. } | Self::TopUpLoad { .. } => Vec::new(), + Self::Split { outputs, .. } | Self::Unload { outputs, .. } => outputs.clone(), + } + } +} + +/// One transaction, with everything the write-ahead log needs to describe it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlannedTransaction { + /// What the chain is being asked to do. + pub kind: TransactionKind, + /// Records the transaction consumes. + pub inputs: LockSet, + /// Records the layer expects it to create. Only the layer's own records + /// appear: an external recipient's coin is not ours to observe. + pub outputs: LockSet, + /// Sequences whose outputs this transaction spends (§7.5). + pub depends_on: Vec, + /// Coins this transaction materializes that are to be exported once it has + /// **definitely** succeeded (§8.4). + /// + /// Emitting a secret on optimistic inclusion would hand out control of a coin + /// a reorg could remove, so the list is kept here rather than acted on when + /// the transaction is built. + pub exports: Vec<(PurseId, CoinIndex)>, +} + +/// The transactions one operation will submit, in submission order. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OperationProgram { + /// Transactions, in the order they are submitted. + pub transactions: Vec, + /// Coins already on chain in the right shape, to be exported as they are. + /// + /// No transaction materializes these, so nothing has to settle before their + /// secrets can be handed out. + pub exports_in_place: Vec<(PurseId, CoinIndex)>, +} + +impl OperationProgram { + /// How many transactions the operation will submit. + pub fn len(&self) -> usize { + self.transactions.len() + } + + /// Whether the operation has nothing to submit. + pub fn is_empty(&self) -> bool { + self.transactions.is_empty() + } + + /// How many unload tokens the program consumes. + pub fn unload_tokens_required(&self) -> usize { + self.transactions + .iter() + .filter(|transaction| matches!(transaction.kind, TransactionKind::Unload { .. })) + .count() + } +} + +/// Where the value an operation produces is meant to end up. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TargetDestinations { + /// Named accounts outside the layer, one coin per entry. + /// + /// Used by transfer: each output is destined for a separately named recipient, + /// so the produced denominations must match these exactly. + Recipients(Vec), + /// Fresh records in one of the layer's purses. + /// + /// Used by rebalance, where the coins stay with the layer, so their shape is + /// free. + IntoPurse(PurseId), + /// Coins to be handed out under their own secrets, staying in `purse`. + /// + /// Export uses this. A coin already in the right shape needs *no transaction + /// at all*: handing over its secret transfers control of it without touching + /// the chain. Only value that has to be reshaped — a split, an unload — costs + /// an extrinsic. + Export(PurseId), +} + +/// Plan the transactions that carry out `selection` from `purse`. +/// +/// Change always returns to `purse`, whatever the targets do: it is value that +/// never left, and routing it elsewhere would move funds the caller did not ask +/// to move. +pub fn plan_operation( + store: &mut CoinageStore, + purse: PurseId, + selection: &SelectionPlan, + targets: &TargetDestinations, +) -> Result { + let mut assignment = TargetAssignment::new(targets); + let mut transactions = Vec::new(); + let mut exports_in_place = Vec::new(); + + // Whole coins move as they are, one transfer each. A coin bound for one of + // our own purses still moves on chain: its destination account is derived in + // that purse's namespace, which is what keeps two purses uncorrelated. + // + // An export is the exception: the coin is already the right shape and already + // ours, so control of it changes hands with the secret and nothing is + // submitted. + for coin in &selection.whole_coins { + if matches!(targets, TargetDestinations::Export(_)) { + exports_in_place.push((purse, coin.index)); + continue; + } + + let to = PlannedOutput { + exponent: coin.exponent, + destination: assignment.take(store, coin.exponent)?, + }; + + transactions.push(PlannedTransaction { + kind: TransactionKind::Transfer { + source: (purse, coin.index), + to, + }, + inputs: LockSet { + coins: vec![(purse, coin.index)], + entries: Vec::new(), + }, + outputs: local_locks(&[to]), + depends_on: Vec::new(), + exports: Vec::new(), + }); + } + + // A split delivers its share of the targets and returns its change. + if let Some(step) = &selection.split { + let mut outputs = Vec::new(); + for exponent in &step.target_outputs { + outputs.push(PlannedOutput { + exponent: *exponent, + destination: assignment.take(store, *exponent)?, + }); + } + for exponent in &step.change_outputs { + outputs.push(change_output(store, purse, *exponent)?); + } + + transactions.push(PlannedTransaction { + kind: TransactionKind::Split { + source: (purse, step.coin.index), + source_exponent: step.coin.exponent, + outputs: outputs.clone(), + }, + inputs: LockSet { + coins: vec![(purse, step.coin.index)], + entries: Vec::new(), + }, + outputs: local_locks(&outputs), + depends_on: Vec::new(), + exports: exported(targets, &outputs, &step.target_outputs), + }); + } + + // Each unload group is one atomic extrinsic carrying one token. + for group in &selection.unloads { + let mut outputs = Vec::new(); + for exponent in &group.target_outputs { + outputs.push(PlannedOutput { + exponent: *exponent, + destination: assignment.take(store, *exponent)?, + }); + } + for exponent in &group.change_outputs { + outputs.push(change_output(store, purse, *exponent)?); + } + + transactions.push(PlannedTransaction { + kind: TransactionKind::Unload { + purse, + ring: group.ring, + exponent: group.exponent, + entries: group.entries.clone(), + outputs: outputs.clone(), + }, + inputs: LockSet { + coins: Vec::new(), + entries: group.entries.iter().map(|index| (purse, *index)).collect(), + }, + outputs: local_locks(&outputs), + depends_on: Vec::new(), + exports: exported(targets, &outputs, &group.target_outputs), + }); + } + + assignment.finish()?; + Ok(OperationProgram { + transactions, + exports_in_place, + }) +} + +/// Plan the transactions that bring externally held coins into `into` (§8.5). +/// +/// One transaction per coin, each independent: a bad secret or a sniped coin costs +/// that coin and no other, which is what makes partial success the normal outcome +/// rather than a failure mode. +/// +/// `coins` pairs each coin's account with the denomination the chain reports for +/// it. Nothing is selected and nothing is locked — the inputs belong to whoever +/// holds the secret, not to this layer. +pub fn plan_import( + store: &mut CoinageStore, + into: PurseId, + coins: &[(CoinAccountId, DenominationExponent)], +) -> Result { + let mut transactions = Vec::new(); + + for (position, (from, exponent)) in coins.iter().enumerate() { + let index = store.add_pending_coin(into, *exponent)?; + let to = PlannedOutput { + exponent: *exponent, + destination: Destination::Local { purse: into, index }, + }; + + transactions.push(PlannedTransaction { + kind: TransactionKind::ImportTransfer { + secret: position, + from: *from, + to, + }, + inputs: LockSet::default(), + outputs: local_locks(&[to]), + depends_on: Vec::new(), + exports: Vec::new(), + }); + } + + Ok(OperationProgram { + transactions, + exports_in_place: Vec::new(), + }) +} + +/// One purse's share of a maintenance sweep, already chosen by the caller. +/// +/// The caller picks the records because picking needs the clock and the chain +/// constants; this module turns the choice into transactions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SweepWork { + /// Purse the work belongs to. + pub purse: PurseId, + /// Coins old enough to recycle, oldest first. + pub aging_coins: Vec<(CoinIndex, DenominationExponent)>, + /// Entries whose ring is close to expiry, grouped as they will be unloaded. + pub rescues: Vec, +} + +/// Entries of one denomination in one ring, to be rescued together. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RescueGroup { + /// Where the entries sit on chain. + pub ring: RingLocation, + /// Denomination shared by the group. + pub exponent: DenominationExponent, + /// Entries to unload. + pub entries: Vec, +} + +/// Plan both sweeps for the given purses (§6.4, §8.7). +/// +/// The two directions are planned together and in this order — coin to entry +/// first, entry to coin second — because that is the order in which they free +/// something up: a rescue mints coins, and a coin minted now is not old enough to +/// recycle, so nothing planned here can undo anything else planned here. +/// +/// `jitter` supplies each new entry's readiness delay, one draw per aging coin, in +/// order. The store holds no randomness source and this module reads no clock, so +/// both arrive from the caller. +pub fn plan_maintenance( + store: &mut CoinageStore, + work: &[SweepWork], + now: crate::host_logic::coinage::types::Timestamp, + jitter: &[core::time::Duration], +) -> Result { + let mut transactions = Vec::new(); + let mut draws = jitter.iter().copied(); + + for purse_work in work { + let purse = purse_work.purse; + + for (coin, exponent) in &purse_work.aging_coins { + let delay = draws.next().ok_or_else(|| { + CoinageError::Internal( + "a maintenance sweep needs one jitter draw per recycled coin".to_string(), + ) + })?; + let entry = store.allocate_entry(purse, *exponent, now, delay)?; + + transactions.push(PlannedTransaction { + kind: TransactionKind::Recycle { + source: (purse, *coin), + entry: (purse, entry), + }, + inputs: LockSet { + coins: vec![(purse, *coin)], + entries: Vec::new(), + }, + outputs: LockSet { + coins: Vec::new(), + entries: vec![(purse, entry)], + }, + depends_on: Vec::new(), + exports: Vec::new(), + }); + } + + for group in &purse_work.rescues { + // A rescue returns the value to the same purse as coins, so every + // output is one of ours and the group's value is conserved. + let mut outputs = Vec::new(); + let total = group.entries.len(); + for _ in 0..total { + outputs.push(change_output(store, purse, group.exponent)?); + } + + transactions.push(PlannedTransaction { + kind: TransactionKind::Unload { + purse, + ring: group.ring, + exponent: group.exponent, + entries: group.entries.clone(), + outputs: outputs.clone(), + }, + inputs: LockSet { + coins: Vec::new(), + entries: group.entries.iter().map(|index| (purse, *index)).collect(), + }, + outputs: local_locks(&outputs), + depends_on: Vec::new(), + exports: Vec::new(), + }); + } + } + + Ok(OperationProgram { + transactions, + exports_in_place: Vec::new(), + }) +} + +/// The records among `outputs` that an export hands out, being the ones that went +/// toward the request rather than back as change. +/// +/// Change is never exported: it is value the caller did not ask to move, so it +/// stays under the layer's control. +fn exported( + targets: &TargetDestinations, + outputs: &[PlannedOutput], + target_outputs: &[DenominationExponent], +) -> Vec<(PurseId, CoinIndex)> { + if !matches!(targets, TargetDestinations::Export(_)) { + return Vec::new(); + } + + outputs + .iter() + .take(target_outputs.len()) + .filter_map(|output| match output.destination { + Destination::Local { purse, index } => Some((purse, index)), + Destination::External(_) => None, + }) + .collect() +} + +/// Allocate a record for change coming back to `purse`. +fn change_output( + store: &mut CoinageStore, + purse: PurseId, + exponent: DenominationExponent, +) -> Result { + let index = store.add_pending_coin(purse, exponent)?; + Ok(PlannedOutput { + exponent, + destination: Destination::Local { purse, index }, + }) +} + +/// The subset of outputs the layer keeps records for. +fn local_locks(outputs: &[PlannedOutput]) -> LockSet { + LockSet { + coins: outputs + .iter() + .filter_map(|output| match output.destination { + Destination::Local { purse, index } => Some((purse, index)), + Destination::External(_) => None, + }) + .collect(), + entries: Vec::new(), + } +} + +/// Hands out one destination per produced target denomination. +/// +/// Under named recipients the assignment is by denomination and each recipient is +/// used exactly once, so a plan that produced the wrong shape is caught here +/// rather than by the runtime after a coin has been consumed. +struct TargetAssignment<'a> { + targets: &'a TargetDestinations, + unclaimed: Vec, +} + +impl<'a> TargetAssignment<'a> { + fn new(targets: &'a TargetDestinations) -> Self { + let unclaimed = match targets { + TargetDestinations::Recipients(outputs) => outputs.clone(), + TargetDestinations::IntoPurse(_) | TargetDestinations::Export(_) => Vec::new(), + }; + Self { targets, unclaimed } + } + + /// The destination for one produced coin of `exponent`. + fn take( + &mut self, + store: &mut CoinageStore, + exponent: DenominationExponent, + ) -> Result { + match self.targets { + TargetDestinations::Recipients(_) => { + let position = self + .unclaimed + .iter() + .position(|output| output.exponent == exponent) + .ok_or(CoinageError::OutputsDoNotSumToAmount)?; + Ok(Destination::External( + self.unclaimed.remove(position).account, + )) + } + TargetDestinations::IntoPurse(purse) | TargetDestinations::Export(purse) => { + let index = store.add_pending_coin(*purse, exponent)?; + Ok(Destination::Local { + purse: *purse, + index, + }) + } + } + } + + /// Every named recipient must have been served. + fn finish(self) -> Result<(), CoinageError> { + if self.unclaimed.is_empty() { + Ok(()) + } else { + Err(CoinageError::OutputsDoNotSumToAmount) + } + } +} + +#[cfg(test)] +mod tests { + use core::time::Duration; + + use crate::host_logic::coinage::chain_constants::next_people_paseo; + use crate::host_logic::coinage::params::CoinageParameters; + use crate::host_logic::coinage::selection::{OutputRequirement, SelectionRequest, select}; + use crate::host_logic::coinage::types::{Amount, CoinAge, RevisionIndex, RingIndex, Timestamp}; + + use super::*; + + const NOW: Timestamp = Timestamp(1_000_000); + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn store() -> CoinageStore { + CoinageStore::new("Main".to_string()) + } + + /// A coin the chain already reports populated. + fn fund(store: &mut CoinageStore, purse: PurseId, exponent_value: i8) -> CoinIndex { + let index = store + .add_pending_coin(purse, exponent(exponent_value)) + .expect("purse exists"); + store + .observe_coin(purse, index, CoinAge(0)) + .expect("coin exists"); + index + } + + /// A selectable entry in a well-populated ring. + fn load_entry(store: &mut CoinageStore, purse: PurseId, exponent_value: i8, ring: u32) { + let index = store + .allocate_entry(purse, exponent(exponent_value), NOW, Duration::ZERO) + .expect("purse exists"); + store + .observe_entry_ring( + purse, + index, + RingLocation::new(RingIndex(ring), RevisionIndex(0)), + 64, + &CoinageParameters::default(), + ) + .expect("entry exists"); + } + + fn recipient(exponent_value: i8, byte: u8) -> CoinOutput { + CoinOutput { + exponent: exponent(exponent_value), + account: CoinAccountId([byte; 32]), + } + } + + /// Select for a named-recipient request, the way a transfer does. + fn select_exact( + store: &CoinageStore, + purse: PurseId, + recipients: &[CoinOutput], + ) -> SelectionPlan { + let amount = recipients + .iter() + .map(|output| output.exponent.value()) + .fold(Amount::ZERO, |total, value| { + total.checked_add(value).expect("no overflow") + }); + select( + &SelectionRequest { + amount, + outputs: OutputRequirement::Exact( + recipients.iter().map(|output| output.exponent).collect(), + ), + allow_degraded: true, + }, + &store.coins_in(purse), + &store.entries_in(purse), + &next_people_paseo(), + NOW, + ) + .expect("selection succeeds") + } + + #[test] + fn an_exact_match_becomes_one_transfer_per_coin() { + let mut store = store(); + let first = fund(&mut store, PurseId::MAIN, 4); + let second = fund(&mut store, PurseId::MAIN, 4); + let recipients = vec![recipient(4, 0xaa), recipient(4, 0xbb)]; + let selection = select_exact(&store, PurseId::MAIN, &recipients); + + let program = plan_operation( + &mut store, + PurseId::MAIN, + &selection, + &TargetDestinations::Recipients(recipients), + ) + .expect("plans"); + + assert_eq!(program.len(), 2); + assert_eq!(program.unload_tokens_required(), 0); + for transaction in &program.transactions { + assert!(matches!(transaction.kind, TransactionKind::Transfer { .. })); + // Nothing depends on anything: each coin moves on its own. + assert!(transaction.depends_on.is_empty()); + // The recipient's coin is not ours, so the log expects no output. + assert!(transaction.outputs.coins.is_empty()); + assert!(matches!( + transaction.kind.outputs()[0].destination, + Destination::External(_) + )); + assert_eq!(transaction.inputs.coins.len(), 1); + } + let sources: Vec = program + .transactions + .iter() + .map(|transaction| match transaction.kind { + TransactionKind::Transfer { source, .. } => source.1, + ref other => panic!("unexpected {other:?}"), + }) + .collect(); + assert_eq!(sources, vec![first, second]); + } + + #[test] + fn a_split_delivers_to_the_recipient_and_keeps_its_change() { + // One 16-cent coin paying an 8-cent recipient: the split mints the + // recipient's coin directly and returns 8 cents of change to us. + let mut store = store(); + let source = fund(&mut store, PurseId::MAIN, 4); + let recipients = vec![recipient(3, 0xcc)]; + let selection = select_exact(&store, PurseId::MAIN, &recipients); + + let program = plan_operation( + &mut store, + PurseId::MAIN, + &selection, + &TargetDestinations::Recipients(recipients), + ) + .expect("plans"); + + assert_eq!(program.len(), 1); + let transaction = &program.transactions[0]; + let TransactionKind::Split { + source: split_source, + source_exponent, + outputs, + } = &transaction.kind + else { + panic!("expected a split, got {:?}", transaction.kind); + }; + assert_eq!(*split_source, (PurseId::MAIN, source)); + assert_eq!(*source_exponent, exponent(4)); + + // Value is conserved across the split, which the pallet requires. + let produced: Amount = outputs + .iter() + .map(|output| output.exponent.value()) + .fold(Amount::ZERO, |total, value| { + total.checked_add(value).expect("no overflow") + }); + assert_eq!(produced, exponent(4).value()); + + // One output leaves, one comes back as a record of ours. + assert_eq!( + outputs + .iter() + .filter(|output| matches!(output.destination, Destination::External(_))) + .count(), + 1 + ); + assert_eq!(transaction.outputs.coins.len(), 1); + let (purse, index) = transaction.outputs.coins[0]; + assert_eq!(purse, PurseId::MAIN); + assert_eq!( + store.coin(purse, index).expect("record exists").exponent, + exponent(3), + "the change record is minted as pending before anything is broadcast" + ); + } + + #[test] + fn an_unload_group_becomes_one_transaction_carrying_one_token() { + let mut store = store(); + load_entry(&mut store, PurseId::MAIN, 4, 3); + load_entry(&mut store, PurseId::MAIN, 4, 3); + let recipients = vec![recipient(4, 0xdd)]; + let selection = select_exact(&store, PurseId::MAIN, &recipients); + + let program = plan_operation( + &mut store, + PurseId::MAIN, + &selection, + &TargetDestinations::Recipients(recipients), + ) + .expect("plans"); + + assert_eq!(program.len(), 1); + assert_eq!( + program.unload_tokens_required(), + 1, + "one group, one token, whatever the group's size" + ); + let transaction = &program.transactions[0]; + let TransactionKind::Unload { + entries, outputs, .. + } = &transaction.kind + else { + panic!("expected an unload, got {:?}", transaction.kind); + }; + assert_eq!( + entries.len(), + 1, + "one 16-cent entry covers a 16-cent target" + ); + assert_eq!(transaction.inputs.entries.len(), entries.len()); + assert_eq!(outputs.len(), 1, "no change: the group is exactly spent"); + assert!(matches!(outputs[0].destination, Destination::External(_))); + } + + #[test] + fn a_rebalance_routes_every_target_into_the_destination_purse() { + let mut store = store(); + let savings = store.create_purse("Savings".to_string()); + fund(&mut store, PurseId::MAIN, 4); + let selection = select( + &SelectionRequest { + amount: Amount::from_cents(16), + outputs: OutputRequirement::AnyDenominations, + allow_degraded: true, + }, + &store.coins_in(PurseId::MAIN), + &store.entries_in(PurseId::MAIN), + &next_people_paseo(), + NOW, + ) + .expect("selection succeeds"); + + let program = plan_operation( + &mut store, + PurseId::MAIN, + &selection, + &TargetDestinations::IntoPurse(savings), + ) + .expect("plans"); + + assert_eq!(program.len(), 1); + let transaction = &program.transactions[0]; + match transaction.kind { + TransactionKind::Transfer { source, to } => { + assert_eq!(source, (PurseId::MAIN, CoinIndex(0))); + // The destination record is allocated in the *target* purse's + // namespace, which is what keeps the two purses uncorrelated. + assert_eq!( + to.destination, + Destination::Local { + purse: savings, + index: CoinIndex(0) + } + ); + assert_eq!(to.exponent, exponent(4)); + } + ref other => panic!("unexpected {other:?}"), + } + // And it *is* one of our records, so the log expects it. + assert_eq!(transaction.outputs.coins, vec![(savings, CoinIndex(0))]); + assert_eq!( + store + .coin(savings, CoinIndex(0)) + .expect("record exists") + .exponent, + exponent(4) + ); + } + + #[test] + fn a_recipient_the_plan_cannot_serve_is_refused_before_anything_moves() { + // Planning is the last point at which a shape mismatch is free. After + // this the coin is consumed by the extension whatever the pallet decides. + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let recipients = vec![recipient(4, 0xaa)]; + let selection = select_exact(&store, PurseId::MAIN, &recipients); + + let refused = plan_operation( + &mut store, + PurseId::MAIN, + &selection, + // A recipient wanting a denomination the plan never produces. + &TargetDestinations::Recipients(vec![recipient(2, 0xaa)]), + ) + .expect_err("the assignment does not balance"); + + assert_eq!(refused, CoinageError::OutputsDoNotSumToAmount); + } + + #[test] + fn an_unserved_recipient_is_refused() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let recipients = vec![recipient(4, 0xaa)]; + let selection = select_exact(&store, PurseId::MAIN, &recipients); + + let refused = plan_operation( + &mut store, + PurseId::MAIN, + &selection, + &TargetDestinations::Recipients(vec![recipient(4, 0xaa), recipient(4, 0xbb)]), + ) + .expect_err("one recipient would go unpaid"); + + assert_eq!(refused, CoinageError::OutputsDoNotSumToAmount); + } + + #[test] + fn planning_allocates_output_indices_before_anything_is_broadcast() { + // §7.4 step 1: local state moves first. An index handed out after a + // broadcast could be handed out twice. + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let recipients = vec![recipient(3, 0xcc)]; + let selection = select_exact(&store, PurseId::MAIN, &recipients); + let before = store.purse(PurseId::MAIN).expect("exists").next_coin_index; + + plan_operation( + &mut store, + PurseId::MAIN, + &selection, + &TargetDestinations::Recipients(recipients), + ) + .expect("plans"); + + let after = store.purse(PurseId::MAIN).expect("exists").next_coin_index; + assert!(after.0 > before.0, "the change index is spent"); + } + + #[test] + fn an_empty_selection_plans_nothing() { + let mut store = store(); + let selection = select( + &SelectionRequest { + amount: Amount::ZERO, + outputs: OutputRequirement::AnyDenominations, + allow_degraded: true, + }, + &[], + &[], + &next_people_paseo(), + NOW, + ) + .expect("zero is selectable"); + + let program = plan_operation( + &mut store, + PurseId::MAIN, + &selection, + &TargetDestinations::IntoPurse(PurseId::MAIN), + ) + .expect("plans"); + + assert!(program.is_empty()); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/proof.rs b/rust/crates/truapi-server/src/runtime/coinage/proof.rs new file mode 100644 index 000000000..f86eaf12c --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/proof.rs @@ -0,0 +1,425 @@ +//! Ring-VRF proofs for recycler entries and unload tokens. +//! +//! An unload presents two kinds of proof, and they are easy to confuse: +//! +//! * **Alias proofs** — one per entry being unloaded. Each proves the prover is +//! a member of the recycler ring and yields the entry's *contextual alias*, +//! which the call carries in its `aliases` argument. The proof and the alias +//! come out of the same operation, so this module returns them together +//! rather than letting a caller pair up mismatched halves. +//! * **The token proof** — one per extrinsic. Proves membership of whichever ring +//! backs the token, and signs a message that includes the alias proofs, which is +//! what binds the token to the exact set of entries it is spending on. A free +//! token proves personhood; a paid one proves membership of the period's paid +//! ring. The signed message is identical in both cases; only the ring, the key +//! and the context differ. +//! +//! Ring membership is the caller's input. Fetching the ring at a pinned block +//! belongs to the chain layer; proving is deterministic given the members. + +use parity_scale_codec::Encode; +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::call::RawEncoded; +use super::extension::{ + RECYCLER_ALIAS_CONTEXT, free_token_signing_context, paid_token_signing_context, +}; +use crate::host_logic::coinage::derivation; +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::types::{CoinAccountId, EntryIndex, PurseId}; +use crate::runtime::statement_allowance::extension::blake2b256; +use crate::runtime::statement_allowance::proof::ring_vrf_proof; + +/// One entry's contribution to an unload: its alias and the proof that earns it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryProof { + /// The entry whose proof this is. + pub index: EntryIndex, + /// Contextual alias, for the call's `aliases` argument. + pub alias: [u8; 32], + /// Ring-VRF membership proof, for the extension's `alias_proofs`. + pub proof: RawEncoded, +} + +/// The contextual alias a recycler entry presents, without proving anything. +/// +/// The same value a proof would yield, which is what lets a balance scan find an +/// entry's `RecyclersUnloaded` record without doing ring-VRF work. +pub fn recycler_alias( + entropy: &[u8], + purse: PurseId, + index: EntryIndex, +) -> Result<[u8; 32], CoinageError> { + let vrf_entropy = derivation::entry_ring_vrf_entropy(entropy, purse, index)?; + let secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let alias = BandersnatchVrfVerifiable::alias_in_context(&secret, RECYCLER_ALIAS_CONTEXT) + .map_err(|error| { + CoinageError::Internal(format!("recycler alias derivation failed: {error:?}")) + })?; + + alias + .as_ref() + .try_into() + .map_err(|_| CoinageError::Internal("recycler alias is not 32 bytes".to_string())) +} + +/// Prove one entry's ring membership, returning its alias alongside the proof. +/// +/// `members` is the recycler ring's included prefix. The entry's member key must +/// be in it or the prover fails, which is the honest outcome: an entry the chain +/// has not yet onboarded cannot be unloaded. +pub fn entry_membership_proof( + domain: RingDomainSize, + entropy: &[u8], + purse: PurseId, + index: EntryIndex, + members: &[[u8; 32]], + inherited_implication: &[u8], +) -> Result { + let vrf_entropy = derivation::entry_ring_vrf_entropy(entropy, purse, index)?; + let secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let commitment = BandersnatchVrfVerifiable::open(domain, &member, members.iter().copied()) + .map_err(|error| { + CoinageError::Internal(format!( + "ring-VRF open failed for entry {index:?}: {error:?}" + )) + })?; + + let message = blake2b256(inherited_implication); + let (proof, alias) = + BandersnatchVrfVerifiable::create(commitment, &secret, RECYCLER_ALIAS_CONTEXT, &message) + .map_err(|error| { + CoinageError::Internal(format!( + "ring-VRF create failed for entry {index:?}: {error:?}" + )) + })?; + + Ok(EntryProof { + index, + alias: alias + .as_ref() + .try_into() + .map_err(|_| CoinageError::Internal("recycler alias is not 32 bytes".to_string()))?, + proof: RawEncoded(proof.into_inner()), + }) +} + +/// Prove personhood for a free unload token. +/// +/// Note this is a different ring and a different key from the alias proofs: the +/// prover here is the user's personhood member key in the People or LitePeople +/// ring, not any recycler entry. `members` must be that ring, and +/// `personhood_entropy` the bandersnatch entropy behind the personhood key. +/// +/// The signed message covers the alias proofs, so the token is bound to exactly +/// the entries it is being spent on and cannot be replayed against a different +/// set. `alias_proofs` must therefore be the same slice, in the same order, that +/// the extension will carry. +pub fn free_token_proof( + domain: RingDomainSize, + personhood_entropy: [u8; 32], + members: &[[u8; 32]], + period: u32, + counter: u32, + alias_proofs: &[RawEncoded], + inherited_implication: &[u8], +) -> Result { + let context = free_token_signing_context(period, counter); + + let mut signed = alias_proofs.encode(); + signed.extend_from_slice(inherited_implication); + let message = blake2b256(&signed); + + let proof = ring_vrf_proof(domain, personhood_entropy, members, &context, &message).map_err( + |error| CoinageError::Internal(format!("free-token personhood proof failed: {error}")), + )?; + + Ok(RawEncoded(proof)) +} + +/// Prove membership of the paid ring for a paid unload token. +/// +/// `members` must be the paid-token ring the slot's key was onboarded into — a +/// different collection from both the recycler rings and the personhood ring — and +/// the domain must come from that collection's own ring size. +/// +/// The signed message is the same as a free token's, so a paid token is bound to +/// its entries in exactly the same way. What differs is the context, which carries +/// the period and no counter: the slot is expressed by *which key signs*, not by +/// anything inside the proof. +pub fn paid_token_proof( + domain: RingDomainSize, + entropy: &[u8], + members: &[[u8; 32]], + period: u32, + slot: u32, + alias_proofs: &[RawEncoded], + inherited_implication: &[u8], +) -> Result { + let vrf_entropy = derivation::paid_token_ring_vrf_entropy(entropy, period, slot)?; + let context = paid_token_signing_context(period); + + let mut signed = alias_proofs.encode(); + signed.extend_from_slice(inherited_implication); + let message = blake2b256(&signed); + + let proof = ring_vrf_proof(domain, vrf_entropy, members, &context, &message) + .map_err(|error| CoinageError::Internal(format!("paid-token proof failed: {error}")))?; + + Ok(RawEncoded(proof)) +} + +/// Prove control of the member key a paid-token join publishes. +/// +/// `pay_for_recycler_unload_fee_token_with_*` carries a `proof_of_ownership` beside +/// the member key, checked by the call itself against the *origin account's* +/// encoded bytes. Its purpose is anti-front-running: without it, watching the pool +/// would let someone else's join publish your key. +/// +/// The message is the joining account's 32 bytes, raw and unhashed — the same rule +/// as [`entry_ownership_proof`], and for the same reason. +pub fn paid_token_ownership_proof( + entropy: &[u8], + period: u32, + slot: u32, + joining_account: CoinAccountId, +) -> Result { + let vrf_entropy = derivation::paid_token_ring_vrf_entropy(entropy, period, slot)?; + let secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let signature = + BandersnatchVrfVerifiable::sign(&secret, &joining_account.0).map_err(|error| { + CoinageError::Internal(format!("paid-token ownership signature failed: {error:?}")) + })?; + + Ok(RawEncoded(signature.encode())) +} + +/// Prove control of the member key an entry is about to publish. +/// +/// `load_recycler_with_coin` carries a `proof_of_ownership` beside the member key, +/// and unlike every other proof in this pallet it is verified by the *call* rather +/// than by the extension — so its message cannot be the inherited implication, +/// which a dispatch cannot see. It signs the recycling coin's account: the fact +/// worth proving is that whoever controls that coin also controls the key being +/// published, which is what stops one wallet publishing another's key. +/// +/// The message is the account's 32 bytes, raw and unhashed. Confirmed against the +/// shipped iOS-compatible top-up flow, which signs the external-asset holder's +/// account the same way for `load_recycler_with_external_asset_unpaid_batch` — +/// the same field, the same key, the same origin-account message. +pub fn entry_ownership_proof( + entropy: &[u8], + purse: PurseId, + index: EntryIndex, + coin_account: CoinAccountId, +) -> Result { + let vrf_entropy = derivation::entry_ring_vrf_entropy(entropy, purse, index)?; + let secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let signature = BandersnatchVrfVerifiable::sign(&secret, &coin_account.0).map_err(|error| { + CoinageError::Internal(format!("member-key ownership signature failed: {error:?}")) + })?; + + Ok(RawEncoded(signature.encode())) +} + +/// Aliases in the order the call expects them. +pub fn aliases_of(proofs: &[EntryProof]) -> Vec<[u8; 32]> { + proofs.iter().map(|proof| proof.alias).collect() +} + +/// Proofs in the order the extension expects them. +pub fn alias_proofs_of(proofs: &[EntryProof]) -> Vec { + proofs.iter().map(|proof| proof.proof.clone()).collect() +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::Decode; + + use crate::host_logic::coinage::derivation; + use crate::runtime::statement_allowance::proof::RING_VRF_PROOF_LEN; + + use super::*; + + const ENTROPY: [u8; 32] = [7; 32]; + /// Smallest supported ring domain, per `domain_for_ring_exponent`. + const DOMAIN: RingDomainSize = RingDomainSize::Domain11; + + /// Pad a ring out with unrelated members. + fn with_fillers(mut members: Vec<[u8; 32]>) -> Vec<[u8; 32]> { + for filler in 1u8..4 { + let secret = BandersnatchVrfVerifiable::new_secret([filler; 32]); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + members.push(member.as_ref().try_into().expect("32 bytes")); + } + members + } + + /// A recycler ring holding one of our entries. + fn ring_containing(purse: PurseId, index: EntryIndex) -> Vec<[u8; 32]> { + with_fillers(vec![ + derivation::entry_member_key(&ENTROPY, purse, index).expect("derives"), + ]) + } + + /// A personhood ring holding our personhood key — a different ring and a + /// different key from any recycler entry. + fn personhood_ring() -> Vec<[u8; 32]> { + with_fillers(vec![ + crate::runtime::statement_allowance::proof::member_key(ENTROPY), + ]) + } + + #[test] + fn an_ownership_proof_is_bound_to_the_coin_being_recycled() { + // The signature's whole job is to tie the member key to the coin paying + // for it, so two coins must not produce the same proof — and the key's own + // public must verify it. + let purse = PurseId::MAIN; + let index = EntryIndex(0); + let first = + entry_ownership_proof(&ENTROPY, purse, index, CoinAccountId([1; 32])).expect("signs"); + let second = + entry_ownership_proof(&ENTROPY, purse, index, CoinAccountId([2; 32])).expect("signs"); + + assert_ne!(first, second, "a proof for one coin must not serve another"); + assert_eq!( + first.0.len(), + 64, + "the call's field is a fixed 64 bytes, spliced raw" + ); + + let vrf_entropy = + derivation::entry_ring_vrf_entropy(&ENTROPY, purse, index).expect("derives"); + let secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let signature = + ::Signature::decode(&mut &first.0[..]) + .expect("the signature round-trips"); + assert!( + BandersnatchVrfVerifiable::verify_signature(&signature, &[1u8; 32], &member), + "the published member key verifies its own ownership proof" + ); + } + + #[test] + fn an_alias_is_derivable_without_proving() { + let alias = recycler_alias(&ENTROPY, PurseId::MAIN, EntryIndex(0)).expect("derives"); + let again = recycler_alias(&ENTROPY, PurseId::MAIN, EntryIndex(0)).expect("derives"); + + assert_eq!(alias, again); + assert_ne!( + alias, + recycler_alias(&ENTROPY, PurseId::MAIN, EntryIndex(1)).expect("derives") + ); + } + + #[test] + fn aliases_are_purse_scoped() { + assert_ne!( + recycler_alias(&ENTROPY, PurseId::MAIN, EntryIndex(0)).expect("derives"), + recycler_alias(&ENTROPY, PurseId(1), EntryIndex(0)).expect("derives") + ); + } + + #[test] + fn proving_yields_the_same_alias_a_scan_would_derive() { + // The scan path finds an entry's on-chain records by its alias, and the + // unload call carries the alias the proof produced. If those two ever + // disagreed, a scan could not see what an unload spent. + let purse = PurseId::MAIN; + let index = EntryIndex(0); + let members = ring_containing(purse, index); + + let proved = entry_membership_proof(DOMAIN, &ENTROPY, purse, index, &members, &[9u8; 8]) + .expect("our member key is in the ring"); + + assert_eq!( + proved.alias, + recycler_alias(&ENTROPY, purse, index).expect("derives") + ); + assert_eq!(proved.index, index); + assert_eq!(proved.proof.0.len(), RING_VRF_PROOF_LEN); + } + + #[test] + fn an_entry_outside_the_ring_cannot_prove_membership() { + // An entry the chain has not onboarded must fail to prove rather than + // produce something the runtime will reject. + let members = ring_containing(PurseId::MAIN, EntryIndex(0)); + + let outsider = entry_membership_proof( + DOMAIN, + &ENTROPY, + PurseId::MAIN, + EntryIndex(99), + &members, + &[9u8; 8], + ); + + assert!(matches!(outsider, Err(CoinageError::Internal(_)))); + } + + #[test] + fn a_token_proof_binds_the_alias_set_it_is_spent_on() { + let members = personhood_ring(); + let implication = [4u8; 8]; + let one = vec![RawEncoded(vec![1; 8])]; + let two = vec![RawEncoded(vec![1; 8]), RawEncoded(vec![2; 8])]; + + let first = free_token_proof(DOMAIN, ENTROPY, &members, 1, 0, &one, &implication) + .expect("our key is in the ring"); + let second = free_token_proof(DOMAIN, ENTROPY, &members, 1, 0, &two, &implication) + .expect("our key is in the ring"); + + // Ring-VRF proofs are randomized, so equality is not the property under + // test; both must simply be well-formed and derived from different + // messages. The binding itself is asserted on the message in + // `extension::tests`. + assert_eq!(first.0.len(), RING_VRF_PROOF_LEN); + assert_eq!(second.0.len(), RING_VRF_PROOF_LEN); + } + + #[test] + fn a_token_proof_needs_the_personhood_ring_not_a_recycler_ring() { + // The two proofs are easy to conflate. Handing the recycler ring to the + // token prover must fail rather than quietly produce something the + // runtime rejects. + let recycler = ring_containing(PurseId::MAIN, EntryIndex(0)); + + assert!( + free_token_proof( + DOMAIN, + ENTROPY, + &recycler, + 1, + 0, + &[RawEncoded(vec![1; 8])], + &[4u8; 8] + ) + .is_err() + ); + } + + #[test] + fn the_two_orderings_stay_aligned() { + let purse = PurseId::MAIN; + let members = ring_containing(purse, EntryIndex(0)); + let proofs = vec![ + entry_membership_proof(DOMAIN, &ENTROPY, purse, EntryIndex(0), &members, &[1u8; 4]) + .expect("in the ring"), + ]; + + let aliases = aliases_of(&proofs); + let alias_proofs = alias_proofs_of(&proofs); + + // The call's `aliases` and the extension's `alias_proofs` are positional + // and must describe the same entries in the same order. + assert_eq!(aliases.len(), alias_proofs.len()); + assert_eq!(aliases[0], proofs[0].alias); + assert_eq!(alias_proofs[0], proofs[0].proof); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/recover.rs b/rust/crates/truapi-server/src/runtime/coinage/recover.rs new file mode 100644 index 000000000..43d272e4f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/recover.rs @@ -0,0 +1,469 @@ +//! Driving operation recovery against finalized chain state. +//! +//! `coinage-layer.md` §7.7. The decision procedure is pure and lives in +//! [`crate::host_logic::coinage::recovery`]; this module supplies it with the +//! chain reads it needs and applies what it decides. +//! +//! Every read is pinned to one finalized block hash. That is not a detail: the +//! whole point is that a decision made here cannot be undone, and a read taken +//! at the best block could be describing a fork that is about to disappear. +//! +//! Recovery runs at layer start, before any new operation is accepted, and +//! whenever tracking reports [`super::submit::TrackerOutcome::Unknown`]. + +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::log::{LogEntry, LogEntryState}; +use crate::host_logic::coinage::operation::LockSet; +use crate::host_logic::coinage::recovery::{self, RecordObservation, Resolution}; +use crate::host_logic::coinage::store::CoinageStore; +use crate::host_logic::coinage::types::{BlockHash, OperationHandle}; +use crate::runtime::statement_allowance::rpc::RpcClient; + +/// A finalized block every read in one recovery pass is pinned to. +#[derive(Debug, Clone)] +pub struct FinalizedAt { + /// Block hash, as the node spells it. + pub hash: String, + /// Decoded block hash, for the log. + pub block_hash: BlockHash, + /// Block height, for the expiry test. + pub number: u64, +} + +/// What one recovery pass resolved. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PassOutcome { + /// Transactions that definitely succeeded. + pub succeeded: Vec<(OperationHandle, u32)>, + /// Transactions that can never take effect. + pub rejected: Vec<(OperationHandle, u32)>, + /// Transactions dropped because a predecessor did not succeed. + pub abandoned: Vec<(OperationHandle, u32)>, + /// Transactions still undecided; ask again at the next finalized block. + pub still_pending: Vec<(OperationHandle, u32)>, +} + +impl PassOutcome { + /// Whether anything is still waiting on a later finalized block. + pub fn is_complete(&self) -> bool { + self.still_pending.is_empty() + } +} + +/// Read the finalized head as the anchor for a recovery pass. +pub async fn finalized_at(rpc: &RpcClient) -> Result { + let hash = rpc + .finalized_head() + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + let number = super::observe::block_number(rpc, &hash).await?; + let block_hash = super::observe::decode_block_hash(&hash)?; + + Ok(FinalizedAt { + hash, + block_hash, + number, + }) +} + +/// Resolve every pending transaction that can be decided at `at`. +/// +/// Runs the abandonment cascade first, then decides each entry whose +/// dependencies are settled. Entries whose dependencies are still open are +/// reported as pending without being read, because their observations cannot be +/// interpreted yet (§7.5). +pub async fn run_pass( + rpc: &RpcClient, + store: &mut CoinageStore, + entropy: &[u8], + at: &FinalizedAt, +) -> Result { + let mut outcome = PassOutcome::default(); + + let handles: Vec = store + .open_operations() + .filter(|operation| operation.log.has_pending()) + .map(|operation| operation.handle) + .collect(); + + for handle in handles { + loop { + // A cascade can settle entries without any chain read, and can + // unblock further cascading, so it runs to a fixed point first. + let cascaded = cascade(store, handle)?; + outcome + .abandoned + .extend(cascaded.iter().map(|sequence| (handle, *sequence))); + + let Some(entry) = next_resolvable(store, handle) else { + break; + }; + let sequence = entry.sequence; + let observation = observe(rpc, entropy, &entry, at).await?; + let resolution = recovery::resolve(&entry, observation, at.number); + + match recovery::log_state(&resolution, at.block_hash) { + None => { + outcome.still_pending.push((handle, sequence)); + break; + } + Some(state) => { + let succeeded = matches!(resolution, Resolution::Succeeded { .. }); + store.resolve_transaction(handle, sequence, state)?; + if succeeded { + outcome.succeeded.push((handle, sequence)); + } else { + outcome.rejected.push((handle, sequence)); + } + } + } + } + + // Anything still open after this handle's pass waits for a later block. + if let Some(operation) = store.operation(handle) { + for entry in operation.log.entries() { + if entry.state.is_pending() + && !outcome.still_pending.contains(&(handle, entry.sequence)) + { + outcome.still_pending.push((handle, entry.sequence)); + } + } + } + } + + Ok(outcome) +} + +/// Apply the abandonment cascade and record it in the store. +fn cascade(store: &mut CoinageStore, handle: OperationHandle) -> Result, CoinageError> { + let doomed: Vec<(u32, String)> = { + let Some(operation) = store.operation(handle) else { + return Ok(Vec::new()); + }; + let mut log = operation.log.clone(); + log.cascade_abandoned() + .into_iter() + .filter_map(|sequence| { + let state = log.entry(sequence)?.state.clone(); + match state { + LogEntryState::Abandoned { reason } => Some((sequence, reason)), + _ => None, + } + }) + .collect() + }; + + for (sequence, reason) in &doomed { + store.resolve_transaction( + handle, + *sequence, + LogEntryState::Abandoned { + reason: reason.clone(), + }, + )?; + } + Ok(doomed.into_iter().map(|(sequence, _)| sequence).collect()) +} + +/// The next entry whose dependencies are all settled. +fn next_resolvable(store: &CoinageStore, handle: OperationHandle) -> Option { + let operation = store.operation(handle)?; + let sequence = *operation.log.resolvable().first()?; + operation.log.entry(sequence).cloned() +} + +/// Ask the chain the two questions the decision needs, at a finalized block. +/// +/// The input read is skipped when the outputs are already visible: that alone +/// settles the transaction as succeeded, and recovery may run over many pending +/// entries at every finalized block, so a read whose answer cannot change the +/// verdict is worth not making. `inputs_consumed` is therefore only meaningful +/// when `outputs_present` is false, which is exactly the precedence +/// [`recovery::resolve`] applies. +async fn observe( + rpc: &RpcClient, + entropy: &[u8], + entry: &LogEntry, + at: &FinalizedAt, +) -> Result { + if all_present(rpc, entropy, &entry.outputs, at).await? { + return Ok(RecordObservation { + outputs_present: true, + inputs_consumed: false, + }); + } + + Ok(RecordObservation { + outputs_present: false, + inputs_consumed: none_present(rpc, entropy, &entry.inputs, at).await?, + }) +} + +/// Whether every named record exists on chain at `at`. +/// +/// An empty set is **not** "all present": a transaction with no outputs the +/// layer can see — a transfer, whose outputs belong to the recipient — must be +/// judged by its inputs instead, and answering `true` here would declare every +/// such transaction successful the moment it was planned. +async fn all_present( + rpc: &RpcClient, + entropy: &[u8], + records: &LockSet, + at: &FinalizedAt, +) -> Result { + if records.is_empty() { + return Ok(false); + } + for (purse, index) in &records.coins { + if !super::observe::coin_present(rpc, entropy, *purse, *index, &at.hash).await? { + return Ok(false); + } + } + for (purse, index) in &records.entries { + if !super::observe::entry_present(rpc, entropy, *purse, *index, &at.hash).await? { + return Ok(false); + } + } + Ok(true) +} + +/// Whether every named record is gone from chain at `at`. +/// +/// An empty set is not "all consumed", for the mirror-image reason: a +/// transaction that consumes nothing must not be read as having consumed +/// everything it was asked to. +async fn none_present( + rpc: &RpcClient, + entropy: &[u8], + records: &LockSet, + at: &FinalizedAt, +) -> Result { + if records.is_empty() { + return Ok(false); + } + for (purse, index) in &records.coins { + if super::observe::coin_present(rpc, entropy, *purse, *index, &at.hash).await? { + return Ok(false); + } + } + for (purse, index) in &records.entries { + if super::observe::entry_present(rpc, entropy, *purse, *index, &at.hash).await? { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::Encode; + use subxt_rpcs::RpcClient as HostRpcClient; + + use crate::host_logic::coinage::chain_constants::next_people_paseo; + use crate::host_logic::coinage::coin::CoinState; + use crate::host_logic::coinage::log::Checkpoint; + use crate::host_logic::coinage::selection::{OutputRequirement, SelectionRequest}; + use crate::host_logic::coinage::types::{ + Amount, CoinAge, CoinIndex, DenominationExponent, OperationKind, PurseId, Timestamp, + }; + use crate::runtime::coinage::storage::ChainCoin; + use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + + use super::*; + + const ENTROPY: [u8; 32] = [7; 32]; + const NOW: Timestamp = Timestamp(1_000_000); + + fn at(number: u64) -> FinalizedAt { + FinalizedAt { + hash: format!("0x{}", "07".repeat(32)), + block_hash: BlockHash([7; 32]), + number, + } + } + + fn checkpoint() -> Checkpoint { + Checkpoint { + number: 1_000, + hash: BlockHash([1; 32]), + mortality: 256, + } + } + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn present() -> String { + format!( + "\"0x{}\"", + hex::encode(ChainCoin { value: 3, age: 0 }.encode()) + ) + } + + fn absent() -> String { + "null".to_string() + } + + fn rpc(responses: &[String]) -> RpcClient { + RpcClient::new(HostRpcClient::new(ScriptedRpc::new( + responses.iter().map(String::as_str), + ))) + } + + fn block_on(future: F) -> F::Output { + futures::executor::block_on(future) + } + + /// A store with one funded coin and one operation that has planned a + /// transaction spending it to produce a fresh coin. + fn in_flight() -> (CoinageStore, OperationHandle, CoinIndex, CoinIndex, u32) { + let mut store = CoinageStore::new("Main".to_string()); + let input = store + .add_pending_coin(PurseId::MAIN, exponent(3)) + .expect("purse exists"); + store + .observe_coin(PurseId::MAIN, input, CoinAge(0)) + .expect("coin exists"); + + let (handle, _plan) = store + .begin_operation( + PurseId::MAIN, + OperationKind::Transfer, + &SelectionRequest { + amount: Amount::from_cents(8), + outputs: OutputRequirement::AnyDenominations, + allow_degraded: true, + }, + &next_people_paseo(), + NOW, + ) + .expect("8 cents are available"); + let output = store + .add_pending_coin(PurseId::MAIN, exponent(3)) + .expect("purse exists"); + let inputs = store.operation(handle).expect("open").locks.clone(); + let sequence = store + .plan_transaction( + handle, + inputs, + LockSet { + coins: vec![(PurseId::MAIN, output)], + entries: Vec::new(), + }, + checkpoint(), + [], + ) + .expect("open"); + + (store, handle, input, output, sequence) + } + + #[test] + fn visible_outputs_settle_the_transaction_as_succeeded() { + let (mut store, handle, input, _output, sequence) = in_flight(); + // outputs read first, and it is present, so the inputs are never read. + let rpc = rpc(&[present()]); + + let outcome = block_on(run_pass(&rpc, &mut store, &ENTROPY, &at(1_100))).expect("passes"); + + assert_eq!(outcome.succeeded, vec![(handle, sequence)]); + assert!(outcome.is_complete()); + assert_eq!( + store.coin(PurseId::MAIN, input).expect("exists").state, + CoinState::Spent + ); + } + + #[test] + fn consumed_inputs_settle_a_transfer_whose_outputs_we_cannot_see() { + let (mut store, handle, input, _output, sequence) = in_flight(); + // The output is absent (it went to a recipient) but the input is gone. + let rpc = rpc(&[absent(), absent()]); + + let outcome = block_on(run_pass(&rpc, &mut store, &ENTROPY, &at(1_100))).expect("passes"); + + assert_eq!(outcome.succeeded, vec![(handle, sequence)]); + assert_eq!( + store.coin(PurseId::MAIN, input).expect("exists").state, + CoinState::Spent + ); + } + + #[test] + fn nothing_observed_inside_the_era_stays_pending_and_holds_its_locks() { + let (mut store, handle, input, _output, sequence) = in_flight(); + // Output absent, input still present: undecided. + let rpc = rpc(&[absent(), present()]); + + let outcome = block_on(run_pass(&rpc, &mut store, &ENTROPY, &at(1_100))).expect("passes"); + + assert_eq!(outcome.still_pending, vec![(handle, sequence)]); + assert!(!outcome.is_complete()); + assert!( + matches!( + store.coin(PurseId::MAIN, input).expect("exists").state, + CoinState::LockedFor(_) + ), + "an undecided transaction keeps its inputs locked" + ); + } + + #[test] + fn an_expired_transaction_returns_its_inputs_to_the_pool() { + let (mut store, handle, input, output, sequence) = in_flight(); + let rpc = rpc(&[absent(), present()]); + + let outcome = block_on(run_pass(&rpc, &mut store, &ENTROPY, &at(1_300))).expect("passes"); + + assert_eq!(outcome.rejected, vec![(handle, sequence)]); + assert!(outcome.is_complete()); + assert_eq!( + store.coin(PurseId::MAIN, input).expect("exists").state, + CoinState::Available, + "past the era the transaction can never land, so the input is free" + ); + assert_eq!( + store.coin(PurseId::MAIN, output).expect("exists").state, + CoinState::Spent, + "the output never came to exist" + ); + } + + #[test] + fn a_rejected_head_abandons_its_dependant_without_any_chain_read() { + let (mut store, handle, _input, output, first) = in_flight(); + let downstream = store + .add_pending_coin(PurseId::MAIN, exponent(3)) + .expect("purse exists"); + let second = store + .plan_transaction( + handle, + LockSet { + coins: vec![(PurseId::MAIN, output)], + entries: Vec::new(), + }, + LockSet { + coins: vec![(PurseId::MAIN, downstream)], + entries: Vec::new(), + }, + checkpoint(), + [first], + ) + .expect("open"); + // Only two responses are scripted: the head's two reads. The dependant + // must be decided by the cascade alone — an extra read would panic the + // scripted transport. + let rpc = rpc(&[absent(), present()]); + + let outcome = block_on(run_pass(&rpc, &mut store, &ENTROPY, &at(1_300))).expect("passes"); + + assert_eq!(outcome.rejected, vec![(handle, first)]); + assert_eq!(outcome.abandoned, vec![(handle, second)]); + assert!(outcome.is_complete()); + assert_eq!( + store.coin(PurseId::MAIN, downstream).expect("exists").state, + CoinState::Spent + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/ring.rs b/rust/crates/truapi-server/src/runtime/coinage/ring.rs new file mode 100644 index 000000000..a79b9da04 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/ring.rs @@ -0,0 +1,581 @@ +//! Reading a membership ring so a proof can be built against it. +//! +//! Unloading an entry means proving, in ring-VRF, that the entry's member key is +//! in its ring; presenting a paid unload token means the same thing in a different +//! collection. The prover reconstructs the ring commitment from the member list, +//! so it needs the same members the runtime verifies against: the `included` +//! prefix of the ring, whole, in order. +//! +//! Two failure modes are worth naming, because neither announces itself: +//! +//! - **A missed page** yields a shorter member list, a different commitment, and +//! a proof the runtime rejects. Paging therefore stops only at an absent page. +//! - **The wrong domain** — the ring's size fixes the FFT domain the proof is +//! built over, so a proof for the wrong size does not verify. The size comes +//! from the collection, never from the member count, because the member count +//! is the *filled* part of a fixed-size ring. +//! +//! Every read is pinned to one block, so members, `included` and the ring's +//! revision describe one state of the chain rather than three. + +use parity_scale_codec::Decode; +use subxt::ext::scale_value::scale::decode_as_type; +use subxt::ext::scale_value::{Composite, Value, ValueDef}; +use verifiable::ring::RingDomainSize; + +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::types::{ + DenominationExponent, RevisionIndex, RingIndex, RingLocation, +}; +use crate::runtime::coinage::storage; +use crate::runtime::statement_allowance::extension::Metadata; +use crate::runtime::statement_allowance::proof::domain_for_ring_exponent; +use crate::runtime::statement_allowance::rpc::RpcClient; + +/// Length of a bandersnatch ring member key. +const MEMBER_LEN: usize = 32; + +/// A recycler ring as the prover needs it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecyclerRing { + /// Which ring, and at which membership revision. + pub location: RingLocation, + /// The `included` prefix of the ring's members, in ring order. + pub members: Vec<[u8; 32]>, + /// Proof domain the ring's size fixes. + pub domain: RingDomainSize, +} + +impl RecyclerRing { + /// Whether `member_key` is in the ring's included prefix. + /// + /// A proof for a key outside it cannot be built, and asking first turns that + /// into a clear refusal rather than an opaque prover error. + pub fn includes(&self, member_key: &[u8; 32]) -> bool { + self.members.contains(member_key) + } +} + +/// Read the ring an entry of `exponent` sits in, pinned to `at`. +pub async fn read_recycler_ring( + rpc: &RpcClient, + metadata: &Metadata, + exponent: DenominationExponent, + location: RingLocation, + at: &str, +) -> Result { + read_ring_in( + rpc, + metadata, + &storage::recycler_collection_id(exponent), + location, + at, + ) + .await +} + +/// Read one ring of an arbitrary membership collection, pinned to `at`. +/// +/// The recycler rings and the paid unload-token rings are both `pallet-members` +/// collections and differ only in their identifier, so both go through here: the +/// paging rule, the `included` truncation and the domain-from-collection rule are +/// properties of the members pallet, not of what the ring is for. +pub async fn read_ring_in( + rpc: &RpcClient, + metadata: &Metadata, + collection: &[u8; 32], + location: RingLocation, + at: &str, +) -> Result { + let domain = read_ring_domain(rpc, metadata, collection, at).await?; + let members = read_ring_members(rpc, collection, location.index, at).await?; + + Ok(RecyclerRing { + location, + members, + domain, + }) +} + +/// The membership revision one ring of `collection` reports, pinned to `at`. +pub async fn read_collection_ring_revision( + rpc: &RpcClient, + metadata: &Metadata, + collection: &[u8; 32], + ring: RingIndex, + at: &str, +) -> Result, CoinageError> { + let Some(raw) = read(rpc, &storage::ring_root_key(collection, ring), at).await? else { + return Ok(None); + }; + let type_id = metadata + .storage_value_type("Members", "Root") + .ok_or_else(|| { + CoinageError::Internal("Members.Root is absent from metadata".to_string()) + })?; + let value = decode_as_type(&mut &raw[..], type_id, metadata.registry()) + .map_err(|error| CoinageError::Internal(format!("decoding a ring root failed: {error}")))?; + + revision_field(&value) + .map(Some) + .ok_or_else(|| CoinageError::Internal("a ring root carried no revision field".to_string())) +} + +/// The newest ring index of a collection, pinned to `at`. +/// +/// `ValueQuery` on the runtime side, so an absent entry is ring zero. +pub async fn read_current_ring_index( + rpc: &RpcClient, + collection: &[u8; 32], + at: &str, +) -> Result { + let Some(raw) = read(rpc, &storage::current_ring_index_key(collection), at).await? else { + return Ok(RingIndex(0)); + }; + let index = u32::decode(&mut &raw[..]).map_err(|error| { + CoinageError::Internal(format!("decoding a current ring index failed: {error}")) + })?; + + Ok(RingIndex(index)) +} + +/// Find the ring of `collection` whose included prefix holds `member_key`. +/// +/// Scans downward from the collection's newest ring, because a key onboarded a +/// while ago sits in an older ring and only a ring that actually contains it can +/// be proved against. The paid ring's onboarding size is one, so a freshly joined +/// key lands in a ring within a block or so — but *which* ring is the chain's +/// choice, never the client's, so it has to be looked up rather than assumed. +/// +/// `Ok(None)` is the ordinary answer for a key whose join has not been onboarded +/// yet, and it is not an error: the caller's move is to wait, not to fail. +pub async fn find_ring_including( + rpc: &RpcClient, + metadata: &Metadata, + collection: &[u8; 32], + member_key: &[u8; 32], + at: &str, +) -> Result, CoinageError> { + let newest = read_current_ring_index(rpc, collection, at).await?; + for index in (0..=newest.0).rev() { + let ring = RingIndex(index); + let Some(revision) = + read_collection_ring_revision(rpc, metadata, collection, ring, at).await? + else { + continue; + }; + let read = read_ring_in( + rpc, + metadata, + collection, + RingLocation::new(ring, revision), + at, + ) + .await?; + if read.includes(member_key) { + return Ok(Some(read)); + } + } + + Ok(None) +} + +/// The membership revision a ring's root currently reports, pinned to `at`. +/// +/// A proof is only valid against the revision it was built for, so this is read +/// fresh at proving time rather than taken from a local record: an entry observed +/// an hour ago may well name a revision the chain has since moved past. +pub async fn read_ring_revision( + rpc: &RpcClient, + metadata: &Metadata, + exponent: DenominationExponent, + ring: RingIndex, + at: &str, +) -> Result, CoinageError> { + read_collection_ring_revision( + rpc, + metadata, + &storage::recycler_collection_id(exponent), + ring, + at, + ) + .await +} + +/// Pull the `revision` field out of a decoded ring root. +fn revision_field(value: &Value) -> Option { + let ValueDef::Composite(Composite::Named(fields)) = &value.value else { + return None; + }; + fields + .iter() + .find(|(name, _)| name == "revision") + .and_then(|(_, value)| value.as_u128()) + .and_then(|revision| u32::try_from(revision).ok()) + .map(RevisionIndex) +} + +/// The proof domain fixed by a collection's ring size. +async fn read_ring_domain( + rpc: &RpcClient, + metadata: &Metadata, + collection: &[u8; 32], + at: &str, +) -> Result { + let raw = read(rpc, &storage::collections_key(collection), at) + .await? + .ok_or_else(|| { + CoinageError::Internal( + "the recycler collection for this denomination does not exist on chain".to_string(), + ) + })?; + let type_id = metadata + .storage_value_type("Members", "Collections") + .ok_or_else(|| { + CoinageError::Internal("Members.Collections is absent from metadata".to_string()) + })?; + let value = decode_as_type(&mut &raw[..], type_id, metadata.registry()).map_err(|error| { + CoinageError::Internal(format!("decoding a member collection failed: {error}")) + })?; + + let exponent = ring_size_exponent(&value).ok_or_else(|| { + CoinageError::Internal("a member collection carried no ring size".to_string()) + })?; + domain_for_ring_exponent(exponent) + .map_err(|error| CoinageError::Internal(format!("recycler ring domain: {error}"))) +} + +/// Pull the ring-size exponent out of a decoded collection. +/// +/// The runtime spells the size as an enum (`R2e9`, `R2e10`, `R2e14`) rather than +/// a number, and an unrecognized variant is a runtime this layer cannot prove +/// against — so it fails rather than guessing a domain. +fn ring_size_exponent(value: &Value) -> Option { + let ValueDef::Composite(Composite::Named(fields)) = &value.value else { + return None; + }; + let size = fields + .iter() + .find(|(name, _)| name == "ring_size") + .map(|(_, value)| value)?; + let ValueDef::Variant(variant) = &size.value else { + return None; + }; + + match variant.name.as_str() { + "R2e9" => Some(9), + "R2e10" => Some(10), + "R2e14" => Some(14), + _ => None, + } +} + +/// Every included member of a ring, paged. +/// +/// Stops at the first absent page, then truncates to the `included` prefix the +/// ring's status reports. An absent status means nothing is excluded. +async fn read_ring_members( + rpc: &RpcClient, + collection: &[u8; 32], + ring: RingIndex, + at: &str, +) -> Result, CoinageError> { + let mut members = Vec::new(); + for page in 0.. { + let Some(bytes) = read(rpc, &storage::ring_keys_key(collection, ring, page), at).await? + else { + break; + }; + let page_members = decode_ring_keys_page(&bytes)?; + if page_members.is_empty() { + break; + } + members.extend(page_members); + } + + let status = storage::decode_ring_status( + read(rpc, &storage::ring_keys_status_key(collection, ring), at).await?, + )?; + if (status.included as usize) < members.len() { + members.truncate(status.included as usize); + } + + Ok(members) +} + +/// Decode one `RingKeys` page: a compact count then that many 32-byte keys. +fn decode_ring_keys_page(bytes: &[u8]) -> Result, CoinageError> { + use parity_scale_codec::{Compact, Decode}; + + let mut cursor = bytes; + let Compact(count) = Compact::::decode(&mut cursor) + .map_err(|error| CoinageError::Internal(format!("ring keys page length: {error}")))?; + + let mut members = Vec::with_capacity(count as usize); + for position in 0..count as usize { + let start = position * MEMBER_LEN; + let member: [u8; 32] = cursor + .get(start..start + MEMBER_LEN) + .ok_or_else(|| { + CoinageError::Internal(format!( + "a ring keys page promised {count} members and delivered {position}" + )) + })? + .try_into() + .expect("the slice is MEMBER_LEN long; qed"); + members.push(member); + } + + Ok(members) +} + +/// One pinned storage read. +async fn read(rpc: &RpcClient, key: &[u8], at: &str) -> Result>, CoinageError> { + rpc.get_storage_at(key, at) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string())) +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::{Compact, Encode}; + use subxt_rpcs::RpcClient as HostRpcClient; + + use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn metadata() -> Metadata { + Metadata::decode(FIXTURE).expect("the fixture decodes") + } + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn location() -> RingLocation { + RingLocation::new(RingIndex(3), RevisionIndex(1)) + } + + fn block_on(future: F) -> F::Output { + futures::executor::block_on(future) + } + + fn scripted(responses: &[String]) -> (ScriptedRpc, RpcClient) { + let scripted = ScriptedRpc::new(responses.iter().map(String::as_str)); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + (scripted, rpc) + } + + fn hex_response(bytes: &[u8]) -> String { + format!("\"0x{}\"", hex::encode(bytes)) + } + + const NONE: &str = "null"; + + /// `CollectionInfo` as the runtime encodes it: + /// `owner ++ mode ++ ring_size ++ self_inclusion_delay`. + /// + /// The field order is the runtime's, and `ring_size` is third — a decoder that + /// read the first byte it found would pick up the owner's variant instead. + /// + /// `RingExponent`'s variant indices *are* the exponents (9 / 10 / 14), not + /// 0 / 1 / 2, which is exactly the kind of layout worth pinning in a fixture. + fn collection_info(ring_size: u8) -> Vec { + let mut encoded = vec![1u8]; // CollectionOwner::Local + encoded.extend([9u8; 32]); // the owning account + encoded.push(0u8); // RingMode::AppendOnly + encoded.push(ring_size); // RingExponent + encoded.push(0u8); // self_inclusion_delay: None + encoded + } + + fn page(members: &[[u8; 32]]) -> Vec { + let mut encoded = Compact(members.len() as u32).encode(); + for member in members { + encoded.extend_from_slice(member); + } + encoded + } + + /// `RingStatus { total, included, .. }` — only `included` is read. + fn ring_status(total: u32, included: u32) -> Vec { + let mut encoded = total.to_le_bytes().to_vec(); + encoded.extend(included.to_le_bytes()); + encoded.push(0); // immutable_since: None + encoded + } + + #[test] + fn a_ring_is_read_across_pages_and_truncated_to_included() { + let first: Vec<[u8; 32]> = (0..3).map(|byte| [byte; 32]).collect(); + let second: Vec<[u8; 32]> = (3..5).map(|byte| [byte; 32]).collect(); + let (_scripted, rpc) = scripted(&[ + hex_response(&collection_info(9)), + hex_response(&page(&first)), + hex_response(&page(&second)), + NONE.to_string(), + hex_response(&ring_status(5, 4)), + ]); + + let ring = block_on(read_recycler_ring( + &rpc, + &metadata(), + exponent(4), + location(), + "0xfeed", + )) + .expect("reads"); + + // Four included of five present: the fifth is onboarding and must not be + // in the commitment the proof is built against. + assert_eq!(ring.members.len(), 4); + assert_eq!(ring.members[0], [0u8; 32]); + assert_eq!(ring.members[3], [3u8; 32]); + assert_eq!(ring.domain, RingDomainSize::Domain11); + assert_eq!(ring.location, location()); + assert!(ring.includes(&[2u8; 32])); + assert!( + !ring.includes(&[4u8; 32]), + "the excluded tail is not usable" + ); + } + + #[test] + fn the_ring_size_fixes_the_proof_domain() { + for (variant, expected) in [ + (9u8, RingDomainSize::Domain11), + (10, RingDomainSize::Domain12), + (14, RingDomainSize::Domain16), + ] { + let (_scripted, rpc) = scripted(&[ + hex_response(&collection_info(variant)), + hex_response(&page(&[[1u8; 32]])), + NONE.to_string(), + hex_response(&ring_status(1, 1)), + ]); + + let ring = block_on(read_recycler_ring( + &rpc, + &metadata(), + exponent(4), + location(), + "0xfeed", + )) + .expect("reads"); + + assert_eq!(ring.domain, expected); + } + } + + #[test] + fn an_unknown_ring_size_is_refused_rather_than_guessed() { + // Guessing a domain produces a proof that does not verify, after an + // unload token has been spent building it. A size the runtime's own enum + // does not name is refused while decoding. + let (_scripted, rpc) = scripted(&[hex_response(&collection_info(3))]); + + let refused = block_on(read_recycler_ring( + &rpc, + &metadata(), + exponent(4), + location(), + "0xfeed", + )) + .expect_err("an unrecognized ring size stops the unload"); + + assert!( + refused.to_string().contains("member collection"), + "unexpected refusal: {refused}" + ); + } + + #[test] + fn a_ring_size_this_layer_cannot_prove_against_yields_no_domain() { + // The other half of the same guard, at the mapping rather than the + // decoder: a future runtime that adds a ring size must stop this layer + // instead of having it fall back to some domain that happens to compile. + use subxt::ext::scale_value::Variant; + + let size = Value { + value: ValueDef::Variant(Variant { + name: "R2e11".to_string(), + values: Composite::Unnamed(Vec::new()), + }), + context: 0u32, + }; + let collection = Value { + value: ValueDef::Composite(Composite::Named(vec![("ring_size".to_string(), size)])), + context: 0u32, + }; + + assert_eq!(ring_size_exponent(&collection), None); + } + + #[test] + fn a_missing_collection_is_refused() { + let (_scripted, rpc) = scripted(&[NONE.to_string()]); + + let refused = block_on(read_recycler_ring( + &rpc, + &metadata(), + exponent(4), + location(), + "0xfeed", + )) + .expect_err("no collection means nothing to prove against"); + + assert!(refused.to_string().contains("does not exist")); + } + + #[test] + fn a_truncated_page_is_an_error_not_a_short_ring() { + // The dangerous version of this bug is silent: a page that promises four + // members and carries two would otherwise produce a proof against a ring + // the chain does not have. + let mut truncated = Compact(4u32).encode(); + truncated.extend([1u8; 32]); + let (_scripted, rpc) = + scripted(&[hex_response(&collection_info(9)), hex_response(&truncated)]); + + let refused = block_on(read_recycler_ring( + &rpc, + &metadata(), + exponent(4), + location(), + "0xfeed", + )) + .expect_err("a short page is a chain read this layer cannot interpret"); + + assert!(refused.to_string().contains("delivered")); + } + + #[test] + fn every_read_is_pinned_to_the_same_block() { + let (scripted, rpc) = scripted(&[ + hex_response(&collection_info(9)), + hex_response(&page(&[[1u8; 32]])), + NONE.to_string(), + hex_response(&ring_status(1, 1)), + ]); + + block_on(read_recycler_ring( + &rpc, + &metadata(), + exponent(4), + location(), + "0xpinned", + )) + .expect("reads"); + + for (method, params) in scripted.calls() { + assert_eq!(method, "state_getStorage"); + assert!( + params.contains("0xpinned"), + "a ring read at a moving head could mix two rings: {params}" + ); + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/scan.rs b/rust/crates/truapi-server/src/runtime/coinage/scan.rs new file mode 100644 index 000000000..5c0248123 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/scan.rs @@ -0,0 +1,518 @@ +//! Rebuilding a wallet's records from the chain (`coinage-layer.md` §8.10, +//! Appendix C). +//! +//! Distinct from the operation recovery of §7.7, which resolves transactions that +//! were in flight when a process died. This is the other kind: durable state is +//! gone entirely, and everything the layer knows has to be re-derived from the root +//! entropy and looked up on chain. +//! +//! The scan walks a purse's derivation indices in batches, asking the chain about +//! each account, and stops after `gap_limit` consecutive batches find nothing. That +//! bound is what makes the scan terminate at all — there is no upper index to walk +//! to — and it is also the scan's one blind spot: a wallet with a long stretch of +//! unused indices followed by a used one stops short of it. §8.10's `extend_scan` +//! exists for exactly that, which is why this module takes its starting cursors as +//! arguments rather than always beginning at zero. +//! +//! # What a scan cannot bring back +//! +//! Two things, and both matter: +//! +//! - **The operation log.** Any transaction in flight when durable state was lost +//! is unrecoverable as a transaction; the scan sees only whatever the chain +//! ended up with. +//! - **Purse identifiers.** The chain has no notion of a purse, so a purse is only +//! found if the caller supplies its identifier from a backup. The main purse is +//! always scanned, because its identifier is fixed by construction. + +use crate::host_logic::coinage::derivation; +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::params::CoinageParameters; +use crate::host_logic::coinage::store::CoinageStore; +use crate::host_logic::coinage::types::{ + CoinIndex, DenominationExponent, EntryIndex, PurseId, Timestamp, +}; +use crate::runtime::coinage::storage; +use crate::runtime::statement_allowance::rpc::RpcClient; + +/// Where a scan of one purse should start, and what it found. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ScanOutcome { + /// Coin records restored. + pub coins_found: u32, + /// Recycler-entry records restored. + pub entries_found: u32, + /// First coin index the scan did not examine. + pub next_coin_index: CoinIndex, + /// First entry index the scan did not examine. + pub next_entry_index: EntryIndex, +} + +impl ScanOutcome { + /// Whether the scan found anything at all. + pub fn is_empty(&self) -> bool { + self.coins_found == 0 && self.entries_found == 0 + } +} + +/// Scan one purse's two derivation sub-trees, restoring what the chain holds. +/// +/// The coin and entry sub-trees are walked independently, each with its own cursor +/// and its own gap counter: they are separate key types over separate storage, and +/// a purse can easily hold entries at high indices and no coins at all. +#[allow(clippy::too_many_arguments)] +pub async fn scan_purse( + rpc: &RpcClient, + store: &mut CoinageStore, + entropy: &[u8], + purse: PurseId, + params: &CoinageParameters, + from_coin: CoinIndex, + from_entry: EntryIndex, + now: Timestamp, + at: &str, +) -> Result { + let coins = scan_coins(rpc, store, entropy, purse, params, from_coin, at).await?; + let entries = scan_entries(rpc, store, entropy, purse, params, from_entry, now, at).await?; + + Ok(ScanOutcome { + coins_found: coins.0, + entries_found: entries.0, + next_coin_index: coins.1, + next_entry_index: entries.1, + }) +} + +/// Walk the coin sub-tree until `gap_limit` consecutive batches are empty. +/// +/// One round trip per batch, not per index: a scan with the recommended parameters +/// asks about hundreds of accounts, and doing that one at a time turns a recovery +/// into minutes of sequential requests against a live node. +async fn scan_coins( + rpc: &RpcClient, + store: &mut CoinageStore, + entropy: &[u8], + purse: PurseId, + params: &CoinageParameters, + from: CoinIndex, + at: &str, +) -> Result<(u32, CoinIndex), CoinageError> { + let mut cursor = from.0; + let mut empty_batches = 0; + let mut found = 0; + + while empty_batches < params.recovery_gap_limit { + let indices = batch_indices(cursor, params.recovery_batch_size); + if indices.is_empty() { + // The index space is exhausted, which is not an error: there is simply + // nothing further to ask about. + return Ok((found, CoinIndex(u32::MAX))); + } + + let mut keys = Vec::with_capacity(indices.len()); + for index in &indices { + let account = derivation::coin_account_id(entropy, purse, CoinIndex(*index))?; + keys.push(storage::coins_by_owner_key(&account)); + } + let values = read_many(rpc, &keys, at).await?; + + let mut batch_found = false; + for (index, raw) in indices.iter().zip(values) { + let Some(coin) = storage::decode_coin(raw)? else { + continue; + }; + let exponent = DenominationExponent::new(coin.value).ok_or_else(|| { + CoinageError::RecoveryFailed(format!( + "the chain reports denomination {} at coin index {index}, which this layer \ + cannot represent", + coin.value + )) + })?; + store.restore_coin( + purse, + CoinIndex(*index), + exponent, + crate::host_logic::coinage::types::CoinAge(coin.age), + )?; + found += 1; + batch_found = true; + } + + cursor = cursor.saturating_add(params.recovery_batch_size); + empty_batches = if batch_found { 0 } else { empty_batches + 1 }; + } + + Ok((found, CoinIndex(cursor))) +} + +/// Walk the recycler-entry sub-tree the same way. +/// +/// An entry is found through the pallet's own `RecyclersCoinToRecycler`, which +/// answers with the denomination collection the member key belongs to — enough to +/// restore the record. Where the entry sits inside that collection is left to +/// ordinary observation, which runs over the restored records afterwards. +#[allow(clippy::too_many_arguments)] +async fn scan_entries( + rpc: &RpcClient, + store: &mut CoinageStore, + entropy: &[u8], + purse: PurseId, + params: &CoinageParameters, + from: EntryIndex, + now: Timestamp, + at: &str, +) -> Result<(u32, EntryIndex), CoinageError> { + let mut cursor = from.0; + let mut empty_batches = 0; + let mut found = 0; + + while empty_batches < params.recovery_gap_limit { + let indices = batch_indices(cursor, params.recovery_batch_size); + if indices.is_empty() { + return Ok((found, EntryIndex(u32::MAX))); + } + + let mut keys = Vec::with_capacity(indices.len()); + for index in &indices { + let member_key = derivation::entry_member_key(entropy, purse, EntryIndex(*index))?; + keys.push(storage::recyclers_coin_to_recycler_key(&member_key)); + } + let values = read_many(rpc, &keys, at).await?; + + let mut batch_found = false; + for (index, raw) in indices.iter().zip(values) { + let Some(bytes) = raw else { + continue; + }; + let value = decode_denomination(&bytes, *index)?; + store.restore_entry(purse, EntryIndex(*index), value, now)?; + found += 1; + batch_found = true; + } + + cursor = cursor.saturating_add(params.recovery_batch_size); + empty_batches = if batch_found { 0 } else { empty_batches + 1 }; + } + + Ok((found, EntryIndex(cursor))) +} + +/// The indices one batch covers, stopping at the end of the index space. +fn batch_indices(cursor: u32, size: u32) -> Vec { + (0..size) + .map_while(|offset| cursor.checked_add(offset)) + .collect() +} + +/// The denomination `RecyclersCoinToRecycler` reports for a member key. +fn decode_denomination(bytes: &[u8], index: u32) -> Result { + let value = bytes.first().map(|byte| *byte as i8).ok_or_else(|| { + CoinageError::RecoveryFailed(format!("entry {index} has an empty collection record")) + })?; + + DenominationExponent::new(value).ok_or_else(|| { + CoinageError::RecoveryFailed(format!( + "the chain reports denomination {value} at entry index {index}, which this layer \ + cannot represent" + )) + }) +} + +/// Read many keys at one block, in one round trip. +/// +/// Returns one entry per key, in the order given, so a caller can zip the answers +/// back onto the indices that produced them. A key the chain has nothing for comes +/// back as `None` — which, for a scan, is the ordinary case. +async fn read_many( + rpc: &RpcClient, + keys: &[Vec], + at: &str, +) -> Result>>, CoinageError> { + use std::collections::HashMap; + + let hex_keys: Vec = keys + .iter() + .map(|key| format!("0x{}", hex::encode(key))) + .collect(); + let response = rpc + .call( + "state_queryStorageAt", + serde_json::json!([hex_keys.clone(), at]), + ) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string()))?; + + // `state_queryStorageAt` answers with one change set per block, each holding + // `[key, value]` pairs for the keys that have a value. + let mut present: HashMap> = HashMap::new(); + for change_set in response.as_array().into_iter().flatten() { + for change in change_set + .get("changes") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + let Some(pair) = change.as_array() else { + continue; + }; + let (Some(key), Some(value)) = ( + pair.first().and_then(serde_json::Value::as_str), + pair.get(1).and_then(serde_json::Value::as_str), + ) else { + continue; + }; + let bytes = + hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|error| { + CoinageError::RecoveryFailed(format!( + "decoding a scanned storage value: {error}" + )) + })?; + present.insert(key.to_string(), bytes); + } + } + + Ok(hex_keys + .into_iter() + .map(|key| present.get(&key).cloned()) + .collect()) +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::Encode; + + use crate::host_logic::coinage::types::CoinAge; + use crate::runtime::coinage::testing::FakeChain; + + use super::*; + + const ENTROPY: [u8; 32] = [7; 32]; + const NOW: Timestamp = Timestamp(1_700_000_000_000); + + fn block_on(future: F) -> F::Output { + futures::executor::block_on(future) + } + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + /// A scan that looks at a handful of indices and gives up after two empty + /// batches, so a test stays readable. + fn params() -> CoinageParameters { + CoinageParameters { + recovery_batch_size: 4, + recovery_gap_limit: 2, + ..CoinageParameters::default() + } + } + + fn store() -> CoinageStore { + CoinageStore::new("Main".to_string()) + } + + /// Put a coin on chain at one of our derived accounts. + fn place_coin(chain: &FakeChain, purse: PurseId, index: u32, exponent_value: i8, age: u16) { + let account = + derivation::coin_account_id(&ENTROPY, purse, CoinIndex(index)).expect("derives"); + chain.set_storage( + &storage::coins_by_owner_key(&account), + storage::ChainCoin { + value: exponent_value, + age, + } + .encode(), + ); + } + + /// Put a recycler entry on chain at one of our derived member keys. + fn place_entry(chain: &FakeChain, purse: PurseId, index: u32, exponent_value: i8) { + let member_key = + derivation::entry_member_key(&ENTROPY, purse, EntryIndex(index)).expect("derives"); + chain.set_storage( + &storage::recyclers_coin_to_recycler_key(&member_key), + exponent_value.encode(), + ); + } + + fn scan(chain: &FakeChain, store: &mut CoinageStore) -> ScanOutcome { + block_on(scan_purse( + &chain.rpc(), + store, + &ENTROPY, + PurseId::MAIN, + ¶ms(), + CoinIndex(0), + EntryIndex(0), + NOW, + "0xfeed", + )) + .expect("scans") + } + + #[test] + fn a_scan_restores_coins_at_the_indices_they_were_derived_under() { + let chain = FakeChain::default(); + let mut store = store(); + place_coin(&chain, PurseId::MAIN, 0, 4, 3); + place_coin(&chain, PurseId::MAIN, 2, 3, 0); + + let outcome = scan(&chain, &mut store); + + assert_eq!(outcome.coins_found, 2); + let restored = store.coins_in(PurseId::MAIN); + assert_eq!(restored.len(), 2); + assert_eq!(restored[0].index, CoinIndex(0)); + assert_eq!(restored[0].exponent, exponent(4)); + assert_eq!(restored[0].age, CoinAge(3), "the age came from the chain"); + assert_eq!(restored[1].index, CoinIndex(2)); + assert_eq!(restored[1].exponent, exponent(3)); + // Restored coins are spendable: the chain says the accounts hold them. + assert_eq!( + store + .balance(PurseId::MAIN, NOW) + .expect("purse exists") + .spendable, + crate::host_logic::coinage::types::Amount::from_cents(24) + ); + } + + #[test] + fn a_scan_leaves_the_index_counter_past_everything_it_found() { + // §4.3's invariant survives a recovery: the next allocation must not + // re-derive an account the scan just restored. + let chain = FakeChain::default(); + let mut store = store(); + place_coin(&chain, PurseId::MAIN, 5, 4, 0); + + scan(&chain, &mut store); + + let next = store + .add_pending_coin(PurseId::MAIN, exponent(2)) + .expect("purse exists"); + assert!( + next.0 > 5, + "the counter moved past the restored index, not to it: {next:?}" + ); + } + + #[test] + fn a_scan_restores_entries_with_the_denomination_the_chain_reports() { + let chain = FakeChain::default(); + let mut store = store(); + place_entry(&chain, PurseId::MAIN, 0, 4); + place_entry(&chain, PurseId::MAIN, 1, 2); + + let outcome = scan(&chain, &mut store); + + assert_eq!(outcome.entries_found, 2); + let restored = store.entries_in(PurseId::MAIN); + assert_eq!(restored[0].exponent, exponent(4)); + assert_eq!(restored[1].exponent, exponent(2)); + // Readiness cannot be restored — the local jitter draw is gone — so a + // recovered entry is selectable at once. + assert!(restored[0].ready_at <= NOW); + } + + #[test] + fn a_scan_stops_after_the_gap_limit_and_says_where_it_stopped() { + let chain = FakeChain::default(); + let mut store = store(); + place_coin(&chain, PurseId::MAIN, 0, 4, 0); + + let outcome = scan(&chain, &mut store); + + // One batch found something, then two empty ones ended it: 4 + 4 + 4. + assert_eq!(outcome.next_coin_index, CoinIndex(12)); + assert_eq!(outcome.coins_found, 1); + } + + #[test] + fn a_coin_beyond_the_gap_is_missed_and_the_cursor_says_where_to_resume() { + // The scan's blind spot, and the reason §8.10 has `extend_scan`: a long + // unused stretch ends the walk before a later index is reached. + let chain = FakeChain::default(); + let mut store = store(); + place_coin(&chain, PurseId::MAIN, 40, 4, 0); + + let outcome = scan(&chain, &mut store); + assert_eq!(outcome.coins_found, 0, "the gap swallowed it"); + + // Resuming from the reported cursor, with the same limits, walks further. + let extended = block_on(scan_purse( + &chain.rpc(), + &mut store, + &ENTROPY, + PurseId::MAIN, + &CoinageParameters { + recovery_batch_size: 4, + recovery_gap_limit: 12, + ..CoinageParameters::default() + }, + outcome.next_coin_index, + outcome.next_entry_index, + NOW, + "0xfeed", + )) + .expect("scans"); + + assert_eq!(extended.coins_found, 1, "a wider gap limit reaches it"); + assert_eq!(store.coins_in(PurseId::MAIN)[0].index, CoinIndex(40)); + } + + #[test] + fn a_denomination_this_layer_cannot_represent_fails_the_scan() { + // Silently skipping it would leave value on chain that the wallet does not + // know it has, which is the exact failure recovery exists to fix. + let chain = FakeChain::default(); + let mut store = store(); + place_coin(&chain, PurseId::MAIN, 0, -1, 0); + + let refused = block_on(scan_purse( + &chain.rpc(), + &mut store, + &ENTROPY, + PurseId::MAIN, + ¶ms(), + CoinIndex(0), + EntryIndex(0), + NOW, + "0xfeed", + )) + .expect_err("a sub-cent denomination has no representation here"); + + assert!(matches!(refused, CoinageError::RecoveryFailed(_))); + } + + #[test] + fn a_scan_of_an_empty_wallet_finds_nothing_and_says_so() { + let chain = FakeChain::default(); + let mut store = store(); + + let outcome = scan(&chain, &mut store); + + assert!(outcome.is_empty()); + assert!(store.coins_in(PurseId::MAIN).is_empty()); + } + + #[test] + fn every_read_is_pinned_to_one_block() { + // A scan spanning blocks could see a coin move mid-walk and record it in + // two places, or in neither. + let chain = FakeChain::default(); + let mut store = store(); + place_coin(&chain, PurseId::MAIN, 1, 4, 0); + + scan(&chain, &mut store); + + for (method, params) in chain.calls() { + assert_eq!( + method, "state_queryStorageAt", + "a scan reads in bulk, one round trip per batch" + ); + assert!(params.contains("0xfeed"), "unpinned read: {params}"); + } + // Two batches of coins and two of entries, not one request per index. + assert!(chain.calls().len() <= 8, "{} requests", chain.calls().len()); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/storage.rs b/rust/crates/truapi-server/src/runtime/coinage/storage.rs new file mode 100644 index 000000000..9c49c9e11 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/storage.rs @@ -0,0 +1,1131 @@ +//! Storage keys and decoding for the chain state coinage observes. +//! +//! The layer's local records are a projection of chain state, so observation is +//! the half of the chain layer that keeps them true. This module builds the keys +//! and decodes the values; issuing the reads and driving the loop belong to the +//! caller, which keeps every byte-layout decision here and unit-testable. +//! +//! Storage keys are pinned by golden tests. A hasher silently changed is a query +//! that returns nothing rather than an error, which would read as "the user has +//! no coins" — the most dangerous possible failure for a wallet. + +use parity_scale_codec::{Decode, Encode}; +use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; + +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::params::CoinageParameters; +use crate::host_logic::coinage::store::CoinageStore; +use crate::host_logic::coinage::types::{ + CoinAccountId, CoinAge, CoinIndex, DenominationExponent, EntryIndex, PurseId, RevisionIndex, + RingIndex, RingLocation, Timestamp, +}; + +/// Prefix of a recycler ring's 32-byte collection identifier. +/// +/// Rings are segregated by denomination, so the exponent goes in the byte right +/// after this prefix and the rest is zero. +const RECYCLER_COLLECTION_PREFIX: &[u8] = b"coinage/recycler"; + +/// Byte holding the denomination inside a recycler collection identifier. +const RECYCLER_COLLECTION_EXPONENT_OFFSET: usize = 16; + +/// Prefix of a paid unload-token ring's 32-byte collection identifier. +/// +/// Note the trailing `!`: the prefix is exactly sixteen bytes, and the pallet pads +/// it that way rather than leaving the sixteenth byte zero. Dropping it shifts the +/// period and produces a collection nobody has ever created. +const PAID_TOKEN_COLLECTION_PREFIX: &[u8] = b"coinage/paidtkn!"; + +/// Offset of the little-endian `u32` period inside a paid-token collection +/// identifier. +const PAID_TOKEN_COLLECTION_PERIOD_OFFSET: usize = 16; + +/// `Blake2_128Concat(x)` = `blake2_128(x) ‖ x`. +fn blake2_128_concat(x: &[u8]) -> Vec { + [blake2_128(x).as_slice(), x].concat() +} + +/// `Twox64Concat(x)` = `twox_64(x) ‖ x`. +fn twox_64_concat(x: &[u8]) -> Vec { + [twox_64(x).as_slice(), x].concat() +} + +/// The membership collection holding recycler rings of one denomination. +pub fn recycler_collection_id(exponent: DenominationExponent) -> [u8; 32] { + let mut id = [0u8; 32]; + id[..RECYCLER_COLLECTION_PREFIX.len()].copy_from_slice(RECYCLER_COLLECTION_PREFIX); + id[RECYCLER_COLLECTION_EXPONENT_OFFSET] = exponent.get() as u8; + id +} + +/// The membership collection holding one period's paid unload-token ring. +/// +/// `"coinage/paidtkn!" ‖ period_le ‖ zeros`, matching +/// `Pallet::paid_token_collection_identifier`. One collection per period, created +/// by the pallet's own `on_poll` ahead of time and deleted once the period has +/// expired — so an identifier for a period that has come and gone resolves to +/// nothing. +pub fn paid_token_collection_id(period: u32) -> [u8; 32] { + let mut id = [0u8; 32]; + id[..PAID_TOKEN_COLLECTION_PREFIX.len()].copy_from_slice(PAID_TOKEN_COLLECTION_PREFIX); + id[PAID_TOKEN_COLLECTION_PERIOD_OFFSET..PAID_TOKEN_COLLECTION_PERIOD_OFFSET + 4] + .copy_from_slice(&period.to_le_bytes()); + id +} + +/// `Coinage::CoinsByOwner(account)` — `Twox64Concat` over `AccountId`. +pub fn coins_by_owner_key(account: &CoinAccountId) -> Vec { + [ + twox_128(b"Coinage").as_slice(), + twox_128(b"CoinsByOwner").as_slice(), + &twox_64_concat(&account.0), + ] + .concat() +} + +/// `Coinage::LockedCoins(account)` — `Twox64Concat` over `AccountId`. +/// +/// Absence is the common case and means the coin is unlocked; a value means the +/// runtime refuses the coin as an origin until its expiry. +pub fn locked_coins_key(account: &CoinAccountId) -> Vec { + [ + twox_128(b"Coinage").as_slice(), + twox_128(b"LockedCoins").as_slice(), + &twox_64_concat(&account.0), + ] + .concat() +} + +/// `Coinage::RecyclersCoinToRecycler(member_key)` — `Twox64Concat` over the +/// bandersnatch member key. Presence means the entry is loaded, and the value is +/// the denomination of the ring holding it. +pub fn recyclers_coin_to_recycler_key(member_key: &[u8; 32]) -> Vec { + [ + twox_128(b"Coinage").as_slice(), + twox_128(b"RecyclersCoinToRecycler").as_slice(), + &twox_64_concat(member_key), + ] + .concat() +} + +/// `Coinage::RecyclerAliasStates((value, ring, alias))` — a three-key +/// `StorageNMap`, every key `Twox64Concat`, concatenated in declaration order. +/// +/// Keyed by the contextual alias rather than the entry's member key: the alias +/// is what an unload reveals, and it is what the pallet locks after a failed +/// dispatch. +pub fn recycler_alias_state_key( + exponent: DenominationExponent, + ring: RingIndex, + alias: &[u8; 32], +) -> Vec { + [ + twox_128(b"Coinage").as_slice(), + twox_128(b"RecyclerAliasStates").as_slice(), + &twox_64_concat(&[exponent.get() as u8]), + &twox_64_concat(&ring.0.to_le_bytes()), + &twox_64_concat(alias), + ] + .concat() +} + +/// `Members::Members(collection, member_key)` — the collection identifier is +/// used raw (`Identity`), the member key `Blake2_128Concat`. +/// +/// This is the member-to-ring lookup: the coinage pallet's own +/// `RecyclersCoinToRecycler` only reports which *denomination* collection an +/// entry belongs to, never which ring inside it. +pub fn members_key(collection: &[u8; 32], member_key: &[u8; 32]) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Members").as_slice(), + collection.as_slice(), + &blake2_128_concat(member_key), + ] + .concat() +} + +/// `Members::Root(collection, ring_index)` — the collection identifier raw, +/// the ring index `Blake2_128Concat`. +/// +/// Holds the ring commitment and its revision. A membership proof is only valid +/// against the revision it was built for, so an unload needs both halves of the +/// ring location. +pub fn ring_root_key(collection: &[u8; 32], ring: RingIndex) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Root").as_slice(), + collection.as_slice(), + &blake2_128_concat(&ring.0.to_le_bytes()), + ] + .concat() +} + +/// `Members::RingKeysStatus((collection, ring_index))` — the collection +/// identifier is used raw, the ring index `Blake2_128Concat`. +pub fn ring_keys_status_key(collection: &[u8; 32], ring: RingIndex) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + collection.as_slice(), + &blake2_128_concat(&ring.0.to_le_bytes()), + ] + .concat() +} + +/// `Members::RingKeys((collection, ring_index, page))` — the collection +/// identifier raw, the ring index `Blake2_128Concat`, the page `Twox64Concat`. +/// +/// A ring's members are paged, and proving membership needs all of them: the +/// prover reconstructs the ring commitment from the member list, so a missed page +/// produces a proof against a ring the chain does not have. +pub fn ring_keys_key(collection: &[u8; 32], ring: RingIndex, page: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeys").as_slice(), + collection.as_slice(), + &blake2_128_concat(&ring.0.to_le_bytes()), + &twox_64_concat(&page.to_le_bytes()), + ] + .concat() +} + +/// `Members::Collections(collection)` — the collection identifier used raw. +/// +/// Carries the ring size, which fixes the proof domain. A proof built for the +/// wrong domain does not verify. +pub fn collections_key(collection: &[u8; 32]) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Collections").as_slice(), + collection.as_slice(), + ] + .concat() +} + +/// `Members::CurrentRingIndex(collection)` — `Identity` over the identifier. +/// +/// The newest ring of a collection, and so the upper bound on where a member key +/// can have been placed. `ValueQuery`, so an absent value means ring zero rather +/// than no rings. +pub fn current_ring_index_key(collection: &[u8; 32]) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"CurrentRingIndex").as_slice(), + collection.as_slice(), + ] + .concat() +} + +/// `Coinage::ConsumedFreeUnloadTokens((period, alias))` — both keys +/// `Twox64Concat`. +/// +/// Presence means the slot is spent. A free unload token is one `(period, +/// counter)` slot, identified on chain by the alias the personhood key produces +/// in that slot's context. +pub fn consumed_free_unload_tokens_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Coinage").as_slice(), + twox_128(b"ConsumedFreeUnloadTokens").as_slice(), + &twox_64_concat(&period.to_le_bytes()), + &twox_64_concat(alias), + ] + .concat() +} + +/// `Coinage::PaidUnloadTokenMembers(member_key)` — `Twox64Concat` over the +/// member key. +/// +/// Presence means this key has joined a paid unload-token ring. +pub fn paid_unload_token_members_key(member_key: &[u8; 32]) -> Vec { + [ + twox_128(b"Coinage").as_slice(), + twox_128(b"PaidUnloadTokenMembers").as_slice(), + &twox_64_concat(member_key), + ] + .concat() +} + +/// `Coinage::PaidTokenCollectionsCreated(period)` — `Identity` over a +/// **big-endian** `u32`. +/// +/// Presence means the pallet has created that period's collection, which is what +/// makes the ring joinable. The pallet creates it proactively in `on_poll`, so +/// absence normally means the period is in the future or already expired. +/// +/// The period is big-endian here and little-endian inside +/// [`paid_token_collection_id`]. That is the pallet's own inconsistency, and it is +/// deliberate on its side: `Identity` keys are iterated in lexicographic order, so +/// only big-endian bytes iterate in numeric order. +pub fn paid_token_collections_created_key(period: u32) -> Vec { + [ + twox_128(b"Coinage").as_slice(), + twox_128(b"PaidTokenCollectionsCreated").as_slice(), + &period.to_be_bytes(), + ] + .concat() +} + +/// `Coinage::PaidUnloadTokenConsumed((period, ring, alias))` — `Identity` over a +/// big-endian `u32`, then `Twox64Concat` over the ring index and the alias. +/// +/// Presence means this token has already been spent. Since a paid member key +/// yields exactly one alias per period, this is also the answer to "has this slot +/// been used". +pub fn paid_unload_token_consumed_key(period: u32, ring: RingIndex, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Coinage").as_slice(), + twox_128(b"PaidUnloadTokenConsumed").as_slice(), + &period.to_be_bytes(), + &twox_64_concat(&ring.0.to_le_bytes()), + &twox_64_concat(alias), + ] + .concat() +} + +/// `System::Account(account)` — `Blake2_128Concat` over `AccountId`. +/// +/// The fee account's native balance lives here, and it is what decides between +/// the two unload fee modes (§6.6). +pub fn system_account_key(account: &CoinAccountId) -> Vec { + [ + twox_128(b"System").as_slice(), + twox_128(b"Account").as_slice(), + &blake2_128_concat(&account.0), + ] + .concat() +} + +/// The coin record the pallet stores per account. +/// +/// `Encode` is derived so tests can build the exact bytes the chain returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct ChainCoin { + /// Denomination exponent. + pub value: i8, + /// Transfers and splits so far. + pub age: u16, +} + +/// Why the chain is holding a coin. +/// +/// A single-variant enum on chain today. Decoded as an enum rather than skipped +/// so a runtime that adds a reason fails to decode instead of being read as the +/// one reason this build knows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum ChainLockReason { + /// A dispatch that used the coin as its origin failed. + FailedDispatch { + /// Consecutive failures so far; the lock doubles with each. + retries: u8, + }, +} + +/// The lock the pallet stores against a coin account. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct ChainCoinLock { + /// Why the coin is locked. + pub reason: ChainLockReason, + /// Unix timestamp, in seconds, at which the lock expires. + pub until: u64, +} + +/// What the pallet records against a recycler alias. +/// +/// Absence means the alias is available. The two present states are not +/// interchangeable: `Locked` is temporary and the entry comes back, `Unloaded` +/// is terminal and it never will. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum ChainAliasState { + /// Temporarily locked after a failed dispatch. + Locked(ChainCoinLock), + /// Permanently consumed by a successful unload. + Unloaded, +} + +/// How full a ring is, as `Members` reports it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct RingKeysStatus { + /// Keys submitted to the ring. + pub total: u32, + /// Keys included in its committed membership. + pub included: u32, + /// When the ring became immutable, in Unix seconds, once it is full. + /// + /// The clock the ring-expiration rescue sweep races: the chain destroys the + /// backing value of any entry still in the ring `RecyclerExpirationTime` + /// after this. Decoding the status without this field would silently drop + /// the only signal that a purse is about to lose money. + pub immutable_since: Option, +} + +/// Where the `Members` pallet places one member key. +/// +/// Only `Included` carries a ring index, and that is the point: an onboarding +/// or suspended member is in no ring, so an entry in either state cannot be +/// unloaded and must not be treated as merely "waiting for members". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum RingPosition { + /// Queued, not yet in a ring. + Onboarding { + /// Page of the onboarding queue. + queue_page: u32, + /// When the member was queued, in Unix seconds. + queued_at: u64, + }, + /// Registered in a ring. + Included { + /// Ring holding the member. + ring_index: u32, + /// Page within the ring. + ring_page: u32, + /// Position within the page. + ring_position: u32, + }, + /// Suspended, and so in no ring at all. + Suspended, +} + +impl RingPosition { + /// The ring holding this member, if it is in one. + pub const fn ring_index(&self) -> Option { + match self { + Self::Included { ring_index, .. } => Some(RingIndex(*ring_index)), + Self::Onboarding { .. } | Self::Suspended => None, + } + } +} + +/// One coin's chain state, ready to apply. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObservedCoin { + /// Which local record this is about. + pub index: CoinIndex, + /// The coin the chain reports, or `None` if the account is empty. + pub coin: Option, + /// The chain's lock on the account, or `None` if it holds none. + pub lock: Option, +} + +/// One recycler entry's chain state, ready to apply. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObservedEntry { + /// Which local record this is about. + pub index: EntryIndex, + /// Where the entry sits, if the chain reports a location for it. + pub ring: Option, + /// Committed member count of that ring, which decides the anonymity + /// classification. + pub included_members: u32, + /// When that ring became immutable, if it has. Drives the rescue sweep. + pub ring_immutable_since: Option, +} + +/// Decode a `CoinsByOwner` value, treating an absent entry as an empty account. +pub fn decode_coin(bytes: Option>) -> Result, CoinageError> { + match bytes { + None => Ok(None), + Some(raw) => ChainCoin::decode(&mut &raw[..]) + .map(Some) + .map_err(|error| CoinageError::Internal(format!("decoding a coin failed: {error}"))), + } +} + +/// Decode a `LockedCoins` value, treating an absent entry as unlocked. +pub fn decode_coin_lock(bytes: Option>) -> Result, CoinageError> { + match bytes { + None => Ok(None), + Some(raw) => ChainCoinLock::decode(&mut &raw[..]) + .map(Some) + .map_err(|error| { + CoinageError::Internal(format!("decoding a coin lock failed: {error}")) + }), + } +} + +/// Decode a `Members::Members` value; absence means the key is unknown to the +/// collection. +pub fn decode_ring_position(bytes: Option>) -> Result, CoinageError> { + match bytes { + None => Ok(None), + Some(raw) => RingPosition::decode(&mut &raw[..]) + .map(Some) + .map_err(|error| { + CoinageError::Internal(format!("decoding a ring position failed: {error}")) + }), + } +} + +/// Decode a `RecyclerAliasStates` value, treating an absent entry as available. +pub fn decode_alias_state(bytes: Option>) -> Result, CoinageError> { + match bytes { + None => Ok(None), + Some(raw) => ChainAliasState::decode(&mut &raw[..]) + .map(Some) + .map_err(|error| { + CoinageError::Internal(format!("decoding an alias state failed: {error}")) + }), + } +} + +/// Decode a `RingKeysStatus` value, treating an absent entry as an empty ring. +pub fn decode_ring_status(bytes: Option>) -> Result { + match bytes { + None => Ok(RingKeysStatus { + total: 0, + included: 0, + immutable_since: None, + }), + Some(raw) => RingKeysStatus::decode(&mut &raw[..]).map_err(|error| { + CoinageError::Internal(format!("decoding a ring status failed: {error}")) + }), + } +} + +/// Apply a batch of observations to the store. +/// +/// A coin the chain no longer holds is left alone rather than retired here: only +/// its owning operation knows whether an empty account means spent or means the +/// extrinsic has not landed yet, and guessing would race it. +pub fn apply_observations( + store: &mut CoinageStore, + purse: PurseId, + coins: &[ObservedCoin], + entries: &[ObservedEntry], + params: &CoinageParameters, +) -> Result<(), CoinageError> { + for observed in coins { + // The lock is applied first and unconditionally: it is a fact about the + // account whether or not the account currently holds a coin, and a + // record whose lock has been dropped must stop reporting one. + store.observe_coin_lock( + purse, + observed.index, + observed + .lock + .map(|lock| Timestamp::from_unix_seconds(lock.until)), + )?; + + if let Some(coin) = observed.coin { + let exponent = DenominationExponent::new(coin.value).ok_or_else(|| { + CoinageError::Internal(format!( + "chain reports coin {:?} at unsupported denomination {}", + observed.index, coin.value + )) + })?; + let known = store + .coin(purse, observed.index) + .ok_or_else(|| untracked("coin", purse))?; + if known.exponent != exponent { + return Err(CoinageError::Internal(format!( + "chain reports coin {:?} as {exponent}, local record says {}", + observed.index, known.exponent + ))); + } + + store.observe_coin(purse, observed.index, CoinAge(coin.age))?; + } + } + + for observed in entries { + store.observe_entry_ring_immutability( + purse, + observed.index, + observed.ring_immutable_since, + )?; + + match observed.ring { + Some(ring) => { + store.observe_entry_ring( + purse, + observed.index, + ring, + observed.included_members, + params, + )?; + } + None => store.observe_entry_missing(purse, observed.index)?, + } + } + + Ok(()) +} + +/// Build the observation for one entry from the two reads it needs. +/// +/// The chain reports an entry's denomination and ring index separately from the +/// ring's fill level, so this pairs them up and keeps the revision the caller +/// pinned its reads at. +pub fn observe_entry( + index: EntryIndex, + recycler_denomination: Option, + ring: RingIndex, + revision: RevisionIndex, + status: RingKeysStatus, +) -> ObservedEntry { + ObservedEntry { + index, + ring: recycler_denomination.map(|_| RingLocation::new(ring, revision)), + included_members: status.included, + ring_immutable_since: status.immutable_since.map(Timestamp::from_unix_seconds), + } +} + +fn untracked(kind: &str, purse: PurseId) -> CoinageError { + CoinageError::Internal(format!( + "chain reports a {kind} in {purse} that the layer does not track" + )) +} + +#[cfg(test)] +mod tests { + use core::time::Duration; + + use crate::host_logic::coinage::entry::{EntryLocalState, EntryOnChainState}; + use crate::host_logic::coinage::types::{Amount, Timestamp}; + + use super::*; + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn params() -> CoinageParameters { + CoinageParameters::default() + } + + #[test] + fn a_recycler_collection_is_segregated_by_denomination() { + let four = recycler_collection_id(exponent(4)); + let five = recycler_collection_id(exponent(5)); + + assert_eq!(&four[..16], b"coinage/recycler"); + assert_eq!(four[16], 4); + assert_eq!(&four[17..], &[0u8; 15]); + assert_ne!(four, five, "each denomination has its own ring collection"); + } + + #[test] + fn a_paid_token_collection_carries_a_little_endian_period_after_a_sixteen_byte_prefix() { + let collection = paid_token_collection_id(0x0102_0304); + + // The `!` is part of the prefix. Treating it as padding would shift the + // period one byte left and name a collection that does not exist. + assert_eq!(&collection[..16], b"coinage/paidtkn!"); + assert_eq!(&collection[16..20], &[0x04, 0x03, 0x02, 0x01]); + assert_eq!(&collection[20..], &[0u8; 12]); + + assert_ne!( + paid_token_collection_id(7), + paid_token_collection_id(8), + "each period has its own ring collection" + ); + } + + #[test] + fn the_paid_token_period_is_little_endian_in_the_collection_and_big_endian_in_its_keys() { + // The pallet spells the same period two ways, and a client that picks one + // and uses it everywhere reads an absent key as "not a member" — which is + // the failure that silently disables the paid fallback. + let period = 0x0102_0304u32; + let collection = paid_token_collection_id(period); + let created = paid_token_collections_created_key(period); + let consumed = paid_unload_token_consumed_key(period, RingIndex(1), &[9u8; 32]); + + assert_eq!(&collection[16..20], &period.to_le_bytes()); + assert_eq!(&created[32..36], &period.to_be_bytes()); + assert_eq!(&consumed[32..36], &period.to_be_bytes()); + } + + #[test] + fn the_consumed_paid_token_key_layers_identity_then_two_twox_concats() { + let alias = [5u8; 32]; + let key = paid_unload_token_consumed_key(9, RingIndex(3), &alias); + + assert_eq!(&key[..16], twox_128(b"Coinage").as_slice()); + assert_eq!( + &key[16..32], + twox_128(b"PaidUnloadTokenConsumed").as_slice() + ); + // Identity: the period's four bytes go in raw, with no hash in front. + assert_eq!(&key[32..36], &9u32.to_be_bytes()); + assert_eq!(&key[36..44], twox_64(&3u32.to_le_bytes()).as_slice()); + assert_eq!(&key[44..48], &3u32.to_le_bytes()); + assert_eq!(&key[48..56], twox_64(&alias).as_slice()); + assert_eq!(&key[56..], &alias); + } + + #[test] + fn storage_keys_are_pinned() { + // A hasher quietly changed makes a query return nothing rather than + // fail, which a wallet would render as "you have no coins". + let account = CoinAccountId([3; 32]); + let coin_key = coins_by_owner_key(&account); + + assert_eq!(&coin_key[..16], twox_128(b"Coinage").as_slice()); + assert_eq!(&coin_key[16..32], twox_128(b"CoinsByOwner").as_slice()); + assert_eq!(&coin_key[32..40], twox_64(&account.0).as_slice()); + assert_eq!(&coin_key[40..], &account.0); + assert_eq!(coin_key.len(), 16 + 16 + 8 + 32); + + let member = [7u8; 32]; + let entry_key = recyclers_coin_to_recycler_key(&member); + assert_eq!( + &entry_key[16..32], + twox_128(b"RecyclersCoinToRecycler").as_slice() + ); + assert_eq!(&entry_key[40..], &member); + + let collection = recycler_collection_id(exponent(4)); + let status_key = ring_keys_status_key(&collection, RingIndex(9)); + assert_eq!(&status_key[16..32], twox_128(b"RingKeysStatus").as_slice()); + // The collection identifier is used raw; only the ring index is hashed. + assert_eq!(&status_key[32..64], &collection); + assert_eq!(&status_key[64..80], blake2_128(&9u32.to_le_bytes())); + assert_eq!(&status_key[80..], &9u32.to_le_bytes()); + + let lock_key = locked_coins_key(&account); + assert_eq!(&lock_key[..16], twox_128(b"Coinage").as_slice()); + assert_eq!(&lock_key[16..32], twox_128(b"LockedCoins").as_slice()); + assert_eq!(&lock_key[32..40], twox_64(&account.0).as_slice()); + assert_eq!(&lock_key[40..], &account.0); + assert_ne!( + lock_key, coin_key, + "the lock and the coin are separate reads on the same account" + ); + } + + #[test] + fn an_absent_lock_means_unlocked_not_an_error() { + assert_eq!(decode_coin_lock(None).expect("absent is fine"), None); + } + + #[test] + fn a_coin_lock_round_trips_through_the_pallet_layout() { + let lock = ChainCoinLock { + reason: ChainLockReason::FailedDispatch { retries: 2 }, + until: 1_700_000_000, + }; + let encoded = lock.encode(); + + assert_eq!(encoded.len(), 1 + 1 + 8, "variant, retries, then u64"); + assert_eq!( + decode_coin_lock(Some(encoded)).expect("decodes"), + Some(lock) + ); + } + + #[test] + fn an_unrecognized_lock_reason_fails_rather_than_being_read_as_the_known_one() { + // Reason variant 9 does not exist. Reading it as `FailedDispatch` would + // attach a wrong expiry to a real coin. + let mut bytes = vec![9u8, 0]; + bytes.extend_from_slice(&1_700_000_000u64.to_le_bytes()); + + assert!(decode_coin_lock(Some(bytes)).is_err()); + } + + #[test] + fn an_absent_coin_is_an_empty_account_not_an_error() { + assert_eq!(decode_coin(None).expect("absent is fine"), None); + } + + #[test] + fn a_coin_round_trips_through_the_pallet_layout() { + let encoded = ChainCoin { value: 4, age: 3 }.encode(); + + assert_eq!(encoded.len(), 1 + 2, "i8 then u16"); + assert_eq!( + decode_coin(Some(encoded)).expect("decodes"), + Some(ChainCoin { value: 4, age: 3 }) + ); + } + + #[test] + fn an_absent_ring_status_reads_as_empty() { + let status = decode_ring_status(None).expect("absent is fine"); + + assert_eq!(status.included, 0); + assert_eq!(status.total, 0); + } + + #[test] + fn observations_move_records_into_their_chain_state() { + let mut store = CoinageStore::new("Main".to_string()); + let coin = store + .add_pending_coin(PurseId::MAIN, exponent(4)) + .expect("purse exists"); + let entry = store + .allocate_entry(PurseId::MAIN, exponent(4), Timestamp(0), Duration::ZERO) + .expect("purse exists"); + + apply_observations( + &mut store, + PurseId::MAIN, + &[ObservedCoin { + index: coin, + coin: Some(ChainCoin { value: 4, age: 2 }), + lock: None, + }], + &[ObservedEntry { + index: entry, + ring: Some(RingLocation::new(RingIndex(1), RevisionIndex(0))), + included_members: 32, + ring_immutable_since: None, + }], + ¶ms(), + ) + .expect("both records are tracked"); + + assert_eq!( + store.coin(PurseId::MAIN, coin).expect("exists").age, + CoinAge(2) + ); + assert_eq!( + store.entry(PurseId::MAIN, entry).expect("exists").on_chain, + EntryOnChainState::Ready + ); + assert_eq!( + store + .balance(PurseId::MAIN, Timestamp(0)) + .expect("purse exists") + .spendable, + Amount::from_cents(32) + ); + } + + /// A store holding one observed coin, ready to have locks applied to it. + fn store_with_a_coin() -> (CoinageStore, CoinIndex) { + let mut store = CoinageStore::new("Main".to_string()); + let coin = store + .add_pending_coin(PurseId::MAIN, exponent(4)) + .expect("purse exists"); + (store, coin) + } + + fn observe( + store: &mut CoinageStore, + index: CoinIndex, + lock: Option, + ) -> Result<(), CoinageError> { + apply_observations( + store, + PurseId::MAIN, + &[ObservedCoin { + index, + coin: Some(ChainCoin { value: 4, age: 0 }), + lock, + }], + &[], + ¶ms(), + ) + } + + #[test] + fn a_chain_lock_is_read_as_seconds_and_stored_as_milliseconds() { + // The pallet counts seconds and this layer counts milliseconds. Getting + // this wrong makes a 60-second lock look 60 milliseconds long, so the + // coin is reselected immediately and the retry is refused again. + let (mut store, coin) = store_with_a_coin(); + + observe( + &mut store, + coin, + Some(ChainCoinLock { + reason: ChainLockReason::FailedDispatch { retries: 0 }, + until: 1_700_000_060, + }), + ) + .expect("the coin is tracked"); + + let record = store.coin(PurseId::MAIN, coin).expect("exists"); + assert_eq!(record.locked_until, Some(Timestamp(1_700_000_060_000))); + assert!(!record.is_selectable(Timestamp(1_700_000_059_999))); + assert!(record.is_selectable(Timestamp(1_700_000_060_000))); + } + + #[test] + fn observing_an_unlocked_account_releases_a_previous_lock() { + // The chain drops the entry once the lock expires, so absence has to + // clear the local record rather than being ignored as "no news". + let (mut store, coin) = store_with_a_coin(); + observe( + &mut store, + coin, + Some(ChainCoinLock { + reason: ChainLockReason::FailedDispatch { retries: 0 }, + until: 1_700_000_060, + }), + ) + .expect("the coin is tracked"); + + observe(&mut store, coin, None).expect("the coin is tracked"); + + let record = store.coin(PurseId::MAIN, coin).expect("exists"); + assert_eq!(record.locked_until, None); + assert!(record.is_selectable(Timestamp(0))); + } + + #[test] + fn a_thin_ring_is_classified_as_degraded() { + let mut store = CoinageStore::new("Main".to_string()); + let entry = store + .allocate_entry(PurseId::MAIN, exponent(4), Timestamp(0), Duration::ZERO) + .expect("purse exists"); + + apply_observations( + &mut store, + PurseId::MAIN, + &[], + &[ObservedEntry { + index: entry, + ring: Some(RingLocation::new(RingIndex(1), RevisionIndex(0))), + included_members: 3, + ring_immutable_since: None, + }], + ¶ms(), + ) + .expect("the entry is tracked"); + + assert_eq!( + store.entry(PurseId::MAIN, entry).expect("exists").on_chain, + EntryOnChainState::Degraded(3) + ); + } + + #[test] + fn an_entry_the_chain_no_longer_locates_reads_as_missing() { + let mut store = CoinageStore::new("Main".to_string()); + let entry = store + .allocate_entry(PurseId::MAIN, exponent(4), Timestamp(0), Duration::ZERO) + .expect("purse exists"); + apply_observations( + &mut store, + PurseId::MAIN, + &[], + &[ObservedEntry { + index: entry, + ring: Some(RingLocation::new(RingIndex(1), RevisionIndex(0))), + included_members: 32, + ring_immutable_since: None, + }], + ¶ms(), + ) + .expect("tracked"); + + apply_observations( + &mut store, + PurseId::MAIN, + &[], + &[ObservedEntry { + index: entry, + ring: None, + included_members: 0, + ring_immutable_since: None, + }], + ¶ms(), + ) + .expect("tracked"); + + let record = store.entry(PurseId::MAIN, entry).expect("exists"); + assert_eq!(record.on_chain, EntryOnChainState::Missing); + assert_eq!( + record.local, + EntryLocalState::Available, + "losing a location does not retire the record" + ); + } + + #[test] + fn a_denomination_disagreement_is_refused_rather_than_absorbed() { + // If the chain says a coin is a different size than the local record, + // something is wrong with derivation or the record. Overwriting would + // silently corrupt the balance. + let mut store = CoinageStore::new("Main".to_string()); + let coin = store + .add_pending_coin(PurseId::MAIN, exponent(4)) + .expect("purse exists"); + + let mismatch = apply_observations( + &mut store, + PurseId::MAIN, + &[ObservedCoin { + index: coin, + coin: Some(ChainCoin { value: 5, age: 0 }), + lock: None, + }], + &[], + ¶ms(), + ); + + assert!(matches!(mismatch, Err(CoinageError::Internal(_)))); + } + + #[test] + fn an_untracked_record_is_refused() { + let mut store = CoinageStore::new("Main".to_string()); + + let stray = apply_observations( + &mut store, + PurseId::MAIN, + &[ObservedCoin { + index: CoinIndex(42), + coin: Some(ChainCoin { value: 4, age: 0 }), + lock: None, + }], + &[], + ¶ms(), + ); + + assert!(matches!(stray, Err(CoinageError::Internal(_)))); + } + + #[test] + fn the_alias_state_key_is_pinned() { + // A three-key NMap: every key Twox64Concat, concatenated in the order + // the pallet declares them. Getting the order wrong reads a different + // alias entirely, which would look like "unlocked" and let selection + // reoffer an entry the runtime refuses. + let alias = [0xab; 32]; + let key = recycler_alias_state_key(exponent(4), RingIndex(7), &alias); + + assert_eq!(&key[..16], twox_128(b"Coinage").as_slice()); + assert_eq!(&key[16..32], twox_128(b"RecyclerAliasStates").as_slice()); + assert_eq!(&key[32..40], twox_64(&[4u8]).as_slice()); + assert_eq!(&key[40..41], &[4u8]); + assert_eq!(&key[41..49], twox_64(&7u32.to_le_bytes()).as_slice()); + assert_eq!(&key[49..53], &7u32.to_le_bytes()); + assert_eq!(&key[53..61], twox_64(&alias).as_slice()); + assert_eq!(&key[61..], &alias); + } + + #[test] + fn the_ring_page_and_collection_keys_are_pinned() { + // The three-key `RingKeys` map mixes all three hashers, so an order or + // hasher slip returns an empty page — indistinguishable from a ring that + // ends there, which would silently produce a proof against a truncated + // ring. + let collection = recycler_collection_id(exponent(4)); + let key = ring_keys_key(&collection, RingIndex(3), 2); + + assert_eq!(&key[..16], twox_128(b"Members").as_slice()); + assert_eq!(&key[16..32], twox_128(b"RingKeys").as_slice()); + assert_eq!(&key[32..64], &collection, "the collection is raw"); + assert_eq!(&key[64..80], blake2_128(&3u32.to_le_bytes())); + assert_eq!(&key[80..84], &3u32.to_le_bytes()); + assert_eq!(&key[84..92], twox_64(&2u32.to_le_bytes()).as_slice()); + assert_eq!(&key[92..], &2u32.to_le_bytes()); + + let collections = collections_key(&collection); + assert_eq!(&collections[16..32], twox_128(b"Collections").as_slice()); + assert_eq!(&collections[32..], &collection); + assert_eq!(collections.len(), 16 + 16 + 32); + } + + #[test] + fn the_unload_token_and_balance_keys_are_pinned() { + let alias = [0x5c; 32]; + let consumed = consumed_free_unload_tokens_key(77, &alias); + + assert_eq!(&consumed[..16], twox_128(b"Coinage").as_slice()); + assert_eq!( + &consumed[16..32], + twox_128(b"ConsumedFreeUnloadTokens").as_slice() + ); + assert_eq!(&consumed[32..40], twox_64(&77u32.to_le_bytes()).as_slice()); + assert_eq!(&consumed[40..44], &77u32.to_le_bytes()); + assert_eq!(&consumed[44..52], twox_64(&alias).as_slice()); + assert_eq!(&consumed[52..], &alias); + + // A different period must be a different slot, or one period's spend + // would read as every period's. + assert_ne!(consumed, consumed_free_unload_tokens_key(78, &alias)); + + let member = [0x91; 32]; + let paid = paid_unload_token_members_key(&member); + assert_eq!( + &paid[16..32], + twox_128(b"PaidUnloadTokenMembers").as_slice() + ); + assert_eq!(&paid[32..40], twox_64(&member).as_slice()); + assert_eq!(&paid[40..], &member); + + let account = CoinAccountId([0x22; 32]); + let balance = system_account_key(&account); + assert_eq!(&balance[..16], twox_128(b"System").as_slice()); + assert_eq!(&balance[16..32], twox_128(b"Account").as_slice()); + assert_eq!(&balance[32..48], blake2_128(&account.0)); + assert_eq!(&balance[48..], &account.0); + } + + #[test] + fn an_alias_state_distinguishes_a_temporary_lock_from_a_permanent_one() { + // The two are not interchangeable: locked comes back, unloaded never + // does, and confusing them either strands value or reoffers a consumed + // entry. + let locked = ChainAliasState::Locked(ChainCoinLock { + reason: ChainLockReason::FailedDispatch { retries: 1 }, + until: 1_700_000_120, + }); + + assert_eq!( + decode_alias_state(Some(locked.encode())).expect("decodes"), + Some(locked) + ); + assert_eq!( + decode_alias_state(Some(ChainAliasState::Unloaded.encode())).expect("decodes"), + Some(ChainAliasState::Unloaded) + ); + assert_eq!( + decode_alias_state(None).expect("absent is fine"), + None, + "an absent entry means the alias is available" + ); + } + + #[test] + fn an_alias_lock_keeps_a_locally_available_entry_out_of_selection() { + let mut store = CoinageStore::new("Main".to_string()); + let entry = store + .allocate_entry(PurseId::MAIN, exponent(4), Timestamp(0), Duration::ZERO) + .expect("purse exists"); + apply_observations( + &mut store, + PurseId::MAIN, + &[], + &[ObservedEntry { + index: entry, + ring: Some(RingLocation::new(RingIndex(1), RevisionIndex(0))), + included_members: 32, + ring_immutable_since: None, + }], + ¶ms(), + ) + .expect("the entry is tracked"); + assert_eq!( + store + .balance(PurseId::MAIN, Timestamp(0)) + .expect("purse exists") + .spendable, + Amount::from_cents(16) + ); + + store + .observe_entry_alias_lock( + PurseId::MAIN, + entry, + Some(Timestamp::from_unix_seconds(1_700_000_120)), + ) + .expect("the entry is tracked"); + + let held = store + .balance(PurseId::MAIN, Timestamp::from_unix_seconds(1_700_000_000)) + .expect("purse exists"); + assert_eq!(held.spendable, Amount::ZERO); + assert_eq!( + held.pending, + Amount::from_cents(16), + "the value is intact, just not yet usable" + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/submit.rs b/rust/crates/truapi-server/src/runtime/coinage/submit.rs new file mode 100644 index 000000000..0925eeb5b --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/submit.rs @@ -0,0 +1,1025 @@ +//! Submitting an assembled coinage extrinsic and reading back what the chain +//! did with it. +//! +//! [`super::extrinsic`] produces the bytes; this module owns everything after +//! that, in three steps: +//! +//! 1. **Dry-run** through `TaggedTransactionQueue_validate_transaction`. A +//! coinage extrinsic carries no signature, so validity is the only +//! pre-broadcast signal that the `AsCoinage` extension accepted its proofs. +//! 2. **Submit and watch** until the extrinsic reaches a block. +//! 3. **Classify** the dispatch outcome from that block's `System.Events`. +//! +//! Step 3 is the one that cannot be skipped. Inclusion is not success: a +//! coinage call that lands in a block and then fails to dispatch produces a +//! block hash indistinguishable from a successful one until its events are +//! read. +//! +//! The result is deliberately three-valued ([`TrackerOutcome`]), because a +//! wallet that confuses two of those values loses money: +//! +//! - **definitively not included** — nothing happened, the caller may rebuild +//! and retry with the same inputs; +//! - **included, with a verdict** — [`SubmissionVerdict`]; +//! - **unknown** — everything else, which the caller must resolve by observing +//! finalized chain state, never by assuming either of the above. +//! +//! Inclusion is also graded. A verdict read at a non-finalized block is +//! *optimistic*: it may move an operation to `InBlock` and drive UI, but it may +//! not retire records or write a receipt, because a reorg can invalidate the +//! transaction on the new canonical chain. Only `SubmissionVerdict::finalized` +//! marks an outcome settled; everything else waits for recovery (§7.7). +//! +//! Note what [`SubmissionVerdict::DispatchFailed`] does *not* mean. A failed +//! dispatch reverts the call's own storage writes, but a transaction +//! extension's `prepare` runs outside that layer, and `AsCoinage` only partly +//! compensates for that in `post_dispatch`: +//! +//! - a coin origin is put back, under a `LockedCoins` entry that refuses it for +//! `2^retries` times `CoinFailureLockPeriod`; +//! - an output-token's first alias is put back, locked the same way; +//! - a **free or paid unload token is gone**. Nothing restores it. +//! +//! So the operation's records survive, but they are not immediately reusable, +//! and a retry costs a fresh token. Settling this verdict by releasing the +//! locks as if nothing happened produces a resubmission the runtime refuses and +//! a second token spent on it. Observe `LockedCoins` and let the record's own +//! lock decide when it is selectable again. + +use serde_json::json; +use sp_crypto_hashing::{blake2_256, twox_128}; +use subxt::ext::scale_value::scale::decode_as_type; +use subxt::ext::scale_value::{Composite, Value, ValueDef, Variant}; +use thiserror::Error; + +use crate::host_logic::coinage::params::EXTRINSIC_MORTALITY_BLOCKS; +use crate::host_logic::coinage::types::{BlockHash, ExtrinsicHash}; +use crate::runtime::statement_allowance::extension::{ChainState, EraAnchor, Metadata}; +use crate::runtime::statement_allowance::rpc::{RpcClient, RpcError}; +use crate::runtime::statement_allowance::{ + StatementAllowanceError, fetch_chain_state, fetch_era_anchor, +}; + +/// Runtime API answering whether the chain would accept an extrinsic. +const VALIDATE_TRANSACTION: &str = "TaggedTransactionQueue_validate_transaction"; + +/// `TransactionSource::External` — the extrinsic arrived over RPC rather than +/// from a block or a local author. +const TRANSACTION_SOURCE_EXTERNAL: u8 = 2; + +/// `TransactionValidityError::Invalid(InvalidTransaction)` variants, in +/// declaration order. +/// +/// Pinned rather than resolved: a runtime API's return type is not in the +/// metadata type registry, so nothing on chain describes this enum. The layout +/// is part of the runtime API's contract and only ever grows at the end, so an +/// unknown discriminant is reported by number instead of guessed at. +const INVALID_TRANSACTION: &[&str] = &[ + "Call", + "Payment", + "Future", + "Stale", + "BadProof", + "AncientBirthBlock", + "ExhaustsResources", + "Custom", + "BadMandatory", + "MandatoryValidation", + "BadSigner", + "IndeterminateImplicit", + "UnknownOrigin", +]; + +/// Discriminant of `InvalidTransaction::Custom(u8)`, the one variant carrying a +/// payload — and the one a pallet's own extension rejections arrive as. +const INVALID_TRANSACTION_CUSTOM: u8 = 7; + +/// `TransactionValidityError::Unknown(UnknownTransaction)` variants, in +/// declaration order. +const UNKNOWN_TRANSACTION: &[&str] = &["CannotLookup", "NoUnsignedValidator", "Custom"]; + +/// Discriminant of `UnknownTransaction::Custom(u8)`. +const UNKNOWN_TRANSACTION_CUSTOM: u8 = 2; + +/// Watch statuses that mean the extrinsic never reached a block, so rebuilding +/// and resubmitting cannot double-spend. Every other terminal status leaves the +/// outcome unknown. +const NOT_INCLUDED_STATUSES: &[&str] = &["invalid", "dropped"]; + +/// What the chain did with an extrinsic that reached a block. +/// +/// **Optimistic unless [`SubmissionVerdict::finalized`].** A verdict read at a +/// non-finalized block says what the chain currently believes, which a reorg +/// can undo. It may drive UI and move an operation to `InBlock`; it may not +/// retire records, release locks, or write a receipt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubmissionVerdict { + /// Included and dispatched successfully. + Succeeded { + /// Block that included the extrinsic. + block_hash: BlockHash, + /// Whether that block is finalized. + finalized: bool, + }, + /// Included, but the dispatch failed. + /// + /// The call's own effects are reverted; whatever the `AsCoinage` extension + /// consumed to build the origin is not. See the module documentation. + DispatchFailed { + /// Block that included the extrinsic. + block_hash: BlockHash, + /// Whether that block is finalized. + finalized: bool, + /// Rendering of the runtime's dispatch error. + reason: String, + }, +} + +impl SubmissionVerdict { + /// Whether the dispatch succeeded. + pub const fn succeeded(&self) -> bool { + matches!(self, Self::Succeeded { .. }) + } + + /// Block the extrinsic landed in, either way. + pub const fn block_hash(&self) -> BlockHash { + match self { + Self::Succeeded { block_hash, .. } | Self::DispatchFailed { block_hash, .. } => { + *block_hash + } + } + } + + /// Whether this verdict is settled, i.e. read at a finalized block. + /// + /// Only a settled verdict may be written into the operation log. + pub const fn finalized(&self) -> bool { + match self { + Self::Succeeded { finalized, .. } | Self::DispatchFailed { finalized, .. } => { + *finalized + } + } + } +} + +/// The three-valued result of best-effort tracking (`coinage-layer.md` §7.6). +/// +/// Modelled as a value rather than a `Result` because all three arms are +/// ordinary outcomes the caller must handle differently, and because collapsing +/// "unknown" into either of the others is the single most dangerous mistake +/// available to this layer: assuming success retires records the chain still +/// holds, and assuming failure releases records the chain is about to consume. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrackerOutcome { + /// The transaction provably never reached a block, so the caller may + /// rebuild and resubmit against the same records. + NotIncluded { + /// Why nothing was included. + reason: String, + }, + /// The transaction reached a block and its dispatch outcome was read. + Included(SubmissionVerdict), + /// The transaction's fate is not established. Hand it to recovery + /// (§7.7), which resolves it against finalized chain state. + Unknown { + /// What stopped tracking from reaching a verdict. + reason: String, + }, +} + +impl TrackerOutcome { + /// Whether the caller may reuse the transaction's inputs immediately. + pub const fn is_definitively_not_included(&self) -> bool { + matches!(self, Self::NotIncluded { .. }) + } + + /// Whether this outcome settles the transaction with no further work. + /// + /// A non-finalized inclusion does not: it still needs recovery to confirm + /// it at a finalized block. + pub const fn is_definite(&self) -> bool { + match self { + Self::NotIncluded { .. } => true, + Self::Included(verdict) => verdict.finalized(), + Self::Unknown { .. } => false, + } + } +} + +/// Failure to submit a coinage extrinsic, or to learn what became of it. +#[derive(Debug, Error)] +pub enum CoinageSubmitError { + /// The JSON-RPC surface failed while reading state or broadcasting. + #[error("chain rpc: {0}")] + Rpc(#[source] Box), + /// The dry-run rejected the extrinsic; it was never broadcast. + #[error("dry-run rejected the extrinsic: {reason}")] + DryRunRejected { + /// Rendering of the runtime's validity error. + reason: String, + }, + /// The node rejected the broadcast without including anything. + #[error("not included: {reason}")] + NotIncluded { + /// Terminal watch status the node reported. + reason: String, + }, + /// The extrinsic's fate could not be established. The caller must resolve + /// this by observing chain state, never by assuming either outcome. + #[error("inclusion unverified: {reason}")] + Unverified { + /// What stopped the classification from reaching a verdict. + reason: String, + }, + /// Metadata lacked something the classification needs. + #[error("metadata: {0}")] + Metadata(String), +} + +impl CoinageSubmitError { + /// Whether the extrinsic provably never reached a block, so the caller may + /// rebuild and resubmit against the same records. + pub const fn is_definitively_not_included(&self) -> bool { + matches!(self, Self::DryRunRejected { .. } | Self::NotIncluded { .. }) + } +} + +/// Hash of an assembled extrinsic, as the chain identifies it. +pub fn extrinsic_hash(extrinsic: &[u8]) -> ExtrinsicHash { + ExtrinsicHash(blake2_256(extrinsic)) +} + +/// Read the chain state a coinage extrinsic signs over, anchored for mortality. +/// +/// The returned anchor is what the operation log records as its checkpoint; the +/// two must be the same block or the expiry test during recovery is unsound. +pub async fn fetch_mortal_chain_state( + rpc: &RpcClient, +) -> Result<(ChainState, EraAnchor), CoinageSubmitError> { + let mut state = fetch_chain_state(rpc).await.map_err(rpc_failed)?; + let anchor = fetch_era_anchor(rpc, EXTRINSIC_MORTALITY_BLOCKS) + .await + .map_err(rpc_failed)?; + state.mortality = Some(anchor); + Ok((state, anchor)) +} + +/// Dry-run, broadcast, and classify one assembled extrinsic. +/// +/// Total by construction: every failure mode maps onto one of the three +/// [`TrackerOutcome`] arms rather than escaping as an error, so a caller cannot +/// accidentally treat "we do not know" as "it failed" by handling a `Result` +/// carelessly. A transport failure *before* the broadcast is `NotIncluded`, +/// because nothing was sent; one after it is `Unknown`. +pub async fn submit(rpc: &RpcClient, metadata: &Metadata, extrinsic: &[u8]) -> TrackerOutcome { + if let Err(error) = dry_run(rpc, extrinsic).await { + return TrackerOutcome::NotIncluded { + reason: error.to_string(), + }; + } + + let inclusion = match rpc.submit_and_watch_inclusion(extrinsic).await { + Ok(inclusion) => inclusion, + Err(error) => return classify_watch_failure(error), + }; + + match verdict_at( + rpc, + metadata, + &inclusion.block_hash, + inclusion.finalized, + extrinsic, + ) + .await + { + Ok(verdict) => TrackerOutcome::Included(verdict), + Err(error) => TrackerOutcome::Unknown { + reason: error.to_string(), + }, + } +} + +/// Ask the runtime whether it would accept the extrinsic, without broadcasting. +/// +/// Validated against the finalized head rather than the best block: a coinage +/// proof binds the chain, not a fork, and a rejection seen at a block that is +/// later reorganized away would be an invented failure. +pub async fn dry_run(rpc: &RpcClient, extrinsic: &[u8]) -> Result<(), CoinageSubmitError> { + let at = rpc.finalized_head().await.map_err(rpc_failed)?; + let at_bytes = decode_hash(&at)?; + + let mut payload = Vec::with_capacity(1 + extrinsic.len() + at_bytes.0.len()); + payload.push(TRANSACTION_SOURCE_EXTERNAL); + payload.extend_from_slice(extrinsic); + payload.extend_from_slice(&at_bytes.0); + + let result = rpc + .call( + "state_call", + json!([ + VALIDATE_TRANSACTION, + format!("0x{}", hex::encode(&payload)), + at + ]), + ) + .await + .map_err(rpc_failed)?; + let encoded = result + .as_str() + .ok_or_else(|| CoinageSubmitError::Unverified { + reason: "state_call returned a non-string result".to_string(), + }) + .and_then(|hex_str| decode_hex(hex_str, "state_call result"))?; + + decode_validity(&encoded) +} + +/// Classify what an already-included extrinsic did, from its inclusion block. +pub async fn verdict_at( + rpc: &RpcClient, + metadata: &Metadata, + block_hash: &str, + finalized: bool, + extrinsic: &[u8], +) -> Result { + let block = rpc + .call("chain_getBlock", json!([block_hash])) + .await + .map_err(rpc_failed)?; + let index = + extrinsic_index(&block, extrinsic).ok_or_else(|| CoinageSubmitError::Unverified { + reason: format!("{block_hash} does not contain the submitted extrinsic"), + })?; + + let raw = rpc + .get_storage_at(&system_events_key(), block_hash) + .await + .map_err(rpc_failed)? + .ok_or_else(|| CoinageSubmitError::Unverified { + reason: format!("{block_hash} reports no System.Events"), + })?; + let type_id = metadata + .storage_value_type("System", "Events") + .ok_or_else(|| CoinageSubmitError::Metadata("System.Events is absent".to_string()))?; + let events = decode_as_type(&mut &raw[..], type_id, metadata.registry()).map_err(|error| { + CoinageSubmitError::Unverified { + reason: format!("decoding System.Events at {block_hash} failed: {error}"), + } + })?; + + classify_events(&events, index, decode_hash(block_hash)?, finalized) +} + +/// `System::Events`, an unhashed plain entry. +fn system_events_key() -> Vec { + [ + twox_128(b"System").as_slice(), + twox_128(b"Events").as_slice(), + ] + .concat() +} + +/// Position of `extrinsic` among a `chain_getBlock` response's extrinsics. +/// +/// Matched on the full encoded bytes, length prefix included, which is exactly +/// what both the node and [`super::extrinsic`] produce. +fn extrinsic_index(block: &serde_json::Value, extrinsic: &[u8]) -> Option { + let wanted = format!("0x{}", hex::encode(extrinsic)); + block + .get("block")? + .get("extrinsics")? + .as_array()? + .iter() + .position(|candidate| candidate.as_str() == Some(wanted.as_str())) + .and_then(|position| u32::try_from(position).ok()) +} + +/// Read the dispatch outcome out of a block's decoded events. +/// +/// Fail-closed in both directions: failure wins over success, and a block whose +/// events name neither outcome for our extrinsic leaves the result unverified +/// rather than assuming the friendlier one. +fn classify_events( + events: &Value, + index: u32, + block_hash: BlockHash, + finalized: bool, +) -> Result { + let records = match &events.value { + ValueDef::Composite(composite) => composite, + other => { + return Err(CoinageSubmitError::Unverified { + reason: format!("System.Events decoded as {other:?}, expected a sequence"), + }); + } + }; + + let ours = records + .values() + .filter(|record| record_phase_index(record) == Some(index)) + .filter_map(record_event); + + let mut succeeded = false; + for event in ours { + let Some(inner) = pallet_event(event, "System") else { + continue; + }; + match inner.name.as_str() { + "ExtrinsicFailed" => { + return Ok(SubmissionVerdict::DispatchFailed { + block_hash, + finalized, + reason: describe_dispatch_error(inner), + }); + } + "ExtrinsicSuccess" => succeeded = true, + _ => {} + } + } + + if succeeded { + Ok(SubmissionVerdict::Succeeded { + block_hash, + finalized, + }) + } else { + Err(CoinageSubmitError::Unverified { + reason: format!("no dispatch outcome for extrinsic {index} in the inclusion block"), + }) + } +} + +/// The extrinsic index an event record is attributed to, if any. +fn record_phase_index(record: &Value) -> Option { + let phase = as_variant(field(record, "phase", 0)?)?; + (phase.name == "ApplyExtrinsic") + .then(|| phase.values.values().next()) + .flatten()? + .as_u128() + .and_then(|index| u32::try_from(index).ok()) +} + +/// The runtime event carried by an event record. +fn record_event(record: &Value) -> Option<&Variant> { + as_variant(field(record, "event", 1)?) +} + +/// The pallet-scoped event inside a runtime event, when it belongs to `pallet`. +fn pallet_event<'a>(event: &'a Variant, pallet: &str) -> Option<&'a Variant> { + (event.name == pallet) + .then(|| event.values.values().next()) + .flatten() + .and_then(as_variant) +} + +/// Render a `System.ExtrinsicFailed` payload's dispatch error. +/// +/// Rendered rather than resolved: naming the module error would need each +/// pallet's error type, which the metadata this layer carries does not collect. +/// A structural rendering is honest about that; a fabricated name would not be. +fn describe_dispatch_error(failed: &Variant) -> String { + match failed.values.values().next() { + Some(error) => error.to_string(), + None => "unspecified".to_string(), + } +} + +/// A composite's field by name, falling back to its position. +fn field<'a>(value: &'a Value, name: &str, position: usize) -> Option<&'a Value> { + match &value.value { + ValueDef::Composite(Composite::Named(fields)) => fields + .iter() + .find_map(|(key, value)| (key == name).then_some(value)), + ValueDef::Composite(Composite::Unnamed(values)) => values.get(position), + _ => None, + } +} + +/// A value as an enum variant. +fn as_variant(value: &Value) -> Option<&Variant> { + match &value.value { + ValueDef::Variant(variant) => Some(variant), + _ => None, + } +} + +/// Split a `Result` into accepted +/// or rejected, naming the rejection. +fn decode_validity(encoded: &[u8]) -> Result<(), CoinageSubmitError> { + match encoded.split_first() { + Some((0, _)) => Ok(()), + Some((1, rest)) => Err(CoinageSubmitError::DryRunRejected { + reason: describe_validity_error(rest), + }), + Some((other, _)) => Err(CoinageSubmitError::Unverified { + reason: format!("validate_transaction returned an unknown Result tag {other}"), + }), + None => Err(CoinageSubmitError::Unverified { + reason: "validate_transaction returned nothing".to_string(), + }), + } +} + +/// Name a `TransactionValidityError`. +fn describe_validity_error(encoded: &[u8]) -> String { + match encoded.split_first() { + Some((0, rest)) => format!( + "Invalid::{}", + describe_variant(INVALID_TRANSACTION, INVALID_TRANSACTION_CUSTOM, rest) + ), + Some((1, rest)) => format!( + "Unknown::{}", + describe_variant(UNKNOWN_TRANSACTION, UNKNOWN_TRANSACTION_CUSTOM, rest) + ), + Some((other, _)) => format!("unknown TransactionValidityError variant {other}"), + None => "truncated TransactionValidityError".to_string(), + } +} + +/// Name one variant of a pinned validity enum, unfolding the `Custom(u8)` code +/// a runtime uses to report its own rejections. +fn describe_variant(names: &[&str], custom: u8, encoded: &[u8]) -> String { + let Some((&discriminant, rest)) = encoded.split_first() else { + return "truncated".to_string(); + }; + let Some(name) = names.get(discriminant as usize) else { + return format!("variant {discriminant}"); + }; + match (discriminant == custom, rest.first()) { + (true, Some(code)) => format!("{name}({code})"), + (true, None) => format!("{name}(truncated)"), + (false, _) => (*name).to_string(), + } +} + +/// Decode a `0x`-prefixed 32-byte hash. +fn decode_hash(value: &str) -> Result { + let bytes = decode_hex(value, "block hash")?; + let length = bytes.len(); + bytes + .try_into() + .map(BlockHash) + .map_err(|_| CoinageSubmitError::Unverified { + reason: format!("block hash is {length} bytes, expected 32"), + }) +} + +/// Decode `0x`-prefixed hex, naming what was being decoded. +fn decode_hex(value: &str, what: &str) -> Result, CoinageSubmitError> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|error| { + CoinageSubmitError::Unverified { + reason: format!("{what} is not hex: {error}"), + } + }) +} + +/// Wrap a JSON-RPC failure. +fn rpc_failed(error: StatementAllowanceError) -> CoinageSubmitError { + CoinageSubmitError::Rpc(Box::new(error)) +} + +/// Separate a broadcast the node definitively refused from one whose fate the +/// watch simply stopped reporting. +/// +/// Only `invalid` and `dropped` mean nothing was included. `retracted`, +/// `usurped` and `finalityTimeout` all describe a transaction that reached a +/// block or a pool and may yet land, so they are unknown rather than refused — +/// treating them as refused would release inputs the chain could still consume. +fn classify_watch_failure(error: StatementAllowanceError) -> TrackerOutcome { + match &error { + StatementAllowanceError::Rpc(RpcError::ExtrinsicRejected { status }) + if NOT_INCLUDED_STATUSES.contains(&status.as_str()) => + { + TrackerOutcome::NotIncluded { + reason: status.clone(), + } + } + _ => TrackerOutcome::Unknown { + reason: error.to_string(), + }, + } +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::{Compact, Encode}; + use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + use subxt::ext::scale_encode::{EncodeAsFields, Field}; + use subxt::ext::scale_value::{Primitive, Value as ScaleValue}; + use subxt::metadata::ArcMetadata; + use subxt_rpcs::RpcClient as HostRpcClient; + + use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn metadata() -> Metadata { + Metadata::decode(FIXTURE).expect("the fixture decodes") + } + + /// The same fixture through Subxt, which exposes the event variants the + /// thin metadata does not, so tests can synthesize a block's events. + fn subxt_metadata() -> ArcMetadata { + ArcMetadata::from( + subxt::Metadata::decode_from(FIXTURE).expect("the fixture decodes for subxt"), + ) + } + + /// One `System` event attributed to extrinsic `index`, encoded exactly as + /// the runtime stores `System.Events`. + fn system_events(event_name: &str, index: u32) -> Vec { + let metadata = subxt_metadata(); + let system = metadata.pallet_by_name("System").expect("System exists"); + let event = system + .event_variants() + .expect("System has events") + .iter() + .find(|event| event.name == event_name) + .expect("the event exists"); + let values = ScaleValue::unnamed_composite( + event + .fields + .iter() + .map(|field| default_value(metadata.types(), field.ty.id)), + ); + let mut fields = event + .fields + .iter() + .map(|field| Field::new(field.ty.id, field.name.as_deref())); + + let mut bytes = Vec::new(); + Compact(1u32).encode_to(&mut bytes); + // Phase::ApplyExtrinsic(index). + 0u8.encode_to(&mut bytes); + index.encode_to(&mut bytes); + system.event_index().encode_to(&mut bytes); + event.index.encode_to(&mut bytes); + values + .encode_as_fields_to(&mut fields, metadata.types(), &mut bytes) + .expect("the event payload encodes"); + Vec::<[u8; 32]>::new().encode_to(&mut bytes); + bytes + } + + fn decoded_events(raw: &[u8], metadata: &Metadata) -> Value { + let type_id = metadata + .storage_value_type("System", "Events") + .expect("System.Events is in metadata"); + decode_as_type(&mut &raw[..], type_id, metadata.registry()).expect("events decode") + } + + fn default_value(types: &PortableRegistry, type_id: u32) -> ScaleValue { + let ty = types.resolve(type_id).expect("metadata type exists"); + match &ty.type_def { + TypeDef::Composite(composite) => ScaleValue::unnamed_composite( + composite + .fields + .iter() + .map(|field| default_value(types, field.ty.id)), + ), + TypeDef::Variant(variants) => { + let variant = variants.variants.first().expect("variant exists"); + ScaleValue::unnamed_variant( + variant.name.clone(), + variant + .fields + .iter() + .map(|field| default_value(types, field.ty.id)), + ) + } + TypeDef::Sequence(_) => ScaleValue::unnamed_composite([]), + TypeDef::Array(array) => ScaleValue::unnamed_composite( + (0..array.len).map(|_| default_value(types, array.type_param.id)), + ), + TypeDef::Tuple(tuple) => ScaleValue::unnamed_composite( + tuple + .fields + .iter() + .map(|field| default_value(types, field.id)), + ), + TypeDef::Primitive(primitive) => match primitive { + TypeDefPrimitive::Bool => ScaleValue::bool(false), + TypeDefPrimitive::Char => ScaleValue::char('\0'), + TypeDefPrimitive::Str => ScaleValue::string(""), + TypeDefPrimitive::U8 + | TypeDefPrimitive::U16 + | TypeDefPrimitive::U32 + | TypeDefPrimitive::U64 + | TypeDefPrimitive::U128 => ScaleValue::u128(0), + TypeDefPrimitive::U256 => ScaleValue::primitive(Primitive::U256([0; 32])), + TypeDefPrimitive::I8 + | TypeDefPrimitive::I16 + | TypeDefPrimitive::I32 + | TypeDefPrimitive::I64 + | TypeDefPrimitive::I128 => ScaleValue::i128(0), + TypeDefPrimitive::I256 => ScaleValue::primitive(Primitive::I256([0; 32])), + }, + TypeDef::Compact(_) => ScaleValue::u128(0), + TypeDef::BitSequence(_) => { + ScaleValue::bit_sequence(subxt::ext::scale_bits::Bits::new()) + } + } + } + + const BLOCK: BlockHash = BlockHash([7; 32]); + + #[test] + fn a_success_event_for_our_index_is_a_success() { + let metadata = metadata(); + let events = decoded_events(&system_events("ExtrinsicSuccess", 3), &metadata); + + assert_eq!( + classify_events(&events, 3, BLOCK, true).expect("classifies"), + SubmissionVerdict::Succeeded { + block_hash: BLOCK, + finalized: true + } + ); + } + + #[test] + fn a_failure_event_for_our_index_is_a_failed_dispatch() { + let metadata = metadata(); + let events = decoded_events(&system_events("ExtrinsicFailed", 0), &metadata); + + let verdict = classify_events(&events, 0, BLOCK, false).expect("classifies"); + assert!(!verdict.succeeded()); + assert_eq!(verdict.block_hash(), BLOCK); + let SubmissionVerdict::DispatchFailed { reason, .. } = verdict else { + unreachable!("the verdict is a failed dispatch"); + }; + assert!(!reason.is_empty(), "the dispatch error is rendered"); + } + + #[test] + fn another_extrinsics_outcome_is_not_ours() { + // The dangerous confusion: a block usually carries several extrinsics, + // and the inherents at the front of it always succeed. + let metadata = metadata(); + let events = decoded_events(&system_events("ExtrinsicSuccess", 0), &metadata); + + let error = classify_events(&events, 1, BLOCK, true).expect_err("no outcome for index 1"); + assert!(matches!(error, CoinageSubmitError::Unverified { .. })); + assert!(!error.is_definitively_not_included()); + } + + #[test] + fn a_block_without_our_outcome_stays_unverified() { + let metadata = metadata(); + let events = decoded_events(&[0u8], &metadata); + + assert!(matches!( + classify_events(&events, 0, BLOCK, true), + Err(CoinageSubmitError::Unverified { .. }) + )); + } + + #[test] + fn the_extrinsic_is_found_by_its_exact_bytes() { + let block = json!({ + "block": { + "extrinsics": ["0xaabb", "0xccdd", "0xeeff"], + } + }); + + assert_eq!(extrinsic_index(&block, &[0xcc, 0xdd]), Some(1)); + assert_eq!(extrinsic_index(&block, &[0xcc]), None); + assert_eq!(extrinsic_index(&block, &[0x11, 0x22]), None); + } + + #[test] + fn a_valid_dry_run_is_accepted() { + // `Ok(ValidTransaction { .. })`; the payload is not inspected. + let encoded = [0u8, 1, 2, 3]; + + assert!(decode_validity(&encoded).is_ok()); + } + + fn rejection_reason(encoded: &[u8]) -> String { + let error = decode_validity(encoded).expect_err("rejected"); + assert!( + error.is_definitively_not_included(), + "a dry-run rejection means nothing was broadcast" + ); + let CoinageSubmitError::DryRunRejected { reason } = error else { + unreachable!("the dry-run rejected it"); + }; + reason + } + + #[test] + fn an_invalid_dry_run_is_named_and_never_broadcast() { + // `Err(Invalid(Payment))`. + assert_eq!(rejection_reason(&[1, 0, 1]), "Invalid::Payment"); + // `Err(Unknown(NoUnsignedValidator))` — what an unsigned coinage + // extrinsic gets when the extension declines to authorize it. + assert_eq!(rejection_reason(&[1, 1, 1]), "Unknown::NoUnsignedValidator"); + } + + #[test] + fn a_custom_rejection_keeps_the_runtime_s_code() { + // The pallet's own extension rejections arrive this way, and the code + // is the only thing distinguishing them. + assert_eq!(rejection_reason(&[1, 0, 7, 42]), "Invalid::Custom(42)"); + assert_eq!(rejection_reason(&[1, 1, 2, 9]), "Unknown::Custom(9)"); + } + + #[test] + fn an_unknown_discriminant_is_reported_by_number_not_guessed() { + // The enums only ever grow at the end, so a newer runtime must not be + // mis-named as the last variant this build happens to know. + assert_eq!(rejection_reason(&[1, 0, 200]), "Invalid::variant 200"); + } + + #[test] + fn a_truncated_dry_run_result_is_unverified() { + assert!(matches!( + decode_validity(&[]), + Err(CoinageSubmitError::Unverified { .. }) + )); + } + + #[test] + fn only_a_refused_broadcast_clears_the_records_for_reuse() { + let refused = classify_watch_failure( + RpcError::ExtrinsicRejected { + status: "invalid".to_string(), + } + .into(), + ); + assert!(refused.is_definitively_not_included()); + + // A transaction that reached a block and was then retracted may still + // land; treating it as refused would resubmit records the chain could + // yet consume. + let retracted = classify_watch_failure( + RpcError::ExtrinsicRejected { + status: "retracted".to_string(), + } + .into(), + ); + assert!(!retracted.is_definitively_not_included()); + assert!(matches!(retracted, TrackerOutcome::Unknown { .. })); + assert!(!retracted.is_definite(), "recovery must resolve it"); + + let timeout = classify_watch_failure(RpcError::SubmitTimeout.into()); + assert!(!timeout.is_definitively_not_included()); + assert!(matches!(timeout, TrackerOutcome::Unknown { .. })); + } + + #[test] + fn the_extrinsic_hash_is_blake2_256_of_the_encoded_bytes() { + let extrinsic = [0x45u8, 0x00, 0xff]; + + assert_eq!(extrinsic_hash(&extrinsic).0, blake2_256(&extrinsic)); + } + + /// The 32-byte hash matching [`BLOCK`], as the node reports it. + const BLOCK_HEX: &str = "0x0707070707070707070707070707070707070707070707070707070707070707"; + + /// Bytes standing in for an assembled coinage extrinsic. + const EXTRINSIC: &[u8] = &[0x45, 0x00, 0xff]; + + fn scripted(responses: &[String]) -> (ScriptedRpc, RpcClient) { + let scripted = ScriptedRpc::new(responses.iter().map(String::as_str)); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + (scripted, rpc) + } + + fn quoted_hex(bytes: &[u8]) -> String { + format!("\"0x{}\"", hex::encode(bytes)) + } + + #[test] + fn a_submission_runs_dry_run_then_watch_then_events() { + let events = system_events("ExtrinsicSuccess", 1); + let (scripted, rpc) = scripted(&[ + format!("\"{BLOCK_HEX}\""), + // `Ok(ValidTransaction { .. })`, payload elided. + quoted_hex(&[0]), + format!( + r#"{{"block":{{"extrinsics":["0xdeadbeef","0x{}"]}}}}"#, + hex::encode(EXTRINSIC) + ), + quoted_hex(&events), + ]); + scripted.script_subscription([format!(r#"{{"inBlock":"{BLOCK_HEX}"}}"#).as_str()]); + + let outcome = futures::executor::block_on(submit(&rpc, &metadata(), EXTRINSIC)); + + // The node reported `inBlock`, not `finalized`, so this is optimistic: + // it says what happened without settling it. + assert_eq!( + outcome, + TrackerOutcome::Included(SubmissionVerdict::Succeeded { + block_hash: BLOCK, + finalized: false, + }) + ); + assert!( + !outcome.is_definite(), + "an in-block inclusion is not settled" + ); + let methods: Vec<_> = scripted + .calls() + .into_iter() + .map(|(method, _)| method) + .collect(); + assert_eq!( + methods, + vec![ + "chain_getFinalizedHead", + "state_call", + "author_submitAndWatchExtrinsic", + "chain_getBlock", + "state_getStorage", + ] + ); + } + + #[test] + fn a_rejected_dry_run_stops_before_broadcasting() { + // The whole point of the dry-run: an extrinsic whose proofs the + // extension refuses must never reach the network. + let (scripted, rpc) = scripted(&[ + format!("\"{BLOCK_HEX}\""), + // `Err(Invalid(Custom(3)))`. + quoted_hex(&[1, 0, 7, 3]), + ]); + + let outcome = futures::executor::block_on(submit(&rpc, &metadata(), EXTRINSIC)); + + assert!(outcome.is_definitively_not_included()); + assert!( + outcome.is_definite(), + "nothing was sent, so nothing is open" + ); + let TrackerOutcome::NotIncluded { reason } = &outcome else { + unreachable!("the dry-run rejected it"); + }; + assert_eq!(reason, "dry-run rejected the extrinsic: Invalid::Custom(3)"); + assert!( + !scripted + .calls() + .iter() + .any(|(method, _)| method.contains("submit")), + "nothing was broadcast" + ); + } + + #[test] + fn an_inclusion_block_without_our_extrinsic_is_unverified() { + let (scripted, rpc) = scripted(&[ + format!("\"{BLOCK_HEX}\""), + quoted_hex(&[0]), + r#"{"block":{"extrinsics":["0xdeadbeef"]}}"#.to_string(), + ]); + scripted.script_subscription([format!(r#"{{"inBlock":"{BLOCK_HEX}"}}"#).as_str()]); + + let outcome = futures::executor::block_on(submit(&rpc, &metadata(), EXTRINSIC)); + + assert!(matches!(outcome, TrackerOutcome::Unknown { .. })); + assert!(!outcome.is_definitively_not_included()); + assert!(!outcome.is_definite()); + } + + #[test] + fn a_finalized_inclusion_settles_the_transaction_immediately() { + // When the node reports `finalized` straight away there is nothing for + // recovery to do, and the outcome may be written to the log as it is. + let events = system_events("ExtrinsicSuccess", 1); + let (scripted, rpc) = scripted(&[ + format!("\"{BLOCK_HEX}\""), + quoted_hex(&[0]), + format!( + r#"{{"block":{{"extrinsics":["0xdeadbeef","0x{}"]}}}}"#, + hex::encode(EXTRINSIC) + ), + quoted_hex(&events), + ]); + scripted.script_subscription([format!(r#"{{"finalized":"{BLOCK_HEX}"}}"#).as_str()]); + + let outcome = futures::executor::block_on(submit(&rpc, &metadata(), EXTRINSIC)); + + assert_eq!( + outcome, + TrackerOutcome::Included(SubmissionVerdict::Succeeded { + block_hash: BLOCK, + finalized: true, + }) + ); + assert!(outcome.is_definite()); + } + + #[test] + fn the_system_events_key_is_stable() { + // Golden: a silently changed key returns no events, which would read as + // "the block had no outcome" rather than as an error. + assert_eq!( + hex::encode(system_events_key()), + "26aa394eea5630e07c48ae0c9558cef780d41e5e16056765bc8461851072c9d7" + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/subscription.rs b/rust/crates/truapi-server/src/runtime/coinage/subscription.rs new file mode 100644 index 000000000..fdd11a3d7 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/subscription.rs @@ -0,0 +1,836 @@ +//! The layer's three subscription surfaces. +//! +//! `coinage-layer.md` §8.9 and §7.2. Three streams, one fan-out point: +//! +//! - **Events** — every [`LayerEvent`] the store produced, in order. An event is +//! a change rather than a value, so this stream has nothing to emit at +//! subscribe time and starts live. +//! - **Purse balance** — the three-value balance, current value first and a new +//! item on every change. +//! - **Operation status** — the state machine of §5.5, current status first, +//! terminal item exactly once, then the stream closes. +//! +//! # Why balances are recomputed rather than published +//! +//! A balance is a projection of the whole purse, and several of its inputs are +//! time-dependent: an entry inside its jitter delay, a coin the chain locked +//! after a failed dispatch. Events cannot carry a balance because the value +//! moves without any event — the clock alone changes it. So this hub holds the +//! last value it emitted per subscriber and recomputes against the store, +//! emitting only on a real change. [`CoinageSubscriptions::publish`] does that +//! after a mutation; [`CoinageSubscriptions::refresh`] does it on a clock tick, +//! where there are no events at all. +//! +//! # Why operation status comes from the events +//! +//! A terminal operation's record is dropped as soon as its status is emitted +//! (§7.8), so by the time a status change is published the store may no longer +//! hold the operation. `OperationCompleted` carries the terminal status and its +//! receipt, which makes the event the only complete source. Progress is taken +//! from the events too, so a subscriber sees every intermediate status rather +//! than only whichever one the store happened to settle on. +//! +//! Subscribing reads the store directly, which assumes the store has no +//! undrained events — the invariant every mutating path already maintains by +//! calling [`crate::runtime::coinage::persistence::publish_and_persist`] before +//! yielding to a caller. + +use std::sync::{Arc, Mutex}; + +use futures::channel::mpsc; +use futures::stream::{self, BoxStream, StreamExt}; + +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::event::LayerEvent; +use crate::host_logic::coinage::operation::OperationStatus; +use crate::host_logic::coinage::purse::PurseBalance; +use crate::host_logic::coinage::store::CoinageStore; +use crate::host_logic::coinage::types::{OperationHandle, PurseId, Timestamp}; + +/// Message for a poisoned subscription mutex. The hub holds no invariant a +/// panicking subscriber could break, but a poisoned lock is still a bug worth +/// naming. +const POISONED: &str = "coinage subscription mutex poisoned"; + +/// One balance subscriber and the value it last saw. +struct BalanceSubscriber { + purse: PurseId, + last: PurseBalance, + sender: mpsc::UnboundedSender, +} + +/// One operation-status subscriber and the status it last saw. +struct StatusSubscriber { + handle: OperationHandle, + last: OperationStatus, + sender: mpsc::UnboundedSender, +} + +/// Fan-out for the layer's subscriptions. +/// +/// Shared behind an [`Arc`]: the driver publishes into it while callers hold +/// streams out of it. Dropping a stream is always safe — the sender is pruned at +/// the next publish and nothing about the operation changes. +#[derive(Default)] +pub struct CoinageSubscriptions { + events: Mutex>>, + balances: Mutex>, + statuses: Mutex>, +} + +impl CoinageSubscriptions { + /// Create a hub with no subscribers. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Subscribe to every event the layer publishes from now on. + pub fn subscribe_events(&self) -> BoxStream<'static, LayerEvent> { + let (sender, receiver) = mpsc::unbounded(); + self.events.lock().expect(POISONED).push(sender); + Box::pin(receiver) + } + + /// Subscribe to a purse's balance, current value first. + pub fn subscribe_purse_balance( + &self, + store: &CoinageStore, + purse: PurseId, + now: Timestamp, + ) -> Result, CoinageError> { + let current = store.balance(purse, now)?; + let (sender, receiver) = mpsc::unbounded(); + self.balances + .lock() + .expect(POISONED) + .push(BalanceSubscriber { + purse, + last: current, + sender, + }); + + Ok(Box::pin( + stream::once(async move { current }).chain(receiver), + )) + } + + /// Subscribe to an operation's status, current status first and the terminal + /// status last. + /// + /// Fails with `OperationNotFound` for a handle the store does not hold, + /// which includes an operation that has already terminated: its record is + /// gone and its terminal status was published to whoever was subscribed at + /// the time. + pub fn subscribe_operation_status( + &self, + store: &CoinageStore, + handle: OperationHandle, + ) -> Result, CoinageError> { + let current = store + .operation(handle) + .ok_or(CoinageError::OperationNotFound(handle))? + .status + .clone(); + let (sender, receiver) = mpsc::unbounded(); + + // A terminal status registers no subscriber: dropping the sender closes + // the stream right after the item the caller is owed. + if !current.is_terminal() { + self.statuses + .lock() + .expect(POISONED) + .push(StatusSubscriber { + handle, + last: current.clone(), + sender, + }); + } + + Ok(Box::pin( + stream::once(async move { current }).chain(receiver), + )) + } + + /// Deliver a batch of drained events and reproject the derived streams. + /// + /// Called with the store as it stands after the mutation that produced + /// `events` and before it is persisted, so a subscriber learns of a terminal + /// operation no later than the durable store does (§7.9). + pub fn publish(&self, events: &[LayerEvent], store: &CoinageStore, now: Timestamp) { + self.publish_events(events); + self.advance_statuses(events); + self.refresh_balances(store, now); + } + + /// Reproject balances with no events to deliver. + /// + /// For the driver's clock tick: an entry leaving its jitter delay or a chain + /// lock expiring changes a balance without changing a record. + pub fn refresh(&self, store: &CoinageStore, now: Timestamp) { + self.refresh_balances(store, now); + } + + /// How many subscribers the hub currently holds, as + /// `(events, balances, statuses)`. For diagnostics and tests. + pub fn subscriber_counts(&self) -> (usize, usize, usize) { + ( + self.events.lock().expect(POISONED).len(), + self.balances.lock().expect(POISONED).len(), + self.statuses.lock().expect(POISONED).len(), + ) + } + + fn publish_events(&self, events: &[LayerEvent]) { + let mut subscribers = self.events.lock().expect(POISONED); + subscribers.retain(|sender| { + events + .iter() + .all(|event| sender.unbounded_send(event.clone()).is_ok()) + }); + } + + fn advance_statuses(&self, events: &[LayerEvent]) { + let mut subscribers = self.statuses.lock().expect(POISONED); + subscribers.retain_mut(|subscriber| { + for event in events { + match event { + LayerEvent::OperationProgress { handle, status } + if *handle == subscriber.handle => + { + // A status equal to the last one emitted carries no + // information, and is what a subscription taken between + // the mutation and this publish would otherwise see + // twice. + if *status == subscriber.last { + continue; + } + subscriber.last = status.clone(); + if subscriber.sender.unbounded_send(status.clone()).is_err() { + return false; + } + } + LayerEvent::OperationCompleted { handle, terminal } + if *handle == subscriber.handle => + { + // The terminal item is the last one the stream carries, + // so the subscriber is dropped whether or not the send + // lands. + let _ = subscriber + .sender + .unbounded_send(OperationStatus::from(terminal.clone())); + return false; + } + _ => {} + } + } + + !subscriber.sender.is_closed() + }); + } + + fn refresh_balances(&self, store: &CoinageStore, now: Timestamp) { + let mut subscribers = self.balances.lock().expect(POISONED); + subscribers.retain_mut(|subscriber| { + // A purse that no longer exists was drained and closed; its balance + // can never change again, so the stream closes. + let Ok(balance) = store.balance(subscriber.purse, now) else { + return false; + }; + + if balance == subscriber.last { + return !subscriber.sender.is_closed(); + } + + subscriber.last = balance; + subscriber.sender.unbounded_send(balance).is_ok() + }); + } +} + +#[cfg(test)] +mod tests { + use core::time::Duration; + + use futures::executor::block_on; + use futures::{FutureExt, StreamExt}; + + use crate::host_logic::coinage::chain_constants::next_people_paseo; + use crate::host_logic::coinage::operation::{OperationReceipt, TerminalStatus}; + use crate::host_logic::coinage::params::CoinageParameters; + use crate::host_logic::coinage::selection::{OutputRequirement, SelectionRequest}; + use crate::host_logic::coinage::types::{ + Amount, CoinAge, CoinIndex, DenominationExponent, EntryIndex, OperationKind, RevisionIndex, + RingIndex, RingLocation, + }; + + use super::*; + + const NOW: Timestamp = Timestamp(1_000_000); + + fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") + } + + fn store() -> CoinageStore { + CoinageStore::new("Main".to_string()) + } + + /// Add a coin the chain already reports as populated. + fn fund(store: &mut CoinageStore, purse: PurseId, exponent_value: i8) -> CoinIndex { + let index = store + .add_pending_coin(purse, exponent(exponent_value)) + .expect("purse exists"); + store + .observe_coin(purse, index, CoinAge(0)) + .expect("coin exists"); + index + } + + /// Allocate an entry in a full-anonymity ring, still inside its jitter + /// delay. + fn entry_awaiting_jitter( + store: &mut CoinageStore, + purse: PurseId, + jitter: Duration, + ) -> EntryIndex { + let index = store + .allocate_entry(purse, exponent(4), NOW, jitter) + .expect("purse exists"); + store + .observe_entry_ring( + purse, + index, + RingLocation::new(RingIndex(0), RevisionIndex(0)), + 64, + &CoinageParameters::default(), + ) + .expect("entry exists"); + index + } + + /// Start an operation holding one coin. + fn begin(store: &mut CoinageStore, purse: PurseId, cents: u64) -> OperationHandle { + let (handle, _plan) = store + .begin_operation( + purse, + OperationKind::Transfer, + &SelectionRequest { + amount: Amount::from_cents(cents), + outputs: OutputRequirement::AnyDenominations, + allow_degraded: true, + }, + &next_people_paseo(), + NOW, + ) + .expect("selection succeeds"); + handle + } + + /// Drain the store's events into the hub, the way the persistence path does. + fn publish(hub: &CoinageSubscriptions, store: &mut CoinageStore, now: Timestamp) { + let events = store.take_events(); + hub.publish(&events, store, now); + } + + // -- events ------------------------------------------------------------ + + #[test] + fn the_event_stream_carries_published_events_in_order() { + let mut store = store(); + let hub = CoinageSubscriptions::new(); + let mut events = hub.subscribe_events(); + + let savings = store.create_purse("Savings".to_string()); + store + .rename_purse(savings, "Rent".to_string()) + .expect("purse exists"); + publish(&hub, &mut store, NOW); + + assert_eq!( + block_on(events.next()), + Some(LayerEvent::PurseCreated { + purse: savings, + name: "Savings".to_string(), + }) + ); + assert_eq!( + block_on(events.next()), + Some(LayerEvent::PurseRenamed { + purse: savings, + name: "Rent".to_string(), + }) + ); + } + + #[test] + fn the_event_stream_starts_live_with_no_backlog() { + let mut store = store(); + let hub = CoinageSubscriptions::new(); + store.create_purse("Savings".to_string()); + publish(&hub, &mut store, NOW); + + // Subscribing after the fact does not replay: an event is a change, not + // a value with a current reading. + let mut events = hub.subscribe_events(); + assert!(events.next().now_or_never().is_none()); + } + + #[test] + fn event_subscriptions_are_independent() { + let mut store = store(); + let hub = CoinageSubscriptions::new(); + let mut first = hub.subscribe_events(); + let mut second = hub.subscribe_events(); + + store.create_purse("Savings".to_string()); + publish(&hub, &mut store, NOW); + + assert!(block_on(first.next()).is_some()); + assert!(block_on(second.next()).is_some()); + } + + #[test] + fn a_dropped_event_subscriber_is_pruned() { + let mut store = store(); + let hub = CoinageSubscriptions::new(); + drop(hub.subscribe_events()); + + store.create_purse("Savings".to_string()); + publish(&hub, &mut store, NOW); + + assert_eq!(hub.subscriber_counts().0, 0); + } + + // -- balance ----------------------------------------------------------- + + #[test] + fn a_balance_subscription_opens_with_the_current_value() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let hub = CoinageSubscriptions::new(); + + let mut balances = hub + .subscribe_purse_balance(&store, PurseId::MAIN, NOW) + .expect("purse exists"); + + let first = block_on(balances.next()).expect("an item at subscribe time"); + assert_eq!(first.spendable, Amount::from_cents(16)); + assert_eq!(first.pending, Amount::ZERO); + } + + #[test] + fn a_balance_subscription_for_an_unknown_purse_is_refused() { + let store = store(); + let hub = CoinageSubscriptions::new(); + + let refused = hub.subscribe_purse_balance(&store, PurseId(7), NOW); + + assert_eq!(refused.err(), Some(CoinageError::PurseNotFound(PurseId(7)))); + } + + #[test] + fn a_balance_item_arrives_on_a_change_and_only_on_a_change() { + let mut store = store(); + let hub = CoinageSubscriptions::new(); + let mut balances = hub + .subscribe_purse_balance(&store, PurseId::MAIN, NOW) + .expect("purse exists"); + let _ = block_on(balances.next()); + + fund(&mut store, PurseId::MAIN, 4); + publish(&hub, &mut store, NOW); + + assert_eq!( + block_on(balances.next()) + .expect("the coin moved the balance") + .spendable, + Amount::from_cents(16) + ); + + // A publish that leaves the balance where it was emits nothing: the + // rename changes the purse's name, not its value. + store + .rename_purse(PurseId::MAIN, "Everyday".to_string()) + .expect("purse exists"); + publish(&hub, &mut store, NOW); + + assert!(balances.next().now_or_never().is_none()); + } + + #[test] + fn locking_a_coin_for_an_operation_moves_it_from_spendable_to_pending() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let hub = CoinageSubscriptions::new(); + let mut balances = hub + .subscribe_purse_balance(&store, PurseId::MAIN, NOW) + .expect("purse exists"); + let _ = block_on(balances.next()); + + begin(&mut store, PurseId::MAIN, 16); + publish(&hub, &mut store, NOW); + + let locked = block_on(balances.next()).expect("the lock moved the balance"); + assert_eq!(locked.spendable, Amount::ZERO); + assert_eq!(locked.pending, Amount::from_cents(16)); + } + + #[test] + fn the_clock_alone_can_move_a_balance() { + // The reason balances are recomputed rather than carried on an event: + // an entry leaving its jitter delay changes the balance with no record + // changing at all. + let mut store = store(); + let hub = CoinageSubscriptions::new(); + entry_awaiting_jitter(&mut store, PurseId::MAIN, Duration::from_secs(60)); + publish(&hub, &mut store, NOW); + let mut balances = hub + .subscribe_purse_balance(&store, PurseId::MAIN, NOW) + .expect("purse exists"); + + let waiting = block_on(balances.next()).expect("an item at subscribe time"); + assert_eq!(waiting.spendable, Amount::ZERO); + assert_eq!(waiting.pending, Amount::from_cents(16)); + + hub.refresh(&store, NOW.saturating_add(Duration::from_secs(61))); + + let ready = block_on(balances.next()).expect("the jitter delay elapsed"); + assert_eq!(ready.spendable, Amount::from_cents(16)); + assert_eq!(ready.pending, Amount::ZERO); + } + + #[test] + fn a_closed_purse_closes_its_balance_stream() { + let mut store = store(); + let savings = store.create_purse("Savings".to_string()); + let hub = CoinageSubscriptions::new(); + let mut balances = hub + .subscribe_purse_balance(&store, savings, NOW) + .expect("purse exists"); + let _ = block_on(balances.next()); + + store + .close_purse(savings, PurseId::MAIN, Amount::ZERO) + .expect("close is valid"); + publish(&hub, &mut store, NOW); + + assert_eq!( + block_on(balances.next()), + None, + "nothing further can change" + ); + assert_eq!(hub.subscriber_counts().1, 0); + } + + #[test] + fn a_dropped_balance_subscriber_is_pruned_even_without_a_change() { + let mut store = store(); + let hub = CoinageSubscriptions::new(); + drop( + hub.subscribe_purse_balance(&store, PurseId::MAIN, NOW) + .expect("purse exists"), + ); + + publish(&hub, &mut store, NOW); + + assert_eq!(hub.subscriber_counts().1, 0); + } + + // -- operation status -------------------------------------------------- + + #[test] + fn a_status_subscription_opens_with_the_current_status() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + + let mut statuses = hub + .subscribe_operation_status(&store, handle) + .expect("the operation is open"); + + assert_eq!(block_on(statuses.next()), Some(OperationStatus::Preparing)); + } + + #[test] + fn a_status_subscription_for_an_unknown_handle_is_refused() { + let store = store(); + let hub = CoinageSubscriptions::new(); + + let refused = hub.subscribe_operation_status(&store, OperationHandle(9)); + + assert_eq!( + refused.err(), + Some(CoinageError::OperationNotFound(OperationHandle(9))) + ); + } + + #[test] + fn every_intermediate_status_reaches_the_stream() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + let mut statuses = hub + .subscribe_operation_status(&store, handle) + .expect("the operation is open"); + let _ = block_on(statuses.next()); + + store + .advance_operation(handle, OperationStatus::Submitted) + .expect("the operation is open"); + store + .advance_operation(handle, OperationStatus::InBlock) + .expect("the operation is open"); + publish(&hub, &mut store, NOW); + + assert_eq!(block_on(statuses.next()), Some(OperationStatus::Submitted)); + assert_eq!(block_on(statuses.next()), Some(OperationStatus::InBlock)); + } + + #[test] + fn a_status_equal_to_the_one_already_emitted_is_not_repeated() { + // The window a subscription can be taken in: the store already holds + // the new status while the event announcing it is still undrained. + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + store + .advance_operation(handle, OperationStatus::Submitted) + .expect("the operation is open"); + + let mut statuses = hub + .subscribe_operation_status(&store, handle) + .expect("the operation is open"); + assert_eq!(block_on(statuses.next()), Some(OperationStatus::Submitted)); + + publish(&hub, &mut store, NOW); + + assert!(statuses.next().now_or_never().is_none()); + } + + #[test] + fn the_terminal_status_is_emitted_once_and_closes_the_stream() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + let mut statuses = hub + .subscribe_operation_status(&store, handle) + .expect("the operation is open"); + let _ = block_on(statuses.next()); + + store + .fail_operation(handle, CoinageError::Cancelled) + .expect("the operation is open"); + publish(&hub, &mut store, NOW); + + assert_eq!( + block_on(statuses.next()), + Some(OperationStatus::Failed(CoinageError::Cancelled)) + ); + assert_eq!(block_on(statuses.next()), None, "the stream then closes"); + assert_eq!(hub.subscriber_counts().2, 0); + } + + #[test] + fn the_terminal_status_carries_the_receipt() { + // §7.8 lets the store drop the operation record the moment its status is + // emitted, so the event is the only place the receipt still exists. + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + let mut statuses = hub + .subscribe_operation_status(&store, handle) + .expect("the operation is open"); + let _ = block_on(statuses.next()); + let receipt = OperationReceipt::default(); + + store + .finish_operation(handle, receipt.clone(), &Default::default()) + .expect("the operation is open"); + publish(&hub, &mut store, NOW); + + assert_eq!( + block_on(statuses.next()), + Some(OperationStatus::Done(receipt)) + ); + assert!( + store.operation(handle).is_none(), + "the record the receipt came from is already gone" + ); + } + + #[test] + fn a_terminated_operation_cannot_be_subscribed_to() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + store + .fail_operation(handle, CoinageError::Cancelled) + .expect("the operation is open"); + publish(&hub, &mut store, NOW); + + assert_eq!( + hub.subscribe_operation_status(&store, handle).err(), + Some(CoinageError::OperationNotFound(handle)) + ); + } + + #[test] + fn statuses_are_delivered_only_to_the_subscribed_operation() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + fund(&mut store, PurseId::MAIN, 5); + let watched = begin(&mut store, PurseId::MAIN, 16); + let other = begin(&mut store, PurseId::MAIN, 32); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + let mut statuses = hub + .subscribe_operation_status(&store, watched) + .expect("the operation is open"); + let _ = block_on(statuses.next()); + + store + .advance_operation(other, OperationStatus::Submitted) + .expect("the operation is open"); + publish(&hub, &mut store, NOW); + + assert!(statuses.next().now_or_never().is_none()); + } + + #[test] + fn a_dropped_status_subscriber_is_pruned() { + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + drop( + hub.subscribe_operation_status(&store, handle) + .expect("the operation is open"), + ); + + publish(&hub, &mut store, NOW); + + assert_eq!(hub.subscriber_counts().2, 0); + } + + #[test] + fn a_restart_reaches_the_streams_it_affects() { + // `reconcile_after_restart` fails everything that never broadcast and + // then announces `Resynced`; a subscriber must see both. + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + let mut statuses = hub + .subscribe_operation_status(&store, handle) + .expect("the operation is open"); + let _ = block_on(statuses.next()); + let mut events = hub.subscribe_events(); + let mut balances = hub + .subscribe_purse_balance(&store, PurseId::MAIN, NOW) + .expect("purse exists"); + let _ = block_on(balances.next()); + + assert!( + store.reconcile_after_restart().is_empty(), + "nothing was broadcast, so nothing needs reconciling" + ); + publish(&hub, &mut store, NOW); + + assert_eq!( + block_on(statuses.next()), + Some(OperationStatus::Failed( + CoinageError::InterruptedPreSubmission + )) + ); + assert_eq!(block_on(statuses.next()), None); + // The coin the interrupted operation held is spendable again. + assert_eq!( + block_on(balances.next()) + .expect("the lock was released") + .spendable, + Amount::from_cents(16) + ); + let published: Vec<_> = + core::iter::from_fn(|| events.next().now_or_never().flatten()).collect(); + assert_eq!(published.last(), Some(&LayerEvent::Resynced)); + } + + #[test] + fn a_terminal_status_reaches_a_subscriber_that_stopped_reading() { + // Dropping a subscription is safe, but a subscriber that simply stops + // polling must still find the terminal item waiting: the channel is + // unbounded, so nothing is dropped on the floor. + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + let statuses = hub + .subscribe_operation_status(&store, handle) + .expect("the operation is open"); + + store + .advance_operation(handle, OperationStatus::Submitted) + .expect("the operation is open"); + store + .fail_operation(handle, CoinageError::SnipedCoin) + .expect("the operation is open"); + publish(&hub, &mut store, NOW); + + let items: Vec<_> = block_on(statuses.collect()); + assert_eq!( + items, + vec![ + OperationStatus::Preparing, + OperationStatus::Submitted, + OperationStatus::Failed(CoinageError::SnipedCoin), + ] + ); + } + + #[test] + fn a_terminal_status_is_the_last_item_even_mid_batch() { + // Events published after the completion in the same batch must not + // appear on the operation's stream. + let mut store = store(); + fund(&mut store, PurseId::MAIN, 4); + let handle = begin(&mut store, PurseId::MAIN, 16); + let hub = CoinageSubscriptions::new(); + publish(&hub, &mut store, NOW); + let mut statuses = hub + .subscribe_operation_status(&store, handle) + .expect("the operation is open"); + let _ = block_on(statuses.next()); + + let events = vec![ + LayerEvent::OperationCompleted { + handle, + terminal: TerminalStatus::Failed(CoinageError::Cancelled), + }, + LayerEvent::OperationProgress { + handle, + status: OperationStatus::Preparing, + }, + ]; + hub.publish(&events, &store, NOW); + + assert_eq!( + block_on(statuses.next()), + Some(OperationStatus::Failed(CoinageError::Cancelled)) + ); + assert_eq!(block_on(statuses.next()), None); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/testing.rs b/rust/crates/truapi-server/src/runtime/coinage/testing.rs new file mode 100644 index 000000000..df666155d --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/testing.rs @@ -0,0 +1,516 @@ +//! An offline chain for the coinage runtime's tests. +//! +//! [`crate::runtime::statement_allowance::rpc::testing::ScriptedRpc`] answers +//! requests in a fixed order, which is enough for a single read but not for a +//! whole operation: an extrinsic this layer assembles carries a fresh sr25519 +//! signature, so a test cannot know its bytes in advance and cannot pre-script the +//! block that contains it. +//! +//! [`FakeChain`] answers by *method* instead. It remembers what was submitted, +//! serves it back inside the block it reports, and keys storage reads off the key +//! it was asked for — so a test describes the chain's state rather than the exact +//! sequence of round trips, and stays readable when a code change reorders reads. +//! +//! The double is deliberately thin. It does not execute anything: what an +//! extrinsic *does* is expressed by the storage and events the test hands it, +//! which keeps a passing test from passing because the fake agreed with the code +//! about something neither should know. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use parity_scale_codec::{Compact, Encode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; +use serde_json::json; +use sp_crypto_hashing::twox_128; +use subxt::ext::scale_encode::{EncodeAsFields, Field}; +use subxt::ext::scale_value::{Primitive, Value as ScaleValue}; +use subxt::metadata::ArcMetadata; +use subxt_rpcs::RpcClient as HostRpcClient; +use subxt_rpcs::client::{RawRpcFuture, RawRpcSubscription, RawValue, RpcClientT}; + +use crate::host_logic::coinage::types::{DenominationExponent, RingLocation}; +use crate::runtime::coinage::storage; +use crate::runtime::statement_allowance::rpc::RpcClient; + +/// Runtime metadata fixture every coinage test builds against. +pub const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + +/// Block hash the fake reports as finalized. +pub const FINALIZED: &str = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +/// Height of that block. +pub const FINALIZED_NUMBER: u64 = 100; + +/// Genesis hash the fake reports. +const GENESIS: &str = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +/// The same fixture through Subxt, which exposes the event variants the thin +/// metadata does not, so a test can synthesize a block's events. +pub fn subxt_metadata() -> ArcMetadata { + ArcMetadata::from(subxt::Metadata::decode_from(FIXTURE).expect("the fixture decodes for subxt")) +} + +/// `System.Events` holding one `System` event attributed to extrinsic `index`, +/// encoded exactly as the runtime stores it. +pub fn system_events(event_name: &str, index: u32) -> Vec { + let metadata = subxt_metadata(); + let system = metadata.pallet_by_name("System").expect("System exists"); + let event = system + .event_variants() + .expect("System has events") + .iter() + .find(|event| event.name == event_name) + .expect("the event exists"); + let values = ScaleValue::unnamed_composite( + event + .fields + .iter() + .map(|field| default_value(metadata.types(), field.ty.id)), + ); + let mut fields = event + .fields + .iter() + .map(|field| Field::new(field.ty.id, field.name.as_deref())); + + let mut bytes = Vec::new(); + Compact(1u32).encode_to(&mut bytes); + // Phase::ApplyExtrinsic(index). + 0u8.encode_to(&mut bytes); + index.encode_to(&mut bytes); + system.event_index().encode_to(&mut bytes); + event.index.encode_to(&mut bytes); + values + .encode_as_fields_to(&mut fields, metadata.types(), &mut bytes) + .expect("the event payload encodes"); + Vec::<[u8; 32]>::new().encode_to(&mut bytes); + bytes +} + +/// A value of `type_id` with every field at its first/default variant. +pub fn default_value(types: &PortableRegistry, type_id: u32) -> ScaleValue { + let ty = types.resolve(type_id).expect("metadata type exists"); + match &ty.type_def { + TypeDef::Composite(composite) => ScaleValue::unnamed_composite( + composite + .fields + .iter() + .map(|field| default_value(types, field.ty.id)), + ), + TypeDef::Variant(variants) => { + let variant = variants.variants.first().expect("variant exists"); + ScaleValue::unnamed_variant( + variant.name.clone(), + variant + .fields + .iter() + .map(|field| default_value(types, field.ty.id)), + ) + } + TypeDef::Sequence(_) => ScaleValue::unnamed_composite([]), + TypeDef::Array(array) => ScaleValue::unnamed_composite( + (0..array.len).map(|_| default_value(types, array.type_param.id)), + ), + TypeDef::Tuple(tuple) => ScaleValue::unnamed_composite( + tuple + .fields + .iter() + .map(|field| default_value(types, field.id)), + ), + TypeDef::Primitive(TypeDefPrimitive::Bool) => ScaleValue::bool(false), + TypeDef::Primitive(TypeDefPrimitive::Str) => ScaleValue::string(String::new()), + TypeDef::Primitive(_) | TypeDef::Compact(_) => { + ScaleValue::unnamed_variant("", []).map_context(|_| 0); + ScaleValue { + value: subxt::ext::scale_value::ValueDef::Primitive(Primitive::U128(0)), + context: (), + } + } + TypeDef::BitSequence(_) => ScaleValue::unnamed_composite([]), + } +} + +/// `System::Events` key, an unhashed plain entry. +fn system_events_key() -> String { + hex::encode( + [ + twox_128(b"System").as_slice(), + twox_128(b"Events").as_slice(), + ] + .concat(), + ) +} + +/// How the fake reports the inclusion of what it was handed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Inclusion { + /// Reported in a finalized block, dispatch successful. + FinalizedSuccess, + /// Reported in a finalized block, dispatch failed. + FinalizedFailure, + /// Reported in a non-finalized block. The caller must resolve it against + /// finalized state. + InBlock, + /// Refused before inclusion, so nothing happened. + Rejected, +} + +/// A chain that answers by method rather than by call order. +#[derive(Clone)] +pub struct FakeChain(Arc); + +struct Inner { + /// Storage by key hex, without the `0x`. An absent key reads as `None`. + storage: Mutex>>, + /// Extrinsics handed to `author_submitAndWatchExtrinsic`, in order. + submitted: Mutex>>, + /// Every `(method, params)` seen. + calls: Mutex>, + inclusion: Mutex, + fee: Mutex, +} + +impl Default for FakeChain { + fn default() -> Self { + Self::new(Inclusion::FinalizedSuccess) + } +} + +impl FakeChain { + /// A chain that reports every submission as `inclusion`. + pub fn new(inclusion: Inclusion) -> Self { + Self(Arc::new(Inner { + storage: Mutex::new(HashMap::new()), + submitted: Mutex::new(Vec::new()), + calls: Mutex::new(Vec::new()), + inclusion: Mutex::new(inclusion), + fee: Mutex::new(0), + })) + } + + /// An [`RpcClient`] backed by this chain. + pub fn rpc(&self) -> RpcClient { + RpcClient::new(HostRpcClient::new(self.clone())) + } + + /// Put a value at `key`. + pub fn set_storage(&self, key: &[u8], value: Vec) { + self.0 + .storage + .lock() + .unwrap() + .insert(hex::encode(key), value); + } + + /// Remove whatever is at `key`, so reads report absence. + pub fn clear_storage(&self, key: &[u8]) { + self.0.storage.lock().unwrap().remove(&hex::encode(key)); + } + + /// What the runtime should charge for an extrinsic. + pub fn set_fee(&self, fee: u128) { + *self.0.fee.lock().unwrap() = fee; + } + + /// Change how later submissions are reported. + pub fn set_inclusion(&self, inclusion: Inclusion) { + *self.0.inclusion.lock().unwrap() = inclusion; + } + + /// Extrinsics submitted so far. + pub fn submitted(&self) -> Vec> { + self.0.submitted.lock().unwrap().clone() + } + + /// How many extrinsics have been submitted. + pub fn submission_count(&self) -> usize { + self.0.submitted.lock().unwrap().len() + } + + /// Every `(method, params)` the fake was asked for. + pub fn calls(&self) -> Vec<(String, String)> { + self.0.calls.lock().unwrap().clone() + } + + /// Whether any request named `method`. + pub fn called(&self, method: &str) -> bool { + self.0 + .calls + .lock() + .unwrap() + .iter() + .any(|(seen, _)| seen == method) + } + + fn record(&self, method: &str, params: Option<&RawValue>) { + self.0.calls.lock().unwrap().push(( + method.to_string(), + params.map_or_else(|| "[]".to_string(), |params| params.get().to_string()), + )); + } + + /// The JSON reply for a plain request. + fn reply(&self, method: &str, params: &serde_json::Value) -> serde_json::Value { + match method { + "chain_getBlockHash" => json!(GENESIS), + "chain_getFinalizedHead" => json!(FINALIZED), + "state_getRuntimeVersion" => json!({ + "specVersion": 1_000_000, + "transactionVersion": 1, + }), + "chain_getHeader" => json!({ "number": format!("0x{FINALIZED_NUMBER:x}") }), + // Every account is fresh, which is what a throwaway top-up holder is. + "system_accountNextIndex" => json!(0), + "state_getMetadata" => json!(format!("0x{}", hex::encode(FIXTURE))), + // The block carries exactly the extrinsic just submitted, so its + // index inside the block is always zero. + "chain_getBlock" => { + let submitted = self.0.submitted.lock().unwrap(); + let extrinsics: Vec = submitted + .last() + .map(|extrinsic| format!("0x{}", hex::encode(extrinsic))) + .into_iter() + .collect(); + json!({ "block": { "extrinsics": extrinsics } }) + } + "state_getStorage" => { + let key = params + .get(0) + .and_then(serde_json::Value::as_str) + .map(|key| key.trim_start_matches("0x").to_string()) + .unwrap_or_default(); + match self.0.storage.lock().unwrap().get(&key) { + Some(value) => json!(format!("0x{}", hex::encode(value))), + None => serde_json::Value::Null, + } + } + // The bulk read a recovery scan uses: one round trip, many keys. + "state_queryStorageAt" => { + let storage = self.0.storage.lock().unwrap(); + let changes: Vec = params + .get(0) + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|key| { + let key = key.as_str()?; + let value = storage.get(key.trim_start_matches("0x"))?; + Some(json!([key, format!("0x{}", hex::encode(value))])) + }) + .collect(); + json!([{ "block": FINALIZED, "changes": changes }]) + } + "state_call" => self.runtime_api(params), + other => panic!("the fake chain was asked for an unmodelled method `{other}`"), + } + } + + /// The reply for a runtime-API call. + fn runtime_api(&self, params: &serde_json::Value) -> serde_json::Value { + let api = params + .get(0) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + match api { + "TaggedTransactionQueue_validate_transaction" => { + if *self.0.inclusion.lock().unwrap() == Inclusion::Rejected { + // `Err(Invalid::Payment)`: refused, nothing broadcast. + json!("0x0100 01".replace(' ', "")) + } else { + // `Ok(ValidTransaction::default())`. + json!("0x00000000000000000000") + } + } + "TransactionPaymentApi_query_info" => { + let mut encoded = Compact(1_000_000u64).encode(); + encoded.extend(Compact(4_096u64).encode()); + encoded.push(0u8); + encoded.extend(self.0.fee.lock().unwrap().encode()); + json!(format!("0x{}", hex::encode(encoded))) + } + other => panic!("the fake chain was asked for an unmodelled runtime API `{other}`"), + } + } +} + +impl FakeChain { + /// Put one recycler entry in a ring, as every read of it will report. + /// + /// Five storage reads answer for an entry (§6.1), and a fixture that sets only + /// some of them makes the entry look onboarding rather than included — which + /// tests would then read as "not ready yet" instead of "your fixture is + /// incomplete". + pub fn place_entry_in_ring( + &self, + exponent: DenominationExponent, + member_key: [u8; 32], + alias: [u8; 32], + ring: RingLocation, + members: &[[u8; 32]], + immutable_since: Option, + ) { + let collection = storage::recycler_collection_id(exponent); + + // 1. The pallet's own record: which denomination collection it belongs to. + self.set_storage( + &storage::recyclers_coin_to_recycler_key(&member_key), + exponent.get().encode(), + ); + // 2. Where the Members pallet placed it. + self.set_storage( + &storage::members_key(&collection, &member_key), + storage::RingPosition::Included { + ring_index: ring.index.0, + ring_page: 0, + ring_position: 0, + } + .encode(), + ); + // 3. The ring's fill and immutability. + self.set_storage( + &storage::ring_keys_status_key(&collection, ring.index), + ring_status(members.len() as u32, immutable_since), + ); + // 4. Its members and size, for proving. + self.set_storage(&storage::collections_key(&collection), collection_info(9)); + self.set_storage( + &storage::ring_keys_key(&collection, ring.index, 0), + ring_page(members), + ); + // 5. The root, whose revision a proof is built against. + self.set_storage( + &storage::ring_root_key(&collection, ring.index), + ring_root(ring.revision.0), + ); + // And the alias's own state, which is absent unless the chain locked it. + self.clear_storage(&storage::recycler_alias_state_key( + exponent, ring.index, &alias, + )); + } +} + +/// `CollectionInfo { owner, mode, ring_size, self_inclusion_delay }`. +pub fn collection_info(ring_size: u8) -> Vec { + let mut encoded = vec![1u8]; + encoded.extend([9u8; 32]); + encoded.push(0u8); + encoded.push(ring_size); + encoded.push(0u8); + encoded +} + +/// One `RingKeys` page. +pub fn ring_page(members: &[[u8; 32]]) -> Vec { + let mut encoded = Compact(members.len() as u32).encode(); + for member in members { + encoded.extend_from_slice(member); + } + encoded +} + +/// `RingStatus { total, included, immutable_since }`, everything included. +pub fn ring_status(count: u32, immutable_since: Option) -> Vec { + let mut encoded = count.to_le_bytes().to_vec(); + encoded.extend(count.to_le_bytes()); + encoded.extend(immutable_since.encode()); + encoded +} + +/// `RingRoot`, carrying the revision a proof will be built against. +pub fn ring_root(revision: u32) -> Vec { + use subxt::ext::scale_encode::EncodeAsType; + use subxt::ext::scale_value::{Composite, ValueDef}; + + let thin = crate::runtime::statement_allowance::extension::Metadata::decode(FIXTURE) + .expect("the fixture decodes"); + let type_id = thin + .storage_value_type("Members", "Root") + .expect("Members.Root is in metadata"); + let subxt = subxt_metadata(); + let mut value = default_value(subxt.types(), type_id); + if let ValueDef::Composite(Composite::Named(fields)) = &mut value.value { + for (name, field) in fields.iter_mut() { + if name == "revision" { + *field = ScaleValue::u128(u128::from(revision)); + } + } + } + value + .encode_as_type(type_id, subxt.types()) + .expect("the root encodes") +} + +impl RpcClientT for FakeChain { + fn request_raw<'a>( + &'a self, + method: &'a str, + params: Option>, + ) -> RawRpcFuture<'a, Box> { + self.record(method, params.as_deref()); + let parsed: serde_json::Value = params + .as_deref() + .and_then(|params| serde_json::from_str(params.get()).ok()) + .unwrap_or_else(|| json!([])); + let reply = self.reply(method, &parsed); + + Box::pin(async move { + Ok(RawValue::from_string(reply.to_string()).expect("the reply is valid JSON")) + }) + } + + fn subscribe_raw<'a>( + &'a self, + sub: &'a str, + params: Option>, + _unsub: &'a str, + ) -> RawRpcFuture<'a, RawRpcSubscription> { + self.record(sub, params.as_deref()); + assert_eq!( + sub, "author_submitAndWatchExtrinsic", + "the fake chain models one subscription" + ); + + let parsed: serde_json::Value = params + .as_deref() + .and_then(|params| serde_json::from_str(params.get()).ok()) + .unwrap_or_else(|| json!([])); + let extrinsic = parsed + .get(0) + .and_then(serde_json::Value::as_str) + .map(|hex_str| { + hex::decode(hex_str.trim_start_matches("0x")).expect("the extrinsic is hex") + }) + .expect("a submission carries an extrinsic"); + self.0.submitted.lock().unwrap().push(extrinsic); + + let inclusion = *self.0.inclusion.lock().unwrap(); + // Events are attributed to index zero, matching the single-extrinsic + // block the fake reports. + let events = match inclusion { + Inclusion::FinalizedFailure => system_events("ExtrinsicFailed", 0), + _ => system_events("ExtrinsicSuccess", 0), + }; + self.set_storage( + &hex::decode(system_events_key()).expect("the key is hex"), + events, + ); + + let status = match inclusion { + Inclusion::FinalizedSuccess | Inclusion::FinalizedFailure => { + json!({ "finalized": FINALIZED }) + } + Inclusion::InBlock => json!({ "inBlock": FINALIZED }), + Inclusion::Rejected => json!({ "invalid": serde_json::Value::Null }), + }; + let items = vec![Ok( + RawValue::from_string(status.to_string()).expect("the status is valid JSON") + )]; + + Box::pin(async move { + Ok(RawRpcSubscription { + stream: Box::pin(futures::stream::iter(items)), + id: Some("fake".to_string()), + }) + }) + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage/tokens.rs b/rust/crates/truapi-server/src/runtime/coinage/tokens.rs new file mode 100644 index 000000000..790cd2e6f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage/tokens.rs @@ -0,0 +1,716 @@ +//! Unload tokens and the unload fee, as the chain reports them. +//! +//! [`crate::host_logic::coinage::unload_token`] decides *which* tokens an unload +//! should present; this module supplies the two facts that decision needs from +//! the chain — which free slots are already spent, and whether the fee account +//! can cover the fee — and turns a chosen grant into the extension that carries +//! it. +//! +//! # A free token is a slot, named by an alias +//! +//! A free token is one `(period, counter)` pair. The chain does not record the +//! pair: it records the *alias* the user's personhood key produces in that pair's +//! signing context, which is what keeps one user's spending from being linkable +//! to another's. So probing whether a slot is free means deriving its alias +//! locally and asking whether the chain has seen it. +//! +//! Slots are probed for the current period first and then, inside the lookback +//! grace window, for the previous one. That window exists because a period can +//! roll over between planning a transaction and the runtime validating it, and a +//! token from the period that just ended is still honoured for a while. +//! +//! # A paid token is a whole key, not a slot in one +//! +//! The paid ring's collection is `"coinage/paidtkn!" ‖ period_le`, one per period, +//! and its proof context carries the period and no counter. So a paid member key +//! is worth exactly one token per period — where a free personhood key covers the +//! period's whole allowance. The wallet therefore keeps a *series* of paid keys per +//! period, derived at `//coinage//paidtkn////`, and each one has to +//! be joined and paid for separately. +//! +//! [`read_paid_ring_state`] reports each slot's three independent facts: whether +//! its key is registered (`PaidUnloadTokenMembers`), whether the members pallet has +//! placed it in a provable ring, and whether its one token has already been spent +//! (`PaidUnloadTokenConsumed`). Registered-and-in-a-ring-and-unspent is a token in +//! hand; unregistered is a token the wallet can buy; spent is dead until the period +//! rolls over, because the pallet refuses a member key it has already seen. +//! +//! Whether a join can be *afforded* is not readable at all. The pallet prices it as +//! `WeightToFee(coin_lifecycle_weight())`, which is neither a published constant nor +//! exposed by a runtime API, so this module does not guess: it reports +//! `can_fund_join: false` and leaves the judgement to the caller through +//! [`PaidRingState::with_fundable_joins`]. + +use core::time::Duration; + +use subxt::ext::scale_value::scale::decode_as_type; +use subxt::ext::scale_value::{Composite, Value, ValueDef}; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use crate::host_logic::coinage::chain_constants::CoinageChainConstants; +use crate::host_logic::coinage::derivation; +use crate::host_logic::coinage::error::CoinageError; +use crate::host_logic::coinage::params::CoinageParameters; +use crate::host_logic::coinage::types::{CoinAccountId, Timestamp}; +use crate::host_logic::coinage::unload_token::{FreeTokenAvailability, PaidRingState, PaidSlot}; +use crate::runtime::coinage::extension::{free_token_signing_context, paid_token_signing_context}; +use crate::runtime::coinage::{ring, storage}; +use crate::runtime::statement_allowance::extension::Metadata; +use crate::runtime::statement_allowance::rpc::RpcClient; + +/// The alias the personhood key produces for one free-token slot. +/// +/// The same value the token's proof will yield, which is what lets the layer +/// check a slot without doing ring-VRF work. +pub fn free_token_alias( + personhood_entropy: [u8; 32], + period: u32, + counter: u32, +) -> Result<[u8; 32], CoinageError> { + let secret = BandersnatchVrfVerifiable::new_secret(personhood_entropy); + let context = free_token_signing_context(period, counter); + let alias = + BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|error| { + CoinageError::Internal(format!("free-token alias derivation failed: {error:?}")) + })?; + + alias + .as_ref() + .try_into() + .map_err(|_| CoinageError::Internal("free-token alias is not 32 bytes".to_string())) +} + +/// The periods whose free tokens are still worth probing, most preferred first. +/// +/// The current period always leads. The previous one follows only while `now` is +/// still inside the grace window after the boundary. +pub fn eligible_periods( + now: Timestamp, + period_length: Duration, + grace: Duration, +) -> Result, CoinageError> { + let length = period_length.as_millis(); + if length == 0 { + return Err(CoinageError::Internal( + "the runtime reports a zero-length unload-token period".to_string(), + )); + } + + let current = u32::try_from(u128::from(now.0) / length) + .map_err(|_| CoinageError::Internal("the unload-token period overflows u32".to_string()))?; + let elapsed_in_period = u128::from(now.0) % length; + + let mut periods = vec![current]; + if current > 0 && elapsed_in_period < grace.as_millis() { + periods.push(current - 1); + } + Ok(periods) +} + +/// Read which free-token slots the chain reports consumed, pinned to `at`. +/// +/// Probes the same window resolution will consider: every counter in the layer's +/// search range, bounded by the runtime's per-period allowance, across every +/// eligible period. Probing a wider window would only find slots the runtime +/// refuses. +pub async fn read_free_token_availability( + rpc: &RpcClient, + personhood_entropy: [u8; 32], + now: Timestamp, + params: &CoinageParameters, + constants: &CoinageChainConstants, + at: &str, +) -> Result { + let periods = eligible_periods( + now, + constants.unload_token_period, + params.period_lookback_grace, + )?; + let search_range = params + .free_token_counter_search_range + .min(constants.max_free_unload_tokens_per_period); + + let mut availability = FreeTokenAvailability::fresh(periods.clone()); + for period in periods { + for counter in 0..search_range { + let alias = free_token_alias(personhood_entropy, period, counter)?; + let consumed = read( + rpc, + &storage::consumed_free_unload_tokens_key(period, &alias), + at, + ) + .await? + .is_some(); + if consumed { + availability.consumed.insert((period, counter)); + } + } + } + + Ok(availability) +} + +/// The paid-token period `now` falls in. +/// +/// Uses the *paid* period length, which is a different constant from the free +/// one — three days against one on the reference runtime. Mixing them names a +/// period whose collection the wallet is not proving against. +pub fn paid_period(now: Timestamp, constants: &CoinageChainConstants) -> Result { + Ok( + *eligible_periods(now, constants.paid_unload_token_period, Duration::ZERO)? + .first() + .expect("eligible_periods always yields the current period; qed"), + ) +} + +/// When the chain stops honouring `period`'s paid tokens. +/// +/// `(period + 1) * paid_period + ring_expiration`, matching the pallet's +/// `period_expiration_time`. A token proved past this is refused as stale, so a +/// join placed near the boundary buys very little. +pub fn paid_period_expiry( + period: u32, + constants: &CoinageChainConstants, +) -> Result { + let length = constants.paid_unload_token_period.as_millis(); + let end = u128::from(period) + .checked_add(1) + .and_then(|next| next.checked_mul(length)) + .and_then(|end| end.checked_add(constants.paid_unload_token_ring_expiration.as_millis())) + .and_then(|end| u64::try_from(end).ok()) + .ok_or_else(|| { + CoinageError::Internal("a paid-token period expiry overflows".to_string()) + })?; + + Ok(Timestamp(end)) +} + +/// The alias one paid-token slot's key produces for its period. +/// +/// The context carries the period and nothing else, which is why one key is one +/// token: there is no counter to vary. +pub fn paid_token_alias(entropy: &[u8], period: u32, slot: u32) -> Result<[u8; 32], CoinageError> { + let vrf_entropy = derivation::paid_token_ring_vrf_entropy(entropy, period, slot)?; + let secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let context = paid_token_signing_context(period); + let alias = + BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|error| { + CoinageError::Internal(format!("paid-token alias derivation failed: {error:?}")) + })?; + + alias + .as_ref() + .try_into() + .map_err(|_| CoinageError::Internal("paid-token alias is not 32 bytes".to_string())) +} + +/// Read what the chain says about the paid unload-token ring, pinned to `at`. +/// +/// Reports each slot's registration, onboarding and consumption, plus whether the +/// period's collection exists at all. +/// +/// A slot's consumption is checked against the ring its key actually sits in, +/// since `PaidUnloadTokenConsumed` is keyed by ring index. A registered key whose +/// onboarding has not completed has no ring yet, and its token is therefore +/// unspent and unprovable at the same time. That is reported as `joined` without +/// `onboarded` rather than as an error, because waiting is the correct response. +/// +/// The returned state reports `can_fund_join: false`. Whether a join is affordable +/// is not a storage read — the pallet prices it from a weight — so the caller +/// decides and applies [`PaidRingState::with_fundable_joins`]. +pub async fn read_paid_ring_state( + rpc: &RpcClient, + metadata: &Metadata, + entropy: &[u8], + now: Timestamp, + params: &CoinageParameters, + constants: &CoinageChainConstants, + at: &str, +) -> Result { + let period = paid_period(now, constants)?; + let collection = storage::paid_token_collection_id(period); + let collection_exists = read( + rpc, + &storage::paid_token_collections_created_key(period), + at, + ) + .await? + .is_some(); + + let mut slots = Vec::with_capacity(params.paid_token_slot_search_range as usize); + for slot in 0..params.paid_token_slot_search_range { + let key = derivation::paid_token_member_key(entropy, period, slot)?; + let joined = read(rpc, &storage::paid_unload_token_members_key(&key), at) + .await? + .is_some(); + + // Only a registered key can be in a ring, and finding out costs a ring + // lookup — so an unregistered slot short-circuits. + let (onboarded, spent) = if joined { + match ring::find_ring_including(rpc, metadata, &collection, &key, at).await? { + Some(ring) => { + let alias = paid_token_alias(entropy, period, slot)?; + let spent = read( + rpc, + &storage::paid_unload_token_consumed_key( + period, + ring.location.index, + &alias, + ), + at, + ) + .await? + .is_some(); + (true, spent) + } + // Registered but not in a ring yet, so nothing can have consumed + // its alias and nothing can prove it either. + None => (false, false), + } + } else { + (false, false) + }; + + slots.push(PaidSlot { + slot, + joined, + onboarded, + spent, + }); + } + + Ok(PaidRingState { + period, + collection_exists, + can_fund_join: false, + slots, + }) +} + +/// The fee account's free native balance, pinned to `at`. +/// +/// An account the chain has never seen has no entry, which reads as a zero +/// balance rather than an error: a fee account nobody has funded is an ordinary +/// state, and it means the unload takes its fee from the output instead. +pub async fn read_fee_account_balance( + rpc: &RpcClient, + metadata: &Metadata, + account: CoinAccountId, + at: &str, +) -> Result { + let Some(raw) = read(rpc, &storage::system_account_key(&account), at).await? else { + return Ok(0); + }; + let type_id = metadata + .storage_value_type("System", "Account") + .ok_or_else(|| { + CoinageError::Internal("System.Account is absent from metadata".to_string()) + })?; + let value = decode_as_type(&mut &raw[..], type_id, metadata.registry()).map_err(|error| { + CoinageError::Internal(format!("decoding the fee account failed: {error}")) + })?; + + free_balance(&value).ok_or_else(|| { + CoinageError::Internal("the fee account carried no free balance".to_string()) + }) +} + +/// Pull `data.free` out of a decoded `AccountInfo`. +fn free_balance(value: &Value) -> Option { + let ValueDef::Composite(Composite::Named(fields)) = &value.value else { + return None; + }; + let data = fields + .iter() + .find(|(name, _)| name == "data") + .map(|(_, value)| value)?; + let ValueDef::Composite(Composite::Named(balances)) = &data.value else { + return None; + }; + balances + .iter() + .find(|(name, _)| name == "free") + .and_then(|(_, value)| value.as_u128()) +} + +/// One pinned storage read. +async fn read(rpc: &RpcClient, key: &[u8], at: &str) -> Result>, CoinageError> { + rpc.get_storage_at(key, at) + .await + .map_err(|error| CoinageError::SubscriptionError(error.to_string())) +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::Encode; + use subxt_rpcs::RpcClient as HostRpcClient; + + use crate::host_logic::coinage::chain_constants::next_people_paseo; + use crate::host_logic::coinage::unload_token::{TokenGrant, resolve}; + use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + const ENTROPY: [u8; 32] = [3; 32]; + + fn metadata() -> Metadata { + Metadata::decode(FIXTURE).expect("the fixture decodes") + } + + fn block_on(future: F) -> F::Output { + futures::executor::block_on(future) + } + + fn scripted(responses: &[String]) -> (ScriptedRpc, RpcClient) { + let scripted = ScriptedRpc::new(responses.iter().map(String::as_str)); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + (scripted, rpc) + } + + /// A `()` storage value: the marker a consumed slot leaves behind. + fn present() -> String { + "\"0x\"".to_string() + } + + const ABSENT: &str = "null"; + + fn params() -> CoinageParameters { + CoinageParameters { + free_token_counter_search_range: 3, + paid_token_slot_search_range: 2, + ..CoinageParameters::default() + } + } + + /// The middle of `period`, which is past the lookback grace window and so + /// probes one period rather than two. + fn mid_period(constants: &CoinageChainConstants, period: u64) -> Timestamp { + let length = constants.unload_token_period.as_millis() as u64; + Timestamp(length * period + length / 2) + } + + #[test] + fn a_slot_alias_is_bound_to_its_period_and_counter() { + // A token replayable across slots would let one allowance be spent + // repeatedly, so every pair must produce its own alias. + let first = free_token_alias(ENTROPY, 10, 0).expect("derives"); + let same = free_token_alias(ENTROPY, 10, 0).expect("derives"); + let other_counter = free_token_alias(ENTROPY, 10, 1).expect("derives"); + let other_period = free_token_alias(ENTROPY, 11, 0).expect("derives"); + let other_user = free_token_alias([9; 32], 10, 0).expect("derives"); + + assert_eq!(first, same, "the same slot is the same alias"); + assert_ne!(first, other_counter); + assert_ne!(first, other_period); + assert_ne!(first, other_user, "aliases do not collide across wallets"); + } + + #[test] + fn the_previous_period_stays_eligible_only_inside_the_grace_window() { + let length = Duration::from_secs(1_000); + let grace = Duration::from_secs(100); + + // 50 seconds into period 5: the boundary is close enough behind us that a + // token minted for period 4 may still be honoured. + let just_after = Timestamp(5_050_000); + assert_eq!( + eligible_periods(just_after, length, grace).expect("computes"), + vec![5, 4] + ); + + // Half way through: only the current period. + let settled = Timestamp(5_500_000); + assert_eq!( + eligible_periods(settled, length, grace).expect("computes"), + vec![5] + ); + + // Inside the first period ever, there is no previous one to fall back to. + assert_eq!( + eligible_periods(Timestamp(10), length, grace).expect("computes"), + vec![0] + ); + } + + #[test] + fn a_zero_length_period_is_refused() { + let refused = eligible_periods(Timestamp(1), Duration::ZERO, Duration::ZERO) + .expect_err("a zero-length period has no slot arithmetic"); + + assert!(refused.to_string().contains("zero-length")); + } + + #[test] + fn consumed_slots_are_read_back_as_consumed() { + // Three counters, of which the middle one is spent. + let (_scripted, rpc) = scripted(&[ABSENT.to_string(), present(), ABSENT.to_string()]); + let constants = next_people_paseo(); + // Mid-period, past the one-hour grace window, so only the current + // period is probed. + let now = mid_period(&constants, 3); + + let availability = block_on(read_free_token_availability( + &rpc, + ENTROPY, + now, + ¶ms(), + &constants, + "0xfeed", + )) + .expect("reads"); + + assert_eq!(availability.eligible_periods, vec![3]); + assert!(availability.is_free(3, 0)); + assert!(!availability.is_free(3, 1), "the chain saw this alias"); + assert!(availability.is_free(3, 2)); + } + + #[test] + fn the_probe_window_never_exceeds_the_runtimes_allowance() { + // The layer's search range is a policy knob; the runtime's per-period cap + // is a fact. Probing past it would read slots the runtime refuses, and + // each read costs a round trip. + let constants = CoinageChainConstants { + max_free_unload_tokens_per_period: 2, + ..next_people_paseo() + }; + let generous = CoinageParameters { + free_token_counter_search_range: 50, + ..CoinageParameters::default() + }; + let (scripted, rpc) = scripted(&[ABSENT.to_string(), ABSENT.to_string()]); + let now = mid_period(&constants, 1); + + block_on(read_free_token_availability( + &rpc, ENTROPY, now, &generous, &constants, "0xfeed", + )) + .expect("reads"); + + assert_eq!(scripted.calls().len(), 2, "one read per allowed counter"); + } + + #[test] + fn an_availability_snapshot_drives_resolution() { + // The whole point of the read: the first free slot resolution picks must + // be one the chain has not already seen. + let (_scripted, rpc) = scripted(&[present(), ABSENT.to_string(), ABSENT.to_string()]); + let constants = next_people_paseo(); + let now = mid_period(&constants, 2); + + let availability = block_on(read_free_token_availability( + &rpc, + ENTROPY, + now, + ¶ms(), + &constants, + "0xfeed", + )) + .expect("reads"); + let plan = resolve( + 1, + &availability, + &PaidRingState::unavailable(2), + ¶ms(), + &constants, + ) + .expect("a free slot remains"); + + assert_eq!( + plan.grants, + vec![TokenGrant::Free { + period: 2, + counter: 1 + }], + "counter 0 is spent, so the next one is taken" + ); + } + + #[test] + fn the_paid_period_is_measured_with_the_paid_period_length() { + // The two period lengths are different constants — one day free, three + // days paid on the reference runtime — and the paid one names the + // collection a token proves against. Measuring with the free length picks + // a period whose ring the wallet is not a member of, after paying to join. + let constants = next_people_paseo(); + let day = constants.unload_token_period.as_millis() as u64; + + // Four days in: free period 4, paid period 1. + let now = Timestamp(4 * day + day / 2); + + assert_eq!( + eligible_periods(now, constants.unload_token_period, Duration::ZERO).expect("computes"), + vec![4] + ); + assert_eq!(paid_period(now, &constants).expect("computes"), 1); + } + + #[test] + fn a_paid_period_expires_after_its_end_plus_the_ring_expiration() { + let constants = next_people_paseo(); + let period_ms = constants.paid_unload_token_period.as_millis() as u64; + let expiration_ms = constants.paid_unload_token_ring_expiration.as_millis() as u64; + + // Period 2 ends when period 3 begins, and the ring lingers past that. + assert_eq!( + paid_period_expiry(2, &constants).expect("computes"), + Timestamp(3 * period_ms + expiration_ms) + ); + } + + #[test] + fn a_slot_alias_is_bound_to_its_period_and_slot() { + // Each slot must produce its own alias, or two slots would be one token. + let first = paid_token_alias(&ENTROPY, 10, 0).expect("derives"); + let same = paid_token_alias(&ENTROPY, 10, 0).expect("derives"); + let other_slot = paid_token_alias(&ENTROPY, 10, 1).expect("derives"); + let other_period = paid_token_alias(&ENTROPY, 11, 0).expect("derives"); + let other_wallet = paid_token_alias(&[9u8; 32], 10, 0).expect("derives"); + + assert_eq!(first, same, "the same slot is the same alias"); + assert_ne!(first, other_slot, "two slots are two tokens"); + assert_ne!(first, other_period); + assert_ne!(first, other_wallet); + + // And a paid alias is not a free alias for the same period, because the + // key and the context both differ. + assert_ne!(first, free_token_alias(ENTROPY, 10, 0).expect("derives")); + } + + #[test] + fn an_unjoined_wallet_reports_every_slot_as_joinable_but_holds_no_token() { + // One read for the collection, then one membership read per slot. Nothing + // is joined, so no ring lookup happens. + let (scripted, rpc) = + scripted(&[ABSENT.to_string(), ABSENT.to_string(), ABSENT.to_string()]); + let constants = next_people_paseo(); + let now = Timestamp(constants.paid_unload_token_period.as_millis() as u64 * 3); + + let state = block_on(read_paid_ring_state( + &rpc, + &metadata(), + &ENTROPY, + now, + ¶ms(), + &constants, + "0xfeed", + )) + .expect("reads"); + + assert_eq!(state.period, 3); + assert!(!state.collection_exists); + assert_eq!(state.slots.len(), 2); + assert!(state.slots.iter().all(|slot| slot.is_joinable())); + assert!( + !state.slots.iter().any(|slot| slot.is_ready()), + "joinable is not the same as held" + ); + assert_eq!( + scripted.calls().len(), + 3, + "an unjoined slot costs no ring lookup" + ); + + // A wallet out of free slots that cannot fund a join is told it has no + // token, rather than handed a grant it cannot present. + let exhausted = FreeTokenAvailability { + eligible_periods: vec![state.period], + consumed: (0..3).map(|counter| (state.period, counter)).collect(), + }; + assert_eq!( + resolve(1, &exhausted, &state, ¶ms(), &constants), + Err(CoinageError::NoUnloadToken) + ); + } + + #[test] + fn a_joined_slot_awaiting_onboarding_is_unspent_and_not_yet_ready() { + // Joined, but the members pallet has not placed the key in a ring yet, so + // there is no ring index to check consumption against. The honest answer + // is "not spent" — and the slot is still not usable, because a proof needs + // a ring. Waiting is the caller's move, not failing. + let (_scripted, rpc) = scripted(&[ + present(), // the period's collection exists + present(), // slot 0 is a member + ABSENT.to_string(), // CurrentRingIndex: defaults to ring 0 + ABSENT.to_string(), // ring 0 has no root, so no ring holds the key + ABSENT.to_string(), // slot 1 is not a member + ]); + let constants = next_people_paseo(); + let now = Timestamp(constants.paid_unload_token_period.as_millis() as u64); + + let state = block_on(read_paid_ring_state( + &rpc, + &metadata(), + &ENTROPY, + now, + ¶ms(), + &constants, + "0xfeed", + )) + .expect("reads"); + + assert!(state.collection_exists); + assert!(state.slots[0].joined); + assert!(!state.slots[0].spent); + assert!( + !state.slots[0].is_ready(), + "a key with no ring cannot be proved" + ); + assert!( + !state.slots[0].is_joinable(), + "and it must not be joined twice; the pallet refuses a known key" + ); + } + + /// `AccountInfo { nonce, consumers, providers, sufficients, data: AccountData + /// { free, reserved, frozen, flags } }`. + fn account_info(free: u128) -> Vec { + let mut encoded = 7u32.encode(); // nonce + encoded.extend(0u32.encode()); // consumers + encoded.extend(1u32.encode()); // providers + encoded.extend(0u32.encode()); // sufficients + encoded.extend(free.encode()); + encoded.extend(0u128.encode()); // reserved + encoded.extend(0u128.encode()); // frozen + encoded.extend(0u128.encode()); // flags + encoded + } + + #[test] + fn the_fee_account_balance_is_read_from_its_free_field() { + let (_scripted, rpc) = scripted(&[format!( + "\"0x{}\"", + hex::encode(account_info(12_345_678_901)) + )]); + + let balance = block_on(read_fee_account_balance( + &rpc, + &metadata(), + CoinAccountId([4; 32]), + "0xfeed", + )) + .expect("reads"); + + assert_eq!(balance, 12_345_678_901); + } + + #[test] + fn an_unfunded_fee_account_reads_as_zero_not_as_an_error() { + // The account exists only once someone sends it money, and an unfunded + // fee account is an ordinary state that selects the from-output fee mode. + let (_scripted, rpc) = scripted(&[ABSENT.to_string()]); + + let balance = block_on(read_fee_account_balance( + &rpc, + &metadata(), + CoinAccountId([4; 32]), + "0xfeed", + )) + .expect("reads"); + + assert_eq!(balance, 0); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 4f70d0006..5abdd8832 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -22,7 +22,7 @@ use sp_crypto_hashing::twox_128; use thiserror::Error; use tracing::{debug, warn}; -use extension::{ChainState, Metadata, MetadataError}; +use extension::{ChainState, EraAnchor, Metadata, MetadataError}; use ring::RingParams; use rpc::RpcClient; use slot::{SlotError, SlotSelection}; @@ -145,9 +145,38 @@ pub async fn fetch_chain_state(rpc: &RpcClient) -> Result Result { + let hash_hex = rpc.finalized_head().await?; + let hash_bytes = hex::decode(hash_hex.strip_prefix("0x").unwrap_or(&hash_hex)) + .map_err(ChainStateError::GenesisHex)?; + let len = hash_bytes.len(); + let hash: [u8; 32] = hash_bytes + .try_into() + .map_err(|_| ChainStateError::GenesisHashLength { len })?; + + let header = rpc.call("chain_getHeader", json!([hash_hex])).await?; + let number = header + .get("number") + .and_then(Value::as_str) + .ok_or(ChainStateError::HeaderNumberMissing)?; + let number = u64::from_str_radix(number.strip_prefix("0x").unwrap_or(number), 16) + .map_err(ChainStateError::HeaderNumberParse)?; + + Ok(EraAnchor::new(number, hash, period)) +} + /// Read a u32 field from a JSON object. fn json_u32(value: &Value, field: &'static str) -> Result { value @@ -636,6 +665,7 @@ mod tests { transaction_version: 1, genesis_hash: [0xab; 32], nonce: 0, + mortality: None, }; let entropy = [0x11; 32]; let ring = RingParams { diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs index 9deccdc46..06da8b696 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -84,6 +84,26 @@ pub enum MetadataError { /// `MembershipCollection::LitePeople` variant was not found. #[error("MembershipCollection::LitePeople not found in metadata")] MissingLitePeopleCollection, + /// A transaction extension named in a request is absent from metadata. + #[error("`{identifier}` extension not found in metadata")] + MissingExtension { + /// Extension identifier that was looked up. + identifier: String, + }, + /// An extension's extra is not the `Option` shape this resolver walks. + #[error("`{identifier}` extra is not an Option")] + ExtensionExtraNotOption { + /// Extension identifier that was looked up. + identifier: String, + }, + /// The named variant is absent from an extension's info enum. + #[error("`{extension}` info enum has no variant `{variant}`")] + MissingExtensionInfoVariant { + /// Extension identifier that was looked up. + extension: String, + /// Variant name that was looked up. + variant: String, + }, /// Type id did not resolve in the portable registry. #[error("unknown type id {type_id}")] UnknownTypeId { @@ -128,6 +148,67 @@ pub enum MetadataError { }, } +/// Anchor for a mortal transaction era. +/// +/// A mortal extrinsic is only includable in `[anchor, anchor + period]`. That +/// bound is what lets a caller eventually decide that a transaction it lost +/// track of can never land — an immortal extrinsic offers no such point, so +/// returning its inputs to a spendable pool is never safe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EraAnchor { + /// Height of the anchor block. + pub number: u64, + /// Hash of the anchor block; the `CheckMortality` implicit. + pub hash: [u8; 32], + /// Era length in blocks. + pub period: u64, +} + +/// Smallest era period Substrate's encoding admits. +const MIN_ERA_PERIOD: u64 = 4; +/// Largest era period Substrate's encoding admits. +const MAX_ERA_PERIOD: u64 = 1 << 16; + +impl EraAnchor { + /// Anchor an era of `period` blocks at the given block. + /// + /// `period` is rounded up to a power of two and clamped to Substrate's + /// `[4, 65536]`, matching `sp_runtime::generic::Era::mortal`. + pub fn new(number: u64, hash: [u8; 32], period: u64) -> Self { + let period = period + .checked_next_power_of_two() + .unwrap_or(MAX_ERA_PERIOD) + .clamp(MIN_ERA_PERIOD, MAX_ERA_PERIOD); + Self { + number, + hash, + period, + } + } + + /// Last block height at which the extrinsic can still be included. + /// + /// The anchor is its own era birth: this layer only ever quantizes by one + /// (periods up to 4096), so `birth(number) == number`. + pub const fn last_valid_block(&self) -> u64 { + self.number.saturating_add(self.period) + } + + /// SCALE-encoded `Era::Mortal`: a little-endian `u16` carrying + /// `log2(period) - 1` in the low nibble and the quantized phase above it. + pub fn encode_era(&self) -> Vec { + let quantize_factor = (self.period >> 12).max(1); + let phase = self.number % self.period; + let quantized_phase = phase / quantize_factor * quantize_factor; + + let low = (self.period.trailing_zeros() as u16) + .saturating_sub(1) + .clamp(1, 15); + let high = ((quantized_phase / quantize_factor) as u16) << 4; + (low | high).encode() + } +} + /// Chain state needed to fill the standard signed extensions. #[derive(Debug, Clone, Copy)] pub struct ChainState { @@ -135,10 +216,17 @@ pub struct ChainState { pub spec_version: u32, /// Runtime `transactionVersion` (CheckTxVersion implicit). pub transaction_version: u32, - /// Genesis block hash (CheckGenesis / CheckMortality implicit). + /// Genesis block hash (CheckGenesis implicit; also CheckMortality's when + /// the transaction is immortal). pub genesis_hash: [u8; 32], /// Account nonce (CheckNonce extra); ignored by the unsigned path. pub nonce: u32, + /// Era anchor, or `None` for an immortal transaction. + /// + /// Opt-in rather than always-on: allowance registration has always used + /// immortal extrinsics and has no recovery procedure that needs an expiry, + /// whereas coinage requires mortality (`coinage-layer.md` §7.4). + pub mortality: Option, } /// A signed extension's identifier plus the type ids of its `extra` and @@ -392,6 +480,101 @@ impl Metadata { Ok((variant.index, lite_people.index)) } + /// Position of a transaction extension in metadata order. + pub fn extension_index(&self, identifier: &str) -> Option { + self.extensions + .iter() + .position(|e| e.identifier == identifier) + } + + /// The raw inherited implication for `identifier`: everything the extension + /// signs over, unhashed. + /// + /// That is the extension version byte, the call, then the extras and the + /// implicits of every extension that follows this one. Returned raw rather + /// than hashed because some proofs prepend their own material before + /// hashing — a free unload token signs + /// `blake2_256(alias_proofs ++ implication)` while an individual alias proof + /// signs `blake2_256(implication)`. + pub fn inherited_implication( + &self, + identifier: &str, + call_data: &[u8], + state: &ChainState, + ) -> Result, StatementAllowanceError> { + let all = self.encode_signed_extensions(state); + let tail_start = self + .extension_index(identifier) + .map(|i| i + 1) + .ok_or_else(|| MetadataError::MissingExtension { + identifier: identifier.to_string(), + })?; + let tail = &all[tail_start..]; + + let mut payload = Vec::with_capacity(1 + call_data.len()); + payload.push(0x00); + payload.extend_from_slice(call_data); + for ext in tail { + payload.extend_from_slice(&ext.extra); + } + for ext in tail { + payload.extend_from_slice(&ext.additional_signed); + } + Ok(payload) + } + + /// Index of a named variant inside a transaction extension's + /// `Option<...Info>` extra. + /// + /// Variant indices are positional in SCALE and therefore not stable across + /// runtime upgrades, so callers resolve them by name for the same reason + /// they resolve call indices by name: a reordered enum should fail loudly + /// rather than silently select a different mode. + pub fn extension_info_variant_index( + &self, + identifier: &str, + variant: &str, + ) -> Result { + let ext = self + .extensions + .iter() + .find(|e| e.identifier == identifier) + .ok_or_else(|| MetadataError::MissingExtension { + identifier: identifier.to_string(), + })?; + + // extra = `Extension(Option)`, with or without the struct wrapper. + let option_type = match &self.resolve_type(ext.extra_type)?.type_def { + TypeDef::Composite(_) => self.single_field_type(ext.extra_type)?, + _ => ext.extra_type, + }; + let info_type = self + .resolve_variant(option_type)? + .variants + .iter() + .find(|v| v.name == "Some") + .and_then(|some| match some.fields.as_slice() { + [field] => Some(field.ty.id), + _ => None, + }) + .ok_or_else(|| MetadataError::ExtensionExtraNotOption { + identifier: identifier.to_string(), + })?; + + self.resolve_variant(info_type)? + .variants + .iter() + .find(|v| v.name == variant) + .map(|found| found.index) + .ok_or_else(|| { + MetadataError::MissingExtensionInfoVariant { + extension: identifier.to_string(), + variant: variant.to_string(), + } + .into() + }) + } + /// Resolve a type id in the registry. fn resolve_type( &self, @@ -460,8 +643,12 @@ impl Metadata { "CheckSpecVersion" => (Vec::new(), state.spec_version.to_le_bytes().to_vec()), "CheckTxVersion" => (Vec::new(), state.transaction_version.to_le_bytes().to_vec()), "CheckGenesis" => (Vec::new(), state.genesis_hash.to_vec()), - // extra = Era::Immortal (0x00); implicit = genesis hash. - "CheckMortality" => (vec![0x00], state.genesis_hash.to_vec()), + // Immortal: extra = 0x00, implicit = genesis hash. Mortal: extra = + // the encoded era, implicit = the anchor block's hash. + "CheckMortality" => match state.mortality { + None => (vec![0x00], state.genesis_hash.to_vec()), + Some(anchor) => (anchor.encode_era(), anchor.hash.to_vec()), + }, // extra = first variant `Disabled` (void) = 0x00. "VerifyMultiSignature" => (vec![0x00], Vec::new()), // extra = { tip: compact(0), asset_id: None } = 0x00 0x00. @@ -591,9 +778,85 @@ mod tests { transaction_version: 1, genesis_hash: [0xab; 32], nonce: 0, + mortality: None, } } + #[test] + fn a_mortal_era_matches_substrates_encoding() { + // Golden against a known-answer pair: `Era::Mortal(64, 61)` is the + // familiar `d5 03` seen on Polkadot extrinsics. Getting the nibble + // layout wrong yields a valid-looking era with the wrong lifetime, so a + // transaction would expire at a time recovery does not expect. + let anchor = EraAnchor::new(64 * 3 + 61, [0u8; 32], 64); + + assert_eq!(anchor.period, 64); + assert_eq!(anchor.encode_era(), vec![0xd5, 0x03]); + } + + #[test] + fn the_coinage_period_encodes_and_bounds_the_transaction() { + use crate::host_logic::coinage::params::EXTRINSIC_MORTALITY_BLOCKS; + + let anchor = EraAnchor::new(1_000, [0u8; 32], EXTRINSIC_MORTALITY_BLOCKS); + + assert_eq!(anchor.period, 256); + // log2(256) - 1 = 7 in the low nibble; phase 1000 % 256 = 232 above it. + assert_eq!(anchor.encode_era(), (7u16 | (232u16 << 4)).encode()); + assert_eq!(anchor.last_valid_block(), 1_256); + } + + #[test] + fn a_period_is_rounded_and_clamped_to_what_the_encoding_admits() { + let hash = [0u8; 32]; + + assert_eq!(EraAnchor::new(0, hash, 100).period, 128, "rounded up"); + assert_eq!(EraAnchor::new(0, hash, 1).period, 4, "clamped up"); + assert_eq!( + EraAnchor::new(0, hash, 1 << 20).period, + 1 << 16, + "clamped down" + ); + } + + #[test] + fn mortality_changes_both_the_extra_and_the_implicit() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let immortal = fixture_state(); + let anchor = EraAnchor::new(1_000, [0x5c; 32], 256); + let mortal = ChainState { + mortality: Some(anchor), + ..immortal + }; + + let find = |state: &ChainState| { + metadata + .extension_ids() + .iter() + .position(|id| *id == "CheckMortality") + .map(|index| { + let all = metadata.encode_signed_extensions(state); + ( + all[index].extra.clone(), + all[index].additional_signed.clone(), + ) + }) + .expect("the runtime carries CheckMortality") + }; + + let (immortal_extra, immortal_implicit) = find(&immortal); + let (mortal_extra, mortal_implicit) = find(&mortal); + + assert_eq!(immortal_extra, vec![0x00]); + assert_eq!(immortal_implicit, immortal.genesis_hash.to_vec()); + assert_eq!(mortal_extra, anchor.encode_era()); + assert_eq!( + mortal_implicit, + anchor.hash.to_vec(), + "a mortal era is anchored to its own block, not to genesis" + ); + } + /// `Resources.set_statement_store_account(period=7, seq=0, target=0)`. fn fixture_call() -> Vec { let mut call = vec![0x3f, 0x0a]; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs index c6e03dd39..26633323a 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -172,6 +172,7 @@ mod tests { transaction_version: 1, genesis_hash: [0xab; 32], nonce: 0, + mortality: None, } } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs index 5aae13b17..67ff588e5 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs @@ -157,6 +157,21 @@ impl RpcClient { &self, extrinsic: &[u8], ) -> Result { + self.submit_and_watch_inclusion(extrinsic) + .await + .map(|inclusion| inclusion.block_hash) + } + + /// Submit an extrinsic and report where it landed **and whether that block + /// was final**. + /// + /// The distinction matters to callers that must not treat a reversible + /// inclusion as a settled outcome: a transaction in a non-finalized block + /// can be invalidated on the new canonical chain after a reorg. + pub async fn submit_and_watch_inclusion( + &self, + extrinsic: &[u8], + ) -> Result { let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); let mut subscription = self .inner @@ -185,9 +200,9 @@ impl RpcClient { })?, () = timeout => return Err(RpcError::SubmitTimeout.into()), }; - tracing::debug!(?status, "allowance extrinsic status"); + tracing::debug!(?status, "extrinsic status"); match extrinsic_status(&status) { - ExtrinsicStatus::Included(hash) => return Ok(hash), + ExtrinsicStatus::Included(inclusion) => return Ok(inclusion), ExtrinsicStatus::Rejected(reason) => { return Err(RpcError::ExtrinsicRejected { status: reason }.into()); } @@ -197,17 +212,30 @@ impl RpcClient { } } +/// Where a submitted extrinsic landed, and how settled that is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Inclusion { + /// Hash of the block the extrinsic was reported in. + pub block_hash: String, + /// Whether the node reported that block as finalized. An inclusion that is + /// not finalized is provisional and may be undone by a reorg. + pub finalized: bool, +} + #[derive(Debug, PartialEq)] enum ExtrinsicStatus { - Included(String), + Included(Inclusion), Rejected(String), Pending, } fn extrinsic_status(status: &Value) -> ExtrinsicStatus { - for key in ["finalized", "inBlock"] { + for (key, finalized) in [("finalized", true), ("inBlock", false)] { if let Some(hash) = status.get(key).and_then(Value::as_str) { - return ExtrinsicStatus::Included(hash.to_string()); + return ExtrinsicStatus::Included(Inclusion { + block_hash: hash.to_string(), + finalized, + }); } } for key in [ @@ -334,20 +362,47 @@ mod tests { use serde_json::json; use super::testing::ScriptedRpc; - use super::{ExtrinsicStatus, HostRpcClient, RpcClient, extrinsic_status}; + use super::{ExtrinsicStatus, HostRpcClient, Inclusion, RpcClient, extrinsic_status}; #[test] - fn in_block_status_completes_submission() { + fn in_block_status_completes_submission_but_is_not_final() { let status = extrinsic_status(&json!({"inBlock": "0x1234"})); - assert!(matches!(status, ExtrinsicStatus::Included(hash) if hash == "0x1234")); + assert_eq!( + status, + ExtrinsicStatus::Included(Inclusion { + block_hash: "0x1234".to_string(), + finalized: false, + }) + ); } #[test] - fn finalized_status_completes_submission() { + fn finalized_status_completes_submission_and_is_final() { let status = extrinsic_status(&json!({"finalized": "0xabcd"})); - assert!(matches!(status, ExtrinsicStatus::Included(hash) if hash == "0xabcd")); + assert_eq!( + status, + ExtrinsicStatus::Included(Inclusion { + block_hash: "0xabcd".to_string(), + finalized: true, + }) + ); + } + + #[test] + fn a_status_carrying_both_is_read_as_finalized() { + // Defensive: finality is the stronger claim, so if a node ever reports + // both, taking the weaker one would understate what is settled. + let status = extrinsic_status(&json!({"inBlock": "0x1", "finalized": "0x2"})); + + assert_eq!( + status, + ExtrinsicStatus::Included(Inclusion { + block_hash: "0x2".to_string(), + finalized: true, + }) + ); } #[test] diff --git a/rust/crates/truapi-server/tests/coinage_lifecycle.rs b/rust/crates/truapi-server/tests/coinage_lifecycle.rs new file mode 100644 index 000000000..37e0e3cf3 --- /dev/null +++ b/rust/crates/truapi-server/tests/coinage_lifecycle.rs @@ -0,0 +1,871 @@ +//! End-to-end scenarios over the coinage base layer. +//! +//! The unit tests inside each module check one thing at a time. This suite +//! drives the whole pipeline the way a host will — derive accounts, observe +//! chain state, select, plan tokens, build the extrinsic, submit, reconcile — +//! and asserts the invariants that only show up once the pieces are composed. +//! +//! There is no platform here because the base layer needs none: it is pure, and +//! chain facts arrive as observations. `ScriptedChain` stands in for the chain, +//! holding the state a real node would report so a scenario can advance it +//! deliberately. + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::Duration; + +use futures::executor::block_on; +use futures::{FutureExt, StreamExt}; +use parity_scale_codec::{Decode, Encode}; + +use truapi_server::coinage::call::{CoinOutput, UnloadRecyclerIntoCoinsArgs}; +use truapi_server::coinage::extension::{AsCoinageInfo, FreeTokenRing}; +use truapi_server::coinage::subscription::CoinageSubscriptions; +use truapi_server::host_logic::coinage::chain_constants::{ + CoinageChainConstants, next_people_paseo, +}; +use truapi_server::host_logic::coinage::coin::CoinState; +use truapi_server::host_logic::coinage::derivation; +use truapi_server::host_logic::coinage::entry::EntryLocalState; +use truapi_server::host_logic::coinage::error::CoinageError; +use truapi_server::host_logic::coinage::event::LayerEvent; +use truapi_server::host_logic::coinage::log::Checkpoint; +use truapi_server::host_logic::coinage::operation::{LockSet, OperationReceipt, OperationStatus}; +use truapi_server::host_logic::coinage::params::CoinageParameters; +use truapi_server::host_logic::coinage::selection::{ + OutputRequirement, SelectionRequest, SelectionTier, +}; +use truapi_server::host_logic::coinage::store::CoinageStore; +use truapi_server::host_logic::coinage::types::{ + Amount, BlockHash, CoinAccountId, CoinAge, CoinIndex, DenominationExponent, EntryIndex, + ExtrinsicHash, OperationHandle, OperationKind, PurseId, RevisionIndex, RingIndex, RingLocation, + Timestamp, +}; +use truapi_server::host_logic::coinage::unload_token::{ + FeeMode, FreeTokenAvailability, PaidRingState, TokenGrant, choose_fee_mode, resolve, +}; + +const ENTROPY: [u8; 32] = [7; 32]; +const HOUR: Duration = Duration::from_secs(60 * 60); +const DAY: Duration = Duration::from_secs(24 * 60 * 60); +const TOKEN_PERIOD: u32 = 19_000; + +/// The chain state a scenario has arranged. +/// +/// Deliberately dumb: it records what a node would report and nothing else, so +/// a test that passes cannot be passing because the fake agreed with the code +/// about something it should not know. +#[derive(Debug, Default)] +struct ScriptedChain { + /// Coin accounts the chain reports populated, with their observed age. + coins: BTreeMap, + /// Recycler entries by member key: where they sit and how full the ring is. + rings: BTreeMap<[u8; 32], (RingLocation, u32)>, + /// Free unload-token slots the chain reports consumed. + consumed_tokens: BTreeSet<(u32, u32)>, +} + +impl ScriptedChain { + /// Place a recycler entry into a ring with the given member count. + fn load_entry(&mut self, member_key: [u8; 32], ring: RingLocation, members: u32) { + self.rings.insert(member_key, (ring, members)); + } + + /// Report a coin account as holding a coin. + fn credit_coin(&mut self, account: CoinAccountId, age: CoinAge) { + self.coins.insert(account, age); + } + + fn ring_of(&self, member_key: &[u8; 32]) -> Option<(RingLocation, u32)> { + self.rings.get(member_key).copied() + } + + fn age_of(&self, account: &CoinAccountId) -> Option { + self.coins.get(account).copied() + } + + fn free_tokens(&self) -> FreeTokenAvailability { + FreeTokenAvailability { + eligible_periods: vec![TOKEN_PERIOD], + consumed: self.consumed_tokens.clone(), + } + } +} + +/// Plan a transaction and record its broadcast, in the order the layer really +/// does it: the write-ahead entry exists before the extrinsic goes out. +fn submit(store: &mut CoinageStore, handle: OperationHandle, hash: ExtrinsicHash) -> u32 { + let locks = store + .operation(handle) + .expect("operation is open") + .locks + .clone(); + let sequence = store + .plan_transaction( + handle, + locks, + LockSet::default(), + Checkpoint { + number: 1_000, + hash: BlockHash([1; 32]), + mortality: 256, + }, + [], + ) + .expect("operation is open"); + store + .record_submission(handle, sequence, hash) + .expect("operation is open"); + sequence +} + +fn exponent(value: i8) -> DenominationExponent { + DenominationExponent::new(value).expect("exponent is in range") +} + +fn ring(index: u32, revision: u32) -> RingLocation { + RingLocation::new(RingIndex(index), RevisionIndex(revision)) +} + +fn params() -> CoinageParameters { + CoinageParameters::default() +} + +fn constants() -> CoinageChainConstants { + next_people_paseo() +} + +fn any(cents: u64) -> SelectionRequest { + SelectionRequest { + amount: Amount::from_cents(cents), + outputs: OutputRequirement::AnyDenominations, + allow_degraded: false, + } +} + +/// Allocate an entry locally and place it into a well-populated ring, the way a +/// top-up does. +fn top_up_entry( + store: &mut CoinageStore, + chain: &mut ScriptedChain, + purse: PurseId, + exponent_value: i8, + now: Timestamp, + jitter: Duration, + ring_at: RingLocation, +) -> EntryIndex { + let index = store + .allocate_entry(purse, exponent(exponent_value), now, jitter) + .expect("purse exists"); + let member_key = + derivation::entry_member_key(&ENTROPY, purse, index).expect("derivation succeeds"); + chain.load_entry(member_key, ring_at, 32); + index +} + +/// Drain the store's events into the subscription hub, the way the persistence +/// path does on every mutation. +fn publish(hub: &CoinageSubscriptions, store: &mut CoinageStore, now: Timestamp) { + let events = store.take_events(); + hub.publish(&events, store, now); +} + +/// Feed every locally known entry's ring state back into the store. +fn observe_entries(store: &mut CoinageStore, chain: &ScriptedChain, purse: PurseId) { + for entry in store.entries_in(purse) { + let member_key = derivation::entry_member_key(&ENTROPY, purse, entry.index) + .expect("derivation succeeds"); + if let Some((ring_at, members)) = chain.ring_of(&member_key) { + store + .observe_entry_ring(purse, entry.index, ring_at, members, ¶ms()) + .expect("entry exists"); + } + } +} + +#[test] +fn the_reference_runtime_is_accepted_before_anything_else_happens() { + // A host validates constants once at connection. Everything downstream + // assumes this passed. + assert_eq!(constants().validate(), Ok(())); + assert_eq!(constants().recycle_at_age(), CoinAge(14)); +} + +#[test] +fn a_topped_up_purse_becomes_spendable_only_after_its_jitter_elapses() { + let mut store = CoinageStore::new("Main".to_string()); + let mut chain = ScriptedChain::default(); + let start = Timestamp(1_000_000); + + let index = top_up_entry( + &mut store, + &mut chain, + PurseId::MAIN, + 4, + start, + HOUR, + ring(1, 0), + ); + observe_entries(&mut store, &chain, PurseId::MAIN); + + // The ring is full, but the entry is still inside its decorrelation delay, + // so the value is real and not yet spendable. + let held = store.balance(PurseId::MAIN, start).expect("purse exists"); + assert_eq!(held.spendable, Amount::ZERO); + assert_eq!(held.pending, Amount::from_cents(16)); + + let later = start.saturating_add(HOUR); + let ready = store.balance(PurseId::MAIN, later).expect("purse exists"); + assert_eq!(ready.spendable, Amount::from_cents(16)); + assert_eq!(ready.spendable_strict, Amount::from_cents(16)); + assert_eq!(ready.pending, Amount::ZERO); + + assert_eq!( + store.entry(PurseId::MAIN, index).expect("exists").local, + EntryLocalState::Available + ); +} + +#[test] +fn an_unload_runs_from_selection_through_to_a_submittable_extrinsic() { + let mut store = CoinageStore::new("Main".to_string()); + let mut chain = ScriptedChain::default(); + let now = Timestamp(2_000_000); + let purse = store.create_purse("Groceries".to_string()); + + // Two 16-cent entries in the same ring: one group, one unload token. + for _ in 0..2 { + top_up_entry( + &mut store, + &mut chain, + purse, + 4, + now, + Duration::ZERO, + ring(3, 5), + ); + } + observe_entries(&mut store, &chain, purse); + store.take_events(); + + // -- select ----------------------------------------------------------- + let (handle, plan) = store + .begin_operation(purse, OperationKind::Transfer, &any(20), &constants(), now) + .expect("32 cents are ready"); + + assert_eq!(plan.tier, SelectionTier::UnloadIntoCoins); + assert_eq!(plan.target_value(), Amount::from_cents(20)); + assert_eq!(plan.unloads.len(), 1); + assert_eq!(plan.unload_tokens_required(), 1); + + let group = &plan.unloads[0]; + assert_eq!(group.entries.len(), 2); + assert_eq!(group.ring, ring(3, 5)); + // The group's own change absorbs what the request does not need. + let produced: Amount = group + .target_outputs + .iter() + .chain(group.change_outputs.iter()) + .map(|exponent| exponent.value()) + .sum(); + assert_eq!(produced, Amount::from_cents(32)); + + // Selecting locked the entries, so the purse now reads as pending. + let locked = store.balance(purse, now).expect("purse exists"); + assert_eq!(locked.spendable, Amount::ZERO); + assert_eq!(locked.pending, Amount::from_cents(32)); + + // -- plan tokens and fee ---------------------------------------------- + let token_plan = resolve( + plan.unload_tokens_required(), + &chain.free_tokens(), + &PaidRingState::unavailable(TOKEN_PERIOD), + ¶ms(), + &constants(), + ) + .expect("a free slot is available"); + + assert_eq!( + token_plan.grants, + vec![TokenGrant::Free { + period: TOKEN_PERIOD, + counter: 0 + }] + ); + assert!( + token_plan.joins.is_empty(), + "a free slot covers it, so nothing is bought" + ); + + let fee_mode = choose_fee_mode(1_000_000, 5_000); + assert_eq!(fee_mode, FeeMode::Prepaid); + + // -- allocate destinations and build the call -------------------------- + let mut outputs = Vec::new(); + for output in group + .target_outputs + .iter() + .chain(group.change_outputs.iter()) + { + let index = store + .add_pending_coin(purse, *output) + .expect("purse exists"); + outputs.push(CoinOutput { + exponent: *output, + account: derivation::coin_account_id(&ENTROPY, purse, index) + .expect("derivation succeeds"), + }); + } + + let aliases: Vec<[u8; 32]> = group + .entries + .iter() + .map(|entry| derivation::entry_member_key(&ENTROPY, purse, *entry).expect("derives")) + .collect(); + + let args = UnloadRecyclerIntoCoinsArgs::new( + aliases.clone(), + group.exponent, + group.ring, + &outputs, + fee_mode.max_fee(5_000), + &constants(), + ) + .expect("the group balances"); + + assert_eq!(args.value, 4); + assert_eq!(args.index, 3); + assert_eq!(args.revision, 5); + assert_eq!(args.max_fee, 0, "prepaid unloads carry no fee ceiling"); + assert_eq!(args.split_into.output_count(), outputs.len()); + assert!(!args.encode().is_empty()); + + // Every destination account is distinct: the purse's index space never + // hands the same account out twice. + let distinct: BTreeSet = outputs.iter().map(|o| o.account).collect(); + assert_eq!(distinct.len(), outputs.len()); + + // -- build the extension ---------------------------------------------- + let info = AsCoinageInfo::FreeUnloadToken { + ring: FreeTokenRing::People, + proof: truapi_server::coinage::call::RawEncoded(vec![0xAB; 96]), + period: TOKEN_PERIOD, + counter: 0, + alias_proofs: aliases + .iter() + .map(|_| truapi_server::coinage::call::RawEncoded(vec![0xCD; 64])) + .collect(), + }; + assert_eq!(info.variant_name(), "AsUnloadTokenPeople"); + assert_eq!(info.alias_proofs().len(), 2); + let extra = info.encode_extra_with_index(1); + assert_eq!(&extra[..2], &[1u8, 1], "Some, then the variant index"); + + // -- submit and settle ------------------------------------------------- + submit(&mut store, handle, ExtrinsicHash([9; 32])); + assert_eq!( + store.operation(handle).expect("still open").status, + OperationStatus::Submitted + ); + assert!( + !store + .operation(handle) + .expect("still open") + .status + .is_cancellable(), + "an in-flight operation cannot be cancelled" + ); + + let consumed = plan.lock_set(purse); + store + .finish_operation(handle, OperationReceipt::default(), &consumed) + .expect("operation is open"); + + for entry in group.entries.iter() { + assert_eq!( + store.entry(purse, *entry).expect("exists").local, + EntryLocalState::Consumed, + "unloaded entries retire so their indices are never reused" + ); + } + assert!(store.operation(handle).is_none()); + + // -- observe the minted coins ------------------------------------------ + for coin in store.coins_in(purse) { + let account = derivation::coin_account_id(&ENTROPY, purse, coin.index).expect("derives"); + chain.credit_coin(account, CoinAge(0)); + // Read the age back out of the chain rather than restating it, so the + // observation path is driven by the fake and not by the assertion. + let age = chain + .age_of(&account) + .expect("the chain reports the account"); + store + .observe_coin(purse, coin.index, age) + .expect("coin exists"); + } + + // Value is conserved end to end: 32 cents in, 32 cents out. + let settled = store.balance(purse, now).expect("purse exists"); + assert_eq!(settled.spendable, Amount::from_cents(32)); + assert_eq!(settled.pending, Amount::ZERO); + + let events = store.take_events(); + assert!( + events + .iter() + .any(|event| matches!(event, LayerEvent::EntryConsumed { .. })) + ); + assert!( + events + .iter() + .any(|event| matches!(event, LayerEvent::OperationCompleted { .. })) + ); +} + +#[test] +fn a_purse_survives_persistence_and_resumes_its_in_flight_operation() { + let mut store = CoinageStore::new("Main".to_string()); + let mut chain = ScriptedChain::default(); + let now = Timestamp(3_000_000); + + top_up_entry( + &mut store, + &mut chain, + PurseId::MAIN, + 5, + now, + Duration::ZERO, + ring(1, 1), + ); + observe_entries(&mut store, &chain, PurseId::MAIN); + + let (handle, _) = store + .begin_operation( + PurseId::MAIN, + OperationKind::ExternalOffload, + &any(20), + &constants(), + now, + ) + .expect("32 cents are ready"); + submit(&mut store, handle, ExtrinsicHash([1; 32])); + + // The host writes the store out and the process dies. + let encoded = store.encode(); + let mut restored = + CoinageStore::decode(&mut &encoded[..]).expect("the store round-trips through SCALE"); + + // An operation that broadcast is handed back for reconciliation rather than + // being failed: the chain may well have accepted it. + let pending = restored.reconcile_after_restart(); + assert_eq!(pending, vec![handle]); + assert_eq!( + restored + .operation(handle) + .expect("still open") + .log + .submitted_hashes(), + vec![ExtrinsicHash([1; 32])] + ); + assert_eq!( + restored.take_events().last(), + Some(&LayerEvent::Resynced), + "Resynced closes reconstruction so later events read as live" + ); +} + +#[test] +fn a_restart_while_preparing_releases_the_records_it_held() { + let mut store = CoinageStore::new("Main".to_string()); + let mut chain = ScriptedChain::default(); + let now = Timestamp(4_000_000); + + let index = top_up_entry( + &mut store, + &mut chain, + PurseId::MAIN, + 5, + now, + Duration::ZERO, + ring(1, 1), + ); + observe_entries(&mut store, &chain, PurseId::MAIN); + + let (handle, _) = store + .begin_operation( + PurseId::MAIN, + OperationKind::Transfer, + &any(32), + &constants(), + now, + ) + .expect("32 cents are ready"); + + // Nothing was broadcast, so pre-submission scratch state is worthless and + // the restart is equivalent to a cancel. + let pending = store.reconcile_after_restart(); + + assert!(pending.is_empty()); + assert!(store.operation(handle).is_none()); + assert_eq!( + store.entry(PurseId::MAIN, index).expect("exists").local, + EntryLocalState::Available, + "the entry is selectable again" + ); + assert_eq!( + store + .balance(PurseId::MAIN, now) + .expect("purse exists") + .spendable, + Amount::from_cents(32) + ); +} + +#[test] +fn an_entry_approaching_ring_expiry_is_flagged_for_rescue() { + // The failure this guards against is the only way value can vanish from a + // wallet whose entropy and chain identity are intact: an entry whose ring + // is cleaned up before it is ever unloaded. Recycling coins into entries + // without unloading entries back out destroys funds silently. + let mut store = CoinageStore::new("Main".to_string()); + let mut chain = ScriptedChain::default(); + let immutable_since = Timestamp(5_000_000); + + let index = top_up_entry( + &mut store, + &mut chain, + PurseId::MAIN, + 4, + immutable_since, + Duration::ZERO, + ring(2, 0), + ); + observe_entries(&mut store, &chain, PurseId::MAIN); + + // The chain reports the ring as immutable; without this observation the + // sweep has no deadline to race and would silently never fire. + store + .observe_entry_ring_immutability(PurseId::MAIN, index, Some(immutable_since)) + .expect("entry exists"); + + let expiration = constants().recycler_expiration_time; + let margin = params().rescue_margin(expiration); + let entry = *store.entry(PurseId::MAIN, index).expect("exists"); + + let deadline = immutable_since.saturating_add(expiration); + let trigger = deadline.saturating_sub(margin); + + assert!( + !entry.needs_rescue(trigger.saturating_sub(DAY), expiration, margin), + "a day before the margin there is nothing to do" + ); + assert!( + entry.needs_rescue(trigger, expiration, margin), + "at the margin the sweep must act" + ); + // 90 days expiry, 25% margin: roughly 22 days of slack. + assert!(margin >= DAY * 22); +} + +#[test] +fn a_purse_cannot_be_closed_while_it_holds_records_for_an_operation() { + let mut store = CoinageStore::new("Main".to_string()); + let mut chain = ScriptedChain::default(); + let now = Timestamp(6_000_000); + let purse = store.create_purse("Groceries".to_string()); + + top_up_entry( + &mut store, + &mut chain, + purse, + 4, + now, + Duration::ZERO, + ring(1, 0), + ); + observe_entries(&mut store, &chain, purse); + + let (handle, _) = store + .begin_operation(purse, OperationKind::Transfer, &any(16), &constants(), now) + .expect("16 cents are ready"); + + assert_eq!( + store.close_purse(purse, PurseId::MAIN, Amount::ZERO), + Err(CoinageError::PurseHasInFlightOperations) + ); + + store + .fail_operation(handle, CoinageError::Cancelled) + .expect("operation is open"); + store + .close_purse(purse, PurseId::MAIN, Amount::from_cents(16)) + .expect("nothing is in flight now"); + + // The identifier is not handed out again: it names a derivation namespace, + // and reuse would let a new purse inherit the closed purse's history. + let next = store.create_purse("Rent".to_string()); + assert_ne!(next, purse); +} + +#[test] +fn waiting_on_a_ripening_entry_reads_differently_from_being_broke() { + let mut store = CoinageStore::new("Main".to_string()); + let mut chain = ScriptedChain::default(); + let now = Timestamp(7_000_000); + + top_up_entry( + &mut store, + &mut chain, + PurseId::MAIN, + 4, + now, + HOUR, + ring(1, 0), + ); + observe_entries(&mut store, &chain, PurseId::MAIN); + + // The value exists but is still ripening, so the caller is told to retry. + assert!(matches!( + store.begin_operation( + PurseId::MAIN, + OperationKind::Transfer, + &any(16), + &constants(), + now + ), + Err(CoinageError::NoReadyEntries { .. }) + )); + + // Ask for more than the purse will ever hold and it is a dead end instead. + assert!(matches!( + store.begin_operation( + PurseId::MAIN, + OperationKind::Transfer, + &any(4_096), + &constants(), + now + ), + Err(CoinageError::InsufficientFunds { .. }) + )); +} + +#[test] +fn a_degraded_ring_is_refused_unless_the_caller_accepts_weaker_anonymity() { + let mut store = CoinageStore::new("Main".to_string()); + let mut chain = ScriptedChain::default(); + let now = Timestamp(8_000_000); + + let index = store + .allocate_entry(PurseId::MAIN, exponent(4), now, Duration::ZERO) + .expect("purse exists"); + let member_key = derivation::entry_member_key(&ENTROPY, PurseId::MAIN, index).expect("derives"); + // Three members is well below the anonymity floor of ten. + chain.load_entry(member_key, ring(1, 0), 3); + observe_entries(&mut store, &chain, PurseId::MAIN); + + let balance = store.balance(PurseId::MAIN, now).expect("purse exists"); + assert_eq!(balance.spendable, Amount::from_cents(16)); + assert_eq!( + balance.spendable_strict, + Amount::ZERO, + "the strict figure excludes value sitting in a thin ring" + ); + + let strict = SelectionRequest { + allow_degraded: false, + ..any(16) + }; + assert!(matches!( + store.begin_operation( + PurseId::MAIN, + OperationKind::Transfer, + &strict, + &constants(), + now + ), + Err(CoinageError::NoReadyEntries { .. }) + )); + + let permissive = SelectionRequest { + allow_degraded: true, + ..any(16) + }; + let (_, plan) = store + .begin_operation( + PurseId::MAIN, + OperationKind::Transfer, + &permissive, + &constants(), + now, + ) + .expect("degraded entries are allowed here"); + assert_eq!(plan.tier, SelectionTier::UnloadIntoCoins); +} + +#[test] +fn an_aging_coin_is_offered_for_recycling_before_the_chain_rejects_it() { + let mut store = CoinageStore::new("Main".to_string()); + + let young = store + .add_pending_coin(PurseId::MAIN, exponent(4)) + .expect("purse exists"); + let old = store + .add_pending_coin(PurseId::MAIN, exponent(4)) + .expect("purse exists"); + store + .observe_coin(PurseId::MAIN, young, CoinAge(2)) + .expect("coin exists"); + store + .observe_coin(PurseId::MAIN, old, CoinAge(14)) + .expect("coin exists"); + + let due = + store.coins_needing_recycling(PurseId::MAIN, constants().recycle_at_age(), Timestamp(0)); + + assert_eq!(due, vec![old]); + // Two transfers of headroom remain below the chain's cap of 16. + let record = store.coin(PurseId::MAIN, old).expect("exists"); + assert!(record.is_usable(constants().maximum_age)); + assert_eq!(record.state, CoinState::Available); +} + +#[test] +fn indices_are_never_reused_across_a_purse_lifetime() { + let mut store = CoinageStore::new("Main".to_string()); + let purse = store.create_purse("Groceries".to_string()); + let mut seen = BTreeSet::new(); + + for _ in 0..8 { + let index = store + .add_pending_coin(purse, exponent(2)) + .expect("purse exists"); + assert!(seen.insert(index), "coin index handed out twice"); + + let account = derivation::coin_account_id(&ENTROPY, purse, index).expect("derives"); + // Distinct indices must yield distinct accounts, or the no-reuse + // invariant buys nothing. + assert_ne!( + account, + derivation::coin_account_id(&ENTROPY, purse, CoinIndex(999)).expect("derives") + ); + } + + assert_eq!( + store.purse(purse).expect("exists").next_coin_index, + CoinIndex(8) + ); +} + +#[test] +fn the_three_streams_tell_one_story_over_an_operations_lifetime() { + // §8.9 and §7.2 composed: a subscriber watching events, a purse balance and + // an operation status must never see the three disagree about what happened. + let mut store = CoinageStore::new("Main".to_string()); + let mut chain = ScriptedChain::default(); + let now = Timestamp(7_000_000); + let hub = CoinageSubscriptions::new(); + + let mut events = hub.subscribe_events(); + let mut balances = hub + .subscribe_purse_balance(&store, PurseId::MAIN, now) + .expect("the main purse exists"); + assert_eq!( + block_on(balances.next()) + .expect("an item at subscribe time") + .spendable, + Amount::ZERO, + "an empty purse still opens its stream with a value" + ); + + // -- top up ------------------------------------------------------------ + let entry = top_up_entry( + &mut store, + &mut chain, + PurseId::MAIN, + 5, + now, + Duration::ZERO, + ring(2, 0), + ); + observe_entries(&mut store, &chain, PurseId::MAIN); + publish(&hub, &mut store, now); + + assert_eq!( + block_on(balances.next()) + .expect("the entry is ready") + .spendable, + Amount::from_cents(32) + ); + + // -- start an operation ------------------------------------------------ + let (handle, plan) = store + .begin_operation( + PurseId::MAIN, + OperationKind::Transfer, + &any(20), + &constants(), + now, + ) + .expect("32 cents are ready"); + publish(&hub, &mut store, now); + + // The lock is visible as pending value before any status is subscribed to. + let locked = block_on(balances.next()).expect("selection locked the entry"); + assert_eq!(locked.spendable, Amount::ZERO); + assert_eq!(locked.pending, Amount::from_cents(32)); + + let mut statuses = hub + .subscribe_operation_status(&store, handle) + .expect("the operation is open"); + assert_eq!(block_on(statuses.next()), Some(OperationStatus::Preparing)); + + // -- submit and settle ------------------------------------------------- + submit(&mut store, handle, ExtrinsicHash([4; 32])); + publish(&hub, &mut store, now); + assert_eq!(block_on(statuses.next()), Some(OperationStatus::Submitted)); + + store + .finish_operation( + handle, + OperationReceipt::default(), + &plan.lock_set(PurseId::MAIN), + ) + .expect("operation is open"); + publish(&hub, &mut store, now); + + assert_eq!( + block_on(statuses.next()), + Some(OperationStatus::Done(OperationReceipt::default())) + ); + assert_eq!( + block_on(statuses.next()), + None, + "the terminal item closes the status stream" + ); + + // The unloaded entry took its value with it, and no output coin was minted + // in this scenario, so the purse reads empty on both counts. + let settled = block_on(balances.next()).expect("the entry retired"); + assert_eq!(settled.spendable, Amount::ZERO); + assert_eq!(settled.pending, Amount::ZERO); + assert_eq!( + store.entry(PurseId::MAIN, entry).expect("exists").local, + EntryLocalState::Consumed + ); + + // The event stream carries the same story, in order, and outlives the + // operation whose status stream has already closed. + let published: Vec = + core::iter::from_fn(|| events.next().now_or_never().flatten()).collect(); + let consumed_at = published + .iter() + .position(|event| matches!(event, LayerEvent::EntryConsumed { .. })) + .expect("the entry was consumed"); + let completed_at = published + .iter() + .position(|event| matches!(event, LayerEvent::OperationCompleted { .. })) + .expect("the operation completed"); + assert!( + consumed_at < completed_at, + "the record retires before the operation that held it reports done" + ); +}