feat(server): coinage base layer (RFC-17 layer 1) - #344
Draft
TorstenStueber wants to merge 30 commits into
Draft
Conversation
Introduce the pure domain model of the coinage layer under host_logic::coinage: purses, coins, recycler entries, operations, events, and the three-tier selection that turns a requested amount into a plan. The layer does no chain access, signing, persistence, or clock reads. Wall-clock instants and jitter draws are supplied by the caller, so behaviour is reproducible under test. Chain-facing orchestration will live in runtime::coinage. Selection fixes record ordering before any strategy runs -- coins by (exponent desc, age desc, index asc), entries by (exponent desc, ring asc, index asc) -- so two conformant implementations with the same purse contents choose the same records. Strategies run in priority order: exact match, split, unload into coins. A request carries an OutputRequirement. Export and rebalance take any denominations totalling the amount; transfer names them, because each output is destined for a separately named recipient account. That distinction affects the exact-match tier only, since the later tiers mint denominations rather than reuse them. Failure is classified by what the caller should do about it: InsufficientFunds when no amount of waiting would cover the request, NoReadyEntries when ripening entries would, and UnsatisfiableOutputs when the value is present but unarrangeable -- coinage divides a coin and never merges two, so two 8-cent coins cannot form a 16-cent output. Amounts are u64 cents internally with a fallible narrowing to the u32 wire type, keeping headroom until the chain's MaximumExponent is confirmed. Follows the coinage-layer design in #122.
Add CoinageStore, the aggregate owning purses, coins, recycler entries and operations. Individual records police their own lifecycles; the store enforces what spans them -- a locked record belongs to a live operation, two operations never hold the same record, a derivation index is handed out once, and every purse a record names exists. begin_operation selects and locks in a single step. That is what makes the design's guarantee that two concurrent selections never disagree about availability structural rather than aspirational: there is no window in which a chosen record is observable but unheld. apply_locks checks the whole set before mutating, so a conflict cannot leave the store half-locked with no owner for the difference. Purse identifiers are monotonic and never reclaimed. A purse id names a derivation namespace, so reuse would let a new purse inherit the on-chain history of a closed one. Non-reuse is also what makes close_purse safe to drop the purse record together with its index counters. Terminal operations are dropped once their status is emitted, per the design. Retaining them would grow the durable store without bound and keep a permanent trail of extrinsic hashes linking coins to on-chain activity -- the correlation the recycler exists to break. The receipt leaves on the event instead, which imposes an ordering the module documents: drain and publish events before persisting, so a crash costs a duplicate event rather than a lost receipt. finish_operation takes the subset of locks the chain actually spent, so a partial receipt is representable instead of assuming everything held was used. The store is pure and SCALE-serializable; the caller owns persistence. Coin and RecyclerEntry become Copy so snapshots can be handed to selection without allocation.
Reconcile the domain layer against the actual pallet in paritytech/individuality and the next-people-paseo runtime configuration, rather than the design document's approximations. Denomination exponents become i8 to match the pallet's CoinValue. Negative exponents are rejected on construction: Amount counts whole cents, so a sub-cent denomination has no representation here, and a runtime that ever ships one now fails loudly instead of producing a truncated balance. The arithmetic ceiling is a code limit only -- the operative bound is the chain's MaximumExponent, which the reference runtime sets to 14, capping a single coin at 16384 cents. Split chain-enforced limits out of CoinageParameters into a new CoinageChainConstants. Exceeding one of those makes an extrinsic invalid, which is a different kind of fact from a tunable, and the params module already claimed to hold policy only. Two values were wrong against the runtime: MaxConsolidation is 64, not 8, and MaxFreeUnloadTokensPerTimePeriod is 1000, so the free-token search range of 10 is conservative rather than matching the chain allowance. Constants are validated once, so an unsupported runtime is refused at connection time. canonical_breakdown is now bounded by the largest denomination the chain mints. An amount needing a bigger coin has no breakdown and says so, instead of yielding outputs the pallet would reject. Recycler entries carry a RingLocation of index plus revision. unload_recycler_into_coins takes both, and a membership proof built against one revision does not verify against another, so grouping keys on the full location. Selection turns out to be policy-free and no longer takes CoinageParameters. The anonymity floor is applied when a ring is observed, so an entry's readiness already encodes it by the time selection runs, and the only limits left to respect are the chain's.
Two offline pieces of the chain layer, both against shapes read from
pallet-coinage rather than inferred.
Derivation implements the scheme from the coinage-layer design's appendix B:
coins at //coinage//coin//<purse>//<page>//<index> as sr25519, recycler
entries at //coinage//<purse>//<page>//<index> as bandersnatch. Splitting by
key type lets recovery enumerate each subtree independently, coins against
CoinsByOwner and entries against recycler-location storage.
Every junction is hard, and that is a security requirement. sr25519 soft
derivation is invertible from the child side, and coinage hands out coin
secrets by design -- that is what a cheque is -- so a soft junction anywhere
on the coin path would mean cashing one coin exposes the purse root and with
it every other coin in the purse. Salting a soft segment does not help. The
reasoning is recorded in the module so it is not optimized away later.
Entry keys fold into RFC-0022's ring-VRF tree using its keyed-hash chain
unchanged, under a reserved `coinage` domain. RFC-0022 shapes that tree as
//{domain}//{index} with the domain always a product's dotNS identifier,
which coinage cannot satisfy; it says so and defers coinage to its own RFC.
A test pins the root to RFC-0022's derivation so the tree cannot quietly
fork.
Call construction mirrors split, transfer, load_recycler_with_coin and
unload_recycler_into_coins as the pallet declares them. Destinations group
under each denomination, matching the pallet's nesting, which is how a
transfer sends two equal outputs to two different recipients. Pallet and
call indices are absent by design: they are resolved by name from live
metadata so a re-indexed runtime fails loudly.
Two pallet constraints are enforced before submission rather than
discovered on chain -- the split-output cap and conservation of value.
The extension consumes the origin coin before dispatch, so a rejected split
costs the coin outright.
RawEncoded carries pre-encoded ring-VRF proofs and bandersnatch signatures
verbatim. Those are runtime-specific types this crate does not model, and
they must reach the extrinsic byte for byte, so the wrapper omits the length
prefix a Vec<u8> would add.
runtime::coinage is native-only, following the statement_allowance
precedent: coinage needs key material, so a seedless pairing host can never
perform it locally.
Mirror all six AsCoinageInfo variants and build the extension's extra. The extension transmutes a transaction's origin into Origin::Coin or Origin::UnloadToken, consuming the coin or the unload token before dispatch. That ordering is why this layer validates so much locally: once the extension has run, a call that fails has already cost the coin. Variant indices are resolved from metadata by name. Add a generic Metadata::extension_info_variant_index that walks extension, extra type, Option, Some field and info enum, reusing the registry helpers already present for AsResources. SCALE variant indices are positional, so a runtime that reorders the enum must fail loudly rather than silently select a different origin mode. AsUnloadTokenPeople and AsUnloadTokenLitePeople carry identical payloads and collapse into one FreeUnloadToken variant parameterized by which membership ring backs the token; the ring index and revision pairs become RingLocation, matching the domain layer. Signing contexts are pinned as constants taken from the pallet: `pop:polkadot.net/coinftk` for the free-token personhood proof, whose context appends the period and counter as little-endian u32s, and `pop:polkadot.network/coinrecyclr` for the recycler alias. The differing domains are the pallet's own. The two proof messages are distinct and both tested for what they bind. A free token signs blake2_256(alias_proofs.encode() ++ inherited_implication), putting the alias set inside the signed message so a token cannot be replayed against a different set of entries. An individual alias proof signs blake2_256(inherited_implication). Ring-VRF proofs and bandersnatch signatures are spliced verbatim through RawEncoded; a Vec of them yields a compact length followed by unprefixed blobs, which is the alias-proof layout and the place a stray length prefix would corrupt the extrinsic silently. Only the InfallibleUnpaidSigned shape has been accepted by a chain so far, and its golden test is anchored to that known-good byte layout. The five unload variants are encoded from the pallet source and await first submission for confirmation.
Every unload of a recycler entry consumes exactly one token, so a plan that unloads three groups needs three. Free slots come from a per-period personhood allowance; paid tokens come from a period ring anyone may join for a fee. The caller chooses neither. Free slots are spent first because they cost nothing and expire unused at the end of their period, then paid tokens cover any shortfall, joining the ring first when the user is not yet a member. NoUnloadToken is reserved for the case where neither class can cover it, which is the difference between waiting for the next period and a wallet that cannot unload at all. Slot choice is deterministic -- periods in preference order, then ascending counter -- so two conformant implementations spend the same slots. The probe window is clamped to the runtime's MaxFreeUnloadTokensPerTimePeriod, since probing past it could only ever find slots the chain refuses. Fee mode is prepaid whenever the fee account can cover the fee, because taking the fee from the output shrinks the unloaded value and forces a different denomination breakdown. The pallet requires max_fee to be zero under Prepaid, which the mode now derives rather than leaving to the caller. Both pieces are pure: they decide from a snapshot of what the chain reports, while fetching it, proving membership and joining the ring stay in the chain layer. Add CoreStorageKey::CoinageState for the layer's durable record store -- one slot for the whole store, so a write is atomic and the host needs no key enumeration. Appended rather than inserted: the key reaches hosts SCALE encoded, so its discriminant is persisted state, and a deployed host has already written the existing slots. The wasm bridge passes the key as opaque bytes, so no generated output changes.
Working notes gathered while implementing the coinage base layer. Every item is either a correction to an existing document or a decision that needs a permanent home. Intended to be folded into a new coinage RFC and the implementation PR description, then deleted. Placed in docs/issue-drafts/ alongside bulletin-preimage-in-core.md, which is the existing precedent for pre-RFC working material. Not in docs/rfcs/, so it does not engage the RFC validation gate. Records the runtime constants nobody had written down, the RFC-0017 amendments the implementation needs (an unsafe derivation appendix, a missing permission variant, disagreeing balance units, an unspecified cheque encryption scheme), the design document's two stale appendix values and the policy-versus-chain-constant split they belong on either side of, RFC-0022's deferral of coinage and what to reuse from it, and three follow-ups that are not document changes but should not be lost -- chief among them an unfiled loss-of-funds bug in the shipping iOS app.
The per-module unit tests 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 and fee mode, build the extrinsic and its extension, submit, settle, reconcile -- and asserts the invariants that only appear once the pieces are composed. No platform is mocked because the base layer needs none: it is pure, and chain facts arrive as observations. ScriptedChain holds what a node would report so a scenario can advance it deliberately, and is deliberately dumb so a passing test cannot be passing because the fake agreed with the code about something it should not know. The scenarios cover value conservation from selection through settlement, jitter delaying spendability of a ready ring, the strict balance excluding value in a thin ring and selection refusing it unless the caller opts in, restart both mid-flight and while preparing, purse closure blocked by held records and identifiers not being reissued afterwards, the distinction between waiting on a ripening entry and being unable to pay, and index non-reuse producing distinct accounts. One scenario guards the failure mode that motivates the rescue sweep: an entry whose ring is cleaned up before it is ever unloaded loses its backing value silently, which is the only way funds can vanish from a wallet whose entropy and chain identity are intact. Driving the crate's public API from tests/ also confirms the surface is usable from outside the crate, which the in-module tests cannot show.
…ntime Add an example that asks the questions only a node can answer, read-only: whether the constants we hard-code as the reference runtime match what the chain reports, whether the coinage calls exist under the names we resolve them by, whether the AsCoinage extension exists and where its variants sit, and whether our derivation finds coins the chain holds. Run against paseo-people-next it confirms a good deal. Coinage is pallet 68 with split 0, transfer 1, load_recycler_with_coin 2 and unload_recycler_into_coins 13, all resolvable by name. All six AsCoinageInfo variants exist at indices 0 through 5 in declaration order, and InfallibleUnpaidSigned lands at 5 — matching the byte layout the CLI host already submits successfully, which independently confirms the ordering the encoder assumes for the five variants no chain has yet accepted. MaxConsolidation is 64 and MaxFreeUnloadTokensPerTimePeriod is 1000, confirming both stale design-document appendix values. It also found something. MaximumAge and RecyclerExpirationTime are absent from metadata entirely: the former is declared without #[pallet::constant], the latter carries the attribute in the pallet source but not in the deployed runtime. Both must therefore be carried as per-network configuration, and they are precisely 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 at connection time; these two it must be told. Recorded on the fields themselves so the constraint travels with the type. Finding that required fixing the example first: it initially reported both as zero rather than absent, conflating a missing constant with a zero one and hiding the more interesting answer. An example rather than a test, so it is linted by --all-targets but never run by CI, which must not depend on the network.
An unload presents two kinds of proof and they are easy to conflate, so this module keeps them apart deliberately. Alias proofs are per entry. Each proves recycler-ring membership and yields the entry's contextual alias in the `pop:polkadot.network/coinrecyclr` context, which the call carries in its `aliases` argument. The proof and the alias fall out of the same operation, so entry_membership_proof returns them together rather than letting a caller pair up mismatched halves; aliases_of and alias_proofs_of then project the two positional orderings the call and the extension expect. The token proof is per extrinsic, over a different ring with a different key: the user's personhood member key in the People or LitePeople ring, not any recycler entry. A test hands the recycler ring to the token prover and asserts it fails, because that confusion is the likeliest mistake here and would otherwise surface as an inscrutable runtime rejection. recycler_alias derives an alias without proving anything, which is what lets a balance scan locate an entry's on-chain records without ring-VRF work. A test pins it equal to the alias a proof produces -- if those ever diverged, a scan could not see what an unload had spent. Ring membership is an input. Fetching a ring at a pinned block belongs to the chain layer; proving is deterministic given the members, so this stays testable offline against a synthetic ring.
The layer's local records are a projection of chain state, so observation is what keeps them true. Add the storage keys, the value decoding and the apply step; issuing the reads stays with the caller, which keeps every byte-layout decision in one unit-testable place. Storage keys are pinned by golden assertions. A hasher quietly changed makes a query return nothing rather than fail, which a wallet renders as "you have no coins" -- the most dangerous failure available to it. Recycler collections are segregated by denomination, with the exponent in the byte after the `coinage/recycler` prefix, so each denomination's rings are a separate membership collection. Two disagreements are refused rather than absorbed. A coin whose on-chain denomination differs from the local record means derivation or the record is wrong, and overwriting would silently corrupt the balance. An observation for a record the layer does not track means the caller derived something it should not have. An entry losing its chain location does not retire the record: an entry can lose it because it was unloaded, but equally because a load has not finalized, and only the owning operation can tell those apart. Same reasoning leaves an emptied coin account alone. Guessing either would race the operation that knows. Adds CoinageStore::observe_entry_missing for that path, emitting a readiness event only on an actual change. The chain-agreement example now shares the real key builder instead of carrying its own copy, so the keys it exercises against the live chain are the ones the layer uses.
A coinage extrinsic 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 doing so, and the proofs inside the extension are what authorize it. That imposes an ordering the caller must respect, which is why the implication is exposed rather than hidden inside assembly: build the call, compute the inherited implication, prove against it, encode the extra with those proofs, then assemble. Steps two and three cannot be swapped — a proof built before the call is known signs the wrong thing and the runtime rejects it without saying why. Add two generic helpers to the statement-allowance metadata type rather than coinage-specific ones: extension_index resolves an extension's position by identifier, and inherited_implication returns the signed bytes unhashed. Unhashed matters because the two coinage proof kinds hash different things — an alias proof signs blake2_256(implication), a free unload token signs blake2_256(alias_proofs ++ implication). Dispatch indices resolve by name, and a test asserts an unknown call name fails rather than falling through to anything hard-coded. Assembly is testable offline against the committed paseo-next-v2 metadata fixture, which does carry the Coinage pallet and the AsCoinage extension. An earlier draft guarded each test on that and reported a vacuous pass when the guard tripped; the guard is now a single assertion, so a fixture regenerated without coinage fails loudly instead of turning the suite into no-ops.
A handoff of where the work stands: what is built across the two module trees, what is not, the known gaps and hazards, and the commit history. Becomes the PR description. Two things in it are worth more than the inventory. First, an honest accounting of scope: the base layer's machinery exists but its §8 primitive API does not, the export/import seam that defines the layer boundary is unbuilt, and the RFC-17 product surface has not been started -- so measured against implementing RFC-17 this is foundation, not delivery. Second, a correction to the plan. Build the CLI surface or a thin driver before any further layers. Everything so far is verified offline and the next increment is the first that moves value; without a live feedback loop it would be unverified code stacked on unverified code. The faucet path is the place to start, since it exercises the whole submission stack using InfallibleUnpaidSigned -- the only extension variant a chain has accepted.
The golden was captured before `CoreStorageKey::CoinageState` was added, so `cargo test --workspace` has been red on this branch since that variant landed. Regenerated; no emitter change.
The layer had a design document, a separate durability design, and a pile of corrections in working notes. This folds them into one normative spec and says so: where it conflicts with another document, it wins. The substantive addition is durability (§7). Coinage local state is not a projection of chain state — a coin sent over an offchain channel is in use locally while vacant on chain, and an empty coin account means either "spent" or "not landed yet". So the layer keeps a write-ahead log, one entry per on-chain transaction, and resolves anything it loses track of against finalized state rather than guessing. Three distinctions are made normative because confusing any of them loses money: optimistic versus definite inclusion, not-included versus unknown, and rejected versus abandoned. Transaction ordering (§7.5) extends the source design: resolving a dependency chain out of order gives wrong answers, since an absent output is consistent both with "the predecessor never landed" and with "it landed and this transaction consumed it". Mortality becomes a requirement rather than a fee choice (Appendix A.14): it is the only thing that lets recovery declare a lost transaction dead, so an immortal extrinsic's inputs could never safely return to the pool. Also folds in the corrections addressed to this document — MaxConsolidation is 64 not 8, chain constants split from policy tunables, signed exponents, ring location as index plus revision, UnsatisfiableOutputs as a third selection failure — and records what is still open above the seam.
Assembly existed; nothing carried the bytes to a node or read back what happened. Adds the pipeline: dry-run through TaggedTransactionQueue_validate_transaction, submit and watch, then classify the dispatch outcome from the inclusion block's System.Events. Inclusion is not success. A coinage call that lands and then fails to dispatch produces a block hash indistinguishable from a successful one until its events are read, so the event read is not optional. The result is three-valued by construction rather than a Result, because all three arms need different handling and collapsing "unknown" into either neighbour is the most expensive mistake available here — assuming success retires records the chain still holds, assuming failure releases records it is about to consume. Only `invalid` and `dropped` mean nothing was included; `retracted`, `usurped` and timeouts are unknown. Inclusion is graded too: a verdict read at a non-finalized block is optimistic and may drive UI, but may not settle anything, so the watch now reports whether the reporting block was final. Dry-run rejections are named from a pinned InvalidTransaction table. A runtime API's return type is absent from the metadata registry, so nothing on chain describes that enum; unknown discriminants report by number rather than being guessed at, and Custom(n) is preserved because that is how the pallet's own extension rejections arrive. Carries task item B3, whose changes live entirely inside these two files.
Reading pallet-coinage's post_dispatch_details turned up an asymmetry the layer did not know about. A failed dispatch does not cost the same thing in every flow: a coin origin is restored but held under a LockedCoins entry for 2^retries times CoinFailureLockPeriod, an output-token alias is restored the same way, and a free or paid unload token is simply gone. The consequence is sharper than it looks. LockedCoins is checked in the extension's validate, so a coin reselected inside its lock produces an extrinsic the runtime refuses — after a fresh unload token has already been spent building it. Each retry doubles the wait, so a naive loop converges on burning one token per attempt. Coins therefore carry a chain-side lock expiry orthogonal to their local lifecycle: a coin can be locally available and still refused. Selection excludes a locked coin, and the failure classifies as "wait" rather than "insufficient funds", because the value is intact and returns without user action. CoinFailureLockPeriod is #[pallet::constant] and confirmed at 60s against the live runtime, so unlike MaximumAge and RecyclerExpirationTime it needs no configuration. The agreement example checks it.
CheckMortality was hardcoded to Era::Immortal and ChainState carried no block anchor to build a mortal era from. That is a correctness problem, not a fee one. Recovery decides that a transaction it lost track of is dead by watching the finalized height pass the era's end. An immortal extrinsic never reaches such a point, so its inputs could never safely return to the spendable pool — it can still land after they have been respent. Mortality is opt-in on ChainState. Allowance registration has always used immortal extrinsics and has no recovery procedure that needs an expiry, so it keeps them; coinage requires the anchor and its assembly refuses a chain state without one. Enforced at build_unsigned_extrinsic because that is the single point every coinage extrinsic passes through. The era period is 256 blocks, roughly 25 minutes at six-second blocks: long enough to survive a socket drop or a backgrounded host, short enough that a vanished transaction does not strand its inputs for hours. Encoding is golden-tested against Substrate's known Era::Mortal(64, 61) = d5 03, because a wrong nibble layout yields a valid-looking era with the wrong lifetime.
CoreStorageKey::CoinageState was declared in truapi-platform and referenced nowhere, so nothing in the layer survived a restart. One slot for the whole store 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. The cost is re-encoding everything on each mutation, fine at testnet purse sizes. A corrupt slot is fatal rather than falling back to an empty store. A fresh store would re-derive from index zero and hand out account identifiers already on chain, breaking the no-reuse invariant; losing records is recoverable by scanning, reusing an index is not. There is no bare persist. Events must be published before the store is written, because a terminal operation drops its record as soon as its status is emitted and the receipt then exists only in the event — persisting first loses both if the process dies in between, while publishing first degrades to a duplicate event. publish_and_persist takes the publisher as an argument, which makes the safe order the only reachable one rather than a rule in a comment.
Adds the step that has to happen before any operation is accepted: read the runtime's chain-enforced limits from metadata, validate them, derive the fee account, and load the store. 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 also observable, disagreement is fatal rather than reconciled silently, because both of those constants drive a sweep whose job is to beat a chain deadline. The fee account derives at //coinage//fee, outside the purse junction. 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 would strand it when that purse is deleted. CoinageLayer holds root entropy, so its Debug rendering deliberately omits both the entropy and the records.
One entry per on-chain transaction, not per logical operation: an operation that unloads two recycler groups and then transfers the result has three entries. Replaces the flat list of submitted hashes, which could not answer what a transaction was for. Each entry is written before its transaction is broadcast and carries what is needed to decide its fate later without having seen any of it happen — inputs, expected outputs, and the era it was anchored in. It records purse-scoped indices rather than accounts, so the durable store does not spell out the input-to-output linkage the recycler anonymity set exists to break. Two ordering rules follow from dependencies between entries. A transaction may not be broadcast until every entry it depends on has *definitely* succeeded — optimistic inclusion is not enough, because a reorg would leave it spending outputs that never existed. And resolution must run in dependency order, because an absent output is consistent both with "the predecessor never landed" and with "it landed and this transaction consumed it"; only the predecessor's verdict separates them. A failed predecessor cascades transitively to Abandoned. The receipt becomes a projection of the log rather than a separately maintained summary, so the two cannot disagree, and gains an Abandoned outcome plus an optional hash for transactions never broadcast. Carries task item B2, whose changes live inside these same two modules.
The slow, guaranteed half of the tracking pair. Best-effort watching handles the common case; anything it cannot settle comes here, and this decides from finalized state alone — needing neither the transaction's hash nor its events, so it works after a crash in which the layer saw neither. Three questions per entry, in this order: are the outputs on chain, were the inputs consumed, has the era expired. Order matters. A transfer's outputs belong to the recipient and will never be visible to us, so consumed inputs are the only evidence it landed; and expiry must be checked last, because a transaction can land inside its era and only be observed afterwards, so testing expiry first would declare a landed transaction dead and release inputs the chain has already spent. The decision is pure and separately testable; the driver supplies chain reads pinned to one finalized block, since a decision taken at the best block could be describing a fork about to disappear. Applying a resolution differs per case in the way that matters. Succeeded retires the inputs. Rejected returns them to the pool and retires the outputs that never came to exist — their indices stay consumed, since an account the layer committed to must not be reissued. Abandoned reverts nothing at all: its inputs were a predecessor's outputs, and the predecessor's own rejection already retired them exactly once.
The entry-side half of the asymmetry recorded for coins. 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. Entries therefore carry an alias lock expiry orthogonal to their local lifecycle, and selection excludes an alias-locked entry, so the layer stops reoffering a record the runtime would reject. Also gives entries the ring's immutability timestamp. needs_rescue now reads it from the record instead of taking it as a parameter: the parameter form is exactly how the value went missing, since no caller had a source for it. Note the residual shape — an entry whose ring immutability was never observed has no deadline and declines silently, which is correct for a ring still accepting members and indistinguishable from one the layer never read.
apply_observations could consume observations; nothing produced them. Adds the driver that issues the reads and assembles them, pinned to one block for the whole purse — a refresh split across a block boundary would produce a view that never existed, and selection would then plan against it. Six storage sets, two of which needed tracking down. The coinage pallet's own RecyclersCoinToRecycler reports only which denomination collection an entry belongs to, never which ring, so the ring index comes from the Members pallet's member lookup. The revision comes from that ring's root, decoded through the metadata registry rather than by byte offset: the root is a bandersnatch ring commitment whose size is a property of the curve, and a hard-coded offset would read a neighbouring field the day it changes. Fixes a decode that was silently dropping data. RingStatus has three fields, not two; the missing one is immutable_since, which is the rescue sweep's only warning that a ring is about to be cleaned up and destroy the value of anything left in it. It is now decoded, carried through the observation, and stored on the record. An entry that is loaded but still onboarding, or suspended, is reported as being in no ring at all — there is nothing to unload from, so its value stays pending rather than being offered to selection.
Rewrites the handoff around the authoritative spec. Records what the foundations, durability and observation work delivered, and carries the full nineteen-item plan in dependency order with per-item state, so the next session resumes without re-deriving it. Also withdraws the earlier advice to build a CLI first. A CLI is not the deliverable, and the risk it was meant to retire is contained in the extension encoder and does not propagate into the shape of the primitive API — whereas durability is cross-cutting and every primitive that submits has to be resumable by construction. Notes the decisions that should not be re-litigated, and the residual hazard that an unobserved ring immutability makes the rescue sweep decline silently.
The commits were split out of one finished working tree rather than replayed, so the tip is verified but intermediate commits do not build standalone. Names the cause and the fix so it is not rediscovered during review.
Completes layer 1. The subscription streams of §8.9 and every primitive of §8.1–§8.10 now exist, along with the submission engine they share and a read-only driver for the assumptions only a live runtime can settle. The engine, which transfer establishes and the rest reuse: - Coin origins are signed transactions 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. That extension precedes `AsCoinage`, so the signature must cover the coinage extra; signing the metadata-default extra instead yields bytes a runtime rejects as a bad proof, silently. - Recycler-ring reads, free unload-token slots, the fee account's balance, and a fee ceiling priced against the bytes that will carry it. - A plan-to-transactions walk that routes outputs straight into their destination accounts, so a payment needs no "mint to myself, then transfer" and its transactions are independent of one another. - Per transaction: mutate local state, write the log entry with its extrinsic hash, persist, broadcast, then grade. Only a definite outcome retires a record; anything else goes to recovery. The primitives: - Purse lifecycle. A drain closes its purse only once the chain agrees the value left, and a purse still holding value that cannot move is refused rather than closed around it. - Transfer, with the memo callback fired at optimistic inclusion. - Export and import, the layer seam. A coin already in the right shape leaves under its own secret with no extrinsic; one that must be reshaped is handed over only after definite success. - Both maintenance sweeps. "Nothing to rescue" is not evidence that observation ran, and the API says so. - External offload, which re-reads the chain between phases because an entry a recycle just created knows nothing about the ring it landed in. - Payment classification, top-up over the unpaid load batch, and the gap-limit wallet rescan, which reads in bulk rather than per index. The fee mode chooses the origin, not just an argument: prepaid presents a token and carries max_fee = 0, from-output presents no token at all. An unfunded fee account therefore spends no free allowance. Verified offline throughout, against the metadata fixture and a new `FakeChain` that answers by method and serves back what was submitted, so a whole operation runs with real proofs and real signatures and no node. Nothing here has been submitted to a chain. Two pallet facts settled while building this: `proof_of_ownership` signs the origin account's raw 32 bytes, and `MaxBatchUnpaidLoad` is 10. The paid unload-token ring remains unimplementable — its membership collection identifier is neither in metadata nor derivable — so resolution reports NoUnloadToken rather than building a token it cannot prove.
The nineteen-item plan is complete, but the layer's specified API and the layer as a running subsystem are different claims. §7 names the four things between them: §6.1's reactive observer, which was never a plan item and needs both a subscription surface on RpcClient and an ownership decision for CoinageLayer; the absence of any caller, which belongs with layer 2; paid unload tokens, still blocked on a pallet fact nobody has read yet; and the fact that nothing has been run against a chain.
The paid fallback of coinage-layer.md §6.5 was blocked on the 32-byte
`Members` collection identifier the pallet derives per period, which is
absent from metadata. Reading pallets/coinage/src/{lib.rs,
paid_tkn_manager.rs} settles it as `b"coinage/paidtkn!"` followed by the
period as a little-endian u32, and settles the proof context, the period
arithmetic, the join calls and the ring exponent with it.
Three of those facts contradict what the design document assumed, and
they matter more than the identifier did:
The paid proof context carries the period and no counter, where the free
context carries period and counter. One paid member key therefore yields
exactly one alias, and so exactly one token, per period. N paid tokens
means N keys, N joins and N fees. A grant now names a slot, keys derive
at //coinage//paidtkn//<period>//<slot>, and the plan carries a list of
joins rather than a single flag.
Registering a key and being able to prove it are two steps: the pallet
records the member at once, the members pallet onboards it into a ring
afterwards, and a ring-VRF proof needs the ring. A slot in between is
paid for and unusable, so `joined` and `onboarded` are separate facts and
the correct response to the gap is to wait rather than to pay again.
The join call takes no period argument. It reads the chain's own clock at
dispatch, so a join near a boundary lands in the next period. Membership
and ring index are read back afterwards instead of being assumed.
Also fixes a latent bug this turned up: the paid period was being
measured with the free period length, one day against three.
`PaidUnloadTokenTimePeriod` and `PaidUnloadTokenRingExpirationTime` are
`#[pallet::constant]` in the pallet source but absent from the deployed
runtime's metadata, so they join `MaximumAge` and
`RecyclerExpirationTime` as configured constants, refused if a newer
runtime contradicts them.
A slot already in the ring now resolves, proves and encodes end to end.
Buying one is not wired: a join has to reach definite success before the
token it buys can be presented, which makes it a dependency-ordered
transaction in the log, and token resolution would have to move from
submission time to plan time to express that. `can_fund_join` is
therefore false and resolution never plans a join, so a wallet with no
joined slot is still told `NoUnloadToken`.
The ring reader is generalized over collections, since the recycler rings
and the paid rings are both pallet-members collections and the paging,
`included` truncation and domain-from-collection rules belong to that
pallet rather than to what a ring is for.
Completes the paid unload-token ring and gives the layer the core-side half of its autonomous behaviour. A join is `pay_for_recycler_unload_fee_token_with_native`, which takes `ensure_signed` and so declares no coinage origin at all. That needs a third extrinsic shape: signed V4 with `AsCoinage(None)`, distinct from both the coin-origin path and the `InfallibleUnpaidSigned` load. The fee account signs and pays, since putting the cost on a coin would spend coinage value to buy the right to move coinage value. Whether a join is affordable is answered by dry-running that exact extrinsic. The pallet prices it as `WeightToFee(coin_lifecycle_weight())`, which is neither a published constant nor a runtime API, so there is no number to compare a balance against. A join deliberately gets no write-ahead log entry. The log 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 of it. After a restart `read_paid_ring_state` observes exactly what happened, so a log entry would describe state the log does not own. Only finalized success counts. A reorg that removed the join would leave the layer proving membership of a ring its key is not in, which a runtime reports as an invalid proof with nothing to say why. Registration and onboarding are also separate steps, and a proof needs the ring, so a slot between the two is reported rather than waited on — the core has no sleep of its own. Retrying is free, because the slot is already registered and resolution finds it instead of buying a second one. `tick` runs whatever is due at a given time and reports how long the host may wait before calling again, following the rule that the core owns what and how while the host owns only when. It reprojects the balance streams, because a jitter delay elapsing moves a purse's spendable balance with no record changing, then runs both sweeps if anything is due and drives that operation to completion. Scheduling holds no persisted state. The sweeps decide what to do from a coin's age and an entry's ring deadline rather than from elapsed time since a previous run, so the entry point is safe at any frequency and a restart loses nothing. The returned interval is advice about sufficient frequency, not a minimum gap. Nothing calls `tick`: no platform surface provides a timer. That mechanism is truapi#356, which also carries the reactive-observation gap and the ownership decision both need. Earlier references pointed at truapi#308, which is a statement-allowance feature that needs the same mechanism rather than a tracking issue for it. The chain-agreement example gains the two paid-token constants and a check that the derived collection identifier resolves to a real pallet-members collection, reading the chain's own clock for the period. Run against paseo-people-next: 25 checks agree, the identifier resolves, and `PaidTokenCollectionsCreated` hits on the big-endian key.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Layer 1 of RFC-17 coinage: the base layer, not the product API.
docs/design/coinage-layer.md. Authoritative, and where all the detail lives. Read this over the RFC where they disagree.coinage-implementation-status.md. What is built, what is not, and why. §7 is the open work.Layer 1 (this PR) owns value: coins, purses, recycler entries, selection, derivation, the
AsCoinageextension, submission and settlement, the durable operation log, recovery. It knows coins and the chain, and nothing about payments.Layer 2 is what RFC-17 actually specifies —
impl CoinPayment for ProductRuntimeHostand the payment-shaped concepts. Not started. Unblocked by theexport_coins/import_coinsseam, which is built.Three things worth knowing before reviewing:
test --workspace,clippy -D warnings,fmt,wasm32,doc.examples/coinage_chain_agreementconfirms 25 facts there: the constants, the call indices, all sixAsCoinagevariant indices, and that the paid unload-token collection identifier — derived from pallet source, absent from metadata — resolves to a realpallet-memberscollection.AsCoinagevariants has ever been accepted by a runtime. Existing at the right index is not the same as parsing. The other five are encoded from pallet source, andexamples/coinage_live_validationdry-runs each one to settle it. Not yet run.Why the diff is ~29.5k lines
43 new files. Only 394 lines touch pre-existing code, with 17 deletions in total, and nothing constructs
CoinageLayeryet — so nothing outsidecoinage/changes behaviour.So it is ~10.4k lines of logic. Two repo conventions account for most of the rest: tests live inline in
#[cfg(test)] mod tests, so all 878 of them land in the same diff as the code they cover, and everypubitem carries a doc comment. No generated files, no vendored code, no lockfile churn.Draft because layer 2 is the point of RFC-17 and none of it is written yet. Review of the spec and of layer 1's shape is welcome now.