Skip to content

feat(server): coinage base layer (RFC-17 layer 1) - #344

Draft
TorstenStueber wants to merge 30 commits into
mainfrom
rfc17-coinage-core
Draft

feat(server): coinage base layer (RFC-17 layer 1)#344
TorstenStueber wants to merge 30 commits into
mainfrom
rfc17-coinage-core

Conversation

@TorstenStueber

@TorstenStueber TorstenStueber commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Layer 1 of RFC-17 coinage: the base layer, not the product API.

Layer 1 (this PR) owns value: coins, purses, recycler entries, selection, derivation, the AsCoinage extension, 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 ProductRuntimeHost and the payment-shaped concepts. Not started. Unblocked by the export_coins / import_coins seam, which is built.

Three things worth knowing before reviewing:

  • Nothing has been submitted to a chain. Every path is verified offline against a metadata fixture and a scripted node. Gates green: test --workspace, clippy -D warnings, fmt, wasm32, doc.
  • Read-only checks have run against paseo-people-next. examples/coinage_chain_agreement confirms 25 facts there: the constants, the call indices, all six AsCoinage variant indices, and that the paid unload-token collection identifier — derived from pallet source, absent from metadata — resolves to a real pallet-members collection.
  • Still, only one of the six AsCoinage variants 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, and examples/coinage_live_validation dry-runs each one to settle it. Not yet run.
  • The branch does not bisect. Early commits were split out of a finished working tree. Worth a replay rebase before this leaves draft.

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 CoinageLayer yet — so nothing outside coinage/ changes behaviour.

Lines Share
Tests 11,411 39%
Production logic 10,447 36%
Doc comments 3,837 13%
Markdown (spec + notes) 2,062 7%
Blank 1,387 5%

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 every pub item 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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant