diff --git a/Cargo.lock b/Cargo.lock index 7bb9e809..cb76a989 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitvec" version = "1.0.1" @@ -122,6 +128,13 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "coinage-layer" +version = "0.0.0" +dependencies = [ + "vstd", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -290,6 +303,12 @@ dependencies = [ "slab", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.17.0" @@ -319,6 +338,16 @@ dependencies = [ "syn", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -326,7 +355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", ] [[package]] @@ -531,6 +560,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tap" version = "1.0.1" @@ -552,7 +592,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -624,6 +664,70 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "verus_builtin" +version = "0.0.0-2026-05-17-0151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab9266bfb5cf45a080f425c560b0e0be2bf81194f0e80258990c49c1829d8b9" + +[[package]] +name = "verus_builtin_macros" +version = "0.0.0-2026-05-10-0145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef946d78c84f284991d59035ca252f29cd8071526003e794be80f0beee18f2d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", + "verus_prettyplease", + "verus_syn", +] + +[[package]] +name = "verus_prettyplease" +version = "0.0.0-2026-05-10-0145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a227e7eaa03f51ac4d659641192bc1b9fe4eda0a509a6734752cf72f5d7daaf" +dependencies = [ + "proc-macro2", + "verus_syn", +] + +[[package]] +name = "verus_state_machines_macros" +version = "0.0.0-2026-05-10-0145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff46046bffd2d55757503feef0b72a74a60ead2b58aa211861267485b955136" +dependencies = [ + "indexmap 1.9.3", + "proc-macro2", + "quote", + "verus_syn", +] + +[[package]] +name = "verus_syn" +version = "0.0.0-2026-05-10-0145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82174e474e1f06418dd304f714785ae1c3e4d2fbff415a0d0c05a02cd995819" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "vstd" +version = "0.0.0-2026-05-17-0151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "584ee1a64e0dea893387007e9b78423a830edace47e6bfe1c7ebe9893ffc3a40" +dependencies = [ + "verus_builtin", + "verus_builtin_macros", + "verus_state_machines_macros", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/docs/design/coinage-layer.md b/docs/design/coinage-layer.md new file mode 100644 index 00000000..0c790eeb --- /dev/null +++ b/docs/design/coinage-layer.md @@ -0,0 +1,745 @@ +--- +title: "Coinage Layer" +status: "Draft" +--- + +# Coinage Layer — Design + +## 1. Summary + +The Coinage Layer is the host's self-contained coinage subsystem. It owns every coin and recycler entry the user controls, partitions them across one or more purses, observes chain state reactively, schedules recycling, and runs the cryptographic and operational machinery for transfers, unloads, and offload. It has no knowledge of RFC‑17 product concepts (receivables, cheques, refunds, invoices); those live in the layer above. + +This document is normative for the layer's behavior. Two conformant implementations operating on the same root entropy against the same chain state must produce the same on-chain effects, the same set of local records, and the same observable events. + +## 2. Scope + +### 2.1 In scope + +Purses; coins and recycler entries (records, state machines, ages); reactive on-chain observation; selection; recycling (payment-folded plus periodic backstop); free / paid unload tokens with automatic fallback; fee-mode auto-selection; transfer to pre-arranged recipient accounts; portable coin export / import (the seam to the upper layer); external offload to a non-coinage account; rebalance between purses; payment classification for direct transfers; operation lifecycle (durable handles, status streams, cancel-before-submission, restart resumption); recovery from root entropy. + +### 2.2 Out of scope + +Receivables; cheques; refunds; invoices; product permissions; consent UI; cheque wire transport; multi-device synchronization; coinage pallet runtime evolution; the product-facing API surface. + +### 2.3 Relationship to the upper layer + +Exactly one upper layer consumes this layer's API. It is trusted (it lives inside the host) and is the only valid caller. The upper layer adds receivables, cheques, refunds, and the RFC‑6 / RFC‑17 product-facing surface, composing them out of the primitives this layer exposes. + +## 3. Concepts + +### 3.1 Purse + +A purse is a named, firewalled coinage balance with an isolated derivation namespace. Every coin and every recycler entry belongs to exactly one purse. Balance, selection, recycling, and operations are scoped to a single purse unless explicitly cross-purse (rebalance, deletion). + +Exactly one purse with a reserved identifier — the **main purse** — exists by construction once the layer is initialized. Any number of additional purses may be created. + +### 3.2 Coin + +A coin is a chain-level NFT representing a fixed denomination of dotUSD. It is identified on chain by an sr25519 account derived from the layer's root entropy, the coin's purse, and its derivation index. A coin carries: + +- a denomination `exponent` (denomination = `2^exponent` cents); +- an integer `age` incremented by the chain on every transfer or split, capped at a chain-enforced maximum above which the coin is unusable. + +A coin is consumed by transfer (to a pre-arranged recipient account), by split (into smaller coins), by recycling (into a fresh recycler entry), or by export (the coin and its secret are handed to the upper layer). + +### 3.3 Recycler entry + +A recycler entry is a Bandersnatch keypair the layer placed into a chain recycler ring — a privacy anonymity pool. The layer realizes the entry's value by **unloading** it: a Ring VRF proof of ring membership produces a fresh age-0 coin (or external-asset output) without revealing which entry was unloaded. An entry holds no spendable value on its own; value is realized at unload time. An entry must wait for its ring to fill before its anonymity claim is meaningful. + +### 3.4 Operation + +An operation is a long-running asynchronous task. The operation kinds this layer supports are: `TopUp`, `Transfer`, `Export`, `Import`, `ExternalOffload`, `Rebalance`, `MaintenanceSweep`, `DeletePurse`, `Recover`. Each operation has a durable opaque handle, a persisted record, a status stream emitted at every state transition, and a set of locked coins / recycler entries that no other operation may touch until the owning operation reaches a terminal state. + +Every call to a long-running primitive starts a fresh operation. The layer does not deduplicate by argument equality; callers needing idempotency MUST track handles themselves. + +### 3.5 Coin export / import (the layer seam) + +The upper layer needs coin secrets to construct cheques but must not have access to the layer's derivation tree. Two primitives bracket this: + +- **Export.** Selects coins in a purse summing to a requested amount, performs any necessary split / unload-into-coins extrinsics, then returns the resulting `(coin_account, coin_secret)` pairs and treats the exported coins as no longer owned by the layer. +- **Import.** Accepts an externally supplied list of `(coin_account, coin_secret)` pairs and routes each one into a purse's namespace by submitting a transfer signed with the supplied secret. + +A `coin_secret` is the raw sr25519 secret-key material controlling the corresponding coin account. Two implementations exchanging exported secrets must agree on the same encoding (the recommended encoding is the raw 64-byte secret-key form). + +These are the only primitives through which coin secrets cross the API. Everything in the upper layer's cheque / receivable machinery composes on top of this seam. + +## 4. Identity + +### 4.1 Per-purse isolation + +Each purse has its own coin-index space and its own recycler-entry-index space. Index `i` in purse A and index `i` in purse B address different on-chain accounts because their derivation paths differ. A coin or entry record carries `(purse_id, index)` as its identity; purse membership is implied by derivation. + +### 4.2 Derivation + +All keys are deterministically derived from the root entropy supplied at initialization. The layer never generates entropy itself. Given identical entropy, two instances derive identical accounts. + +The exact derivation scheme is implementation-defined. The recommended scheme is in Appendix B. Two invariants are normative regardless of scheme: + +- Given the same root entropy, the same purse identifier, and the same index, the layer produces the same coin (or recycler-entry) account. +- Two distinct purses have non-overlapping derivation namespaces. + +### 4.3 No-reuse invariant + +Within a purse, a coin derivation index, once allocated, is never reused. The same rule applies to recycler-entry derivation indices. + +This invariant is unconditional: it holds after the coin is spent and the on-chain account is empty, and after the recycler entry is unloaded and removed from the ring. Implementations may realize it by retaining record stubs, by a high-water mark per purse, or by chain scanning — any mechanism that guarantees no index is allocated twice. + +Rationale: a coin's account ID may have appeared in a transfer memo passed out-of-band; a recycler entry's Bandersnatch public key sits in a public ring member list. Reuse would correlate new activity with old. + +## 5. State + +### 5.1 Coin lifecycle + +Each coin record carries a lifecycle state: + +- **Pending** — created locally as a future output of an in-flight operation; chain account not yet observed. +- **Available** — chain confirms the account holds a coin with a known age. Selectable. +- **LockedFor(op)** — held by in-flight operation `op`. Not selectable. +- **Spent** — terminal. Chain confirms the account is empty (or the coin has been exported). Record retained for the no-reuse invariant; subject to garbage collection by any mechanism that still guarantees no reuse. + +Transitions: + +| From | To | When | +|-|-|-| +| (none) | `Pending` | Created locally as an output of an operation | +| `Pending` | `Available` | First chain observation reports the account holds a coin | +| `Available` | `LockedFor(op)` | Operation `op` locks the coin during `Preparing` | +| `LockedFor(op)` | `Available` | `op` aborts or is cancelled before submitting any extrinsic | +| `LockedFor(op)` | `Spent` | `op` reaches terminal success and the account is observed empty (or, for export, immediately after the export emits the secret) | +| `LockedFor(op)` | `Available` | `op` fails post-submission and the account is still observed populated | + +### 5.2 Recycler entry — on-chain readiness and the anonymity floor + +An entry's anonymity at unload time comes from its ring: a Ring VRF proof hides the prover among the ring's members, so the larger the ring, the stronger the anonymity. The chain accepts unloads from rings of any size; this layer applies its own **anonymity floor** — a minimum ring member-count below which it flags the entry as offering reduced anonymity. The floor is a single value scoped to the layer instance; it is not configurable per purse or per operation. The floor is a tunable parameter (Appendix A.2). + +Each entry has an on-chain readiness state derived from chain observation: + +- **Missing** — no recycler location on chain for the entry's member key. The load extrinsic has not finalized, or the entry has been consumed. +- **Waiting** — chain reports a recycler location, but the ring is in onboarding or chain-side readiness conditions are unmet. +- **Ready** — ring member-count meets or exceeds the anonymity floor. +- **Degraded(n)** — ring member-count is `n`, below the floor. + +`Ready` and `Degraded` are both usable for selection. The choice of whether to use `Degraded` entries is controlled by the caller per primitive (§8). + +### 5.3 Recycler entry — readiness jitter + +When the layer creates a new recycler entry (top-up or recycling), it records the creation timestamp as `allocated_at` and draws a per-entry random delay `d` uniformly from `[0, D]`. The entry's `ready_at` is `allocated_at + d`; the entry is not selectable until `now ≥ ready_at`, regardless of on-chain readiness. + +Without jitter, an observer with timing data could match a load to its subsequent unload. The bound `D` is tunable (Appendix A.3). The mechanism is SHOULD, not MUST: implementations may set `D = 0` if a specific deployment knowingly accepts the timing correlation. + +### 5.4 Recycler entry — local lifecycle + +Independent of on-chain readiness, each entry has a local lifecycle state: + +- **Available** — free for selection. +- **LockedFor(op)** — held by in-flight operation `op`. +- **Consumed** — terminal. The owning operation reached terminal success and the entry was unloaded. Record retained for the no-reuse invariant; subject to garbage collection on the same terms as `Spent` coins. + +An entry is **selectable** iff: + +``` +local_state = Available ∧ on_chain_state ∈ {Ready, Degraded} ∧ ready_at ≤ now +``` + +A caller may further restrict selection to exclude `Degraded` entries via a per-primitive flag (§8). The selectability condition above is the maximum set; flags only narrow it. + +### 5.5 Operation lifecycle + +Every operation traverses: + +| State | Meaning | +|-|-| +| `Preparing` | Selecting, deriving, signing, building extrinsics, or re-planning between phases. No extrinsic currently in flight. | +| `Submitted` | An extrinsic has been broadcast. | +| `InBlock` | An extrinsic has been included in a non-finalized block. | +| `Finalized` | An extrinsic has been finalized. | +| `Waiting(until)` | The operation cannot progress until the indicated wall-clock time (e.g. waiting for a recycler entry's `ready_at` or for ring readiness). The layer wakes the operation at or shortly after `until` and returns to `Preparing`. | +| `Done(receipt)` | Terminal. At least one submitted extrinsic was successfully finalized. The receipt (§9) enumerates per-extrinsic outcomes (success or rejection); partial-failure interpretation is the caller's. | +| `Failed(reason)` | Terminal. Either no extrinsic was submitted (pre-submission failure), every submitted extrinsic was rejected, or the operation was cancelled. | + +A long-running operation (e.g. `ExternalOffload`) may cycle through phases: `Preparing` → `Submitted` → `InBlock` → `Finalized` → `Preparing` → `Waiting` → `Preparing` → `Submitted` → … and so on until it reaches `Done` or `Failed`. Each phase transition is durably persisted; the operation resumes from the same phase across restart. + +Operations that submit no extrinsics (e.g. `Recover`) emit `Preparing` followed directly by a terminal item. + +## 6. Operational model + +### 6.1 Reactive on-chain observation + +The layer maintains continuous subscriptions to every chain storage entry backing its local records: coin storage for each known coin account, ring-member storage for each recycler entry's member key, recycler revision and member-count for the rings entries belong to, and consumed-unload-token storage relevant to the user's allowance. Subscription events update local records in place. The layer does not pull-poll; callers read its cached view, which the subscription keeps fresh. + +The layer is therefore long-lived. Subscription updates must be reconciled with operation-driven changes — for example, a `LockedFor` coin observed empty on chain transitions cleanly to `Spent`. + +### 6.2 Balance + +Per purse, the layer exposes three values, emitted by the balance subscription on every change: + +- **Spendable** — sum of values of all coins in `Available` plus all currently selectable recycler entries (`Ready` or `Degraded`). +- **Spendable strict** — same, but counting only `Ready` recycler entries. Always `≤ spendable`. The difference is the value held in `Degraded` entries. +- **Pending** — sum of values of all coins in `Pending` or `LockedFor`, plus all recycler entries that are not selectable (`Waiting`, `Missing`, `LockedFor`, or with `ready_at > now`). + +### 6.3 Selection + +This section describes the selection used for operations that produce coinage value at a destination *inside* coinage — transfer, export, rebalance. External offload uses a different, planner-driven strategy described in §8.6. + +When the layer must produce a specified `amount` from a purse for one of these operations, it tries the following strategies in priority order, returning the first that succeeds. + +Selection orders coins by `(exponent desc, age desc, derivation_index asc)` and recycler entries by `(exponent desc, ring_index asc, derivation_index asc)` before applying each strategy's heuristic. This ordering is fully deterministic — two conformant implementations with the same purse contents produce the same selection. + +1. **Exact match.** Find a subset of `Available` coins (in the order above) summing exactly to `amount`. Zero extrinsics. +2. **Split.** Find the smallest single `Available` coin strictly greater than `amount`; split it into `amount` + change denominations using one extrinsic. If no single coin suffices, build a multi-coin cover with whole coins (the deterministic order naturally produces largest-first) and split the last coin that crosses the target; if that is also impossible, fall through. No unload token consumed. +3. **Unload into coins.** Use selectable recycler entries (§5.4), optionally with whole coins for partial coverage, to mint coins of the target denominations. Entries are grouped by `(denomination, ring)`; each group becomes one atomic `unload-into-coins` extrinsic carrying its own unload token. The output value of each group equals its input value (the group's own change absorbs the remainder). Prefer a single smallest sufficient entry; otherwise take entries in the deterministic order above to cover the deficit. + +If all three strategies fail and the purse contains recycler entries whose summed value would have covered `amount` had they all been ready, return `NoReadyEntries` so the caller can distinguish "wait" from "insufficient funds". + +Selection runs against the live local view. Selection holds locks for the lifetime of the resulting operation; two concurrent selections never disagree about availability. + +If the caller has disallowed `Degraded` entries for a particular operation, the effective selectability condition narrows accordingly. If selection would have succeeded with `Degraded` entries but cannot succeed without them, return `NoReadyEntries`. + +### 6.4 Autonomous lifecycle maintenance + +The chain places a hard time limit on **both** states of a logical coin's value: + +- A **coin** ages out at `MaximumAge` transfers/splits and becomes unusable. +- A **recycler entry** dies when its ring is cleaned up after `RecyclerExpirationTime` from the ring's `immutable_since`. Backing value of any entry that has not been unloaded by then is destroyed by the pallet (added to `TotalValueOfDestroyedCoins`). + +The layer MUST run two autonomous sweeps that together form a closed loop: `coin → entry` (coin-age recycling) and `entry → coin` (ring-expiration rescue). A coin that is never spent cycles between forms indefinitely; no value is lost so long as both sweeps run regularly. Skipping either sweep causes silent loss of funds for users who don't actively spend. + +**Coin-age recycling sweep (coin → entry).** A scheduler runs at a tunable interval (Appendix A.4). Per purse, the sweep scans `Available` coins whose `age ≥ recycle_at_age` (Appendix A.1), oldest first, and submits one `load_recycler_with_coin` extrinsic per coin. Post-submission failure marks the coin `Spent`; pre-submission failure releases the lock so a future sweep can retry. Each successful recycle consumes the coin (terminal `Spent`) and produces a new `Available` recycler entry whose `ready_at` is set per §5.3. + +Payment-folded refresh complements this: selection (§6.3) prefers older coins, and unload-into-coins emits age-0 coins. Active wallets refresh themselves implicitly. + +**Ring-expiration rescue sweep (entry → coin).** A scheduler runs at a tunable interval (Appendix A.12). Per purse, the sweep scans recycler entries whose ring is approaching expiration — i.e. `now ≥ ring.immutable_since + RecyclerExpirationTime − rescue_margin` (Appendix A.13). The sweep groups eligible entries by `(denomination, ring)` and submits one `unload_recycler_into_coins` extrinsic per group, each carrying its own unload token (§6.5). Each successful rescue consumes the entry (terminal `Consumed`) and produces a new age-0 `Available` coin in the same purse. + +The ring-expiration sweep is critical: without it, entries created by the coin-age sweep (or by top-up) can expire silently if the host is unused long enough for the ring lifecycle to complete. This is the only way for value to permanently disappear from a wallet whose root entropy and chain identity are otherwise intact. + +**Triggers.** For both sweeps, the periodic schedule is the contractual minimum. Implementations MAY add opportunistic triggers (e.g. on host wake / foreground; on a subscription update that brings a coin past `recycle_at_age` or an entry past the rescue margin). Both sweeps are also invoked synchronously by `run_maintenance_sweep` (§8.7). + +### 6.5 Unload tokens + +Every unload of a recycler entry consumes exactly one unload token. Two classes exist: + +- **Free** — derived from the user's people / lite-people ring membership; per-period allowance. +- **Paid** — derived from a period-specific paid-token ring that anyone may join by paying a fee (an on-chain extrinsic). + +When the layer needs `N` tokens for a multi-group unload, it resolves them in this order: + +1. For each token slot needed, probe `ConsumedFreeUnloadTokens` (cached from chain) for the current period and any prior period within the lookback grace window (Appendix A.6). Pick the first counter in the search range (Appendix A.5) whose alias is not consumed. +2. If free slots run out, fall back to paid tokens. If no paid-token ring membership exists for the current period, the layer first joins the current paid ring (a pre-step extrinsic), then derives the alias. + +If neither free nor paid tokens can be obtained (no people/lite-people ring membership and the fee account cannot fund joining the paid ring), the operation fails with `NoUnloadToken`. + +The caller does not select the class. Per-token cost is reported in the operation's status stream. + +### 6.6 Fee account and fee mode + +The layer derives a single **fee account** (sr25519) from the root entropy at initialization. This account pays the on-chain fee for every unload operation across every purse — it is not per-purse, not exposed in the API, and not configurable. How the fee account is funded is outside the layer's concern; the user / upper layer is expected to keep it topped up out of band. + +Unloads support two fee modes: + +- **Prepaid** — fee paid in native currency / asset from the fee account, alongside the unload extrinsic. +- **From-output** — fee deducted from the unloaded value. + +The layer picks the mode automatically per unload: prepaid if the fee account holds sufficient external funds at submission time, from-output otherwise. The caller does not specify. + +## 7. Operations + +### 7.1 Handles + +Every operation primitive returns an opaque, durable `OperationHandle`. A handle is sufficient to subscribe to the operation's status stream, read its current status, or cancel it (§7.3). Handles are layer-issued; callers do not supply correlation keys. Two operations with disjoint lock sets may run concurrently; lock conflicts are impossible by construction. + +### 7.2 Status streams + +Each operation emits the state machine of §5.5. The first item is the current status at subscription time. The terminal item (`Done` or `Failed`) is emitted exactly once and the stream then closes. Dropping the subscription is always safe; the operation continues regardless of whether anyone is subscribed. + +### 7.3 Cancellation + +A caller may cancel an operation whenever no extrinsic is currently in flight — i.e. while the operation is in `Preparing` or `Waiting`. The layer aborts, releases all locks, and emits `Failed(Cancelled)`. + +While an extrinsic is in flight (`Submitted` / `InBlock` not yet `Finalized`), the operation cannot be cancelled at the API. The caller must await the extrinsic's resolution. A multi-phase operation may become cancellable again once it returns to `Preparing` or `Waiting`. + +### 7.4 Restart durability and record retention + +**In-flight operations.** Each operation record is persisted at start, together with its lock set. Each extrinsic submission is appended to the operation record before broadcast. On restart, the layer: + +1. Reads back every open operation record and every locked record. +2. Re-establishes chain subscriptions for the affected accounts. +3. For each open operation: if no extrinsic was submitted, fail with `Failed(InterruptedPreSubmission)` and release locks. Otherwise, reconcile each submitted extrinsic against current chain state: if all expected effects are observed, transition to `Done`; if any are rejected, transition to `Failed`; otherwise, resume watching. + +Pre-submission scratch state (in-flight selection, partial signing) is not durable. A restart in `Preparing` is equivalent to a cancel. + +**Subscriptions.** All subscription streams (balance, operation status, events) are torn down on restart. Callers MUST re-subscribe after restart; subscriptions are not auto-resumed. + +**Terminal-operation records.** Once an operation reaches a terminal status (`Done` or `Failed`) and the terminal status item has been emitted on its status stream, the layer MAY immediately drop the operation record from durable storage. Subsequent re-subscription via the now-stale handle returns `OperationNotFound`. Callers that need to retain the receipt MUST capture it from the terminal status item; the layer does not maintain history. + +## 8. Primitives + +All long-running primitives return: + +```text +struct OperationStart { + handle: OperationHandle, + status: Stream, +} +``` + +Errors emitted synchronously describe failure to start an operation. Errors emitted via the status stream (as `Failed(Error)`) describe terminal failure of a started operation. The full error enum is in §10. + +### 8.1 Purse lifecycle + +```text +fn create_purse(name: String) -> Result +fn query_purse(purse: PurseId) -> Result +fn rename_purse(purse: PurseId, name: String) -> Result<(), Error> +fn delete_purse(target: PurseId, drain_into: PurseId) + -> Result +fn rebalance_purse(from: PurseId, to: PurseId, amount: Amount, allow_degraded: bool) + -> Result + +struct PurseInfo { + id: PurseId, + name: String, + spendable: Amount, + spendable_strict: Amount, + pending: Amount, +} +``` + +`create_purse` assigns a fresh non-reserved `PurseId`, persists the purse, returns synchronously. No chain interaction. + +`query_purse` returns a synchronous snapshot. + +`rename_purse` updates the purse's name. No chain interaction. + +`delete_purse` drains the target into `drain_into` via on-chain transfer, then closes the purse record. The main purse cannot be deleted. A purse cannot be deleted while it has in-flight operations. + +`rebalance_purse` transfers `amount` from one purse to another by selection in the source purse's namespace, with destination coin accounts allocated in the target purse's namespace. `allow_degraded` controls whether `Degraded` recycler entries may be selected. + +Errors: `PurseNotFound`, `CannotDeleteMainPurse`, `PurseHasInFlightOperations`, `InsufficientFunds`, `NoReadyEntries`, `ChainRejected`, `Cancelled`. + +### 8.2 Top-up + +```text +trait FundingOrigin { + fn external_account(&self) -> ExternalAccountId; + fn sign_payload(&self, payload: &[u8]) -> Signature; +} + +fn top_up(into: PurseId, amount: Amount, origin: &dyn FundingOrigin) + -> Result +``` + +Decomposes `amount` into recycler-entry denominations, allocates fresh entry indices in `into`, and submits one external-asset load extrinsic per denomination, signed by `origin`. Successful per-entry loads do not roll back failed ones; per-entry outcomes are reported in the status stream. + +Errors: `PurseNotFound`, `InsufficientExternalFunds`, `ChainRejected`. + +### 8.3 Transfer + +```text +fn transfer( + from: PurseId, + amount: Amount, + recipient_outputs: Vec, + allow_degraded: bool, + memo_callback: Option, +) -> Result + +struct RecipientOutput { + exponent: DenominationExponent, + account: CoinAccountId, +} + +type MemoCallback = fn(memo_entries: Vec); + +struct MemoEntry { + sender_coin_account: CoinAccountId, + recipient_account: CoinAccountId, + derivation_index: CoinIndex, +} +``` + +Transfers `amount` from `from` to the supplied recipient-controlled accounts. The constraint on `recipient_outputs` is: + +``` +Σ 2^output.exponent over recipient_outputs == amount +``` + +Multiple outputs with the same `exponent` are allowed (e.g. two `exponent = 3` outputs to two distinct accounts). + +Selection from `from` uses the three-tier strategy (§6.3) routing the output coins to the supplied accounts. If `memo_callback` is supplied, the layer invokes it with one `MemoEntry` per transferred coin once the corresponding extrinsic reaches `InBlock` (chain inclusion, before finalization). The layer does not encode or transmit memos; the caller owns the wire format. + +Errors: `PurseNotFound`, `InsufficientFunds`, `NoReadyEntries`, `OutputsDoNotSumToAmount`, `ChainRejected`, `Cancelled`. + +### 8.4 Export coins + +```text +fn export_coins(from: PurseId, amount: Amount, allow_degraded: bool) + -> Result + +struct ExportStart { + handle: OperationHandle, + status: Stream, + coins: Stream, // emits once per coin, then closes +} + +struct ExportedCoin { + account: CoinAccountId, + secret: CoinSecret, + exponent: DenominationExponent, +} +``` + +Materializes `amount` worth of coins in `from`'s namespace by selection and any required split / unload-into-coins extrinsics, then emits one `ExportedCoin` per resulting coin. Each exported coin transitions to `Spent` in this layer's view: the on-chain account still holds the coin but it is now controlled by the externally held secret. + +`export_coins` is the **only** primitive through which coin secrets cross the API. The caller is responsible for the confidentiality of the emitted secrets. + +Errors: `PurseNotFound`, `InsufficientFunds`, `NoReadyEntries`, `ChainRejected`, `Cancelled`. + +### 8.5 Import coins + +```text +fn import_coins(into: PurseId, coins: Vec<(CoinAccountId, CoinSecret)>) + -> Result +``` + +For each supplied pair, the layer (a) reads the coin's denomination from chain, (b) allocates a fresh coin derivation index in `into`, (c) submits a transfer extrinsic from `account` (signed with the supplied secret) to the freshly derived recipient account in `into`'s namespace. The layer does not retain supplied secrets after submission. New coin records appear in `into` and become `Available` once the chain confirms. + +Per-coin outcomes (`Done` / `BadCoinSecret` / `SnipedCoin` / `ChainRejected`) are reported in the status stream; partial success is possible. A pair whose `account` is already known to this layer is rejected with `BadCoinSecret`. + +Errors: `PurseNotFound`, `BadCoinSecret`, `SnipedCoin`, `ChainRejected`, `Cancelled`. + +### 8.6 External offload + +```text +fn external_offload( + from: PurseId, + amount: Amount, + destination: ExternalAccountId, + allow_degraded: bool = false, +) -> Result +``` + +Moves `amount` from `from` into a non-coinage account on chain. `allow_degraded` defaults to `false`: an external offload reveals the unloaded value to chain observers, so the anonymity set should be at full strength unless the caller explicitly opts in to `Degraded` entries. + +External offload is a **multi-phase, possibly long-running** operation. The layer drives it through the loop below until a terminal state is reached. Each phase transition is durably persisted (§7.4); cancellation is permitted in `Preparing` and `Waiting` (§7.3). + +1. **Plan** (status: `Preparing`). Read the current view of `from`. Choose the next phase: + - If selectable entries (per `allow_degraded`) cover `amount` → **Offboard**. + - Else if selectable + non-yet-ready entries together cover `amount` → **Wait** until the latest such entry's `ready_at`. + - Else compute the deficit. If available coins (`state = Available`) cover the deficit → **Recycle**. + - Else if non-spent coins (including coins locked by this or another operation, recycling, pending-transfer) together cover the deficit → **Wait** for a short retry interval (Appendix A.11). + - Else fail with `InsufficientFunds`. +2. **Recycle** (status cycles `Submitted` → `InBlock` → `Finalized` per coin). Pick the coins to cover the deficit in the deterministic order of §6.3. Submit one `load_recycler_with_coin` extrinsic per coin. Each successful recycle produces a new `Available` recycler entry locked to this operation. Return to **Plan**. +3. **Wait** (status: `Waiting(until)`). Suspend until the indicated time. On wake (or operation resume after restart), return to **Plan**. +4. **Offboard** (status cycles `Submitted` → `InBlock` → `Finalized` per recycler group). Submit one `unload_recycler_into_external_asset_and_vouchers` extrinsic per `(denomination, ring)` group, each carrying its own unload token (§6.5). The total transferred to `destination` is `amount`. Any surplus from the selected entries is **always atomically reloaded** into fresh recycler entries within the same extrinsic — surplus value MUST NOT land as a free coin, because that would re-link the entry-side anonymity set to a fresh sr25519 account. Once all groups have finalized, reach `Done(receipt)`. + +The operation locks every coin and recycler entry it touches throughout its lifetime, including entries produced during the **Recycle** phase. Locks are released on terminal status per §7.4. + +Fee mode is auto-selected per §6.6. + +Errors (via terminal `Failed`): `InsufficientFunds`, `NoUnloadToken`, `ChainRejected`, `Cancelled`. +Errors (synchronous): `PurseNotFound`. + +### 8.7 Maintenance sweep + +```text +fn run_maintenance_sweep(purses: Option>) + -> Result +``` + +Runs both the coin-age recycling sweep and the ring-expiration rescue sweep once across the listed purses (or all purses if `None`). For each purse the layer: + +1. Submits one `load_recycler_with_coin` extrinsic per eligible aging coin (oldest first). +2. Submits one `unload_recycler_into_coins` extrinsic per `(denomination, ring)` group of entries past the rescue margin. + +Per-extrinsic outcomes are reported via the operation's receipt. The layer also runs both sweeps autonomously per §6.4; this primitive exists so the upper layer can force a run on demand (e.g. on app foreground). + +Errors: `PurseNotFound`. + +### 8.8 Payment classification + +```text +fn classify_incoming_payment(entries: Vec) + -> Result + +enum PaymentClassification { + Matched, // every entry's recipient_account corresponds to a coin in some purse known to this layer + Received, // some entries' coins are present, others are not + Unmatched, // no entries match +} +``` + +Synchronous classification against the live local view. The layer treats an empty entry list as `Unmatched`. The classification is informational only; no operation is started, no record is modified. + +### 8.9 Subscriptions + +```text +fn subscribe_purse_balance(purse: PurseId) -> Stream +fn subscribe_operation_status(handle: OperationHandle) -> Stream +fn subscribe_events() -> Stream + +struct PurseBalance { + spendable: Amount, + spendable_strict: Amount, + pending: Amount, +} +``` + +Each stream emits the current value at subscribe time, then a new item on every state change. Closing the stream releases the subscription. Multiple concurrent subscriptions are independent. + +### 8.10 Recovery + +```text +fn recover(non_main_purse_ids: Vec) + -> Result + +fn extend_scan( + purse: PurseId, + from_coin_index: CoinIndex, + from_entry_index: RecyclerEntryIndex, +) -> Result +``` + +Long-running operations of kind `Recover`. Reconstruct records for the listed purses, plus the main purse (always restored). Scan chain storage using a gap-limit strategy (Appendix C). After the operation reaches `Done`, reactive observation continues from the discovered records. + +The operation emits no on-chain extrinsics, so its status stream goes `Preparing` → terminal. Per-record discovery is observable via the event stream (`CoinAvailable`, `EntryAllocated`). + +The layer cannot enumerate non-main purse identifiers from the chain; the caller must supply them from its own backup. + +Pre-submission operation records are not recoverable; any operation mid-flight at the moment durable state was lost is gone. + +Errors (via `Failed` status item): `RecoveryFailed`. + +## 9. Receipts + +When an operation reaches `Done`, the layer attaches a receipt summarizing the on-chain outcome of every extrinsic submitted by the operation: + +```text +struct OperationReceipt { + extrinsics: Vec, +} + +struct ExtrinsicRecord { + extrinsic_hash: ExtrinsicHash, + outcome: ExtrinsicOutcome, +} + +enum ExtrinsicOutcome { + Succeeded { + block_hash: BlockHash, + affected_coins: Vec, // consumed and created together + }, + Rejected { + reason: String, + }, +} +``` + +For a multi-extrinsic operation, the receipt may contain a mix of `Succeeded` and `Rejected` records — `Done` means *at least one* extrinsic succeeded (§5.5); the caller introspects per-extrinsic outcomes here. + +The receipt is emitted as part of the terminal `Done` status item. Per §7.4, the layer may drop the operation record (and the receipt) immediately after emission. + +## 10. Errors + +```text +enum Error { + // Pre-submission + PurseNotFound(PurseId), + OperationNotFound(OperationHandle), + CannotDeleteMainPurse, + PurseHasInFlightOperations, + OutputsDoNotSumToAmount, + InsufficientFunds { requested: Amount, available: Amount }, + InsufficientExternalFunds, + NoReadyEntries { requested: Amount, available_when_ready: Amount }, + NoUnloadToken, // neither free nor paid tokens available + BadCoinSecret, + + // Post-submission / chain + SnipedCoin, + ChainRejected { extrinsic_hash: ExtrinsicHash, reason: String }, + + // Lifecycle + Cancelled, + InterruptedPreSubmission, + + // Internal + StorageError(String), + SubscriptionError(String), + RecoveryFailed(String), + Internal(String), +} +``` + +## 11. Events + +```text +enum LayerEvent { + Resynced, // post-restart reconciliation complete + + PurseCreated { purse: PurseId, name: String }, + PurseRenamed { purse: PurseId, name: String }, + PurseDeleted { purse: PurseId, drained_into: PurseId, amount: Amount }, + + CoinAvailable { purse: PurseId, exponent: DenominationExponent }, + CoinSpent { purse: PurseId, exponent: DenominationExponent }, + CoinAged { purse: PurseId, exponent: DenominationExponent, age: u16 }, + + EntryAllocated { purse: PurseId, exponent: DenominationExponent }, + EntryReadinessChanged { purse: PurseId, exponent: DenominationExponent, + new_state: RecyclerEntryOnChainState }, + EntryConsumed { purse: PurseId, exponent: DenominationExponent }, + + OperationStarted { handle: OperationHandle, kind: OperationKind, purse: PurseId }, + OperationProgress { handle: OperationHandle, status: OperationStatus }, + OperationCompleted { handle: OperationHandle, terminal: TerminalStatus }, + + MaintenanceSweepStarted { purses: Vec }, + MaintenanceSweepCompleted { + coins_recycled: u32, // coin → entry + entries_rescued: u32, // entry → coin + failed: u32, + }, +} +``` + +Records are identified by `(purse, exponent)`, not by derivation index — derivation indices are not part of the API. `Resynced` is emitted exactly once after the layer completes post-restart reconciliation; subscribers treat earlier events as reconstruction and later events as live state changes. + +## 12. Trust boundaries + +### 12.1 No raw cryptography across the API + +The layer holds and uses, but never returns to the caller, any signing key derived from root entropy except as the explicit return value of `export_coins`. The API otherwise exposes only structured values: balances, denominations, ages, readiness states, opaque handles, receipts, errors, events. `export_coins` is the single named exception. + +### 12.2 Information surface + +To the caller, the layer exposes per-purse identity, name, and balance triples; per-operation handles, status streams, and receipts; coin and recycler-entry aggregates via balance and events. Records are not individually addressable from the API. + +To the chain, the layer is an ordinary coinage protocol participant. + +### 12.3 Durable-state confidentiality + +The layer's durable store holds operation records (with extrinsic hashes), local-only timestamps, derivation-index counters, and the root entropy (or a handle to it). Implementations MUST treat the store as confidential and SHOULD encrypt it at rest. The exact scheme is implementation-defined. + +## 13. Bootstrap and recovery + +The layer is initialized with root entropy supplied by the caller. The main purse exists by construction once entropy is present. No non-main purses exist on first initialization; the caller is expected to track non-main purse identifiers and supply them to `recover` if local durable state is ever lost. + +Recovery from root entropy alone is mandatory: given entropy and a list of purse identifiers to restore, the layer reconstructs durable records by chain scanning (Appendix C). Recovery loses local-only state the chain cannot witness — per-entry jitter timestamps reset (entries become immediately eligible once chain readiness is satisfied), and pre-submission operation records are gone. + +## 14. Open questions + +- **Coinage runtime evolution.** Pallet storage / constant / fee changes are not this layer's concern; metadata-aware negotiation is not constrained here. +- **Recovery UX.** Surfacing recovery progress to the user is a layer-above concern. + +--- + +## Appendix A: Recommended parameter values + +Tunable. Implementations SHOULD start from the recommended values. + +### A.1 `recycle_at_age` +**Value:** `chain_coin_max_age − 2`. +**Why:** Margin against the chain age cap absorbs one or two retry windows under congestion or downtime. + +### A.2 `minimum_anonymous_ring_size` +**Value:** `10`. +**Why:** Chain enforces no minimum. A conservative floor. + +### A.3 `recycler_entry_jitter_upper_bound` +**Value:** `6 h`, drawn uniformly from `[0, bound]`. +**Why:** Decorrelates load from subsequent unload. + +### A.4 `recycling_sweep_interval` +**Value:** `24 h`. +**Why:** Catches anything past the threshold within a day. + +### A.5 `free_token_counter_search_range` +**Value:** `[0, 10)`. +**Why:** Matches the chain per-period allowance. Must not exceed it. + +### A.6 `period_lookback_grace` +**Value:** `1 h`. +**Why:** Absorbs transactions prepared near a period boundary. + +### A.7 `recovery_batch_size` +**Value:** `500`. +**Why:** Balances per-batch RPC cost against gap-detection responsiveness. + +### A.8 `recovery_gap_limit` +**Value:** `4 consecutive empty batches`. +**Why:** With `batch_size = 500`, tolerates gaps up to 2000 indices. + +### A.9 `max_split_outputs` +**Value:** `32` (chain-enforced). +**Why:** Pallet cap on outputs per split / unload-into-coins extrinsic. + +### A.10 `max_recycler_entries_per_group` +**Value:** `8` (chain-enforced; pallet `MaxConsolidation`). +**Why:** Pallet cap on entries consolidated per unload-into-coins extrinsic. + +### A.11 `external_offload_retry_interval` +**Value:** `30 s`. +**Why:** Short wake-up used by `external_offload` when the deficit could be covered by coins currently in transient states (locked / recycling / pending-transfer). Long enough to give those transients a chance to settle; short enough to keep the operation responsive. + +### A.12 `ring_expiration_sweep_interval` +**Value:** `24 h`. +**Why:** Periodic schedule for the ring-expiration rescue sweep (§6.4). Same cadence as the coin-age sweep — there is no reason to run them at different frequencies and a single nightly schedule simplifies operations. + +### A.13 `rescue_margin` +**Value:** `25 % of RecyclerExpirationTime`, or at minimum `7 days`, whichever is larger. +**Why:** Slack between the rescue-sweep trigger time and the chain's actual ring expiration. Must be large enough to absorb (a) gaps between sweeps when the host is rarely active, (b) congestion delays for the unload extrinsic, (c) the per-entry jitter and ring-fill time of the rescued coin's eventual re-recycling. Too small → rescue races the chain cleanup. Too large → premature rescue, more unload tokens consumed than necessary. + +## Appendix B: Recommended derivation scheme + +Hard junctions throughout. The key-type tag separates the sr25519 sub-tree used for coin keys from the Bandersnatch sub-tree used for recycler-entry keys, so each sub-tree can be enumerated independently during recovery. + +Paths: + +```text +// Coin at item I in purse P: +//coinage//coin//

//// + +// Recycler entry at item I in purse P: +//coinage//ring-vrf//

//// +``` + +- All segments are hard junctions. +- `

` is the integer purse identifier. The main purse uses a reserved purse identifier (e.g. `0`) — the purse junction is always present. +- `` is `0` for this version of the design. Future versions may partition a purse's index space across pages; until then, every item lives on page `0`. +- `` is the item index within `(purse, page)`. + +This is a clean break from the legacy iOS paths (`//pps//coin//` and `//pps//ring-vrf//`): the root segment changes from `pps` to `coinage`, the purse and page junctions are added, and existing main-purse coins are not on the new path. + +Coin and recycler-entry index counters are maintained independently per purse. Recovery scans the coin sub-tree (sr25519, querying `Coinage::CoinsByOwner`) and the recycler-entry sub-tree (Bandersnatch, querying recycler-location storage) independently, each with its own gap-limit scan (Appendix C). + +## Appendix C: Recommended recovery algorithm + +Parameters: `batch_size`, `gap_limit` (Appendix A.7, A.8). + +```text +recover(non_main_purse_ids): + for purse in {MAIN_PURSE} ∪ non_main_purse_ids: + recover_coins(purse) + recover_entries(purse) + +recover_coins(purse): + cursor = 0 + empty_batches = 0 + while empty_batches < gap_limit: + idxs = [cursor, cursor + batch_size) + accts = derive_coin_accounts(purse, idxs) + results = query_coin_storage(accts) // bulk RPC + for (i, r) in zip(idxs, results): + if r is Some((exponent, age)): + persist Coin { purse, derivation_index: i, + exponent, age: Some(age), state: Available } + empty_batches = (empty_batches + 1) if all None else 0 + cursor += batch_size + +recover_entries(purse): + // analogous over recycler-location storage; each found entry + // is persisted with on_chain_state derived from chain reply, + // local_state = Available, allocated_at = now, ready_at = .distantPast. +``` + +`extend_scan` runs the same algorithm starting at supplied non-zero cursors, for use when a gap is suspected past the previous stopping point. diff --git a/docs/design/coinage-management-contract.md b/docs/design/coinage-management-contract.md new file mode 100644 index 00000000..cf048d80 --- /dev/null +++ b/docs/design/coinage-management-contract.md @@ -0,0 +1,680 @@ +--- +title: "Coinage Management Component — API Contract" +status: "Draft" +--- + +# Coinage Management Component — API Contract + +Companion to [`coinage-management.md`](./coinage-management.md). Names and shapes here are normative. + +## 1. Notation + +- Pseudocode is Rust-flavoured. `T?` = `Option`. `Stream` = async stream. +- MUST / SHOULD / MAY per RFC 2119. +- "Caller" = the RFC‑6 / RFC‑17 layer plus the cheque transport adapter. Not a product. +- Integer widths and byte lengths below are illustrative unless chain-fixed. + +## 2. Durable records + +All records MUST survive restart. Storage layout is implementation-defined. + +### 2.1 Purse + +```text +Purse { + id: PurseId, + name: String, + creator: CreatorId, + created_at: Timestamp, +} +``` + +Invariants: + +- `id` unique. Never reused after deletion. +- Exactly one `Purse` with `id = MAIN_PURSE_ID`; its `creator` is a reserved value. +- `name`, `creator` are caller-supplied, not interpreted. + +### 2.2 Coin + +```text +Coin { + purse: PurseId, + derivation_index: CoinIndex, + exponent: DenominationExponent, + age: Age?, + state: CoinState, +} + +enum CoinState { + Pending, + Available, + LockedFor(OperationHandle), + Spent, +} +``` + +State transitions: §3.1. + +Invariants: + +- `(purse, derivation_index)` unique. +- `derivation_index` within a `purse` never reused. +- `age` is `None` until first chain observation. +- `exponent` fixed for record lifetime. +- `state` per §3.1. + +### 2.3 Recycler entry + +```text +RecyclerEntry { + purse: PurseId, + derivation_index: RecyclerEntryIndex, + exponent: DenominationExponent, + allocated_at: Timestamp, + ready_at: Timestamp, + placement: RecyclerPlacement?, + on_chain_state: RecyclerEntryOnChainState, + local_state: RecyclerEntryLocalState, +} + +RecyclerPlacement { + ring_index: RingIndex, + member_count: u32, +} + +enum RecyclerEntryOnChainState { + Missing, + Waiting, + Ready, + Degraded { member_count: u32 }, +} + +enum RecyclerEntryLocalState { + Available, + LockedFor(OperationHandle), + Consumed, +} +``` + +State transitions: §3.2. + +Invariants: + +- `(purse, derivation_index)` unique. Index never reused. +- `ready_at = allocated_at + jitter_delay` (overview §A.3). +- `placement` is `None` until the chain confirms ring assignment. +- Selectable iff `local_state = Available` ∧ `on_chain_state ∈ {Ready, Degraded}` ∧ `ready_at ≤ now`. + +### 2.4 Receivable + +```text +Receivable { + id: ReceivablePublicKey, + purse: PurseId, + secret_handle: SecretHandle, // never crosses API + created_at: Timestamp, + state: ReceivableState, // Open | Closed + return_context: ReturnContext?, +} +``` + +Invariants: + +- `id` unique. +- A `Closed` receivable accepts no further deposits. Implementations MAY garbage-collect long-idle receivables but MUST preserve any with non-empty `return_context`. +- `return_context` populated by `deposit_cheque` when a return hint is supplied (§6.6). + +### 2.5 Operation + +```text +Operation { + handle: OperationHandle, + kind: OperationKind, + purse: PurseId, + locked_coins: Set<(PurseId, CoinIndex)>, + locked_entries: Set<(PurseId, RecyclerEntryIndex)>, + submitted: Vec, + status: OperationStatus, + created_at: Timestamp, + updated_at: Timestamp, +} + +ExtrinsicSubmission { + extrinsic_hash: ExtrinsicHash, + block_hash: BlockHash?, + affected_coins: Vec, + affected_entries: Vec, +} +``` + +Invariants: + +- `handle` unique, stable across restart. +- `kind` fixed at start. +- Locks released exactly when `status` is terminal. +- Every coin in `locked_coins` has `state = LockedFor(handle)`; same for entries. +- `submitted` is append-only. +- `status` per §3.3. + +### 2.6 Cross-record invariants + +- Every `Coin`, `RecyclerEntry`, `Receivable` references an existing `Purse`. +- A purse cannot be deleted while it has open receivables. +- Every lock in `Operation` corresponds to exactly one record in `LockedFor(handle)` state, and vice versa. + +## 3. State machines + +### 3.1 Coin + +```text + allocated by an operation + │ + v + ┌───────────┐ + │ Pending │ (not yet observed on chain; age = None) + └─────┬─────┘ + │ chain confirms account with age + v + ┌───────────┐ + │ Available │ ◄──────────────────────────┐ + └─────┬─────┘ │ + │ operation locks │ + │ │ release on + v │ pre-submission + ┌──────────────────┐ │ abort or cancel + │ LockedFor(opid) │────────────────────────┘ + └─────┬────────────┘ + │ operation finalizes, chain shows consumed + v + ┌─────────┐ + │ Spent │ (terminal; retained for no-reuse; + └─────────┘ GC by policy) +``` + +- Selection takes only `Available` coins. +- `Pending` → `Available` on first chain observation. +- `LockedFor` → `Available` on pre-submission release. +- `LockedFor` → `Spent` on chain-confirmed consumption. + +### 3.2 Recycler entry + +Two dimensions, observed independently. + +**On-chain:** + +```text + ┌──────────┐ chain shows ┌─────────┐ + │ Missing │ ─ recycler location ► │ Waiting │ + └──────────┘ └────┬────┘ + │ ring member-count ≥ floor + v + ┌──────────────┐ + │ Ready │ + └──────────────┘ + ▲ + │ floor breach (rare) + │ + ┌──────────────────────┐ + │ Degraded(n), n Stream + +struct PurseBalance { + spendable: Amount, + pending: Amount, +} +``` + +- `spendable` = sum of `Available` coins + value of recycler entries that are `(Ready | Degraded) ∧ local_state = Available ∧ ready_at ≤ now`. +- `pending` = sum of `LockedFor` coins + entries that are `Waiting | Missing | LockedFor | ready_at > now`. + +### 4.2 Operation status + +```text +fn subscribe_operation_status(handle: OperationHandle) -> Stream +``` + +Closes after emitting a terminal status. Handle remains valid for synchronous reads. + +### 4.3 Component events + +```text +fn subscribe_events() -> Stream +``` + +Taxonomy in §9. Emits a `Resynced` event after post-restart reconciliation completes. + +## 5. Identifiers and amounts + +```text +type PurseId = u32 // MAIN_PURSE_ID reserved +type CoinIndex = u32 +type RecyclerEntryIndex = u32 +type OperationHandle = OpaqueBytes +type ReceivablePublicKey = [u8; 32] +type CoinAccountId = [u8; 32] +type MemberKey = [u8; 32] +type RingIndex = u32 +type ExtrinsicHash = [u8; 32] +type BlockHash = [u8; 32] +type CreatorId = String +type Age = u16 +type Timestamp = i64 // Unix seconds +type DenominationExponent = i8 +type Amount = u64 // coinage cents; overview §5.5 +``` + +## 6. Operation primitives + +All long-running primitives return: + +```text +struct OperationStart { + handle: OperationHandle, + status: Stream, +} +``` + +### 6.1 Purse lifecycle + +```text +fn create_purse(name: String, creator: CreatorId) + -> Result + +fn query_purse(purse: PurseId) + -> Result + +struct PurseInfo { + id: PurseId, + name: String, + creator: CreatorId, + created_at: Timestamp, + spendable: Amount, + pending: Amount, +} + +fn rebalance_purse(from: PurseId, to: PurseId, amount: Amount) + -> Result + +fn delete_purse(target: PurseId, drain_into: PurseId) + -> Result +``` + +`create_purse`, `query_purse`: synchronous, no chain interaction. + +`rebalance_purse`: on-chain transfer between purse derivation namespaces. Source records become `Spent`/`Consumed`; destination records appear in `to`'s namespace. + +`delete_purse`: drains via rebalance then closes the purse record. Main purse cannot be deleted; purse with open receivables cannot be deleted. + +Errors: `PurseNotFound`, `InsufficientFunds`, `NoReadyVouchers`, `CannotDeleteMainPurse`, `PurseHasOpenReceivables`, `ChainRejection`, `Cancelled`. + +### 6.2 Funding + +```text +trait FundingOrigin { + fn external_account(&self) -> ExternalAccountId; + fn sign_payload(&self, payload: &[u8]) -> Signature; +} + +fn top_up(into: PurseId, amount: Amount, origin: &dyn FundingOrigin) + -> Result +``` + +Decomposes `amount` into recycler-entry denominations, allocates fresh indices in `into`, and submits one external-asset load per denomination signed by `origin`. Per-entry outcomes are reported in the status stream; partial success does not roll back successes. + +Errors: `PurseNotFound`, `InsufficientExternalFunds`, `ChainRejection`. + +### 6.3 Direct transfer + +```text +fn transfer( + from: PurseId, + amount: Amount, + recipient_outputs: Vec, + sender_memo_callback: Option, +) -> Result + +struct RecipientOutput { + exponent: DenominationExponent, + account: CoinAccountId, +} + +type MemoCallback = fn(memo_entries: Vec); +``` + +Total of `recipient_outputs` MUST equal `amount`. Component selects from `from` and routes to the supplied recipient accounts via the three-tier strategy (overview §5.6). + +If `sender_memo_callback` is supplied, the component invokes it once per executed transfer with `MemoEntry` values; the component itself does not encode or transmit memos (Appendix C). + +Errors: `PurseNotFound`, `InsufficientFunds`, `NoReadyVouchers`, `OutputsDoNotSumToAmount`, `ChainRejection`, `Cancelled`. + +### 6.4 Receivable lifecycle + +```text +fn create_receivable(into: PurseId) + -> Result + +fn close_receivable(receivable: ReceivablePublicKey) + -> Result<(), ComponentError> +``` + +`create_receivable`: fresh keypair, secret retained internally, persisted under `into`. + +`close_receivable`: no further deposits accepted. A purse cannot be deleted while it has open receivables. + +### 6.5 Create cheque + +```text +fn create_cheque(from: PurseId, to: ReceivablePublicKey, amount: Amount) + -> Result + +struct ChequeStart { + handle: OperationHandle, + status: Stream, + cheque: Stream, // emits once, then closes +} + +type ChequeBlob = OpaqueBytes; +``` + +Selects from `from`, executes any required split / unload-into-coins extrinsics so the chosen coins exist as separate accounts at the right denominations, then encrypts their secrets to `to` and emits the blob. + +The selected coins remain locked in `from` and remain under `from`'s keys on chain until the receiver deposits. The receiver gains control only on deposit (§6.6). + +Errors: `PurseNotFound`, `ReceivableNotFound`, `InsufficientFunds`, `NoReadyVouchers`, `ChainRejection`, `Cancelled`. + +### 6.6 Deposit cheque + +```text +fn deposit_cheque(blob: ChequeBlob, return_hint: Option) + -> Result + +struct ReturnHint { + sender_account: CoinAccountId, + note: OpaqueBytes?, // caller-opaque payload +} +``` + +Reads the receivable id from the blob, decrypts secrets, and submits a transfer per coin moving it from the sender-controlled account into a fresh coin account in the receivable's purse. Status stream reports per-coin outcomes; partial success is possible (some coins sniped). + +`return_hint`, if supplied, is persisted in `Receivable.return_context` for future refunds. + +Errors: `ReceivableNotFound`, `BadCheque{reason}`, `BadCoins`, `SnipedCoins`, `ChainRejection`, `Cancelled`. + +### 6.7 Refund + +```text +fn refund(receivable: ReceivablePublicKey, amount: Amount?) + -> Result +``` + +Returns `amount` (or all received value if `None`) to the sender recorded in `Receivable.return_context`. Best-effort: if the originally-received coins are gone, falls back to spending other coins from the receivable's purse. Component does not earmark received coins for refunds. + +Errors: `ReceivableNotFound`, `RefundUnavailable`, `InsufficientFunds`, `NoReadyVouchers`, `ChainRejection`, `Cancelled`. + +### 6.8 External offload + +```text +fn external_offload(from: PurseId, amount: Amount, destination: ExternalAccountId) + -> Result +``` + +Moves `amount` from `from` to a non-coinage account. Biases selection toward `unload-into-external-asset`; uses the unload-and-reload variant where leftover value would otherwise sit unprotected. Fee mode auto-selected (overview §5.9). + +Errors: `PurseNotFound`, `InsufficientFunds`, `NoReadyVouchers`, `ChainRejection`, `Cancelled`. + +### 6.9 Recycling sweep + +```text +fn run_recycling_sweep(purses: Option>) + -> Result +``` + +Runs the sweep once against the listed purses (or all if `None`). Sequential per coin, oldest first. Per-coin outcomes reported in the status stream. The component also runs this autonomously on a periodic timer; this primitive forces a run. + +Errors: `PurseNotFound`. + +### 6.10 Payment classification + +```text +fn classify_incoming_payment(parsed_entries: Vec) + -> Result + +struct MemoEntry { + sender_coin_account: CoinAccountId, + recipient_account: CoinAccountId, + recipient_index: CoinIndex, +} + +enum PaymentClassification { + Matched, // every entry corresponds to a coin in this component + Received, // partial; caller should retry + Unmatched, // no entries correspond + Spent, // entries corresponded but coins are gone +} +``` + +Synchronous, no operation started. Empty input → `Unmatched`. Used by the chat layer to drive a payment-state UI without re-querying chain. + +## 7. Receipts + +```text +struct OperationReceipt { + extrinsics: Vec, +} + +struct ExtrinsicRecord { + extrinsic_hash: ExtrinsicHash, + block_hash: BlockHash, + affected_coins: Vec, +} +``` + +Emitted in the terminal `Done` status item and retained on the operation record. RFC‑17 transforms this into `CoinPaymentClearingReference`, redacting as needed. + +## 8. Errors + +```text +enum ComponentError { + // Pre-submission + PurseNotFound(PurseId), + ReceivableNotFound(ReceivablePublicKey), + OperationNotFound(OperationHandle), + InsufficientFunds { requested: Amount, available: Amount }, + NoReadyVouchers { requested: Amount, available_when_ready: Amount }, + InsufficientExternalFunds, + CannotDeleteMainPurse, + PurseHasOpenReceivables, + OutputsDoNotSumToAmount, + RefundUnavailable, + BadCheque { reason: String }, + + // Post-submission / chain + BadCoins, + SnipedCoins, + ChainRejection { extrinsic_hash: ExtrinsicHash, reason: String }, + + // Lifecycle + Cancelled, + + // Internal + StorageError(String), + SubscriptionError(String), + Internal(String), +} +``` + +RFC‑17 mapping (in the RFC‑17 layer): + +| Component | RFC‑17 | +|-|-| +| `InsufficientFunds` | `BalanceLow` | +| `NoReadyVouchers` | UI-surfaced wait, else `BalanceLow` | +| `Cancelled` | `Denied` | +| `BadCoins` | `BadCoins` | +| `SnipedCoins` | `SnipedCoins` | +| `PurseNotFound` | `PurseNotFound` | +| `ReceivableNotFound` | `ReceivableNotFound` | +| `ChainRejection` | `Internal` (logged, not exposed) | +| `StorageError`, `SubscriptionError`, `Internal` | `Internal` | + +## 9. Events + +```text +enum ComponentEvent { + Resynced, + + PurseCreated { purse: PurseId, creator: CreatorId, name: String }, + PurseDeleted { purse: PurseId, drained_into: PurseId, amount: Amount }, + + ReceivableCreated { receivable: ReceivablePublicKey, purse: PurseId }, + ReceivableClosed { receivable: ReceivablePublicKey }, + + CoinAvailable { purse: PurseId, exponent: DenominationExponent }, + CoinSpent { purse: PurseId, exponent: DenominationExponent }, + CoinAged { purse: PurseId, exponent: DenominationExponent, age: Age }, + + RecyclerEntryAllocated { purse: PurseId, exponent: DenominationExponent }, + RecyclerEntryReadinessChanged { purse: PurseId, exponent: DenominationExponent, + new_state: RecyclerEntryReadinessState }, + RecyclerEntryConsumed { purse: PurseId, exponent: DenominationExponent }, + + OperationStarted { handle: OperationHandle, kind: OperationKind, purse: PurseId }, + OperationProgress { handle: OperationHandle, status: OperationStatus }, + OperationCompleted { handle: OperationHandle, terminal: TerminalStatus }, + + RecyclingSweepStarted { purses: Vec }, + RecyclingSweepCompleted { recycled: u32, destroyed: u32, failed: u32 }, + + IncomingChequeDeposited { receivable: ReceivablePublicKey, amount: Amount }, +} +``` + +Records are identified by `(purse, exponent)`, not by derivation index — indices are not part of the API address space. + +`Resynced` fires exactly once after post-restart reconciliation. Subscribers treat earlier events as reconstruction and later events as live changes. + +## Appendix A: Derivation scheme (recommended) + +Same root entropy → same coin and recycler-entry accounts. Purse-scoped paths under the coinage root: + +```text +// Coin at index I in purse P: +//coinage//

/// + +// Recycler entry at index I in purse P: +// purse-scoped equivalent, with

inserted immediately after //coinage +``` + +Purse ID is a hard junction after `//coinage`. Main purse uses a reserved purse identifier. Matches RFC‑17 Appendix A. + +Properties: non-overlapping purse namespaces; recoverable from root entropy; new purses cost only an identifier. + +## Appendix B: Recovery algorithm (recommended) + +Parameters `batch_size`, `gap_limit` tunable (overview §A.7, §A.8). + +```text +recover(): + for each known purse id (main + product purses backed up by the layer above): + recover_coins(purse) + recover_recycler_entries(purse) + +recover_coins(purse): + cursor = 0 + empty_batches = 0 + while empty_batches < gap_limit: + idxs = [cursor .. cursor + batch_size) + accts = derive_coin_accounts(purse, idxs) + results = query_coin_storage(accts) // bulk RPC + for (i, r) in zip(idxs, results): + if r is Some((exponent, age)): + persist Coin { purse, derivation_index: i, + exponent, age: Some(age), state: Available } + empty_batches = (empty_batches + 1) if all None else 0 + cursor += batch_size + +recover_recycler_entries(purse): + // analogous against recycler-location storage; persisted records get + // local_state = Available, allocated_at = now, ready_at = .distantPast + // (jitter lost; entry eligible once chain readiness is satisfied). +``` + +`extend_scan(purse, start_index)` is exposed so callers can probe deeper if a gap is suspected. + +Product purse identifiers are bootstrap data the layer above MUST persist alongside the user's backup; without them only the main purse is recoverable. + +## Appendix C: Memo classification interface + +The component does not know the wire encoding of direct-transfer memos. The chat layer owns the schema; the component owns the matching primitive (§6.10). + +Adapter shape: + +1. Chat layer encodes `Vec` into its wire format (e.g. SCALE-encoded `TransferMemo`) and attaches to a chat message. +2. Recipient's chat layer decodes back to `Vec`, calls `classify_incoming_payment`. +3. Chat layer drives the per-message UI state machine from the returned `PaymentClassification`. + +On-chain transfer outcomes are independent of the memo — coins are owned regardless. Memo is metadata for UI only. diff --git a/docs/design/coinage-management.md b/docs/design/coinage-management.md new file mode 100644 index 00000000..a0cd0cbb --- /dev/null +++ b/docs/design/coinage-management.md @@ -0,0 +1,316 @@ +--- +title: "Coinage Management Component" +status: "Draft" +--- + +# Coinage Management Component — Design + +## 1. Summary + +The Coinage Management Component is the internal subsystem of a Triangle Host that owns all coinage state and chain interaction: purses, coins, recycler entries, transfers, cheques, recycling, and unload tokens. + +It sits below the host's RFC‑6 and RFC‑17 layers. It is sufficient to implement them. It does not enforce product permissions, consent UI, or transport. + +The detailed API contract is in [`coinage-management-contract.md`](./coinage-management-contract.md). + +## 2. Scope + +### 2.1 In scope + +Per-purse derivation isolation, age tracking, recycling, ring-membership pre-loading, readiness evaluation, unload-token allowance, atomic per-recycler unloads, selection across coins and recycler entries, recovery from root entropy. + +### 2.2 Out of scope + +- Product-facing API (RFC‑6, RFC‑17 live above). +- Permissions and consent UI. +- Cheque wire transport (statement store, HOP, deep links, QR). +- Multi-device sync (RFC‑17 unresolved). +- Chain abstraction — the component is coinage-pallet specific. + +### 2.3 Mapping to RFC‑6 / RFC‑17 + +| RFC primitive | Component primitive | +|-|-| +| `host_payment_balance_subscribe` | Purse balance subscription | +| `host_payment_top_up` | Funding via abstract origin | +| `host_payment_request` | External offload | +| `host_payment_status_subscribe` | Operation status | +| `create_purse` / `delete_purse` / `query_purse` / `rebalance_purse` | Purse lifecycle | +| `create_receivable` | Receivable creation | +| `create_cheque` | Cheque generation | +| `deposit` | Cheque deposit | +| `refund` | Refund | +| `listen_for_payment` | Transport responsibility | +| `CoinPaymentClearingReference` | Per-extrinsic receipt | + +## 3. Architecture + +``` ++--------------------------------------+ +| Product (webview) | speaks TrUAPI ++--------------------------------------+ +| RFC-6 / RFC-17 layer | permissions, consent, product identity +| + Cheque transport adapter | ++------+-------------------+-----------+ + | | + v v ++-------------------+ +---------------------------+ +| Coinage | | Cheque transport backend | +| Management | | (statement store, HOP) | +| Component | | | ++-------------------+ +---------------------------+ + | + v ++--------------------------------------+ +| Chain (Coinage pallet, Members, ...) | ++--------------------------------------+ +``` + +Three boundary contracts: + +1. **Downward (chain).** Component is the sole writer of coinage-pallet state. Higher layers cannot construct raw coinage extrinsics. +2. **Upward (API).** Structured primitives only. No coin secret, member key, ring-VRF proof, signed extrinsic byte, or derivation path crosses this boundary. Detailed in the contract doc. +3. **Lateral (transport).** Cheques cross as opaque blobs. Wire format, channel addressing, and oversized-payload handling are outside the component. + +## 4. Core concepts + +**Purse.** Firewalled coinage balance with an isolated derivation namespace. Exactly one main purse (reserved identifier) plus zero or more product-created purses. A purse owns coin and recycler-entry inventories, open receivables, and in-flight operations. Per-purse metadata: identifier, name, creator, created-at. + +**Coin.** Chain-level NFT representing a fixed denomination of dotUSD, identified by a derived sr25519 account. Has `exponent` (denomination = `2^exponent`) and `age`. The chain increments `age` on every transfer or split; once it reaches a chain-enforced maximum, the coin is unusable. Spending a coin transfers it to a recipient account, or splits it into smaller coins, or recycles it (§6.4). + +**Recycler entry.** A Bandersnatch keypair the component placed into a chain recycler ring — a privacy anonymity pool of many users' entries. To realize value, the component **unloads** the entry by submitting a Ring VRF proof of ring membership, receiving a fresh age‑0 coin in return. An entry holds no spendable value on its own; value is realized at unload time. An entry must wait for its ring to fill enough that the membership proof is meaningfully anonymous. + +**Receivable.** Public key generated by the component; the corresponding secret is retained internally. Bound to one purse. Used by a sender to encrypt a cheque destined for that purse. Durable across restart. + +**Cheque.** Opaque encrypted blob conveying coin secrets to a receivable. Produced from `(source purse, target receivable, amount)`. Consumed by deposit. Wire transport is external. + +**Operation.** Long-running task: transfer, cheque create / deposit, refund, top-up, external offload, rebalance, recycling sweep, purse delete. Each operation has a durable opaque handle, a persisted record, a status stream, and a set of locked coins / recycler entries that no other operation may touch until terminal. + +## 5. Data model + +### 5.1 Per-purse isolation + +Each purse has its own coin-index and recycler-entry-index counters. Index `7` in purse A and index `7` in purse B address different on-chain accounts because the derivation paths differ. No record needs an explicit purse pointer beyond the `(purse, index)` pair — purse membership is implied by derivation. + +### 5.2 No-reuse for derivation indices + +A coin or recycler-entry derivation index, once allocated within a purse, is never reused. + +A coin's derived account ID may have appeared in a memo passed out-of-band; a recycler entry's member key sits in a public ring member list. Reuse would correlate new activity with old. Implementations may realize the invariant via a monotonic counter or a one-past-highest probe. + +### 5.3 Coin lifecycle + +Each coin record carries a lifecycle state: + +- **Pending** — created locally as a future output of an in-flight operation; chain account not yet observable. Age unknown. +- **Available** — chain confirms the account holds a coin and reports its age. Selectable. +- **LockedFor(opid)** — held by an in-flight operation. Not selectable until released. +- **Spent** — terminal. Chain confirms the account is empty. Record retained for the no-reuse invariant (§5.2); may be garbage-collected by policy. + +Transitions: `Pending → Available` on first chain observation; `Available → LockedFor` on operation lock; `LockedFor → Available` on pre-submission abort or pre-submission cancel; `LockedFor → Spent` on operation finalize and chain-confirmed consumption. + +### 5.4 Recycler entry — on-chain readiness + +An entry's anonymity at unload time comes from its ring: a Ring VRF proof hides the prover among that ring's members, so the larger the ring, the stronger the anonymity. The chain accepts unloads from rings of any size; the component imposes its own **anonymity floor** — a minimum ring member-count below which it flags the entry as offering reduced anonymity. Floor value: Appendix A.2. + +Each entry has an on-chain readiness state derived from chain observation: + +- **Missing** — chain reports no recycler location. Either the load extrinsic has not finalized, or the entry was consumed and the local record should be deleted. +- **Waiting** — chain shows a recycler location, but the ring is in onboarding or readiness conditions are unmet. +- **Ready** — ring member-count meets or exceeds the anonymity floor. +- **Degraded(n)** — ring member-count is `n`, below the floor. + +Selection treats `Degraded` entries as usable but the layer above is expected to surface the state so the user can defer or accept reduced anonymity. + +### 5.5 Readiness jitter + +When the component creates a new recycler entry (load or recycling), it records the creation timestamp as `allocated_at` and draws a per-entry random delay from `[0, D]`. The entry's `ready_at` is set to `allocated_at + delay`; until then it is not selectable, regardless of on-chain readiness. + +Without jitter, an observer with timing data could match a load to its subsequent unload. The bound `D` is tunable (Appendix A.3). This is a SHOULD, not a MUST. + +### 5.6 Recycler entry — local lifecycle + +Independent of on-chain readiness, each entry has a local lifecycle state: + +- **Available** — free for selection. +- **LockedFor(opid)** — held by an in-flight operation. +- **Consumed** — terminal. The operation finalized and the entry was unloaded; record retained for the no-reuse invariant, GC by policy. + +Selectability requires `local_state = Available` AND `on_chain_state ∈ {Ready, Degraded}` AND `ready_at ≤ now`. + +## 6. Operational model + +### 6.1 Reactive on-chain observation + +The component holds continuous subscriptions to every chain storage entry backing its local records (coin storage, ring member storage, recycler revision and member-count). Subscription events update local records in place. The component does not pull-poll; callers read its locally cached view. + +The component is therefore long-lived. Subscription updates must be reconciled with locally-initiated changes — for example, a `LockedFor` coin observed empty on chain transitions to `Spent` cleanly. + +### 6.2 Balance + +Per purse, two values, emitted on every change by the balance subscription: + +- **Spendable** — sum of `Available` coins + value of recycler entries that are `(Ready ∨ Degraded) ∧ local_state = Available ∧ ready_at ≤ now`. +- **Pending** — sum of `LockedFor` coins + value of recycler entries that are `Waiting`, `Missing`, `LockedFor`, or have `ready_at > now`. + +### 6.3 Selection + +When the component must spend a specified amount from a purse, it tries three strategies in order: + +1. **Exact match.** Subset of `Available` coins summing exactly to the target. Zero extrinsics. Tiebreak: oldest coins first. +2. **Split.** Smallest single coin exceeding the target, split into target + change. Falls back to multi-coin cover with a final split. One extrinsic, no unload token. Tiebreak: smallest sufficient coin; otherwise oldest among multi-coin covers. +3. **Unload into coins.** Use selectable recycler entries (§5.5) to mint coins of the target denominations. Coins may contribute partial value. Entries are grouped by `(denomination, ring)`; each group becomes one atomic `unload-into-coins` extrinsic with its own unload token (§6.5). Tiebreak: smallest sufficient single entry; otherwise greedy largest-first cover. + +If all three fail but recycler entries would have covered the amount once ready, return a distinct error so the caller can surface "wait" rather than "insufficient funds". + +### 6.4 Recycling + +A coin's age is bounded by the chain. The component must recycle aging coins into fresh recycler entries before they hit the cap. Two mechanisms: + +**Payment-folded.** Selection prefers older coins (§6.3) and the unload-into-coins strategy emits fresh age‑0 coins. Active wallets refresh themselves. + +**Periodic sweep.** A scheduler runs a sweep on a tunable interval. Per purse, scans `Available` coins with age ≥ `recycle_at_age`, oldest first, and submits one recycler-load extrinsic per coin. Post-submission failure marks the coin `Spent`; pre-submission failure releases the lock for retry on the next sweep. + +The sweep also runs **opportunistically** on age-threshold crossings, caller activity hints, and explicit `run_recycling_sweep` calls. Implementations choose which opportunistic triggers to honor; the periodic schedule is the contractual minimum. + +A successful recycling consumes the coin and produces a new recycler entry whose `ready_at` is set per §5.6. + +### 6.5 Unload tokens + +Every unload of a recycler entry consumes one unload token. Two classes: + +- **Free** — derived from the user's people / lite-people ring membership. Per-period allowance. +- **Paid** — derived from a period-specific ring joined by paying a fee (an on-chain extrinsic). + +For `N` tokens needed in one multi-group unload: + +1. Probe `ConsumedFreeUnloadTokens` for the current period (and one period back within a grace window) and pick unconsumed slots. +2. If free slots run out, fall back to paid. Join the current paid ring first if needed. + +Callers do not select the class. Per-token cost is reported in the operation status stream. + +### 6.6 Fee mode + +Unloads support **prepaid** (fee from external funds) or **from-output** (fee deducted from unloaded value). The component picks automatically based on available external fee funds. Callers do not select. + +## 7. Operation lifecycle + +### 7.1 Status states + +Every operation emits its progress through a state machine: + +| State | Meaning | +|-|-| +| `Preparing` | Selecting, signing, encrypting. No extrinsic submitted. | +| `Submitted` | Extrinsic broadcast. | +| `InBlock` | Included in an unfinalized block. | +| `Finalized` | Extrinsic finalized. | +| `Failed{reason}` | Terminal failure. | +| `Done{receipt}` | Terminal success with receipt. | + +`Submitted` / `InBlock` / `Finalized` may recur for operations that submit multiple extrinsics (e.g. multi-group unloads). Exactly one terminal item closes the stream. + +### 7.2 Handles + +Every operation returns an opaque durable handle. Sufficient to: subscribe to status, read status, cancel (§7.3). Component-issued. Non-overlapping operations run concurrently; lock conflicts are impossible by construction. + +### 7.3 Cancellation + +Before first submission: caller may cancel; locks released, `Failed{Cancelled}` emitted. + +After first submission: no cancel. Caller waits for terminal status. Dropping the subscription is always safe; the operation continues. + +### 7.4 Restart durability + +Operations are persisted at start, alongside their locks. On restart: + +1. Read back open operations and locked records. +2. Re-establish subscriptions for affected accounts. +3. For each operation: resume watching if mid-chain, finalize if chain shows completion, fail if pre-submission. +4. Re-subscription via the durable handle. + +Pre-submission scratch state (in-progress selection, partial encryption) is not durable. A restart in `Preparing` aborts the operation. + +## 8. Trust and security + +### 8.1 No raw crypto across the API + +The component holds and uses, but never exposes, coin secrets, recycler-entry member keys, receivable secrets, ring-VRF witnesses, and signed extrinsic bytes. The API surfaces structured values only: balances, denominations, ages, readiness states, handles, opaque cheque blobs, receipts, errors. + +Two preserved-opacity cases worth flagging: + +- Cheque blobs returned to the caller are opaque; the caller transports without inspecting. +- Receipts include extrinsic / block hashes and affected coin account IDs. Account IDs are public but identify coins; the RFC layers redact for product-facing receipts. + +### 8.2 Information surface + +Exposed to the caller: + +- Per-purse: identity, metadata, spendable, pending. +- Per-operation: handle, kind, status, receipt. +- Per-coin / per-recycler-entry: only in aggregate via balance and events. Not individually addressable. +- A typed event stream (contract doc §9). + +### 8.3 Confidentiality at rest + +The durable store holds receivable secrets, in-flight operation records, refund return contexts, and operation logs. Implementations MUST treat the store as confidential and SHOULD encrypt at rest. Scheme implementation-defined. + +## 9. Bootstrap and recovery + +The component is initialized with **root entropy** — the user's wallet seed, supplied by the layer above. All purse-scoped derivation (coin accounts, recycler entry member keys, receivable secrets) hangs off this seed; the component never generates entropy itself. Identical seeds across instances derive identical accounts, which is what makes recovery possible. + +The main purse is implied by the entropy. Product purses must be re-discovered (the layer above persists purse identifiers alongside the user's backup). + +Recovery from entropy alone is **required**. Given entropy, the component reconstructs durable state by scanning the chain with a gap-limit strategy. Algorithm specifics in the contract doc Appendix B. + +Recovery loses local-only state: per-entry jitter timestamps reset, pre-submission operations are abandoned, refund contexts not backed up by the caller are gone. Acceptable: chain is authoritative for value; jitter randomization is fine to reset; refund context should be backed up by the layer above. + +## 10. Open questions + +- **Recovery UX.** Higher-layer concern. +- **Channel descriptors.** Transport-layer concern. +- **Coinage runtime evolution.** Pallet storage / constant / fee changes are not our business; metadata-aware negotiation is not constrained here. + +## Appendix A: Recommended parameter values + +Tunable. Implementations SHOULD start from the recommended values and revisit when operational evidence justifies a change. + +### A.1 `recycle_at_age` +**Value:** `chain_coin_max_age − 2` (currently `14`). +**Why:** Margin against the chain's hard age cap absorbs one or two retry windows. More margin recycles too eagerly; less risks aging out under congestion or downtime. + +### A.2 `minimum_anonymous_ring_size` +**Value:** `10`. +**Why:** Chain enforces no minimum. `10` is a conservative floor; lower weakens claimed anonymity, higher starves low-activity denominations. + +### A.3 `recycler_entry_jitter_upper_bound` +**Value:** `6 h`. +**Why:** Long enough to absorb several blocks of ring growth and break load / unload timing correlation, short enough for users to plan around. Implementations SHOULD draw uniformly from `[0, bound]`. + +### A.4 `recycling_sweep_interval` +**Value:** `24 h`. +**Why:** Catches anything that crossed the threshold in the last day with comfortable margin. More frequent: wasted extrinsics; less: risk on idle hosts. + +### A.5 `free_token_counter_search_range` +**Value:** `[0, 10)`. +**Why:** Matches the chain per-period allowance. Must not exceed it. + +### A.6 `period_lookback_grace` +**Value:** `1 h`. +**Why:** Absorbs transactions prepared near a period boundary. Larger expands the search space for nothing; smaller risks rejecting valid attempts. + +### A.7 `recovery_batch_size` +**Value:** `500`. +**Why:** Balances per-batch RPC cost against gap-detection responsiveness. + +### A.8 `recovery_gap_limit` +**Value:** `4 consecutive empty batches`. +**Why:** With `batch_size = 500`, tolerates gaps up to 2000 indices — beyond any organic gap. `extend_scan` is available for deeper probing. + +### A.9 `max_split_outputs` +**Value:** `32` (chain-enforced). +**Why:** Pallet cap on outputs per split / unload-into-coins extrinsic. Tracks the pallet. + +### A.10 `max_recycler_entries_per_group` +**Value:** `8` (chain-enforced; pallet `MaxConsolidation`). +**Why:** Pallet cap on entries consolidated per unload-into-coins extrinsic. Tracks the pallet. diff --git a/docs/specs/COINAGE-LAYER-WORK-NOTES.md b/docs/specs/COINAGE-LAYER-WORK-NOTES.md new file mode 100644 index 00000000..72d92e0f --- /dev/null +++ b/docs/specs/COINAGE-LAYER-WORK-NOTES.md @@ -0,0 +1,337 @@ +# Coinage Layer — Work-in-Progress Notes + +Working handoff for continuing the Coinage Layer design + Quint formal-spec work. +Read this top-to-bottom and you should have everything needed to resume. + +## 1. Repo state + +- Branch: `add-coinage-design`, PR #122 open against `main`. +- Last committed work: `61c61f5` "docs: add coinage management component design" (the original unified design doc, before the bottom-layer split). PR is open with that commit. +- **Uncommitted** work-in-progress on disk: + - `docs/design/coinage-layer.md` — the bottom-layer design (the one we want to land). + - `docs/specs/coinage-layer.qnt` — the Quint formal spec (working skeleton). + - `docs/specs/COINAGE-LAYER-WORK-NOTES.md` — this file. +- The earlier doc `docs/design/coinage-management.md` + `docs/design/coinage-management-contract.md` are the original *unified* design. The user explicitly asked NOT to touch them; the new bottom-layer split lives in `coinage-layer.md`. + +## 2. Context — what this is about + +The user is rebuilding the protocol-layer design for the Triangle Host's coinage subsystem. Two existing implementations grew organically without a design: +- iOS app `paritytech/polkadot-app-ios-v2` (branches `develop` and `feature/payment-request`) +- Rust crate `paritytech/useragent-kit` + +Goal: write the design that SHOULD have come first, split into a bottom layer (this work) and a top RFC‑17 layer (later). Then formally verify both layers via Quint, and eventually verify a Rust implementation against the spec. + +## 3. Architecture: two-layer split + +- **Bottom layer (Coinage Layer)** — self-contained coinage. Owns coins, recycler entries, purses, recycling, selection, unload tokens. Knows nothing about RFC‑17 product concepts. +- **Top layer (Coinage Payment / RFC‑17)** — adds receivables, cheques, refunds, RFC‑17 product-facing API. Built on bottom-layer primitives. +- **Layer seam**: `export_coins` and `import_coins`. The only API points where coin secrets cross the boundary. Top layer wraps these to build RFC‑17 cheques. + +## 4. Design decisions baked into `coinage-layer.md` + +All committed dialog answers, in one place: + +| Decision | Choice | +|-|-| +| Scope | Internal layer, contract-aware (interface boundary fully specified) | +| Purse model | Purse-aware (one component, many purses, main purse has reserved id) | +| Recycling | Both — payment-folded + periodic backstop sweep | +| State sync | Reactive subscriptions | +| Local locks | Full lifecycle states for coins and entries | +| Coin index allocation | Strict no-reuse invariant | +| Entry index allocation | Same no-reuse invariant | +| Balance model | Spendable + spendable_strict + pending per purse | +| Payment primitives | Both direct transfer AND cheque (via export/import seam) | +| Voucher jitter | SHOULD (recommended), not MUST | +| Anonymity floor | Explicit Ready / Degraded; global to layer (not per-purse) | +| Unload concurrency | Design-agnostic (constraints + per-group outcomes specified) | +| Memo classification | First-class primitive; memo bytes opaque | +| Refunds | First-class with stored return context (top layer concern) | +| Funding source | Abstract origin (opaque signing authority) | +| Cheque transport | Blob in/out; transport external to layer | +| API boundary | No raw crypto across API except export_coins as named exception | +| Unload tokens | Free + paid, automatic fallback | +| Recovery | Required from entropy alone; mechanism in appendix | +| Purse metadata | Layer owns full metadata (id, name only — creator etc. is RFC-17 layer) | +| Receipt shape | Per-extrinsic with affected coin account IDs and outcome | +| Status stream | Internal lifecycle (Preparing/Submitted/InBlock/Finalized/Waiting/Done/Failed) | +| Selection | Three-tier prescribed (exact / split / unload); deterministic order | +| Restart durability | Full — operations resume; subscriptions torn down | +| Cancellation | Pre-submission and Waiting only | +| Errors | Internal taxonomy; RFC-17 layer maps | +| Sweep triggers | Periodic + opportunistic | +| Observation | Per-purse balance + per-op status + typed event stream | +| Direct-transfer memo | Component models as opaque blobs | +| Index scope | Per-purse | +| Terminology | "Recycler entry" (not "voucher") | +| Derivation paths | `//coinage//coin//

////` and `//coinage//ring-vrf//

////` — all hard junctions, page=0 for now, main purse uses purse id 0 | +| External offload | Multi-phase planner-driven; auto-recycles coins; supports Waiting state; surplus always atomically reloaded as fresh entries; defaults `allow_degraded = false` | +| `host_payment_request` mapping | Maps to `external_offload`, NOT `export_coins` | +| Ring-expiration rescue | Second autonomous sweep (entry → coin via `unload_recycler_into_coins`) — mandated; iOS bug to be filed | +| Maintenance sweep API | Unified `run_maintenance_sweep` covering both age-recycle AND ring-rescue | + +## 5. Key bug discovered + +**iOS silent loss-of-funds bug** (both `develop` and `feature/payment-request`): + +iOS's `CoinageRecyclingService` only recycles coins INTO entries. It never unloads entries OUT. If a user tops up (creating entries) and doesn't open the app long enough for the ring to be cleaned up by chain (`immutableSince + RecyclerExpirationTime`), the entry's backing value is destroyed silently. + +This is what motivated the **ring-expiration rescue sweep** in §6.4 of the new design. + +Should be filed as a security-grade bug against the iOS app independent of this design work. + +## 6. Quint spec status + +File: `docs/specs/coinage-layer.qnt`. Currently **3726 lines**. All 12 original work-plan steps complete, plus 7 behavioral-contract tracks (A–G), plus 3 design-coverage tasks (1–3). **Last committed: `bedc7ae`** (the 3066-line behavioral-contract version); the additional ~660 lines for design-coverage tasks 1–3 are uncommitted. + +### Coverage of design doc (`docs/design/coinage-layer.md`) + +Roughly **92–93%** of the design is reflected in the spec. See §9 below for the detailed gap analysis. + +### Verification status (current uncommitted code) + +- **Simulator**: `safety` (28 invariants combined) passes 5 consecutive runs at 2000 traces × 40 steps. +- **Apalache (on `bedc7ae`, 3066 lines)**: `safety` aggregate clean at `--max-steps=3` (165s); individual invariants like `conservation`, `noEntryOnExpiredRing`, `derivationInjective` clean at depth 3 (~30–80s each). +- **Apalache (on current 3726 lines)**: not yet re-run after design-coverage additions. Pending. + +### What's modeled +- **State machines** — coin lifecycle, entry on-chain readiness (with anonymity floor), entry local lifecycle, operation status (full Submitted/InBlock/Finalized/Waiting/Done/SFailed(reason) progression). +- **Primitives** — `createPurse`, `renamePurse`, `deletePurse`, `rebalancePurse`, `topUp`, `transfer`, `transferAmount` (multi-coin selection), `exportCoin`, `importCoin`, `startExternalOffload`, `cancelOp`, `opOffboard`, `opEnterWait`, `opWake`, `opAdvanceToSubmitted`, `opAdvanceToInBlock`, `opAdvanceToFinalized`, `opChainReject`, `coinAgeRecycle`, `ringExpirationRescue`, `runMaintenanceSweep`, `restart`, `joinPaidRing`, `topUpFeeAccount`, `recover`, `extendScan`, `chainPromoteToReady`, `chainSealRing`, `chainExpireRing`, `tick`. +- **Functional defs** — `queryPurse`, `purseSpendable`, `purseSpendableStrict`, `pursePending`, `classifyIncomingPayment`, `selectionFeasible` (3-tier covering predicate), `deriveCoinAccount`, `deriveMemberKey` (Appendix B derivation). +- **State variables** — purses, coins, entries, operations, rings, receipts (per-handle list of `ExtrinsicRecord`), events (typed log per §11), tokens (period × class × counter), paid-ring membership, fee account balance, time. +- **Failure modes** — `FailureReason` enum (`FRSnipedCoin`, `FRChainRejected`, `FRCancelled`, `FRInterruptedPreSubmission`, `FRStorage`, `FRSubscription`, `FRRecovery`, `FRInternal`). +- **Unload tokens** — free (per-period, indexed counter) and paid (with one-time ring-join fee). Fee-mode pick (`FMPrepaid` vs `FMFromOutput`) follows §6.6. + +### Invariants (all pass under simulator, 5000 traces × 60 steps) + +| Invariant | What it asserts | +|-|-| +| `coinIndexBounded`, `entryIndexBounded` | No-reuse namespace invariant | +| `lockConsistency` | Coin/entry locks ↔ operation `lockedCoins`/`lockedEntries` agree | +| `coinAgeBound` | Available coins never reach `MaxAge` | +| `conservation` | `totalIn − totalOut == liveValue` | +| `terminalReleasesLocks` | Terminal ops release all locks | +| `noEntryOnExpiredRing` | The rescue-sweep contract | +| `mainPurseExists` | Main purse never deleted | +| `operationsPurseExists` | Active ops reference live purses | +| `externalOffloadLocksEntriesOnly` | KExternalOffload never locks coins | +| `liveRecordsRefExistingPurse` | Live records reference live purses | +| `terminalOpsHaveReceipts` | Terminal ops have at least one receipt | +| `receiptOutcomeMatchesStatus` | SDone → some XSucceeded; SFailed → some XRejected | +| `terminalOpsHaveCompletedEvent` | Every terminal op has an `EOperationCompleted` event | +| `feeBalanceNonNegative` | Fee account never goes negative | +| `tokenRecordsConsistent` | Token-map keys match record fields | +| `midSubmissionHoldsLocks` | Submitted/InBlock/Finalized ops still hold locks | +| `derivationDeterministic` | Every coin/entry's account/key matches `derive*` | +| `derivationInjective` | Distinct (purse, idx) ⇒ distinct account/key | +| `handleMonotone` | `nextHandle` > every issued handle | +| `ringIntegrity` | `rings.get(r).idx == r` | +| `consumedFreeTokensInRange` | Free-token counters within search range | +| `eventOrderOpStartBeforeComplete` | Op-completed events preceded by op-started events | +| `noCoinResurrection`, `noEntryResurrection` | Records keyed at their own purse+idx | + +### Key modeling abstraction +`chainExpireRing` is gated on `ringEntriesAllConsumed(ridx)` — the chain action only fires after every entry on the ring is in terminal state. This encodes the **design contract** that the host rescues entries before the chain destroys them. Without this gate, the simulator finds traces matching the iOS silent-loss bug. + +### Verification workflow followed per step +1. `quint typecheck docs/specs/coinage-layer.qnt` — must be clean. +2. `quint run docs/specs/coinage-layer.qnt --invariant=safety --max-samples=2000 --max-steps=50` — must pass. +3. New invariants — additionally checked individually with `--invariant=NAME`. + +### Behavioral-contract upgrades (tracks A–G, after step 12) +- **A**: every API primitive produces a real multi-phase op (Transfer/TopUp/Export/Import/Rebalance). +- **B**: deterministic-selection ordering scaffolding (`coinOrderLT`, `entryOrderLT`, `coinPriorityRank`, `entryPriorityRank`); `startTransferDeterministic` for single-coin tier-1. +- **C**: ring sizes tracked; `chainPromoteToDegraded` action; `anonymityFloorEnforced` invariant (later moved out of `safety` — see §11 below). +- **D**: external offload surplus reload (`opOffboard` requires `surplusExponent`; `opRequested` per-op state var). +- **E**: `runMaintenanceSweep` actually fires one eligible recycle/rescue per call. +- **F**: three temporal properties (`everyOpEventuallyTerminates`, `everyAgingCoinEventuallyConsumed`, `everyNearExpiryEntryRescued`). Liveness checking requires Apalache fairness flags; not yet run. +- **G**: `chainSnipeCoin` adversary action exercises the `FRSnipedCoin` failure path. + +### Design-coverage tasks 1–3 (latest, uncommitted) +- **Task 1**: multi-coin deterministic selection — `selectExactCoverDeterministic` function returning the lex-min exact cover via powerset + lex-min fold; `startTransferDeterministicMulti(p, amount)` action. +- **Task 2**: multi-group unload — `startExternalOffloadMulti(ents, requested)` locks a set; `opOffboardGroup(h, gExp, gRing, surplusExponent, externalForGroup)` consumes one (denom, ring) group per call; `opCompleteExternalOffload(h)` asserts `externalized == requested`. Legacy single-entry `opOffboard` gated to `lockedEntries.size() == 1`. `canAdvanceToSubmitted` tightened to require pending locks for non-allocate-only kinds. +- **Task 3**: gap-limit recovery — `chainCoins`/`chainEntries` mirror state vars (with creation-time semantics); `BatchSize=2`, `GapLimit=2`, `MaxScanIndex=10`; pure `gapLimitScanCoins`/`gapLimitScanEntries`; `restartAndRecover(p)` atomic loss+recover action (DEFINED but excluded from default `step` because chain stores creation-state records and would reincarnate locally-spent records, breaking conservation; testable in dedicated scenarios). `recover` refactored to use gap-limit scan with purse cursors updated to max idx+1. Every creation site mirrors into chain. `deletePurse` wipes local AND chain records. `chainMirrorsLocal` invariant added to `safety`. + +### Limitations of current spec +- **Apalache check on current 3726-line spec** not yet re-run after design-coverage tasks. Last Apalache baseline is at commit `bedc7ae` (3066 lines, depth 3). +- **`restartAndRecover` not in default `step`** — recovery is encoded but only testable in dedicated scenarios. Resolving this needs the chain mirror to track state changes (spends, lock/release) not just creates. +- **`anonymityFloorEnforced` moved out of `safety`** — `deletePurse` shrinks rings post-seal; the invariant is a per-promotion property (already enforced by `chainPromoteToReady`'s precondition) not a state invariant. Definition still present in spec, just not in the aggregate. +- **Subscription state (§8.9) absent** — events log is the underlying substrate, but there's no subscription stream entity. +- **Probabilistic uniform-random jitter** is modeled as deterministic max (Quint can't natively express probability distributions). +- **Information surface (§12.2)** isn't modeled — would need info-flow formalism. + +### What's NOT achievable in pure Quint (~5–8% of design) +- Probabilistic-distribution semantics (uniform-random jitter). +- Information-flow / observer / privacy analysis. +- True cryptographic primitive correctness (BLS/Bandersnatch oracles). +- Full external-offload planner re-planning loop (Plan → Recycle → Wait → Offboard with re-planning). +- Multi-entry surplus reload over non-power-of-2 amounts (current model requires `coinValue(surplusExponent) == surplus`). + +## 7. Verification workflow + +Every change to the spec: +1. `quint typecheck docs/specs/coinage-layer.qnt` — must be clean. +2. `quint run docs/specs/coinage-layer.qnt --invariant=safety --max-samples=2000 --max-steps=50` — must pass. +3. For new invariants: also check individually with `--invariant=NAME`. + +## 8. Quint syntax cheat sheet (learned the hard way) + +- Action params need return type: `action foo(x: int): bool = ...` +- Local `val`s must be **before** an `all { ... }` block, not inside it. Pattern: `action foo(x): bool = { val a = ...; val b = ...; all { conds, effects } }`. +- Record update is `r.with("field", value)`, NOT `{ ...r, field: v }`. +- `Rec` is a built-in name; use `MyRec` etc. +- `nondet x = oneOf(set)\n action(x),` separates branches inside `any { ... }`. +- Set methods include `.fold(init, fn)`, `.filter`, `.map`, `.forall`, `.exists`, `.exclude`, `.contains`. +- Quint CLI: `--invariant=NAME` is on `run` and `verify`, not on `test`. + +## 9. Known iOS / useragent-kit references + +When in doubt about a design point, the following code is the existing-reality reference: + +- `polkadot-app-ios-v2`: + - `Packages/Coinage/Sources/Recycling/CoinageRecyclingService.swift` — periodic + foreground recycling + - `Packages/Coinage/Sources/Transfer/CoinSelection/CoinSelector.swift` — three-tier selection + - `Packages/Coinage/Sources/CoinageBackupRecoveryService.swift` — gap-limit recovery + - `Packages/Coinage/Sources/ExternalPayment/Planner/ExternalPaymentPlanner.swift` (on `feature/payment-request` branch only) — the planner this design's §8.6 mirrors + - Derivation paths: `//pps//coin//` and `//pps//ring-vrf//` (legacy; new design uses `//coinage//...`) +- `useragent-kit`: + - `crates/host-coinage/src/selection.rs` — three-tier selection + - `crates/host-coinage/src/chain.rs` — recovery, query, transfer + - `crates/host-coinage/src/unload.rs` — unload token contexts + +## 10. Open follow-ups (not yet acted on) + +- **Commit the post-`bedc7ae` work** (tasks 1, 2, 3) when ready. User will instruct when. +- **Re-run Apalache** on the current 3726-line spec (was clean at depth 3 on prior 3066-line version). Suggested batch: + ```bash + for inv in conservation noEntryOnExpiredRing derivationInjective midSubmissionHoldsLocks lockConsistency chainMirrorsLocal safety; do + quint verify --invariant=$inv docs/specs/coinage-layer.qnt --max-steps=3 + done + ``` +- File the iOS silent-loss-of-funds bug as a security issue. +- PR #122 description still reflects the original *unified* design — update after bottom-layer split is finalized. +- Top-layer (RFC‑17 / Coinage Payment) design not yet started. + +## 11. NEXT MAJOR STAGE — Verus Rust implementation + +**The user wants to start implementing the Coinage Layer in Rust using Verus, with the Quint spec as the contract source.** This is Stage 2 of the three-stage pipeline (Quint spec → Verus impl → optional Lean refinement) documented in the Obsidian vault notes at: +- `/Users/torsten/Documents/knowledge/Knowledge/09-Resources/Knowledge/Formal Methods + AI/00_overview.md` (reference + manual) +- `/Users/torsten/Documents/knowledge/Knowledge/05-Areas/Engineering-Excellence/Formal Methods + AI at Parity.md` (strategy) + +### Stack confirmed +- **Verus** (MIT, github.com/verus-lang/verus) — Rust subset with `spec`, `proof`, `ghost`, `requires`, `ensures`, `invariant`, `decreases`. SMT-backed via Z3. +- **Claude Code** as the interactive proof assistant (no separate API key needed; AutoVerus requires API access we don't have). +- **Quint spec** at `docs/specs/coinage-layer.qnt` is the contract source — every Verus `requires`/`ensures` should derive from a Quint invariant or action precondition. + +### Suggested pilot scope +Start with TWO bounded primitives to validate the workflow: +1. `createPurse(newId, name)` — synchronous, no chain interaction; smallest possible test. +2. `queryPurse(purseId)` — synchronous read returning `PurseInfo`; tests data structure invariants. + +This pilot validates: +- Verus toolchain setup on the repo +- Translation pattern from Quint definitions to Verus contracts +- Claude Code conversational proof workflow +- Time-box estimate (target: ~1 week per the strategy note) + +### Suggested first commands to run after compact +```bash +# Verify spec is clean before starting implementation +quint typecheck docs/specs/coinage-layer.qnt +quint run docs/specs/coinage-layer.qnt --invariant=safety --max-samples=2000 --max-steps=40 + +# Install Verus toolchain (one-time) +# Per https://github.com/verus-lang/verus — installs via `vargo` or rustup component +# Add a Rust crate within the truapi workspace, e.g. `rust/crates/coinage-layer/` + +# First module to translate: PurseId, PurseRec, basic invariants +``` + +### Critical context to keep +- The Quint spec is authoritative. Verus contracts must match Quint semantics; Quint catches design bugs the Rust impl shouldn't introduce. +- Selection ordering (§6.3) and gap-limit recovery (Appendix C) have specific Quint encodings — see `selectExactCoverDeterministic`, `gapLimitScanCoins`, `coinOrderLT` for reference. +- Derivation: hard junctions only, `//coinage//coin//

////` and `//coinage//ring-vrf//

////`, page=0 for now. + +## 12. Verus pilot — actual status + +Pilot landed at `rust/crates/coinage-layer/`. Eleven commits on `add-coinage-design`. `cargo verus verify`: **32 verified, 0 errors**. `cargo build --workspace`: clean. + +Stage timeline: + +| Commit | Stage | Cumulative verified | +|---|---|---| +| `1289ee2` | init / create_purse / query_purse | 3 | +| `19166cd` | rename_purse, delete_purse (local), refint invariant | 7 | +| `8788b76` | add_coin + coin-idx invariant (k) | 9 | +| `b86141c` | top_up_purse + CoinState lifecycle | 15 | +| `5f65008` | docs consolidation | 15 | +| `e23d0f9` | exec coin Vec backing (stage 5a-c) | 19 | +| `7eeb7fe` | purge_coins_of_purse with find/remove_at decomposition | 24 | +| `faff146` | delete_purse composes purge_coins_of_purse | 24 | +| `90272f7` | real query_purse.spendable via Vec scan | 27 | +| `010c6b5` | select_coin primitive | 29 | +| `f9be6dc` | transfer composite operation | 32 | + +### Operations verified + +| Op | Contract | +|---|---| +| `init()` | dom = {MAIN_PURSE}, coins = ∅ | +| `create_purse(name)` | fresh id; `insert(new_id, new_rec)` | +| `rename_purse(p, name)` | dom unchanged; name field updated; others preserved field-by-field | +| `delete_purse(p)` | requires `!has_live_coin_in(p)`; `purses == old.remove(p)`; `coins == old.coins().remove_keys({k.0 == p})`; internally calls purge then scan-remove | +| `query_purse(p)` | id+name match ghost; `spendable` == count of Available coins in p | +| `add_coin(p, exp)` | pushes to Vec + ghost; allocates (p, next_coin_idx); state = Available | +| `top_up_purse(p, exp_seq)` | loops add_coin; n consecutive new coins with matching exponents | +| `mark_coin_pending_spend(key)` | Available → PendingSpend (Vec + ghost via helper) | +| `mark_coin_spent(key)` | PendingSpend → Spent (Vec + ghost via helper) | +| `purge_coins_of_purse(p)` | drops every coin with purse == p from Vec and ghost | +| `select_coin(p, min_exp)` | first Available coin in p with exponent ≥ min_exp, or None | +| **`transfer(from, to, min_exp)`** | composite: select → mark PendingSpend → mark Spent → add_coin at destination | + +Plus internal helpers: `find_coin_with_purse`, `remove_coin_at`, `transition_coin_state`, `read_coin_exponent`. Plus spec function `count_avail_prefix(v, p, j)` for spendable. + +### Invariant clauses (a–n) + +(a) `next_purse_id != MAIN_PURSE`. (b) `MAIN_PURSE ∈ dom`. (c) `forall p ∈ dom. m[p].id == p`. (d) `forall p ∈ dom. p < next_purse_id`. (e–h) purse Vec ↔ ghost-map refinement (forward, reverse, no duplicates). (i) coin key consistency. (j) coin referential integrity to purses. (k) coin idx below the owning purse's allocator. (l–n) coin Vec ↔ ghost-map refinement. + +All 14 clauses are machine-checked preserved across every state-mutating operation. + +### Pilot scope honesty (still deferred) + +- **Coin value semantics.** Quint's `coinValue(exp) = 2^exp` not modeled; spendable uses **count** semantics, not sum-of-values. +- **Ages, accounts.** `CoinRec.account`, `CoinRec.age` deferred. +- **Recycler entries.** Entire EntryRec / on-chain ring state out of scope. Quint state has 16+ vars; the pilot models 5 (`purses`, `coins`, `next_purse_id`, plus the two ghost mirrors). Modeling entries follows the same pattern as coins (ghost map + exec Vec + (i)-(n)-style invariant clauses). +- **Operations / chain mirror / tokens / events.** None modeled. + +### Proof economy actuals + +| | Lines | +|---|---| +| Exec code | ~250 | +| Spec / contracts | ~280 | +| Proof blocks | ~1,600 | + +Per-op marginal cost converged: state-mutating primitives cost ~120 proof lines each; composite operations (transfer) cost **zero**. The investment in primitive contracts pays off when composing — `transfer` is ~10 lines of exec with no proof block. + +### Proof-engineering patterns + +See `docs/specs/VERUS-BY-EXAMPLE.md` for the reference. Patterns that crystallized: +1. **Pre-state ghost capture quad** — capture `old_v` / `old_m` / `old_coins` / `old_coins_vec` at function entry. +2. **Per-clause `assert forall ... by { ... }` blocks** — walk each invariant clause individually with explicit `if k == target_idx { ... } else { ... }` branches. +3. **Trigger choice** — prefer triggers that already appear in the goal (`Map::dom().contains(_)`) over bound-variable-only triggers (`seq@[j]`). +4. **Decomposition for runaway proofs** — when one operation's proof exceeds ~150 lines, split into smaller primitives whose contracts compose (e.g. `find_coin_with_purse` + `remove_coin_at` + `purge_coins_of_purse`). +5. **`unreached()` with invariant-derived contradictions** — for scans guaranteed to find by an invariant, derive `false` via `choose|i| ...` on the existential and lean on the loop's "not found" accumulated fact. +6. **Exposing sibling-field stability** — methods that mutate one ghost field must also assert `final.purses@ == old.purses@` and `final.next_purse_id == old.next_purse_id` in the contract, not just in the body. Otherwise callers can't carry the invariant through. + +## 13. Continuing the work + +To resume: +1. Read this file. +2. `cargo verus verify` in `rust/crates/coinage-layer/` — confirm 32 verified, 0 errors. +3. Pick a next move from the deferred list. Natural candidates (ordered by leverage): + - `rebalance(src, dst, min_exp)` — same shape as transfer, namespace-bridging. + - Recycler entries (`EntryRec`, `EntryState`, `add_entry`, `mark_entry_*`) — mechanical extension following the coin lifecycle pattern. + - Real coin value semantics (`coinValue(exp) = 1 << exp`) with overflow-bounded sums. + +`docs/design/coinage-layer.md` and `docs/specs/coinage-layer.qnt` remain authoritative — the Verus pilot should track them, never the reverse. diff --git a/docs/specs/VERUS-BY-EXAMPLE.md b/docs/specs/VERUS-BY-EXAMPLE.md new file mode 100644 index 00000000..40c8240c --- /dev/null +++ b/docs/specs/VERUS-BY-EXAMPLE.md @@ -0,0 +1,433 @@ +# Verus by Example — patterns from the coinage-layer pilot + +A working developer's reference for the proof-engineering patterns that have repeatedly paid off in `rust/crates/coinage-layer/`. Not a Verus tutorial — assumes you've read [the Verus tutorial](https://verus-lang.github.io/verus/guide/) and have the toolchain running. + +Every pattern below is grounded in real code in `rust/crates/coinage-layer/src/lib.rs`. The reference numbers like (e), (k) are invariant-clause labels from that file. + +## 1. Installing Verus + +`cargo install verus` does **not** work — produces a wrapper without a verusroot. Use the release binary: + +```bash +gh release download --repo verus-lang/verus --pattern '*macos-arm64.zip' --dir ~/Downloads +unzip ~/Downloads/verus-*-arm64-macos.zip -d ~/tools/ +mv ~/tools/verus-* ~/tools/verus +echo 'export PATH="$HOME/tools/verus:$PATH"' >> ~/.zshrc +exec zsh +verus --version +``` + +Verify a crate with `cargo verus verify` from inside the crate directory. + +## 2. State struct + ghost-map shape + +The single most useful pattern: keep an exec storage (Vec or HashMap) and a ghost map (the contract surface). Tie them with a refinement invariant. + +```rust +pub struct State { + pub purses: Vec, // exec + pub next_purse_id: u64, // exec + pub spec_purses: Ghost>, // ghost — contract surface +} +``` + +All fields must be `pub` so that `open spec fn` accessors can read them across crate boundaries. Verus treats a struct with even one private field as fully opaque externally; you can't have a public `open spec` body that touches a private field. + +External code can write to these fields and break the invariant, but every method's `requires self.invariant()` makes that state stuck — they get no useful operation. The invariant is the only valid entry point. + +## 3. The `view()` lift + +For exec records that contain non-Copy data (`Vec` names, etc.), define a spec twin and lift via `view()`: + +```rust +pub struct PurseRec { + pub id: PurseId, + pub name: Vec, + pub next_coin_idx: u64, + pub next_entry_idx: u64, +} + +pub struct PurseRecSpec { + pub id: PurseId, + pub name: Seq, + pub next_coin_idx: nat, + pub next_entry_idx: nat, +} + +impl PurseRec { + pub open spec fn view(&self) -> PurseRecSpec { + PurseRecSpec { + id: self.id, + name: self.name@, + next_coin_idx: self.next_coin_idx as nat, + next_entry_idx: self.next_entry_idx as nat, + } + } +} +``` + +Then `rec@` in spec contexts gives the spec twin. + +## 4. The pre-state ghost capture trio + +At the top of every method that mutates state, capture the relevant pre-state: + +```rust +fn create_purse(&mut self, name: Vec) -> (new_id: PurseId) + requires + old(self).invariant(), + old(self).has_create_capacity(), + ensures /* ... */, +{ + let new_id = self.next_purse_id; + let ghost old_v = self.purses@; + let ghost old_m = self.spec_purses@; + // ... rest of body +} +``` + +The captures serve two purposes: +- **Bridging `old(self).X` to in-body reasoning.** Inside a loop body, `old(self)` is the *function* entry, not the iteration start; the ghost capture lets you talk about a specific snapshot. +- **Re-asserting unchanged-fields equality after partial mutation.** If a method modifies one field, Verus does NOT automatically conclude the others are unchanged. After the mutation: + ```rust + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.next_purse_id == old_next_purse_id); + ``` + These three lines turn `assert(self.invariant())` from failing to discharging instantly. + +## 5. Trigger choice + +The single rule that saves the most debugging time: + +**For `forall|k| ... ==> P(k)` over keys/indices, choose `#[trigger]` to be an expression Verus already needs to talk about when evaluating `P(k)`** — typically `Map::dom().contains(k)`, `Set::contains(k)`, `Seq::index(k)`. Reserve bound-variable-only triggers (e.g. `exp_seq@[j]`) for places where the conclusion structurally returns a value from that sequence. + +Example that works: + +```rust +forall|j: int| 0 <= j < n ==> + #[trigger] self.coins().dom().contains(make_key(j)) + && self.coins()[make_key(j)].exponent == exp_seq@[j] +``` + +Example that fails to instantiate (Verus has no reason to fire `exp_seq@[j]` when trying to prove `coins().dom().contains(...)`): + +```rust +forall|j: int| #![trigger exp_seq@[j]] 0 <= j < n ==> + self.coins().dom().contains(make_key(j)) + && self.coins()[make_key(j)].exponent == exp_seq@[j] +``` + +This bit the coinage-layer pilot on `top_up_purse`: switching from the bottom form to the top form turned three failing postconditions into instant discharge. + +## 6. Loop invariant template for Vec scans + +For a "find-and-mutate" loop over an exec Vec, the invariant looks like: + +```rust +let ghost old_v = self.purses@; +let ghost old_m = self.spec_purses@; +let ghost old_coins = self.spec_coins@; + +let mut i: usize = 0; +while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + // Pre-mutation Vec/map captures, propagated for the search phase. + self.purses@ == old_v, + self.spec_purses@ == old_m, + self.spec_coins@ == old_coins, + // Pin the captured ghosts to the function-entry state. These bridges + // are required for postconditions that mention `old(self).X` and + // proof code inside the body that needs the equality. + old_m == old(self).spec_purses@, + old_v == old(self).purses@, + old_coins == old(self).spec_coins@, + self.next_purse_id == old(self).next_purse_id, + // Searched-but-not-found facts so far. + forall|j: int| 0 <= j < i ==> (#[trigger] self.purses@[j]).id != target_id, + decreases self.purses.len() - i, +{ + if self.purses[i].id == target_id { + // The branch where the mutation happens. After mutating, the per- + // clause proof block establishes the invariant for the new state. + return ...; + } + i += 1; +} +``` + +## 7. Per-clause `assert forall ... by { ... }` blocks + +After mutating a Vec entry (push, swap_remove, IndexMut), the invariant needs to be re-established. Walk each clause separately with explicit branches: + +```rust +proof { + let new_v = self.purses@; + let new_m = self.spec_purses@; + + // (e) every Vec entry's id is in dom + assert forall|k: int| 0 <= k < new_v.len() implies + new_m.dom().contains(#[trigger] new_v[k].id) + by { + if k == target_idx { + assert(new_v[k].id == p); + } else { + assert(new_v[k] == old_v[k]); + assert(old_m.dom().contains(old_v[k].id)); + } + } + + // (f) every Vec entry's spec view matches its dom entry + assert forall|k: int| 0 <= k < new_v.len() implies + new_m[(#[trigger] new_v[k]).id] == new_v[k]@ + by { + if k == target_idx { + // ... + } else { + assert(new_v[k] == old_v[k]); + assert(old_m[old_v[k].id] == old_v[k]@); + } + } + + // ... etc for (g), (h) +} +``` + +Two-branch structure (`if k == target_idx`) is the common shape. For swap_remove the "changed" side has a sub-case (`if target_idx < last_idx`); for full filtering the loop becomes a nested filter-and-rebuild. + +## 8. Capturing constructed values before move + +When constructing a struct that gets moved into a Vec, capture the spec view *before* the move so the post-mutation proof can refer to it: + +```rust +let new_rec = PurseRec { id, name, next_coin_idx: 0, next_entry_idx: 0 }; +let ghost new_rec_spec = new_rec@; // capture BEFORE move +self.purses.push(new_rec); // moves new_rec +proof { + // new_rec is gone in exec, but new_rec_spec persists. + self.spec_purses = Ghost(self.spec_purses@.insert(new_id, new_rec_spec)); + assert(self.purses@[old_v.len() as int]@ == new_rec_spec); +} +``` + +## 9. Compositional operations (looping a smaller op) + +For an operation that loops a primitive (`top_up_purse` loops `add_coin`), the inner-loop proof needs two key ingredients: + +1. **Capture pre-call state** before each invocation of the primitive — even within a loop body, so post-call we can talk about what existed before. +2. **In the assert-forall body, handle "new key just added" and "old key still present" as separate branches.** The "old key" branch needs `(k != new_key)` — that's the fact that lets `insert(new_key, _)` preserve old entries. + +```rust +let exp = exp_seq[k]; +let ghost prev_next = self.purses()[p].next_coin_idx; +let ghost pre_coins = self.coins(); // capture BEFORE the call +let new_key = self.add_coin(p, exp); +proof { + assert(new_key.1 == (old_p_next + k as nat) as u64); + assert forall|j: int| 0 <= j < (k + 1) as int implies + #[trigger] self.coins().dom().contains((p, (old_p_next + j) as u64)) + && self.coins()[(p, (old_p_next + j) as u64)].exponent == exp_seq@[j] + by { + let nk = (p, (old_p_next + j) as u64); + if j == k as int { + assert(nk == new_key); + assert(self.coins()[new_key].exponent == exp); + } else { + assert(j < k as int); + assert(pre_coins.dom().contains(nk)); // from pre-call loop inv + assert(pre_coins[nk].exponent == exp_seq@[j]); + assert(nk.1 != new_key.1); // distinct keys + } + } +} +``` + +## 10. `&mut self` postcondition syntax + +In `ensures` clauses, `self` references are disambiguated: +- `old(self).X` → pre-call value +- `final(self).X` → post-call value + +```rust +fn create_purse(&mut self, name: Vec) -> (new_id: PurseId) + requires + old(self).invariant(), // pre + ensures + final(self).invariant(), // post + !old(self).purses().dom().contains(new_id), // pre + final(self).purses() == old(self).purses().insert(new_id, _), // post relative to pre +``` + +## 11. Unreachable code with `vstd::pervasive::unreached()` + +Some scan loops are guaranteed to find a target by the invariant (e.g. `add_coin` after a precondition `purse exists`). For the post-loop case, derive `false` from the invariant and then return: + +```rust +// Cannot reach here: p is in old(self).purses().dom() by precondition, +// so invariant (g) gives a Vec witness; the scan loop would have found it. +proof { + assert(old_m.dom().contains(p)); + let w = choose|k: int| 0 <= k < old_v.len() && #[trigger] old_v[k].id == p; + assert(0 <= w < old_v.len()); + assert(self.purses@[w].id != p); // contradiction with old_v[w].id == p +} +vstd::pervasive::unreached() +``` + +## 12. Avoiding `cargo build --workspace` regressions + +Verus crates use the `vstd` dependency (`vstd = "=0.0.0--"`) which IS published on crates.io. Vanilla `cargo build` works as long as proof blocks are gated behind `#[cfg(verus_only)]` — the `verus! { ... }` macro handles this. To silence dead-code / unused-variable warnings under non-Verus builds, scope `#[allow(dead_code)]` to specific fields and `#[allow(unused_variables)]` to functions whose parameter is only consumed in proof blocks. + +```rust +pub struct State { + pub purses: Vec, + pub next_purse_id: u64, + #[allow(dead_code)] + pub spec_purses: Ghost>, +} + +#[allow(unused_variables)] +pub fn mark_coin_pending_spend(&mut self, key: (PurseId, u64)) + // ... `key` is consumed by ghost code only in this pilot +``` + +## 13. Proof economy reality check + +For the coinage-layer pilot (15 operations, 14 invariant clauses): + +| | Lines | +|---|---| +| Executable code | ~250 | +| Spec / contracts | ~280 | +| Proof blocks (assert-forall, ghost captures) | ~1,600 | + +Roughly **6.4:1 proof-to-exec ratio** for primitive operations. Per-op marginal cost converged to ~120 proof lines once the invariant stabilized. + +**Composite operations cost zero proof.** `transfer` decomposes into `select_coin` + `read_coin_exponent` + `mark_coin_pending_spend` + `mark_coin_spent` + `add_coin`. Verus chains the contracts mechanically: each call's `ensures` discharges the next call's `requires`. The transfer body is ~10 exec lines with no `proof { }` block. This is the actual payoff of writing strong primitive contracts. + +## 14. When to stop and ship — decomposition rule + +**If a proof block exceeds ~150 lines for a single operation, the operation is trying to do too much.** Decompose into smaller primitives whose contracts compose. + +Worked example from the pilot: `delete_purse` initially tried to inline-filter the coin Vec while removing the purse, with one giant proof block. It blew past 200 proof lines without discharging. The fix was to split: + +1. `find_coin_with_purse(p) -> Option` — ~30 proof lines. +2. `remove_coin_at(idx)` — one `swap_remove` + ghost `remove`, ~150 proof lines. +3. `purge_coins_of_purse(p)` — loops `find` + `remove_at`, ~50 proof lines because each call's contract carries the heavy lifting. +4. `delete_purse(p)` — calls `purge_coins_of_purse(p)` then does the existing purse removal, ~5 added proof lines. + +Total: ~235 proof lines split across 4 functions, vs. the original ~250+ that wouldn't discharge as one block. **Smaller proofs are not just easier to write — they're easier for SMT.** + +## 15. Sibling-field stability is part of the contract + +A method that mutates only one ghost field still has to *declare* the others unchanged, not just leave them alone. The pattern that bites you: + +```rust +// Contract that LOOKS fine but isn't enough: +fn purge_coins_of_purse(&mut self, p: PurseId) + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), // spec map view + final(self).coins() == old(self).coins().remove_keys(...), +{ + /* body that only mutates self.coins / self.spec_coins */ +} +``` + +A caller that needs to continue using `self.purses@` (the Vec, not just the spec view) or `self.next_purse_id` after this call will hit unprovable loop invariants because Verus can't deduce those fields are unchanged from the contract alone. Add: + +```rust +ensures + final(self).purses@ == old(self).purses@, // exec Vec + final(self).next_purse_id == old(self).next_purse_id, +``` + +Even if the body trivially preserves them. Verus operates from contracts, not bodies. Forgetting this rule costs ~3 iterations of "wait, this should be obvious" debugging. + +## 16. Composition pattern: chaining primitives without proof blocks + +When a composite operation's body is purely sequential calls to primitives with strong contracts, the verification effort drops to zero. Recipe: + +```rust +pub fn transfer(&mut self, from: PurseId, to: PurseId, min_exp: u8) + -> (res: Option<(PurseId, u64)>) + requires + old(self).invariant(), + old(self).purses().dom().contains(to), + old(self).purses()[to].next_coin_idx < u64::MAX, + ensures + final(self).invariant(), + /* result-shape clauses derived from primitives' postconditions */ +{ + match self.select_coin(from, min_exp) { + None => None, + Some(key) => { + let exp = self.read_coin_exponent(key); + self.mark_coin_pending_spend(key); + self.mark_coin_spent(key); + let new_key = self.add_coin(to, exp); + Some(new_key) + } + } +} +``` + +No `proof { }` blocks. The chain works because: +- `select_coin`'s `Some(key)` postcondition gives us `coins.dom.contains(key)`, `coins[key].state == Available`, `coins[key].exponent >= min_exp`. +- These satisfy `read_coin_exponent`'s `requires coins.dom.contains(key)`. +- And `mark_coin_pending_spend`'s `requires ... && state == Available`. +- After the mark, state is `PendingSpend`, matching `mark_coin_spent`'s precondition. +- After both marks, the purse-side state (`purses().dom`, `purses[to].next_coin_idx`) is unchanged (sibling-field stability — §15), so `add_coin`'s preconditions still hold. + +**The cost of writing strong primitive contracts is paid once. Every composite operation built on those primitives is essentially free.** + +## 17. Recursive spec functions for aggregations + +For aggregations over a sequence (counts, sums), define a recursive spec function over a prefix: + +```rust +pub open spec fn count_avail_prefix(v: Seq, p: PurseId, j: nat) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = count_avail_prefix(v, p, (j - 1) as nat); + if v[(j - 1) as int].purse == p + && v[(j - 1) as int].state == CoinState::Available + { + prev + 1 + } else { + prev + } + } +} +``` + +Exec implementation iterates and accumulates; loop invariant is `count == count_avail_prefix(v, p, j as nat)`. The proof that `count + 1` doesn't overflow uses an inline `assert`: + +```rust +assert(count_avail_prefix(self.coins@, p, (j + 1) as nat) + <= count_avail_prefix(self.coins@, p, j as nat) + 1); +``` + +Verus discharges this by unfolding `count_avail_prefix`'s definition. From the invariant `count <= j`, combined with `j < self.coins.len() <= u64::MAX` (from precondition), `count + 1 <= u64::MAX`. No overflow. + +This pattern generalizes to any "scan and accumulate" aggregator. Avoids the complexity of folding over a `Set` or `Map` and gets a clean Verus-friendly recursive definition. + +## 18. Enum equality in exec code + +If your enum derives `PartialEq` (the typical Rust convention), Verus rejects `state == CoinState::Available` in exec code because `PartialEq::eq` is declared outside the `verus!` macro. Use `matches!` instead: + +```rust +// Doesn't verify: +if self.coins[j].state == CoinState::Available { ... } + +// Verifies: +let is_avail = matches!(self.coins[j].state, CoinState::Available); +if is_avail { ... } +``` + +In spec contexts, enum equality works natively (`state == CoinState::Available` is fine inside `ensures` and assertions). The exec/spec distinction here is non-obvious but trivial once known. diff --git a/docs/specs/coinage-layer.qnt b/docs/specs/coinage-layer.qnt new file mode 100644 index 00000000..a8084767 --- /dev/null +++ b/docs/specs/coinage-layer.qnt @@ -0,0 +1,3726 @@ +// Coinage Layer — Quint specification +// +// Companion to docs/design/coinage-layer.md. Models the bottom-layer +// coinage subsystem as a state machine suitable for invariant checking +// (Quint simulator / Apalache) and for use as a reference contract when +// verifying Rust implementations. +// +// Modeling choices +// ---------------- +// * Cryptography is opaque. Accounts and member keys are fresh integers +// allocated from monotonic counters. +// * The chain is modeled inside this module via the same state variables. +// Chain-level events (age increment, ring sealing, ring expiry) are +// explicit actions. +// * Multi-extrinsic operations are decomposed into per-phase actions. +// * Selection algorithms are encoded as nondeterministic choices over +// records that satisfy the §6.3 / §8.6 preconditions. +// * Time advances only via the explicit `tick` action. + +module CoinageLayer { + + // ========================================================================== + // 1. Types + // ========================================================================== + + type PurseId = int + type CoinIndex = int + type EntryIndex = int + type OpHandle = int + type RingIndex = int + type Account = int + type MemberKey = int + type Exponent = int + type Age = int + type Time = int + type Amount = int + + type CoinState = + | CoinPending + | CoinAvailable + | CoinLockedFor(OpHandle) + | CoinSpent + + type EntryOnChain = + | OnMissing + | OnWaiting + | OnReady + | OnDegraded(int) + + type EntryLocal = + | LocalAvailable + | LocalLockedFor(OpHandle) + | LocalConsumed + + type OpKind = + | KTopUp + | KTransfer + | KExport + | KImport + | KExternalOffload + | KRebalance + | KMaintenance + | KDeletePurse + | KRecover + + /// Failure reason carried by `SFailed` and `XRejected`. Mirrors the + /// post-submission and lifecycle subset of design §10. Pre-submission + /// errors do not appear here — they prevent the operation from being + /// started in the first place and are returned as caller-visible errors. + type FailureReason = + | FRSnipedCoin + | FRChainRejected + | FRCancelled + | FRInterruptedPreSubmission + | FRStorage + | FRSubscription + | FRRecovery + | FRInternal + + type OpStatus = + | SPreparing + | SSubmitted + | SInBlock + | SFinalized + | SWaiting(Time) + | SDone + | SFailed(FailureReason) + + type PurseRec = { + id: PurseId, + name: str, + nextCoinIdx: CoinIndex, + nextEntryIdx: EntryIndex, + } + + /// Synchronous purse-info snapshot returned by `query_purse`. + type PurseInfo = { + id: PurseId, + name: str, + spendable: Amount, + spendableStrict: Amount, + pending: Amount, + } + + type CoinRec = { + purse: PurseId, + idx: CoinIndex, + account: Account, + exponent: Exponent, + age: Age, + state: CoinState, + } + + type EntryRec = { + purse: PurseId, + idx: EntryIndex, + memberKey: MemberKey, + exponent: Exponent, + allocatedAt: Time, + readyAt: Time, + ringIdx: int, + onChain: EntryOnChain, + local: EntryLocal, + } + + type OperationRec = { + handle: OpHandle, + kind: OpKind, + purse: PurseId, + status: OpStatus, + lockedCoins: Set[(PurseId, CoinIndex)], + lockedEntries: Set[(PurseId, EntryIndex)], + } + + type RingRec = { + idx: RingIndex, + immutableSince: int, + expired: bool, + } + + /// Per-extrinsic outcome attached to an operation receipt. + type ExtrinsicOutcome = + | XSucceeded(Set[Account]) + | XRejected(FailureReason) + + type ExtrinsicRecord = { + extrinsicId: int, + outcome: ExtrinsicOutcome, + } + + /// Result of `classifyIncomingPayment` (§8.8). + type PaymentClassification = + | PCMatched + | PCReceived + | PCUnmatched + + /// `MemoEntry` (§8.3). The layer treats memos opaquely; only the + /// `recipient_account` field is used by `classifyIncomingPayment`. + type MemoEntry = { + senderAccount: Account, + recipientAccount: Account, + derivationIndex: CoinIndex, + } + + /// Unload-token class (§6.5). + type UnloadTokenClass = + | UTFree + | UTPaid + + /// Fee mode (§6.6). The layer picks automatically: prepaid if the fee + /// account has external funds, from-output otherwise. Caller does not + /// specify. + type FeeMode = + | FMPrepaid + | FMFromOutput + + /// Unload-token record. A token is identified by its (period, class, + /// counter) triple. The chain tracks `consumed` flags; the layer mirrors + /// them. + type UnloadToken = { + period: int, + class: UnloadTokenClass, + counter: int, + consumed: bool, + } + + /// Layer-level event (§11). The event stream is append-only. + type LayerEvent = + | EResynced + | EPurseCreated({ purse: PurseId, name: str }) + | EPurseRenamed({ purse: PurseId, name: str }) + | EPurseDeleted(PurseId) + | ECoinAvailable({ purse: PurseId, exponent: Exponent }) + | ECoinSpent({ purse: PurseId, exponent: Exponent }) + | ECoinAged({ purse: PurseId, exponent: Exponent, age: Age }) + | EEntryAllocated({ purse: PurseId, exponent: Exponent }) + | EEntryReadinessChanged({ purse: PurseId, exponent: Exponent, newState: EntryOnChain }) + | EEntryConsumed({ purse: PurseId, exponent: Exponent }) + | EOperationStarted({ handle: OpHandle, kind: OpKind, purse: PurseId }) + | EOperationProgress({ handle: OpHandle, status: OpStatus }) + | EOperationCompleted({ handle: OpHandle, status: OpStatus }) + | EMaintenanceSweepStarted + | EMaintenanceSweepCompleted({ recycled: int, rescued: int }) + + // ========================================================================== + // 2. Parameters (small for tractability) + // ========================================================================== + + pure val MAIN_PURSE: PurseId = 0 + pure val MaxAge: Age = 4 + pure val RecycleAtAge: Age = 3 + pure val AnonymityFloor: int = 2 + pure val MaxExponent: Exponent = 2 + pure val RecyclerExpiration: Time = 8 + pure val RescueMargin: Time = 3 + pure val JitterMax: Time = 2 + pure val FreeTokenSearchRange: int = 3 + pure val PeriodLookbackGrace: int = 1 + pure val PeriodLength: Time = 4 + pure val UnloadFee: Amount = 1 + /// Recovery: gap-limit scan parameters (Appendix A.7, A.8). + pure val BatchSize: int = 2 + pure val GapLimit: int = 2 + /// Bounded scan ceiling per recovery call. + pure val MaxScanIndex: int = 10 + + // ========================================================================== + // 3. State + // ========================================================================== + + var purses: PurseId -> PurseRec + var coins: (PurseId, CoinIndex) -> CoinRec + var entries: (PurseId, EntryIndex) -> EntryRec + var operations: OpHandle -> OperationRec + var rings: RingIndex -> RingRec + var nextHandle: OpHandle + var nextRingIdx: RingIndex + var nextAccount: Account + var nextMemberKey: MemberKey + var nextExtrinsicId: int + var now: Time + var totalIn: Amount + var totalOut: Amount + /// Per-operation receipts. Each entry is the ordered list of per-extrinsic + /// outcomes accumulated since the operation began. + var receipts: OpHandle -> List[ExtrinsicRecord] + /// Per-operation `requested` amount (only meaningful for KExternalOffload). + /// Allows the offload's surplus-reload logic to know how much value + /// must land externally vs be returned to the layer. + var opRequested: OpHandle -> Amount + /// Per-operation accumulated externalized value (KExternalOffload). Each + /// per-group offboard extrinsic adds its contribution; the operation + /// completes when this equals `opRequested`. + var opExternalized: OpHandle -> Amount + /// Chain-side coin records. Holds every coin the chain has ever known. + /// The local `coins` is a (possibly partial) cache; recovery reconstructs + /// from this. Invariant: `coins.keys() ⊆ chainCoins.keys()`. + var chainCoins: (PurseId, CoinIndex) -> CoinRec + /// Chain-side entry records. Same pattern as `chainCoins`. + var chainEntries: (PurseId, EntryIndex) -> EntryRec + /// Layer event stream (append-only). Models the typed subscription + /// surface of §11. + var events: List[LayerEvent] + /// Unload-token state. Keyed by `(period, class, counter)`. Tokens for + /// the current period exist for the host; consumption flips `consumed`. + var tokens: (int, UnloadTokenClass, int) -> UnloadToken + /// Whether the host has joined the paid-token ring for a given period. + var paidRingMembership: int -> bool + /// Cached fee-account balance (off-chain external currency). Drives the + /// `prepaid` vs `from-output` fee-mode pick per §6.6. + var feeAccountBalance: Amount + + // ========================================================================== + // 4. Pure helpers + // ========================================================================== + + /// Coin value `2^exp`, expanded as a linear lookup so Apalache's SMT + /// backend stays in the decidable fragment (Z3 reports UNKNOWN for + /// symbolic exponentiation). `MaxExponent` bounds the table size. + pure def coinValue(exp: Exponent): Amount = + if (exp == 0) 1 + else if (exp == 1) 2 + else if (exp == 2) 4 + else if (exp == 3) 8 + else if (exp == 4) 16 + else 0 + + // ========================================================================== + // 4.x Derivation (Appendix B) + // ========================================================================== + // + // The bottom layer's account and member-key derivation is fully + // deterministic from `(purse, page, idx, kind)`. With `page = 0` fixed + // for this version, the derivation collapses to `(purse, idx, kind)`. + // We model the resulting bytes as integers: any conformant implementation + // produces the same integer for the same input. + pure val PAGE: int = 0 + pure val PURSE_STRIDE: int = 1000 + pure val COIN_KIND_OFFSET: int = 0 + pure val ENTRY_KIND_OFFSET: int = 1000000 + + /// `//coinage//coin//

////` (Appendix B). + pure def deriveCoinAccount(p: PurseId, idx: CoinIndex): Account = + COIN_KIND_OFFSET + p * PURSE_STRIDE + idx + + /// `//coinage//ring-vrf//

////` (Appendix B). + pure def deriveMemberKey(p: PurseId, idx: EntryIndex): MemberKey = + ENTRY_KIND_OFFSET + p * PURSE_STRIDE + idx + + def isDegraded(e: EntryRec): bool = match e.onChain { + | OnDegraded(_) => true + | _ => false + } + + def entrySelectable(e: EntryRec, allowDegraded: bool): bool = and { + e.local == LocalAvailable, + e.onChain == OnReady or (allowDegraded and isDegraded(e)), + e.readyAt <= now, + } + + def isCoinLocked(c: CoinRec): bool = match c.state { + | CoinLockedFor(_) => true + | _ => false + } + + def isEntryLocked(e: EntryRec): bool = match e.local { + | LocalLockedFor(_) => true + | _ => false + } + + def coinLockHandle(c: CoinRec): OpHandle = match c.state { + | CoinLockedFor(h) => h + | _ => -1 + } + + def entryLockHandle(e: EntryRec): OpHandle = match e.local { + | LocalLockedFor(h) => h + | _ => -1 + } + + // Append an extrinsic record to an operation handle's receipt log. + pure def appendReceipt( + rs: OpHandle -> List[ExtrinsicRecord], + h: OpHandle, + rec: ExtrinsicRecord, + ): OpHandle -> List[ExtrinsicRecord] = { + val current = if (rs.keys().contains(h)) rs.get(h) else List() + rs.put(h, current.append(rec)) + } + + // Total of value across all non-terminal records. + def liveValue: Amount = { + val cv = coins.keys().fold(0, (a, k) => + if (coins.get(k).state == CoinSpent) a else a + coinValue(coins.get(k).exponent)) + val ev = entries.keys().fold(0, (a, k) => + if (entries.get(k).local == LocalConsumed) a else a + coinValue(entries.get(k).exponent)) + cv + ev + } + + // ========================================================================== + // 5. Init + // ========================================================================== + + action init = all { + purses' = Map(MAIN_PURSE -> { id: MAIN_PURSE, name: "main", nextCoinIdx: 0, nextEntryIdx: 0 }), + coins' = Map(), + entries' = Map(), + operations' = Map(), + rings' = Map(), + nextHandle' = 1, + nextRingIdx' = 0, + nextAccount' = 1, + nextMemberKey' = 1, + nextExtrinsicId' = 1, + now' = 0, + totalIn' = 0, + totalOut' = 0, + receipts' = Map(), + events' = List(), + tokens' = Map(), + paidRingMembership' = Map(), + feeAccountBalance' = 100, + opRequested' = Map(), + opExternalized' = Map(), + chainCoins' = Map(), + chainEntries' = Map(), + } + + // ========================================================================== + // 6. Caller-facing primitives + // ========================================================================== + + // 6.1 Create a purse. + action createPurse(newId: PurseId, nm: str): bool = all { + not(purses.keys().contains(newId)), + newId != MAIN_PURSE, + purses' = purses.put(newId, + { id: newId, name: nm, nextCoinIdx: 0, nextEntryIdx: 0 }), + coins' = coins, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EPurseCreated({ purse: newId, name: nm })), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalOut' = totalOut, + } + + // 6.1.1 Rename a purse. Synchronous, no chain interaction. + action renamePurse(p: PurseId, nm: str): bool = { + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + all { + pExists, + purses' = purses.put(p, purse.with("name", nm)), + coins' = coins, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EPurseRenamed({ purse: p, name: nm })), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalOut' = totalOut, + } + } + + /// True iff there is any live (non-terminal) coin in `p`. + def purseHasLiveCoins(p: PurseId): bool = + coins.keys().exists(k => + coins.get(k).purse == p and coins.get(k).state != CoinSpent) + + /// True iff there is any live (non-terminal) entry in `p`. + def purseHasLiveEntries(p: PurseId): bool = + entries.keys().exists(k => + entries.get(k).purse == p and entries.get(k).local != LocalConsumed) + + /// True iff `p` has any non-terminal operation. + def purseHasInFlight(p: PurseId): bool = + operations.keys().exists(h => { + val op = operations.get(h) + op.purse == p and not(op.status == SDone or isFailed(op)) + }) + + // 6.1.2 Delete a purse. The main purse cannot be deleted; the purse must + // have no live coins, no live entries and no in-flight operations. Drain + // is modeled as a separate prior `rebalancePurse`. + action deletePurse(p: PurseId): bool = all { + purses.keys().contains(p), + p != MAIN_PURSE, + not(purseHasLiveCoins(p)), + not(purseHasLiveEntries(p)), + not(purseHasInFlight(p)), + purses' = purses.keys().exclude(Set(p)).fold(Map(), (m, k) => m.put(k, purses.get(k))), + coins' = coins.keys().filter(k => coins.get(k).purse != p) + .fold(Map(), (m, k) => m.put(k, coins.get(k))), + entries' = entries.keys().filter(k => entries.get(k).purse != p) + .fold(Map(), (m, k) => m.put(k, entries.get(k))), + chainCoins' = chainCoins.keys().filter(k => k._1 != p) + .fold(Map(), (m, k) => m.put(k, chainCoins.get(k))), + chainEntries' = chainEntries.keys().filter(k => k._1 != p) + .fold(Map(), (m, k) => m.put(k, chainEntries.get(k))), + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EPurseDeleted(p)), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + totalOut' = totalOut, + } + + // 6.1.3 Rebalance: move one Available coin from `from` to `to`. The model + // abstracts amount/selection into "pick a single coin", capturing the + // namespace-bridging invariant: the source coin terminates and a fresh + // age-0 coin appears in the destination purse's namespace. + action rebalancePurse(src: PurseId, dst: PurseId, k: (PurseId, CoinIndex)): bool = { + val fromExists = purses.keys().contains(src) + val toExists = purses.keys().contains(dst) + val cExists = coins.keys().contains(k) + val c = if (cExists) coins.get(k) + else { purse: -1, idx: -1, account: -1, exponent: 0, age: 0, state: CoinPending } + val toPurse = if (toExists) purses.get(dst) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val newIdx = toPurse.nextCoinIdx + val acct = deriveCoinAccount(dst, newIdx) + val newCoin: CoinRec = { + purse: dst, idx: newIdx, account: acct, exponent: c.exponent, + age: 0, state: CoinAvailable, + } + all { + fromExists, + toExists, + src != dst, + cExists, + c.purse == src, + c.state == CoinAvailable, + coins' = coins.put(k, c.with("state", CoinSpent)) + .put((dst, newIdx), newCoin), + chainCoins' = chainCoins.put((dst, newIdx), newCoin), + purses' = purses.put(dst, toPurse.with("nextCoinIdx", newIdx + 1)), + nextAccount' = nextAccount, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(ECoinSpent({ purse: src, exponent: c.exponent })) + .append(ECoinAvailable({ purse: dst, exponent: c.exponent })), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainEntries' = chainEntries, + totalOut' = totalOut, + } + } + + // 6.1.4 Synchronous query: balance snapshot. + def purseSpendable(p: PurseId): Amount = { + val readyEntries = entries.keys().fold(0, (a, k) => { + val e = entries.get(k) + if (e.purse == p and e.local == LocalAvailable + and (e.onChain == OnReady or isDegraded(e))) + a + coinValue(e.exponent) + else a + }) + val availableCoins = coins.keys().fold(0, (a, k) => { + val c = coins.get(k) + if (c.purse == p and c.state == CoinAvailable) + a + coinValue(c.exponent) + else a + }) + readyEntries + availableCoins + } + + def purseSpendableStrict(p: PurseId): Amount = { + val readyEntries = entries.keys().fold(0, (a, k) => { + val e = entries.get(k) + if (e.purse == p and e.local == LocalAvailable and e.onChain == OnReady) + a + coinValue(e.exponent) + else a + }) + val availableCoins = coins.keys().fold(0, (a, k) => { + val c = coins.get(k) + if (c.purse == p and c.state == CoinAvailable) + a + coinValue(c.exponent) + else a + }) + readyEntries + availableCoins + } + + def pursePending(p: PurseId): Amount = + entries.keys().fold(0, (a, k) => { + val e = entries.get(k) + if (e.purse == p and e.local == LocalAvailable + and (e.onChain == OnWaiting or e.onChain == OnMissing)) + a + coinValue(e.exponent) + else a + }) + + def queryPurse(p: PurseId): PurseInfo = { + val purse = purses.get(p) + { + id: p, + name: purse.name, + spendable: purseSpendable(p), + spendableStrict: purseSpendableStrict(p), + pending: pursePending(p), + } + } + + // ========================================================================== + // 6.x Selection (§6.3) + // ========================================================================== + // + // Selection is decomposed into pure predicates that determine *which* + // strategy is feasible. The three-tier policy (exact, split, unload) + // commits to the first feasible strategy in priority order. Selection + // *itself* is purely functional; the corresponding `transferAmount`, + // `exportAmount`, and `rebalanceAmount` actions consume the result. + + /// Sum of values of a set of coin keys. + pure def sumOfCoinValues( + cs: (PurseId, CoinIndex) -> CoinRec, + sel: Set[(PurseId, CoinIndex)], + ): Amount = + sel.fold(0, (a, k) => a + coinValue(cs.get(k).exponent)) + + /// Sum of values of a set of entry keys. + pure def sumOfEntryValues( + es: (PurseId, EntryIndex) -> EntryRec, + sel: Set[(PurseId, EntryIndex)], + ): Amount = + sel.fold(0, (a, k) => a + coinValue(es.get(k).exponent)) + + // -------------------------------------------------------------------------- + // Deterministic ordering (§6.3) + // -------------------------------------------------------------------------- + // + // Coins are ordered by (exponent desc, age desc, idx asc); entries by + // (exponent desc, ring asc, idx asc). The ordering is total and pure. + // Two conformant implementations with the same purse contents pick the + // same coin / entry under this ordering. + // + // Selection ranks: smaller rank tuple ⇒ higher priority. Encoded as + // tuples so lexicographic comparison gives the design ordering. + // + // The first element is `-exponent` (or `MaxExponent - exponent`) so that + // a larger exponent yields a smaller rank. + + pure def coinPriorityRank(c: CoinRec): (int, int, int) = + (MaxExponent - c.exponent, MaxAge - c.age, c.idx) + + pure def entryPriorityRank(e: EntryRec): (int, int, int) = + (MaxExponent - e.exponent, e.ringIdx, e.idx) + + /// Lexicographic less-than on rank tuples. + pure def rankLT(a: (int, int, int), b: (int, int, int)): bool = or { + a._1 < b._1, + a._1 == b._1 and a._2 < b._2, + a._1 == b._1 and a._2 == b._2 and a._3 < b._3, + } + + pure def coinOrderLT(c1: CoinRec, c2: CoinRec): bool = + rankLT(coinPriorityRank(c1), coinPriorityRank(c2)) + + pure def entryOrderLT(e1: EntryRec, e2: EntryRec): bool = + rankLT(entryPriorityRank(e1), entryPriorityRank(e2)) + + /// True iff `k` has higher priority than every other available coin + /// in its purse. + def isMinPriorityAvailableCoin(k: (PurseId, CoinIndex)): bool = { + val c = coins.get(k) + availableCoinsIn(c.purse).forall(k2 => + k2 == k or coinOrderLT(c, coins.get(k2))) + } + + /// True iff `k` has higher priority than every other selectable entry + /// in its purse under `allowDegraded`. + def isMinPrioritySelectableEntry(k: (PurseId, EntryIndex), allowDegraded: bool): bool = { + val e = entries.get(k) + selectableEntriesIn(e.purse, allowDegraded).forall(k2 => + k2 == k or entryOrderLT(e, entries.get(k2))) + } + + /// All Available coin keys in `p`. + def availableCoinsIn(p: PurseId): Set[(PurseId, CoinIndex)] = + coins.keys().filter(k => { + val c = coins.get(k) + c.purse == p and c.state == CoinAvailable + }) + + /// All selectable entry keys in `p` under `allowDegraded`. + def selectableEntriesIn(p: PurseId, allowDegraded: bool): Set[(PurseId, EntryIndex)] = + entries.keys().filter(k => entrySelectable(entries.get(k), allowDegraded) + and entries.get(k).purse == p) + + /// Tier 1: exists a subset of Available coins in `p` summing exactly to + /// `amount`. Encoded as: there exists `sel ⊆ availableCoinsIn(p)` with + /// `Σ value = amount`. We bound subset search via `powerset`. + def existsExactCover(p: PurseId, amount: Amount): bool = + availableCoinsIn(p).powerset().exists(sel => + sumOfCoinValues(coins, sel) == amount) + + /// Tier 2: exists a single Available coin whose value strictly exceeds + /// `amount` (split + change). + def existsSplitCover(p: PurseId, amount: Amount): bool = + availableCoinsIn(p).exists(k => coinValue(coins.get(k).exponent) > amount) + + /// Tier 3: exists selectable entries (optionally with whole coins) whose + /// combined value covers `amount` (entries supply the deficit). + def existsUnloadCover(p: PurseId, amount: Amount, allowDegraded: bool): bool = + selectableEntriesIn(p, allowDegraded).powerset().exists(esel => + availableCoinsIn(p).powerset().exists(csel => + sumOfCoinValues(coins, csel) + sumOfEntryValues(entries, esel) >= amount)) + + /// Selection succeeds at any tier. + def selectionFeasible(p: PurseId, amount: Amount, allowDegraded: bool): bool = or { + existsExactCover(p, amount), + existsSplitCover(p, amount), + existsUnloadCover(p, amount, allowDegraded), + } + + /// Spendable-when-ready value of a purse (used to distinguish + /// `NoReadyEntries` from `InsufficientFunds`). + def spendableWhenReady(p: PurseId): Amount = + purseSpendable(p) + pursePending(p) + + // 6.2 Top-up: external funds → fresh recycler entries. + action topUp(p: PurseId, exp: Exponent): bool = { + val purse = if (purses.keys().contains(p)) + purses.get(p) + else + { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val idx = purse.nextEntryIdx + val mk = deriveMemberKey(p, idx) + val ring = nextRingIdx + val ringExists = rings.keys().contains(ring) + val ringRec: RingRec = if (ringExists) + rings.get(ring) + else + { idx: ring, immutableSince: -1, expired: false } + val rec: EntryRec = { + purse: p, idx: idx, memberKey: mk, exponent: exp, + allocatedAt: now, readyAt: now + JitterMax, + ringIdx: ring, + onChain: OnWaiting, + local: LocalAvailable, + } + all { + purses.keys().contains(p), + exp <= MaxExponent, + exp >= 0, + entries' = entries.put((p, idx), rec), + chainEntries' = chainEntries.put((p, idx), rec), + rings' = rings.put(ring, ringRec), + purses' = purses.put(p, purse.with("nextEntryIdx", idx + 1)), + nextMemberKey' = nextMemberKey, + totalIn' = totalIn + coinValue(exp), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + coins' = coins, + operations' = operations, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EEntryAllocated({ purse: p, exponent: exp })), + totalOut' = totalOut, + } + } + + // 6.3 Direct transfer of an Available coin to a pre-arranged recipient. + action transfer(k: (PurseId, CoinIndex)): bool = { + val cExists = coins.keys().contains(k) + val c = if (cExists) coins.get(k) + else { purse: -1, idx: -1, account: -1, exponent: 0, age: 0, state: CoinPending } + all { + cExists, + c.state == CoinAvailable, + coins' = coins.put(k, c.with("state", CoinSpent).with("age", c.age + 1)), + totalOut' = totalOut + coinValue(c.exponent), + purses' = purses, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(ECoinSpent({ purse: c.purse, exponent: c.exponent })), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + } + } + + // ========================================================================== + // 6.x Operational primitives (multi-phase) + // ========================================================================== + // + // For each API primitive that submits one or more extrinsics, the layer + // creates a real OperationRec in SPreparing, locks the affected records, + // and walks the op through the SSubmitted → SInBlock → SFinalized → SDone + // progression (or to SFailed via opChainReject / cancelOp). + // + // Per §5.5 the multi-phase machinery here is per-extrinsic, but the spec + // tracks one extrinsic per operation at a time; operations that fan out + // across multiple extrinsics walk the phase progression once per group + // in sequence. + // + // Commit actions (`opCommit*`) are the side-effecting transitions that + // fire when the op transitions from SFinalized to SDone. They consume + // the locked records, produce the operation's results, append the + // receipt, and emit the terminal event. + + // 6.x.1 Start a Transfer operation. Locks the selected coin subset and + // creates a Preparing op of kind KTransfer. + action startTransfer( + p: PurseId, + sel: Set[(PurseId, CoinIndex)], + allowDegraded: bool, + ): bool = { + val amount = sumOfCoinValues(coins, sel) + val h = nextHandle + val op: OperationRec = { + handle: h, kind: KTransfer, purse: p, + status: SPreparing, + lockedCoins: sel, + lockedEntries: Set(), + } + val newCoins = sel.fold(coins, (m, k) => { + val c = m.get(k) + m.put(k, c.with("state", CoinLockedFor(h))) + }) + all { + purses.keys().contains(p), + sel.size() > 0, + sel.subseteq(availableCoinsIn(p)), + selectionFeasible(p, amount, allowDegraded), + operations' = operations.put(h, op), + coins' = newCoins, + nextHandle' = h + 1, + purses' = purses, + entries' = entries, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationStarted({ handle: h, kind: KTransfer, purse: p })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 6.x.2 Commit Transfer (Finalized → Done). Consumes the locked coins. + action opCommitTransfer(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val locked = op.lockedCoins + val accs = locked.fold(Set(), (a, k) => a.union(Set(coins.get(k).account))) + val totalSpent = locked.fold(0, (a, k) => a + coinValue(coins.get(k).exponent)) + val newCoins = locked.fold(coins, (m, k) => { + val c = m.get(k) + m.put(k, c.with("state", CoinSpent).with("age", c.age + 1)) + }) + val newEvents = locked.fold(events, (es, k) => + es.append(ECoinSpent({ purse: op.purse, exponent: coins.get(k).exponent }))) + .append(EOperationCompleted({ handle: h, status: SDone })) + all { + opExists, + op.kind == KTransfer, + op.status == SFinalized, + operations' = operations.put(h, op.with("status", SDone) + .with("lockedCoins", Set())), + coins' = newCoins, + totalOut' = totalOut + totalSpent, + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XSucceeded(accs), + }), + events' = newEvents, + purses' = purses, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + } + } + + // 6.x.3 Start a TopUp operation. Reserves the next entry index in + // the purse and creates a Preparing op of kind KTopUp. + action startTopUp(p: PurseId, exp: Exponent): bool = { + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val h = nextHandle + val op: OperationRec = { + handle: h, kind: KTopUp, purse: p, + status: SPreparing, + lockedCoins: Set(), + lockedEntries: Set(), + } + all { + pExists, + exp <= MaxExponent, + exp >= 0, + operations' = operations.put(h, op), + nextHandle' = h + 1, + purses' = purses, + coins' = coins, + entries' = entries, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationStarted({ handle: h, kind: KTopUp, purse: p })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 6.x.4 Commit TopUp (Finalized → Done). Creates the fresh recycler + // entry at the purse's next index. + action opCommitTopUp(h: OpHandle, exp: Exponent): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val p = op.purse + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val idx = purse.nextEntryIdx + val mk = deriveMemberKey(p, idx) + val ring = nextRingIdx + val ringExists = rings.keys().contains(ring) + val ringRec: RingRec = if (ringExists) + rings.get(ring) + else + { idx: ring, immutableSince: -1, expired: false } + val rec: EntryRec = { + purse: p, idx: idx, memberKey: mk, exponent: exp, + allocatedAt: now, readyAt: now + JitterMax, + ringIdx: ring, + onChain: OnWaiting, + local: LocalAvailable, + } + all { + opExists, + op.kind == KTopUp, + op.status == SFinalized, + pExists, + exp <= MaxExponent, + exp >= 0, + entries' = entries.put((p, idx), rec), + chainEntries' = chainEntries.put((p, idx), rec), + rings' = rings.put(ring, ringRec), + purses' = purses.put(p, purse.with("nextEntryIdx", idx + 1)), + operations' = operations.put(h, op.with("status", SDone)), + totalIn' = totalIn + coinValue(exp), + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XSucceeded(Set(mk)), + }), + events' = events.append(EEntryAllocated({ purse: p, exponent: exp })) + .append(EOperationCompleted({ handle: h, status: SDone })), + coins' = coins, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + totalOut' = totalOut, + } + } + + // 6.x.4b Start a Transfer deterministically. Picks the highest-priority + // Available coin (under §6.3 ordering) whose value equals `amount`. + // Models the single-coin exact-match case of tier 1. + // + // Precondition: there exists at least one Available coin in `p` whose + // value equals `amount`, and the picked `k` is the unique min-priority + // such coin. Two implementations agree on `k` for the same purse state. + action startTransferDeterministic(p: PurseId, k: (PurseId, CoinIndex)): bool = { + val cExists = coins.keys().contains(k) + val c = if (cExists) coins.get(k) + else { purse: -1, idx: -1, account: -1, exponent: 0, age: 0, state: CoinPending } + val amount = coinValue(c.exponent) + val h = nextHandle + val op: OperationRec = { + handle: h, kind: KTransfer, purse: p, + status: SPreparing, + lockedCoins: Set(k), + lockedEntries: Set(), + } + val matching = availableCoinsIn(p).filter(kk => + coinValue(coins.get(kk).exponent) == amount) + val isDeterministicPick = matching.contains(k) and matching.forall(kk => + kk == k or coinOrderLT(coins.get(k), coins.get(kk))) + all { + purses.keys().contains(p), + cExists, + c.purse == p, + c.state == CoinAvailable, + isDeterministicPick, + operations' = operations.put(h, op), + coins' = coins.put(k, c.with("state", CoinLockedFor(h))), + nextHandle' = h + 1, + purses' = purses, + entries' = entries, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationStarted({ handle: h, kind: KTransfer, purse: p })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 6.x.5 Start Export. Locks an Available coin; the secret will leave + // the layer when the op commits. + action startExport(k: (PurseId, CoinIndex)): bool = { + val cExists = coins.keys().contains(k) + val c = if (cExists) coins.get(k) + else { purse: -1, idx: -1, account: -1, exponent: 0, age: 0, state: CoinPending } + val h = nextHandle + val op: OperationRec = { + handle: h, kind: KExport, purse: c.purse, + status: SPreparing, + lockedCoins: Set(k), + lockedEntries: Set(), + } + all { + cExists, + c.state == CoinAvailable, + operations' = operations.put(h, op), + coins' = coins.put(k, c.with("state", CoinLockedFor(h))), + nextHandle' = h + 1, + purses' = purses, + entries' = entries, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationStarted({ handle: h, kind: KExport, purse: c.purse })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 6.x.6 Commit Export (Finalized → Done). + action opCommitExport(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val locked = op.lockedCoins + val totalSpent = locked.fold(0, (a, k) => a + coinValue(coins.get(k).exponent)) + val accs = locked.fold(Set(), (a, k) => a.union(Set(coins.get(k).account))) + val newCoins = locked.fold(coins, (m, k) => { + val c = m.get(k) + m.put(k, c.with("state", CoinSpent)) + }) + val newEvents = locked.fold(events, (es, k) => + es.append(ECoinSpent({ purse: op.purse, exponent: coins.get(k).exponent }))) + .append(EOperationCompleted({ handle: h, status: SDone })) + all { + opExists, + op.kind == KExport, + op.status == SFinalized, + operations' = operations.put(h, op.with("status", SDone) + .with("lockedCoins", Set())), + coins' = newCoins, + totalOut' = totalOut + totalSpent, + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XSucceeded(accs), + }), + events' = newEvents, + purses' = purses, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + } + } + + // 6.x.7 Start Import. Allocate-only, no locks. + action startImport(p: PurseId, exp: Exponent): bool = { + val pExists = purses.keys().contains(p) + val h = nextHandle + val op: OperationRec = { + handle: h, kind: KImport, purse: p, + status: SPreparing, + lockedCoins: Set(), + lockedEntries: Set(), + } + all { + pExists, + exp <= MaxExponent, + exp >= 0, + operations' = operations.put(h, op), + nextHandle' = h + 1, + purses' = purses, + coins' = coins, + entries' = entries, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationStarted({ handle: h, kind: KImport, purse: p })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 6.x.8 Commit Import (Finalized → Done). + action opCommitImport(h: OpHandle, exp: Exponent): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val p = op.purse + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val idx = purse.nextCoinIdx + val acct = deriveCoinAccount(p, idx) + val c: CoinRec = { + purse: p, idx: idx, account: acct, exponent: exp, + age: 0, state: CoinAvailable, + } + all { + opExists, + op.kind == KImport, + op.status == SFinalized, + pExists, + exp <= MaxExponent, + exp >= 0, + operations' = operations.put(h, op.with("status", SDone)), + coins' = coins.put((p, idx), c), + chainCoins' = chainCoins.put((p, idx), c), + purses' = purses.put(p, purse.with("nextCoinIdx", idx + 1)), + totalIn' = totalIn + coinValue(exp), + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XSucceeded(Set(acct)), + }), + events' = events.append(ECoinAvailable({ purse: p, exponent: exp })) + .append(EOperationCompleted({ handle: h, status: SDone })), + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainEntries' = chainEntries, + totalOut' = totalOut, + } + } + + // 6.x.9 Start Rebalance. Locks one coin in `src`. + action startRebalance(src: PurseId, dst: PurseId, k: (PurseId, CoinIndex)): bool = { + val srcExists = purses.keys().contains(src) + val dstExists = purses.keys().contains(dst) + val cExists = coins.keys().contains(k) + val c = if (cExists) coins.get(k) + else { purse: -1, idx: -1, account: -1, exponent: 0, age: 0, state: CoinPending } + val h = nextHandle + val op: OperationRec = { + handle: h, kind: KRebalance, purse: src, + status: SPreparing, + lockedCoins: Set(k), + lockedEntries: Set(), + } + all { + srcExists, + dstExists, + src != dst, + cExists, + c.purse == src, + c.state == CoinAvailable, + operations' = operations.put(h, op), + coins' = coins.put(k, c.with("state", CoinLockedFor(h))), + nextHandle' = h + 1, + purses' = purses, + entries' = entries, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationStarted({ handle: h, kind: KRebalance, purse: src })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 6.x.10 Commit Rebalance. The locked source coin terminates; a fresh + // age-0 coin appears in `dst`. + action opCommitRebalance(h: OpHandle, dst: PurseId): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val dstExists = purses.keys().contains(dst) + val dstPurse = if (dstExists) purses.get(dst) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val newIdx = dstPurse.nextCoinIdx + val acct = deriveCoinAccount(dst, newIdx) + val locked = op.lockedCoins + val srcExp = locked.fold(0, (a, k) => coins.get(k).exponent) + val newCoin: CoinRec = { + purse: dst, idx: newIdx, account: acct, exponent: srcExp, + age: 0, state: CoinAvailable, + } + val newCoins = locked.fold(coins, (m, k) => { + val c = m.get(k) + m.put(k, c.with("state", CoinSpent)) + }).put((dst, newIdx), newCoin) + all { + opExists, + op.kind == KRebalance, + op.status == SFinalized, + dstExists, + dst != op.purse, + locked.size() > 0, + operations' = operations.put(h, op.with("status", SDone) + .with("lockedCoins", Set())), + coins' = newCoins, + chainCoins' = chainCoins.put((dst, newIdx), newCoin), + purses' = purses.put(dst, dstPurse.with("nextCoinIdx", newIdx + 1)), + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XSucceeded(Set(acct)), + }), + events' = events.append(ECoinSpent({ purse: op.purse, exponent: srcExp })) + .append(ECoinAvailable({ purse: dst, exponent: srcExp })) + .append(EOperationCompleted({ handle: h, status: SDone })), + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // -------------------------------------------------------------------------- + // Deterministic multi-coin selection (§6.3 tier 1) + // -------------------------------------------------------------------------- + // + // The selection algorithm picks the **lex-min exact cover** under the + // priority ordering. Equivalently: of all subsets of available coins + // summing exactly to `amount`, the one that prefers higher-priority + // coins. Two implementations agree on the result. + // + // Formal definition: cover S beats cover T iff the highest-priority + // coin in the symmetric difference S Δ T is in S (not T). This is a + // strict total order on the (finite) set of exact covers, so the + // minimum exists and is unique when any cover exists at all. + + /// Min-priority coin key from a non-empty candidate set. + pure def pickMinPriorityCoinKey( + cs: (PurseId, CoinIndex) -> CoinRec, + candidates: Set[(PurseId, CoinIndex)], + ): (PurseId, CoinIndex) = + candidates.fold((-1, -1), (best, k) => + if (best == (-1, -1)) k + else if (coinOrderLT(cs.get(k), cs.get(best))) k + else best) + + /// True iff cover `s` beats cover `t` under lex-min-by-priority. Beats + /// means: the highest-priority key in the symmetric difference is in s. + /// If s == t this is false (no strict preference). + pure def coverBeats( + cs: (PurseId, CoinIndex) -> CoinRec, + s: Set[(PurseId, CoinIndex)], + t: Set[(PurseId, CoinIndex)], + ): bool = { + val diff = s.exclude(t).union(t.exclude(s)) + if (diff.size() == 0) false + else s.contains(pickMinPriorityCoinKey(cs, diff)) + } + + /// The lex-min exact cover. Returns empty set when no exact cover exists. + /// (Caller distinguishes "empty because amount == 0" from "no cover" + /// by checking selectionFeasible / amount separately.) + pure def selectExactCoverDeterministic( + cs: (PurseId, CoinIndex) -> CoinRec, + available: Set[(PurseId, CoinIndex)], + amount: Amount, + ): Set[(PurseId, CoinIndex)] = { + val covers = available.powerset().filter(s => + s.fold(0, (a, k) => a + coinValue(cs.get(k).exponent)) == amount) + if (covers.size() == 0) Set() + else covers.fold(Set(), (best, c) => + if (best.size() == 0 and not(covers.contains(Set()))) c + else if (coverBeats(cs, c, best)) c + else best) + } + + // 6.3.1.det Transfer of an exact amount, deterministic multi-coin. + // Picks the lex-min exact cover from the source purse and locks it. + // Encodes §6.3 tier 1 operationally — any implementation must select + // the same set for the same state. + action startTransferDeterministicMulti(p: PurseId, amount: Amount): bool = { + val cover = selectExactCoverDeterministic(coins, availableCoinsIn(p), amount) + val h = nextHandle + val op: OperationRec = { + handle: h, kind: KTransfer, purse: p, + status: SPreparing, + lockedCoins: cover, + lockedEntries: Set(), + } + val newCoins = cover.fold(coins, (m, k) => { + val c = m.get(k) + m.put(k, c.with("state", CoinLockedFor(h))) + }) + all { + purses.keys().contains(p), + amount > 0, + cover.size() > 0, + operations' = operations.put(h, op), + coins' = newCoins, + nextHandle' = h + 1, + purses' = purses, + entries' = entries, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationStarted({ handle: h, kind: KTransfer, purse: p })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 6.3.1 Transfer of an exact amount. Selection picks an Available coin + // subset whose value sums exactly to `amount` (tier 1). Tier 2 / tier 3 + // are modeled implicitly: they would arise as split/unload extrinsics in + // a multi-extrinsic operation, captured separately in step 8. For the + // single-shot abstraction here, only tier 1 fires inside this action, + // but `selectionFeasible` is asserted as a precondition so the model is + // honest about when selection can succeed at all. + action transferAmount( + p: PurseId, + sel: Set[(PurseId, CoinIndex)], + allowDegraded: bool, + ): bool = { + val amount = sumOfCoinValues(coins, sel) + val newCoins = sel.fold(coins, (m, k) => { + val c = m.get(k) + m.put(k, c.with("state", CoinSpent).with("age", c.age + 1)) + }) + val newEvents = sel.fold(events, (es, k) => + es.append(ECoinSpent({ purse: p, exponent: coins.get(k).exponent }))) + all { + purses.keys().contains(p), + sel.size() > 0, + sel.subseteq(availableCoinsIn(p)), + selectionFeasible(p, amount, allowDegraded), + coins' = newCoins, + totalOut' = totalOut + amount, + events' = newEvents, + purses' = purses, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + } + } + + // 6.4 Export coins: coin leaves the layer's view, secret handed off externally. + action exportCoin(k: (PurseId, CoinIndex)): bool = { + val cExists = coins.keys().contains(k) + val c = if (cExists) coins.get(k) + else { purse: -1, idx: -1, account: -1, exponent: 0, age: 0, state: CoinPending } + all { + cExists, + c.state == CoinAvailable, + coins' = coins.put(k, c.with("state", CoinSpent)), + totalOut' = totalOut + coinValue(c.exponent), + purses' = purses, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(ECoinSpent({ purse: c.purse, exponent: c.exponent })), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + } + } + + // 6.5 Import coins: external (account, secret) → fresh coin in p. + action importCoin(p: PurseId, exp: Exponent): bool = { + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val idx = purse.nextCoinIdx + val acct = deriveCoinAccount(p, idx) + val c: CoinRec = { + purse: p, idx: idx, account: acct, exponent: exp, + age: 0, state: CoinAvailable, + } + all { + pExists, + exp <= MaxExponent, + exp >= 0, + coins' = coins.put((p, idx), c), + chainCoins' = chainCoins.put((p, idx), c), + purses' = purses.put(p, purse.with("nextCoinIdx", idx + 1)), + nextAccount' = nextAccount, + totalIn' = totalIn + coinValue(exp), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainEntries' = chainEntries, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(ECoinAvailable({ purse: p, exponent: exp })), + totalOut' = totalOut, + } + } + + // 6.6 Start external offload: lock an entry, create Preparing operation. + // `requested` is the externally-bound amount; the entry's surplus + // (entry value − requested) is reloaded atomically at offboard time. + action startExternalOffload(k: (PurseId, EntryIndex), requested: Amount): bool = { + val eExists = entries.keys().contains(k) + val e = if (eExists) entries.get(k) + else { purse: -1, idx: -1, memberKey: -1, exponent: 0, allocatedAt: 0, + readyAt: 0, ringIdx: -1, onChain: OnMissing, local: LocalAvailable } + val h = nextHandle + val op: OperationRec = { + handle: h, kind: KExternalOffload, purse: k._1, + status: SPreparing, + lockedCoins: Set(), + lockedEntries: Set(k), + } + all { + eExists, + e.local == LocalAvailable, + requested >= 0, + requested <= coinValue(e.exponent), + operations' = operations.put(h, op), + entries' = entries.put(k, e.with("local", LocalLockedFor(h))), + nextHandle' = h + 1, + purses' = purses, + coins' = coins, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + opRequested' = opRequested.put(h, requested), + opExternalized' = opExternalized.put(h, 0), + events' = events.append(EOperationStarted({ handle: h, kind: KExternalOffload, purse: k._1 })), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalOut' = totalOut, + } + } + + // 6.6.b Start an external offload over a *set* of entries. Each entry + // in the set is locked to the new operation. The operation will then + // submit one extrinsic per `(denomination, ring)` group via + // `opOffboardGroup`, with surplus reload per group. + action startExternalOffloadMulti( + ents: Set[(PurseId, EntryIndex)], + requested: Amount, + ): bool = { + val h = nextHandle + val somePurse = if (ents.size() > 0) + entries.get(ents.fold((-1, -1), (a, k) => k)).purse + else -1 + val op: OperationRec = { + handle: h, kind: KExternalOffload, purse: somePurse, + status: SPreparing, + lockedCoins: Set(), + lockedEntries: ents, + } + val newEntries = ents.fold(entries, (m, k) => { + val e = m.get(k) + m.put(k, e.with("local", LocalLockedFor(h))) + }) + val totalAvailable = sumOfEntryValues(entries, ents) + all { + ents.size() > 0, + requested >= 0, + requested <= totalAvailable, + // All entries must currently be LocalAvailable and from the same purse. + ents.forall(k => entries.keys().contains(k) and entries.get(k).local == LocalAvailable), + ents.forall(k => entries.get(k).purse == somePurse), + purses.keys().contains(somePurse), + operations' = operations.put(h, op), + entries' = newEntries, + nextHandle' = h + 1, + opRequested' = opRequested.put(h, requested), + opExternalized' = opExternalized.put(h, 0), + purses' = purses, + coins' = coins, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationStarted({ handle: h, kind: KExternalOffload, purse: somePurse })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 6.6.c Offboard one (denomination, ring) group of an external offload. + // Consumes all locked entries with the given `(gExp, gRing)`, produces + // `externalForGroup` to totalOut + a single fresh entry of + // `surplusExponent` carrying the group's surplus value. Consumes one + // unload token. Op stays SPreparing until all groups are processed, + // then `opCompleteExternalOffload` fires. + action opOffboardGroup( + h: OpHandle, + gExp: Exponent, + gRing: RingIndex, + surplusExponent: Exponent, + externalForGroup: Amount, + ): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val groupEntries = op.lockedEntries.filter(k => { + val e = entries.get(k) + e.local == LocalLockedFor(h) and e.exponent == gExp and e.ringIdx == gRing + }) + val groupSize = groupEntries.size() + val groupValue = groupSize * coinValue(gExp) + val surplus = groupValue - externalForGroup + val p = op.purse + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val newIdx = purse.nextEntryIdx + val mk = deriveMemberKey(p, newIdx) + val newRing = nextRingIdx + val newRingExists = rings.keys().contains(newRing) + val newRingRec: RingRec = if (newRingExists) + rings.get(newRing) + else + { idx: newRing, immutableSince: -1, expired: false } + val surplusEntry: EntryRec = { + purse: p, idx: newIdx, memberKey: mk, exponent: surplusExponent, + allocatedAt: now, readyAt: now + JitterMax, + ringIdx: newRing, + onChain: OnWaiting, + local: LocalAvailable, + } + val hasSurplus = surplus > 0 + val newEntries = groupEntries.fold(entries, (m, k) => + m.put(k, m.get(k).with("local", LocalConsumed))) + val newEntries2 = if (hasSurplus) + newEntries.put((p, newIdx), surplusEntry) + else newEntries + val newPurses = if (hasSurplus) + purses.put(p, purse.with("nextEntryIdx", newIdx + 1)) + else purses + val newRings = if (hasSurplus) rings.put(newRing, newRingRec) else rings + val currentExt = if (opExternalized.keys().contains(h)) opExternalized.get(h) else 0 + val requested = if (opRequested.keys().contains(h)) opRequested.get(h) else 0 + val freeCtr = availableFreeCounter(currentPeriod) + val useFree = freeCtr >= 0 + val newTokens = if (useFree) + consumeFreeToken(tokens, currentPeriod, freeCtr) + else + consumePaidToken(tokens, currentPeriod, 0) + val newPaid = if (useFree) paidRingMembership + else paidRingMembership.put(currentPeriod, true) + val paidJoinFee = if (useFree) 0 + else if (paidRingMembership.keys().contains(currentPeriod) + and paidRingMembership.get(currentPeriod)) + 0 + else 1 + val feeMode: FeeMode = if (feeAccountBalance - paidJoinFee >= UnloadFee) + FMPrepaid else FMFromOutput + val unloadFeePaid = if (feeMode == FMPrepaid) UnloadFee else 0 + val newFee = feeAccountBalance - paidJoinFee - unloadFeePaid + val opEvents = if (hasSurplus) + events.append(EEntryAllocated({ purse: p, exponent: surplusExponent })) + else events + all { + opExists, + op.kind == KExternalOffload, + op.status == SPreparing, + groupSize > 0, + pExists, + // All group entries must be Ready and past readyAt (strict). + groupEntries.forall(k => entries.get(k).onChain == OnReady and entries.get(k).readyAt <= now), + externalForGroup >= 0, + externalForGroup <= groupValue, + currentExt + externalForGroup <= requested, + canObtainUnloadToken, + not(hasSurplus) or coinValue(surplusExponent) == surplus, + not(hasSurplus) or (surplusExponent >= 0 and surplusExponent <= MaxExponent), + operations' = operations.put(h, op.with("lockedEntries", + op.lockedEntries.exclude(groupEntries))), + entries' = newEntries2, + purses' = newPurses, + rings' = newRings, + totalOut' = totalOut + externalForGroup, + opExternalized' = opExternalized.put(h, currentExt + externalForGroup), + tokens' = newTokens, + paidRingMembership' = newPaid, + feeAccountBalance' = newFee, + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XSucceeded(groupEntries.fold(Set(), (a, k) => a.union(Set(entries.get(k).memberKey)))), + }), + events' = opEvents, + coins' = coins, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + opRequested' = opRequested, + chainCoins' = chainCoins, + chainEntries' = if (hasSurplus) chainEntries.put((p, newIdx), surplusEntry) else chainEntries, + totalIn' = totalIn, + } + } + + // 6.6.d Complete an external-offload operation after every group has + // been offboarded. Asserts the externalized total equals `requested`. + action opCompleteExternalOffload(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val externalized = if (opExternalized.keys().contains(h)) opExternalized.get(h) else 0 + val requested = if (opRequested.keys().contains(h)) opRequested.get(h) else 0 + all { + opExists, + op.kind == KExternalOffload, + op.status == SPreparing, + op.lockedEntries.size() == 0, + externalized == requested, + operations' = operations.put(h, op.with("status", SDone)), + events' = events.append(EOperationCompleted({ handle: h, status: SDone })), + purses' = purses, + coins' = coins, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 6.7 Cancel a Preparing or Waiting operation: release locks, emit Failed. + def isCancellable(op: OperationRec): bool = match op.status { + | SPreparing => true + | SWaiting(_) => true + | _ => false + } + + action cancelOp(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val releasedCoins = coins.keys().fold(coins, (m, ck) => { + val c = m.get(ck) + if (isCoinLocked(c) and coinLockHandle(c) == h) + m.put(ck, c.with("state", CoinAvailable)) + else m + }) + val releasedEntries = entries.keys().fold(entries, (m, ek) => { + val e = m.get(ek) + if (isEntryLocked(e) and entryLockHandle(e) == h) + m.put(ek, e.with("local", LocalAvailable)) + else m + }) + all { + opExists, + isCancellable(op), + operations' = operations.put(h, op.with("status", SFailed(FRCancelled)) + .with("lockedCoins", Set()) + .with("lockedEntries", Set())), + coins' = releasedCoins, + entries' = releasedEntries, + purses' = purses, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XRejected(FRCancelled), + }), + events' = events.append(EOperationCompleted({ handle: h, status: SFailed(FRCancelled) })), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalOut' = totalOut, + } + } + + // ========================================================================== + // 7. Operation phase transitions (external offload) + // ========================================================================== + + // Pick the (only) locked entry from an operation. + def pickLockedEntry(op: OperationRec): (PurseId, EntryIndex) = + op.lockedEntries.fold((-1, -1), (acc, k) => k) + + // 7.1 Offboard: consume the locked entry, complete the operation. + /// Surplus-reload: when an external offload consumes an entry of value + /// V to satisfy a `requested` < V, the surplus V − requested MUST be + /// reloaded atomically as fresh entries in the same purse so that no + /// surplus value escapes back as a free coin (§8.6). + /// + /// For tractability, the model places the entire surplus into ONE + /// fresh entry of an arbitrary exponent. The `surplusExponent` parameter + /// declares the chosen exponent; the precondition asserts that + /// `coinValue(surplusExponent) == surplus`, i.e. the surplus must + /// happen to be a power of two. In the actual chain, multiple + /// reload-entries are produced if necessary; the spec abstracts that + /// case by requiring the caller to size `requested` such that the + /// surplus fits a single power of two. + action opOffboard(h: OpHandle, surplusExponent: Exponent): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val ek = pickLockedEntry(op) + val eExists = entries.keys().contains(ek) + val e = if (eExists) entries.get(ek) + else { purse: -1, idx: -1, memberKey: -1, exponent: 0, allocatedAt: 0, + readyAt: 0, ringIdx: -1, onChain: OnMissing, local: LocalAvailable } + val requested = if (opRequested.keys().contains(h)) opRequested.get(h) + else coinValue(e.exponent) + val entryValue = coinValue(e.exponent) + val surplus = entryValue - requested + val p = e.purse + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val newIdx = purse.nextEntryIdx + val mk = deriveMemberKey(p, newIdx) + val ring = nextRingIdx + val ringExists = rings.keys().contains(ring) + val ringRec: RingRec = if (ringExists) + rings.get(ring) + else + { idx: ring, immutableSince: -1, expired: false } + val surplusEntry: EntryRec = { + purse: p, idx: newIdx, memberKey: mk, exponent: surplusExponent, + allocatedAt: now, readyAt: now + JitterMax, + ringIdx: ring, + onChain: OnWaiting, + local: LocalAvailable, + } + val hasSurplus = surplus > 0 + val newEntries = if (hasSurplus) + entries.put(ek, e.with("local", LocalConsumed)).put((p, newIdx), surplusEntry) + else + entries.put(ek, e.with("local", LocalConsumed)) + val newPurses = if (hasSurplus) + purses.put(p, purse.with("nextEntryIdx", newIdx + 1)) + else + purses + val newRings = if (hasSurplus) rings.put(ring, ringRec) else rings + val newEvents = if (hasSurplus) + events.append(EEntryConsumed({ purse: p, exponent: e.exponent })) + .append(EEntryAllocated({ purse: p, exponent: surplusExponent })) + .append(EOperationCompleted({ handle: h, status: SDone })) + else + events.append(EEntryConsumed({ purse: p, exponent: e.exponent })) + .append(EOperationCompleted({ handle: h, status: SDone })) + all { + opExists, + op.kind == KExternalOffload, + op.status == SPreparing, + // Legacy single-entry opOffboard: only fires when exactly one entry + // is locked. Multi-entry ops use opOffboardGroup + opCompleteExternalOffload. + op.lockedEntries.size() == 1, + eExists, + pExists, + // Entry must be selectable when offboarding (strict). + e.local == LocalLockedFor(h), + e.onChain == OnReady, + e.readyAt <= now, + // Surplus precondition: the surplus equals exactly one power of two. + surplus >= 0, + not(hasSurplus) or coinValue(surplusExponent) == surplus, + not(hasSurplus) or surplusExponent <= MaxExponent, + not(hasSurplus) or surplusExponent >= 0, + entries' = newEntries, + operations' = operations.put(h, op.with("status", SDone) + .with("lockedEntries", Set())), + totalOut' = totalOut + requested, + purses' = newPurses, + coins' = coins, + rings' = newRings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XSucceeded(Set(e.memberKey)), + }), + events' = newEvents, + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = if (hasSurplus) chainEntries.put((p, newIdx), surplusEntry) else chainEntries, + } + } + + // 7.2 Enter Waiting: locked entry not yet ready. + action opEnterWait(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val ek = pickLockedEntry(op) + val eExists = entries.keys().contains(ek) + val e = if (eExists) entries.get(ek) + else { purse: -1, idx: -1, memberKey: -1, exponent: 0, allocatedAt: 0, + readyAt: 0, ringIdx: -1, onChain: OnMissing, local: LocalAvailable } + all { + opExists, + op.kind == KExternalOffload, + op.status == SPreparing, + op.lockedEntries.size() > 0, + eExists, + not(e.onChain == OnReady and e.readyAt <= now), + operations' = operations.put(h, op.with("status", SWaiting(e.readyAt))), + purses' = purses, + coins' = coins, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 7.3 Wake from Waiting → Preparing. + def waitingUntil(op: OperationRec): Time = match op.status { + | SWaiting(t) => t + | _ => -1 + } + + action opWake(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val until = waitingUntil(op) + all { + opExists, + until >= 0, + until <= now, + operations' = operations.put(h, op.with("status", SPreparing)), + purses' = purses, + coins' = coins, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // ========================================================================== + // 7.x Multi-extrinsic phase transitions (§5.5) + // ========================================================================== + // + // Operations that submit extrinsics walk a per-extrinsic progression + // SPreparing → SSubmitted → SInBlock → SFinalized → SDone + // The status reflects the *least-progressed* unfinalized extrinsic; in + // this simplified model we track one extrinsic per operation at a time. + + /// Operation may currently advance from Preparing to Submitted. For + /// kinds that consume records (non-allocate-only), at least one locked + /// record must remain — otherwise `midSubmissionHoldsLocks` is violated. + def canAdvanceToSubmitted(op: OperationRec): bool = and { + op.status == SPreparing, + isAllocateOnlyKind(op.kind) + or op.lockedCoins.size() > 0 + or op.lockedEntries.size() > 0, + } + + action opAdvanceToSubmitted(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + all { + opExists, + canAdvanceToSubmitted(op), + operations' = operations.put(h, op.with("status", SSubmitted)), + purses' = purses, + coins' = coins, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationProgress({ handle: h, status: SSubmitted })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + action opAdvanceToInBlock(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + all { + opExists, + op.status == SSubmitted, + operations' = operations.put(h, op.with("status", SInBlock)), + purses' = purses, + coins' = coins, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationProgress({ handle: h, status: SInBlock })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + action opAdvanceToFinalized(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + all { + opExists, + op.status == SInBlock, + operations' = operations.put(h, op.with("status", SFinalized)), + purses' = purses, + coins' = coins, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EOperationProgress({ handle: h, status: SFinalized })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + /// Chain rejects a submitted extrinsic. Operation enters SFailed and + /// the held locks remain to be released by `cancelOp`. + action opChainReject(h: OpHandle): bool = { + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val releasedCoins = coins.keys().fold(coins, (m, ck) => { + val c = m.get(ck) + if (isCoinLocked(c) and coinLockHandle(c) == h) + m.put(ck, c.with("state", CoinAvailable)) + else m + }) + val releasedEntries = entries.keys().fold(entries, (m, ek) => { + val e = m.get(ek) + if (isEntryLocked(e) and entryLockHandle(e) == h) + m.put(ek, e.with("local", LocalAvailable)) + else m + }) + all { + opExists, + op.status == SSubmitted or op.status == SInBlock, + operations' = operations.put(h, op.with("status", SFailed(FRChainRejected)) + .with("lockedCoins", Set()) + .with("lockedEntries", Set())), + coins' = releasedCoins, + entries' = releasedEntries, + purses' = purses, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XRejected(FRChainRejected), + }), + events' = events.append(EOperationCompleted({ handle: h, status: SFailed(FRChainRejected) })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // ========================================================================== + // 8. Autonomous maintenance sweeps (§6.4) + // ========================================================================== + + // 8.1 Coin-age recycle: consume an aging coin, produce a fresh entry. + action coinAgeRecycle(k: (PurseId, CoinIndex)): bool = { + val cExists = coins.keys().contains(k) + val c = if (cExists) coins.get(k) + else { purse: -1, idx: -1, account: -1, exponent: 0, age: 0, state: CoinPending } + val p = c.purse + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val newIdx = purse.nextEntryIdx + val mk = deriveMemberKey(p, newIdx) + val ring = nextRingIdx + val ringExists = rings.keys().contains(ring) + val ringRec: RingRec = if (ringExists) + rings.get(ring) + else + { idx: ring, immutableSince: -1, expired: false } + val newEntry: EntryRec = { + purse: p, idx: newIdx, memberKey: mk, exponent: c.exponent, + allocatedAt: now, readyAt: now + JitterMax, + ringIdx: ring, + onChain: OnWaiting, + local: LocalAvailable, + } + all { + cExists, + c.state == CoinAvailable, + c.age >= RecycleAtAge, + pExists, + coins' = coins.put(k, c.with("state", CoinSpent)), + entries' = entries.put((p, newIdx), newEntry), + chainEntries' = chainEntries.put((p, newIdx), newEntry), + purses' = purses.put(p, purse.with("nextEntryIdx", newIdx + 1)), + rings' = rings.put(ring, ringRec), + nextMemberKey' = nextMemberKey, + operations' = operations, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(ECoinSpent({ purse: p, exponent: c.exponent })) + .append(EEntryAllocated({ purse: p, exponent: c.exponent })), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + totalOut' = totalOut, + } + } + + // 8.2 Ring-expiration rescue: pick a selectable entry whose ring is + // approaching expiry, consume it, produce a fresh age-0 coin. + action ringExpirationRescue(k: (PurseId, EntryIndex)): bool = { + val eExists = entries.keys().contains(k) + val e = if (eExists) entries.get(k) + else { purse: -1, idx: -1, memberKey: -1, exponent: 0, allocatedAt: 0, + readyAt: 0, ringIdx: -1, onChain: OnMissing, local: LocalAvailable } + val ringExists = rings.keys().contains(e.ringIdx) + val ring = if (ringExists) + rings.get(e.ringIdx) + else + { idx: -1, immutableSince: -1, expired: true } + val p = e.purse + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val newIdx = purse.nextCoinIdx + val acct = deriveCoinAccount(p, newIdx) + val newCoin: CoinRec = { + purse: p, idx: newIdx, account: acct, exponent: e.exponent, + age: 0, state: CoinAvailable, + } + val freeCtr = availableFreeCounter(currentPeriod) + val useFree = freeCtr >= 0 + val newTokens = if (useFree) + consumeFreeToken(tokens, currentPeriod, freeCtr) + else + consumePaidToken(tokens, currentPeriod, 0) + val joinedRing = paidRingMembership.put(currentPeriod, true) + val newPaid = if (useFree) paidRingMembership else joinedRing + val paidJoinFee = if (useFree) 0 + else if (paidRingMembership.keys().contains(currentPeriod) + and paidRingMembership.get(currentPeriod)) + 0 + else 1 + // Fee mode pick (§6.6): prepaid if the fee account can cover the + // unload fee *after* joining the paid ring; otherwise from-output. + val feeMode: FeeMode = if (feeAccountBalance - paidJoinFee >= UnloadFee) + FMPrepaid else FMFromOutput + val unloadFeePaid = if (feeMode == FMPrepaid) UnloadFee else 0 + val newFee = feeAccountBalance - paidJoinFee - unloadFeePaid + all { + eExists, + e.local == LocalAvailable, + ringExists, + ring.immutableSince >= 0, + now + RescueMargin >= ring.immutableSince + RecyclerExpiration, + not(ring.expired), + pExists, + canObtainUnloadToken, + entries' = entries.put(k, e.with("local", LocalConsumed)), + coins' = coins.put((p, newIdx), newCoin), + chainCoins' = chainCoins.put((p, newIdx), newCoin), + purses' = purses.put(p, purse.with("nextCoinIdx", newIdx + 1)), + nextAccount' = nextAccount, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + tokens' = newTokens, + paidRingMembership' = newPaid, + feeAccountBalance' = newFee, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainEntries' = chainEntries, + events' = events.append(EEntryConsumed({ purse: p, exponent: e.exponent })) + .append(ECoinAvailable({ purse: p, exponent: e.exponent })), + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // ========================================================================== + // 8.x Unload tokens (§6.5) + // ========================================================================== + + /// Current period as a function of `now`. + def currentPeriod: int = now / PeriodLength + + /// True iff the (period, class, counter) slot is currently consumed. + def isTokenConsumed(period: int, cls: UnloadTokenClass, ctr: int): bool = + tokens.keys().contains((period, cls, ctr)) + and tokens.get((period, cls, ctr)).consumed + + /// Picks the smallest counter index < `FreeTokenSearchRange` that is + /// not consumed for `(period, UTFree)`. Returns `-1` if none. + def availableFreeCounter(period: int): int = { + val candidates = 0.to(FreeTokenSearchRange - 1) + .filter(c => not(isTokenConsumed(period, UTFree, c))) + if (candidates.size() == 0) -1 + else candidates.fold(FreeTokenSearchRange, (a, c) => if (c < a) c else a) + } + + /// Periods to probe per design §6.5: current and prior `PeriodLookbackGrace`. + def candidatePeriods: Set[int] = + 0.to(PeriodLookbackGrace).map(off => currentPeriod - off) + .filter(p => p >= 0) + + /// True iff any free token slot is available in any candidate period. + def hasFreeUnloadToken: bool = + candidatePeriods.exists(p => availableFreeCounter(p) >= 0) + + /// True iff the host can use a paid token: it has joined the current + /// paid ring (cached), or it can afford to join now. + def canUsePaidToken: bool = or { + paidRingMembership.keys().contains(currentPeriod) + and paidRingMembership.get(currentPeriod), + feeAccountBalance >= 1, + } + + /// Caller-side precondition: at least one unload token can be obtained. + def canObtainUnloadToken: bool = hasFreeUnloadToken or canUsePaidToken + + // 8.x.1 Join the paid-token ring for the current period (one-time fee). + action joinPaidRing: bool = all { + not(paidRingMembership.keys().contains(currentPeriod) + and paidRingMembership.get(currentPeriod)), + feeAccountBalance >= 1, + paidRingMembership' = paidRingMembership.put(currentPeriod, true), + feeAccountBalance' = feeAccountBalance - 1, + purses' = purses, + coins' = coins, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events, + tokens' = tokens, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + + // 8.x.2 External fee account top-up (out of band; modeled as a free + // action that adds balance). + action topUpFeeAccount(amount: Amount): bool = all { + amount > 0, + amount <= 5, + feeAccountBalance' = feeAccountBalance + amount, + paidRingMembership' = paidRingMembership, + purses' = purses, + coins' = coins, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events, + tokens' = tokens, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + + /// Consume a token slot, returning the updated token map. + pure def consumeFreeToken( + ts: (int, UnloadTokenClass, int) -> UnloadToken, + period: int, + ctr: int, + ): (int, UnloadTokenClass, int) -> UnloadToken = { + val rec: UnloadToken = { period: period, class: UTFree, counter: ctr, consumed: true } + ts.put((period, UTFree, ctr), rec) + } + + pure def consumePaidToken( + ts: (int, UnloadTokenClass, int) -> UnloadToken, + period: int, + ctr: int, + ): (int, UnloadTokenClass, int) -> UnloadToken = { + val rec: UnloadToken = { period: period, class: UTPaid, counter: ctr, consumed: true } + ts.put((period, UTPaid, ctr), rec) + } + + /// True iff coin `k` is eligible for the coin-age recycle sweep. + def coinAgeRecycleEligible(k: (PurseId, CoinIndex)): bool = { + val c = coins.get(k) + coins.keys().contains(k) + and c.state == CoinAvailable + and c.age >= RecycleAtAge + and purses.keys().contains(c.purse) + } + + /// True iff entry `k` is eligible for the ring-expiration rescue sweep. + def ringExpirationRescueEligible(k: (PurseId, EntryIndex)): bool = { + val e = entries.get(k) + val ringExists = rings.keys().contains(e.ringIdx) + entries.keys().contains(k) + and e.local == LocalAvailable + and ringExists + and rings.get(e.ringIdx).immutableSince >= 0 + and now + RescueMargin >= rings.get(e.ringIdx).immutableSince + RecyclerExpiration + and not(rings.get(e.ringIdx).expired) + and purses.keys().contains(e.purse) + and canObtainUnloadToken + } + + /// True iff *some* maintenance work is eligible right now. + def anySweepEligible: bool = or { + coins.keys().exists(coinAgeRecycleEligible), + entries.keys().exists(ringExpirationRescueEligible), + } + + // 8.3 Caller-triggered maintenance sweep (§8.7). Fires one eligible + // sweep action — `coinAgeRecycle` if any aging coin is eligible, else + // `ringExpirationRescue` if any entry is near ring expiry. The caller + // invokes the sweep repeatedly to drain. The autonomous actions remain + // independently enabled per their own preconditions; the sweep API + // exists to give the caller a single entry point that mirrors what an + // implementation's periodic scheduler does. + action runMaintenanceSweep: bool = any { + nondet k = oneOf(coins.keys().filter(coinAgeRecycleEligible)) + coinAgeRecycle(k), + + nondet k = oneOf(entries.keys().filter(ringExpirationRescueEligible)) + ringExpirationRescue(k), + } + + // 8.4 Restart (§7.4). All durable state is preserved. Any operation + // whose status is `SSubmitted` / `SInBlock` / `SFinalized` / `SWaiting` + // is restored as `SPreparing` so the host can re-observe the chain and + // proceed. Terminal operations are preserved. Locks held by restored + // operations remain in place. + def isInFlight(op: OperationRec): bool = match op.status { + | SPreparing => true + | SSubmitted => true + | SInBlock => true + | SFinalized => true + | SWaiting(_) => true + | _ => false + } + + action restart: bool = all { + operations' = operations.keys().fold(Map(), (m, h) => { + val op = operations.get(h) + if (isInFlight(op)) + m.put(h, op.with("status", SPreparing)) + else + m.put(h, op) + }), + purses' = purses, + coins' = coins, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EResynced), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalOut' = totalOut, + } + + // ========================================================================== + // 8.y Classification and recovery (§8.8 / §8.10) + // ========================================================================== + + /// True iff `acct` is the account of some coin known to any purse. + def coinExistsForAccount(acct: Account): bool = + coins.keys().exists(k => coins.get(k).account == acct) + + /// Synchronous classification of incoming payment memos (§8.8). The + /// layer treats an empty memo list as `PCUnmatched`. + def classifyIncomingPayment(memos: List[MemoEntry]): PaymentClassification = { + val n = memos.length() + val matched = memos.foldl(0, (a, m) => + if (coinExistsForAccount(m.recipientAccount)) a + 1 else a) + if (n == 0) PCUnmatched + else if (matched == n) PCMatched + else if (matched == 0) PCUnmatched + else PCReceived + } + + // -------------------------------------------------------------------------- + // Gap-limit recovery (Appendix C) + // -------------------------------------------------------------------------- + // + // `chainCoins` / `chainEntries` model the on-chain storage. Local state + // may diverge from chain state via `induceLocalLoss` (modeling restart + // with partial state loss). `recover` rebuilds local state from chain + // via a deterministic gap-limit scan in batches of `BatchSize` over the + // (purse, idx) namespace. + + /// Scan accumulator: cursor, consecutive-empty batch count, recovered set. + type CoinScanAcc = { + cursor: int, + emptyBatches: int, + found: (PurseId, CoinIndex) -> CoinRec, + } + + /// One batch step: scan indices [cursor, cursor+BatchSize) in purse `p`. + /// Records present on the chain (and not already local) are added to + /// `found`. Empty-batch counter resets on any find. + pure def scanCoinsBatchStep( + cc: (PurseId, CoinIndex) -> CoinRec, + p: PurseId, + acc: CoinScanAcc, + ): CoinScanAcc = { + if (acc.emptyBatches >= GapLimit) acc + else { + val batchIdxs = acc.cursor.to(acc.cursor + BatchSize - 1) + val batchFound = batchIdxs.fold(Set(), (s, i) => + if (cc.keys().contains((p, i))) s.union(Set(i)) else s) + val newFound = batchFound.fold(acc.found, (m, i) => m.put((p, i), cc.get((p, i)))) + val anyFound = batchFound.size() > 0 + { + cursor: acc.cursor + BatchSize, + emptyBatches: if (anyFound) 0 else acc.emptyBatches + 1, + found: newFound, + } + } + } + + /// Drive `scanCoinsBatchStep` until the gap limit is hit or the scan + /// ceiling is reached. The bounded `MaxScanIndex` keeps the fold finite. + pure def gapLimitScanCoins( + cc: (PurseId, CoinIndex) -> CoinRec, + p: PurseId, + ): (PurseId, CoinIndex) -> CoinRec = { + val initAcc: CoinScanAcc = { cursor: 0, emptyBatches: 0, found: Map() } + val final = 0.to(MaxScanIndex / BatchSize).fold(initAcc, (acc, _) => + scanCoinsBatchStep(cc, p, acc)) + final.found + } + + type EntryScanAcc = { + cursor: int, + emptyBatches: int, + found: (PurseId, EntryIndex) -> EntryRec, + } + + pure def scanEntriesBatchStep( + ce: (PurseId, EntryIndex) -> EntryRec, + p: PurseId, + acc: EntryScanAcc, + ): EntryScanAcc = { + if (acc.emptyBatches >= GapLimit) acc + else { + val batchIdxs = acc.cursor.to(acc.cursor + BatchSize - 1) + val batchFound = batchIdxs.fold(Set(), (s, i) => + if (ce.keys().contains((p, i))) s.union(Set(i)) else s) + val newFound = batchFound.fold(acc.found, (m, i) => m.put((p, i), ce.get((p, i)))) + val anyFound = batchFound.size() > 0 + { + cursor: acc.cursor + BatchSize, + emptyBatches: if (anyFound) 0 else acc.emptyBatches + 1, + found: newFound, + } + } + } + + pure def gapLimitScanEntries( + ce: (PurseId, EntryIndex) -> EntryRec, + p: PurseId, + ): (PurseId, EntryIndex) -> EntryRec = { + val initAcc: EntryScanAcc = { cursor: 0, emptyBatches: 0, found: Map() } + val final = 0.to(MaxScanIndex / BatchSize).fold(initAcc, (acc, _) => + scanEntriesBatchStep(ce, p, acc)) + final.found + } + + /// Restart-with-recovery scenario: atomically wipe local `coins` and + /// `entries` for `p`, then rebuild them via the gap-limit scan over + /// chain storage. Models the full restart-and-recover sequence as one + /// transition so safety invariants (conservation, etc.) remain valid. + /// Recovery is exact iff chain contains all records the local view had, + /// which is the invariant `chainCoins ⊇ coins` upheld by every creation + /// site. Spent coins on the chain remain Spent after recovery. + action restartAndRecover(p: PurseId): bool = { + val localKeptCoins = coins.keys().filter(k => k._1 != p) + .fold(Map(), (m, k) => m.put(k, coins.get(k))) + val localKeptEntries = entries.keys().filter(k => k._1 != p) + .fold(Map(), (m, k) => m.put(k, entries.get(k))) + val recovered = gapLimitScanCoins(chainCoins, p) + val recoveredE = gapLimitScanEntries(chainEntries, p) + val newCoins = recovered.keys().fold(localKeptCoins, (m, k) => + m.put(k, recovered.get(k))) + val newEntries = recoveredE.keys().fold(localKeptEntries, (m, k) => + m.put(k, recoveredE.get(k))) + all { + purses.keys().contains(p), + coins' = newCoins, + entries' = newEntries, + purses' = purses, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EResynced), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 8.y.1 Recover. Walks the deterministic namespace for each purse and + // rebuilds local `coins` / `entries` from chain storage using the + // gap-limit scan (Appendix C). Operation completes synchronously to + // SDone. For purses not in the local `purses` map, a placeholder record + // is created so the rebuilt coins/entries can be attached. + action recover(toRecover: Set[PurseId]): bool = { + val h = nextHandle + val withMain = toRecover.union(Set(MAIN_PURSE)) + val newPurses = withMain.fold(purses, (m, pid) => { + if (m.keys().contains(pid)) m + else m.put(pid, { id: pid, name: "recovered", nextCoinIdx: 0, nextEntryIdx: 0 }) + }) + val recoveredCoins = withMain.fold(coins, (m, pid) => + gapLimitScanCoins(chainCoins, pid).keys().fold(m, (mm, k) => + if (mm.keys().contains(k)) mm + else mm.put(k, gapLimitScanCoins(chainCoins, pid).get(k)))) + val recoveredEntries = withMain.fold(entries, (m, pid) => + gapLimitScanEntries(chainEntries, pid).keys().fold(m, (mm, k) => + if (mm.keys().contains(k)) mm + else mm.put(k, gapLimitScanEntries(chainEntries, pid).get(k)))) + val recoveredPurses = withMain.fold(newPurses, (m, pid) => + m.put(pid, m.get(pid) + .with("nextCoinIdx", + recoveredCoins.keys().filter(k => k._1 == pid) + .fold(m.get(pid).nextCoinIdx, (a, k) => if (k._2 + 1 > a) k._2 + 1 else a)) + .with("nextEntryIdx", + recoveredEntries.keys().filter(k => k._1 == pid) + .fold(m.get(pid).nextEntryIdx, (a, k) => if (k._2 + 1 > a) k._2 + 1 else a)))) + val op: OperationRec = { + handle: h, kind: KRecover, purse: MAIN_PURSE, + status: SDone, + lockedCoins: Set(), + lockedEntries: Set(), + } + all { + withMain.size() > 0, + operations' = operations.put(h, op), + purses' = recoveredPurses, + coins' = recoveredCoins, + entries' = recoveredEntries, + nextHandle' = h + 1, + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XSucceeded(Set()), + }), + events' = events.append(EOperationStarted({ handle: h, kind: KRecover, purse: MAIN_PURSE })) + .append(EOperationCompleted({ handle: h, status: SDone })), + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 8.y.2 Extend scan. Bumps the purse's `nextCoinIdx` / `nextEntryIdx` + // forward to `fromCoin` / `fromEntry`. This reflects the design: the + // caller is asking the layer to look further out in the deterministic + // namespace. The operation completes synchronously. + action extendScan(p: PurseId, fromCoin: CoinIndex, fromEntry: EntryIndex): bool = { + val pExists = purses.keys().contains(p) + val purse = if (pExists) purses.get(p) + else { id: -1, name: "", nextCoinIdx: 0, nextEntryIdx: 0 } + val newCoinIdx = if (fromCoin > purse.nextCoinIdx) fromCoin else purse.nextCoinIdx + val newEntryIdx = if (fromEntry > purse.nextEntryIdx) fromEntry else purse.nextEntryIdx + val h = nextHandle + val op: OperationRec = { + handle: h, kind: KRecover, purse: p, + status: SDone, + lockedCoins: Set(), + lockedEntries: Set(), + } + all { + pExists, + fromCoin >= 0, + fromEntry >= 0, + operations' = operations.put(h, op), + purses' = purses.put(p, purse.with("nextCoinIdx", newCoinIdx) + .with("nextEntryIdx", newEntryIdx)), + nextHandle' = h + 1, + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XSucceeded(Set()), + }), + events' = events.append(EOperationStarted({ handle: h, kind: KRecover, purse: p })) + .append(EOperationCompleted({ handle: h, status: SDone })), + coins' = coins, + entries' = entries, + rings' = rings, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // ========================================================================== + // 9. Chain-side events + // ========================================================================== + + /// Number of entries on a given ring (sum across all purses). + def ringSize(ridx: RingIndex): int = + entries.keys().fold(0, (a, k) => + if (entries.get(k).ringIdx == ridx) a + 1 else a) + + /// Anonymity deficit (positive ⇒ ring is below floor). + def ringDeficit(ridx: RingIndex): int = AnonymityFloor - ringSize(ridx) + + // 9.1 Ring members reach readiness, but only if the ring has met the + // anonymity floor by sealing time. Rings under the floor go to + // OnDegraded via `chainPromoteToDegraded` instead. + action chainPromoteToReady(k: (PurseId, EntryIndex)): bool = { + val eExists = entries.keys().contains(k) + val e = if (eExists) entries.get(k) + else { purse: -1, idx: -1, memberKey: -1, exponent: 0, allocatedAt: 0, + readyAt: 0, ringIdx: -1, onChain: OnMissing, local: LocalAvailable } + val ringExists = rings.keys().contains(e.ringIdx) + val ring = if (ringExists) rings.get(e.ringIdx) + else { idx: -1, immutableSince: -1, expired: false } + all { + eExists, + e.onChain == OnWaiting, + e.ringIdx >= 0, + ringExists, + ring.immutableSince >= 0, + ringSize(e.ringIdx) >= AnonymityFloor, + entries' = entries.put(k, e.with("onChain", OnReady)), + purses' = purses, + coins' = coins, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EEntryReadinessChanged({ purse: e.purse, exponent: e.exponent, newState: OnReady })), + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalOut' = totalOut, + } + } + + /// Ring sealed *under* the anonymity floor: entry promoted to + /// OnDegraded carrying the deficit. Caller can opt in to degraded + /// usage explicitly (see `entrySelectable(_, allowDegraded=true)`). + action chainPromoteToDegraded(k: (PurseId, EntryIndex)): bool = { + val eExists = entries.keys().contains(k) + val e = if (eExists) entries.get(k) + else { purse: -1, idx: -1, memberKey: -1, exponent: 0, allocatedAt: 0, + readyAt: 0, ringIdx: -1, onChain: OnMissing, local: LocalAvailable } + val ringExists = rings.keys().contains(e.ringIdx) + val ring = if (ringExists) rings.get(e.ringIdx) + else { idx: -1, immutableSince: -1, expired: false } + val deficit = ringDeficit(e.ringIdx) + all { + eExists, + e.onChain == OnWaiting, + e.ringIdx >= 0, + ringExists, + ring.immutableSince >= 0, + deficit > 0, + entries' = entries.put(k, e.with("onChain", OnDegraded(deficit))), + purses' = purses, + coins' = coins, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events.append(EEntryReadinessChanged({ + purse: e.purse, exponent: e.exponent, newState: OnDegraded(deficit) + })), + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 9.2 Ring sealed: immutable_since set. + action chainSealRing(ridx: RingIndex): bool = { + val rExists = rings.keys().contains(ridx) + val r = if (rExists) rings.get(ridx) + else { idx: -1, immutableSince: -1, expired: false } + all { + rExists, + r.immutableSince == -1, + rings' = rings.put(ridx, r.with("immutableSince", now)), + nextRingIdx' = if (ridx == nextRingIdx) nextRingIdx + 1 else nextRingIdx, + purses' = purses, + coins' = coins, + entries' = entries, + operations' = operations, + nextHandle' = nextHandle, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + // 9.3 Ring cleanup. In reality the chain fires this unconditionally at + // `immutable_since + RecyclerExpiration`, destroying any backing value of + // entries that the host failed to unload. To encode the *design contract* + // (rescue must happen before destruction), this action is additionally + // gated on "no Available entry remains on this ring". The scheduler is + // thereby forced to fire `ringExpirationRescue` first; if the host falls + // behind, the spec is unsatisfiable — which corresponds to a real-world + // bug, not to a valid execution. + def ringEntriesAllConsumed(ridx: RingIndex): bool = + entries.keys().forall(k => { + val e = entries.get(k) + e.ringIdx != ridx or e.local == LocalConsumed + }) + + action chainExpireRing(ridx: RingIndex): bool = { + val rExists = rings.keys().contains(ridx) + val r = if (rExists) rings.get(ridx) + else { idx: -1, immutableSince: -1, expired: false } + all { + rExists, + r.immutableSince >= 0, + now >= r.immutableSince + RecyclerExpiration, + not(r.expired), + // Design contract: every entry on the ring must already be terminal + // (Consumed) before the chain destroys it. The host is expected to + // rescue Available entries via the rescue sweep, and to complete or + // cancel in-flight operations holding LockedFor entries. The + // unconstrained chain action (no precondition) would model the + // iOS silent-loss bug; constraining it expresses the design contract. + ringEntriesAllConsumed(ridx), + rings' = rings.put(ridx, r.with("expired", true)), + purses' = purses, + coins' = coins, + entries' = entries, + operations' = operations, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + totalOut' = totalOut, + } + } + + /// Adversary action: a coin whose secret has been exposed externally + /// (via an in-flight or completed Export operation) is snipped by a + /// third party who races the layer to spend it. Models the + /// `FRSnipedCoin` failure mode (§10). Fires only on Available coins + /// that have been involved in a KExport op. + def coinHasExportHistory(k: (PurseId, CoinIndex)): bool = + operations.keys().exists(h => { + val op = operations.get(h) + op.kind == KExport + and (op.lockedCoins.contains(k) or op.status == SDone) + }) + + action chainSnipeCoin(k: (PurseId, CoinIndex), h: OpHandle): bool = { + val cExists = coins.keys().contains(k) + val c = if (cExists) coins.get(k) + else { purse: -1, idx: -1, account: -1, exponent: 0, age: 0, state: CoinPending } + val opExists = operations.keys().contains(h) + val op = if (opExists) operations.get(h) + else { handle: -1, kind: KRecover, purse: -1, status: SFailed(FRInternal), + lockedCoins: Set(), lockedEntries: Set() } + val isExportOp = op.kind == KExport and op.lockedCoins.contains(k) + val mid = op.status == SPreparing or op.status == SSubmitted or op.status == SInBlock + all { + cExists, + opExists, + isExportOp, + mid, + // Coin transitions to Spent (it's gone from our view) and the op + // fails with FRSnipedCoin. Locks release. + coins' = coins.put(k, c.with("state", CoinSpent)), + operations' = operations.put(h, op.with("status", SFailed(FRSnipedCoin)) + .with("lockedCoins", Set()) + .with("lockedEntries", Set())), + totalOut' = totalOut + coinValue(c.exponent), + nextExtrinsicId' = nextExtrinsicId + 1, + receipts' = appendReceipt(receipts, h, { + extrinsicId: nextExtrinsicId, + outcome: XRejected(FRSnipedCoin), + }), + events' = events.append(ECoinSpent({ purse: c.purse, exponent: c.exponent })) + .append(EOperationCompleted({ handle: h, status: SFailed(FRSnipedCoin) })), + purses' = purses, + entries' = entries, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalIn' = totalIn, + } + } + + // 9.4 Time advances. + action tick: bool = all { + now < 100, + purses' = purses, + coins' = coins, + entries' = entries, + operations' = operations, + rings' = rings, + nextHandle' = nextHandle, + nextRingIdx' = nextRingIdx, + nextAccount' = nextAccount, + nextMemberKey' = nextMemberKey, + now' = now + 1, + nextExtrinsicId' = nextExtrinsicId, + receipts' = receipts, + events' = events, + totalIn' = totalIn, + tokens' = tokens, + paidRingMembership' = paidRingMembership, + feeAccountBalance' = feeAccountBalance, + opRequested' = opRequested, + opExternalized' = opExternalized, + chainCoins' = chainCoins, + chainEntries' = chainEntries, + totalOut' = totalOut, + } + + // ========================================================================== + // 10. Step relation + // ========================================================================== + + action step: bool = any { + nondet np = oneOf(Set(1, 2).exclude(purses.keys())) + nondet nm = oneOf(Set("a", "b")) + createPurse(np, nm), + + nondet p = oneOf(purses.keys()) + nondet nm = oneOf(Set("renamed", "x")) + renamePurse(p, nm), + + nondet p = oneOf(purses.keys()) + deletePurse(p), + + nondet pf = oneOf(purses.keys()) + nondet pt = oneOf(purses.keys()) + nondet k = oneOf(coins.keys()) + rebalancePurse(pf, pt, k), + + nondet p = oneOf(purses.keys()) + nondet e = oneOf(Set(0, 1, 2)) + topUp(p, e), + + nondet k = oneOf(coins.keys()) + transfer(k), + + nondet p = oneOf(purses.keys()) + nondet sel = oneOf(availableCoinsIn(p).powerset()) + transferAmount(p, sel, true), + + nondet p = oneOf(purses.keys()) + nondet sel = oneOf(availableCoinsIn(p).powerset()) + startTransfer(p, sel, true), + + nondet p = oneOf(purses.keys()) + nondet k = oneOf(coins.keys()) + startTransferDeterministic(p, k), + + nondet p = oneOf(purses.keys()) + nondet a = oneOf(Set(1, 2, 3, 4, 5)) + startTransferDeterministicMulti(p, a), + + nondet h = oneOf(operations.keys()) + opCommitTransfer(h), + + nondet p = oneOf(purses.keys()) + nondet e = oneOf(Set(0, 1, 2)) + startTopUp(p, e), + + nondet h = oneOf(operations.keys()) + nondet e = oneOf(Set(0, 1, 2)) + opCommitTopUp(h, e), + + nondet k = oneOf(coins.keys()) + startExport(k), + + nondet h = oneOf(operations.keys()) + opCommitExport(h), + + nondet p = oneOf(purses.keys()) + nondet e = oneOf(Set(0, 1, 2)) + startImport(p, e), + + nondet h = oneOf(operations.keys()) + nondet e = oneOf(Set(0, 1, 2)) + opCommitImport(h, e), + + nondet pf = oneOf(purses.keys()) + nondet pt = oneOf(purses.keys()) + nondet k = oneOf(coins.keys()) + startRebalance(pf, pt, k), + + nondet h = oneOf(operations.keys()) + nondet pt = oneOf(purses.keys()) + opCommitRebalance(h, pt), + + nondet k = oneOf(coins.keys()) + exportCoin(k), + + nondet p = oneOf(purses.keys()) + nondet e = oneOf(Set(0, 1, 2)) + importCoin(p, e), + + nondet k = oneOf(entries.keys()) + nondet req = oneOf(Set(1, 2, 3, 4)) + startExternalOffload(k, req), + + nondet ents = oneOf(entries.keys().powerset()) + nondet req = oneOf(Set(0, 1, 2, 3, 4)) + startExternalOffloadMulti(ents, req), + + nondet h = oneOf(operations.keys()) + nondet gExp = oneOf(Set(0, 1, 2)) + nondet gRing = oneOf(rings.keys().union(Set(0))) + nondet sExp = oneOf(Set(0, 1, 2)) + nondet ext = oneOf(Set(0, 1, 2, 3, 4)) + opOffboardGroup(h, gExp, gRing, sExp, ext), + + nondet h = oneOf(operations.keys()) + opCompleteExternalOffload(h), + + nondet h = oneOf(operations.keys()) + cancelOp(h), + + nondet h = oneOf(operations.keys()) + nondet se = oneOf(Set(0, 1, 2)) + opOffboard(h, se), + + nondet h = oneOf(operations.keys()) + opAdvanceToSubmitted(h), + + nondet h = oneOf(operations.keys()) + opAdvanceToInBlock(h), + + nondet h = oneOf(operations.keys()) + opAdvanceToFinalized(h), + + nondet h = oneOf(operations.keys()) + opChainReject(h), + + nondet k = oneOf(coins.keys()) + nondet h = oneOf(operations.keys()) + chainSnipeCoin(k, h), + + nondet h = oneOf(operations.keys()) + opEnterWait(h), + + nondet h = oneOf(operations.keys()) + opWake(h), + + nondet k = oneOf(coins.keys()) + coinAgeRecycle(k), + + nondet k = oneOf(entries.keys()) + ringExpirationRescue(k), + + nondet k = oneOf(entries.keys()) + chainPromoteToReady(k), + + nondet k = oneOf(entries.keys()) + chainPromoteToDegraded(k), + + nondet r = oneOf(rings.keys()) + chainSealRing(r), + + nondet r = oneOf(rings.keys()) + chainExpireRing(r), + + runMaintenanceSweep, + restart, + joinPaidRing, + + nondet rs = oneOf(Set(1, 2).powerset()) + recover(rs), + + // `restartAndRecover` is defined but excluded from the default step: + // it interacts with totalOut accounting in ways that require a more + // refined chain-state model than we have (chain stores creation-state + // records only; recovery would reincarnate locally-spent records and + // break conservation). Test it via dedicated scenarios: + // + // quint run --invariant=conservation \ + // --init=initAndCreate --step=restartAndRecoverStep + // + // (see test scaffolding below if added). + + nondet p = oneOf(purses.keys()) + nondet fc = oneOf(Set(0, 1, 2, 3)) + nondet fe = oneOf(Set(0, 1, 2, 3)) + extendScan(p, fc, fe), + + nondet a = oneOf(Set(1, 2)) + topUpFeeAccount(a), + + tick, + } + + // ========================================================================== + // 11. Invariants + // ========================================================================== + + val coinIndexBounded: bool = + coins.keys().forall(k => + purses.keys().contains(k._1) and k._2 < purses.get(k._1).nextCoinIdx) + + val entryIndexBounded: bool = + entries.keys().forall(k => + purses.keys().contains(k._1) and k._2 < purses.get(k._1).nextEntryIdx) + + val lockConsistency: bool = and { + coins.keys().forall(k => { + val c = coins.get(k) + not(isCoinLocked(c)) or + (operations.keys().contains(coinLockHandle(c)) + and operations.get(coinLockHandle(c)).lockedCoins.contains(k)) + }), + entries.keys().forall(k => { + val e = entries.get(k) + not(isEntryLocked(e)) or + (operations.keys().contains(entryLockHandle(e)) + and operations.get(entryLockHandle(e)).lockedEntries.contains(k)) + }), + operations.keys().forall(h => { + val op = operations.get(h) + and { + op.lockedCoins.forall(k => + coins.keys().contains(k) + and isCoinLocked(coins.get(k)) + and coinLockHandle(coins.get(k)) == h), + op.lockedEntries.forall(k => + entries.keys().contains(k) + and isEntryLocked(entries.get(k)) + and entryLockHandle(entries.get(k)) == h), + } + }), + } + + val coinAgeBound: bool = + coins.keys().forall(k => { + val c = coins.get(k) + c.state != CoinAvailable or c.age < MaxAge + }) + + val conservation: bool = + totalIn - totalOut == liveValue + + def isFailed(op: OperationRec): bool = match op.status { + | SFailed(_) => true + | _ => false + } + + def isTerminal(op: OperationRec): bool = + op.status == SDone or isFailed(op) + + val terminalReleasesLocks: bool = + operations.keys().forall(h => { + val op = operations.get(h) + not(isTerminal(op)) or (op.lockedCoins.size() == 0 and op.lockedEntries.size() == 0) + }) + + // The central design property: if a ring has expired, no entry on it is + // still Available. The rescue sweep is what enforces this. + val noEntryOnExpiredRing: bool = + entries.keys().forall(k => { + val e = entries.get(k) + not(rings.keys().contains(e.ringIdx)) + or not(rings.get(e.ringIdx).expired) + or e.local == LocalConsumed + }) + + val mainPurseExists: bool = purses.keys().contains(MAIN_PURSE) + + val operationsPurseExists: bool = + operations.keys().forall(h => { + val op = operations.get(h) + isTerminal(op) or purses.keys().contains(op.purse) + }) + + // Every terminal operation has at least one receipt entry. + val terminalOpsHaveReceipts: bool = + operations.keys().forall(h => { + val op = operations.get(h) + not(isTerminal(op)) + or (receipts.keys().contains(h) and receipts.get(h).length() > 0) + }) + + // SDone implies at least one Succeeded receipt; SFailed implies at least + // one Rejected receipt (per design §9 / §5.5). + def isSucceeded(r: ExtrinsicRecord): bool = match r.outcome { + | XSucceeded(_) => true + | _ => false + } + def isRejected(r: ExtrinsicRecord): bool = match r.outcome { + | XRejected(_) => true + | _ => false + } + val receiptOutcomeMatchesStatus: bool = + operations.keys().forall(h => { + val op = operations.get(h) + val rs = if (receipts.keys().contains(h)) receipts.get(h) else List() + and { + op.status != SDone or rs.foldl(false, (a, r) => a or isSucceeded(r)), + not(isFailed(op)) or rs.foldl(false, (a, r) => a or isRejected(r)), + } + }) + + /// True iff `events` contains an `EOperationCompleted` event for handle `h`. + def hasCompletedEvent(h: OpHandle): bool = + events.foldl(false, (a, ev) => a or (match ev { + | EOperationCompleted(payload) => payload.handle == h + | _ => false + })) + + // Every terminal operation has an EOperationCompleted event. + val terminalOpsHaveCompletedEvent: bool = + operations.keys().forall(h => { + val op = operations.get(h) + not(isTerminal(op)) or hasCompletedEvent(h) + }) + + // Live coin/entry records always reference an existing purse. + val liveRecordsRefExistingPurse: bool = and { + coins.keys().forall(k => { + val c = coins.get(k) + c.state == CoinSpent or purses.keys().contains(c.purse) + }), + entries.keys().forall(k => { + val e = entries.get(k) + e.local == LocalConsumed or purses.keys().contains(e.purse) + }), + } + + val externalOffloadLocksEntriesOnly: bool = + operations.keys().forall(h => { + val op = operations.get(h) + op.kind != KExternalOffload or op.lockedCoins.size() == 0 + }) + + // ========================================================================== + // Temporal / liveness properties + // ========================================================================== + // + // These require fairness assumptions on the autonomous and chain-side + // actions: under fair scheduling, every eligible action eventually fires. + // They are NOT part of `safety`; check them with + // quint verify --temporal= docs/specs/coinage-layer.qnt + // or run the simulator with --temporal flag if supported. + + /// Every in-flight operation eventually reaches a terminal status. + temporal everyOpEventuallyTerminates = + operations.keys().forall(h => + eventually(not(operations.keys().contains(h)) or isTerminal(operations.get(h)))) + + /// Every Available coin whose age is at the recycle threshold is + /// eventually consumed (either recycled or otherwise spent). + temporal everyAgingCoinEventuallyConsumed = + coins.keys().forall(k => { + val c = coins.get(k) + c.state != CoinAvailable or c.age < RecycleAtAge + or eventually(coins.get(k).state == CoinSpent) + }) + + /// Every entry on a near-expired ring is eventually consumed (rescued + /// or offboarded) before the chain destroys the ring. + temporal everyNearExpiryEntryRescued = + entries.keys().forall(k => { + val e = entries.get(k) + val ringExists = rings.keys().contains(e.ringIdx) + val nearExpiry = ringExists + and rings.get(e.ringIdx).immutableSince >= 0 + and now + RescueMargin >= rings.get(e.ringIdx).immutableSince + RecyclerExpiration + not(nearExpiry) or e.local != LocalAvailable + or eventually(entries.get(k).local == LocalConsumed) + }) + + /// Standard weak-fairness assumption: every always-eligible step fires + /// infinitely often. (The simulator picks transitions nondeterministically; + /// Apalache requires the fairness assumption to be stated explicitly when + /// checking liveness.) + + val safety: bool = and { + coinIndexBounded, + entryIndexBounded, + lockConsistency, + coinAgeBound, + conservation, + terminalReleasesLocks, + noEntryOnExpiredRing, + mainPurseExists, + operationsPurseExists, + externalOffloadLocksEntriesOnly, + liveRecordsRefExistingPurse, + terminalOpsHaveReceipts, + receiptOutcomeMatchesStatus, + terminalOpsHaveCompletedEvent, + feeBalanceNonNegative, + tokenRecordsConsistent, + midSubmissionHoldsLocks, + derivationDeterministic, + derivationInjective, + handleMonotone, + ringIntegrity, + consumedFreeTokensInRange, + eventOrderOpStartBeforeComplete, + noCoinResurrection, + noEntryResurrection, + // anonymityFloorEnforced was a *current-state* invariant; deletePurse + // shrinks rings post-sealing, so it's a per-transition property of + // chainPromoteToReady (enforced by its `ringSize >= AnonymityFloor` + // precondition), not a state invariant. Kept as a refinement target. + chainMirrorsLocal, + } + + /// Distinct coin records map to distinct accounts; distinct entry + /// records map to distinct member keys. Follows from `derivationDeterministic` + /// + the no-reuse purse-namespace invariant, but worth stating directly. + val derivationInjective: bool = and { + coins.keys().forall(k1 => + coins.keys().forall(k2 => + k1 == k2 or coins.get(k1).account != coins.get(k2).account)), + entries.keys().forall(k1 => + entries.keys().forall(k2 => + k1 == k2 or entries.get(k1).memberKey != entries.get(k2).memberKey)), + } + + /// nextHandle is strictly greater than every issued handle. + val handleMonotone: bool = + operations.keys().forall(h => h < nextHandle) + + /// Ring record keys agree with their stored idx. + val ringIntegrity: bool = + rings.keys().forall(r => rings.get(r).idx == r) + + /// Every consumed free token's counter is within the allowed search range. + val consumedFreeTokensInRange: bool = + tokens.keys().forall(k => { + val rec = tokens.get(k) + rec.class != UTFree or rec.counter < FreeTokenSearchRange + }) + + /// Within the event log, every `EOperationCompleted(h, ...)` is preceded + /// by an `EOperationStarted({ handle: h, ... })` somewhere earlier. + /// + /// Encoded with `foldl` over the event list rather than symbolic index + /// ranges so the invariant stays inside the fragment Apalache can + /// unfold. The accumulator carries the set of handles already started + /// and whether any completion has fired without a prior start. + type EventScanAcc = { seenStarts: Set[OpHandle], violated: bool } + val eventOrderOpStartBeforeComplete: bool = { + val initAcc: EventScanAcc = { seenStarts: Set(), violated: false } + val final = events.foldl(initAcc, (acc, ev) => match ev { + | EOperationStarted(p) => + { seenStarts: acc.seenStarts.union(Set(p.handle)), violated: acc.violated } + | EOperationCompleted(p) => + { seenStarts: acc.seenStarts, + violated: acc.violated or not(acc.seenStarts.contains(p.handle)) } + | _ => acc + }) + not(final.violated) + } + + /// Terminal-coin records remain terminal: there is no action that + /// flips a `Spent` coin back to `Available` (rebalance/recover only + /// create new coin records at fresh indices). + /// + /// We express this as a static well-formedness rule: any `Spent` coin + /// remains keyed at its original `(purse, idx)`. The "no resurrection" + /// behavior follows from the absence of any action writing `Spent → + /// Available`. Invariant-wise, we check that no Spent coin has age + /// reset below its previously recorded value — but without history, + /// we instead check that Spent coins are never re-inserted at a + /// previously-issued (purse, idx) by a fresh derivation. This is + /// equivalent to the no-reuse `coinIndexBounded` invariant already + /// enforced: idx < nextCoinIdx. + val noCoinResurrection: bool = + coins.keys().forall(k => { + val c = coins.get(k) + c.purse == k._1 and c.idx == k._2 + }) + + val noEntryResurrection: bool = + entries.keys().forall(k => { + val e = entries.get(k) + e.purse == k._1 and e.idx == k._2 + }) + + // -------------------------------------------------------------------------- + // Refinement-only invariants + // -------------------------------------------------------------------------- + // + // These are NOT part of `safety`: the abstract spec contains + // nondeterministic actions (like `startTransfer` taking any covering + // subset) that broaden the behavioral envelope. They are *refinement + // targets* — properties an implementation must hold when only the + // deterministic actions (`startTransferDeterministic`, ...) fire. + + /// Single-coin transfers are deterministic: among Available coins in + /// the purse with the same value as the locked coin, the locked coin + /// has highest priority. + val singleCoinTransferDeterministic: bool = + operations.keys().forall(h => { + val op = operations.get(h) + val mid = op.status == SPreparing or op.status == SSubmitted + or op.status == SInBlock or op.status == SFinalized + not(op.kind == KTransfer and mid and op.lockedCoins.size() == 1) + or op.lockedCoins.forall(k => { + val c = coins.get(k) + val amount = coinValue(c.exponent) + availableCoinsIn(op.purse).forall(kk => + coinValue(coins.get(kk).exponent) != amount + or coinOrderLT(c, coins.get(kk))) + }) + }) + + /// Chain mirrors local: every local coin/entry is in chain storage. + /// This is what makes recovery possible — the chain is the source of + /// truth, and `recover` rebuilds local from it. + val chainMirrorsLocal: bool = and { + coins.keys().forall(k => chainCoins.keys().contains(k)), + entries.keys().forall(k => chainEntries.keys().contains(k)), + } + + /// Anonymity floor enforced: an entry can be OnReady only if its ring + /// has at least `AnonymityFloor` members. Sub-floor rings appear as + /// OnDegraded(deficit) only. + val anonymityFloorEnforced: bool = + entries.keys().forall(k => { + val e = entries.get(k) + e.onChain != OnReady or ringSize(e.ringIdx) >= AnonymityFloor + }) + + /// Derivation determinism (Appendix B): every coin's account matches + /// its (purse, idx) derivation; every entry's member key likewise. + val derivationDeterministic: bool = and { + coins.keys().forall(k => { + val c = coins.get(k) + c.account == deriveCoinAccount(c.purse, c.idx) + }), + entries.keys().forall(k => { + val e = entries.get(k) + e.memberKey == deriveMemberKey(e.purse, e.idx) + }), + } + + /// Phase order: SSubmitted/SInBlock/SFinalized implies the op holds at + /// least one lock — except for allocate-only kinds (KTopUp, KImport, + /// KRecover) which produce records without consuming any. + def isAllocateOnlyKind(k: OpKind): bool = + k == KTopUp or k == KImport or k == KRecover + val midSubmissionHoldsLocks: bool = + operations.keys().forall(h => { + val op = operations.get(h) + val mid = op.status == SSubmitted or op.status == SInBlock or op.status == SFinalized + not(mid) or isAllocateOnlyKind(op.kind) + or (op.lockedCoins.size() + op.lockedEntries.size() > 0) + }) + + /// Fee account balance never goes negative. + val feeBalanceNonNegative: bool = feeAccountBalance >= 0 + + /// Every stored token record's keys match its tagged contents. + val tokenRecordsConsistent: bool = + tokens.keys().forall(k => { + val rec = tokens.get(k) + and { + rec.period == k._1, + rec.class == k._2, + rec.counter == k._3, + } + }) +} diff --git a/rust/crates/coinage-layer/Cargo.toml b/rust/crates/coinage-layer/Cargo.toml new file mode 100644 index 00000000..03265c4f --- /dev/null +++ b/rust/crates/coinage-layer/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "coinage-layer" +version = "0.0.0" +edition.workspace = true +publish = false + +[dependencies] +vstd = "=0.0.0-2026-05-17-0151" + +[package.metadata.verus] +verify = true + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(verus_only)'] } diff --git a/rust/crates/coinage-layer/src/lib.rs b/rust/crates/coinage-layer/src/lib.rs new file mode 100644 index 00000000..956ceabf --- /dev/null +++ b/rust/crates/coinage-layer/src/lib.rs @@ -0,0 +1,82 @@ +//! Verus translation of the Coinage Layer Quint specification. +//! +//! Source-of-truth references: +//! - Quint spec : `docs/specs/coinage-layer.qnt` +//! - Design doc : `docs/design/coinage-layer.md` +//! +//! **Scope.** Verified protocol kernel covering the four core state +//! components — purses, coins, recycler entries, operations — with +//! their lifecycle transitions, the §6.3 priority order, chain-mirror +//! recovery state, the events stream, the fee account, unload tokens, +//! and the totals/accumulators. Chain interaction is abstracted: +//! chain-side state changes arrive via caller-driven primitives +//! (`set_entry_on_chain`, `mark_op_finalized`, …) rather than being +//! modeled directly. No persistence, no crypto; `member_key` / +//! `account` / chain timestamps are `u64` placeholders supplied by +//! the host. +//! +//! **Module map.** +//! +//! ```text +//! types.rs — public types, constants, tag enums, the State struct +//! spec_helpers.rs — top-level spec functions (lock predicates, priority, +//! sums, payment classify, coin_value/pow2_nat) +//! pow2.rs — pow2 lemmas + executable `pow2_u64_exec` +//! +//! state_invariant.rs — view accessors, `invariant()`, `init()` +//! state_purses.rs — purse lifecycle (create/rename/delete/purge) +//! state_coins.rs — coin lifecycle (add, mark, lock/unlock/commit) +//! state_entries.rs — entry lifecycle (add, set, mark, lock/release) +//! state_operations.rs — op status transitions + bulk release helpers +//! state_composites.rs — atomic op composites (start/cancel/commit pairs) +//! state_high_level.rs — transfer, rebalance, export/import, split, +//! unload, top-up, reserve +//! state_tracked.rs — `tracked_*` wrappers (op-handle-bearing variants) +//! state_chain.rs — chain-mirror state + recovery scans +//! state_selectors.rs — `find_*`, subset-sum covers, classify-payment exec +//! state_aggregators.rs — count / sum / total / lock-count helpers +//! state_queries.rs — read-only queries + has-/check- helpers +//! state_fee.rs — fee account top-up / deduct / select-mode +//! state_tokens.rs — unload-token mint / consume / count +//! state_events.rs — `emit_event`, `event_count` +//! state_accumulators.rs — total_in/out, paid_ring_membership, extrinsic-id +//! +//! refinement.rs — Quint→Verus refinement scaffolding (per-method +//! `quint_step_*` spec fns + `lemma_*_refines` proofs) +//! ``` +//! +//! **Encoding.** Exec storage is `Vec<…Rec>` per component. Contracts +//! quantify over ghost spec maps (`Ghost>`). The +//! invariant ties them: every Vec entry is in the ghost map under +//! its key; every ghost-map key has a matching Vec entry; no +//! duplicates. State-mutating methods explicitly preserve untouched +//! components (`final.next_handle == old.next_handle`, …) in their +//! contracts — Verus's `&mut self` SMT encoding doesn't carry these +//! over for free. + +pub mod types; +pub mod spec_helpers; +pub mod pow2; + +pub mod state_invariant; +pub mod state_purses; +pub mod state_coins; +pub mod state_entries; +pub mod state_operations; +pub mod state_composites; +pub mod state_high_level; +pub mod state_tracked; +pub mod state_chain; +pub mod state_selectors; +pub mod state_aggregators; +pub mod state_queries; +pub mod state_fee; +pub mod state_tokens; +pub mod state_events; +pub mod state_accumulators; + +pub mod refinement; + +pub use types::*; +pub use spec_helpers::*; +pub use pow2::*; diff --git a/rust/crates/coinage-layer/src/pow2.rs b/rust/crates/coinage-layer/src/pow2.rs new file mode 100644 index 00000000..f4cb8b3d --- /dev/null +++ b/rust/crates/coinage-layer/src/pow2.rs @@ -0,0 +1,95 @@ +//! Proof lemmas about `2^exp` (`pow2_nat`) and the executable +//! `pow2_u64_exec` / `coin_value_pow2_exec` helpers. +//! +//! The lemmas establish monotonicity (`e1 <= e2 ==> pow2_nat(e1) <= +//! pow2_nat(e2)`) and the saturating bound (`pow2_nat(30) == 2^30 == +//! 1073741824`). The exec helpers compute `2^exp` under the +//! `exp <= MAX_EXPONENT` precondition, with `res <= 1073741824u64` +//! as a postcondition for downstream overflow reasoning. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +/// Spec-only lemma: `pow2_nat` is monotone (non-decreasing). Proved by +/// straightforward induction on the exponent. +pub proof fn lemma_pow2_monotone(e1: nat, e2: nat) + requires + e1 <= e2, + ensures + pow2_nat(e1) <= pow2_nat(e2), + decreases e2, +{ + if e2 == 0 { + // e1 == 0 too; trivially equal. + } else if e1 == e2 { + // trivial + } else { + lemma_pow2_monotone(e1, (e2 - 1) as nat); + } +} + +/// Spec-only lemma: `pow2_nat(30) == 2^30 = 1073741824`. Unrolled +/// once-per-step (Verus's default fuel is 1, so a single recursive +/// step). Used to derive the u64-overflow-safety bound for +/// `pow2_u64_exec`. +pub proof fn lemma_pow2_at_30() + ensures + pow2_nat(30) == 1073741824nat, +{ + reveal_with_fuel(pow2_nat, 31); +} + +/// Executable real coin value (Quint `coinValue`): `2^exp` for +/// `exp <= MAX_EXPONENT`. Thin convenience wrapper over +/// [`pow2_u64_exec`] that matches the `coin_value_pow2` spec fn. +pub fn coin_value_pow2_exec(exp: u8) -> (res: u64) + requires + exp <= MAX_EXPONENT, + ensures + res as nat == coin_value_pow2(exp), +{ + pow2_u64_exec(exp) +} + +/// Executable `2^exp` for `exp <= MAX_EXPONENT` (= 30). Returns the +/// real Quint `coinValue` for that exponent. Verus-verified +/// overflow-safe: `MAX_EXPONENT = 30 ⇒ 2^30 < u64::MAX`. +/// +/// This is the foundational primitive for switching the pilot's +/// `coin_value(exp) = exp + 1` scheme over to real `2^exp` arithmetic +/// (task #84). Existing aggregations still use the pilot scheme — this +/// just gives callers (and a future rewrite) the safe building block. +pub fn pow2_u64_exec(exp: u8) -> (res: u64) + requires + exp <= MAX_EXPONENT, + ensures + res as nat == pow2_nat(exp as nat), + res <= 1073741824u64, +{ + let mut result: u64 = 1; + let mut k: u8 = 0; + while k < exp + invariant + k <= exp, + exp <= MAX_EXPONENT, + result as nat == pow2_nat(k as nat), + result <= 1073741824u64, + decreases exp - k, + { + proof { + // Bound `result * 2` by 2^30 = 1073741824 to keep within u64. + // After this iteration, k+1 <= exp <= 30, so + // pow2(k+1) <= pow2(30) = 2^30. + lemma_pow2_at_30(); + lemma_pow2_monotone((k as nat) + 1, MAX_EXPONENT as nat); + } + result = result * 2; + k = k + 1; + } + result +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/refinement.rs b/rust/crates/coinage-layer/src/refinement.rs new file mode 100644 index 00000000..3bd38821 --- /dev/null +++ b/rust/crates/coinage-layer/src/refinement.rs @@ -0,0 +1,5883 @@ +//! Quint → Verus refinement scaffolding. +//! +//! Establishes a machine-checked correspondence between the Verus +//! implementation and the Quint specification at +//! `docs/specs/coinage-layer.qnt`. Every public state-mutating Verus +//! method has a corresponding `quint_step_*` spec function that +//! describes its effect on the Quint shadow state, plus a +//! `lemma_*_refines` proof obligation that the Verus contract +//! implies the spec function's output. +//! +//! See the trailing `Findings from the refinement attempt` block +//! for primitives whose contracts were strengthened during the +//! refinement push, plus a per-pattern catalog (single-step, +//! multi-step composite, branch split for Result/Option, existential +//! witness for non-deterministic selection, multi-key removal via +//! `Map::remove_keys`, bulk-loop via `Map::new`). + +use vstd::prelude::*; + +use crate::*; + +verus! { + +// ========================================================================== +// Quint → Verus refinement scaffolding (PoC, task #94) +// +// Establishes a machine-checked correspondence between the Verus +// implementation and the Quint specification at `docs/specs/coinage-layer.qnt`. +// This is a proof-of-concept: it covers a 4-field shadow of the Quint state +// and refines two primitives (`mark_coin_observed`, `chain_register_coin`). +// Full refinement of all ~30 mutators is a multi-week effort; the goal here +// is to demonstrate the methodology is tractable in Verus and to surface +// any structural friction. +// ========================================================================== + +/// Spec-only shadow of the Quint state machine's variables — covers +/// all 13 vars that the Verus pilot models. Quint vars not in scope of +/// the pilot (below the chain-abstraction boundary or derived) remain +/// excluded: `rings`, `now`, `receipts`, `opRequested`, `opExternalized`, +/// `nextRingIdx`, `nextAccount`, `nextMemberKey`. +/// +/// Verus-only state (`next_purse_id`, `next_age`) is similarly excluded +/// — these are local allocators the Quint spec doesn't use (Quint +/// addresses purses and coins directly by id). +pub ghost struct QuintViewState { + pub purses: Map, + pub coins: Map<(PurseId, u64), CoinRec>, + pub entries: Map<(PurseId, u64), EntryRec>, + pub operations: Map, + pub events: Seq, + pub next_handle: u64, + pub next_extrinsic_id: u64, + pub total_in: u64, + pub total_out: u64, + pub fee_balance: u64, + pub paid_ring_membership: u64, + pub tokens: Seq, + pub chain_coins: Seq, + pub chain_entries: Seq, +} + +/// Refinement map: extract the Quint-shaped view from a Verus `State`. +/// The body is a direct projection — each Quint var maps to its Verus +/// counterpart. The view is well-defined for any `State`, regardless +/// of whether the invariant holds. +pub open spec fn quint_view(s: State) -> QuintViewState { + QuintViewState { + purses: s.purses(), + coins: s.coins(), + entries: s.entries(), + operations: s.operations(), + events: s.events@, + next_handle: s.next_handle, + next_extrinsic_id: s.next_extrinsic_id, + total_in: s.total_in, + total_out: s.total_out, + fee_balance: s.fee_balance, + paid_ring_membership: s.paid_ring_membership, + tokens: s.tokens@, + chain_coins: s.chain_coins@, + chain_entries: s.chain_entries@, + } +} + +/// Spec encoding of the Quint `init` action (restricted to the +/// `QuintViewState` shadow). This is what Quint says the initial state +/// looks like. +/// +/// **Known divergences from the literal Quint** (not in the shadow, +/// so they don't surface here, but documented for completeness): +/// - Quint `purses[MAIN].name == "main"` (4 bytes); Verus `init` +/// produces an empty `Vec` for the name. This PoC encodes the +/// empty-name convention as the pilot's interpretation of Quint +/// init — a real refinement would either match Quint exactly or +/// document the placeholder explicitly. +/// - Quint `nextHandle == 1`; Verus `init` sets `next_handle = 0`. +/// - Quint `nextExtrinsicId == 1`; Verus `init` sets `next_extrinsic_id = 0`. +/// - Quint `feeAccountBalance == 100`; Verus `init` sets `fee_balance = 0`. +pub open spec fn quint_init_view() -> QuintViewState { + QuintViewState { + purses: Map::::empty().insert(MAIN_PURSE, PurseRecSpec { + id: MAIN_PURSE, + name: Seq::empty(), + next_coin_idx: 0, + next_entry_idx: 0, + }), + coins: Map::<(PurseId, u64), CoinRec>::empty(), + entries: Map::<(PurseId, u64), EntryRec>::empty(), + operations: Map::::empty(), + events: Seq::empty(), + next_handle: 0, + next_extrinsic_id: 0, + total_in: 0, + total_out: 0, + fee_balance: 0, + paid_ring_membership: 0, + tokens: Seq::empty(), + chain_coins: Seq::empty(), + chain_entries: Seq::empty(), + } +} + +/// **Refinement lemma (init)**: any state matching `State::init()`'s +/// postconditions has Quint view equal to `quint_init_view()`. This +/// proves the entry-point correspondence at the level of the PoC +/// shadow. +/// +/// Parameterized over the post-init state rather than invoking +/// `State::init()` directly (which is exec), so the lemma works +/// against the contract surface. +proof fn lemma_init_refines(s: State) + requires + // Verus `init()`'s postconditions (witnessed by `s`): + s.purses().dom() =~= set![MAIN_PURSE], + s.purses()[MAIN_PURSE] == (PurseRecSpec { + id: MAIN_PURSE, + name: Seq::::empty(), + next_coin_idx: 0, + next_entry_idx: 0, + }), + s.coins().dom() =~= Set::<(PurseId, u64)>::empty(), + s.entries().dom() =~= Set::<(PurseId, u64)>::empty(), + s.operations().dom() =~= Set::::empty(), + s.events@ =~= Seq::::empty(), + s.next_handle == 0, + s.next_extrinsic_id == 0, + s.total_in == 0, + s.total_out == 0, + s.fee_balance == 0, + s.paid_ring_membership == 0, + s.tokens@ =~= Seq::::empty(), + s.chain_coins@ =~= Seq::::empty(), + s.chain_entries@ =~= Seq::::empty(), + ensures + quint_view(s) == quint_init_view(), +{ + // Discharged by extensional equality across all shadow fields. + assert(quint_view(s).purses =~= quint_init_view().purses); + assert(quint_view(s).coins =~= quint_init_view().coins); + assert(quint_view(s).entries =~= quint_init_view().entries); + assert(quint_view(s).operations =~= quint_init_view().operations); + assert(quint_view(s).events =~= quint_init_view().events); + assert(quint_view(s).tokens =~= quint_init_view().tokens); + assert(quint_view(s).chain_coins =~= quint_init_view().chain_coins); + assert(quint_view(s).chain_entries =~= quint_init_view().chain_entries); +} + +/// Spec encoding of Quint's effect on `QuintViewState` when +/// `mark_coin_observed` fires. Quint analog: a transition where +/// `coins' = coins.set(key, {...with state = Available...})` and +/// `events' = events.append(ECoinAvailable{purse, exp})`. +pub open spec fn quint_step_mark_coin_observed( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Pending, +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Available, + }), + events: pre.events.push(Event::CoinAvailable { + purse: key.0, + exponent: pre.coins[key].exponent, + }), + ..pre + } +} + +/// **Refinement lemma (mark_coin_observed step)**: for any state +/// satisfying `mark_coin_observed`'s preconditions, the Verus +/// transition is equivalent (under `quint_view`) to the Quint +/// transition. +/// +/// This is a *theorem about contracts*, not a runtime function. It +/// says: any `(pre, post)` pair satisfying the contract of +/// `mark_coin_observed` also satisfies the Quint step's effect when +/// projected via `quint_view`. +proof fn lemma_mark_coin_observed_refines(pre: State, post: State, key: (PurseId, u64)) + requires + // The Verus contract of mark_coin_observed (preconditions): + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Pending, + pre.events@.len() < u64::MAX as nat, + // ...and its postconditions, witnessed by (pre, post): + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Available, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@.push(Event::CoinAvailable { + purse: key.0, + exponent: pre.coins()[key].exponent, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_coin_observed(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_coin_observed(quint_view(pre), key); + assert(post_view.purses =~= step_view.purses); + assert(post_view.coins =~= step_view.coins); + assert(post_view.entries =~= step_view.entries); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); + assert(post_view.tokens =~= step_view.tokens); + assert(post_view.chain_coins =~= step_view.chain_coins); + assert(post_view.chain_entries =~= step_view.chain_entries); +} + +/// Spec encoding of Quint's effect on `QuintViewState` when +/// `chain_register_coin` fires. The chain emits a new coin record into +/// the chain mirror; local state is untouched. +pub open spec fn quint_step_chain_register_coin( + pre: QuintViewState, + c: CoinRec, +) -> QuintViewState { + QuintViewState { + chain_coins: pre.chain_coins.push(c), + ..pre + } +} + +/// **Refinement lemma (chain_register_coin step)**: the chain-emit +/// transition appends to `chain_coins` and leaves everything else +/// untouched. +proof fn lemma_chain_register_coin_refines(pre: State, post: State, c: CoinRec) + requires + pre.invariant(), + pre.chain_coins@.len() < u64::MAX as nat, + c.exponent <= MAX_EXPONENT, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@.push(c), + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_chain_register_coin(quint_view(pre), c), +{ + let post_view = quint_view(post); + let step_view = quint_step_chain_register_coin(quint_view(pre), c); + assert(post_view.chain_coins =~= step_view.chain_coins); +} + +/// Quint analog: `coins' = coins.set(key, {..with state = PendingSpend..})`. +pub open spec fn quint_step_mark_coin_pending_spend( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Available, +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::PendingSpend, + }), + ..pre + } +} + +proof fn lemma_mark_coin_pending_spend_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Available, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::PendingSpend, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_coin_pending_spend(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_coin_pending_spend(quint_view(pre), key); + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `coins' = coins.set(key, {..with state = Available..})`. +pub open spec fn quint_step_reverse_pending_spend( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::PendingSpend, +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Available, + }), + ..pre + } +} + +proof fn lemma_reverse_pending_spend_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::PendingSpend, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Available, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_reverse_pending_spend(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_reverse_pending_spend(quint_view(pre), key); + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `coins' = coins.set(key, {..with state = Spent..})`, +/// `events' = events.append(ECoinSpent{purse, exp})`. +pub open spec fn quint_step_mark_coin_spent( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::PendingSpend, +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Spent, + }), + events: pre.events.push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins[key].exponent, + }), + ..pre + } +} + +proof fn lemma_mark_coin_spent_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::PendingSpend, + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Spent, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@.push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins()[key].exponent, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_coin_spent(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_coin_spent(quint_view(pre), key); + assert(post_view.coins =~= step_view.coins); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `entries' = entries.set(key, {..on_chain = Ready..})`, +/// `events' = events.append(EEntryReadinessChanged{purse, exp, new_state})`. +pub open spec fn quint_step_mark_entry_ready( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.entries.dom().contains(key), + pre.entries[key].on_chain == EntryOnChain::Waiting, +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + on_chain: EntryOnChain::Ready, + ..pre.entries[key] + }), + events: pre.events.push(Event::EntryReadinessChanged { + purse: key.0, + exponent: pre.entries[key].exponent, + new_state: EntryOnChain::Ready, + }), + ..pre + } +} + +proof fn lemma_mark_entry_ready_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.entries().dom().contains(key), + pre.entries()[key].on_chain == EntryOnChain::Waiting, + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + on_chain: EntryOnChain::Ready, + ..pre.entries()[key] + }), + post.operations() == pre.operations(), + post.events@ == pre.events@.push(Event::EntryReadinessChanged { + purse: key.0, + exponent: pre.entries()[key].exponent, + new_state: EntryOnChain::Ready, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_entry_ready(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_entry_ready(quint_view(pre), key); + assert(post_view.entries =~= step_view.entries); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `chain_entries' = chain_entries.append(e)`. +pub open spec fn quint_step_chain_register_entry( + pre: QuintViewState, + e: EntryRec, +) -> QuintViewState { + QuintViewState { + chain_entries: pre.chain_entries.push(e), + ..pre + } +} + +proof fn lemma_chain_register_entry_refines(pre: State, post: State, e: EntryRec) + requires + pre.invariant(), + pre.chain_entries@.len() < u64::MAX as nat, + e.exponent <= MAX_EXPONENT, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@.push(e), + ensures + quint_view(post) == quint_step_chain_register_entry(quint_view(pre), e), +{ + let post_view = quint_view(post); + let step_view = quint_step_chain_register_entry(quint_view(pre), e); + assert(post_view.chain_entries =~= step_view.chain_entries); +} + +/// Quint analog: `events' = events.append(e)`. +pub open spec fn quint_step_emit_event( + pre: QuintViewState, + e: Event, +) -> QuintViewState { + QuintViewState { + events: pre.events.push(e), + ..pre + } +} + +proof fn lemma_emit_event_refines(pre: State, post: State, e: Event) + requires + pre.invariant(), + pre.events@.len() < u64::MAX as nat, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@.push(e), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_emit_event(quint_view(pre), e), +{ + let post_view = quint_view(post); + let step_view = quint_step_emit_event(quint_view(pre), e); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `entries' = entries.set(key, {..local = LocalConsumed..})`, +/// `events' = events.append(EEntryConsumed{purse, exp})`. +pub open spec fn quint_step_consume_entry( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends pre.entries.dom().contains(key), +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..pre.entries[key] + }), + events: pre.events.push(Event::EntryConsumed { + purse: key.0, + exponent: pre.entries[key].exponent, + }), + ..pre + } +} + +proof fn lemma_consume_entry_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.entries().dom().contains(key), + exists|h: OpHandle| pre.entries()[key].local == EntryLocal::LocalLockedFor(h), + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..pre.entries()[key] + }), + post.operations() == pre.operations(), + post.events@ == pre.events@.push(Event::EntryConsumed { + purse: key.0, + exponent: pre.entries()[key].exponent, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_consume_entry(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_consume_entry(quint_view(pre), key); + assert(post_view.entries =~= step_view.entries); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `operations' = operations.set(handle, {..status = Submitted..})`, +/// `events' = events.append(EOperationProgress{handle, status=Submitted})`. +pub open spec fn quint_step_mark_op_submitted( + pre: QuintViewState, + handle: OpHandle, +) -> QuintViewState + recommends pre.operations.dom().contains(handle), +{ + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + status: OpStatus::Submitted, + ..pre.operations[handle] + }), + events: pre.events.push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + ..pre + } +} + +proof fn lemma_mark_op_submitted_refines(pre: State, post: State, handle: OpHandle) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + pre.operations()[handle].status == OpStatus::Preparing, + pre.events@.len() < u64::MAX as nat, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::Submitted, + }), + post.events@ == pre.events@.push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_op_submitted(quint_view(pre), handle), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_op_submitted(quint_view(pre), handle); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `operations' = operations.set(handle, {..status = Done..})`, +/// `events' = events.append(EOperationCompleted{handle, status=Done})`. +pub open spec fn quint_step_mark_op_done( + pre: QuintViewState, + handle: OpHandle, +) -> QuintViewState + recommends pre.operations.dom().contains(handle), +{ + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + status: OpStatus::Done, + ..pre.operations[handle] + }), + events: pre.events.push(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }), + ..pre + } +} + +proof fn lemma_mark_op_done_refines(pre: State, post: State, handle: OpHandle) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + pre.events@.len() < u64::MAX as nat, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::Done, + }), + post.events@ == pre.events@.push(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_op_done(quint_view(pre), handle), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_op_done(quint_view(pre), handle); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `operations' = operations.set(handle, {..status = Failed..})`, +/// `events' = events.append(EOperationCompleted{handle, status=Failed})`. +pub open spec fn quint_step_set_op_failed( + pre: QuintViewState, + handle: OpHandle, +) -> QuintViewState + recommends pre.operations.dom().contains(handle), +{ + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + status: OpStatus::Failed, + ..pre.operations[handle] + }), + events: pre.events.push(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }), + ..pre + } +} + +proof fn lemma_set_op_failed_refines(pre: State, post: State, handle: OpHandle) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + pre.events@.len() < u64::MAX as nat, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::Failed, + }), + post.events@ == pre.events@.push(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_set_op_failed(quint_view(pre), handle), +{ + let post_view = quint_view(post); + let step_view = quint_step_set_op_failed(quint_view(pre), handle); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `totalIn' = totalIn + amount`. +pub open spec fn quint_step_add_total_in( + pre: QuintViewState, + amount: u64, +) -> QuintViewState + recommends pre.total_in + amount <= u64::MAX, +{ + QuintViewState { + total_in: (pre.total_in + amount) as u64, + ..pre + } +} + +proof fn lemma_add_total_in_refines(pre: State, post: State, amount: u64) + requires + pre.invariant(), + pre.total_in <= u64::MAX - amount, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in + amount, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_add_total_in(quint_view(pre), amount), +{ + // total_in is the only field that changes; others preserved. +} + +/// Quint analog: `totalOut' = totalOut + amount`. +pub open spec fn quint_step_add_total_out( + pre: QuintViewState, + amount: u64, +) -> QuintViewState + recommends pre.total_out + amount <= u64::MAX, +{ + QuintViewState { + total_out: (pre.total_out + amount) as u64, + ..pre + } +} + +proof fn lemma_add_total_out_refines(pre: State, post: State, amount: u64) + requires + pre.invariant(), + pre.total_out <= u64::MAX - amount, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out + amount, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_add_total_out(quint_view(pre), amount), +{ +} + +/// Quint analog: `operations' = operations.put(handle, {handle, kind, +/// purse, status: Preparing})`, `nextHandle' = nextHandle + 1`, +/// `events' = events.append(EOperationStarted{handle, kind, purse})`. +/// Allocator-bumping primitive — three fields change. +pub open spec fn quint_step_start_op( + pre: QuintViewState, + kind: OpKind, + purse: PurseId, +) -> QuintViewState + recommends pre.next_handle < u64::MAX, +{ + let handle = pre.next_handle; + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind, + purse, + status: OpStatus::Preparing, + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events.push(Event::OperationStarted { handle, kind, purse }), + ..pre + } +} + +proof fn lemma_start_op_refines( + pre: State, + post: State, + kind: OpKind, + purse: PurseId, + handle: OpHandle, +) + requires + pre.invariant(), + pre.purses().dom().contains(purse), + pre.next_handle < u64::MAX, + pre.events@.len() < u64::MAX as nat, + handle == pre.next_handle, + post.operations() == pre.operations().insert(handle, OperationRec { + handle, + kind, + purse, + status: OpStatus::Preparing, + }), + post.next_handle == pre.next_handle + 1, + post.events@ == pre.events@.push(Event::OperationStarted { handle, kind, purse }), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_start_op(quint_view(pre), kind, purse), +{ + let post_view = quint_view(post); + let step_view = quint_step_start_op(quint_view(pre), kind, purse); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `coins' = coins.set(key, {..state = LockedFor(handle)..})`. +pub open spec fn quint_step_lock_coin( + pre: QuintViewState, + key: (PurseId, u64), + handle: OpHandle, +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Available, +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::LockedFor(handle), + }), + ..pre + } +} + +proof fn lemma_lock_coin_refines( + pre: State, + post: State, + key: (PurseId, u64), + handle: OpHandle, +) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Available, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::LockedFor(handle), + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_lock_coin(quint_view(pre), key, handle), +{ + let post_view = quint_view(post); + let step_view = quint_step_lock_coin(quint_view(pre), key, handle); + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `coins' = coins.set(key, {..state = Available..})`, +/// applied to a LockedFor(handle) coin. +pub open spec fn quint_step_release_locked_coin( + pre: QuintViewState, + key: (PurseId, u64), + handle: OpHandle, +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::LockedFor(handle), +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Available, + }), + ..pre + } +} + +proof fn lemma_release_locked_coin_refines( + pre: State, + post: State, + key: (PurseId, u64), + handle: OpHandle, +) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::LockedFor(handle), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Available, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_release_locked_coin(quint_view(pre), key, handle), +{ + let post_view = quint_view(post); + let step_view = quint_step_release_locked_coin(quint_view(pre), key, handle); + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `entries' = entries.set(key, {..local = LocalLockedFor(handle)..})`. +pub open spec fn quint_step_lock_entry( + pre: QuintViewState, + key: (PurseId, u64), + handle: OpHandle, +) -> QuintViewState + recommends + pre.entries.dom().contains(key), + pre.entries[key].local == EntryLocal::LocalAvailable, +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + local: EntryLocal::LocalLockedFor(handle), + ..pre.entries[key] + }), + ..pre + } +} + +proof fn lemma_lock_entry_refines( + pre: State, + post: State, + key: (PurseId, u64), + handle: OpHandle, +) + requires + pre.invariant(), + pre.entries().dom().contains(key), + pre.entries()[key].local == EntryLocal::LocalAvailable, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalLockedFor(handle), + ..pre.entries()[key] + }), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_lock_entry(quint_view(pre), key, handle), +{ + let post_view = quint_view(post); + let step_view = quint_step_lock_entry(quint_view(pre), key, handle); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: `entries' = entries.set(key, {..local = LocalAvailable..})`, +/// applied to a LocalLockedFor(handle) entry. +pub open spec fn quint_step_release_locked_entry( + pre: QuintViewState, + key: (PurseId, u64), + handle: OpHandle, +) -> QuintViewState + recommends + pre.entries.dom().contains(key), + pre.entries[key].local == EntryLocal::LocalLockedFor(handle), +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + local: EntryLocal::LocalAvailable, + ..pre.entries[key] + }), + ..pre + } +} + +proof fn lemma_release_locked_entry_refines( + pre: State, + post: State, + key: (PurseId, u64), + handle: OpHandle, +) + requires + pre.invariant(), + pre.entries().dom().contains(key), + pre.entries()[key].local == EntryLocal::LocalLockedFor(handle), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalAvailable, + ..pre.entries()[key] + }), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_release_locked_entry(quint_view(pre), key, handle), +{ + let post_view = quint_view(post); + let step_view = quint_step_release_locked_entry(quint_view(pre), key, handle); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: `entries' = entries.set(key, {..on_chain = new_state..})`. +pub open spec fn quint_step_set_entry_on_chain( + pre: QuintViewState, + key: (PurseId, u64), + new_state: EntryOnChain, +) -> QuintViewState + recommends + pre.entries.dom().contains(key), +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + on_chain: new_state, + ..pre.entries[key] + }), + ..pre + } +} + +proof fn lemma_set_entry_on_chain_refines( + pre: State, + post: State, + key: (PurseId, u64), + new_state: EntryOnChain, +) + requires + pre.invariant(), + pre.entries().dom().contains(key), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + on_chain: new_state, + ..pre.entries()[key] + }), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_set_entry_on_chain(quint_view(pre), key, new_state), +{ + let post_view = quint_view(post); + let step_view = quint_step_set_entry_on_chain(quint_view(pre), key, new_state); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: `entries' = entries.set(key, {..local = new_state..})`. +pub open spec fn quint_step_set_entry_local( + pre: QuintViewState, + key: (PurseId, u64), + new_state: EntryLocal, +) -> QuintViewState + recommends + pre.entries.dom().contains(key), +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + local: new_state, + ..pre.entries[key] + }), + ..pre + } +} + +proof fn lemma_set_entry_local_refines( + pre: State, + post: State, + key: (PurseId, u64), + new_state: EntryLocal, +) + requires + pre.invariant(), + pre.entries().dom().contains(key), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + local: new_state, + ..pre.entries()[key] + }), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_set_entry_local(quint_view(pre), key, new_state), +{ + let post_view = quint_view(post); + let step_view = quint_step_set_entry_local(quint_view(pre), key, new_state); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: `operations' = operations.set(handle, {..status = new_status..})`. +pub open spec fn quint_step_set_op_status( + pre: QuintViewState, + handle: OpHandle, + new_status: OpStatus, +) -> QuintViewState + recommends + pre.operations.dom().contains(handle), +{ + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle: pre.operations[handle].handle, + kind: pre.operations[handle].kind, + purse: pre.operations[handle].purse, + status: new_status, + }), + ..pre + } +} + +proof fn lemma_set_op_status_refines( + pre: State, + post: State, + handle: OpHandle, + new_status: OpStatus, +) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: new_status, + }), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_set_op_status(quint_view(pre), handle, new_status), +{ + let post_view = quint_view(post); + let step_view = quint_step_set_op_status(quint_view(pre), handle, new_status); + assert(post_view.operations =~= step_view.operations); +} + +/// Quint analog: `operations' = operations.set(handle, {..status = InBlock..})`. +pub open spec fn quint_step_mark_op_in_block( + pre: QuintViewState, + handle: OpHandle, +) -> QuintViewState + recommends + pre.operations.dom().contains(handle), + pre.operations[handle].status == OpStatus::Submitted, +{ + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle: pre.operations[handle].handle, + kind: pre.operations[handle].kind, + purse: pre.operations[handle].purse, + status: OpStatus::InBlock, + }), + ..pre + } +} + +proof fn lemma_mark_op_in_block_refines(pre: State, post: State, handle: OpHandle) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + pre.operations()[handle].status == OpStatus::Submitted, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::InBlock, + }), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_op_in_block(quint_view(pre), handle), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_op_in_block(quint_view(pre), handle); + assert(post_view.operations =~= step_view.operations); +} + +/// Quint analog: `operations' = operations.set(handle, {..status = Finalized..})`. +pub open spec fn quint_step_mark_op_finalized( + pre: QuintViewState, + handle: OpHandle, +) -> QuintViewState + recommends + pre.operations.dom().contains(handle), + pre.operations[handle].status == OpStatus::InBlock, +{ + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle: pre.operations[handle].handle, + kind: pre.operations[handle].kind, + purse: pre.operations[handle].purse, + status: OpStatus::Finalized, + }), + ..pre + } +} + +proof fn lemma_mark_op_finalized_refines(pre: State, post: State, handle: OpHandle) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + pre.operations()[handle].status == OpStatus::InBlock, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::Finalized, + }), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_op_finalized(quint_view(pre), handle), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_op_finalized(quint_view(pre), handle); + assert(post_view.operations =~= step_view.operations); +} + +/// Quint analog: `entries' = entries.set(key, {..on_chain = Missing..})`. +pub open spec fn quint_step_mark_entry_missing( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.entries.dom().contains(key), +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + on_chain: EntryOnChain::Missing, + ..pre.entries[key] + }), + ..pre + } +} + +proof fn lemma_mark_entry_missing_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.entries().dom().contains(key), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + on_chain: EntryOnChain::Missing, + ..pre.entries()[key] + }), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_entry_missing(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_entry_missing(quint_view(pre), key); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: `nextExtrinsicId' = nextExtrinsicId + 1`. The Quint +/// allocator returns the pre-increment value (matching Verus exec). +pub open spec fn quint_step_alloc_extrinsic_id( + pre: QuintViewState, +) -> QuintViewState + recommends + pre.next_extrinsic_id < u64::MAX, +{ + QuintViewState { + next_extrinsic_id: (pre.next_extrinsic_id + 1) as u64, + ..pre + } +} + +proof fn lemma_alloc_extrinsic_id_refines(pre: State, post: State) + requires + pre.invariant(), + pre.next_extrinsic_id < u64::MAX, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id + 1, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_alloc_extrinsic_id(quint_view(pre)), +{ +} + +/// Quint analog: `coins' = coins.set(rec.purse, rec.idx) -> rec`. Inverse of +/// the chain-mirror loss path: a coin previously observed lives in +/// `chain_coins` and is being re-injected into the canonical `coins` map. +pub open spec fn quint_step_restore_chain_coin( + pre: QuintViewState, + rec: CoinRec, +) -> QuintViewState + recommends + !pre.coins.dom().contains((rec.purse, rec.idx)), +{ + QuintViewState { + coins: pre.coins.insert((rec.purse, rec.idx), rec), + ..pre + } +} + +proof fn lemma_restore_chain_coin_refines(pre: State, post: State, rec: CoinRec) + requires + pre.invariant(), + !pre.coins().dom().contains((rec.purse, rec.idx)), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert((rec.purse, rec.idx), rec), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_restore_chain_coin(quint_view(pre), rec), +{ + let post_view = quint_view(post); + let step_view = quint_step_restore_chain_coin(quint_view(pre), rec); + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `entries' = entries.set(rec.purse, rec.idx) -> rec`. +/// Mirror of `restore_chain_coin` for entries. +pub open spec fn quint_step_restore_chain_entry( + pre: QuintViewState, + rec: EntryRec, +) -> QuintViewState + recommends + !pre.entries.dom().contains((rec.purse, rec.idx)), +{ + QuintViewState { + entries: pre.entries.insert((rec.purse, rec.idx), rec), + ..pre + } +} + +proof fn lemma_restore_chain_entry_refines(pre: State, post: State, rec: EntryRec) + requires + pre.invariant(), + !pre.entries().dom().contains((rec.purse, rec.idx)), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert((rec.purse, rec.idx), rec), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_restore_chain_entry(quint_view(pre), rec), +{ + let post_view = quint_view(post); + let step_view = quint_step_restore_chain_entry(quint_view(pre), rec); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: insert a fresh coin and bump the owning purse's +/// `next_coin_idx`. Quint does NOT model `next_age` (it's a Verus-only +/// allocator); only `purses` and `coins` are in the shadow. +pub open spec fn quint_step_add_coin_with_account( + pre: QuintViewState, + p: PurseId, + exponent: u8, + account: u64, + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, +{ + let key = (p, new_idx); + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: p, + idx: new_idx, + exponent, + state: CoinState::Pending, + age: next_age, + account, + }), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx + 1, + next_entry_idx: pre.purses[p].next_entry_idx, + }), + ..pre + } +} + +proof fn lemma_add_coin_with_account_refines( + pre: State, + post: State, + p: PurseId, + exponent: u8, + account: u64, + new_idx: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_age < u64::MAX, + exponent <= MAX_EXPONENT, + post.invariant(), + post.coins() == pre.coins().insert( + (p, new_idx), + CoinRec { + purse: p, + idx: new_idx, + exponent, + state: CoinState::Pending, + age: pre.next_age, + account, + }, + ), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx + 1, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_add_coin_with_account( + quint_view(pre), p, exponent, account, pre.next_age, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_add_coin_with_account( + quint_view(pre), p, exponent, account, pre.next_age, new_idx, + ); + assert(post_view.coins =~= step_view.coins); + assert(post_view.purses =~= step_view.purses); +} + +/// Quint analog: insert a fresh entry and bump the owning purse's +/// `next_entry_idx`. +pub open spec fn quint_step_add_entry_with_meta( + pre: QuintViewState, + p: PurseId, + exponent: u8, + on_chain: EntryOnChain, + local: EntryLocal, + member_key: u64, + allocated_at: u64, + ready_at: u64, + ring_idx: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_entry_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, +{ + let key = (p, new_idx); + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + purse: p, + idx: new_idx, + exponent, + on_chain, + local, + member_key, + allocated_at, + ready_at, + ring_idx, + }), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx, + next_entry_idx: pre.purses[p].next_entry_idx + 1, + }), + ..pre + } +} + +proof fn lemma_add_entry_with_meta_refines( + pre: State, + post: State, + p: PurseId, + exponent: u8, + on_chain: EntryOnChain, + local: EntryLocal, + member_key: u64, + allocated_at: u64, + ready_at: u64, + ring_idx: u64, + new_idx: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_entry_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + exponent <= MAX_EXPONENT, + post.invariant(), + post.entries() == pre.entries().insert( + (p, new_idx), + EntryRec { + purse: p, + idx: new_idx, + exponent, + on_chain, + local, + member_key, + allocated_at, + ready_at, + ring_idx, + }, + ), + post.coins() == pre.coins(), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx + 1, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_add_entry_with_meta( + quint_view(pre), p, exponent, on_chain, local, + member_key, allocated_at, ready_at, ring_idx, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_add_entry_with_meta( + quint_view(pre), p, exponent, on_chain, local, + member_key, allocated_at, ready_at, ring_idx, new_idx, + ); + assert(post_view.entries =~= step_view.entries); + assert(post_view.purses =~= step_view.purses); +} + +/// Quint analog: `feeBalance' = feeBalance + amount`. +pub open spec fn quint_step_top_up_fee_account( + pre: QuintViewState, + amount: u64, +) -> QuintViewState + recommends + pre.fee_balance <= u64::MAX - amount, +{ + QuintViewState { + fee_balance: (pre.fee_balance + amount) as u64, + ..pre + } +} + +proof fn lemma_top_up_fee_account_refines(pre: State, post: State, amount: u64) + requires + pre.invariant(), + pre.fee_balance <= u64::MAX - amount, + post.invariant(), + post.fee_balance == pre.fee_balance + amount, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_top_up_fee_account(quint_view(pre), amount), +{ +} + +/// Quint analog: `feeBalance' = feeBalance - amount` (only fires on +/// the successful branch; the InsufficientFunds branch leaves +/// `feeBalance` unchanged, refined separately as the no-op step). +pub open spec fn quint_step_deduct_fee_success( + pre: QuintViewState, + amount: u64, +) -> QuintViewState + recommends + pre.fee_balance >= amount, +{ + QuintViewState { + fee_balance: (pre.fee_balance - amount) as u64, + ..pre + } +} + +proof fn lemma_deduct_fee_success_refines(pre: State, post: State, amount: u64) + requires + pre.invariant(), + pre.fee_balance >= amount, + post.invariant(), + post.fee_balance == pre.fee_balance - amount, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_deduct_fee_success(quint_view(pre), amount), +{ +} + +/// Quint analog: `feeBalance' = feeBalance` (the InsufficientFunds +/// branch of `deduct_fee` is a state-preserving no-op). +proof fn lemma_deduct_fee_fail_refines(pre: State, post: State) + requires + pre.invariant(), + post.invariant(), + post.fee_balance == pre.fee_balance, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_view(pre), +{ +} + +/// Quint analog: `tokens' = tokens.append(UnloadToken{..})`. +pub open spec fn quint_step_mint_token( + pre: QuintViewState, + period: u64, + class: UnloadTokenClass, + counter: u64, +) -> QuintViewState { + QuintViewState { + tokens: pre.tokens.push(UnloadToken { + period, class, counter, consumed: false, + }), + ..pre + } +} + +proof fn lemma_mint_token_refines( + pre: State, + post: State, + period: u64, + class: UnloadTokenClass, + counter: u64, +) + requires + pre.invariant(), + pre.tokens@.len() < u64::MAX as nat, + post.invariant(), + post.tokens@ == pre.tokens@.push(UnloadToken { + period, class, counter, consumed: false, + }), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mint_token(quint_view(pre), period, class, counter), +{ + let post_view = quint_view(post); + let step_view = quint_step_mint_token(quint_view(pre), period, class, counter); + assert(post_view.tokens =~= step_view.tokens); +} + +/// Quint analog: flip `tokens[idx].consumed = true` (only the success +/// branch; the failure branches are state-preserving no-ops). +pub open spec fn quint_step_consume_token_success( + pre: QuintViewState, + idx: usize, +) -> QuintViewState + recommends + idx < pre.tokens.len(), + !pre.tokens[idx as int].consumed, +{ + QuintViewState { + tokens: pre.tokens.update(idx as int, UnloadToken { + period: pre.tokens[idx as int].period, + class: pre.tokens[idx as int].class, + counter: pre.tokens[idx as int].counter, + consumed: true, + }), + ..pre + } +} + +proof fn lemma_consume_token_success_refines(pre: State, post: State, idx: usize) + requires + pre.invariant(), + idx < pre.tokens@.len(), + !pre.tokens@[idx as int].consumed, + post.invariant(), + post.tokens@.len() == pre.tokens@.len(), + post.tokens@[idx as int].consumed, + post.tokens@[idx as int].period == pre.tokens@[idx as int].period, + post.tokens@[idx as int].class == pre.tokens@[idx as int].class, + post.tokens@[idx as int].counter == pre.tokens@[idx as int].counter, + forall|i: int| 0 <= i < pre.tokens@.len() && i != idx as int + ==> #[trigger] post.tokens@[i] == pre.tokens@[i], + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_consume_token_success(quint_view(pre), idx), +{ + let post_view = quint_view(post); + let step_view = quint_step_consume_token_success(quint_view(pre), idx); + assert(post_view.tokens =~= step_view.tokens); +} + +/// Quint analog: `tokens' = tokens` (the failure branches of +/// `consume_token` are state-preserving no-ops). +proof fn lemma_consume_token_fail_refines(pre: State, post: State) + requires + pre.invariant(), + post.invariant(), + post.tokens@ == pre.tokens@, + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_view(pre), +{ +} + +/// Quint analog: insert a fresh Waiting/LocalAvailable entry, bump +/// the owning purse's `next_entry_idx`, and push `EEntryAllocated`. +pub open spec fn quint_step_top_up_via_entry( + pre: QuintViewState, + p: PurseId, + exponent: u8, + member_key: u64, + allocated_at: u64, + ready_at: u64, + ring_idx: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_entry_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, +{ + let key = (p, new_idx); + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + purse: p, + idx: new_idx, + exponent, + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key, + allocated_at, + ready_at, + ring_idx, + }), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx, + next_entry_idx: pre.purses[p].next_entry_idx + 1, + }), + events: pre.events.push(Event::EntryAllocated { purse: p, exponent }), + ..pre + } +} + +proof fn lemma_top_up_via_entry_refines( + pre: State, + post: State, + p: PurseId, + exponent: u8, + member_key: u64, + allocated_at: u64, + ready_at: u64, + ring_idx: u64, + new_idx: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_entry_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.events@.len() < u64::MAX as nat, + exponent <= MAX_EXPONENT, + post.invariant(), + post.entries() == pre.entries().insert( + (p, new_idx), + EntryRec { + purse: p, + idx: new_idx, + exponent, + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key, + allocated_at, + ready_at, + ring_idx, + }, + ), + post.coins() == pre.coins(), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx + 1, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.operations() == pre.operations(), + post.events@ == pre.events@.push(Event::EntryAllocated { purse: p, exponent }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_top_up_via_entry( + quint_view(pre), p, exponent, + member_key, allocated_at, ready_at, ring_idx, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_top_up_via_entry( + quint_view(pre), p, exponent, + member_key, allocated_at, ready_at, ring_idx, new_idx, + ); + assert(post_view.entries =~= step_view.entries); + assert(post_view.purses =~= step_view.purses); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `coins' = coins.set(key, {..state = Available..})`, +/// applied to any LockedFor(_) coin (no handle constraint — the +/// pre-state existentially binds the handle). +pub open spec fn quint_step_unlock_coin( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + exists|h: OpHandle| pre.coins[key].state == CoinState::LockedFor(h), +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Available, + }), + ..pre + } +} + +proof fn lemma_unlock_coin_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.coins().dom().contains(key), + exists|h: OpHandle| pre.coins()[key].state == CoinState::LockedFor(h), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Available, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_unlock_coin(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_unlock_coin(quint_view(pre), key); + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `coins' = coins.set(key, {..state = PendingSpend..})`, +/// applied to any LockedFor(_) coin. +pub open spec fn quint_step_commit_locked_coin( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + exists|h: OpHandle| pre.coins[key].state == CoinState::LockedFor(h), +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::PendingSpend, + }), + ..pre + } +} + +proof fn lemma_commit_locked_coin_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.coins().dom().contains(key), + exists|h: OpHandle| pre.coins()[key].state == CoinState::LockedFor(h), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::PendingSpend, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_commit_locked_coin(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_commit_locked_coin(quint_view(pre), key); + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `operations' = operations.set(handle, {..status = Waiting(ready_at)..})`. +pub open spec fn quint_step_mark_op_waiting( + pre: QuintViewState, + handle: OpHandle, + ready_at: u64, +) -> QuintViewState + recommends + pre.operations.dom().contains(handle), + pre.operations[handle].status == OpStatus::Finalized, +{ + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle: pre.operations[handle].handle, + kind: pre.operations[handle].kind, + purse: pre.operations[handle].purse, + status: OpStatus::Waiting(ready_at), + }), + ..pre + } +} + +proof fn lemma_mark_op_waiting_refines( + pre: State, + post: State, + handle: OpHandle, + ready_at: u64, +) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + pre.operations()[handle].status == OpStatus::Finalized, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::Waiting(ready_at), + }), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_mark_op_waiting(quint_view(pre), handle, ready_at), +{ + let post_view = quint_view(post); + let step_view = quint_step_mark_op_waiting(quint_view(pre), handle, ready_at); + assert(post_view.operations =~= step_view.operations); +} + +/// Quint analog: `entries' = entries.set(key, {..local = LocalAvailable..})`, +/// applied to any LocalLockedFor(_) entry. +pub open spec fn quint_step_release_entry_lock( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.entries.dom().contains(key), + exists|h: OpHandle| pre.entries[key].local == EntryLocal::LocalLockedFor(h), +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + local: EntryLocal::LocalAvailable, + ..pre.entries[key] + }), + ..pre + } +} + +proof fn lemma_release_entry_lock_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.entries().dom().contains(key), + exists|h: OpHandle| pre.entries()[key].local == EntryLocal::LocalLockedFor(h), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalAvailable, + ..pre.entries()[key] + }), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_release_entry_lock(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_release_entry_lock(quint_view(pre), key); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: thin wrapper over `add_coin_with_account` with +/// `account = 0`. +pub open spec fn quint_step_add_coin( + pre: QuintViewState, + p: PurseId, + exponent: u8, + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, +{ + quint_step_add_coin_with_account(pre, p, exponent, 0, next_age, new_idx) +} + +proof fn lemma_add_coin_refines( + pre: State, + post: State, + p: PurseId, + exponent: u8, + new_idx: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_age < u64::MAX, + exponent <= MAX_EXPONENT, + post.invariant(), + post.coins() == pre.coins().insert( + (p, new_idx), + CoinRec { + purse: p, + idx: new_idx, + exponent, + state: CoinState::Pending, + age: pre.next_age, + account: 0, + }, + ), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx + 1, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_add_coin( + quint_view(pre), p, exponent, pre.next_age, new_idx, + ), +{ + lemma_add_coin_with_account_refines(pre, post, p, exponent, 0, new_idx); +} + +/// Quint analog: thin wrapper over `add_entry_with_meta` with zero +/// placeholders for the four chain-side metadata fields. +pub open spec fn quint_step_add_entry( + pre: QuintViewState, + p: PurseId, + exponent: u8, + on_chain: EntryOnChain, + local: EntryLocal, + new_idx: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_entry_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, +{ + quint_step_add_entry_with_meta( + pre, p, exponent, on_chain, local, 0, 0, 0, 0, new_idx, + ) +} + +proof fn lemma_add_entry_refines( + pre: State, + post: State, + p: PurseId, + exponent: u8, + on_chain: EntryOnChain, + local: EntryLocal, + new_idx: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_entry_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + exponent <= MAX_EXPONENT, + post.invariant(), + post.entries() == pre.entries().insert( + (p, new_idx), + EntryRec { + purse: p, + idx: new_idx, + exponent, + on_chain, + local, + member_key: 0, + allocated_at: 0, + ready_at: 0, + ring_idx: 0, + }, + ), + post.coins() == pre.coins(), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx + 1, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_add_entry( + quint_view(pre), p, exponent, on_chain, local, new_idx, + ), +{ + lemma_add_entry_with_meta_refines( + pre, post, p, exponent, on_chain, local, 0, 0, 0, 0, new_idx, + ); +} + +/// Quint analog: `purses' = purses.set(p, {..name = name..})`. Only +/// fires on the success branch — the PurseNotFound branch refines as +/// a state-preserving no-op. +pub open spec fn quint_step_rename_purse_success( + pre: QuintViewState, + p: PurseId, + name: Seq, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), +{ + QuintViewState { + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name, + next_coin_idx: pre.purses[p].next_coin_idx, + next_entry_idx: pre.purses[p].next_entry_idx, + }), + ..pre + } +} + +proof fn lemma_rename_purse_success_refines( + pre: State, + post: State, + p: PurseId, + name: Seq, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + post.invariant(), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].id == p, + post.purses()[p].name == name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_rename_purse_success(quint_view(pre), p, name), +{ + let post_view = quint_view(post); + let step_view = quint_step_rename_purse_success(quint_view(pre), p, name); + assert(post_view.purses =~= step_view.purses); +} + +/// Quint analog: `purses' = purses` (the PurseNotFound branch of +/// `rename_purse` is a state-preserving no-op). +proof fn lemma_rename_purse_fail_refines(pre: State, post: State) + requires + pre.invariant(), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_view(pre), +{ +} + +/// Quint analog (Some branch): `coins' = coins.put(rec.purse, rec.idx) -> rec` +/// where `rec = chain_coins[j]`. Composes with +/// [`quint_step_restore_chain_coin`] for the actual step. +proof fn lemma_recover_scan_step_coin_some_refines( + pre: State, + post: State, + j: usize, +) + requires + pre.invariant(), + 0 <= j < pre.chain_coins@.len(), + !pre.coins().dom().contains( + (pre.chain_coins@[j as int].purse, + pre.chain_coins@[j as int].idx)), + post.invariant(), + post.coins() == pre.coins().insert( + (pre.chain_coins@[j as int].purse, + pre.chain_coins@[j as int].idx), + pre.chain_coins@[j as int]), + post.purses() == pre.purses(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_restore_chain_coin( + quint_view(pre), pre.chain_coins@[j as int], + ), +{ + lemma_restore_chain_coin_refines(pre, post, pre.chain_coins@[j as int]); +} + +/// Quint analog (None branch): state-preserving no-op. +proof fn lemma_recover_scan_step_coin_none_refines(pre: State, post: State) + requires + pre.invariant(), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_view(pre), +{ +} + +/// Entry parallel of [`lemma_recover_scan_step_coin_some_refines`]. +proof fn lemma_recover_scan_step_entry_some_refines( + pre: State, + post: State, + j: usize, +) + requires + pre.invariant(), + 0 <= j < pre.chain_entries@.len(), + !pre.entries().dom().contains( + (pre.chain_entries@[j as int].purse, + pre.chain_entries@[j as int].idx)), + post.invariant(), + post.entries() == pre.entries().insert( + (pre.chain_entries@[j as int].purse, + pre.chain_entries@[j as int].idx), + pre.chain_entries@[j as int]), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_restore_chain_entry( + quint_view(pre), pre.chain_entries@[j as int], + ), +{ + lemma_restore_chain_entry_refines(pre, post, pre.chain_entries@[j as int]); +} + +/// Entry parallel of [`lemma_recover_scan_step_coin_none_refines`]. +proof fn lemma_recover_scan_step_entry_none_refines(pre: State, post: State) + requires + pre.invariant(), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_view(pre), +{ +} + +/// Some-branch refinement of `release_one_coin_lock_for`: refines as +/// `quint_step_release_locked_coin` at the returned key. +proof fn lemma_release_one_coin_lock_for_some_refines( + pre: State, + post: State, + handle: OpHandle, + key: (PurseId, u64), +) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::LockedFor(handle), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Available, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_release_locked_coin( + quint_view(pre), key, handle, + ), +{ + lemma_release_locked_coin_refines(pre, post, key, handle); +} + +/// None-branch refinement: state-preserving no-op. +proof fn lemma_release_one_coin_lock_for_none_refines(pre: State, post: State) + requires + pre.invariant(), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_view(pre), +{ +} + +/// Entry parallel: Some branch refines as `quint_step_release_locked_entry`. +proof fn lemma_release_one_entry_lock_for_some_refines( + pre: State, + post: State, + handle: OpHandle, + key: (PurseId, u64), +) + requires + pre.invariant(), + pre.entries().dom().contains(key), + pre.entries()[key].local == EntryLocal::LocalLockedFor(handle), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalAvailable, + ..pre.entries()[key] + }), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_release_locked_entry( + quint_view(pre), key, handle, + ), +{ + lemma_release_locked_entry_refines(pre, post, key, handle); +} + +/// Entry parallel: None branch refines as a no-op. +proof fn lemma_release_one_entry_lock_for_none_refines(pre: State, post: State) + requires + pre.invariant(), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_view(pre), +{ +} + +/// Quint analog: `release_locked_coin(key, handle) ; +/// set_op_failed(handle)`. Composes two individual refinement steps. +pub open spec fn quint_step_cancel_op_releasing_coin( + pre: QuintViewState, + handle: OpHandle, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::LockedFor(handle), + pre.operations.dom().contains(handle), + match pre.operations[handle].status { + OpStatus::Preparing => true, + OpStatus::Waiting(_) => true, + _ => false, + }, +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Available, + }), + operations: pre.operations.insert(handle, OperationRec { + handle: pre.operations[handle].handle, + kind: pre.operations[handle].kind, + purse: pre.operations[handle].purse, + status: OpStatus::Failed, + }), + events: pre.events.push(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }), + ..pre + } +} + +proof fn lemma_cancel_op_releasing_coin_refines( + pre: State, + post: State, + handle: OpHandle, + key: (PurseId, u64), +) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + match pre.operations()[handle].status { + OpStatus::Preparing => true, + OpStatus::Waiting(_) => true, + _ => false, + }, + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::LockedFor(handle), + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Available, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::Failed, + }), + post.events@ == pre.events@.push(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_cancel_op_releasing_coin(quint_view(pre), handle, key), +{ + let post_view = quint_view(post); + let step_view = quint_step_cancel_op_releasing_coin(quint_view(pre), handle, key); + assert(post_view.coins =~= step_view.coins); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Entry parallel of [`quint_step_cancel_op_releasing_coin`]. +pub open spec fn quint_step_cancel_op_releasing_entry( + pre: QuintViewState, + handle: OpHandle, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.entries.dom().contains(key), + pre.entries[key].local == EntryLocal::LocalLockedFor(handle), + pre.operations.dom().contains(handle), + match pre.operations[handle].status { + OpStatus::Preparing => true, + OpStatus::Waiting(_) => true, + _ => false, + }, +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + local: EntryLocal::LocalAvailable, + ..pre.entries[key] + }), + operations: pre.operations.insert(handle, OperationRec { + handle: pre.operations[handle].handle, + kind: pre.operations[handle].kind, + purse: pre.operations[handle].purse, + status: OpStatus::Failed, + }), + events: pre.events.push(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }), + ..pre + } +} + +proof fn lemma_cancel_op_releasing_entry_refines( + pre: State, + post: State, + handle: OpHandle, + key: (PurseId, u64), +) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + match pre.operations()[handle].status { + OpStatus::Preparing => true, + OpStatus::Waiting(_) => true, + _ => false, + }, + pre.entries().dom().contains(key), + pre.entries()[key].local == EntryLocal::LocalLockedFor(handle), + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalAvailable, + ..pre.entries()[key] + }), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::Failed, + }), + post.events@ == pre.events@.push(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_cancel_op_releasing_entry(quint_view(pre), handle, key), +{ + let post_view = quint_view(post); + let step_view = quint_step_cancel_op_releasing_entry(quint_view(pre), handle, key); + assert(post_view.entries =~= step_view.entries); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `start_op(kind, key.0) ; lock_coin(key, handle)`. +/// Composes two refinement steps with `handle = pre.next_handle`. +pub open spec fn quint_step_start_op_locking_coin( + pre: QuintViewState, + kind: OpKind, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Available, + pre.purses.dom().contains(key.0), + pre.next_handle < u64::MAX, +{ + let handle = pre.next_handle; + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind, + purse: key.0, + status: OpStatus::Preparing, + }), + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::LockedFor(handle), + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events.push(Event::OperationStarted { + handle, + kind, + purse: key.0, + }), + ..pre + } +} + +proof fn lemma_start_op_locking_coin_refines( + pre: State, + post: State, + kind: OpKind, + key: (PurseId, u64), +) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Available, + pre.purses().dom().contains(key.0), + pre.next_handle < u64::MAX, + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.entries() == pre.entries(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::LockedFor(pre.next_handle), + }), + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind, + purse: key.0, + status: OpStatus::Preparing, + }), + post.events@ == pre.events@.push(Event::OperationStarted { + handle: pre.next_handle, + kind, + purse: key.0, + }), + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_start_op_locking_coin(quint_view(pre), kind, key), +{ + let post_view = quint_view(post); + let step_view = quint_step_start_op_locking_coin(quint_view(pre), kind, key); + assert(post_view.coins =~= step_view.coins); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Entry parallel of [`quint_step_start_op_locking_coin`]. +pub open spec fn quint_step_start_op_locking_entry( + pre: QuintViewState, + kind: OpKind, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.entries.dom().contains(key), + pre.entries[key].local == EntryLocal::LocalAvailable, + pre.purses.dom().contains(key.0), + pre.next_handle < u64::MAX, +{ + let handle = pre.next_handle; + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind, + purse: key.0, + status: OpStatus::Preparing, + }), + entries: pre.entries.insert(key, EntryRec { + local: EntryLocal::LocalLockedFor(handle), + ..pre.entries[key] + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events.push(Event::OperationStarted { + handle, + kind, + purse: key.0, + }), + ..pre + } +} + +proof fn lemma_start_op_locking_entry_refines( + pre: State, + post: State, + kind: OpKind, + key: (PurseId, u64), +) + requires + pre.invariant(), + pre.entries().dom().contains(key), + pre.entries()[key].local == EntryLocal::LocalAvailable, + pre.purses().dom().contains(key.0), + pre.next_handle < u64::MAX, + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalLockedFor(pre.next_handle), + ..pre.entries()[key] + }), + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind, + purse: key.0, + status: OpStatus::Preparing, + }), + post.events@ == pre.events@.push(Event::OperationStarted { + handle: pre.next_handle, + kind, + purse: key.0, + }), + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_start_op_locking_entry(quint_view(pre), kind, key), +{ + let post_view = quint_view(post); + let step_view = quint_step_start_op_locking_entry(quint_view(pre), kind, key); + assert(post_view.entries =~= step_view.entries); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `consume_entry(key) ; mark_op_done(handle)`. Two +/// refinement steps; the coin map is unchanged. +pub open spec fn quint_step_commit_op_consuming_locked_entry( + pre: QuintViewState, + handle: OpHandle, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.entries.dom().contains(key), + pre.entries[key].local == EntryLocal::LocalLockedFor(handle), + pre.operations.dom().contains(handle), + pre.operations[handle].status == OpStatus::Finalized, +{ + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..pre.entries[key] + }), + operations: pre.operations.insert(handle, OperationRec { + handle: pre.operations[handle].handle, + kind: pre.operations[handle].kind, + purse: pre.operations[handle].purse, + status: OpStatus::Done, + }), + events: pre.events + .push(Event::EntryConsumed { + purse: key.0, + exponent: pre.entries[key].exponent, + }) + .push(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }), + ..pre + } +} + +proof fn lemma_commit_op_consuming_locked_entry_refines( + pre: State, + post: State, + handle: OpHandle, + key: (PurseId, u64), +) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + pre.operations()[handle].status == OpStatus::Finalized, + pre.entries().dom().contains(key), + pre.entries()[key].local == EntryLocal::LocalLockedFor(handle), + pre.events@.len() + 2 <= u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..pre.entries()[key] + }), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::Done, + }), + post.events@ == pre.events@ + .push(Event::EntryConsumed { + purse: key.0, + exponent: pre.entries()[key].exponent, + }) + .push(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_commit_op_consuming_locked_entry( + quint_view(pre), handle, key, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_commit_op_consuming_locked_entry( + quint_view(pre), handle, key, + ); + assert(post_view.entries =~= step_view.entries); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `commit_locked_coin(key) ; mark_coin_spent(key) ; +/// mark_op_done(handle)`. Three refinement steps composed; the +/// intermediate PendingSpend state is invisible in the composite delta. +pub open spec fn quint_step_commit_op_consuming_locked_coin( + pre: QuintViewState, + handle: OpHandle, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::LockedFor(handle), + pre.operations.dom().contains(handle), + pre.operations[handle].status == OpStatus::Finalized, +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Spent, + }), + operations: pre.operations.insert(handle, OperationRec { + handle: pre.operations[handle].handle, + kind: pre.operations[handle].kind, + purse: pre.operations[handle].purse, + status: OpStatus::Done, + }), + events: pre.events + .push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins[key].exponent, + }) + .push(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }), + ..pre + } +} + +proof fn lemma_commit_op_consuming_locked_coin_refines( + pre: State, + post: State, + handle: OpHandle, + key: (PurseId, u64), +) + requires + pre.invariant(), + pre.operations().dom().contains(handle), + pre.operations()[handle].status == OpStatus::Finalized, + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::LockedFor(handle), + pre.events@.len() + 2 <= u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.entries() == pre.entries(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Spent, + }), + post.operations() == pre.operations().insert(handle, OperationRec { + handle: pre.operations()[handle].handle, + kind: pre.operations()[handle].kind, + purse: pre.operations()[handle].purse, + status: OpStatus::Done, + }), + post.events@ == pre.events@ + .push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins()[key].exponent, + }) + .push(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_commit_op_consuming_locked_coin( + quint_view(pre), handle, key, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_commit_op_consuming_locked_coin( + quint_view(pre), handle, key, + ); + assert(post_view.coins =~= step_view.coins); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `mark_coin_pending_spend(key) ; mark_coin_spent(key)`. +/// The intermediate `PendingSpend` state is hidden in the composite. +pub open spec fn quint_step_export_coin( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Available, +{ + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Spent, + }), + events: pre.events.push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins[key].exponent, + }), + ..pre + } +} + +proof fn lemma_export_coin_refines(pre: State, post: State, key: (PurseId, u64)) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Available, + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Spent, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@.push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins()[key].exponent, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_export_coin(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_export_coin(quint_view(pre), key); + assert(post_view.coins =~= step_view.coins); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `add_coin_with_account(p, exp, account) ; +/// mark_coin_observed(key)`. The intermediate `Pending` state is +/// hidden in the composite — the coin emerges directly as Available +/// with a CoinAvailable event. +pub open spec fn quint_step_import_coin( + pre: QuintViewState, + p: PurseId, + exponent: u8, + account: u64, + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, +{ + let key = (p, new_idx); + QuintViewState { + coins: pre.coins.insert(key, CoinRec { + purse: p, + idx: new_idx, + exponent, + state: CoinState::Available, + age: next_age, + account, + }), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx + 1, + next_entry_idx: pre.purses[p].next_entry_idx, + }), + events: pre.events.push(Event::CoinAvailable { purse: p, exponent }), + ..pre + } +} + +proof fn lemma_import_coin_refines( + pre: State, + post: State, + p: PurseId, + exponent: u8, + account: u64, + new_idx: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_age < u64::MAX, + pre.events@.len() < u64::MAX as nat, + exponent <= MAX_EXPONENT, + post.invariant(), + post.coins() == pre.coins().insert( + (p, new_idx), + CoinRec { + purse: p, + idx: new_idx, + exponent, + state: CoinState::Available, + age: pre.next_age, + account, + }, + ), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx + 1, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@.push(Event::CoinAvailable { purse: p, exponent }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_import_coin( + quint_view(pre), p, exponent, account, pre.next_age, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_import_coin( + quint_view(pre), p, exponent, account, pre.next_age, new_idx, + ); + assert(post_view.coins =~= step_view.coins); + assert(post_view.purses =~= step_view.purses); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog (success branch): `purses' = purses.remove(p) ; +/// coins' = coins.remove_keys(filter purse==p) ; entries' = entries +/// .remove_keys(filter purse==p)`. +pub open spec fn quint_step_delete_purse_success( + pre: QuintViewState, + p: PurseId, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + p != MAIN_PURSE, +{ + QuintViewState { + purses: pre.purses.remove(p), + coins: pre.coins.remove_keys(Set::new(|k: (PurseId, u64)| k.0 == p)), + entries: pre.entries.remove_keys(Set::new(|k: (PurseId, u64)| k.0 == p)), + ..pre + } +} + +proof fn lemma_delete_purse_success_refines(pre: State, post: State, p: PurseId) + requires + pre.invariant(), + pre.purses().dom().contains(p), + p != MAIN_PURSE, + !pre.has_live_coin_in(p), + forall|h: OpHandle| #[trigger] pre.operations().dom().contains(h) + ==> pre.operations()[h].purse != p, + post.invariant(), + post.purses() == pre.purses().remove(p), + post.coins() == pre.coins().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p)), + post.entries() == pre.entries().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p)), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_delete_purse_success(quint_view(pre), p), +{ + let post_view = quint_view(post); + let step_view = quint_step_delete_purse_success(quint_view(pre), p); + assert(post_view.purses =~= step_view.purses); + assert(post_view.coins =~= step_view.coins); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog (CannotDeleteMainPurse branch): identity. +proof fn lemma_delete_purse_main_refines(pre: State, post: State) + requires + pre.invariant(), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_view(pre), +{ +} + +/// Quint analog (PurseNotFound branch): `coins' = coins.remove_keys +/// (filter purse==p)` and `entries' = entries.remove_keys(filter purse +/// ==p)`. By invariant, these filters are vacuous when p ∉ purses.dom +/// — but the Verus contract still spells out the deltas because +/// remove_keys is unconditional in the body. +pub open spec fn quint_step_delete_purse_notfound( + pre: QuintViewState, + p: PurseId, +) -> QuintViewState +{ + QuintViewState { + coins: pre.coins.remove_keys(Set::new(|k: (PurseId, u64)| k.0 == p)), + entries: pre.entries.remove_keys(Set::new(|k: (PurseId, u64)| k.0 == p)), + ..pre + } +} + +proof fn lemma_delete_purse_notfound_refines(pre: State, post: State, p: PurseId) + requires + pre.invariant(), + !pre.purses().dom().contains(p), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p)), + post.entries() == pre.entries().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p)), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_delete_purse_notfound(quint_view(pre), p), +{ + let post_view = quint_view(post); + let step_view = quint_step_delete_purse_notfound(quint_view(pre), p); + assert(post_view.coins =~= step_view.coins); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: `set_entry_local(key, LocalLockedFor) ; set_entry_local +/// (key, LocalConsumed) ; add_coin(purse, exp) ; mark_coin_observed(new)`. +/// The intermediate `LocalLockedFor` state is hidden in the composite. +pub open spec fn quint_step_unload_via_entry( + pre: QuintViewState, + key: (PurseId, u64), + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.entries.dom().contains(key), + pre.entries[key].local == EntryLocal::LocalAvailable, + pre.entries[key].on_chain == EntryOnChain::Ready, + pre.purses.dom().contains(key.0), + pre.purses[key.0].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, +{ + let p = key.0; + let exp = pre.entries[key].exponent; + let new_coin_key = (p, new_idx); + QuintViewState { + entries: pre.entries.insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..pre.entries[key] + }), + coins: pre.coins.insert(new_coin_key, CoinRec { + purse: p, + idx: new_idx, + exponent: exp, + state: CoinState::Available, + age: next_age, + account: 0, + }), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx + 1, + next_entry_idx: pre.purses[p].next_entry_idx, + }), + events: pre.events.push(Event::CoinAvailable { + purse: p, + exponent: exp, + }), + ..pre + } +} + +proof fn lemma_unload_via_entry_refines( + pre: State, + post: State, + key: (PurseId, u64), + new_idx: u64, +) + requires + pre.invariant(), + pre.entries().dom().contains(key), + pre.entries()[key].local == EntryLocal::LocalAvailable, + pre.entries()[key].on_chain == EntryOnChain::Ready, + pre.purses().dom().contains(key.0), + pre.purses()[key.0].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_age < u64::MAX, + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..pre.entries()[key] + }), + post.coins() == pre.coins().insert((key.0, new_idx), CoinRec { + purse: key.0, + idx: new_idx, + exponent: pre.entries()[key].exponent, + state: CoinState::Available, + age: pre.next_age, + account: 0, + }), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[key.0].id == key.0, + post.purses()[key.0].name == pre.purses()[key.0].name, + post.purses()[key.0].next_coin_idx == pre.purses()[key.0].next_coin_idx + 1, + post.purses()[key.0].next_entry_idx == pre.purses()[key.0].next_entry_idx, + forall|q: PurseId| q != key.0 && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.operations() == pre.operations(), + post.events@ == pre.events@.push(Event::CoinAvailable { + purse: key.0, + exponent: pre.entries()[key].exponent, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_unload_via_entry( + quint_view(pre), key, pre.next_age, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_unload_via_entry( + quint_view(pre), key, pre.next_age, new_idx, + ); + assert(post_view.entries =~= step_view.entries); + assert(post_view.coins =~= step_view.coins); + assert(post_view.purses =~= step_view.purses); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: spend `key` (in `src`), mint a fresh coin of the +/// same exponent in `dst`. The intermediate PendingSpend / Pending +/// states are hidden in the composite. +pub open spec fn quint_step_rebalance( + pre: QuintViewState, + src: PurseId, + dst: PurseId, + key: (PurseId, u64), + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + src != dst, + key.0 == src, + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Available, + pre.purses.dom().contains(dst), + pre.purses[dst].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, +{ + let exp = pre.coins[key].exponent; + let new_key = (dst, new_idx); + QuintViewState { + coins: pre.coins + .insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: exp, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Spent, + }) + .insert(new_key, CoinRec { + purse: dst, + idx: new_idx, + exponent: exp, + state: CoinState::Available, + age: next_age, + account: 0, + }), + purses: pre.purses.insert(dst, PurseRecSpec { + id: pre.purses[dst].id, + name: pre.purses[dst].name, + next_coin_idx: pre.purses[dst].next_coin_idx + 1, + next_entry_idx: pre.purses[dst].next_entry_idx, + }), + events: pre.events + .push(Event::CoinSpent { purse: src, exponent: exp }) + .push(Event::CoinAvailable { purse: dst, exponent: exp }), + ..pre + } +} + +proof fn lemma_rebalance_refines( + pre: State, + post: State, + src: PurseId, + dst: PurseId, + key: (PurseId, u64), + new_idx: u64, +) + requires + pre.invariant(), + src != dst, + key.0 == src, + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Available, + pre.purses().dom().contains(dst), + pre.purses()[dst].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.events@.len() + 2 <= u64::MAX as nat, + pre.next_age < u64::MAX, + post.invariant(), + post.coins() == pre.coins() + .insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Spent, + }) + .insert((dst, new_idx), CoinRec { + purse: dst, + idx: new_idx, + exponent: pre.coins()[key].exponent, + state: CoinState::Available, + age: pre.next_age, + account: 0, + }), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[dst].id == dst, + post.purses()[dst].name == pre.purses()[dst].name, + post.purses()[dst].next_coin_idx == pre.purses()[dst].next_coin_idx + 1, + post.purses()[dst].next_entry_idx == pre.purses()[dst].next_entry_idx, + forall|q: PurseId| q != dst && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@ + .push(Event::CoinSpent { + purse: src, + exponent: pre.coins()[key].exponent, + }) + .push(Event::CoinAvailable { + purse: dst, + exponent: pre.coins()[key].exponent, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_rebalance( + quint_view(pre), src, dst, key, pre.next_age, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_rebalance( + quint_view(pre), src, dst, key, pre.next_age, new_idx, + ); + assert(post_view.coins =~= step_view.coins); + assert(post_view.purses =~= step_view.purses); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog (Some branch): non-deterministic transfer of a +/// specific source coin to a fresh coin in `to`. The Quint +/// transfer Action uses `oneOf` over candidate coins; Verus +/// realizes the choice via `select_coin`. The refinement lemma +/// is parameterized over the witness `src_key`. +pub open spec fn quint_step_transfer_some( + pre: QuintViewState, + from: PurseId, + to: PurseId, + src_key: (PurseId, u64), + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.coins.dom().contains(src_key), + src_key.0 == from, + pre.coins[src_key].state == CoinState::Available, + pre.purses.dom().contains(to), + pre.purses[to].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, +{ + let exp = pre.coins[src_key].exponent; + let new_key = (to, new_idx); + QuintViewState { + coins: pre.coins + .insert(src_key, CoinRec { + purse: pre.coins[src_key].purse, + idx: pre.coins[src_key].idx, + exponent: exp, + age: pre.coins[src_key].age, + account: pre.coins[src_key].account, + state: CoinState::Spent, + }) + .insert(new_key, CoinRec { + purse: to, + idx: new_idx, + exponent: exp, + state: CoinState::Available, + age: next_age, + account: 0, + }), + purses: pre.purses.insert(to, PurseRecSpec { + id: pre.purses[to].id, + name: pre.purses[to].name, + next_coin_idx: pre.purses[to].next_coin_idx + 1, + next_entry_idx: pre.purses[to].next_entry_idx, + }), + events: pre.events + .push(Event::CoinSpent { purse: from, exponent: exp }) + .push(Event::CoinAvailable { purse: to, exponent: exp }), + ..pre + } +} + +proof fn lemma_transfer_some_refines( + pre: State, + post: State, + from: PurseId, + to: PurseId, + src_key: (PurseId, u64), + new_idx: u64, +) + requires + pre.invariant(), + pre.coins().dom().contains(src_key), + src_key.0 == from, + pre.coins()[src_key].state == CoinState::Available, + pre.purses().dom().contains(to), + pre.purses()[to].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.events@.len() + 2 <= u64::MAX as nat, + pre.next_age < u64::MAX, + post.invariant(), + post.coins() == pre.coins() + .insert(src_key, CoinRec { + purse: pre.coins()[src_key].purse, + idx: pre.coins()[src_key].idx, + exponent: pre.coins()[src_key].exponent, + age: pre.coins()[src_key].age, + account: pre.coins()[src_key].account, + state: CoinState::Spent, + }) + .insert((to, new_idx), CoinRec { + purse: to, + idx: new_idx, + exponent: pre.coins()[src_key].exponent, + state: CoinState::Available, + age: pre.next_age, + account: 0, + }), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[to].id == to, + post.purses()[to].name == pre.purses()[to].name, + post.purses()[to].next_coin_idx == pre.purses()[to].next_coin_idx + 1, + post.purses()[to].next_entry_idx == pre.purses()[to].next_entry_idx, + forall|q: PurseId| q != to && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@ + .push(Event::CoinSpent { + purse: from, + exponent: pre.coins()[src_key].exponent, + }) + .push(Event::CoinAvailable { + purse: to, + exponent: pre.coins()[src_key].exponent, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_transfer_some( + quint_view(pre), from, to, src_key, pre.next_age, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_transfer_some( + quint_view(pre), from, to, src_key, pre.next_age, new_idx, + ); + assert(post_view.coins =~= step_view.coins); + assert(post_view.purses =~= step_view.purses); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog (None branch): identity — no Available coin met +/// the threshold, no state change. +proof fn lemma_transfer_none_refines(pre: State, post: State) + requires + pre.invariant(), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_view(pre), +{ +} + +/// Quint analog: `start_op(Export, purse) ; export_coin(key) ; +/// mark_op_submitted(handle)`. Three refinement steps composed. +pub open spec fn quint_step_tracked_export_coin( + pre: QuintViewState, + key: (PurseId, u64), +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Available, + pre.next_handle < u64::MAX, +{ + let handle = pre.next_handle; + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind: OpKind::Export, + purse: key.0, + status: OpStatus::Submitted, + }), + coins: pre.coins.insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: pre.coins[key].exponent, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Spent, + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events + .push(Event::OperationStarted { + handle, + kind: OpKind::Export, + purse: key.0, + }) + .push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins[key].exponent, + }) + .push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + ..pre + } +} + +proof fn lemma_tracked_export_coin_refines( + pre: State, + post: State, + key: (PurseId, u64), +) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Available, + pre.next_handle < u64::MAX, + pre.events@.len() + 3 <= u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Spent, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind: OpKind::Export, + purse: key.0, + status: OpStatus::Submitted, + }), + post.events@ == pre.events@ + .push(Event::OperationStarted { + handle: pre.next_handle, + kind: OpKind::Export, + purse: key.0, + }) + .push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins()[key].exponent, + }) + .push(Event::OperationProgress { + handle: pre.next_handle, + status: OpStatus::Submitted, + }), + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_tracked_export_coin(quint_view(pre), key), +{ + let post_view = quint_view(post); + let step_view = quint_step_tracked_export_coin(quint_view(pre), key); + assert(post_view.coins =~= step_view.coins); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `start_op(Import, p) ; import_coin(p, exp, account) +/// ; mark_op_submitted(handle)`. Three refinement steps composed. +pub open spec fn quint_step_tracked_import_coin( + pre: QuintViewState, + p: PurseId, + exponent: u8, + account: u64, + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_handle < u64::MAX, +{ + let handle = pre.next_handle; + let new_key = (p, new_idx); + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind: OpKind::Import, + purse: p, + status: OpStatus::Submitted, + }), + coins: pre.coins.insert(new_key, CoinRec { + purse: p, + idx: new_idx, + exponent, + state: CoinState::Available, + age: next_age, + account, + }), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx + 1, + next_entry_idx: pre.purses[p].next_entry_idx, + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events + .push(Event::OperationStarted { + handle, + kind: OpKind::Import, + purse: p, + }) + .push(Event::CoinAvailable { purse: p, exponent }) + .push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + ..pre + } +} + +proof fn lemma_tracked_import_coin_refines( + pre: State, + post: State, + p: PurseId, + exponent: u8, + account: u64, + new_idx: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_age < u64::MAX, + pre.next_handle < u64::MAX, + pre.events@.len() + 3 <= u64::MAX as nat, + exponent <= MAX_EXPONENT, + post.invariant(), + post.coins() == pre.coins().insert((p, new_idx), CoinRec { + purse: p, + idx: new_idx, + exponent, + state: CoinState::Available, + age: pre.next_age, + account, + }), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx + 1, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind: OpKind::Import, + purse: p, + status: OpStatus::Submitted, + }), + post.events@ == pre.events@ + .push(Event::OperationStarted { + handle: pre.next_handle, + kind: OpKind::Import, + purse: p, + }) + .push(Event::CoinAvailable { purse: p, exponent }) + .push(Event::OperationProgress { + handle: pre.next_handle, + status: OpStatus::Submitted, + }), + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_tracked_import_coin( + quint_view(pre), p, exponent, account, pre.next_age, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_tracked_import_coin( + quint_view(pre), p, exponent, account, pre.next_age, new_idx, + ); + assert(post_view.coins =~= step_view.coins); + assert(post_view.purses =~= step_view.purses); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `start_op(Rebalance, src) ; rebalance(src, dst, key) +/// ; mark_op_submitted(handle)`. Three refinement steps composed. +pub open spec fn quint_step_tracked_rebalance( + pre: QuintViewState, + src: PurseId, + dst: PurseId, + key: (PurseId, u64), + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + src != dst, + key.0 == src, + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Available, + pre.purses.dom().contains(dst), + pre.purses[dst].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_handle < u64::MAX, +{ + let handle = pre.next_handle; + let exp = pre.coins[key].exponent; + let new_key = (dst, new_idx); + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind: OpKind::Rebalance, + purse: src, + status: OpStatus::Submitted, + }), + coins: pre.coins + .insert(key, CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: exp, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Spent, + }) + .insert(new_key, CoinRec { + purse: dst, + idx: new_idx, + exponent: exp, + state: CoinState::Available, + age: next_age, + account: 0, + }), + purses: pre.purses.insert(dst, PurseRecSpec { + id: pre.purses[dst].id, + name: pre.purses[dst].name, + next_coin_idx: pre.purses[dst].next_coin_idx + 1, + next_entry_idx: pre.purses[dst].next_entry_idx, + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events + .push(Event::OperationStarted { + handle, + kind: OpKind::Rebalance, + purse: src, + }) + .push(Event::CoinSpent { purse: src, exponent: exp }) + .push(Event::CoinAvailable { purse: dst, exponent: exp }) + .push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + ..pre + } +} + +proof fn lemma_tracked_rebalance_refines( + pre: State, + post: State, + src: PurseId, + dst: PurseId, + key: (PurseId, u64), + new_idx: u64, +) + requires + pre.invariant(), + src != dst, + key.0 == src, + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Available, + pre.purses().dom().contains(dst), + pre.purses()[dst].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_age < u64::MAX, + pre.next_handle < u64::MAX, + pre.events@.len() + 4 <= u64::MAX as nat, + post.invariant(), + post.coins() == pre.coins() + .insert(key, CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Spent, + }) + .insert((dst, new_idx), CoinRec { + purse: dst, + idx: new_idx, + exponent: pre.coins()[key].exponent, + state: CoinState::Available, + age: pre.next_age, + account: 0, + }), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[dst].id == dst, + post.purses()[dst].name == pre.purses()[dst].name, + post.purses()[dst].next_coin_idx == pre.purses()[dst].next_coin_idx + 1, + post.purses()[dst].next_entry_idx == pre.purses()[dst].next_entry_idx, + forall|q: PurseId| q != dst && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind: OpKind::Rebalance, + purse: src, + status: OpStatus::Submitted, + }), + post.events@ == pre.events@ + .push(Event::OperationStarted { + handle: pre.next_handle, + kind: OpKind::Rebalance, + purse: src, + }) + .push(Event::CoinSpent { + purse: src, + exponent: pre.coins()[key].exponent, + }) + .push(Event::CoinAvailable { + purse: dst, + exponent: pre.coins()[key].exponent, + }) + .push(Event::OperationProgress { + handle: pre.next_handle, + status: OpStatus::Submitted, + }), + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_tracked_rebalance( + quint_view(pre), src, dst, key, pre.next_age, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_tracked_rebalance( + quint_view(pre), src, dst, key, pre.next_age, new_idx, + ); + assert(post_view.coins =~= step_view.coins); + assert(post_view.purses =~= step_view.purses); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog (Some branch): `start_op(Transfer, from) ; +/// transfer(from, to, min_exp) ; mark_op_done(handle)`. Refinement +/// witnesses the existentially-chosen `src_key`. +pub open spec fn quint_step_tracked_transfer_some( + pre: QuintViewState, + from: PurseId, + to: PurseId, + src_key: (PurseId, u64), + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.coins.dom().contains(src_key), + src_key.0 == from, + pre.coins[src_key].state == CoinState::Available, + pre.purses.dom().contains(to), + pre.purses[to].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_handle < u64::MAX, +{ + let handle = pre.next_handle; + let exp = pre.coins[src_key].exponent; + let new_key = (to, new_idx); + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind: OpKind::Transfer, + purse: from, + status: OpStatus::Done, + }), + coins: pre.coins + .insert(src_key, CoinRec { + purse: pre.coins[src_key].purse, + idx: pre.coins[src_key].idx, + exponent: exp, + age: pre.coins[src_key].age, + account: pre.coins[src_key].account, + state: CoinState::Spent, + }) + .insert(new_key, CoinRec { + purse: to, + idx: new_idx, + exponent: exp, + state: CoinState::Available, + age: next_age, + account: 0, + }), + purses: pre.purses.insert(to, PurseRecSpec { + id: pre.purses[to].id, + name: pre.purses[to].name, + next_coin_idx: pre.purses[to].next_coin_idx + 1, + next_entry_idx: pre.purses[to].next_entry_idx, + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events + .push(Event::OperationStarted { + handle, + kind: OpKind::Transfer, + purse: from, + }) + .push(Event::CoinSpent { purse: from, exponent: exp }) + .push(Event::CoinAvailable { purse: to, exponent: exp }), + ..pre + } +} + +proof fn lemma_tracked_transfer_some_refines( + pre: State, + post: State, + from: PurseId, + to: PurseId, + src_key: (PurseId, u64), + new_idx: u64, +) + requires + pre.invariant(), + pre.coins().dom().contains(src_key), + src_key.0 == from, + pre.coins()[src_key].state == CoinState::Available, + pre.purses().dom().contains(to), + pre.purses()[to].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_age < u64::MAX, + pre.next_handle < u64::MAX, + pre.events@.len() + 3 <= u64::MAX as nat, + post.invariant(), + post.coins() == pre.coins() + .insert(src_key, CoinRec { + purse: pre.coins()[src_key].purse, + idx: pre.coins()[src_key].idx, + exponent: pre.coins()[src_key].exponent, + age: pre.coins()[src_key].age, + account: pre.coins()[src_key].account, + state: CoinState::Spent, + }) + .insert((to, new_idx), CoinRec { + purse: to, + idx: new_idx, + exponent: pre.coins()[src_key].exponent, + state: CoinState::Available, + age: pre.next_age, + account: 0, + }), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[to].id == to, + post.purses()[to].name == pre.purses()[to].name, + post.purses()[to].next_coin_idx == pre.purses()[to].next_coin_idx + 1, + post.purses()[to].next_entry_idx == pre.purses()[to].next_entry_idx, + forall|q: PurseId| q != to && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind: OpKind::Transfer, + purse: from, + status: OpStatus::Done, + }), + post.events@ == pre.events@ + .push(Event::OperationStarted { + handle: pre.next_handle, + kind: OpKind::Transfer, + purse: from, + }) + .push(Event::CoinSpent { + purse: from, + exponent: pre.coins()[src_key].exponent, + }) + .push(Event::CoinAvailable { + purse: to, + exponent: pre.coins()[src_key].exponent, + }), + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_tracked_transfer_some( + quint_view(pre), from, to, src_key, pre.next_age, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_tracked_transfer_some( + quint_view(pre), from, to, src_key, pre.next_age, new_idx, + ); + assert(post_view.coins =~= step_view.coins); + assert(post_view.purses =~= step_view.purses); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog (None branch): `start_op(Transfer, from) ; +/// set_op_failed-equivalent`. No coin moves. +pub open spec fn quint_step_tracked_transfer_none( + pre: QuintViewState, + from: PurseId, +) -> QuintViewState + recommends + pre.next_handle < u64::MAX, +{ + let handle = pre.next_handle; + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind: OpKind::Transfer, + purse: from, + status: OpStatus::Failed, + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events.push(Event::OperationStarted { + handle, + kind: OpKind::Transfer, + purse: from, + }), + ..pre + } +} + +proof fn lemma_tracked_transfer_none_refines( + pre: State, + post: State, + from: PurseId, +) + requires + pre.invariant(), + pre.next_handle < u64::MAX, + pre.events@.len() < u64::MAX as nat, + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind: OpKind::Transfer, + purse: from, + status: OpStatus::Failed, + }), + post.events@ == pre.events@.push(Event::OperationStarted { + handle: pre.next_handle, + kind: OpKind::Transfer, + purse: from, + }), + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_tracked_transfer_none(quint_view(pre), from), +{ + let post_view = quint_view(post); + let step_view = quint_step_tracked_transfer_none(quint_view(pre), from); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `start_op(ExternalOffload, p) ; unload_via_entry(key, +/// handle) ; mark_op_submitted(handle)`. Three refinement steps composed. +pub open spec fn quint_step_tracked_unload_via_entry( + pre: QuintViewState, + key: (PurseId, u64), + next_age: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.entries.dom().contains(key), + pre.entries[key].local == EntryLocal::LocalAvailable, + pre.entries[key].on_chain == EntryOnChain::Ready, + pre.purses.dom().contains(key.0), + pre.purses[key.0].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_handle < u64::MAX, +{ + let p = key.0; + let handle = pre.next_handle; + let exp = pre.entries[key].exponent; + let new_coin_key = (p, new_idx); + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind: OpKind::ExternalOffload, + purse: p, + status: OpStatus::Submitted, + }), + entries: pre.entries.insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..pre.entries[key] + }), + coins: pre.coins.insert(new_coin_key, CoinRec { + purse: p, + idx: new_idx, + exponent: exp, + state: CoinState::Available, + age: next_age, + account: 0, + }), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx + 1, + next_entry_idx: pre.purses[p].next_entry_idx, + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events + .push(Event::OperationStarted { + handle, + kind: OpKind::ExternalOffload, + purse: p, + }) + .push(Event::CoinAvailable { purse: p, exponent: exp }) + .push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + ..pre + } +} + +proof fn lemma_tracked_unload_via_entry_refines( + pre: State, + post: State, + key: (PurseId, u64), + new_idx: u64, +) + requires + pre.invariant(), + pre.entries().dom().contains(key), + pre.entries()[key].local == EntryLocal::LocalAvailable, + pre.entries()[key].on_chain == EntryOnChain::Ready, + pre.purses().dom().contains(key.0), + pre.purses()[key.0].next_coin_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_age < u64::MAX, + pre.next_handle < u64::MAX, + pre.events@.len() + 3 <= u64::MAX as nat, + post.invariant(), + post.entries() == pre.entries().insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..pre.entries()[key] + }), + post.coins() == pre.coins().insert((key.0, new_idx), CoinRec { + purse: key.0, + idx: new_idx, + exponent: pre.entries()[key].exponent, + state: CoinState::Available, + age: pre.next_age, + account: 0, + }), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[key.0].id == key.0, + post.purses()[key.0].name == pre.purses()[key.0].name, + post.purses()[key.0].next_coin_idx == pre.purses()[key.0].next_coin_idx + 1, + post.purses()[key.0].next_entry_idx == pre.purses()[key.0].next_entry_idx, + forall|q: PurseId| q != key.0 && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind: OpKind::ExternalOffload, + purse: key.0, + status: OpStatus::Submitted, + }), + post.events@ == pre.events@ + .push(Event::OperationStarted { + handle: pre.next_handle, + kind: OpKind::ExternalOffload, + purse: key.0, + }) + .push(Event::CoinAvailable { + purse: key.0, + exponent: pre.entries()[key].exponent, + }) + .push(Event::OperationProgress { + handle: pre.next_handle, + status: OpStatus::Submitted, + }), + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_tracked_unload_via_entry( + quint_view(pre), key, pre.next_age, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_tracked_unload_via_entry( + quint_view(pre), key, pre.next_age, new_idx, + ); + assert(post_view.entries =~= step_view.entries); + assert(post_view.coins =~= step_view.coins); + assert(post_view.purses =~= step_view.purses); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `coins' = coins.remove_keys(filter purse==p)`. +pub open spec fn quint_step_purge_coins_of_purse( + pre: QuintViewState, + p: PurseId, +) -> QuintViewState { + QuintViewState { + coins: pre.coins.remove_keys(Set::new(|k: (PurseId, u64)| k.0 == p)), + ..pre + } +} + +proof fn lemma_purge_coins_of_purse_refines(pre: State, post: State, p: PurseId) + requires + pre.invariant(), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p)), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_purge_coins_of_purse(quint_view(pre), p), +{ + let post_view = quint_view(post); + let step_view = quint_step_purge_coins_of_purse(quint_view(pre), p); + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `entries' = entries.remove_keys(filter purse==p)`. +pub open spec fn quint_step_purge_entries_of_purse( + pre: QuintViewState, + p: PurseId, +) -> QuintViewState { + QuintViewState { + entries: pre.entries.remove_keys(Set::new(|k: (PurseId, u64)| k.0 == p)), + ..pre + } +} + +proof fn lemma_purge_entries_of_purse_refines(pre: State, post: State, p: PurseId) + requires + pre.invariant(), + post.invariant(), + post.purses() == pre.purses(), + post.coins() == pre.coins(), + post.entries() == pre.entries().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p)), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_purge_entries_of_purse(quint_view(pre), p), +{ + let post_view = quint_view(post); + let step_view = quint_step_purge_entries_of_purse(quint_view(pre), p); + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: bulk mint `exp_seq.len()` Pending coins in `p` with +/// sequential indices `[base_idx, base_idx + n)` and sequential ages +/// `[base_age, base_age + n)`. Quint createCoins fold reduced to a +/// single map-union expression. +pub open spec fn quint_step_top_up_purse( + pre: QuintViewState, + p: PurseId, + exp_seq: Seq, + base_idx: u64, + base_age: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_coin_idx == base_idx as nat, + (base_idx as nat) + exp_seq.len() <= u64::MAX as nat, + (base_age as nat) + exp_seq.len() <= u64::MAX as nat, +{ + QuintViewState { + coins: Map::new( + |k: (PurseId, u64)| + pre.coins.dom().contains(k) + || (k.0 == p + && (base_idx as int) <= (k.1 as int) + && (k.1 as int) < (base_idx as int) + exp_seq.len() as int), + |k: (PurseId, u64)| + if pre.coins.dom().contains(k) { + pre.coins[k] + } else { + let j = (k.1 as int) - (base_idx as int); + CoinRec { + purse: p, + idx: k.1, + exponent: exp_seq[j], + state: CoinState::Pending, + age: ((base_age as int) + j) as u64, + account: 0, + } + } + ), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx + exp_seq.len(), + next_entry_idx: pre.purses[p].next_entry_idx, + }), + ..pre + } +} + +proof fn lemma_top_up_purse_refines( + pre: State, + post: State, + p: PurseId, + exp_seq: Seq, + base_idx: u64, + base_age: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_coin_idx == base_idx as nat, + pre.next_age == base_age, + (base_idx as nat) + exp_seq.len() <= u64::MAX as nat, + (base_age as nat) + exp_seq.len() <= u64::MAX as nat, + forall|j: int| 0 <= j < exp_seq.len() ==> + (#[trigger] exp_seq[j]) <= MAX_EXPONENT, + post.invariant(), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx + exp_seq.len(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.coins().dom() =~= pre.coins().dom().union( + Set::new(|k: (PurseId, u64)| + k.0 == p + && (base_idx as int) <= (k.1 as int) + && (k.1 as int) < (base_idx as int) + exp_seq.len() as int) + ), + forall|k: (PurseId, u64)| #[trigger] pre.coins().dom().contains(k) + ==> post.coins()[k] == pre.coins()[k], + forall|j: int| 0 <= j < exp_seq.len() ==> + #[trigger] post.coins()[(p, (base_idx + j) as u64)] + == (CoinRec { + purse: p, + idx: (base_idx + j) as u64, + exponent: exp_seq[j], + state: CoinState::Pending, + age: (base_age + j) as u64, + account: 0, + }), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_top_up_purse( + quint_view(pre), p, exp_seq, base_idx, base_age, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_top_up_purse( + quint_view(pre), p, exp_seq, base_idx, base_age, + ); + assert(post_view.purses =~= step_view.purses); + // For coins, prove extensional equality: for every key, both maps + // agree on dom and value. + assert forall|k: (PurseId, u64)| + #[trigger] post_view.coins.dom().contains(k) + <==> step_view.coins.dom().contains(k) + by { + } + assert forall|k: (PurseId, u64)| post_view.coins.dom().contains(k) + implies #[trigger] post_view.coins[k] == step_view.coins[k] + by { + if pre.coins().dom().contains(k) { + assert(post_view.coins[k] == pre.coins()[k]); + assert(step_view.coins[k] == pre.coins()[k]); + } else { + // k is in the new range; k.0 == p, k.1 in [base_idx, base_idx + n). + let j = (k.1 as int) - (base_idx as int); + assert(0 <= j < exp_seq.len()); + assert(k == (p, (base_idx + j) as u64)); + assert(post_view.coins[k] == (CoinRec { + purse: p, + idx: (base_idx + j) as u64, + exponent: exp_seq[j], + state: CoinState::Pending, + age: (base_age + j) as u64, + account: 0, + })); + } + } + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: bulk allocate `exp_seq.len()` recycler entries in `p` +/// with sequential indices `[base_idx, base_idx + n)`. Mirror of +/// `quint_step_top_up_purse` for entries. +pub open spec fn quint_step_reserve_entries( + pre: QuintViewState, + p: PurseId, + exp_seq: Seq, + base_idx: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_entry_idx == base_idx as nat, + (base_idx as nat) + exp_seq.len() <= u64::MAX as nat, +{ + QuintViewState { + entries: Map::new( + |k: (PurseId, u64)| + pre.entries.dom().contains(k) + || (k.0 == p + && (base_idx as int) <= (k.1 as int) + && (k.1 as int) < (base_idx as int) + exp_seq.len() as int), + |k: (PurseId, u64)| + if pre.entries.dom().contains(k) { + pre.entries[k] + } else { + let j = (k.1 as int) - (base_idx as int); + EntryRec { + purse: p, + idx: k.1, + exponent: exp_seq[j], + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key: 0, + allocated_at: 0, + ready_at: 0, + ring_idx: 0, + } + } + ), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx, + next_entry_idx: pre.purses[p].next_entry_idx + exp_seq.len(), + }), + ..pre + } +} + +proof fn lemma_reserve_entries_refines( + pre: State, + post: State, + p: PurseId, + exp_seq: Seq, + base_idx: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_entry_idx == base_idx as nat, + (base_idx as nat) + exp_seq.len() <= u64::MAX as nat, + forall|j: int| 0 <= j < exp_seq.len() ==> + (#[trigger] exp_seq[j]) <= MAX_EXPONENT, + post.invariant(), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx + exp_seq.len(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries().dom() =~= pre.entries().dom().union( + Set::new(|k: (PurseId, u64)| + k.0 == p + && (base_idx as int) <= (k.1 as int) + && (k.1 as int) < (base_idx as int) + exp_seq.len() as int) + ), + forall|k: (PurseId, u64)| #[trigger] pre.entries().dom().contains(k) + ==> post.entries()[k] == pre.entries()[k], + forall|j: int| 0 <= j < exp_seq.len() ==> + #[trigger] post.entries()[(p, (base_idx + j) as u64)] + == (EntryRec { + purse: p, + idx: (base_idx + j) as u64, + exponent: exp_seq[j], + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key: 0, + allocated_at: 0, + ready_at: 0, + ring_idx: 0, + }), + post.coins() == pre.coins(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_age == pre.next_age, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_reserve_entries( + quint_view(pre), p, exp_seq, base_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_reserve_entries(quint_view(pre), p, exp_seq, base_idx); + assert(post_view.purses =~= step_view.purses); + assert forall|k: (PurseId, u64)| post_view.entries.dom().contains(k) + implies #[trigger] post_view.entries[k] == step_view.entries[k] + by { + if pre.entries().dom().contains(k) { + assert(post_view.entries[k] == pre.entries()[k]); + assert(step_view.entries[k] == pre.entries()[k]); + } else { + let j = (k.1 as int) - (base_idx as int); + assert(0 <= j < exp_seq.len()); + assert(k == (p, (base_idx + j) as u64)); + } + } + assert(post_view.entries =~= step_view.entries); +} + +/// Quint analog: spend the source coin at `key`, then bulk-mint +/// `new_exponents.len()` Pending coins in the same purse. The two +/// mark_coin_* intermediate state transitions are hidden in the +/// composite delta. +pub open spec fn quint_step_split_coin( + pre: QuintViewState, + key: (PurseId, u64), + new_exponents: Seq, + base_idx: u64, + base_age: u64, +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Available, + pre.purses.dom().contains(key.0), + pre.purses[key.0].next_coin_idx == base_idx as nat, + (base_idx as nat) + new_exponents.len() <= u64::MAX as nat, + (base_age as nat) + new_exponents.len() <= u64::MAX as nat, +{ + let p = key.0; + let exp = pre.coins[key].exponent; + QuintViewState { + coins: Map::new( + |k: (PurseId, u64)| + pre.coins.dom().contains(k) + || (k.0 == p + && (base_idx as int) <= (k.1 as int) + && (k.1 as int) < (base_idx as int) + new_exponents.len() as int), + |k: (PurseId, u64)| + if k == key { + CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: exp, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Spent, + } + } else if pre.coins.dom().contains(k) { + pre.coins[k] + } else { + let j = (k.1 as int) - (base_idx as int); + CoinRec { + purse: p, + idx: k.1, + exponent: new_exponents[j], + state: CoinState::Pending, + age: ((base_age as int) + j) as u64, + account: 0, + } + } + ), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx + new_exponents.len(), + next_entry_idx: pre.purses[p].next_entry_idx, + }), + events: pre.events.push(Event::CoinSpent { + purse: p, + exponent: exp, + }), + ..pre + } +} + +proof fn lemma_split_coin_refines( + pre: State, + post: State, + key: (PurseId, u64), + new_exponents: Seq, + base_idx: u64, + base_age: u64, +) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Available, + pre.purses().dom().contains(key.0), + pre.purses()[key.0].next_coin_idx == base_idx as nat, + pre.next_age == base_age, + (base_idx as nat) + new_exponents.len() <= u64::MAX as nat, + (base_age as nat) + new_exponents.len() <= u64::MAX as nat, + pre.events@.len() < u64::MAX as nat, + forall|j: int| 0 <= j < new_exponents.len() ==> + (#[trigger] new_exponents[j]) <= MAX_EXPONENT, + post.invariant(), + post.coins()[key] == (CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Spent, + }), + forall|j: int| 0 <= j < new_exponents.len() ==> + #[trigger] post.coins()[(key.0, (base_idx + j) as u64)] + == (CoinRec { + purse: key.0, + idx: (base_idx + j) as u64, + exponent: new_exponents[j], + state: CoinState::Pending, + age: (base_age + j) as u64, + account: 0, + }), + post.coins().dom() =~= pre.coins().dom().union( + Set::new(|k: (PurseId, u64)| + k.0 == key.0 + && (base_idx as int) <= (k.1 as int) + && (k.1 as int) < (base_idx as int) + new_exponents.len() as int) + ), + forall|k: (PurseId, u64)| #[trigger] pre.coins().dom().contains(k) + && k != key + ==> post.coins()[k] == pre.coins()[k], + post.purses().dom() =~= pre.purses().dom(), + post.purses()[key.0].id == key.0, + post.purses()[key.0].name == pre.purses()[key.0].name, + post.purses()[key.0].next_coin_idx + == pre.purses()[key.0].next_coin_idx + new_exponents.len(), + post.purses()[key.0].next_entry_idx == pre.purses()[key.0].next_entry_idx, + forall|q: PurseId| q != key.0 && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@.push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins()[key].exponent, + }), + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_split_coin( + quint_view(pre), key, new_exponents, base_idx, base_age, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_split_coin( + quint_view(pre), key, new_exponents, base_idx, base_age, + ); + assert(post_view.purses =~= step_view.purses); + assert(post_view.events =~= step_view.events); + assert forall|k: (PurseId, u64)| post_view.coins.dom().contains(k) + implies #[trigger] post_view.coins[k] == step_view.coins[k] + by { + if k == key { + // Both maps put Spent record at key. + } else if pre.coins().dom().contains(k) { + assert(post_view.coins[k] == pre.coins()[k]); + assert(step_view.coins[k] == pre.coins()[k]); + } else { + let j = (k.1 as int) - (base_idx as int); + assert(0 <= j < new_exponents.len()); + assert(k == (key.0, (base_idx + j) as u64)); + } + } + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `start_op(Maintenance, key.0) ; split_coin(key, +/// new_exponents) ; mark_op_submitted(handle)`. Composes the bulk-mint +/// step with op lifecycle. +pub open spec fn quint_step_tracked_split_coin( + pre: QuintViewState, + key: (PurseId, u64), + new_exponents: Seq, + base_idx: u64, + base_age: u64, +) -> QuintViewState + recommends + pre.coins.dom().contains(key), + pre.coins[key].state == CoinState::Available, + pre.purses.dom().contains(key.0), + pre.purses[key.0].next_coin_idx == base_idx as nat, + (base_idx as nat) + new_exponents.len() <= u64::MAX as nat, + (base_age as nat) + new_exponents.len() <= u64::MAX as nat, + pre.next_handle < u64::MAX, +{ + let p = key.0; + let handle = pre.next_handle; + let exp = pre.coins[key].exponent; + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind: OpKind::Maintenance, + purse: p, + status: OpStatus::Submitted, + }), + coins: Map::new( + |k: (PurseId, u64)| + pre.coins.dom().contains(k) + || (k.0 == p + && (base_idx as int) <= (k.1 as int) + && (k.1 as int) < (base_idx as int) + new_exponents.len() as int), + |k: (PurseId, u64)| + if k == key { + CoinRec { + purse: pre.coins[key].purse, + idx: pre.coins[key].idx, + exponent: exp, + age: pre.coins[key].age, + account: pre.coins[key].account, + state: CoinState::Spent, + } + } else if pre.coins.dom().contains(k) { + pre.coins[k] + } else { + let j = (k.1 as int) - (base_idx as int); + CoinRec { + purse: p, + idx: k.1, + exponent: new_exponents[j], + state: CoinState::Pending, + age: ((base_age as int) + j) as u64, + account: 0, + } + } + ), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx + new_exponents.len(), + next_entry_idx: pre.purses[p].next_entry_idx, + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events + .push(Event::OperationStarted { + handle, + kind: OpKind::Maintenance, + purse: p, + }) + .push(Event::CoinSpent { purse: p, exponent: exp }) + .push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + ..pre + } +} + +proof fn lemma_tracked_split_coin_refines( + pre: State, + post: State, + key: (PurseId, u64), + new_exponents: Seq, + base_idx: u64, + base_age: u64, +) + requires + pre.invariant(), + pre.coins().dom().contains(key), + pre.coins()[key].state == CoinState::Available, + pre.purses().dom().contains(key.0), + pre.purses()[key.0].next_coin_idx == base_idx as nat, + pre.next_age == base_age, + (base_idx as nat) + new_exponents.len() <= u64::MAX as nat, + (base_age as nat) + new_exponents.len() <= u64::MAX as nat, + pre.next_handle < u64::MAX, + pre.events@.len() + 3 <= u64::MAX as nat, + forall|j: int| 0 <= j < new_exponents.len() ==> + (#[trigger] new_exponents[j]) <= MAX_EXPONENT, + post.invariant(), + post.coins()[key] == (CoinRec { + purse: pre.coins()[key].purse, + idx: pre.coins()[key].idx, + exponent: pre.coins()[key].exponent, + age: pre.coins()[key].age, + account: pre.coins()[key].account, + state: CoinState::Spent, + }), + forall|j: int| 0 <= j < new_exponents.len() ==> + #[trigger] post.coins()[(key.0, (base_idx + j) as u64)] + == (CoinRec { + purse: key.0, + idx: (base_idx + j) as u64, + exponent: new_exponents[j], + state: CoinState::Pending, + age: (base_age + j) as u64, + account: 0, + }), + post.coins().dom() =~= pre.coins().dom().union( + Set::new(|k: (PurseId, u64)| + k.0 == key.0 + && (base_idx as int) <= (k.1 as int) + && (k.1 as int) < (base_idx as int) + new_exponents.len() as int) + ), + forall|k: (PurseId, u64)| #[trigger] pre.coins().dom().contains(k) + && k != key + ==> post.coins()[k] == pre.coins()[k], + post.purses().dom() =~= pre.purses().dom(), + post.purses()[key.0].id == key.0, + post.purses()[key.0].name == pre.purses()[key.0].name, + post.purses()[key.0].next_coin_idx + == pre.purses()[key.0].next_coin_idx + new_exponents.len(), + post.purses()[key.0].next_entry_idx == pre.purses()[key.0].next_entry_idx, + forall|q: PurseId| q != key.0 && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.entries() == pre.entries(), + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind: OpKind::Maintenance, + purse: key.0, + status: OpStatus::Submitted, + }), + post.events@ == pre.events@ + .push(Event::OperationStarted { + handle: pre.next_handle, + kind: OpKind::Maintenance, + purse: key.0, + }) + .push(Event::CoinSpent { + purse: key.0, + exponent: pre.coins()[key].exponent, + }) + .push(Event::OperationProgress { + handle: pre.next_handle, + status: OpStatus::Submitted, + }), + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_tracked_split_coin( + quint_view(pre), key, new_exponents, base_idx, base_age, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_tracked_split_coin( + quint_view(pre), key, new_exponents, base_idx, base_age, + ); + assert(post_view.purses =~= step_view.purses); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); + assert forall|k: (PurseId, u64)| post_view.coins.dom().contains(k) + implies #[trigger] post_view.coins[k] == step_view.coins[k] + by { + if k == key { + } else if pre.coins().dom().contains(k) { + assert(post_view.coins[k] == pre.coins()[k]); + assert(step_view.coins[k] == pre.coins()[k]); + } else { + let j = (k.1 as int) - (base_idx as int); + assert(0 <= j < new_exponents.len()); + assert(k == (key.0, (base_idx + j) as u64)); + } + } + assert(post_view.coins =~= step_view.coins); +} + +/// Quint analog: `start_op(TopUp, p) ; top_up_via_entry(p, ...) ; +/// mark_op_submitted(handle)`. Three refinement steps composed. +pub open spec fn quint_step_tracked_top_up_via_entry( + pre: QuintViewState, + p: PurseId, + exponent: u8, + member_key: u64, + allocated_at: u64, + ready_at: u64, + ring_idx: u64, + new_idx: u64, +) -> QuintViewState + recommends + pre.purses.dom().contains(p), + pre.purses[p].next_entry_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_handle < u64::MAX, +{ + let handle = pre.next_handle; + let new_entry_key = (p, new_idx); + QuintViewState { + operations: pre.operations.insert(handle, OperationRec { + handle, + kind: OpKind::TopUp, + purse: p, + status: OpStatus::Submitted, + }), + entries: pre.entries.insert(new_entry_key, EntryRec { + purse: p, + idx: new_idx, + exponent, + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key, + allocated_at, + ready_at, + ring_idx, + }), + purses: pre.purses.insert(p, PurseRecSpec { + id: pre.purses[p].id, + name: pre.purses[p].name, + next_coin_idx: pre.purses[p].next_coin_idx, + next_entry_idx: pre.purses[p].next_entry_idx + 1, + }), + next_handle: (pre.next_handle + 1) as u64, + events: pre.events + .push(Event::OperationStarted { + handle, + kind: OpKind::TopUp, + purse: p, + }) + .push(Event::EntryAllocated { purse: p, exponent }) + .push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + ..pre + } +} + +proof fn lemma_tracked_top_up_via_entry_refines( + pre: State, + post: State, + p: PurseId, + exponent: u8, + member_key: u64, + allocated_at: u64, + ready_at: u64, + ring_idx: u64, + new_idx: u64, +) + requires + pre.invariant(), + pre.purses().dom().contains(p), + pre.purses()[p].next_entry_idx == new_idx as nat, + (new_idx as nat) < u64::MAX as nat, + pre.next_handle < u64::MAX, + pre.events@.len() + 3 <= u64::MAX as nat, + exponent <= MAX_EXPONENT, + post.invariant(), + post.entries() == pre.entries().insert((p, new_idx), EntryRec { + purse: p, + idx: new_idx, + exponent, + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key, + allocated_at, + ready_at, + ring_idx, + }), + post.coins() == pre.coins(), + post.purses().dom() =~= pre.purses().dom(), + post.purses()[p].id == p, + post.purses()[p].name == pre.purses()[p].name, + post.purses()[p].next_coin_idx == pre.purses()[p].next_coin_idx, + post.purses()[p].next_entry_idx == pre.purses()[p].next_entry_idx + 1, + forall|q: PurseId| q != p && #[trigger] pre.purses().dom().contains(q) + ==> post.purses()[q] == pre.purses()[q], + post.operations() == pre.operations().insert(pre.next_handle, OperationRec { + handle: pre.next_handle, + kind: OpKind::TopUp, + purse: p, + status: OpStatus::Submitted, + }), + post.events@ == pre.events@ + .push(Event::OperationStarted { + handle: pre.next_handle, + kind: OpKind::TopUp, + purse: p, + }) + .push(Event::EntryAllocated { purse: p, exponent }) + .push(Event::OperationProgress { + handle: pre.next_handle, + status: OpStatus::Submitted, + }), + post.next_age == pre.next_age, + post.next_handle == pre.next_handle + 1, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_tracked_top_up_via_entry( + quint_view(pre), p, exponent, + member_key, allocated_at, ready_at, ring_idx, new_idx, + ), +{ + let post_view = quint_view(post); + let step_view = quint_step_tracked_top_up_via_entry( + quint_view(pre), p, exponent, + member_key, allocated_at, ready_at, ring_idx, new_idx, + ); + assert(post_view.entries =~= step_view.entries); + assert(post_view.purses =~= step_view.purses); + assert(post_view.operations =~= step_view.operations); + assert(post_view.events =~= step_view.events); +} + +/// Quint analog: `purses' = purses.put(new_id, {id, name, 0, 0})`. +/// Note: Quint createPurse also emits `EPurseCreated`; the Verus +/// implementation deliberately doesn't (the pilot scheme treats purse +/// creation as silent). This refinement lemma covers the state delta; +/// the event divergence is a known correspondence gap, not a bug. +pub open spec fn quint_step_create_purse( + pre: QuintViewState, + new_id: PurseId, + name: Seq, +) -> QuintViewState + recommends + !pre.purses.dom().contains(new_id), + new_id != MAIN_PURSE, +{ + QuintViewState { + purses: pre.purses.insert(new_id, PurseRecSpec { + id: new_id, + name, + next_coin_idx: 0, + next_entry_idx: 0, + }), + ..pre + } +} + +proof fn lemma_create_purse_refines( + pre: State, + post: State, + name: Seq, + new_id: PurseId, +) + requires + pre.invariant(), + pre.has_create_capacity(), + new_id != MAIN_PURSE, + !pre.purses().dom().contains(new_id), + post.purses() == pre.purses().insert(new_id, PurseRecSpec { + id: new_id, + name, + next_coin_idx: 0, + next_entry_idx: 0, + }), + post.coins() == pre.coins(), + post.entries() == pre.entries(), + post.operations() == pre.operations(), + post.events@ == pre.events@, + post.next_handle == pre.next_handle, + post.next_extrinsic_id == pre.next_extrinsic_id, + post.total_in == pre.total_in, + post.total_out == pre.total_out, + post.fee_balance == pre.fee_balance, + post.paid_ring_membership == pre.paid_ring_membership, + post.tokens@ == pre.tokens@, + post.chain_coins@ == pre.chain_coins@, + post.chain_entries@ == pre.chain_entries@, + ensures + quint_view(post) == quint_step_create_purse(quint_view(pre), new_id, name), +{ + let post_view = quint_view(post); + let step_view = quint_step_create_purse(quint_view(pre), new_id, name); + assert(post_view.purses =~= step_view.purses); +} + +// ========================================================================== +// Findings from the refinement attempt — primitives whose contracts are +// too loose to refine without strengthening: +// +// - ~~`create_purse`~~: contract strengthened with full preservation +// clauses; refined via lemma_create_purse_refines above. +// - `add_coin_with_account` / `add_entry_with_meta`: pre-cascade contracts. +// Cover most preservation but miss `next_extrinsic_id`, `total_in`, +// `total_out`, `fee_balance`, `paid_ring_membership`, `tokens@`, +// `chain_coins@`, `chain_entries@`. The implementations DO preserve these +// (their bodies don't touch them), but the contracts don't say so. +// - `top_up_fee_account`, `deduct_fee`: contracts mention `fee_balance` +// but omit `events`, `total_*`, `tokens`, `chain_*`, `paid_ring_membership` +// preservation. +// - `mint_token`, `consume_token`: contracts focus on `tokens@` mutation +// but skip preservation clauses for ~10 other fields. +// +// These are real correspondence gaps. The implementations DO preserve +// the un-mentioned fields (their bodies only touch the named ones), but +// the contracts don't say so, so callers can't reason about preservation +// and refinement step-lemmas can't be discharged. +// +// Strengthening these contracts is mechanical (~10 lines per primitive) +// and would unblock the corresponding step lemmas. Deferred from this +// PoC because the methodology is already demonstrated — closing the gaps +// is mechanical contract editing, not a verification challenge. +// ========================================================================== + +} // verus! diff --git a/rust/crates/coinage-layer/src/spec_helpers.rs b/rust/crates/coinage-layer/src/spec_helpers.rs new file mode 100644 index 00000000..4e3cca5d --- /dev/null +++ b/rust/crates/coinage-layer/src/spec_helpers.rs @@ -0,0 +1,392 @@ +//! Top-level spec functions. +//! +//! - Lock-handle extractors: [`coin_lock_handle`], [`entry_lock_handle`]. +//! - Lock-counting predicates: [`count_coin_locks_in_vec`], +//! [`count_entry_locks_in_vec`]. +//! - Cross-state lock referential integrity: [`lock_refint`]. +//! - Op-status classifiers: [`is_terminal_op_status`], +//! [`is_cancellable_op_status`], [`is_mid_op_status`]. +//! - Payment-memo helpers: [`count_matched_memos`], +//! [`classify_incoming_payment`]. +//! - Coin/entry §6.3 priority orders: [`coin_priority_lt`], +//! [`entry_priority_lt`]. +//! - Prefix-sum aggregators (used by exec-side aggregator +//! implementations): the `sum_*_prefix` family + [`sum_of_coin_values`]. +//! - The `2^exp` coin-value spec family: [`coin_value`], [`pow2_nat`], +//! [`coin_value_pow2`]. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +/// Spec helper: extract the lock handle from a coin's state, if any. +/// Returns `Some(h)` for `LockedFor(h)`, `None` otherwise. Avoids +/// match-bound variables in proof contexts — see Phase 1d note in +/// project memory. +pub open spec fn coin_lock_handle(state: CoinState) -> Option { + match state { + CoinState::LockedFor(h) => Some(h), + _ => None, + } +} + +/// Spec-only: count the number of Vec coins currently `LockedFor(handle)` +/// within the prefix `v[0..j]`. Used as a decreases measure for +/// bulk-sweep loops. +pub open spec fn count_coin_locks_in_vec( + v: Seq, + handle: OpHandle, + j: nat, +) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = count_coin_locks_in_vec(v, handle, (j - 1) as nat); + if v[(j - 1) as int].state == CoinState::LockedFor(handle) { + prev + 1 + } else { + prev + } + } +} + +/// Spec-only: count the number of Vec entries currently +/// `LocalLockedFor(handle)` within the prefix `v[0..j]`. +pub open spec fn count_entry_locks_in_vec( + v: Seq, + handle: OpHandle, + j: nat, +) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = count_entry_locks_in_vec(v, handle, (j - 1) as nat); + if v[(j - 1) as int].local == EntryLocal::LocalLockedFor(handle) { + prev + 1 + } else { + prev + } + } +} + +/// Spec helper: extract the lock handle from an entry's local state, +/// if any. Returns `Some(h)` for `LocalLockedFor(h)`, `None` otherwise. +pub open spec fn entry_lock_handle(local: EntryLocal) -> Option { + match local { + EntryLocal::LocalLockedFor(h) => Some(h), + _ => None, + } +} + +/// Cross-state lock referential integrity (Phase 1d-deferred +/// invariant). Every coin in `LockedFor(h)` references an existing +/// operation `h`; same for every entry in `LocalLockedFor(h)`. +/// +/// Not part of the State's main `invariant()` predicate — that would +/// cascade through every method's proof. Instead this is an *opt-in* +/// predicate that callers can preserve themselves and pass as a +/// precondition to primitives that need it (e.g. a future bulk-sweep +/// `cancel_op` that wants to assert "after release, no LockedFor(h) +/// references h"). +pub open spec fn lock_refint( + coins: Map<(PurseId, u64), CoinRec>, + entries: Map<(PurseId, u64), EntryRec>, + operations: Map, +) -> bool { + (forall|k: (PurseId, u64)| + #[trigger] coins.dom().contains(k) + ==> { + let h_opt = coin_lock_handle(coins[k].state); + h_opt.is_none() || operations.dom().contains(h_opt.unwrap()) + }) + && (forall|k: (PurseId, u64)| + #[trigger] entries.dom().contains(k) + ==> { + let h_opt = entry_lock_handle(entries[k].local); + h_opt.is_none() || operations.dom().contains(h_opt.unwrap()) + }) +} + +/// True iff `status` is a terminal op state (no further transitions +/// follow). Quint `isTerminal`. +pub open spec fn is_terminal_op_status(status: OpStatus) -> bool { + match status { + OpStatus::Done => true, + OpStatus::Failed => true, + _ => false, + } +} + +/// True iff an op in `status` can transition to `Failed` via +/// `set_op_failed`. Mirrors the Quint `isCancellable` predicate. +pub open spec fn is_cancellable_op_status(status: OpStatus) -> bool { + match status { + OpStatus::Preparing => true, + OpStatus::Waiting(_) => true, + _ => false, + } +} + +/// True iff `status` is a mid-flight chain state (extrinsic in transit +/// or just landed). Quint `isMid`. +pub open spec fn is_mid_op_status(status: OpStatus) -> bool { + match status { + OpStatus::Submitted => true, + OpStatus::InBlock => true, + OpStatus::Finalized => true, + _ => false, + } +} + +/// Spec-only: count memos whose `recipient_account` matches the +/// account of some coin in the global coin map. Used by +/// [`classify_incoming_payment`] to decide between Matched / Received +/// / Unmatched. +pub open spec fn count_matched_memos( + memos: Seq, + coins: Map<(PurseId, u64), CoinRec>, + j: nat, +) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = count_matched_memos(memos, coins, (j - 1) as nat); + let m = memos[(j - 1) as int]; + if exists|k: (PurseId, u64)| + #[trigger] coins.dom().contains(k) + && coins[k].account == m.recipient_account + { + prev + 1 + } else { + prev + } + } +} + +/// Synchronous classification of an incoming chain payment (Quint +/// `classifyIncomingPayment`, §8.8). Returns: +/// - `Unmatched` if `memos` is empty or no memo matches a local coin. +/// - `Matched` if every memo matches a local coin. +/// - `Received` if some but not all memos match. +pub open spec fn classify_incoming_payment( + memos: Seq, + coins: Map<(PurseId, u64), CoinRec>, +) -> PaymentClassification { + let n = memos.len(); + let matched = count_matched_memos(memos, coins, n); + if n == 0 { + PaymentClassification::Unmatched + } else if matched == 0 { + PaymentClassification::Unmatched + } else if matched == n { + PaymentClassification::Matched + } else { + PaymentClassification::Received + } +} + +/// Spec-only coin value. **Pilot scheme: `coin_value(exp) = exp + 1`** +/// — linear, monotone in `exp`, no overflow under any realistic `Vec` +/// size. Real semantics is `2^exp` (Quint `coinValue`); the spec for +/// that is `coin_value_pow2` below, kept parallel so the protocol's +/// design-faithful value model is documented even while the exec +/// arithmetic uses the pilot scheme. Switching exec to real `2^exp` +/// requires bounded-exponent invariants + saturating-`u64` (or `u128`) +/// arithmetic plumbing; tracked as a dedicated future stage. +pub open spec fn coin_value(exp: u8) -> nat { + pow2_nat(exp as nat) +} + +/// Recursive `2^exp` over `nat`. Used by `coin_value_pow2`. +pub open spec fn pow2_nat(exp: nat) -> nat + decreases exp +{ + if exp == 0 { 1 } else { 2 * pow2_nat((exp - 1) as nat) } +} + +/// Spec-only **real** coin value (Quint `coinValue`). `2^exp` per the +/// design. Available as a parallel definition; not yet wired to the +/// exec arithmetic. +pub open spec fn coin_value_pow2(exp: u8) -> nat { + pow2_nat(exp as nat) +} + +/// Lexicographic priority comparison for two coins (Quint §6.3 +/// `coinOrderLT`). Returns true if `a` has *higher* priority than `b` +/// (smaller rank tuple). The rank tuple is `(MaxExp - exp, MaxAge - age, +/// idx)` — bigger exponent wins, then older (smaller age), then +/// smaller idx as tiebreaker. +pub open spec fn coin_priority_lt(a: CoinRec, b: CoinRec) -> bool { + a.exponent > b.exponent + || (a.exponent == b.exponent && a.age < b.age) + || (a.exponent == b.exponent && a.age == b.age && a.idx < b.idx) +} + +/// Lexicographic priority comparison for two entries (Quint §6.3 +/// `entryOrderLT`). Returns true if `a` has *higher* priority than +/// `b`. The rank tuple is `(MaxExp - exp, ring_idx, idx)` — bigger +/// exponent wins, then smaller ring_idx, then smaller idx. +pub open spec fn entry_priority_lt(a: EntryRec, b: EntryRec) -> bool { + a.exponent > b.exponent + || (a.exponent == b.exponent && a.ring_idx < b.ring_idx) + || (a.exponent == b.exponent && a.ring_idx == b.ring_idx + && a.idx < b.idx) +} + +/// Spec-only recursive sum: total spendable value across `v[0..j]` +/// among coins that are `Available` and belong to purse `p`. +pub open spec fn sum_avail_prefix(v: Seq, p: PurseId, j: nat) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = sum_avail_prefix(v, p, (j - 1) as nat); + if v[(j - 1) as int].purse == p + && v[(j - 1) as int].state == CoinState::Available + { + prev + coin_value(v[(j - 1) as int].exponent) + } else { + prev + } + } +} + +/// Spec-only recursive sum: total spendable value across `v[0..j]` +/// using the **real** Quint coin value `2^exp` (Quint `coinValue`). +/// Companion to `sum_avail_prefix` (pilot scheme). +pub open spec fn sum_avail_real_prefix(v: Seq, p: PurseId, j: nat) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = sum_avail_real_prefix(v, p, (j - 1) as nat); + if v[(j - 1) as int].purse == p + && v[(j - 1) as int].state == CoinState::Available + { + prev + coin_value_pow2(v[(j - 1) as int].exponent) + } else { + prev + } + } +} + +/// Spec-only recursive sum: total pending entry value across `v[0..j]` +/// among entries that belong to purse `p`, are `LocalAvailable`, and +/// are either `Waiting` or `Missing` on-chain (Quint `pursePending`). +pub open spec fn sum_pending_prefix(v: Seq, p: PurseId, j: nat) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = sum_pending_prefix(v, p, (j - 1) as nat); + let e = v[(j - 1) as int]; + if e.purse == p + && e.local == EntryLocal::LocalAvailable + && (e.on_chain == EntryOnChain::Waiting + || e.on_chain == EntryOnChain::Missing) + { + prev + coin_value(e.exponent) + } else { + prev + } + } +} + +/// Real-value (2^exp) variant of [`sum_pending_prefix`]. +pub open spec fn sum_pending_real_prefix(v: Seq, p: PurseId, j: nat) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = sum_pending_real_prefix(v, p, (j - 1) as nat); + let e = v[(j - 1) as int]; + if e.purse == p + && e.local == EntryLocal::LocalAvailable + && (e.on_chain == EntryOnChain::Waiting + || e.on_chain == EntryOnChain::Missing) + { + prev + coin_value_pow2(e.exponent) + } else { + prev + } + } +} + +/// Spec-only recursive sum: total ready entry value across `v[0..j]` +/// among entries that belong to purse `p`, are `LocalAvailable`, and +/// are `Ready` on-chain. Used by the strict-spendable aggregation +/// (Quint `purseSpendableStrict`'s entry component). +pub open spec fn sum_ready_prefix(v: Seq, p: PurseId, j: nat) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = sum_ready_prefix(v, p, (j - 1) as nat); + let e = v[(j - 1) as int]; + if e.purse == p + && e.local == EntryLocal::LocalAvailable + && e.on_chain == EntryOnChain::Ready + { + prev + coin_value(e.exponent) + } else { + prev + } + } +} + +/// Real-value (2^exp) variant of [`sum_ready_prefix`]. +pub open spec fn sum_ready_real_prefix(v: Seq, p: PurseId, j: nat) -> nat + decreases j +{ + if j == 0 { + 0 + } else { + let prev = sum_ready_real_prefix(v, p, (j - 1) as nat); + let e = v[(j - 1) as int]; + if e.purse == p + && e.local == EntryLocal::LocalAvailable + && e.on_chain == EntryOnChain::Ready + { + prev + coin_value_pow2(e.exponent) + } else { + prev + } + } +} + +/// Spec-only sum of coin values across a sequence of keys, looked up +/// in the coin map. Used to describe selection results. +pub open spec fn sum_of_coin_values( + coins: Map<(PurseId, u64), CoinRec>, + keys: Seq<(PurseId, u64)>, +) -> nat + decreases keys.len() +{ + if keys.len() == 0 { + 0 + } else { + let last_idx = (keys.len() - 1) as int; + let last_key = keys[last_idx]; + let rest = sum_of_coin_values(coins, keys.subrange(0, last_idx)); + if coins.dom().contains(last_key) { + rest + coin_value(coins[last_key].exponent) + } else { + rest + } + } +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_accumulators.rs b/rust/crates/coinage-layer/src/state_accumulators.rs new file mode 100644 index 00000000..662f286b --- /dev/null +++ b/rust/crates/coinage-layer/src/state_accumulators.rs @@ -0,0 +1,214 @@ +//! Accumulators (`total_in`, `total_out`, `paid_ring_membership`) and the extrinsic-id allocator. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Increment `total_in` by `amount` (Quint accumulator advance on + /// inflow: top-up, import). + pub fn add_total_in(&mut self, amount: u64) + requires + old(self).invariant(), + old(self).total_in <= u64::MAX - amount, + ensures + final(self).invariant(), + final(self).total_in == old(self).total_in + amount, + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + let ghost old_events = self.events@; + self.total_in = self.total_in + amount; + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + assert(self.events@ == old_events); + } + } + + + /// Increment `total_out` by `amount` (Quint accumulator advance on + /// outflow: export, cross-host transfer-out). + pub fn add_total_out(&mut self, amount: u64) + requires + old(self).invariant(), + old(self).total_out <= u64::MAX - amount, + ensures + final(self).invariant(), + final(self).total_out == old(self).total_out + amount, + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + let ghost old_events = self.events@; + self.total_out = self.total_out + amount; + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + assert(self.events@ == old_events); + } + } + + + /// Read total_in. + pub fn read_total_in(&self) -> (v: u64) + requires self.invariant(), + ensures v == self.total_in, + { self.total_in } + + + /// Read total_out. + pub fn read_total_out(&self) -> (v: u64) + requires self.invariant(), + ensures v == self.total_out, + { self.total_out } + + + /// Read paid_ring_membership. + pub fn read_paid_ring_membership(&self) -> (v: u64) + requires self.invariant(), + ensures v == self.paid_ring_membership, + { self.paid_ring_membership } + + + /// Allocate a fresh chain-extrinsic ID and bump the allocator. + /// Quint `nextExtrinsicId`. Called by chain-bound op submission + /// to identify the corresponding chain extrinsic for receipt + /// matching. + pub fn alloc_extrinsic_id(&mut self) -> (id: u64) + requires + old(self).invariant(), + old(self).next_extrinsic_id < u64::MAX, + ensures + final(self).invariant(), + id == old(self).next_extrinsic_id, + final(self).next_extrinsic_id == old(self).next_extrinsic_id + 1, + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + let id = self.next_extrinsic_id; + self.next_extrinsic_id = id + 1; + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + } + id + } + + + /// Synchronous read of `next_extrinsic_id` (the next allocator value). + pub fn read_next_extrinsic_id(&self) -> (id: u64) + requires + self.invariant(), + ensures + id == self.next_extrinsic_id, + { + self.next_extrinsic_id + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_aggregators.rs b/rust/crates/coinage-layer/src/state_aggregators.rs new file mode 100644 index 00000000..39db42cd --- /dev/null +++ b/rust/crates/coinage-layer/src/state_aggregators.rs @@ -0,0 +1,578 @@ +//! Aggregators: count, sum, total, lock-count, in-flight helpers. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Count of coins currently `LockedFor(handle)` across the whole + /// state. Useful for diagnostics ("how much is reserved by this + /// in-flight op?") and for callers driving bulk-sweep loops + /// host-side. + pub fn coin_count_for_handle(&self, handle: OpHandle) -> (count: usize) + requires + self.invariant(), + ensures + count as nat == count_coin_locks_in_vec(self.coins@, handle, self.coins@.len() as nat), + count <= self.coins@.len(), + { + let mut c: usize = 0; + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + c <= j, + self.invariant(), + c as nat == count_coin_locks_in_vec(self.coins@, handle, j as nat), + decreases self.coins.len() - j, + { + let is_locked_for = match self.coins[j].state { + CoinState::LockedFor(h) => h == handle, + _ => false, + }; + if is_locked_for { + c = c + 1; + } + j = j + 1; + } + c + } + + + /// Count of entries currently `LocalLockedFor(handle)` across the + /// whole state. Mirror of `coin_count_for_handle` for the entry + /// side. + pub fn entry_count_for_handle(&self, handle: OpHandle) -> (count: usize) + requires + self.invariant(), + ensures + count as nat == count_entry_locks_in_vec(self.entries@, handle, self.entries@.len() as nat), + count <= self.entries@.len(), + { + let mut c: usize = 0; + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + c <= j, + self.invariant(), + c as nat == count_entry_locks_in_vec(self.entries@, handle, j as nat), + decreases self.entries.len() - j, + { + let is_locked_for = match self.entries[j].local { + EntryLocal::LocalLockedFor(h) => h == handle, + _ => false, + }; + if is_locked_for { + c = c + 1; + } + j = j + 1; + } + c + } + + + /// Count of operations currently in-flight (non-terminal status). + pub fn op_count_in_flight(&self) -> (count: usize) + requires + self.invariant(), + ensures + count <= self.operations@.len(), + { + let mut c: usize = 0; + let mut j: usize = 0; + while j < self.operations.len() + invariant + 0 <= j <= self.operations.len(), + c <= j, + self.invariant(), + decreases self.operations.len() - j, + { + let op = &self.operations[j]; + let is_terminal = match op.status { + OpStatus::Done => true, + OpStatus::Failed => true, + _ => false, + }; + if !is_terminal { + c = c + 1; + } + j = j + 1; + } + c + } + + + /// Count of all coins (any state) in purse `p`. Useful diagnostic + /// for "how cluttered is this purse?". Distinguish from + /// coin_count_available which excludes locked/spent/pending. + pub fn coin_count_in_purse(&self, p: PurseId) -> (count: usize) + requires + self.invariant(), + ensures + count <= self.coins@.len(), + { + let mut c: usize = 0; + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + c <= j, + self.invariant(), + decreases self.coins.len() - j, + { + if self.coins[j].purse == p { + c = c + 1; + } + j = j + 1; + } + c + } + + + /// Count of all entries (any state) in purse `p`. Entry parallel + /// of `coin_count_in_purse`. + pub fn entry_count_in_purse(&self, p: PurseId) -> (count: usize) + requires + self.invariant(), + ensures + count <= self.entries@.len(), + { + let mut c: usize = 0; + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + c <= j, + self.invariant(), + decreases self.entries.len() - j, + { + if self.entries[j].purse == p { + c = c + 1; + } + j = j + 1; + } + c + } + + + /// Count of `Available` coins in purse `p`. Used by maintenance + /// triggers — e.g. "if coin_count_available(p) > threshold, run + /// rebalance to consolidate into fewer larger coins". + pub fn coin_count_available(&self, p: PurseId) -> (count: usize) + requires + self.invariant(), + ensures + count <= self.coins@.len(), + { + let mut c: usize = 0; + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + c <= j, + self.invariant(), + decreases self.coins.len() - j, + { + let is_avail = matches!(self.coins[j].state, CoinState::Available); + if self.coins[j].purse == p && is_avail { + c = c + 1; + } + j = j + 1; + } + c + } + + + /// Count of selectable entries (Ready + LocalAvailable) in purse + /// `p`. Used by maintenance triggers and §6.3 selection feasibility + /// checks. + pub fn entry_count_selectable(&self, p: PurseId) -> (count: usize) + requires + self.invariant(), + ensures + count <= self.entries@.len(), + { + let mut c: usize = 0; + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + c <= j, + self.invariant(), + decreases self.entries.len() - j, + { + let e = &self.entries[j]; + let is_ready = matches!(e.on_chain, EntryOnChain::Ready); + let is_local_avail = matches!(e.local, EntryLocal::LocalAvailable); + if e.purse == p && is_ready && is_local_avail { + c = c + 1; + } + j = j + 1; + } + c + } + + + /// Number of purses in the state. + pub fn total_purses(&self) -> (count: usize) + requires + self.invariant(), + ensures + count == self.purses@.len(), + { + self.purses.len() + } + + + /// Number of coins (across all states and purses) in the state. + pub fn total_coins(&self) -> (count: usize) + requires + self.invariant(), + ensures + count == self.coins@.len(), + { + self.coins.len() + } + + + /// Number of recycler entries (across all states and purses). + pub fn total_entries(&self) -> (count: usize) + requires + self.invariant(), + ensures + count == self.entries@.len(), + { + self.entries.len() + } + + + /// Number of operations (terminal or in-flight) in the state. + pub fn total_operations(&self) -> (count: usize) + requires + self.invariant(), + ensures + count == self.operations@.len(), + { + self.operations.len() + } + + + /// Sum of `coin_value(exp)` across entries in purse `p` that are + /// LocalAvailable and Ready on-chain. Quint analog: the entry + /// component of `purseSpendableStrict(p)`. + pub(crate) fn sum_ready_in(&self, p: PurseId) -> (sum: u64) + requires + self.invariant(), + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + ensures + sum as nat == sum_ready_prefix(self.entries@, p, self.entries@.len() as nat), + sum as nat <= self.entries@.len() as nat * 1073741824, + { + let mut sum: u64 = 0; + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + self.invariant(), + sum as nat == sum_ready_prefix(self.entries@, p, j as nat), + sum as nat <= (j as nat) * 1073741824, + decreases self.entries.len() - j, + { + let e = &self.entries[j]; + let is_local_avail = matches!(e.local, EntryLocal::LocalAvailable); + let is_ready = matches!(e.on_chain, EntryOnChain::Ready); + proof { + let entry_key = (self.entries@[j as int].purse, + self.entries@[j as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.spec_entries@[entry_key] == self.entries@[j as int]); + assert(self.entries@[j as int].exponent <= MAX_EXPONENT); + lemma_pow2_at_30(); + lemma_pow2_monotone(self.entries@[j as int].exponent as nat, + MAX_EXPONENT as nat); + assert(sum_ready_prefix(self.entries@, p, (j + 1) as nat) + <= sum_ready_prefix(self.entries@, p, j as nat) + 1073741824); + } + if e.purse == p && is_local_avail && is_ready { + let value: u64 = pow2_u64_exec(e.exponent); + sum = sum + value; + } + j = j + 1; + } + sum + } + + + /// Sum of `coin_value(exp)` across entries in purse `p` that are + /// LocalAvailable and on-chain in {Waiting, Missing} — i.e. pending + /// recycler-floor confirmation. Quint analog: `pursePending(p)`. + pub(crate) fn sum_pending_in(&self, p: PurseId) -> (sum: u64) + requires + self.invariant(), + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + ensures + sum as nat == sum_pending_prefix(self.entries@, p, self.entries@.len() as nat), + { + let mut sum: u64 = 0; + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + self.invariant(), + sum as nat == sum_pending_prefix(self.entries@, p, j as nat), + sum as nat <= (j as nat) * 1073741824, + decreases self.entries.len() - j, + { + let e = &self.entries[j]; + let is_local_avail = matches!(e.local, EntryLocal::LocalAvailable); + let is_waiting = matches!(e.on_chain, EntryOnChain::Waiting); + let is_missing = matches!(e.on_chain, EntryOnChain::Missing); + proof { + let entry_key = (self.entries@[j as int].purse, + self.entries@[j as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.spec_entries@[entry_key] == self.entries@[j as int]); + assert(self.entries@[j as int].exponent <= MAX_EXPONENT); + lemma_pow2_at_30(); + lemma_pow2_monotone(self.entries@[j as int].exponent as nat, + MAX_EXPONENT as nat); + assert(sum_pending_prefix(self.entries@, p, (j + 1) as nat) + <= sum_pending_prefix(self.entries@, p, j as nat) + 1073741824); + } + if e.purse == p && is_local_avail && (is_waiting || is_missing) { + let value: u64 = pow2_u64_exec(e.exponent); + sum = sum + value; + } + j = j + 1; + } + sum + } + + + /// Real-value (2^exp) variant of `sum_pending_in`. Used by callers + /// that want production-scheme purse-pending totals. + pub fn sum_pending_real_in(&self, p: PurseId) -> (sum: u64) + requires + self.invariant(), + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + ==> self.entries()[k].exponent <= MAX_EXPONENT, + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + ensures + sum as nat == sum_pending_real_prefix(self.entries@, p, + self.entries@.len() as nat), + sum as nat <= self.entries@.len() as nat * 1073741824, + { + let mut sum: u64 = 0; + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + sum as nat == sum_pending_real_prefix(self.entries@, p, j as nat), + sum as nat <= (j as nat) * 1073741824, + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + ==> self.entries()[k].exponent <= MAX_EXPONENT, + self.invariant(), + decreases self.entries.len() - j, + { + let e = &self.entries[j]; + let is_local_avail = matches!(e.local, EntryLocal::LocalAvailable); + let is_waiting = matches!(e.on_chain, EntryOnChain::Waiting); + let is_missing = matches!(e.on_chain, EntryOnChain::Missing); + proof { + let entry_key = (self.entries@[j as int].purse, + self.entries@[j as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.entries()[entry_key] == self.entries@[j as int]); + assert(self.entries()[entry_key].exponent <= MAX_EXPONENT); + assert(self.entries@[j as int].exponent <= MAX_EXPONENT); + lemma_pow2_at_30(); + lemma_pow2_monotone(self.entries@[j as int].exponent as nat, 30); + assert(sum_pending_real_prefix(self.entries@, p, (j + 1) as nat) + <= sum_pending_real_prefix(self.entries@, p, j as nat) + 1073741824); + } + if e.purse == p && is_local_avail && (is_waiting || is_missing) { + let value = pow2_u64_exec(e.exponent); + sum = sum + value; + } + j = j + 1; + } + sum + } + + + /// Real-value (2^exp) variant of `sum_ready_in`. + pub fn sum_ready_real_in(&self, p: PurseId) -> (sum: u64) + requires + self.invariant(), + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + ==> self.entries()[k].exponent <= MAX_EXPONENT, + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + ensures + sum as nat == sum_ready_real_prefix(self.entries@, p, + self.entries@.len() as nat), + sum as nat <= self.entries@.len() as nat * 1073741824, + { + let mut sum: u64 = 0; + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + sum as nat == sum_ready_real_prefix(self.entries@, p, j as nat), + sum as nat <= (j as nat) * 1073741824, + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + ==> self.entries()[k].exponent <= MAX_EXPONENT, + self.invariant(), + decreases self.entries.len() - j, + { + let e = &self.entries[j]; + let is_local_avail = matches!(e.local, EntryLocal::LocalAvailable); + let is_ready = matches!(e.on_chain, EntryOnChain::Ready); + proof { + let entry_key = (self.entries@[j as int].purse, + self.entries@[j as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.entries()[entry_key] == self.entries@[j as int]); + assert(self.entries()[entry_key].exponent <= MAX_EXPONENT); + assert(self.entries@[j as int].exponent <= MAX_EXPONENT); + lemma_pow2_at_30(); + lemma_pow2_monotone(self.entries@[j as int].exponent as nat, 30); + assert(sum_ready_real_prefix(self.entries@, p, (j + 1) as nat) + <= sum_ready_real_prefix(self.entries@, p, j as nat) + 1073741824); + } + if e.purse == p && is_local_avail && is_ready { + let value = pow2_u64_exec(e.exponent); + sum = sum + value; + } + j = j + 1; + } + sum + } + + + /// Sum of **real** `coin_value_pow2(exp) = 2^exp` across `Available` + /// coins in purse `p`. Companion to `sum_available_in` (pilot scheme). + /// Returned sum equals `sum_avail_real_prefix(self.coins@, p, len)`. + /// + /// Preconditions: + /// - Every coin in the state has `exponent <= MAX_EXPONENT` (= 30), + /// so each coin value <= 2^30. + /// - Vec length bounded so the cumulative u64 sum (≤ len · 2^30) + /// stays within u64::MAX. + pub fn sum_available_real_in(&self, p: PurseId) -> (sum: u64) + requires + self.invariant(), + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + ==> self.coins()[k].exponent <= MAX_EXPONENT, + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + ensures + sum as nat == sum_avail_real_prefix(self.coins@, p, self.coins@.len() as nat), + sum as nat <= self.coins@.len() as nat * 1073741824, + { + let mut sum: u64 = 0; + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + sum as nat == sum_avail_real_prefix(self.coins@, p, j as nat), + sum as nat <= (j as nat) * 1073741824, + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + ==> self.coins()[k].exponent <= MAX_EXPONENT, + self.invariant(), + decreases self.coins.len() - j, + { + let is_available = matches!(self.coins[j].state, CoinState::Available); + proof { + // Per-step increment is at most 2^30, bounded by the + // global exponent constraint via invariant (l). + assert(self.spec_coins@.dom().contains( + (self.coins@[j as int].purse, self.coins@[j as int].idx) + )); + let coin_key = (self.coins@[j as int].purse, self.coins@[j as int].idx); + assert(self.coins()[coin_key].exponent + == self.coins@[j as int].exponent); + assert(self.coins()[coin_key].exponent <= MAX_EXPONENT); + assert(self.coins@[j as int].exponent <= MAX_EXPONENT); + lemma_pow2_at_30(); + lemma_pow2_monotone(self.coins@[j as int].exponent as nat, 30); + assert(sum_avail_real_prefix(self.coins@, p, (j + 1) as nat) + <= sum_avail_real_prefix(self.coins@, p, j as nat) + 1073741824); + } + if self.coins[j].purse == p && is_available { + let value: u64 = pow2_u64_exec(self.coins[j].exponent); + sum = sum + value; + } + j = j + 1; + } + sum + } + + + /// Sum of `coin_value(exp)` across `Available` coins in purse `p`. + /// Scans the coin Vec; returned sum equals `sum_avail_prefix(self.coins@, + /// p, len)`. + /// + /// **Pilot value scheme:** `coin_value(exp) = exp + 1` (linear). Real + /// `coinValue(exp) = 2^exp` is deferred. Precondition bounds Vec size to + /// keep the cumulative `u64` sum safe. + pub(crate) fn sum_available_in(&self, p: PurseId) -> (sum: u64) + requires + self.invariant(), + // With coin_value(exp) <= 2^30, sum is bounded by len * 2^30. + // Bound Vec length to ensure no u64 overflow. + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + ensures + sum as nat == sum_avail_prefix(self.coins@, p, self.coins@.len() as nat), + sum as nat <= self.coins@.len() as nat * 1073741824, + { + let mut sum: u64 = 0; + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + self.invariant(), + sum as nat == sum_avail_prefix(self.coins@, p, j as nat), + sum as nat <= (j as nat) * 1073741824, + decreases self.coins.len() - j, + { + let is_available = matches!(self.coins[j].state, CoinState::Available); + proof { + let coin_key = (self.coins@[j as int].purse, self.coins@[j as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[j as int]); + assert(self.coins@[j as int].exponent <= MAX_EXPONENT); + lemma_pow2_at_30(); + lemma_pow2_monotone(self.coins@[j as int].exponent as nat, + MAX_EXPONENT as nat); + // Per-step increment is at most coin_value(_) <= 2^30, so the + // monotone bound `sum_avail_prefix(_, _, j+1) <= (j+1) * 2^30` + // is preserved. + assert(sum_avail_prefix(self.coins@, p, (j + 1) as nat) + <= sum_avail_prefix(self.coins@, p, j as nat) + 1073741824); + } + if self.coins[j].purse == p && is_available { + let value: u64 = pow2_u64_exec(self.coins[j].exponent); + sum = sum + value; + } + j = j + 1; + } + sum + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_chain.rs b/rust/crates/coinage-layer/src/state_chain.rs new file mode 100644 index 00000000..e436ec27 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_chain.rs @@ -0,0 +1,890 @@ +//! Chain-mirror state: registration, recovery scans, restore primitives. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// 6.1.2 `deletePurse` (Quint lines 471-506; design §8.1 `delete_purse`). + /// + /// **Pilot scope:** local-state-only deletion. The Quint precondition set + /// includes `!purseHasLiveCoins(p)`, `!purseHasLiveEntries(p)`, + /// `!purseHasInFlight(p)`. These are vacuous here because the pilot state + /// has no coins, entries, or operations. The design's user-facing variant + /// drains funds via a separate prior operation before this local cleanup. + /// + /// Returns: + /// - `Ok(())` if the purse is removed. + /// - `Err(CannotDeleteMainPurse)` if `p == MAIN_PURSE`; state unchanged. + /// - `Err(PurseNotFound(p))` if `p` is not a known purse; state unchanged. + /// Chain-side mirror: register that a coin exists on chain. The + /// chain pushes a CoinRec into `chain_coins`. Local state is not + /// touched — local discovery happens via recovery scans. Quint + /// analog: `chainCoins' = chainCoins.put(...)` in a chain mint. + pub fn chain_register_coin(&mut self, c: CoinRec) + requires + old(self).invariant(), + old(self).chain_coins@.len() < u64::MAX as nat, + c.exponent <= MAX_EXPONENT, + ensures + final(self).invariant(), + final(self).chain_coins@ == old(self).chain_coins@.push(c), + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + let ghost old_events = self.events@; + let ghost old_tokens = self.tokens@; + self.chain_coins.push(c); + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + assert(self.events@ == old_events); + assert(self.tokens@ == old_tokens); + } + } + + + /// Number of chain-coin records. + pub fn chain_coin_count(&self) -> (n: usize) + requires self.invariant(), + ensures n == self.chain_coins@.len(), + { + self.chain_coins.len() + } + + + /// Find a chain coin (by index in chain_coins) whose (purse, idx) + /// key is not present in local `coins`. Returns the Vec index, or + /// `None` if every chain coin is mirrored locally. Foundation for + /// the gap-limit recovery scan. + pub fn find_missing_chain_coin(&self) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(j) => + 0 <= j < self.chain_coins@.len() + && !self.coins().dom().contains( + (self.chain_coins@[j as int].purse, + self.chain_coins@[j as int].idx) + ), + None => true, + }, + { + let mut j: usize = 0; + while j < self.chain_coins.len() + invariant + 0 <= j <= self.chain_coins.len(), + self.invariant(), + decreases self.chain_coins.len() - j, + { + let c = &self.chain_coins[j]; + let key = (c.purse, c.idx); + if self.coin_state(key).is_none() { + return Some(j); + } + j = j + 1; + } + None + } + + + /// Restore a chain-mirror coin record into local state. Reads + /// `chain_coins[j]` and inserts it into local `coins` (both the + /// exec Vec and the ghost map) under its `(purse, idx)` key. + /// The purse allocator is not touched: the slot must already be + /// allocated, i.e. + /// `chain_coins[j].idx < purses[chain_coins[j].purse].next_coin_idx`. + /// This is the "restore an old slot we lost track of" primitive + /// that composes with [`State::find_missing_chain_coin`] to form + /// the recovery scan body. + pub fn restore_chain_coin(&mut self, j: usize) + requires + old(self).invariant(), + j < old(self).chain_coins@.len(), + old(self).purses().dom().contains( + old(self).chain_coins@[j as int].purse + ), + !old(self).coins().dom().contains( + (old(self).chain_coins@[j as int].purse, + old(self).chain_coins@[j as int].idx) + ), + old(self).chain_coins@[j as int].idx + < old(self).purses()[old(self).chain_coins@[j as int].purse] + .next_coin_idx, + ensures + final(self).invariant(), + final(self).coins() == old(self).coins().insert( + (old(self).chain_coins@[j as int].purse, + old(self).chain_coins@[j as int].idx), + old(self).chain_coins@[j as int], + ), + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let rec = self.chain_coins[j]; + let key = (rec.purse, rec.idx); + + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + let ghost old_events = self.events@; + let ghost old_tokens = self.tokens@; + let ghost old_chain_coins = self.chain_coins@; + let ghost old_chain_entries = self.chain_entries@; + + self.coins.push(rec); + proof { + self.spec_coins = Ghost(self.spec_coins@.insert(key, rec)); + + let new_coins = self.spec_coins@; + let new_coins_vec = self.coins@; + let last = old_coins_vec.len() as int; + + // Sibling-field stability (the ghost-field-mutation pattern). + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_operations); + assert(self.events@ == old_events); + assert(self.tokens@ == old_tokens); + assert(self.chain_coins@ == old_chain_coins); + assert(self.chain_entries@ == old_chain_entries); + + // (i) coin key consistency. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies new_coins[k].purse == k.0 && new_coins[k].idx == k.1 + by { + if k == key { + assert(new_coins[k] == rec); + } else { + assert(old_coins.dom().contains(k)); + } + } + + // (j) coin referential integrity. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies old_spec_purses.dom().contains(k.0) + by { + if k == key { + assert(old(self).purses().dom().contains(rec.purse)); + } else { + assert(old_coins.dom().contains(k)); + } + } + + // (k) coin idx below purse's allocator. Unchanged purses. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies k.1 < old_spec_purses[k.0].next_coin_idx + by { + if k == key { + // by precondition. + } else { + assert(old_coins.dom().contains(k)); + } + } + + // Vec post-state. + assert(new_coins_vec.len() == old_coins_vec.len() + 1); + assert(new_coins_vec[last] == rec); + assert forall|k: int| 0 <= k < old_coins_vec.len() implies + new_coins_vec[k] == #[trigger] old_coins_vec[k] + by {} + + // (l) exec Vec → ghost. + assert forall|jj: int| 0 <= jj < new_coins_vec.len() implies + new_coins.dom().contains( + (#[trigger] new_coins_vec[jj].purse, new_coins_vec[jj].idx) + ) + && new_coins[(new_coins_vec[jj].purse, new_coins_vec[jj].idx)] + == new_coins_vec[jj] + by { + if jj == last { + assert(new_coins_vec[jj] == rec); + assert(new_coins[key] == rec); + } else { + assert(new_coins_vec[jj] == old_coins_vec[jj]); + let oc = old_coins_vec[jj]; + assert(old_coins.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != key); + assert(old_coins[(oc.purse, oc.idx)] == oc); + } + } + + // (m) every dom key has a Vec witness. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies exists|jj: int| + 0 <= jj < new_coins_vec.len() + && #[trigger] new_coins_vec[jj].purse == k.0 + && new_coins_vec[jj].idx == k.1 + by { + if k == key { + let w = last; + assert(new_coins_vec[w].purse == rec.purse); + assert(new_coins_vec[w].idx == rec.idx); + } else { + assert(old_coins.dom().contains(k)); + let w = choose|jj: int| + 0 <= jj < old_coins_vec.len() + && #[trigger] old_coins_vec[jj].purse == k.0 + && old_coins_vec[jj].idx == k.1; + assert(new_coins_vec[w] == old_coins_vec[w]); + } + } + + // (n) no duplicate (purse, idx) in Vec. + assert forall|a: int, b: int| + 0 <= a < new_coins_vec.len() && 0 <= b < new_coins_vec.len() + && (#[trigger] new_coins_vec[a]).purse + == (#[trigger] new_coins_vec[b]).purse + && new_coins_vec[a].idx == new_coins_vec[b].idx + implies a == b + by { + if a == last && b == last { + } else if a == last { + assert(new_coins_vec[b] == old_coins_vec[b]); + let oc = old_coins_vec[b]; + assert(old_coins.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != key); + } else if b == last { + assert(new_coins_vec[a] == old_coins_vec[a]); + let oc = old_coins_vec[a]; + assert(old_coins.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != key); + } else { + assert(new_coins_vec[a] == old_coins_vec[a]); + assert(new_coins_vec[b] == old_coins_vec[b]); + } + } + } + } + + + /// Find a chain coin (by index in `chain_coins`) whose + /// `(purse, idx)` is not in local `coins` AND whose purse exists + /// locally AND whose `idx` is below that purse's `next_coin_idx`. + /// In other words: a chain coin we lost track of, that is still + /// restorable into our current state. The returned `j` satisfies + /// exactly the preconditions of [`State::restore_chain_coin`]. + pub fn find_restorable_missing_chain_coin(&self) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(j) => { + &&& 0 <= j < self.chain_coins@.len() + &&& !self.coins().dom().contains( + (self.chain_coins@[j as int].purse, + self.chain_coins@[j as int].idx)) + &&& self.purses().dom().contains( + self.chain_coins@[j as int].purse) + &&& self.chain_coins@[j as int].idx + < self.purses()[self.chain_coins@[j as int].purse] + .next_coin_idx + }, + None => true, + }, + { + let mut j: usize = 0; + while j < self.chain_coins.len() + invariant + 0 <= j <= self.chain_coins.len(), + self.invariant(), + decreases self.chain_coins.len() - j, + { + let c = self.chain_coins[j]; + let key = (c.purse, c.idx); + if self.coin_state(key).is_none() { + // Missing locally. Walk purses to check restorability. + let mut i: usize = 0; + while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + j < self.chain_coins@.len(), + c == self.chain_coins@[j as int], + key == (c.purse, c.idx), + !self.coins().dom().contains(key), + decreases self.purses.len() - i, + { + if self.purses[i].id == c.purse { + let next_idx = self.purses[i].next_coin_idx; + if c.idx < next_idx { + proof { + let m = self.spec_purses@; + let v = self.purses@; + let cc = self.chain_coins@[j as int]; + assert(cc == c); + assert(cc.purse == c.purse); + assert(cc.idx == c.idx); + assert(0 <= i < v.len()); + assert(v[i as int].id == c.purse); + assert(m.dom().contains(v[i as int].id)); + assert(m[v[i as int].id] == v[i as int]@); + assert(m[c.purse] == v[i as int]@); + assert(v[i as int].next_coin_idx == next_idx); + assert(v[i as int]@.next_coin_idx == next_idx as nat); + assert(m[c.purse].next_coin_idx == next_idx as nat); + assert(m.dom().contains(c.purse)); + assert(self.purses().dom().contains(cc.purse)); + assert(cc.idx < self.purses()[cc.purse].next_coin_idx); + assert(!self.coins().dom().contains((cc.purse, cc.idx))); + } + return Some(j); + } + // Found the purse but slot not allocated yet — skip. + break; + } + i = i + 1; + } + } + j = j + 1; + } + None + } + + + /// Chain-side mirror: register that an entry exists on chain. + /// Quint analog: `chainEntries' = chainEntries.put(...)`. + pub fn chain_register_entry(&mut self, e: EntryRec) + requires + old(self).invariant(), + old(self).chain_entries@.len() < u64::MAX as nat, + e.exponent <= MAX_EXPONENT, + ensures + final(self).invariant(), + final(self).chain_entries@ == old(self).chain_entries@.push(e), + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + let ghost old_events = self.events@; + let ghost old_tokens = self.tokens@; + let ghost old_chain_coins = self.chain_coins@; + self.chain_entries.push(e); + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + assert(self.events@ == old_events); + assert(self.tokens@ == old_tokens); + assert(self.chain_coins@ == old_chain_coins); + } + } + + + /// Number of chain-entry records. + pub fn chain_entry_count(&self) -> (n: usize) + requires self.invariant(), + ensures n == self.chain_entries@.len(), + { + self.chain_entries.len() + } + + + /// Find a chain entry whose (purse, idx) is not present in local + /// `entries`. Entry parallel of `find_missing_chain_coin`. + pub fn find_missing_chain_entry(&self) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(j) => + 0 <= j < self.chain_entries@.len() + && !self.entries().dom().contains( + (self.chain_entries@[j as int].purse, + self.chain_entries@[j as int].idx) + ), + None => true, + }, + { + let mut j: usize = 0; + while j < self.chain_entries.len() + invariant + 0 <= j <= self.chain_entries.len(), + self.invariant(), + decreases self.chain_entries.len() - j, + { + let e = &self.chain_entries[j]; + let key = (e.purse, e.idx); + if self.entry_local_state(key).is_none() { + return Some(j); + } + j = j + 1; + } + None + } + + + /// Restore a chain-mirror entry record into local state. Entry + /// parallel of [`State::restore_chain_coin`]: reads + /// `chain_entries[j]` and inserts it into local `entries`. The + /// slot must already be allocated + /// (`chain.idx < purses[chain.purse].next_entry_idx`). + pub fn restore_chain_entry(&mut self, j: usize) + requires + old(self).invariant(), + j < old(self).chain_entries@.len(), + old(self).purses().dom().contains( + old(self).chain_entries@[j as int].purse + ), + !old(self).entries().dom().contains( + (old(self).chain_entries@[j as int].purse, + old(self).chain_entries@[j as int].idx) + ), + old(self).chain_entries@[j as int].idx + < old(self).purses()[old(self).chain_entries@[j as int].purse] + .next_entry_idx, + ensures + final(self).invariant(), + final(self).entries() == old(self).entries().insert( + (old(self).chain_entries@[j as int].purse, + old(self).chain_entries@[j as int].idx), + old(self).chain_entries@[j as int], + ), + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let rec = self.chain_entries[j]; + let key = (rec.purse, rec.idx); + + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + let ghost old_events = self.events@; + let ghost old_tokens = self.tokens@; + let ghost old_chain_coins = self.chain_coins@; + let ghost old_chain_entries = self.chain_entries@; + + self.entries.push(rec); + proof { + self.spec_entries = Ghost(self.spec_entries@.insert(key, rec)); + + let new_entries = self.spec_entries@; + let new_entries_vec = self.entries@; + let last = old_entries_vec.len() as int; + + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_coins); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_operations); + assert(self.events@ == old_events); + assert(self.tokens@ == old_tokens); + assert(self.chain_coins@ == old_chain_coins); + assert(self.chain_entries@ == old_chain_entries); + + // (o) entry key consistency. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies new_entries[k].purse == k.0 && new_entries[k].idx == k.1 + by { + if k == key { + assert(new_entries[k] == rec); + } else { + assert(old_entries.dom().contains(k)); + } + } + + // (p) entry referential integrity. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies old_spec_purses.dom().contains(k.0) + by { + if k == key { + assert(old(self).purses().dom().contains(rec.purse)); + } else { + assert(old_entries.dom().contains(k)); + } + } + + // (q) entry idx below purse's allocator. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies k.1 < old_spec_purses[k.0].next_entry_idx + by { + if k == key { + // by precondition. + } else { + assert(old_entries.dom().contains(k)); + } + } + + // Vec post-state. + assert(new_entries_vec.len() == old_entries_vec.len() + 1); + assert(new_entries_vec[last] == rec); + assert forall|k: int| 0 <= k < old_entries_vec.len() implies + new_entries_vec[k] == #[trigger] old_entries_vec[k] + by {} + + // (r) exec Vec → ghost. + assert forall|jj: int| 0 <= jj < new_entries_vec.len() implies + new_entries.dom().contains( + (#[trigger] new_entries_vec[jj].purse, new_entries_vec[jj].idx) + ) + && new_entries[(new_entries_vec[jj].purse, new_entries_vec[jj].idx)] + == new_entries_vec[jj] + by { + if jj == last { + assert(new_entries_vec[jj] == rec); + assert(new_entries[key] == rec); + } else { + assert(new_entries_vec[jj] == old_entries_vec[jj]); + let oc = old_entries_vec[jj]; + assert(old_entries.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != key); + assert(old_entries[(oc.purse, oc.idx)] == oc); + } + } + + // (s) every dom key has a Vec witness. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies exists|jj: int| + 0 <= jj < new_entries_vec.len() + && #[trigger] new_entries_vec[jj].purse == k.0 + && new_entries_vec[jj].idx == k.1 + by { + if k == key { + let w = last; + assert(new_entries_vec[w].purse == rec.purse); + assert(new_entries_vec[w].idx == rec.idx); + } else { + assert(old_entries.dom().contains(k)); + let w = choose|jj: int| + 0 <= jj < old_entries_vec.len() + && #[trigger] old_entries_vec[jj].purse == k.0 + && old_entries_vec[jj].idx == k.1; + assert(new_entries_vec[w] == old_entries_vec[w]); + } + } + + // (t) no duplicate (purse, idx) in Vec. + assert forall|a: int, b: int| + 0 <= a < new_entries_vec.len() && 0 <= b < new_entries_vec.len() + && (#[trigger] new_entries_vec[a]).purse + == (#[trigger] new_entries_vec[b]).purse + && new_entries_vec[a].idx == new_entries_vec[b].idx + implies a == b + by { + if a == last && b == last { + } else if a == last { + assert(new_entries_vec[b] == old_entries_vec[b]); + let oc = old_entries_vec[b]; + assert(old_entries.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != key); + } else if b == last { + assert(new_entries_vec[a] == old_entries_vec[a]); + let oc = old_entries_vec[a]; + assert(old_entries.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != key); + } else { + assert(new_entries_vec[a] == old_entries_vec[a]); + assert(new_entries_vec[b] == old_entries_vec[b]); + } + } + } + } + + + /// Entry parallel of [`State::find_restorable_missing_chain_coin`]. + /// Returns an index `j` such that `chain_entries[j]` is missing + /// locally, its purse exists, and its `idx` is below the purse's + /// `next_entry_idx` — satisfying exactly the preconditions of + /// [`State::restore_chain_entry`]. + pub fn find_restorable_missing_chain_entry(&self) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(j) => { + &&& 0 <= j < self.chain_entries@.len() + &&& !self.entries().dom().contains( + (self.chain_entries@[j as int].purse, + self.chain_entries@[j as int].idx)) + &&& self.purses().dom().contains( + self.chain_entries@[j as int].purse) + &&& self.chain_entries@[j as int].idx + < self.purses()[self.chain_entries@[j as int].purse] + .next_entry_idx + }, + None => true, + }, + { + let mut j: usize = 0; + while j < self.chain_entries.len() + invariant + 0 <= j <= self.chain_entries.len(), + self.invariant(), + decreases self.chain_entries.len() - j, + { + let e = self.chain_entries[j]; + let key = (e.purse, e.idx); + if self.entry_local_state(key).is_none() { + let mut i: usize = 0; + while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + j < self.chain_entries@.len(), + e == self.chain_entries@[j as int], + key == (e.purse, e.idx), + !self.entries().dom().contains(key), + decreases self.purses.len() - i, + { + if self.purses[i].id == e.purse { + let next_idx = self.purses[i].next_entry_idx; + if e.idx < next_idx { + proof { + let m = self.spec_purses@; + let v = self.purses@; + let ee = self.chain_entries@[j as int]; + assert(ee == e); + assert(0 <= i < v.len()); + assert(v[i as int].id == e.purse); + assert(m.dom().contains(v[i as int].id)); + assert(m[v[i as int].id] == v[i as int]@); + assert(m[e.purse] == v[i as int]@); + assert(v[i as int].next_entry_idx == next_idx); + assert(v[i as int]@.next_entry_idx == next_idx as nat); + assert(m[e.purse].next_entry_idx == next_idx as nat); + assert(m.dom().contains(e.purse)); + assert(self.purses().dom().contains(ee.purse)); + assert(ee.idx < self.purses()[ee.purse].next_entry_idx); + assert(!self.entries().dom().contains((ee.purse, ee.idx))); + } + return Some(j); + } + break; + } + i = i + 1; + } + } + j = j + 1; + } + None + } + + + /// One step of the recovery scan. Looks for a restorable missing + /// chain coin; if found, restores it and returns the chain-coin + /// index that was processed. Returns `None` if no restorable + /// missing chain coin exists in the current state. + /// + /// Recovery callers drive this in a loop until it returns `None` + /// for both the coin and entry side, at which point the local + /// state has absorbed every chain record it can. + pub fn recover_scan_step_coin(&mut self) -> (res: Option) + requires + old(self).invariant(), + ensures + final(self).invariant(), + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + match res { + Some(j) => { + &&& 0 <= j < old(self).chain_coins@.len() + &&& !old(self).coins().dom().contains( + (old(self).chain_coins@[j as int].purse, + old(self).chain_coins@[j as int].idx)) + &&& final(self).coins() == old(self).coins().insert( + (old(self).chain_coins@[j as int].purse, + old(self).chain_coins@[j as int].idx), + old(self).chain_coins@[j as int]) + }, + None => + final(self).coins() == old(self).coins(), + }, + final(self).purses() == old(self).purses(), + final(self).entries() == old(self).entries(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + { + let res = self.find_restorable_missing_chain_coin(); + match res { + Some(j) => { + self.restore_chain_coin(j); + Some(j) + } + None => None, + } + } + + + /// Entry parallel of [`State::recover_scan_step_coin`]. Returns + /// the chain-entry index processed, or `None` if no restorable + /// missing chain entry exists. + pub fn recover_scan_step_entry(&mut self) -> (res: Option) + requires + old(self).invariant(), + ensures + final(self).invariant(), + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + match res { + Some(j) => { + &&& 0 <= j < old(self).chain_entries@.len() + &&& !old(self).entries().dom().contains( + (old(self).chain_entries@[j as int].purse, + old(self).chain_entries@[j as int].idx)) + &&& final(self).entries() == old(self).entries().insert( + (old(self).chain_entries@[j as int].purse, + old(self).chain_entries@[j as int].idx), + old(self).chain_entries@[j as int]) + }, + None => + final(self).entries() == old(self).entries(), + }, + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + { + let res = self.find_restorable_missing_chain_entry(); + match res { + Some(j) => { + self.restore_chain_entry(j); + Some(j) + } + None => None, + } + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_coins.rs b/rust/crates/coinage-layer/src/state_coins.rs new file mode 100644 index 00000000..d63010e8 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_coins.rs @@ -0,0 +1,1216 @@ +//! Coin lifecycle: `add_coin*`, mark/lock/unlock/commit, helpers. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Allocate a fresh coin in purse `p` carrying a caller-supplied + /// chain `account`. Quint analog: the bottom-layer effect of any + /// op that delivers a coin (top-up, transfer destination, + /// rebalance destination) to a specific chain account. The coin's + /// `idx` is the purse's current `next_coin_idx`, after which the + /// per-purse allocator is bumped. The coin's `age` is the + /// state-global `next_age`, after which the global allocator is + /// bumped — this gives a total order on coin creation suitable + /// for the §6.3 priority ordering. + pub fn add_coin_with_account(&mut self, p: PurseId, exponent: u8, account: u64) + -> (key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).purses().dom().contains(p), + old(self).purses()[p].next_coin_idx < u64::MAX, + old(self).next_age < u64::MAX, + exponent <= MAX_EXPONENT, + ensures + final(self).invariant(), + key.0 == p, + key.1 == old(self).purses()[p].next_coin_idx, + !old(self).coins().dom().contains(key), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: p, + idx: key.1, + exponent, + state: CoinState::Pending, + age: old(self).next_age, + account, + }), + final(self).next_age == old(self).next_age + 1, + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx + 1, + final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + // Entries untouched. + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).events@ == old(self).events@, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_v = self.purses@; + let ghost old_m = self.spec_purses@; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + let ghost p_old_rec = old_m[p]; + + let mut i: usize = 0; + while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + exponent <= MAX_EXPONENT, + self.purses@ == old_v, + self.spec_purses@ == old_m, + self.spec_coins@ == old_coins, + self.coins@ == old_coins_vec, + self.spec_entries@ == old_entries, + self.entries@ == old_entries_vec, + self.spec_operations@ == old_operations, + self.operations@ == old_operations_vec, + self.spec_operations@ == old_operations, + self.operations@ == old_operations_vec, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + old_m == old(self).spec_purses@, + old_v == old(self).purses@, + old_coins == old(self).spec_coins@, + old_coins_vec == old(self).coins@, + old_entries == old(self).spec_entries@, + old_entries == old(self).entries(), + old_entries_vec == old(self).entries@, + old_operations == old(self).spec_operations@, + old_operations_vec == old(self).operations@, + old_operations == old(self).spec_operations@, + old_operations_vec == old(self).operations@, + self.next_purse_id == old(self).next_purse_id, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + old(self).purses().dom().contains(p), + p_old_rec == old_m[p], + p_old_rec.next_coin_idx < u64::MAX, + old(self).next_age < u64::MAX, + forall|j: int| 0 <= j < i ==> (#[trigger] self.purses@[j]).id != p, + decreases self.purses.len() - i, + { + if self.purses[i].id == p { + let ghost target_idx = i as int; + let cur_idx = self.purses[i].next_coin_idx; + let cur_age = self.next_age; + let ghost old_p_rec_at_idx = old_v[target_idx]@; + self.purses[i].next_coin_idx = cur_idx + 1; + self.next_age = cur_age + 1; + + let key = (p, cur_idx); + let new_coin = CoinRec { + purse: p, + idx: cur_idx, + exponent, + state: CoinState::Pending, + age: cur_age, + account, + }; + self.coins.push(new_coin); + + proof { + let new_p_rec_spec = PurseRecSpec { + id: p, + name: old_p_rec_at_idx.name, + next_coin_idx: (cur_idx + 1) as nat, + next_entry_idx: old_p_rec_at_idx.next_entry_idx, + }; + self.spec_purses = Ghost(self.spec_purses@.insert(p, new_p_rec_spec)); + self.spec_coins = Ghost(self.spec_coins@.insert(key, new_coin)); + + let new_v = self.purses@; + let new_m = self.spec_purses@; + let new_coins = self.spec_coins@; + + // Vec post-state: only target_idx changed; only field + // `next_coin_idx` differs. + assert(new_v[target_idx].id == p); + assert(new_v[target_idx].next_coin_idx == cur_idx + 1); + assert(new_v[target_idx].name == old_v[target_idx].name); + assert(new_v[target_idx].next_entry_idx == old_v[target_idx].next_entry_idx); + assert forall|k: int| 0 <= k < new_v.len() && k != target_idx implies + #[trigger] new_v[k] == old_v[k] + by {} + assert(old_v[target_idx].id == p); + assert forall|k: int| 0 <= k < old_v.len() && k != target_idx implies + (#[trigger] old_v[k]).id != p + by {} + + // p was already in old_m.dom — insert leaves dom unchanged. + assert(old_m.dom().contains(p)); + assert(new_m.dom() =~= old_m.dom()); + + // The new coin key was not previously a member. + assert forall|k: (PurseId, u64)| #[trigger] old_coins.dom().contains(k) + implies k != key + by { + assert(k.1 < old_m[k.0].next_coin_idx); + if k.0 == p { + assert(k.1 < cur_idx); + } + } + assert(!old_coins.dom().contains(key)); + + // (a) next_purse_id unchanged. + assert(self.next_purse_id != MAIN_PURSE); + // (b) MAIN_PURSE in dom unchanged. + assert(new_m.dom().contains(MAIN_PURSE)); + + // (c) forall q in dom. m[q].id == q + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies new_m[q].id == q + by { + if q == p { + } else { + assert(old_m.dom().contains(q)); + } + } + + // (d) forall q in dom. q < next_purse_id + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies q < self.next_purse_id + by { + assert(old_m.dom().contains(q)); + } + + // (e) every Vec entry's id is in dom + assert forall|k: int| 0 <= k < new_v.len() implies + new_m.dom().contains(#[trigger] new_v[k].id) + by { + if k == target_idx { + } else { + assert(new_v[k] == old_v[k]); + assert(old_m.dom().contains(old_v[k].id)); + } + } + + // (f) every Vec entry's spec view matches its dom entry + assert forall|k: int| 0 <= k < new_v.len() implies + new_m[(#[trigger] new_v[k]).id] == new_v[k]@ + by { + if k == target_idx { + assert(new_v[k].id == p); + assert(new_v[k]@ == new_p_rec_spec); + } else { + assert(new_v[k] == old_v[k]); + assert(old_v[k].id != p); + assert(old_m[old_v[k].id] == old_v[k]@); + } + } + + // (g) every dom key has a Vec witness + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies exists|k: int| 0 <= k < new_v.len() && #[trigger] new_v[k].id == q + by { + if q == p { + let w = target_idx; + assert(new_v[w].id == p); + } else { + assert(old_m.dom().contains(q)); + let w = choose|k: int| 0 <= k < old_v.len() && #[trigger] old_v[k].id == q; + assert(w != target_idx); + assert(new_v[w] == old_v[w]); + } + } + + // (h) no duplicates + assert forall|a: int, b: int| + 0 <= a < new_v.len() && 0 <= b < new_v.len() + && #[trigger] new_v[a].id == #[trigger] new_v[b].id + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_v[b] == old_v[b]); + } else if b == target_idx { + assert(new_v[a] == old_v[a]); + } else { + assert(new_v[a] == old_v[a]); + assert(new_v[b] == old_v[b]); + } + } + + // (i) coin key consistency. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies new_coins[k].purse == k.0 && new_coins[k].idx == k.1 + by { + if k == key { + } else { + assert(old_coins.dom().contains(k)); + } + } + + // (j) coin referential integrity. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies new_m.dom().contains(k.0) + by { + if k == key { + } else { + assert(old_coins.dom().contains(k)); + assert(old_m.dom().contains(k.0)); + } + } + + // (k) coin idx below purse's allocator. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies k.1 < new_m[k.0].next_coin_idx + by { + if k == key { + assert(new_m[p].next_coin_idx == cur_idx + 1); + } else { + assert(old_coins.dom().contains(k)); + assert(k.1 < old_m[k.0].next_coin_idx); + if k.0 == p { + assert(new_m[p].next_coin_idx == old_m[p].next_coin_idx + 1); + } else { + assert(new_m[k.0] == old_m[k.0]); + } + } + } + + // (l, m, n) coin-Vec ↔ ghost refinement, post-push. + let new_coins_vec = self.coins@; + let last = old_coins_vec.len() as int; + assert(new_coins_vec.len() == old_coins_vec.len() + 1); + assert(new_coins_vec[last] == new_coin); + assert forall|k: int| 0 <= k < old_coins_vec.len() implies + new_coins_vec[k] == #[trigger] old_coins_vec[k] + by {} + + // No old Vec entry could have key (p, cur_idx): + // by old invariant (k), every old coin's idx < old_m[purse].next_coin_idx; + // for purse == p, that's < cur_idx. So no collision. + assert forall|jj: int| 0 <= jj < old_coins_vec.len() implies + (#[trigger] old_coins_vec[jj]).purse != p + || old_coins_vec[jj].idx < cur_idx + by { + let oc = old_coins_vec[jj]; + assert(old_coins.dom().contains((oc.purse, oc.idx))); + if oc.purse == p { + assert(old_m[p].next_coin_idx == cur_idx as nat); + } + } + + // (l): each new Vec entry's (purse, idx) is in new dom and matches. + assert forall|jj: int| 0 <= jj < new_coins_vec.len() implies + new_coins.dom().contains( + (#[trigger] new_coins_vec[jj].purse, new_coins_vec[jj].idx) + ) + && new_coins[(new_coins_vec[jj].purse, new_coins_vec[jj].idx)] + == new_coins_vec[jj] + by { + if jj == last { + assert(new_coins_vec[jj] == new_coin); + assert(new_coins[key] == new_coin); + } else { + assert(new_coins_vec[jj] == old_coins_vec[jj]); + let oc = old_coins_vec[jj]; + assert(old_coins.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != key); + assert(old_coins[(oc.purse, oc.idx)] == oc); + } + } + + // (m): every dom key has a Vec witness. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies exists|jj: int| + 0 <= jj < new_coins_vec.len() + && #[trigger] new_coins_vec[jj].purse == k.0 + && new_coins_vec[jj].idx == k.1 + by { + if k == key { + let w = last; + assert(new_coins_vec[w].purse == p); + assert(new_coins_vec[w].idx == cur_idx); + } else { + assert(old_coins.dom().contains(k)); + let w = choose|jj: int| + 0 <= jj < old_coins_vec.len() + && #[trigger] old_coins_vec[jj].purse == k.0 + && old_coins_vec[jj].idx == k.1; + assert(new_coins_vec[w] == old_coins_vec[w]); + } + } + + // (n): no duplicate (purse, idx) in Vec. + assert forall|a: int, b: int| + 0 <= a < new_coins_vec.len() && 0 <= b < new_coins_vec.len() + && (#[trigger] new_coins_vec[a]).purse + == (#[trigger] new_coins_vec[b]).purse + && new_coins_vec[a].idx == new_coins_vec[b].idx + implies a == b + by { + if a == last && b == last { + } else if a == last { + assert(new_coins_vec[b] == old_coins_vec[b]); + assert(new_coins_vec[a].purse == p); + assert(new_coins_vec[a].idx == cur_idx); + } else if b == last { + assert(new_coins_vec[a] == old_coins_vec[a]); + assert(new_coins_vec[b].purse == p); + assert(new_coins_vec[b].idx == cur_idx); + } else { + assert(new_coins_vec[a] == old_coins_vec[a]); + assert(new_coins_vec[b] == old_coins_vec[b]); + } + } + + // (aa) every coin's exponent <= MAX_EXPONENT. + assert(new_coin.exponent == exponent); + assert(exponent <= MAX_EXPONENT); + assert forall|kk: (PurseId, u64)| #[trigger] new_coins.dom().contains(kk) + implies new_coins[kk].exponent <= MAX_EXPONENT + by { + if kk == key { + assert(new_coins[kk] == new_coin); + } else { + // kk is in old_coins (since new_coins = insert(key, _) and kk != key) + assert(old_coins.dom().contains(kk)); + // Map::insert axiom: insert(k, v)[k'] == m[k'] for k' != k + assert(new_coins[kk] == old_coins[kk]); + // old (aa) gives the bound on old_coins[kk] + assert(old_coins[kk].exponent <= MAX_EXPONENT); + assert(new_coins[kk].exponent == old_coins[kk].exponent); + } + } + } + return key; + } + i += 1; + } + // Unreachable: p is in old(self).purses().dom() by precondition, + // so the invariant guarantees the scan must find it. + proof { + assert(old_m.dom().contains(p)); + let w = choose|k: int| 0 <= k < old_v.len() && #[trigger] old_v[k].id == p; + assert(0 <= w < old_v.len()); + assert(self.purses@[w].id != p); + } + vstd::pervasive::unreached() + } + + + /// Allocate a fresh coin in purse `p` without specifying its chain + /// account. Thin wrapper over [`Self::add_coin_with_account`] that + /// passes `account = 0` — used by callers that don't yet thread the + /// chain side (transfer, rebalance, split_coin, top_up_purse). + pub fn add_coin(&mut self, p: PurseId, exponent: u8) -> (key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).purses().dom().contains(p), + old(self).purses()[p].next_coin_idx < u64::MAX, + old(self).next_age < u64::MAX, + exponent <= MAX_EXPONENT, + ensures + final(self).invariant(), + key.0 == p, + key.1 == old(self).purses()[p].next_coin_idx, + !old(self).coins().dom().contains(key), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: p, + idx: key.1, + exponent, + state: CoinState::Pending, + age: old(self).next_age, + account: 0, + }), + final(self).next_age == old(self).next_age + 1, + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx + 1, + final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).events@ == old(self).events@, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.add_coin_with_account(p, exponent, 0) + } + + + /// Release a coin that's locked for `handle`, returning it to + /// `Available`. Quint analog: the per-coin step of `cancelOp`'s + /// `releasedCoins` fold. + #[allow(unused_variables)] + pub fn release_locked_coin(&mut self, key: (PurseId, u64), handle: OpHandle) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::LockedFor(handle), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Available, + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).coins@.len() == old(self).coins@.len(), + lock_refint(old(self).coins(), old(self).entries(), old(self).operations()) + ==> lock_refint(final(self).coins(), final(self).entries(), + final(self).operations()), + { + self.transition_coin_state(key, CoinState::Available); + } + + + /// Coin lifecycle: `Pending` → `Available`. Called when chain + /// observation confirms the coin exists on-chain. + pub fn mark_coin_observed(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Pending, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Available, + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::CoinAvailable { + purse: key.0, + exponent: old(self).coins()[key].exponent, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let exp = self.read_coin_exponent(key); + self.transition_coin_state(key, CoinState::Available); + self.emit_event(Event::CoinAvailable { + purse: key.0, + exponent: exp, + }); + } + + + /// Coin lifecycle: `Available` → `PendingSpend`. + pub fn mark_coin_pending_spend(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Available, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::PendingSpend, + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.transition_coin_state(key, CoinState::PendingSpend); + } + + + /// Coin lifecycle: `PendingSpend` → `Spent`. + pub fn mark_coin_spent(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::PendingSpend, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Spent, + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::CoinSpent { + purse: key.0, + exponent: old(self).coins()[key].exponent, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let exp = self.read_coin_exponent(key); + self.transition_coin_state(key, CoinState::Spent); + self.emit_event(Event::CoinSpent { + purse: key.0, + exponent: exp, + }); + } + + + /// Coin lifecycle: `PendingSpend` → `Available`. Called when an + /// in-flight operation that had reserved this coin is cancelled + /// before chain settlement; the reservation is reverted. + pub fn reverse_pending_spend(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::PendingSpend, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Available, + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.transition_coin_state(key, CoinState::Available); + } + + + /// Coin lifecycle: `Available` → `LockedFor(handle)`. Reserves the + /// coin for the operation identified by `handle`. Reversible via + /// `unlock_coin`; commits to spending via `commit_locked_coin`. + pub fn lock_coin(&mut self, key: (PurseId, u64), handle: OpHandle) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Available, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::LockedFor(handle), + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + // lock_refint preservation: if the old state satisfied + // refint AND the handle is a known op, the new state still + // satisfies refint (the only new LockedFor edge references h, + // which is in operations.dom). + (lock_refint(old(self).coins(), old(self).entries(), old(self).operations()) + && old(self).operations().dom().contains(handle)) + ==> lock_refint(final(self).coins(), final(self).entries(), + final(self).operations()), + { + self.transition_coin_state(key, CoinState::LockedFor(handle)); + } + + + /// Coin lifecycle: `LockedFor(_)` → `Available`. Releases the + /// reservation. Used when the operation that locked this coin + /// cancels before submission. + pub fn unlock_coin(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + exists|h: OpHandle| old(self).coins()[key].state == CoinState::LockedFor(h), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Available, + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).coins@.len() == old(self).coins@.len(), + // lock_refint preservation: removing a LockedFor edge can + // never break refint (no new dangling references). + lock_refint(old(self).coins(), old(self).entries(), old(self).operations()) + ==> lock_refint(final(self).coins(), final(self).entries(), + final(self).operations()), + { + self.transition_coin_state(key, CoinState::Available); + } + + + /// Coin lifecycle: `LockedFor(_)` → `PendingSpend`. Commits a locked + /// coin to its operation's spend pipeline (i.e., the operation has + /// been submitted and is now in flight). + pub fn commit_locked_coin(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + exists|h: OpHandle| old(self).coins()[key].state == CoinState::LockedFor(h), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::PendingSpend, + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).coins@.len() == old(self).coins@.len(), + // lock_refint preservation: removing a LockedFor edge. + lock_refint(old(self).coins(), old(self).entries(), old(self).operations()) + ==> lock_refint(final(self).coins(), final(self).entries(), + final(self).operations()), + { + self.transition_coin_state(key, CoinState::PendingSpend); + } + + + /// Internal: locate the coin keyed `key` in the exec Vec and rewrite its + /// `state` field to `new_state`; mirror to the ghost map. The state + /// transition is unconstrained here — callers (`mark_coin_*`) enforce + /// the valid Available → PendingSpend → Spent ordering. + pub(crate) fn transition_coin_state(&mut self, key: (PurseId, u64), new_state: CoinState) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: new_state, + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).coins@.len() == old(self).coins@.len(), + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_next_purse_id = self.next_purse_id; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + self.purses@ == old_purses_vec, + self.spec_purses@ == old_spec_purses, + self.next_purse_id == old_next_purse_id, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + self.spec_coins@ == old_coins, + self.coins@ == old_coins_vec, + self.spec_entries@ == old_entries, + self.entries@ == old_entries_vec, + self.spec_operations@ == old_operations, + self.operations@ == old_operations_vec, + old_spec_purses == old(self).spec_purses@, + old_spec_purses == old(self).purses(), + old_coins == old(self).spec_coins@, + old_coins == old(self).coins(), + old_coins_vec == old(self).coins@, + old_entries == old(self).spec_entries@, + old_entries == old(self).entries(), + old_entries_vec == old(self).entries@, + old_operations == old(self).spec_operations@, + old_operations_vec == old(self).operations@, + old_coins.dom().contains(key), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != key.0 + || self.coins@[jj].idx != key.1, + decreases self.coins.len() - j, + { + if self.coins[j].purse == key.0 && self.coins[j].idx == key.1 { + let ghost target_idx = j as int; + let ghost updated = CoinRec { + purse: old_coins[key].purse, + idx: old_coins[key].idx, + exponent: old_coins[key].exponent, + state: new_state, + age: old_coins[key].age, + account: old_coins[key].account, + }; + self.coins[j].state = new_state; + + proof { + assert(old_coins[key].purse == key.0); + assert(old_coins[key].idx == key.1); + self.spec_coins = Ghost(self.spec_coins@.insert(key, updated)); + + let new_coins_vec = self.coins@; + let new_coins = self.spec_coins@; + + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.next_purse_id == old_next_purse_id); + + // Vec post-mutation: only the entry at target_idx changed, + // and only its `state` field. + assert(new_coins_vec[target_idx].purse == key.0); + assert(new_coins_vec[target_idx].idx == key.1); + assert(new_coins_vec[target_idx].exponent + == old_coins_vec[target_idx].exponent); + assert(new_coins_vec[target_idx].state == new_state); + assert forall|k: int| + 0 <= k < new_coins_vec.len() && k != target_idx implies + #[trigger] new_coins_vec[k] == old_coins_vec[k] + by {} + + // The old entry at target_idx had purse/idx == key (branch + // guard); uniqueness gives that no other Vec entry matches. + assert(old_coins_vec[target_idx].purse == key.0); + assert(old_coins_vec[target_idx].idx == key.1); + assert forall|kk: int| + 0 <= kk < old_coins_vec.len() && kk != target_idx implies + (#[trigger] old_coins_vec[kk]).purse != key.0 + || old_coins_vec[kk].idx != key.1 + by {} + + // (i) coin key consistency. + assert forall|kk: (PurseId, u64)| #[trigger] new_coins.dom().contains(kk) + implies new_coins[kk].purse == kk.0 && new_coins[kk].idx == kk.1 + by { + if kk != key { + assert(old_coins.dom().contains(kk)); + } + } + + // (j) coin referential integrity. + assert forall|kk: (PurseId, u64)| #[trigger] new_coins.dom().contains(kk) + implies self.spec_purses@.dom().contains(kk.0) + by { + assert(old_coins.dom().contains(kk)); + } + + // (k) coin idx below purse's allocator. + assert forall|kk: (PurseId, u64)| #[trigger] new_coins.dom().contains(kk) + implies kk.1 < self.spec_purses@[kk.0].next_coin_idx + by { + assert(old_coins.dom().contains(kk)); + } + + // (l) exec → ghost + assert forall|jj: int| 0 <= jj < new_coins_vec.len() implies + new_coins.dom().contains( + (#[trigger] new_coins_vec[jj].purse, new_coins_vec[jj].idx) + ) + && new_coins[(new_coins_vec[jj].purse, new_coins_vec[jj].idx)] + == new_coins_vec[jj] + by { + if jj == target_idx { + assert(new_coins[key] == updated); + assert(updated == new_coins_vec[target_idx]); + } else { + assert(new_coins_vec[jj] == old_coins_vec[jj]); + let oc = old_coins_vec[jj]; + assert(old_coins.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != key); + assert(old_coins[(oc.purse, oc.idx)] == oc); + } + } + + // (m) ghost → exec + assert forall|kk: (PurseId, u64)| #[trigger] new_coins.dom().contains(kk) + implies exists|jj: int| + 0 <= jj < new_coins_vec.len() + && #[trigger] new_coins_vec[jj].purse == kk.0 + && new_coins_vec[jj].idx == kk.1 + by { + if kk == key { + let w = target_idx; + assert(new_coins_vec[w].purse == kk.0); + assert(new_coins_vec[w].idx == kk.1); + } else { + assert(old_coins.dom().contains(kk)); + let w = choose|jj: int| + 0 <= jj < old_coins_vec.len() + && #[trigger] old_coins_vec[jj].purse == kk.0 + && old_coins_vec[jj].idx == kk.1; + assert(new_coins_vec[w] == old_coins_vec[w]); + } + } + + // (n) no duplicates — unchanged. + assert forall|a: int, b: int| + 0 <= a < new_coins_vec.len() && 0 <= b < new_coins_vec.len() + && (#[trigger] new_coins_vec[a]).purse + == (#[trigger] new_coins_vec[b]).purse + && new_coins_vec[a].idx == new_coins_vec[b].idx + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_coins_vec[b] == old_coins_vec[b]); + } else if b == target_idx { + assert(new_coins_vec[a] == old_coins_vec[a]); + } else { + assert(new_coins_vec[a] == old_coins_vec[a]); + assert(new_coins_vec[b] == old_coins_vec[b]); + } + } + // Vec length preservation: state field write doesn't + // change Vec length. + assert(self.coins@.len() == old_coins_vec.len()); + } + return; + } + j += 1; + } + // Unreachable: precondition + invariant (m) guarantee a Vec witness. + proof { + assert(old_coins.dom().contains(key)); + let w = choose|jj: int| + 0 <= jj < old_coins_vec.len() + && #[trigger] old_coins_vec[jj].purse == key.0 + && old_coins_vec[jj].idx == key.1; + } + vstd::pervasive::unreached() + } + + + /// Internal: read the `exponent` of a coin known to exist by `key`. + pub(crate) fn read_coin_exponent(&self, key: (PurseId, u64)) -> (exp: u8) + requires + self.invariant(), + self.coins().dom().contains(key), + ensures + exp == self.coins()[key].exponent, + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + self.coins().dom().contains(key), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != key.0 + || self.coins@[jj].idx != key.1, + decreases self.coins.len() - j, + { + if self.coins[j].purse == key.0 && self.coins[j].idx == key.1 { + proof { + // (l) gives us that self.coins@[j] matches the ghost record at this key. + assert(self.spec_coins@[(self.coins@[j as int].purse, self.coins@[j as int].idx)] + == self.coins@[j as int]); + } + return self.coins[j].exponent; + } + j = j + 1; + } + // Unreachable: precondition + (m) guarantee a Vec witness. + proof { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == key.0 + && self.coins@[jj].idx == key.1; + } + vstd::pervasive::unreached() + } + + + /// True iff `key` is currently in the coin map. O(n) scan; useful for + /// gap-limit recovery (Appendix C) which probes (purse, idx) tuples + /// without a precomputed index. + pub fn has_coin(&self, key: (PurseId, u64)) -> (b: bool) + requires + self.invariant(), + ensures + b == self.coins().dom().contains(key), + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != key.0 + || self.coins@[jj].idx != key.1, + decreases self.coins.len() - j, + { + if self.coins[j].purse == key.0 && self.coins[j].idx == key.1 { + proof { + assert(self.spec_coins@.dom().contains( + (self.coins@[j as int].purse, self.coins@[j as int].idx) + )); + } + return true; + } + j = j + 1; + } + // No Vec witness for `key`: by (m), key not in ghost dom. + proof { + if self.coins().dom().contains(key) { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == key.0 + && self.coins@[jj].idx == key.1; + assert(self.coins@[w].purse == key.0); + } + } + false + } + + + /// Gap-limit recovery scan (Appendix C). Probes coin indices + /// `0, 1, 2, …, max_idx` in purse `p`, returning each existing key. + /// Termination: after seeing `gap_limit` consecutive missing indices, + /// the scan stops early. + /// + /// **Pilot scope:** the contract guarantees soundness (every returned + /// key is in the coin map under purse `p`) but is *not* complete with + /// respect to "discovered all coins below `max_idx`". A coin at idx + /// `i` may be missed if a gap of length `gap_limit` precedes it. + /// Real recovery in the design relies on a high-enough gap_limit + /// (per RFC-6 derivation discipline) to make this safe in practice. + pub fn scan_with_gap_limit(&self, p: PurseId, gap_limit: u64, max_idx: u64) + -> (found: Vec<(PurseId, u64)>) + requires + self.invariant(), + ensures + // Each returned key is in the coin map under purse `p`. + forall|i: int| 0 <= i < found@.len() ==> + self.coins().dom().contains(#[trigger] found@[i]) + && found@[i].0 == p, + { + let mut found: Vec<(PurseId, u64)> = Vec::new(); + let mut i: u64 = 0; + let mut gap: u64 = 0; + loop + invariant + self.invariant(), + i <= max_idx + 1, + gap <= gap_limit, + forall|k: int| 0 <= k < found@.len() ==> + self.coins().dom().contains(#[trigger] found@[k]) + && found@[k].0 == p, + decreases + if gap >= gap_limit || i > max_idx { 0int } + else { (max_idx - i) as int + 1 }, + { + if i > max_idx { break; } + if gap >= gap_limit { break; } + if self.has_coin((p, i)) { + found.push((p, i)); + gap = 0; + } else { + gap = gap + 1; + } + if i == u64::MAX { break; } + i = i + 1; + } + found + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_composites.rs b/rust/crates/coinage-layer/src/state_composites.rs new file mode 100644 index 00000000..facd1b02 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_composites.rs @@ -0,0 +1,363 @@ +//! Atomic op composites: kick-off, cancel, commit (coin and entry variants). + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Atomic composite: commit an op that's holding one locked entry. + /// Consumes the entry (`LocalLockedFor → LocalConsumed`) and + /// marks the op `Done`. Used by the commit path of unload / + /// external-offload when the chain has confirmed the entry-spend + /// extrinsic. + pub fn commit_op_consuming_locked_entry( + &mut self, + handle: OpHandle, + key: (PurseId, u64), + ) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + old(self).operations()[handle].status == OpStatus::Finalized, + old(self).entries().dom().contains(key), + old(self).entries()[key].local == EntryLocal::LocalLockedFor(handle), + old(self).events@.len() + 2 <= u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).entries() == old(self).entries().insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..old(self).entries()[key] + }), + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::Done, + }), + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@ + .push(Event::EntryConsumed { + purse: key.0, + exponent: old(self).entries()[key].exponent, + }) + .push(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.consume_entry(key); + self.mark_op_done(handle); + } + + + /// Atomic composite: commit an op that's holding one locked coin. + /// Consumes the coin (`LockedFor → PendingSpend → Spent`) and + /// marks the op `Done`. Used by the commit path of transfer / + /// rebalance / export when the chain has finalized the spend. + pub fn commit_op_consuming_locked_coin( + &mut self, + handle: OpHandle, + key: (PurseId, u64), + ) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + old(self).operations()[handle].status == OpStatus::Finalized, + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::LockedFor(handle), + old(self).events@.len() + 2 <= u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).entries() == old(self).entries(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Spent, + }), + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::Done, + }), + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@ + .push(Event::CoinSpent { + purse: key.0, + exponent: old(self).coins()[key].exponent, + }) + .push(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.commit_locked_coin(key); + self.mark_coin_spent(key); + self.mark_op_done(handle); + } + + + /// Atomic composite: cancel an op that's holding one locked coin. + /// Releases the coin back to `Available` and marks the op + /// `Failed`. Inverse of [`Self::start_op_locking_coin`] (when the + /// op was started and the lock holds but the op hasn't progressed + /// beyond `Preparing` / `Waiting(_)`). + pub fn cancel_op_releasing_coin( + &mut self, + handle: OpHandle, + key: (PurseId, u64), + ) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + match old(self).operations()[handle].status { + OpStatus::Preparing => true, + OpStatus::Waiting(_) => true, + _ => false, + }, + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::LockedFor(handle), + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Available, + }), + final(self).entries() == old(self).entries(), + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::Failed, + }), + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.release_locked_coin(key, handle); + self.set_op_failed(handle); + } + + + /// Atomic composite: cancel an op that's holding one locked entry. + /// Releases the entry back to `LocalAvailable` and marks the op + /// `Failed`. Inverse of [`Self::start_op_locking_entry`]. + pub fn cancel_op_releasing_entry( + &mut self, + handle: OpHandle, + key: (PurseId, u64), + ) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + match old(self).operations()[handle].status { + OpStatus::Preparing => true, + OpStatus::Waiting(_) => true, + _ => false, + }, + old(self).entries().dom().contains(key), + old(self).entries()[key].local == EntryLocal::LocalLockedFor(handle), + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).entries() == old(self).entries().insert(key, EntryRec { + local: EntryLocal::LocalAvailable, + ..old(self).entries()[key] + }), + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::Failed, + }), + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.release_locked_entry(key, handle); + self.set_op_failed(handle); + } + + + /// Atomic composite: start a new operation and lock `key`'s coin + /// for it. The coin must currently be `Available`; on return it + /// is `LockedFor(handle)`, and the operation is in `Preparing`. + /// + /// This is the canonical entry point for op flows that reserve a + /// specific coin upfront (transfer, rebalance, export). Avoids + /// the temporal-gap problem of separately starting the op then + /// locking the coin, where another concurrent call could observe + /// the half-built state. + /// Atomic composite: start a new operation and lock `key`'s entry + /// for it. The entry must currently be `LocalAvailable`; on + /// return it is `LocalLockedFor(handle)`, and the operation is + /// in `Preparing`. Mirror of [`Self::start_op_locking_coin`] for + /// recycler-entry-bearing op flows (unload, external offload). + pub fn start_op_locking_entry( + &mut self, + kind: OpKind, + key: (PurseId, u64), + ) -> (handle: OpHandle) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + old(self).entries()[key].local == EntryLocal::LocalAvailable, + old(self).purses().dom().contains(key.0), + old(self).next_handle < u64::MAX, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + handle == old(self).next_handle, + !old(self).operations().dom().contains(handle), + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle, + kind, + purse: key.0, + status: OpStatus::Preparing, + }), + final(self).entries() == old(self).entries().insert(key, EntryRec { + local: EntryLocal::LocalLockedFor(handle), + ..old(self).entries()[key] + }), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).next_handle == old(self).next_handle + 1, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::OperationStarted { + handle, + kind, + purse: key.0, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let handle = self.start_op(kind, key.0); + proof { + assert(self.entries()[key].local == EntryLocal::LocalAvailable); + } + self.lock_entry(key, handle); + handle + } + + + pub fn start_op_locking_coin( + &mut self, + kind: OpKind, + key: (PurseId, u64), + ) -> (handle: OpHandle) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Available, + old(self).purses().dom().contains(key.0), + old(self).next_handle < u64::MAX, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + handle == old(self).next_handle, + !old(self).operations().dom().contains(handle), + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle, + kind, + purse: key.0, + status: OpStatus::Preparing, + }), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::LockedFor(handle), + }), + final(self).purses() == old(self).purses(), + final(self).entries() == old(self).entries(), + final(self).next_handle == old(self).next_handle + 1, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::OperationStarted { + handle, + kind, + purse: key.0, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let handle = self.start_op(kind, key.0); + proof { + assert(self.coins()[key].state == CoinState::Available); + } + self.lock_coin(key, handle); + handle + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_entries.rs b/rust/crates/coinage-layer/src/state_entries.rs new file mode 100644 index 00000000..daebfc80 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_entries.rs @@ -0,0 +1,1287 @@ +//! Entry lifecycle: `add_entry*`, `set_entry_*`, lock/release/consume, helpers. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Allocate a fresh recycler entry in purse `p` with full chain + /// bookkeeping: `exponent`, `on_chain`/`local` lifecycle states, and + /// the four chain-side metadata fields (`member_key`, `allocated_at`, + /// `ready_at`, `ring_idx`). The entry's `idx` is the purse's current + /// `next_entry_idx`, after which the allocator is bumped. Quint + /// analog: the bottom-layer effect of `topUp`'s entry construction. + pub fn add_entry_with_meta( + &mut self, + p: PurseId, + exponent: u8, + on_chain: EntryOnChain, + local: EntryLocal, + member_key: u64, + allocated_at: u64, + ready_at: u64, + ring_idx: u64, + ) -> (key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).purses().dom().contains(p), + old(self).purses()[p].next_entry_idx < u64::MAX, + exponent <= MAX_EXPONENT, + ensures + final(self).invariant(), + key.0 == p, + key.1 == old(self).purses()[p].next_entry_idx, + !old(self).entries().dom().contains(key), + final(self).entries() == old(self).entries().insert(key, EntryRec { + purse: p, + idx: key.1, + exponent, + on_chain, + local, + member_key, + allocated_at, + ready_at, + ring_idx, + }), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx, + final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx + 1, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_v = self.purses@; + let ghost old_m = self.spec_purses@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + let ghost old_coins = self.spec_coins@; + let ghost p_old_rec = old_m[p]; + + let mut i: usize = 0; + while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + exponent <= MAX_EXPONENT, + self.purses@ == old_v, + self.spec_purses@ == old_m, + self.spec_coins@ == old_coins, + self.coins@ == old(self).coins@, + self.spec_entries@ == old_entries, + self.entries@ == old_entries_vec, + self.spec_operations@ == old_operations, + self.operations@ == old_operations_vec, + old_operations == old(self).spec_operations@, + old_operations_vec == old(self).operations@, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + old_m == old(self).spec_purses@, + old_v == old(self).purses@, + old_entries == old(self).spec_entries@, + old_entries_vec == old(self).entries@, + old_coins == old(self).spec_coins@, + self.next_purse_id == old(self).next_purse_id, + old(self).purses().dom().contains(p), + p_old_rec == old_m[p], + p_old_rec.next_entry_idx < u64::MAX, + forall|j: int| 0 <= j < i ==> (#[trigger] self.purses@[j]).id != p, + decreases self.purses.len() - i, + { + if self.purses[i].id == p { + let ghost target_idx = i as int; + let cur_idx = self.purses[i].next_entry_idx; + let ghost old_p_rec_at_idx = old_v[target_idx]@; + self.purses[i].next_entry_idx = cur_idx + 1; + + let key = (p, cur_idx); + let new_entry = EntryRec { + purse: p, + idx: cur_idx, + exponent, + on_chain, + local, + member_key, + allocated_at, + ready_at, + ring_idx, + }; + self.entries.push(new_entry); + + proof { + let new_p_rec_spec = PurseRecSpec { + id: p, + name: old_p_rec_at_idx.name, + next_coin_idx: old_p_rec_at_idx.next_coin_idx, + next_entry_idx: (cur_idx + 1) as nat, + }; + self.spec_purses = Ghost(self.spec_purses@.insert(p, new_p_rec_spec)); + self.spec_entries = Ghost(self.spec_entries@.insert(key, new_entry)); + + let new_v = self.purses@; + let new_m = self.spec_purses@; + let new_entries = self.spec_entries@; + + // Purse-side post-state for (e-h). + assert(new_v[target_idx].id == p); + assert(new_v[target_idx].next_entry_idx == cur_idx + 1); + assert(new_v[target_idx].next_coin_idx == old_v[target_idx].next_coin_idx); + assert(new_v[target_idx].name == old_v[target_idx].name); + assert forall|k: int| 0 <= k < new_v.len() && k != target_idx implies + #[trigger] new_v[k] == old_v[k] + by {} + assert(old_v[target_idx].id == p); + assert forall|k: int| 0 <= k < old_v.len() && k != target_idx implies + (#[trigger] old_v[k]).id != p + by {} + assert(old_m.dom().contains(p)); + assert(new_m.dom() =~= old_m.dom()); + + // New entry key is fresh: by (q) old, every entry's idx < + // old_m[purse].next_entry_idx. For purse == p that's < cur_idx. + assert forall|k: (PurseId, u64)| #[trigger] old_entries.dom().contains(k) + implies k != key + by { + assert(k.1 < old_m[k.0].next_entry_idx); + if k.0 == p { + assert(k.1 < cur_idx); + } + } + assert(!old_entries.dom().contains(key)); + + // Purse-side (a-h) — re-prove as in add_coin. + assert(self.next_purse_id != MAIN_PURSE); + assert(new_m.dom().contains(MAIN_PURSE)); + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies new_m[q].id == q + by { if q != p { assert(old_m.dom().contains(q)); } } + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies q < self.next_purse_id + by { assert(old_m.dom().contains(q)); } + assert forall|k: int| 0 <= k < new_v.len() implies + new_m.dom().contains(#[trigger] new_v[k].id) + by { + if k != target_idx { + assert(new_v[k] == old_v[k]); + assert(old_m.dom().contains(old_v[k].id)); + } + } + assert forall|k: int| 0 <= k < new_v.len() implies + new_m[(#[trigger] new_v[k]).id] == new_v[k]@ + by { + if k == target_idx { + assert(new_v[k].id == p); + assert(new_v[k]@ == new_p_rec_spec); + } else { + assert(new_v[k] == old_v[k]); + assert(old_v[k].id != p); + assert(old_m[old_v[k].id] == old_v[k]@); + } + } + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies exists|k: int| 0 <= k < new_v.len() && #[trigger] new_v[k].id == q + by { + if q == p { + let w = target_idx; + assert(new_v[w].id == p); + } else { + assert(old_m.dom().contains(q)); + let w = choose|k: int| 0 <= k < old_v.len() && #[trigger] old_v[k].id == q; + assert(w != target_idx); + assert(new_v[w] == old_v[w]); + } + } + assert forall|a: int, b: int| + 0 <= a < new_v.len() && 0 <= b < new_v.len() + && #[trigger] new_v[a].id == #[trigger] new_v[b].id + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_v[b] == old_v[b]); + } else if b == target_idx { + assert(new_v[a] == old_v[a]); + } else { + assert(new_v[a] == old_v[a]); + assert(new_v[b] == old_v[b]); + } + } + + // (i, j, k) coin-side unchanged since spec_coins and self.coins + // are untouched. Only thing to re-prove for (k): for coin keys + // with purse == p, new_m[p].next_coin_idx still equals old. + assert forall|k: (PurseId, u64)| #[trigger] self.spec_coins@.dom().contains(k) + implies k.1 < new_m[k.0].next_coin_idx + by { + assert(old_coins.dom().contains(k)); + assert(k.1 < old_m[k.0].next_coin_idx); + if k.0 == p { + assert(new_m[p].next_coin_idx == old_m[p].next_coin_idx); + } else { + assert(new_m[k.0] == old_m[k.0]); + } + } + + // (o) entry key consistency. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies new_entries[k].purse == k.0 && new_entries[k].idx == k.1 + by { + if k != key { assert(old_entries.dom().contains(k)); } + } + + // (p) entry refint. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies new_m.dom().contains(k.0) + by { + if k != key { + assert(old_entries.dom().contains(k)); + assert(old_m.dom().contains(k.0)); + } + } + + // (q) entry idx below next_entry_idx. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies k.1 < new_m[k.0].next_entry_idx + by { + if k == key { + assert(new_m[p].next_entry_idx == cur_idx + 1); + } else { + assert(old_entries.dom().contains(k)); + assert(k.1 < old_m[k.0].next_entry_idx); + if k.0 == p { + assert(new_m[p].next_entry_idx == old_m[p].next_entry_idx + 1); + } else { + assert(new_m[k.0] == old_m[k.0]); + } + } + } + + // (r, s, t) entry Vec ↔ ghost refinement post-push. + let new_entries_vec = self.entries@; + let last = old_entries_vec.len() as int; + assert(new_entries_vec.len() == old_entries_vec.len() + 1); + assert(new_entries_vec[last] == new_entry); + assert forall|k: int| 0 <= k < old_entries_vec.len() implies + new_entries_vec[k] == #[trigger] old_entries_vec[k] + by {} + // No old Vec entry collides with the new key. + assert forall|jj: int| 0 <= jj < old_entries_vec.len() implies + (#[trigger] old_entries_vec[jj]).purse != p + || old_entries_vec[jj].idx < cur_idx + by { + let oe = old_entries_vec[jj]; + assert(old_entries.dom().contains((oe.purse, oe.idx))); + if oe.purse == p { + assert(old_m[p].next_entry_idx == cur_idx as nat); + } + } + // (r) + assert forall|jj: int| 0 <= jj < new_entries_vec.len() implies + new_entries.dom().contains( + (#[trigger] new_entries_vec[jj].purse, new_entries_vec[jj].idx) + ) + && new_entries[(new_entries_vec[jj].purse, new_entries_vec[jj].idx)] + == new_entries_vec[jj] + by { + if jj == last { + assert(new_entries_vec[jj] == new_entry); + assert(new_entries[key] == new_entry); + } else { + assert(new_entries_vec[jj] == old_entries_vec[jj]); + let oe = old_entries_vec[jj]; + assert(old_entries.dom().contains((oe.purse, oe.idx))); + assert((oe.purse, oe.idx) != key); + assert(old_entries[(oe.purse, oe.idx)] == oe); + } + } + // (s) + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies exists|jj: int| + 0 <= jj < new_entries_vec.len() + && #[trigger] new_entries_vec[jj].purse == k.0 + && new_entries_vec[jj].idx == k.1 + by { + if k == key { + let w = last; + assert(new_entries_vec[w].purse == p); + assert(new_entries_vec[w].idx == cur_idx); + } else { + assert(old_entries.dom().contains(k)); + let w = choose|jj: int| + 0 <= jj < old_entries_vec.len() + && #[trigger] old_entries_vec[jj].purse == k.0 + && old_entries_vec[jj].idx == k.1; + assert(new_entries_vec[w] == old_entries_vec[w]); + } + } + // (t) + assert forall|a: int, b: int| + 0 <= a < new_entries_vec.len() && 0 <= b < new_entries_vec.len() + && (#[trigger] new_entries_vec[a]).purse + == (#[trigger] new_entries_vec[b]).purse + && new_entries_vec[a].idx == new_entries_vec[b].idx + implies a == b + by { + if a == last && b == last { + } else if a == last { + assert(new_entries_vec[b] == old_entries_vec[b]); + assert(new_entries_vec[a].purse == p); + assert(new_entries_vec[a].idx == cur_idx); + } else if b == last { + assert(new_entries_vec[a] == old_entries_vec[a]); + assert(new_entries_vec[b].purse == p); + assert(new_entries_vec[b].idx == cur_idx); + } else { + assert(new_entries_vec[a] == old_entries_vec[a]); + assert(new_entries_vec[b] == old_entries_vec[b]); + } + } + + // (ab) every entry's exponent <= MAX_EXPONENT. + assert forall|kk: (PurseId, u64)| #[trigger] new_entries.dom().contains(kk) + implies new_entries[kk].exponent <= MAX_EXPONENT + by { + if kk == key { + assert(new_entries[key] == new_entry); + assert(new_entry.exponent == exponent); + } else { + assert(old_entries.dom().contains(kk)); + assert(new_entries[kk] == old_entries[kk]); + assert(old_entries[kk].exponent <= MAX_EXPONENT); + } + } + } + return key; + } + i += 1; + } + proof { + assert(old_m.dom().contains(p)); + let w = choose|k: int| 0 <= k < old_v.len() && #[trigger] old_v[k].id == p; + assert(0 <= w < old_v.len()); + assert(self.purses@[w].id != p); + } + vstd::pervasive::unreached() + } + + + /// Allocate a fresh recycler entry without chain bookkeeping. Thin + /// wrapper over [`Self::add_entry_with_meta`] that supplies zero + /// placeholders for `member_key`, `allocated_at`, `ready_at`, and + /// `ring_idx`. Used by callers that don't yet model the chain side + /// (notably `reserve_entries`). + pub fn add_entry( + &mut self, + p: PurseId, + exponent: u8, + on_chain: EntryOnChain, + local: EntryLocal, + ) -> (key: (PurseId, u64)) + requires + old(self).invariant(), + exponent <= MAX_EXPONENT, + old(self).purses().dom().contains(p), + old(self).purses()[p].next_entry_idx < u64::MAX, + ensures + final(self).invariant(), + key.0 == p, + key.1 == old(self).purses()[p].next_entry_idx, + !old(self).entries().dom().contains(key), + final(self).entries() == old(self).entries().insert(key, EntryRec { + purse: p, + idx: key.1, + exponent, + on_chain, + local, + member_key: 0, + allocated_at: 0, + ready_at: 0, + ring_idx: 0, + }), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx, + final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx + 1, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.add_entry_with_meta(p, exponent, on_chain, local, 0, 0, 0, 0) + } + + + /// Release an entry that's locally locked for `handle`, returning + /// it to `LocalAvailable`. Quint analog: per-entry step of + /// `cancelOp`'s `releasedEntries` fold. + #[allow(unused_variables)] + pub fn release_locked_entry(&mut self, key: (PurseId, u64), handle: OpHandle) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + old(self).entries()[key].local == EntryLocal::LocalLockedFor(handle), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries().insert(key, EntryRec { + purse: old(self).entries()[key].purse, + idx: old(self).entries()[key].idx, + exponent: old(self).entries()[key].exponent, + member_key: old(self).entries()[key].member_key, + allocated_at: old(self).entries()[key].allocated_at, + ready_at: old(self).entries()[key].ready_at, + ring_idx: old(self).entries()[key].ring_idx, + local: EntryLocal::LocalAvailable, + on_chain: old(self).entries()[key].on_chain, + }), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).entries@.len() == old(self).entries@.len(), + lock_refint(old(self).coins(), old(self).entries(), old(self).operations()) + ==> lock_refint(final(self).coins(), final(self).entries(), + final(self).operations()), + { + self.set_entry_local(key, EntryLocal::LocalAvailable); + } + + + /// Promote a recycler entry's on-chain state (e.g. Waiting → Ready + /// when chain notifications confirm anonymity-floor membership). + /// Quint analog: `chainPromoteToReady`, `chainPromoteToDegraded`. + pub fn set_entry_on_chain(&mut self, key: (PurseId, u64), new_state: EntryOnChain) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries().insert(key, EntryRec { + purse: old(self).entries()[key].purse, + idx: old(self).entries()[key].idx, + exponent: old(self).entries()[key].exponent, + member_key: old(self).entries()[key].member_key, + allocated_at: old(self).entries()[key].allocated_at, + ready_at: old(self).entries()[key].ready_at, + ring_idx: old(self).entries()[key].ring_idx, + on_chain: new_state, + local: old(self).entries()[key].local, + }), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_next_purse_id = self.next_purse_id; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + self.purses@ == old_purses_vec, + self.spec_purses@ == old_spec_purses, + self.next_purse_id == old_next_purse_id, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + self.spec_coins@ == old_coins, + self.coins@ == old_coins_vec, + self.spec_entries@ == old_entries, + self.entries@ == old_entries_vec, + self.spec_operations@ == old_operations, + self.operations@ == old_operations_vec, + old_spec_purses == old(self).spec_purses@, + old_spec_purses == old(self).purses(), + old_coins == old(self).spec_coins@, + old_coins == old(self).coins(), + old_coins_vec == old(self).coins@, + old_entries == old(self).spec_entries@, + old_entries == old(self).entries(), + old_entries_vec == old(self).entries@, + old_operations == old(self).spec_operations@, + old_operations_vec == old(self).operations@, + old_entries.dom().contains(key), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != key.0 + || self.entries@[jj].idx != key.1, + decreases self.entries.len() - j, + { + if self.entries[j].purse == key.0 && self.entries[j].idx == key.1 { + let ghost target_idx = j as int; + let ghost updated = EntryRec { + purse: old_entries[key].purse, + idx: old_entries[key].idx, + exponent: old_entries[key].exponent, + on_chain: new_state, + local: old_entries[key].local, + member_key: old_entries[key].member_key, + allocated_at: old_entries[key].allocated_at, + ready_at: old_entries[key].ready_at, + ring_idx: old_entries[key].ring_idx, + }; + self.entries[j].on_chain = new_state; + + proof { + assert(old_entries[key].purse == key.0); + assert(old_entries[key].idx == key.1); + self.spec_entries = Ghost(self.spec_entries@.insert(key, updated)); + + let new_entries_vec = self.entries@; + let new_entries = self.spec_entries@; + + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.next_purse_id == old_next_purse_id); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_coins); + + assert(new_entries_vec[target_idx].purse == key.0); + assert(new_entries_vec[target_idx].idx == key.1); + assert(new_entries_vec[target_idx].exponent + == old_entries_vec[target_idx].exponent); + assert(new_entries_vec[target_idx].on_chain == new_state); + assert forall|k: int| + 0 <= k < new_entries_vec.len() && k != target_idx implies + #[trigger] new_entries_vec[k] == old_entries_vec[k] + by {} + assert(old_entries_vec[target_idx].purse == key.0); + assert(old_entries_vec[target_idx].idx == key.1); + assert forall|kk: int| + 0 <= kk < old_entries_vec.len() && kk != target_idx implies + (#[trigger] old_entries_vec[kk]).purse != key.0 + || old_entries_vec[kk].idx != key.1 + by {} + + // (o) entry key consistency. + assert forall|kk: (PurseId, u64)| #[trigger] new_entries.dom().contains(kk) + implies new_entries[kk].purse == kk.0 && new_entries[kk].idx == kk.1 + by { if kk != key { assert(old_entries.dom().contains(kk)); } } + + // (p) entry referential integrity. + assert forall|kk: (PurseId, u64)| #[trigger] new_entries.dom().contains(kk) + implies self.spec_purses@.dom().contains(kk.0) + by { assert(old_entries.dom().contains(kk)); } + + // (q) entry idx below allocator. + assert forall|kk: (PurseId, u64)| #[trigger] new_entries.dom().contains(kk) + implies kk.1 < self.spec_purses@[kk.0].next_entry_idx + by { assert(old_entries.dom().contains(kk)); } + + // (r) Vec → ghost + assert forall|jj: int| 0 <= jj < new_entries_vec.len() implies + new_entries.dom().contains( + (#[trigger] new_entries_vec[jj].purse, new_entries_vec[jj].idx) + ) + && new_entries[(new_entries_vec[jj].purse, new_entries_vec[jj].idx)] + == new_entries_vec[jj] + by { + if jj == target_idx { + assert(new_entries[key] == updated); + assert(updated == new_entries_vec[target_idx]); + } else { + assert(new_entries_vec[jj] == old_entries_vec[jj]); + let oe = old_entries_vec[jj]; + assert(old_entries.dom().contains((oe.purse, oe.idx))); + assert((oe.purse, oe.idx) != key); + assert(old_entries[(oe.purse, oe.idx)] == oe); + } + } + + // (s) ghost → Vec + assert forall|kk: (PurseId, u64)| #[trigger] new_entries.dom().contains(kk) + implies exists|jj: int| + 0 <= jj < new_entries_vec.len() + && #[trigger] new_entries_vec[jj].purse == kk.0 + && new_entries_vec[jj].idx == kk.1 + by { + if kk == key { + let w = target_idx; + assert(new_entries_vec[w].purse == kk.0); + assert(new_entries_vec[w].idx == kk.1); + } else { + assert(old_entries.dom().contains(kk)); + let w = choose|jj: int| + 0 <= jj < old_entries_vec.len() + && #[trigger] old_entries_vec[jj].purse == kk.0 + && old_entries_vec[jj].idx == kk.1; + assert(new_entries_vec[w] == old_entries_vec[w]); + } + } + + // (t) no duplicates. + assert forall|a: int, b: int| + 0 <= a < new_entries_vec.len() && 0 <= b < new_entries_vec.len() + && (#[trigger] new_entries_vec[a]).purse + == (#[trigger] new_entries_vec[b]).purse + && new_entries_vec[a].idx == new_entries_vec[b].idx + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_entries_vec[b] == old_entries_vec[b]); + } else if b == target_idx { + assert(new_entries_vec[a] == old_entries_vec[a]); + } else { + assert(new_entries_vec[a] == old_entries_vec[a]); + assert(new_entries_vec[b] == old_entries_vec[b]); + } + } + } + return; + } + j += 1; + } + proof { + assert(old_entries.dom().contains(key)); + let w = choose|jj: int| + 0 <= jj < old_entries_vec.len() + && #[trigger] old_entries_vec[jj].purse == key.0 + && old_entries_vec[jj].idx == key.1; + } + vstd::pervasive::unreached() + } + + + /// Anonymity-floor confirmation: entry's on-chain state advances + /// `Waiting → Ready` because the chain has confirmed sufficient + /// ring-membership has accumulated. Quint analog: + /// `chainPromoteToReady`. + pub fn mark_entry_ready(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + old(self).entries()[key].on_chain == EntryOnChain::Waiting, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries().dom().contains(key), + final(self).entries()[key].on_chain == EntryOnChain::Ready, + final(self).entries()[key].local == old(self).entries()[key].local, + final(self).entries()[key].exponent == old(self).entries()[key].exponent, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::EntryReadinessChanged { + purse: key.0, + exponent: old(self).entries()[key].exponent, + new_state: EntryOnChain::Ready, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let exp = self.read_entry_exponent(key); + self.set_entry_on_chain(key, EntryOnChain::Ready); + self.emit_event(Event::EntryReadinessChanged { + purse: key.0, + exponent: exp, + new_state: EntryOnChain::Ready, + }); + } + + + /// Anonymity-floor regression: entry's on-chain state degrades + /// `Ready → Missing` because subsequent ring activity has dropped + /// below the floor (or the entry has expired). Quint analog: + /// `chainPromoteToDegraded`. + pub fn mark_entry_missing(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries().insert(key, EntryRec { + on_chain: EntryOnChain::Missing, + ..old(self).entries()[key] + }), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.set_entry_on_chain(key, EntryOnChain::Missing); + } + + + /// Entry local lifecycle: `LocalAvailable` → `LocalLockedFor`. + /// Reserve an entry for an in-flight operation. + pub fn lock_entry(&mut self, key: (PurseId, u64), handle: OpHandle) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + old(self).entries()[key].local == EntryLocal::LocalAvailable, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries().insert(key, EntryRec { + purse: old(self).entries()[key].purse, + idx: old(self).entries()[key].idx, + exponent: old(self).entries()[key].exponent, + member_key: old(self).entries()[key].member_key, + allocated_at: old(self).entries()[key].allocated_at, + ready_at: old(self).entries()[key].ready_at, + ring_idx: old(self).entries()[key].ring_idx, + on_chain: old(self).entries()[key].on_chain, + local: EntryLocal::LocalLockedFor(handle), + }), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + // lock_refint preservation: same conditional shape as lock_coin. + (lock_refint(old(self).coins(), old(self).entries(), old(self).operations()) + && old(self).operations().dom().contains(handle)) + ==> lock_refint(final(self).coins(), final(self).entries(), + final(self).operations()), + { + self.set_entry_local(key, EntryLocal::LocalLockedFor(handle)); + } + + + /// Entry local lifecycle: `LocalLockedFor(_)` → `LocalConsumed`. + /// Finalize an entry's consumption after settlement. + pub fn consume_entry(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + exists|h: OpHandle| old(self).entries()[key].local == EntryLocal::LocalLockedFor(h), + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries().insert(key, EntryRec { + purse: old(self).entries()[key].purse, + idx: old(self).entries()[key].idx, + exponent: old(self).entries()[key].exponent, + member_key: old(self).entries()[key].member_key, + allocated_at: old(self).entries()[key].allocated_at, + ready_at: old(self).entries()[key].ready_at, + ring_idx: old(self).entries()[key].ring_idx, + on_chain: old(self).entries()[key].on_chain, + local: EntryLocal::LocalConsumed, + }), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::EntryConsumed { + purse: key.0, + exponent: old(self).entries()[key].exponent, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).entries@.len() == old(self).entries@.len(), + lock_refint(old(self).coins(), old(self).entries(), old(self).operations()) + ==> lock_refint(final(self).coins(), final(self).entries(), + final(self).operations()), + { + let exp = self.read_entry_exponent(key); + self.set_entry_local(key, EntryLocal::LocalConsumed); + self.emit_event(Event::EntryConsumed { + purse: key.0, + exponent: exp, + }); + } + + + /// Entry local lifecycle: `LocalLockedFor(_)` → `LocalAvailable`. + /// Release the entry's reservation when the in-flight operation cancels. + pub fn release_entry_lock(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + exists|h: OpHandle| old(self).entries()[key].local == EntryLocal::LocalLockedFor(h), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries().insert(key, EntryRec { + local: EntryLocal::LocalAvailable, + ..old(self).entries()[key] + }), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.set_entry_local(key, EntryLocal::LocalAvailable); + } + + + /// Set a recycler entry's local-side state (Available → LockedFor → + /// Consumed lifecycle). Mirror of `set_entry_on_chain` for the + /// `local` field. Quint analog: `lockEntry`, `consumeEntry`. + pub fn set_entry_local(&mut self, key: (PurseId, u64), new_state: EntryLocal) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries().insert(key, EntryRec { + purse: old(self).entries()[key].purse, + idx: old(self).entries()[key].idx, + exponent: old(self).entries()[key].exponent, + member_key: old(self).entries()[key].member_key, + allocated_at: old(self).entries()[key].allocated_at, + ready_at: old(self).entries()[key].ready_at, + ring_idx: old(self).entries()[key].ring_idx, + on_chain: old(self).entries()[key].on_chain, + local: new_state, + }), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).entries@.len() == old(self).entries@.len(), + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_next_purse_id = self.next_purse_id; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + self.purses@ == old_purses_vec, + self.spec_purses@ == old_spec_purses, + self.next_purse_id == old_next_purse_id, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + self.spec_coins@ == old_coins, + self.coins@ == old_coins_vec, + self.spec_entries@ == old_entries, + self.entries@ == old_entries_vec, + self.spec_operations@ == old_operations, + self.operations@ == old_operations_vec, + old_spec_purses == old(self).spec_purses@, + old_spec_purses == old(self).purses(), + old_coins == old(self).spec_coins@, + old_coins == old(self).coins(), + old_coins_vec == old(self).coins@, + old_entries == old(self).spec_entries@, + old_entries == old(self).entries(), + old_entries_vec == old(self).entries@, + old_operations == old(self).spec_operations@, + old_operations_vec == old(self).operations@, + old_entries.dom().contains(key), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != key.0 + || self.entries@[jj].idx != key.1, + decreases self.entries.len() - j, + { + if self.entries[j].purse == key.0 && self.entries[j].idx == key.1 { + let ghost target_idx = j as int; + let ghost updated = EntryRec { + purse: old_entries[key].purse, + idx: old_entries[key].idx, + exponent: old_entries[key].exponent, + on_chain: old_entries[key].on_chain, + local: new_state, + member_key: old_entries[key].member_key, + allocated_at: old_entries[key].allocated_at, + ready_at: old_entries[key].ready_at, + ring_idx: old_entries[key].ring_idx, + }; + self.entries[j].local = new_state; + + proof { + assert(old_entries[key].purse == key.0); + assert(old_entries[key].idx == key.1); + self.spec_entries = Ghost(self.spec_entries@.insert(key, updated)); + + let new_entries_vec = self.entries@; + let new_entries = self.spec_entries@; + + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.next_purse_id == old_next_purse_id); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_coins); + + assert(new_entries_vec[target_idx].purse == key.0); + assert(new_entries_vec[target_idx].idx == key.1); + assert(new_entries_vec[target_idx].exponent + == old_entries_vec[target_idx].exponent); + assert(new_entries_vec[target_idx].local == new_state); + assert forall|k: int| + 0 <= k < new_entries_vec.len() && k != target_idx implies + #[trigger] new_entries_vec[k] == old_entries_vec[k] + by {} + assert(old_entries_vec[target_idx].purse == key.0); + assert(old_entries_vec[target_idx].idx == key.1); + assert forall|kk: int| + 0 <= kk < old_entries_vec.len() && kk != target_idx implies + (#[trigger] old_entries_vec[kk]).purse != key.0 + || old_entries_vec[kk].idx != key.1 + by {} + + // (o) + assert forall|kk: (PurseId, u64)| #[trigger] new_entries.dom().contains(kk) + implies new_entries[kk].purse == kk.0 && new_entries[kk].idx == kk.1 + by { if kk != key { assert(old_entries.dom().contains(kk)); } } + // (p) + assert forall|kk: (PurseId, u64)| #[trigger] new_entries.dom().contains(kk) + implies self.spec_purses@.dom().contains(kk.0) + by { assert(old_entries.dom().contains(kk)); } + // (q) + assert forall|kk: (PurseId, u64)| #[trigger] new_entries.dom().contains(kk) + implies kk.1 < self.spec_purses@[kk.0].next_entry_idx + by { assert(old_entries.dom().contains(kk)); } + // (r) + assert forall|jj: int| 0 <= jj < new_entries_vec.len() implies + new_entries.dom().contains( + (#[trigger] new_entries_vec[jj].purse, new_entries_vec[jj].idx) + ) + && new_entries[(new_entries_vec[jj].purse, new_entries_vec[jj].idx)] + == new_entries_vec[jj] + by { + if jj == target_idx { + assert(new_entries[key] == updated); + assert(updated == new_entries_vec[target_idx]); + } else { + assert(new_entries_vec[jj] == old_entries_vec[jj]); + let oe = old_entries_vec[jj]; + assert(old_entries.dom().contains((oe.purse, oe.idx))); + assert((oe.purse, oe.idx) != key); + assert(old_entries[(oe.purse, oe.idx)] == oe); + } + } + // (s) + assert forall|kk: (PurseId, u64)| #[trigger] new_entries.dom().contains(kk) + implies exists|jj: int| + 0 <= jj < new_entries_vec.len() + && #[trigger] new_entries_vec[jj].purse == kk.0 + && new_entries_vec[jj].idx == kk.1 + by { + if kk == key { + let w = target_idx; + assert(new_entries_vec[w].purse == kk.0); + assert(new_entries_vec[w].idx == kk.1); + } else { + assert(old_entries.dom().contains(kk)); + let w = choose|jj: int| + 0 <= jj < old_entries_vec.len() + && #[trigger] old_entries_vec[jj].purse == kk.0 + && old_entries_vec[jj].idx == kk.1; + assert(new_entries_vec[w] == old_entries_vec[w]); + } + } + // (t) + assert forall|a: int, b: int| + 0 <= a < new_entries_vec.len() && 0 <= b < new_entries_vec.len() + && (#[trigger] new_entries_vec[a]).purse + == (#[trigger] new_entries_vec[b]).purse + && new_entries_vec[a].idx == new_entries_vec[b].idx + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_entries_vec[b] == old_entries_vec[b]); + } else if b == target_idx { + assert(new_entries_vec[a] == old_entries_vec[a]); + } else { + assert(new_entries_vec[a] == old_entries_vec[a]); + assert(new_entries_vec[b] == old_entries_vec[b]); + } + } + } + return; + } + j += 1; + } + proof { + assert(old_entries.dom().contains(key)); + let w = choose|jj: int| + 0 <= jj < old_entries_vec.len() + && #[trigger] old_entries_vec[jj].purse == key.0 + && old_entries_vec[jj].idx == key.1; + } + vstd::pervasive::unreached() + } + + + /// Internal: read the `exponent` of a recycler entry known to exist by `key`. + pub(crate) fn read_entry_exponent(&self, key: (PurseId, u64)) -> (exp: u8) + requires + self.invariant(), + self.entries().dom().contains(key), + ensures + exp == self.entries()[key].exponent, + { + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + self.entries().dom().contains(key), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != key.0 + || self.entries@[jj].idx != key.1, + decreases self.entries.len() - j, + { + if self.entries[j].purse == key.0 && self.entries[j].idx == key.1 { + proof { + assert(self.spec_entries@[(self.entries@[j as int].purse, self.entries@[j as int].idx)] + == self.entries@[j as int]); + } + return self.entries[j].exponent; + } + j = j + 1; + } + proof { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == key.0 + && self.entries@[jj].idx == key.1; + } + vstd::pervasive::unreached() + } + + + /// True iff `key` is currently in the entry map. + pub fn has_entry(&self, key: (PurseId, u64)) -> (b: bool) + requires + self.invariant(), + ensures + b == self.entries().dom().contains(key), + { + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != key.0 + || self.entries@[jj].idx != key.1, + decreases self.entries.len() - j, + { + if self.entries[j].purse == key.0 && self.entries[j].idx == key.1 { + proof { + assert(self.spec_entries@.dom().contains( + (self.entries@[j as int].purse, self.entries@[j as int].idx) + )); + } + return true; + } + j = j + 1; + } + proof { + if self.entries().dom().contains(key) { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == key.0 + && self.entries@[jj].idx == key.1; + assert(self.entries@[w].purse == key.0); + } + } + false + } + + + /// Gap-limit recovery scan for entries. Same shape as `scan_with_gap_limit` + /// but probing the entry map instead of the coin map. + pub fn scan_entries_with_gap_limit(&self, p: PurseId, gap_limit: u64, max_idx: u64) + -> (found: Vec<(PurseId, u64)>) + requires + self.invariant(), + ensures + forall|i: int| 0 <= i < found@.len() ==> + self.entries().dom().contains(#[trigger] found@[i]) + && found@[i].0 == p, + { + let mut found: Vec<(PurseId, u64)> = Vec::new(); + let mut i: u64 = 0; + let mut gap: u64 = 0; + loop + invariant + self.invariant(), + i <= max_idx + 1, + gap <= gap_limit, + forall|k: int| 0 <= k < found@.len() ==> + self.entries().dom().contains(#[trigger] found@[k]) + && found@[k].0 == p, + decreases + if gap >= gap_limit || i > max_idx { 0int } + else { (max_idx - i) as int + 1 }, + { + if i > max_idx { break; } + if gap >= gap_limit { break; } + if self.has_entry((p, i)) { + found.push((p, i)); + gap = 0; + } else { + gap = gap + 1; + } + if i == u64::MAX { break; } + i = i + 1; + } + found + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_events.rs b/rust/crates/coinage-layer/src/state_events.rs new file mode 100644 index 00000000..3b32bfa0 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_events.rs @@ -0,0 +1,86 @@ +//! Event emission and event-count readers. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Append an event to the layer event stream. Quint analog: any + /// `events' = events.append(e)` clause. Callers compose this with + /// state-mutating ops to declare emissions (note: the existing + /// mutators don't emit yet — this is the primitive on which to + /// build event-emitting wrappers). + pub fn emit_event(&mut self, e: Event) + requires + old(self).invariant(), + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).events@ == old(self).events@.push(e), + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + let ghost old_tokens = self.tokens@; + let ghost old_chain_coins = self.chain_coins@; + let ghost old_chain_entries = self.chain_entries@; + self.events.push(e); + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + assert(self.tokens@ == old_tokens); + assert(self.chain_coins@ == old_chain_coins); + assert(self.chain_entries@ == old_chain_entries); + } + } + + + /// Number of events emitted so far. Quint `events.length()`. + pub fn event_count(&self) -> (n: usize) + requires + self.invariant(), + ensures + n == self.events@.len(), + { + self.events.len() + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_fee.rs b/rust/crates/coinage-layer/src/state_fee.rs new file mode 100644 index 00000000..a1822140 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_fee.rs @@ -0,0 +1,166 @@ +//! Fee-account top-up / deduct / read / `FeeMode` selection. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Top up the fee-account reservoir. Quint `topUpFeeAccount`. + pub fn top_up_fee_account(&mut self, amount: u64) + requires + old(self).invariant(), + old(self).fee_balance <= u64::MAX - amount, + ensures + final(self).invariant(), + final(self).fee_balance == old(self).fee_balance + amount, + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + self.fee_balance = self.fee_balance + amount; + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + } + } + + + /// Spend from the fee-account reservoir. + pub fn deduct_fee(&mut self, amount: u64) -> (res: Result<(), Error>) + requires + old(self).invariant(), + ensures + final(self).invariant(), + match res { + Ok(()) => + old(self).fee_balance >= amount + && final(self).fee_balance == old(self).fee_balance - amount, + Err(Error::InsufficientFunds { requested, available }) => + old(self).fee_balance < amount + && requested == amount + && available == old(self).fee_balance + && final(self).fee_balance == old(self).fee_balance, + Err(_) => false, + }, + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + let res = if self.fee_balance >= amount { + self.fee_balance = self.fee_balance - amount; + Ok(()) + } else { + Err(Error::InsufficientFunds { + requested: amount, + available: self.fee_balance, + }) + }; + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + } + res + } + + + /// Synchronous read of the fee-account balance. + pub fn read_fee_balance(&self) -> (b: u64) + requires + self.invariant(), + ensures + b == self.fee_balance, + { + self.fee_balance + } + + + /// Auto-pick a `FeeMode` based on the current reservoir. + pub fn select_fee_mode(&self, fee: u64) -> (mode: FeeMode) + requires + self.invariant(), + ensures + match mode { + FeeMode::Prepaid => self.fee_balance >= fee, + FeeMode::FromOutput => self.fee_balance < fee, + }, + { + if self.fee_balance >= fee { + FeeMode::Prepaid + } else { + FeeMode::FromOutput + } + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_high_level.rs b/rust/crates/coinage-layer/src/state_high_level.rs new file mode 100644 index 00000000..e580611c --- /dev/null +++ b/rust/crates/coinage-layer/src/state_high_level.rs @@ -0,0 +1,946 @@ +//! High-level ops: transfer, rebalance, export/import, split, unload, top-up, reserve. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Composite operation: `transfer(from, to, min_exp)` selects an + /// `Available` coin in purse `from` with `exponent >= min_exp`, walks + /// it through `PendingSpend → Spent` (simulating chain settlement), + /// then mints a fresh coin in purse `to` with the same exponent. + /// + /// Returns the new coin's `(to, idx)` key, or `None` if no suitable + /// coin was available in `from`. + pub fn transfer(&mut self, from: PurseId, to: PurseId, min_exp: u8) + -> (res: Option<(PurseId, u64)>) + requires + old(self).invariant(), + old(self).purses().dom().contains(to), + old(self).purses()[to].next_coin_idx < u64::MAX, + old(self).next_age < u64::MAX, + old(self).events@.len() + 2 <= u64::MAX as nat, + ensures + final(self).invariant(), + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).entries() == old(self).entries(), + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + match res { + Some(new_key) => + new_key.0 == to + && new_key.1 == old(self).purses()[to].next_coin_idx + && final(self).next_age == old(self).next_age + 1 + && final(self).purses().dom() =~= old(self).purses().dom() + && final(self).purses()[to].id == to + && final(self).purses()[to].name == old(self).purses()[to].name + && final(self).purses()[to].next_coin_idx + == old(self).purses()[to].next_coin_idx + 1 + && final(self).purses()[to].next_entry_idx + == old(self).purses()[to].next_entry_idx + && (forall|q: PurseId| q != to + && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q]) + && (exists|src_key: (PurseId, u64)| + #[trigger] old(self).coins().dom().contains(src_key) + && src_key.0 == from + && old(self).coins()[src_key].state == CoinState::Available + && old(self).coins()[src_key].exponent >= min_exp + && final(self).coins() == old(self).coins() + .insert(src_key, CoinRec { + purse: old(self).coins()[src_key].purse, + idx: old(self).coins()[src_key].idx, + exponent: old(self).coins()[src_key].exponent, + age: old(self).coins()[src_key].age, + account: old(self).coins()[src_key].account, + state: CoinState::Spent, + }) + .insert(new_key, CoinRec { + purse: to, + idx: new_key.1, + exponent: old(self).coins()[src_key].exponent, + state: CoinState::Available, + age: old(self).next_age, + account: 0, + }) + && final(self).events@ == old(self).events@ + .push(Event::CoinSpent { + purse: from, + exponent: old(self).coins()[src_key].exponent, + }) + .push(Event::CoinAvailable { + purse: to, + exponent: old(self).coins()[src_key].exponent, + })), + None => + // No Available coin in `from` met the threshold. + final(self).next_age == old(self).next_age + && final(self).purses() == old(self).purses() + && final(self).coins() == old(self).coins() + && final(self).events@ == old(self).events@ + && (forall|k: (PurseId, u64)| + #[trigger] old(self).coins().dom().contains(k) + && k.0 == from + && old(self).coins()[k].state == CoinState::Available + ==> old(self).coins()[k].exponent < min_exp), + }, + { + match self.select_coin(from, min_exp) { + None => None, + Some(key) => { + let exp = self.read_coin_exponent(key); + self.mark_coin_pending_spend(key); + self.mark_coin_spent(key); + let new_key = self.add_coin(to, exp); + self.mark_coin_observed(new_key); + Some(new_key) + } + } + } + + + /// Export a coin: the layer surrenders custody of a specific + /// `Available` coin (the host has handed its secret to an external + /// party). The coin transitions Available → PendingSpend → Spent; + /// no new coin is minted. Quint analog: `exportCoin`. + pub fn export_coin(&mut self, key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Available, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Spent, + }), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::CoinSpent { + purse: key.0, + exponent: old(self).coins()[key].exponent, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + self.mark_coin_pending_spend(key); + self.mark_coin_spent(key); + } + + + /// Import a coin: an external (account, secret) pair becomes a + /// fresh `Available` coin in purse `p` carrying that account. + /// Quint analog: `importCoin`. The coin skips the Pending → + /// Available chain-observation gap (the host has already verified + /// the coin exists on-chain via the imported secret). + pub fn import_coin(&mut self, p: PurseId, exponent: u8, account: u64) + -> (key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).purses().dom().contains(p), + old(self).purses()[p].next_coin_idx < u64::MAX, + old(self).next_age < u64::MAX, + old(self).events@.len() < u64::MAX as nat, + exponent <= MAX_EXPONENT, + ensures + final(self).invariant(), + key.0 == p, + key.1 == old(self).purses()[p].next_coin_idx, + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: p, + idx: key.1, + exponent, + state: CoinState::Available, + age: old(self).next_age, + account, + }), + final(self).next_age == old(self).next_age + 1, + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx + 1, + final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).events@ == old(self).events@.push(Event::CoinAvailable { + purse: p, + exponent, + }), + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let key = self.add_coin_with_account(p, exponent, account); + self.mark_coin_observed(key); + key + } + + + /// Rebalance: move one specific `Available` coin from purse `src` to + /// purse `dst`. The source coin transitions Available → PendingSpend + /// → Spent; a fresh `Available` coin with the same exponent is minted + /// in `dst`'s namespace. Quint §6.1.3 `rebalancePurse`. + /// + /// Differs from `transfer` in that the caller selects the specific + /// coin (no min-exp search), and `src != dst` is required. + #[allow(unused_variables)] + pub fn rebalance(&mut self, src: PurseId, dst: PurseId, key: (PurseId, u64)) + -> (new_key: (PurseId, u64)) + requires + old(self).invariant(), + src != dst, + key.0 == src, + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Available, + old(self).purses().dom().contains(dst), + old(self).purses()[dst].next_coin_idx < u64::MAX, + old(self).events@.len() + 2 <= u64::MAX as nat, + old(self).next_age < u64::MAX, + ensures + final(self).invariant(), + new_key.0 == dst, + new_key.1 == old(self).purses()[dst].next_coin_idx, + final(self).coins() == old(self).coins() + .insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Spent, + }) + .insert(new_key, CoinRec { + purse: dst, + idx: new_key.1, + exponent: old(self).coins()[key].exponent, + state: CoinState::Available, + age: old(self).next_age, + account: 0, + }), + final(self).next_age == old(self).next_age + 1, + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[dst].id == dst, + final(self).purses()[dst].name == old(self).purses()[dst].name, + final(self).purses()[dst].next_coin_idx + == old(self).purses()[dst].next_coin_idx + 1, + final(self).purses()[dst].next_entry_idx + == old(self).purses()[dst].next_entry_idx, + forall|q: PurseId| q != dst && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).entries() == old(self).entries(), + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).events@ == old(self).events@ + .push(Event::CoinSpent { + purse: src, + exponent: old(self).coins()[key].exponent, + }) + .push(Event::CoinAvailable { + purse: dst, + exponent: old(self).coins()[key].exponent, + }), + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let exp = self.read_coin_exponent(key); + self.mark_coin_pending_spend(key); + self.mark_coin_spent(key); + let new_key = self.add_coin(dst, exp); + self.mark_coin_observed(new_key); + new_key + } + + + /// Split a single `Available` coin into a batch of fresh coins in the + /// same purse, one per element of `new_exponents`. Quint analog: the + /// Tier-2 split step of three-tier selection. + /// + /// The source coin walks Available → PendingSpend → Spent. The new + /// coins arrive in `Pending` state (chain settlement is simulated by + /// the existing `add_coin` semantics; the caller invokes + /// `mark_coin_observed` on each later if needed). + /// + /// **Pilot scope:** no value-preservation check between the source + /// coin's exponent and the sum of new exponents. The design requires + /// `sum(coin_value(new_exp)) == coin_value(old_exp)`; verifying this + /// requires the real `2^exp` semantics (deferred — see stage 7c). + pub fn split_coin(&mut self, key: (PurseId, u64), new_exponents: Vec) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Available, + old(self).purses().dom().contains(key.0), + old(self).purses()[key.0].next_coin_idx as nat + new_exponents@.len() + <= u64::MAX as nat, + old(self).next_age as nat + new_exponents@.len() <= u64::MAX as nat, + old(self).events@.len() < u64::MAX as nat, + forall|j: int| 0 <= j < new_exponents@.len() ==> + (#[trigger] new_exponents@[j]) <= MAX_EXPONENT, + ensures + final(self).invariant(), + // Source coin: same key, state flipped to Spent, other fields preserved. + final(self).coins()[key] == (CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Spent, + }), + // New coins: full records matching the bulk-mint pattern. + forall|j: int| 0 <= j < new_exponents@.len() ==> + #[trigger] final(self).coins()[ + (key.0, (old(self).purses()[key.0].next_coin_idx + j) as u64) + ] == (CoinRec { + purse: key.0, + idx: (old(self).purses()[key.0].next_coin_idx + j) as u64, + exponent: new_exponents@[j], + state: CoinState::Pending, + age: (old(self).next_age + j) as u64, + account: 0, + }), + // Coins domain: old keys (each preserving its old record, except + // for `key` which is now Spent) plus the new contiguous range. + final(self).coins().dom() =~= old(self).coins().dom().union( + Set::new(|k: (PurseId, u64)| + k.0 == key.0 + && (old(self).purses()[key.0].next_coin_idx as int) <= (k.1 as int) + && (k.1 as int) < (old(self).purses()[key.0].next_coin_idx as int) + + new_exponents@.len() as int) + ), + forall|k: (PurseId, u64)| #[trigger] old(self).coins().dom().contains(k) + && k != key + ==> final(self).coins()[k] == old(self).coins()[k], + // Purses: only key.0's next_coin_idx advances. + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[key.0].id == key.0, + final(self).purses()[key.0].name == old(self).purses()[key.0].name, + final(self).purses()[key.0].next_coin_idx + == old(self).purses()[key.0].next_coin_idx + new_exponents@.len(), + final(self).purses()[key.0].next_entry_idx + == old(self).purses()[key.0].next_entry_idx, + forall|q: PurseId| q != key.0 && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).next_age == old(self).next_age + new_exponents@.len(), + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).events@ == old(self).events@.push(Event::CoinSpent { + purse: key.0, + exponent: old(self).coins()[key].exponent, + }), + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_coins = self.coins(); + self.mark_coin_pending_spend(key); + self.mark_coin_spent(key); + let ghost pre_top_up_coins = self.coins(); + let ghost pre_top_up_purses = self.purses(); + self.top_up_purse(key.0, new_exponents); + proof { + // top_up_purse preserves existing keys: key is still in dom with + // its Spent state. + assert(pre_top_up_coins.dom().contains(key)); + assert(pre_top_up_coins[key].state == CoinState::Spent); + // For every old key k != key: the two mark_coin_* calls preserve + // it (they only insert at `key`), and top_up_purse preserves all + // existing keys. + assert forall|k: (PurseId, u64)| #[trigger] old_coins.dom().contains(k) + && k != key + implies self.coins()[k] == old_coins[k] + by { + assert(pre_top_up_coins.dom().contains(k)); + assert(pre_top_up_coins[k] == old_coins[k]); + } + } + } + + + /// Tier-3 unload: consume a `Ready` recycler entry to mint a fresh + /// `Available` coin in the same purse. The entry walks + /// `LocalAvailable → LocalLockedFor → LocalConsumed`; the new coin + /// walks `Pending → Available` via observation. + /// + /// Quint analog: the local-state effect of `startExternalOffload` + /// (without the external account / chain-side bookkeeping). + pub fn unload_via_entry(&mut self, key: (PurseId, u64), handle: OpHandle) + -> (new_coin_key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + old(self).entries()[key].local == EntryLocal::LocalAvailable, + old(self).entries()[key].on_chain == EntryOnChain::Ready, + old(self).purses().dom().contains(key.0), + old(self).purses()[key.0].next_coin_idx < u64::MAX, + old(self).next_age < u64::MAX, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + new_coin_key.0 == key.0, + new_coin_key.1 == old(self).purses()[key.0].next_coin_idx, + final(self).entries() == old(self).entries().insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..old(self).entries()[key] + }), + final(self).coins() == old(self).coins().insert(new_coin_key, CoinRec { + purse: key.0, + idx: new_coin_key.1, + exponent: old(self).entries()[key].exponent, + state: CoinState::Available, + age: old(self).next_age, + account: 0, + }), + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[key.0].id == key.0, + final(self).purses()[key.0].name == old(self).purses()[key.0].name, + final(self).purses()[key.0].next_coin_idx + == old(self).purses()[key.0].next_coin_idx + 1, + final(self).purses()[key.0].next_entry_idx + == old(self).purses()[key.0].next_entry_idx, + forall|q: PurseId| q != key.0 && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).next_age == old(self).next_age + 1, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).events@ == old(self).events@.push(Event::CoinAvailable { + purse: key.0, + exponent: old(self).entries()[key].exponent, + }), + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let exp = self.read_entry_exponent(key); + self.set_entry_local(key, EntryLocal::LocalLockedFor(handle)); + self.set_entry_local(key, EntryLocal::LocalConsumed); + let ghost post_consume_entries = self.entries(); + let new_key = self.add_coin(key.0, exp); + self.mark_coin_observed(new_key); + proof { + // add_coin and mark_coin_observed preserve entries (sibling-field + // stability). The entry's local==Consumed survives unchanged. + assert(self.entries() == post_consume_entries); + assert(post_consume_entries.dom().contains(key)); + assert(post_consume_entries[key].local == EntryLocal::LocalConsumed); + } + new_key + } + + + /// Top-up via recycler entry (Quint `topUp`): allocate a fresh + /// recycler entry of `exponent` in purse `p`, in the `Waiting` / + /// `LocalAvailable` state. Caller supplies the chain-side + /// bookkeeping (`member_key`, `allocated_at`, `ready_at`, + /// `ring_idx`) — these come from the host's chain abstraction + /// (e.g. derive `member_key` from the purse's anonymity-ring + /// secret, `ready_at = allocated_at + JitterMax`). + /// + /// This is the entry-side bottom-layer effect of the design §8.2 + /// top-up — funds entering via a recycler ring rather than as + /// direct coins. Pair with `set_entry_on_chain` once the chain + /// confirms ring-membership floor → entry becomes `Ready`. + pub fn top_up_via_entry( + &mut self, + p: PurseId, + exponent: u8, + member_key: u64, + allocated_at: u64, + ready_at: u64, + ring_idx: u64, + ) -> (key: (PurseId, u64)) + requires + old(self).invariant(), + old(self).purses().dom().contains(p), + old(self).purses()[p].next_entry_idx < u64::MAX, + old(self).events@.len() < u64::MAX as nat, + exponent <= MAX_EXPONENT, + ensures + final(self).invariant(), + key.0 == p, + key.1 == old(self).purses()[p].next_entry_idx, + final(self).entries() == old(self).entries().insert(key, EntryRec { + purse: p, + idx: key.1, + exponent, + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key, + allocated_at, + ready_at, + ring_idx, + }), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx, + final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx + 1, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::EntryAllocated { + purse: p, + exponent, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let key = self.add_entry_with_meta( + p, + exponent, + EntryOnChain::Waiting, + EntryLocal::LocalAvailable, + member_key, + allocated_at, + ready_at, + ring_idx, + ); + self.emit_event(Event::EntryAllocated { + purse: p, + exponent, + }); + key + } + + + /// Top-up: allocate `exp_seq.len()` fresh coins in purse `p`, one per + /// exponent in `exp_seq` (in order). Each call to `add_coin` allocates the + /// next available coin index, so the resulting coin keys are + /// `(p, old_next_coin_idx)`, `(p, old_next_coin_idx + 1)`, … + /// + /// This is the design §8.2 top-up reduced to its bottom-layer effect: + /// produce a batch of new coins under the purse's namespace. The chain + /// interaction, fee handling, and `FundingOrigin` plumbing are deferred. + pub fn top_up_purse(&mut self, p: PurseId, exp_seq: Vec) + requires + old(self).invariant(), + old(self).purses().dom().contains(p), + old(self).purses()[p].next_coin_idx as nat + exp_seq@.len() <= u64::MAX as nat, + old(self).next_age as nat + exp_seq@.len() <= u64::MAX as nat, + forall|j: int| 0 <= j < exp_seq@.len() ==> + (#[trigger] exp_seq@[j]) <= MAX_EXPONENT, + ensures + final(self).invariant(), + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx + exp_seq@.len(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_entry_idx == old(self).purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + // Existing coins preserved. + forall|k: (PurseId, u64)| #[trigger] old(self).coins().dom().contains(k) + ==> final(self).coins().dom().contains(k) + && final(self).coins()[k] == old(self).coins()[k], + // New coin keys are in the dom; record fields match the request. + forall|j: int| 0 <= j < exp_seq@.len() ==> + #[trigger] final(self).coins().dom().contains( + (p, (old(self).purses()[p].next_coin_idx + j) as u64) + ) + && final(self).coins()[ + (p, (old(self).purses()[p].next_coin_idx + j) as u64) + ].exponent == exp_seq@[j], + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).events@ == old(self).events@, + final(self).next_age == old(self).next_age + exp_seq@.len(), + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + // Domain-equality form: every key in the final coins map is + // either an old key (with its old record) or one of the new + // (p, old_next + j) keys (with its exp_seq[j] record). + final(self).coins().dom() =~= old(self).coins().dom().union( + Set::new(|k: (PurseId, u64)| + k.0 == p + && (old(self).purses()[p].next_coin_idx as int) <= (k.1 as int) + && (k.1 as int) < (old(self).purses()[p].next_coin_idx as int) + + exp_seq@.len() as int) + ), + forall|j: int| 0 <= j < exp_seq@.len() ==> + #[trigger] final(self).coins()[ + (p, (old(self).purses()[p].next_coin_idx + j) as u64) + ] == (CoinRec { + purse: p, + idx: (old(self).purses()[p].next_coin_idx + j) as u64, + exponent: exp_seq@[j], + state: CoinState::Pending, + age: (old(self).next_age + j) as u64, + account: 0, + }), + { + let ghost old_p_next = old(self).purses()[p].next_coin_idx; + let ghost old_next_age = old(self).next_age; + let ghost old_purses_map = old(self).purses(); + let ghost old_coins_map = old(self).coins(); + let ghost old_operations_map = old(self).operations(); + let ghost old_operations_vec = old(self).operations@; + let ghost old_spec_operations = old(self).spec_operations@; + let ghost old_entries_map = old(self).entries(); + let ghost old_entries_vec = old(self).entries@; + let ghost old_spec_entries = old(self).spec_entries@; + let ghost old_next_handle = old(self).next_handle; + let ghost old_events = old(self).events@; + let n = exp_seq.len(); + + let mut k: usize = 0; + while k < n + invariant + 0 <= k <= n, + n == exp_seq@.len(), + self.invariant(), + self.events@ == old_events, + forall|j: int| 0 <= j < exp_seq@.len() ==> + (#[trigger] exp_seq@[j]) <= MAX_EXPONENT, + self.purses().dom() =~= old_purses_map.dom(), + old_purses_map.dom().contains(p), + self.purses()[p].next_coin_idx == old_p_next + k as nat, + self.purses()[p].id == p, + self.purses()[p].name == old_purses_map[p].name, + self.purses()[p].next_entry_idx == old_purses_map[p].next_entry_idx, + old_p_next == old_purses_map[p].next_coin_idx, + old_p_next as nat + n as nat <= u64::MAX as nat, + self.next_age == old_next_age + k as nat, + old_next_age == old(self).next_age, + old_next_age as nat + n as nat <= u64::MAX as nat, + self.operations() == old_operations_map, + self.operations@ == old_operations_vec, + self.spec_operations@ == old_spec_operations, + self.next_handle == old_next_handle, + self.entries() == old_entries_map, + self.entries@ == old_entries_vec, + self.spec_entries@ == old_spec_entries, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + // Cumulative new coins so far have their full records. + forall|j: int| 0 <= j < k as int ==> + #[trigger] self.coins()[(p, (old_p_next + j) as u64)] + == (CoinRec { + purse: p, + idx: (old_p_next + j) as u64, + exponent: exp_seq@[j], + state: CoinState::Pending, + age: (old_next_age + j) as u64, + account: 0, + }), + // Cumulative new-key domain. + self.coins().dom() =~= old_coins_map.dom().union( + Set::new(|kk: (PurseId, u64)| + kk.0 == p + && (old_p_next as int) <= (kk.1 as int) + && (kk.1 as int) < (old_p_next as int) + k as int) + ), + old_operations_map == old(self).operations(), + old_operations_vec == old(self).operations@, + old_spec_operations == old(self).spec_operations@, + old_next_handle == old(self).next_handle, + old_entries_map == old(self).entries(), + old_entries_vec == old(self).entries@, + old_spec_entries == old(self).spec_entries@, + forall|q: PurseId| q != p && #[trigger] old_purses_map.dom().contains(q) + ==> self.purses()[q] == old_purses_map[q], + forall|key: (PurseId, u64)| #[trigger] old_coins_map.dom().contains(key) + ==> self.coins().dom().contains(key) + && self.coins()[key] == old_coins_map[key], + forall|j: int| 0 <= j < k as int ==> + #[trigger] self.coins().dom().contains((p, (old_p_next + j) as u64)) + && self.coins()[(p, (old_p_next + j) as u64)].exponent == exp_seq@[j], + decreases n - k, + { + let exp = exp_seq[k]; + let ghost prev_next_coin_idx = self.purses()[p].next_coin_idx; + let ghost pre_coins = self.coins(); + assert(prev_next_coin_idx == old_p_next + k as nat); + assert(prev_next_coin_idx < u64::MAX); + #[allow(unused_variables)] + let new_key = self.add_coin(p, exp); + proof { + assert(new_key == (p, (old_p_next + k as nat) as u64)); + // Forall j in [0, k+1), the expected key is in coins.dom. + // j == k is the just-added coin; j < k is an existing coin + // that survives `insert(new_key, _)` since keys differ. + assert forall|j: int| 0 <= j < (k + 1) as int implies + #[trigger] self.coins().dom().contains((p, (old_p_next + j) as u64)) + && self.coins()[(p, (old_p_next + j) as u64)].exponent == exp_seq@[j] + by { + let nk = (p, (old_p_next + j) as u64); + if j == k as int { + assert(nk == new_key); + assert(self.coins()[new_key].exponent == exp); + assert(exp == exp_seq@[k as int]); + } else { + assert(j < k as int); + assert(pre_coins.dom().contains(nk)); + assert(pre_coins[nk].exponent == exp_seq@[j]); + assert(nk.1 != new_key.1); + } + } + } + k += 1; + } + } + + + /// Reserve: allocate `exp_seq.len()` fresh recycler entries in purse `p`, + /// one per exponent in `exp_seq` (in order). Mirror of `top_up_purse` for + /// the entry side. New entries start in `(on_chain=Waiting, + /// local=LocalAvailable)`. + pub fn reserve_entries(&mut self, p: PurseId, exp_seq: Vec) + requires + old(self).invariant(), + old(self).purses().dom().contains(p), + old(self).purses()[p].next_entry_idx as nat + exp_seq@.len() <= u64::MAX as nat, + forall|j: int| 0 <= j < exp_seq@.len() ==> + (#[trigger] exp_seq@[j]) <= MAX_EXPONENT, + ensures + final(self).invariant(), + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx + exp_seq@.len(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_coin_idx == old(self).purses()[p].next_coin_idx, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + // Coins entirely untouched. + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + // Existing entries preserved. + forall|k: (PurseId, u64)| #[trigger] old(self).entries().dom().contains(k) + ==> final(self).entries().dom().contains(k) + && final(self).entries()[k] == old(self).entries()[k], + // New entry keys are in the dom; full records match the request. + forall|j: int| 0 <= j < exp_seq@.len() ==> + #[trigger] final(self).entries()[ + (p, (old(self).purses()[p].next_entry_idx + j) as u64) + ] == (EntryRec { + purse: p, + idx: (old(self).purses()[p].next_entry_idx + j) as u64, + exponent: exp_seq@[j], + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key: 0, + allocated_at: 0, + ready_at: 0, + ring_idx: 0, + }), + // Domain-union form: old keys plus the new contiguous range. + final(self).entries().dom() =~= old(self).entries().dom().union( + Set::new(|k: (PurseId, u64)| + k.0 == p + && (old(self).purses()[p].next_entry_idx as int) <= (k.1 as int) + && (k.1 as int) < (old(self).purses()[p].next_entry_idx as int) + + exp_seq@.len() as int) + ), + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).events@ == old(self).events@, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_p_next = old(self).purses()[p].next_entry_idx; + let ghost old_purses_map = old(self).purses(); + let ghost old_entries_map = old(self).entries(); + let n = exp_seq.len(); + + let mut k: usize = 0; + while k < n + invariant + 0 <= k <= n, + n == exp_seq@.len(), + self.invariant(), + forall|j: int| 0 <= j < exp_seq@.len() ==> + (#[trigger] exp_seq@[j]) <= MAX_EXPONENT, + self.purses().dom() =~= old_purses_map.dom(), + old_purses_map.dom().contains(p), + self.purses()[p].next_entry_idx == old_p_next + k as nat, + self.purses()[p].id == p, + self.purses()[p].name == old_purses_map[p].name, + self.purses()[p].next_coin_idx == old_purses_map[p].next_coin_idx, + old_p_next == old_purses_map[p].next_entry_idx, + old_p_next as nat + n as nat <= u64::MAX as nat, + forall|q: PurseId| q != p && #[trigger] old_purses_map.dom().contains(q) + ==> self.purses()[q] == old_purses_map[q], + self.coins() == old(self).coins(), + self.coins@ == old(self).coins@, + self.operations() == old(self).operations(), + self.operations@ == old(self).operations@, + self.spec_operations@ == old(self).spec_operations@, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.events@ == old(self).events@, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + forall|key: (PurseId, u64)| #[trigger] old_entries_map.dom().contains(key) + ==> self.entries().dom().contains(key) + && self.entries()[key] == old_entries_map[key], + forall|j: int| 0 <= j < k as int ==> + #[trigger] self.entries()[(p, (old_p_next + j) as u64)] + == (EntryRec { + purse: p, + idx: (old_p_next + j) as u64, + exponent: exp_seq@[j], + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key: 0, + allocated_at: 0, + ready_at: 0, + ring_idx: 0, + }), + self.entries().dom() =~= old_entries_map.dom().union( + Set::new(|kk: (PurseId, u64)| + kk.0 == p + && (old_p_next as int) <= (kk.1 as int) + && (kk.1 as int) < (old_p_next as int) + k as int) + ), + decreases n - k, + { + let exp = exp_seq[k]; + let ghost prev_next_entry_idx = self.purses()[p].next_entry_idx; + let ghost pre_entries = self.entries(); + assert(prev_next_entry_idx == old_p_next + k as nat); + assert(prev_next_entry_idx < u64::MAX); + #[allow(unused_variables)] + let new_key = self.add_entry( + p, + exp, + EntryOnChain::Waiting, + EntryLocal::LocalAvailable, + ); + proof { + assert(new_key == (p, (old_p_next + k as nat) as u64)); + assert forall|j: int| 0 <= j < (k + 1) as int implies + #[trigger] self.entries().dom().contains((p, (old_p_next + j) as u64)) + && self.entries()[(p, (old_p_next + j) as u64)].exponent == exp_seq@[j] + by { + let nk = (p, (old_p_next + j) as u64); + if j == k as int { + assert(nk == new_key); + assert(self.entries()[new_key].exponent == exp); + assert(exp == exp_seq@[k as int]); + } else { + assert(j < k as int); + assert(pre_entries.dom().contains(nk)); + assert(pre_entries[nk].exponent == exp_seq@[j]); + assert(nk.1 != new_key.1); + } + } + } + k += 1; + } + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_invariant.rs b/rust/crates/coinage-layer/src/state_invariant.rs new file mode 100644 index 00000000..73af09e9 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_invariant.rs @@ -0,0 +1,240 @@ +//! Core `impl State` items: view accessors, invariant, init. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Spec view of the purse map. + pub open spec fn purses(&self) -> Map { + self.spec_purses@ + } + + + /// Spec view of the coin map. + pub open spec fn coins(&self) -> Map<(PurseId, u64), CoinRec> { + self.spec_coins@ + } + + + /// Spec view of the recycler-entry map. + pub open spec fn entries(&self) -> Map<(PurseId, u64), EntryRec> { + self.spec_entries@ + } + + + /// Spec view of the operations map. + pub open spec fn operations(&self) -> Map { + self.spec_operations@ + } + + + /// True iff some coin currently lives in purse `p`. + pub open spec fn has_coin_in(&self, p: PurseId) -> bool { + exists|k: (PurseId, u64)| #[trigger] self.coins().dom().contains(k) && k.0 == p + } + + + /// True iff some *live* (non-`Spent`) coin currently lives in purse `p`. + pub open spec fn has_live_coin_in(&self, p: PurseId) -> bool { + exists|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state != CoinState::Spent + } + + + /// Whether the allocator can still mint a fresh `PurseId`. + pub open spec fn has_create_capacity(&self) -> bool { + self.next_purse_id < u64::MAX + } + + + /// State well-formedness. Combines: + /// (a) ghost-map well-formedness (dom keys agree with `id` fields, + /// all ids below `next_purse_id`, MAIN_PURSE present), + /// (b) exec/spec refinement (Vec contents and ghost-map dom in + /// 1-to-1 correspondence, no duplicates). + pub open spec fn invariant(&self) -> bool { + let m = self.spec_purses@; + let v = self.purses@; + &&& self.next_purse_id != MAIN_PURSE + &&& m.dom().contains(MAIN_PURSE) + &&& forall|p: PurseId| #[trigger] m.dom().contains(p) ==> m[p].id == p + &&& forall|p: PurseId| #[trigger] m.dom().contains(p) ==> p < self.next_purse_id + // exec → ghost: every Vec entry is in the map under its own id + &&& forall|i: int| 0 <= i < v.len() ==> #[trigger] m.dom().contains(v[i].id) + &&& forall|i: int| 0 <= i < v.len() ==> m[(#[trigger] v[i]).id] == v[i]@ + // ghost → exec: every map key has a matching Vec entry + &&& forall|p: PurseId| #[trigger] m.dom().contains(p) + ==> exists|i: int| 0 <= i < v.len() && #[trigger] v[i].id == p + // no duplicate ids in the Vec + &&& forall|i: int, j: int| + 0 <= i < v.len() && 0 <= j < v.len() + && #[trigger] v[i].id == #[trigger] v[j].id ==> i == j + // (i) coin key consistency: keyed by (purse, idx), record matches. + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_coins@.dom().contains(k) + ==> self.spec_coins@[k].purse == k.0 && self.spec_coins@[k].idx == k.1 + // (j) coin referential integrity: every coin's purse is a known purse. + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_coins@.dom().contains(k) + ==> m.dom().contains(k.0) + // (k) coin idx is below the owning purse's allocator. Ensures + // `purses[p].next_coin_idx` is always a fresh coin index for p. + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_coins@.dom().contains(k) + ==> k.1 < m[k.0].next_coin_idx + // (l) exec coin Vec → ghost: every Vec entry's (purse, idx) is in dom + // and matches the ghost record. + &&& forall|i: int| 0 <= i < self.coins@.len() ==> + #[trigger] self.spec_coins@.dom().contains( + (self.coins@[i].purse, self.coins@[i].idx) + ) + &&& forall|i: int| 0 <= i < self.coins@.len() ==> + self.spec_coins@[(#[trigger] self.coins@[i].purse, self.coins@[i].idx)] + == self.coins@[i] + // (m) ghost coin map → exec: every dom key has a Vec witness. + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_coins@.dom().contains(k) + ==> exists|i: int| + 0 <= i < self.coins@.len() + && #[trigger] self.coins@[i].purse == k.0 + && self.coins@[i].idx == k.1 + // (n) no duplicate (purse, idx) keys in the coin Vec. + &&& forall|i: int, j: int| + 0 <= i < self.coins@.len() && 0 <= j < self.coins@.len() + && (#[trigger] self.coins@[i]).purse == (#[trigger] self.coins@[j]).purse + && self.coins@[i].idx == self.coins@[j].idx + ==> i == j + // (o) entry key consistency. + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_entries@.dom().contains(k) + ==> self.spec_entries@[k].purse == k.0 + && self.spec_entries@[k].idx == k.1 + // (p) entry referential integrity: every entry's purse is in dom. + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_entries@.dom().contains(k) + ==> m.dom().contains(k.0) + // (q) entry idx is below the owning purse's allocator. + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_entries@.dom().contains(k) + ==> k.1 < m[k.0].next_entry_idx + // (r) exec entry Vec → ghost: every Vec entry's (purse, idx) is in dom + // and matches the ghost record. + &&& forall|i: int| 0 <= i < self.entries@.len() ==> + #[trigger] self.spec_entries@.dom().contains( + (self.entries@[i].purse, self.entries@[i].idx) + ) + &&& forall|i: int| 0 <= i < self.entries@.len() ==> + self.spec_entries@[(#[trigger] self.entries@[i].purse, self.entries@[i].idx)] + == self.entries@[i] + // (s) ghost entry map → exec: every dom key has a Vec witness. + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_entries@.dom().contains(k) + ==> exists|i: int| + 0 <= i < self.entries@.len() + && #[trigger] self.entries@[i].purse == k.0 + && self.entries@[i].idx == k.1 + // (t) no duplicate (purse, idx) keys in the entry Vec. + &&& forall|i: int, j: int| + 0 <= i < self.entries@.len() && 0 <= j < self.entries@.len() + && (#[trigger] self.entries@[i]).purse == (#[trigger] self.entries@[j]).purse + && self.entries@[i].idx == self.entries@[j].idx + ==> i == j + // (u) operation key consistency: spec_operations[h].handle == h. + &&& forall|h: OpHandle| #[trigger] self.spec_operations@.dom().contains(h) + ==> self.spec_operations@[h].handle == h + // (v) handle below allocator. + &&& forall|h: OpHandle| #[trigger] self.spec_operations@.dom().contains(h) + ==> h < self.next_handle + // (w) operation refint to purses. + &&& forall|h: OpHandle| #[trigger] self.spec_operations@.dom().contains(h) + ==> m.dom().contains(self.spec_operations@[h].purse) + // (x) exec operations Vec → ghost. + &&& forall|i: int| 0 <= i < self.operations@.len() ==> + #[trigger] self.spec_operations@.dom().contains(self.operations@[i].handle) + &&& forall|i: int| 0 <= i < self.operations@.len() ==> + self.spec_operations@[(#[trigger] self.operations@[i]).handle] + == self.operations@[i] + // (y) ghost → exec. + &&& forall|h: OpHandle| #[trigger] self.spec_operations@.dom().contains(h) + ==> exists|i: int| + 0 <= i < self.operations@.len() + && #[trigger] self.operations@[i].handle == h + // (z) no duplicate handles in operations Vec. + &&& forall|i: int, j: int| + 0 <= i < self.operations@.len() && 0 <= j < self.operations@.len() + && (#[trigger] self.operations@[i]).handle + == (#[trigger] self.operations@[j]).handle + ==> i == j + // (aa) every coin's exponent is bounded by MAX_EXPONENT. Foundation + // for real `2^exp` arithmetic safety (pow2_u64_exec(exp) doesn't + // overflow u64 only when exp <= 30 = MAX_EXPONENT). + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_coins@.dom().contains(k) + ==> self.spec_coins@[k].exponent <= MAX_EXPONENT + // (ab) every entry's exponent is bounded by MAX_EXPONENT. + &&& forall|k: (PurseId, u64)| #[trigger] self.spec_entries@.dom().contains(k) + ==> self.spec_entries@[k].exponent <= MAX_EXPONENT + // (ac) every chain-mirror coin's exponent is bounded too. This lets + // restore_chain_coin reconstruct local state without losing the + // exponent bound. + &&& forall|i: int| 0 <= i < self.chain_coins@.len() + ==> (#[trigger] self.chain_coins@[i]).exponent <= MAX_EXPONENT + // (ad) every chain-mirror entry's exponent is bounded. + &&& forall|i: int| 0 <= i < self.chain_entries@.len() + ==> (#[trigger] self.chain_entries@[i]).exponent <= MAX_EXPONENT + } + + + /// Initialize the layer with only the main purse and an empty coin map. + pub fn init() -> (s: State) + ensures + s.invariant(), + s.purses().dom() =~= set![MAIN_PURSE], + s.purses()[MAIN_PURSE] == (PurseRecSpec { + id: MAIN_PURSE, + name: Seq::empty(), + next_coin_idx: 0, + next_entry_idx: 0, + }), + s.coins().dom() =~= Set::<(PurseId, u64)>::empty(), + lock_refint(s.coins(), s.entries(), s.operations()), + { + let main_rec = PurseRec { + id: MAIN_PURSE, + name: Vec::new(), + next_coin_idx: 0, + next_entry_idx: 0, + }; + let ghost main_spec = main_rec@; + let mut purses: Vec = Vec::new(); + purses.push(main_rec); + let coins: Vec = Vec::new(); + let entries: Vec = Vec::new(); + let operations: Vec = Vec::new(); + let s = State { + purses, + coins, + entries, + operations, + next_purse_id: 1, + next_handle: 0, + next_age: 0, + fee_balance: 0, + next_extrinsic_id: 0, + events: Vec::new(), + paid_ring_membership: 0, + total_in: 0, + total_out: 0, + tokens: Vec::new(), + chain_coins: Vec::new(), + chain_entries: Vec::new(), + spec_purses: Ghost(Map::::empty().insert(MAIN_PURSE, main_spec)), + spec_coins: Ghost(Map::<(PurseId, u64), CoinRec>::empty()), + spec_entries: Ghost(Map::<(PurseId, u64), EntryRec>::empty()), + spec_operations: Ghost(Map::::empty()), + }; + assert(s.purses@.len() == 1); + assert(s.purses@[0].id == MAIN_PURSE); + assert(s.spec_purses@.dom() =~= set![MAIN_PURSE]); + s + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_operations.rs b/rust/crates/coinage-layer/src/state_operations.rs new file mode 100644 index 00000000..3dac0e86 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_operations.rs @@ -0,0 +1,836 @@ +//! Operation lifecycle: `start_op`, status transitions, bulk lock-release helpers. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Start a new operation in the `Preparing` state. Allocates a fresh + /// `OpHandle` from the layer's allocator. Quint analog: the local- + /// state effect of starting any operation kind (the chain interaction + /// is deferred to `transition_op_status`). + pub fn start_op(&mut self, kind: OpKind, purse: PurseId) -> (handle: OpHandle) + requires + old(self).invariant(), + old(self).purses().dom().contains(purse), + old(self).next_handle < u64::MAX, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + handle == old(self).next_handle, + !old(self).operations().dom().contains(handle), + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle, + kind, + purse, + status: OpStatus::Preparing, + }), + final(self).next_handle == old(self).next_handle + 1, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::OperationStarted { + handle, + kind, + purse, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + // Other state untouched. + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).next_purse_id == old(self).next_purse_id, + // lock_refint preservation: operations.dom strictly grows + // (adds `handle`), and coins/entries are untouched. Every + // existing edge in refint still points into the larger ops set. + lock_refint(old(self).coins(), old(self).entries(), old(self).operations()) + ==> lock_refint(final(self).coins(), final(self).entries(), + final(self).operations()), + { + let ghost old_ops = self.spec_operations@; + let ghost old_ops_vec = self.operations@; + let ghost old_m = self.spec_purses@; + let handle = self.next_handle; + let new_op = OperationRec { + handle, + kind, + purse, + status: OpStatus::Preparing, + }; + // Each existing operation's handle is strictly less than the new one + // by old invariant (v). + proof { + assert forall|i: int| 0 <= i < old_ops_vec.len() implies + #[trigger] old_ops_vec[i].handle < handle + by { + assert(old_ops.dom().contains(old_ops_vec[i].handle)); + } + } + self.operations.push(new_op); + proof { + self.spec_operations = Ghost(self.spec_operations@.insert(handle, new_op)); + } + self.next_handle = handle + 1; + + proof { + // Purses / coins / entries are entirely untouched. + assert(self.purses@ == old(self).purses@); + assert(self.spec_purses@ == old_m); + assert(self.coins@ == old(self).coins@); + assert(self.spec_coins@ == old(self).spec_coins@); + assert(self.entries@ == old(self).entries@); + assert(self.spec_entries@ == old(self).spec_entries@); + assert(self.next_purse_id == old(self).next_purse_id); + + let new_ops = self.spec_operations@; + let new_ops_vec = self.operations@; + let last = old_ops_vec.len() as int; + assert(new_ops_vec.len() == old_ops_vec.len() + 1); + assert(new_ops_vec[last] == new_op); + assert forall|i: int| 0 <= i < old_ops_vec.len() implies + #[trigger] new_ops_vec[i] == old_ops_vec[i] + by {} + + // (u) key consistency. + assert forall|h: OpHandle| #[trigger] new_ops.dom().contains(h) + implies new_ops[h].handle == h + by { + if h != handle { assert(old_ops.dom().contains(h)); } + } + // (v) handle below allocator. + assert forall|h: OpHandle| #[trigger] new_ops.dom().contains(h) + implies h < self.next_handle + by { + if h != handle { assert(old_ops.dom().contains(h)); } + } + // (w) refint. + assert forall|h: OpHandle| #[trigger] new_ops.dom().contains(h) + implies self.spec_purses@.dom().contains(new_ops[h].purse) + by { + if h == handle { + assert(new_ops[handle].purse == purse); + } else { + assert(old_ops.dom().contains(h)); + } + } + // (x) Vec → ghost. + assert forall|i: int| 0 <= i < new_ops_vec.len() implies + new_ops.dom().contains((#[trigger] new_ops_vec[i]).handle) + && new_ops[new_ops_vec[i].handle] == new_ops_vec[i] + by { + if i == last { + assert(new_ops_vec[i] == new_op); + assert(new_ops[handle] == new_op); + } else { + assert(new_ops_vec[i] == old_ops_vec[i]); + assert(old_ops.dom().contains(old_ops_vec[i].handle)); + assert(old_ops_vec[i].handle != handle); + assert(old_ops[old_ops_vec[i].handle] == old_ops_vec[i]); + } + } + // (y) ghost → Vec. + assert forall|h: OpHandle| #[trigger] new_ops.dom().contains(h) + implies exists|i: int| + 0 <= i < new_ops_vec.len() + && #[trigger] new_ops_vec[i].handle == h + by { + if h == handle { + let w = last; + assert(new_ops_vec[w].handle == handle); + } else { + assert(old_ops.dom().contains(h)); + let w = choose|i: int| + 0 <= i < old_ops_vec.len() + && #[trigger] old_ops_vec[i].handle == h; + assert(new_ops_vec[w] == old_ops_vec[w]); + } + } + // (z) no duplicates. + assert forall|a: int, b: int| + 0 <= a < new_ops_vec.len() && 0 <= b < new_ops_vec.len() + && (#[trigger] new_ops_vec[a]).handle + == (#[trigger] new_ops_vec[b]).handle + implies a == b + by { + if a == last && b == last { + } else if a == last { + assert(new_ops_vec[b] == old_ops_vec[b]); + assert(new_ops_vec[a].handle == handle); + assert(old_ops_vec[b].handle < handle); + } else if b == last { + assert(new_ops_vec[a] == old_ops_vec[a]); + assert(new_ops_vec[b].handle == handle); + assert(old_ops_vec[a].handle < handle); + } else { + assert(new_ops_vec[a] == old_ops_vec[a]); + assert(new_ops_vec[b] == old_ops_vec[b]); + } + } + } + self.emit_event(Event::OperationStarted { handle, kind, purse }); + handle + } + + + /// Transition the operation identified by `handle` to a new status. + /// Mirror of `set_entry_on_chain` for operations. Used by named + /// wrappers (`mark_op_submitted`, `mark_op_done`, `mark_op_failed`). + pub fn set_op_status(&mut self, handle: OpHandle, new_status: OpStatus) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: new_status, + }), + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + let ghost old_ops = self.spec_operations@; + let ghost old_ops_vec = self.operations@; + + let mut j: usize = 0; + while j < self.operations.len() + invariant + 0 <= j <= self.operations.len(), + self.invariant(), + self.purses@ == old_purses_vec, + self.spec_purses@ == old_spec_purses, + self.next_purse_id == old(self).next_purse_id, + self.spec_coins@ == old_coins, + self.coins@ == old_coins_vec, + self.spec_entries@ == old_entries, + self.entries@ == old_entries_vec, + self.spec_operations@ == old_operations, + self.operations@ == old_operations_vec, + self.spec_operations@ == old_ops, + self.operations@ == old_ops_vec, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + old_purses_vec == old(self).purses@, + old_spec_purses == old(self).spec_purses@, + old_spec_purses == old(self).purses(), + old_coins == old(self).spec_coins@, + old_coins == old(self).coins(), + old_coins_vec == old(self).coins@, + old_entries == old(self).spec_entries@, + old_entries == old(self).entries(), + old_entries_vec == old(self).entries@, + old_operations == old(self).spec_operations@, + old_operations_vec == old(self).operations@, + old_ops == old(self).spec_operations@, + old_ops == old(self).operations(), + old_ops.dom().contains(handle), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.operations@[jj]).handle != handle, + decreases self.operations.len() - j, + { + if self.operations[j].handle == handle { + let ghost target_idx = j as int; + let ghost updated = OperationRec { + handle: old_ops[handle].handle, + kind: old_ops[handle].kind, + purse: old_ops[handle].purse, + status: new_status, + }; + self.operations[j].status = new_status; + + proof { + assert(old_ops[handle].handle == handle); + self.spec_operations = Ghost(self.spec_operations@.insert(handle, updated)); + + let new_ops_vec = self.operations@; + let new_ops = self.spec_operations@; + + assert(new_ops_vec[target_idx].handle == handle); + assert(new_ops_vec[target_idx].kind == old_ops_vec[target_idx].kind); + assert(new_ops_vec[target_idx].purse == old_ops_vec[target_idx].purse); + assert(new_ops_vec[target_idx].status == new_status); + assert forall|k: int| + 0 <= k < new_ops_vec.len() && k != target_idx implies + #[trigger] new_ops_vec[k] == old_ops_vec[k] + by {} + assert(old_ops_vec[target_idx].handle == handle); + assert forall|kk: int| + 0 <= kk < old_ops_vec.len() && kk != target_idx implies + (#[trigger] old_ops_vec[kk]).handle != handle + by {} + + // (u) handle consistency. + assert forall|h: OpHandle| #[trigger] new_ops.dom().contains(h) + implies new_ops[h].handle == h + by { if h != handle { assert(old_ops.dom().contains(h)); } } + // (v) handle bound. + assert forall|h: OpHandle| #[trigger] new_ops.dom().contains(h) + implies h < self.next_handle + by { assert(old_ops.dom().contains(h)); } + // (w) refint. + assert forall|h: OpHandle| #[trigger] new_ops.dom().contains(h) + implies self.spec_purses@.dom().contains(new_ops[h].purse) + by { + if h != handle { assert(old_ops.dom().contains(h)); } + } + // (x) Vec → ghost. + assert forall|i: int| 0 <= i < new_ops_vec.len() implies + new_ops.dom().contains((#[trigger] new_ops_vec[i]).handle) + && new_ops[new_ops_vec[i].handle] == new_ops_vec[i] + by { + if i == target_idx { + assert(new_ops[handle] == updated); + assert(updated == new_ops_vec[target_idx]); + } else { + assert(new_ops_vec[i] == old_ops_vec[i]); + let oo = old_ops_vec[i]; + assert(old_ops.dom().contains(oo.handle)); + assert(oo.handle != handle); + assert(old_ops[oo.handle] == oo); + } + } + // (y) ghost → Vec. + assert forall|h: OpHandle| #[trigger] new_ops.dom().contains(h) + implies exists|i: int| + 0 <= i < new_ops_vec.len() + && #[trigger] new_ops_vec[i].handle == h + by { + if h == handle { + let w = target_idx; + assert(new_ops_vec[w].handle == h); + } else { + assert(old_ops.dom().contains(h)); + let w = choose|i: int| + 0 <= i < old_ops_vec.len() + && #[trigger] old_ops_vec[i].handle == h; + assert(new_ops_vec[w] == old_ops_vec[w]); + } + } + // (z) no duplicates. + assert forall|a: int, b: int| + 0 <= a < new_ops_vec.len() && 0 <= b < new_ops_vec.len() + && (#[trigger] new_ops_vec[a]).handle + == (#[trigger] new_ops_vec[b]).handle + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_ops_vec[b] == old_ops_vec[b]); + } else if b == target_idx { + assert(new_ops_vec[a] == old_ops_vec[a]); + } else { + assert(new_ops_vec[a] == old_ops_vec[a]); + assert(new_ops_vec[b] == old_ops_vec[b]); + } + } + + // Purses / coins / entries entirely unchanged. + assert(self.purses@ == old(self).purses@); + assert(self.spec_purses@ == old(self).spec_purses@); + assert(self.coins@ == old(self).coins@); + assert(self.spec_coins@ == old(self).spec_coins@); + assert(self.entries@ == old(self).entries@); + assert(self.spec_entries@ == old(self).spec_entries@); + } + return; + } + j += 1; + } + proof { + assert(old_ops.dom().contains(handle)); + let w = choose|jj: int| + 0 <= jj < old_ops_vec.len() + && #[trigger] old_ops_vec[jj].handle == handle; + } + vstd::pervasive::unreached() + } + + + /// Operation lifecycle: `Preparing` → `Submitted`. Phase order + /// gate matching Quint `submitOp`. + pub fn mark_op_submitted(&mut self, handle: OpHandle) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + old(self).operations()[handle].status == OpStatus::Preparing, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::Submitted, + }), + { + self.set_op_status(handle, OpStatus::Submitted); + self.emit_event(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }); + } + + + /// Operation lifecycle: `Submitted` → `InBlock`. Fires when the + /// extrinsic lands in a block. + pub fn mark_op_in_block(&mut self, handle: OpHandle) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + old(self).operations()[handle].status == OpStatus::Submitted, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::InBlock, + }), + { + self.set_op_status(handle, OpStatus::InBlock); + } + + + /// Operation lifecycle: `InBlock` → `Finalized`. + pub fn mark_op_finalized(&mut self, handle: OpHandle) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + old(self).operations()[handle].status == OpStatus::InBlock, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::Finalized, + }), + { + self.set_op_status(handle, OpStatus::Finalized); + } + + + /// Operation lifecycle: `Finalized` → `Waiting(ready_at)`. Used by + /// top-up: the op waits for a freshly-allocated entry to mature + /// before it can be marked `Done`. + pub fn mark_op_waiting(&mut self, handle: OpHandle, ready_at: u64) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + old(self).operations()[handle].status == OpStatus::Finalized, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::Waiting(ready_at), + }), + { + self.set_op_status(handle, OpStatus::Waiting(ready_at)); + } + + + /// Operation lifecycle: `Finalized | Waiting(_)` → `Done`. Marks + /// the operation as successfully completed. + pub fn mark_op_done(&mut self, handle: OpHandle) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + match old(self).operations()[handle].status { + OpStatus::Finalized => true, + OpStatus::Waiting(_) => true, + _ => false, + }, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::Done, + }), + { + self.set_op_status(handle, OpStatus::Done); + self.emit_event(Event::OperationCompleted { + handle, + status: OpStatus::Done, + }); + } + + + /// Operation lifecycle: any cancellable status (`Preparing`, + /// `Waiting(_)`) → `Failed`. Quint analog: `cancelOp`'s status + /// transition. The caller is responsible for releasing locks via + /// [`Self::release_locked_coin`] / [`Self::release_locked_entry`] + /// before or after invoking this; the bulk-sweep is not bundled + /// here because the cross-state refint invariant that would let + /// us prove "no LockedFor(h) remains" is not yet in the model. + pub fn set_op_failed(&mut self, handle: OpHandle) + requires + old(self).invariant(), + old(self).operations().dom().contains(handle), + match old(self).operations()[handle].status { + OpStatus::Preparing => true, + OpStatus::Waiting(_) => true, + _ => false, + }, + old(self).events@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@.push(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle: old(self).operations()[handle].handle, + kind: old(self).operations()[handle].kind, + purse: old(self).operations()[handle].purse, + status: OpStatus::Failed, + }), + { + self.set_op_status(handle, OpStatus::Failed); + self.emit_event(Event::OperationCompleted { + handle, + status: OpStatus::Failed, + }); + } + + + /// Find and release a single coin locked for `handle`. Returns the + /// released key, or `None` if no coin is currently `LockedFor(handle)`. + /// + /// Building block for bulk sweeps: callers loop until `None` to + /// drain all locks. Decomposes the bulk-sweep proof obligation + /// into one-step ghost map updates, which Verus discharges + /// directly via the underlying release_locked_coin contract. + pub fn release_one_coin_lock_for(&mut self, handle: OpHandle) + -> (res: Option<(PurseId, u64)>) + requires + old(self).invariant(), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + match res { + Some(key) => + old(self).coins().dom().contains(key) + && old(self).coins()[key].state == CoinState::LockedFor(handle) + && final(self).coins() == + old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Available, + }), + None => + final(self).coins() == old(self).coins() + && final(self).coins@ == old(self).coins@ + && forall|k: (PurseId, u64)| + #[trigger] old(self).coins().dom().contains(k) + ==> old(self).coins()[k].state != CoinState::LockedFor(handle), + }, + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + self == old(self), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).state != CoinState::LockedFor(handle), + decreases self.coins.len() - j, + { + let needs_release = match self.coins[j].state { + CoinState::LockedFor(h) => h == handle, + _ => false, + }; + if needs_release { + let key = (self.coins[j].purse, self.coins[j].idx); + proof { + assert(self.spec_coins@.dom().contains(key)); + assert(self.coins()[key] == self.coins@[j as int]); + assert(self.coins()[key].state == CoinState::LockedFor(handle)); + } + self.release_locked_coin(key, handle); + return Some(key); + } + j = j + 1; + } + // No match: lift Vec-side bound to ghost map. + proof { + assert forall|k: (PurseId, u64)| + #[trigger] old(self).coins().dom().contains(k) + implies old(self).coins()[k].state != CoinState::LockedFor(handle) + by { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].state == self.coins()[k].state); + } + } + None + } + + + /// Find and release a single entry locally locked for `handle`. + /// Returns the released key, or `None` if no entry is currently + /// `LocalLockedFor(handle)`. Entry parallel of + /// [`Self::release_one_coin_lock_for`]. + pub fn release_one_entry_lock_for(&mut self, handle: OpHandle) + -> (res: Option<(PurseId, u64)>) + requires + old(self).invariant(), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + match res { + Some(key) => + old(self).entries().dom().contains(key) + && old(self).entries()[key].local + == EntryLocal::LocalLockedFor(handle) + && final(self).entries() == + old(self).entries().insert(key, EntryRec { + purse: old(self).entries()[key].purse, + idx: old(self).entries()[key].idx, + exponent: old(self).entries()[key].exponent, + member_key: old(self).entries()[key].member_key, + allocated_at: old(self).entries()[key].allocated_at, + ready_at: old(self).entries()[key].ready_at, + ring_idx: old(self).entries()[key].ring_idx, + on_chain: old(self).entries()[key].on_chain, + local: EntryLocal::LocalAvailable, + }), + None => + final(self).entries() == old(self).entries() + && final(self).entries@ == old(self).entries@ + && forall|k: (PurseId, u64)| + #[trigger] old(self).entries().dom().contains(k) + ==> old(self).entries()[k].local + != EntryLocal::LocalLockedFor(handle), + }, + { + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + self == old(self), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).local + != EntryLocal::LocalLockedFor(handle), + decreases self.entries.len() - j, + { + let needs_release = match self.entries[j].local { + EntryLocal::LocalLockedFor(h) => h == handle, + _ => false, + }; + if needs_release { + let key = (self.entries[j].purse, self.entries[j].idx); + proof { + assert(self.spec_entries@.dom().contains(key)); + assert(self.entries()[key] == self.entries@[j as int]); + assert(self.entries()[key].local + == EntryLocal::LocalLockedFor(handle)); + } + self.release_locked_entry(key, handle); + return Some(key); + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] old(self).entries().dom().contains(k) + implies old(self).entries()[k].local + != EntryLocal::LocalLockedFor(handle) + by { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == k.0 + && self.entries@[jj].idx == k.1; + assert(self.entries@[w].local == self.entries()[k].local); + } + } + None + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_purses.rs b/rust/crates/coinage-layer/src/state_purses.rs new file mode 100644 index 00000000..f1645b0f --- /dev/null +++ b/rust/crates/coinage-layer/src/state_purses.rs @@ -0,0 +1,1397 @@ +//! Purse lifecycle: create, rename, delete (safe/forced), bulk purges. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// 6.1 `createPurse` (Quint lines 393-420; design §8.1 `create_purse`). + /// + /// Allocates a fresh `PurseId != MAIN_PURSE`, persists a new purse with + /// the given `name`, returns the assigned id. Synchronous; no chain + /// interaction. + pub fn create_purse(&mut self, name: Vec) -> (new_id: PurseId) + requires + old(self).invariant(), + old(self).has_create_capacity(), + ensures + final(self).invariant(), + new_id != MAIN_PURSE, + new_id == old(self).next_purse_id, + !old(self).purses().dom().contains(new_id), + final(self).purses() == old(self).purses().insert(new_id, PurseRecSpec { + id: new_id, + name: name@, + next_coin_idx: 0, + next_entry_idx: 0, + }), + final(self).next_purse_id == old(self).next_purse_id + 1, + // All other state preserved. + final(self).coins() == old(self).coins(), + final(self).entries() == old(self).entries(), + final(self).operations() == old(self).operations(), + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let new_id = self.next_purse_id; + let ghost old_v = self.purses@; + let ghost old_m = self.spec_purses@; + let rec = PurseRec { + id: new_id, + name, + next_coin_idx: 0, + next_entry_idx: 0, + }; + let ghost rec_spec = rec@; + + // Every existing Vec entry's id is < new_id. + proof { + assert forall|i: int| 0 <= i < old_v.len() implies + #[trigger] old_v[i].id < new_id + by { + assert(old_m.dom().contains(old_v[i].id)); + } + } + + self.purses.push(rec); + proof { + self.spec_purses = Ghost(self.spec_purses@.insert(new_id, rec_spec)); + } + self.next_purse_id = new_id + 1; + + proof { + let new_v = self.purses@; + let new_m = self.spec_purses@; + let new_next = self.next_purse_id; + + // (a) next_purse_id != MAIN_PURSE + assert(new_next != MAIN_PURSE); + + // (b) MAIN_PURSE in dom + assert(new_m.dom().contains(MAIN_PURSE)); + + // (c) forall p in dom. m[p].id == p + assert forall|p: PurseId| #[trigger] new_m.dom().contains(p) + implies new_m[p].id == p + by { + if p == new_id { + assert(new_m[new_id] == rec_spec); + } else { + assert(old_m.dom().contains(p)); + } + } + + // (d) forall p in dom. p < next_purse_id + assert forall|p: PurseId| #[trigger] new_m.dom().contains(p) + implies p < new_next + by { + if p == new_id { + } else { + assert(old_m.dom().contains(p)); + } + } + + // (e) every Vec entry's id is in dom + assert(new_v == old_v.push(rec)); + assert forall|i: int| 0 <= i < new_v.len() implies + new_m.dom().contains(#[trigger] new_v[i].id) + by { + if i < old_v.len() { + assert(new_v[i] == old_v[i]); + assert(old_m.dom().contains(old_v[i].id)); + } else { + assert(new_v[i].id == new_id); + } + } + + // (f) every Vec entry's spec view matches its dom entry + assert forall|i: int| 0 <= i < new_v.len() implies + new_m[(#[trigger] new_v[i]).id] == new_v[i]@ + by { + if i < old_v.len() { + assert(new_v[i] == old_v[i]); + assert(old_v[i].id < new_id); + assert(old_m[old_v[i].id] == old_v[i]@); + } else { + assert(new_v[i].id == new_id); + assert(new_v[i]@ == rec_spec); + } + } + + // (g) every dom key has a Vec witness + assert forall|p: PurseId| #[trigger] new_m.dom().contains(p) + implies exists|i: int| 0 <= i < new_v.len() && #[trigger] new_v[i].id == p + by { + if p == new_id { + let w = old_v.len() as int; + assert(0 <= w < new_v.len()); + assert(new_v[w].id == new_id); + } else { + assert(old_m.dom().contains(p)); + let w = choose|i: int| 0 <= i < old_v.len() && #[trigger] old_v[i].id == p; + assert(new_v[w] == old_v[w]); + } + } + + // (h) no duplicates in Vec + assert forall|i: int, j: int| + 0 <= i < new_v.len() && 0 <= j < new_v.len() + && #[trigger] new_v[i].id == #[trigger] new_v[j].id + implies i == j + by { + if i < old_v.len() && j < old_v.len() { + } else if i == old_v.len() && j == old_v.len() { + } else if i < old_v.len() { + assert(new_v[i] == old_v[i]); + assert(old_v[i].id < new_id); + assert(new_v[j].id == new_id); + } else { + assert(new_v[j] == old_v[j]); + assert(old_v[j].id < new_id); + assert(new_v[i].id == new_id); + } + } + } + new_id + } + + + /// 6.1.1 `renamePurse` (Quint lines 422-452; design §8.1 `rename_purse`). + /// + /// Updates the purse's name. Synchronous; no chain interaction. + /// Returns `Err(PurseNotFound(p))` if `p` is not a known purse; the state + /// is unchanged in that case. + pub fn rename_purse(&mut self, p: PurseId, name: Vec) -> (res: Result<(), Error>) + requires + old(self).invariant(), + ensures + final(self).invariant(), + match res { + Ok(()) => { + &&& old(self).purses().dom().contains(p) + &&& final(self).purses().dom() =~= old(self).purses().dom() + &&& final(self).purses()[p].id == p + &&& final(self).purses()[p].name == name@ + &&& final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx + &&& final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx + &&& forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q] + }, + Err(Error::PurseNotFound(q)) => + !old(self).purses().dom().contains(p) + && q == p + && final(self).purses() == old(self).purses(), + Err(_) => false, + }, + final(self).coins() == old(self).coins(), + final(self).entries() == old(self).entries(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_v = self.purses@; + let ghost old_m = self.spec_purses@; + let ghost name_seq = name@; + + let mut i: usize = 0; + while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + self.purses@ == old_v, + self.spec_purses@ == old_m, + old_m == old(self).spec_purses@, + old_v == old(self).purses@, + name_seq == name@, + self.next_purse_id == old(self).next_purse_id, + self.coins() == old(self).coins(), + self.entries() == old(self).entries(), + self.operations@ == old(self).operations@, + self.spec_operations@ == old(self).spec_operations@, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + forall|j: int| 0 <= j < i ==> (#[trigger] self.purses@[j]).id != p, + decreases self.purses.len() - i, + { + if self.purses[i].id == p { + let ghost target_idx = i as int; + let ghost old_p_rec = old_v[target_idx]@; + let cur_id = self.purses[i].id; + let cur_cidx = self.purses[i].next_coin_idx; + let cur_eidx = self.purses[i].next_entry_idx; + let new_rec = PurseRec { + id: cur_id, + name, + next_coin_idx: cur_cidx, + next_entry_idx: cur_eidx, + }; + let ghost new_rec_spec = new_rec@; + self.purses[i] = new_rec; + proof { + self.spec_purses = Ghost(self.spec_purses@.insert(p, new_rec_spec)); + + let new_v = self.purses@; + let new_m = self.spec_purses@; + + // The mutated entry has the new spec view. + assert(new_v[target_idx]@ == new_rec_spec); + assert(new_v[target_idx].id == p); + // Off-index entries are unchanged. + assert forall|k: int| 0 <= k < new_v.len() && k != target_idx implies + #[trigger] new_v[k] == old_v[k] + by {} + // The old entry at target_idx had id == p; by uniqueness it was + // the only one. + assert(old_v[target_idx].id == p); + assert forall|k: int| 0 <= k < old_v.len() && k != target_idx implies + (#[trigger] old_v[k]).id != p + by {} + // p was in old_m.dom — so insert(p, _) leaves dom unchanged. + assert(old_m.dom().contains(p)); + assert(new_m.dom() =~= old_m.dom()); + + // (a) next_purse_id != MAIN_PURSE — unchanged. + assert(self.next_purse_id != MAIN_PURSE); + // (b) MAIN_PURSE in dom — preserved. + assert(new_m.dom().contains(MAIN_PURSE)); + // (d) forall p in dom. p < next_purse_id — preserved. + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies q < self.next_purse_id + by { + assert(old_m.dom().contains(q)); + } + + // (c) forall p' in dom. m[p'].id == p' + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies new_m[q].id == q + by { + if q == p { + } else { + assert(old_m.dom().contains(q)); + } + } + + // (e) every Vec entry's id is in dom + assert forall|k: int| 0 <= k < new_v.len() implies + new_m.dom().contains(#[trigger] new_v[k].id) + by { + if k == target_idx { + } else { + assert(new_v[k] == old_v[k]); + assert(old_m.dom().contains(old_v[k].id)); + } + } + + // (f) every Vec entry's spec view matches its dom entry + assert forall|k: int| 0 <= k < new_v.len() implies + new_m[(#[trigger] new_v[k]).id] == new_v[k]@ + by { + if k == target_idx { + } else { + assert(new_v[k] == old_v[k]); + assert(old_v[k].id != p); + assert(old_m[old_v[k].id] == old_v[k]@); + } + } + + // (g) every dom key has a Vec witness + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies exists|k: int| 0 <= k < new_v.len() && #[trigger] new_v[k].id == q + by { + if q == p { + let w = target_idx; + assert(new_v[w].id == p); + } else { + assert(old_m.dom().contains(q)); + let w = choose|k: int| 0 <= k < old_v.len() && #[trigger] old_v[k].id == q; + assert(w != target_idx); + assert(new_v[w] == old_v[w]); + } + } + + // (h) no duplicates + assert forall|a: int, b: int| + 0 <= a < new_v.len() && 0 <= b < new_v.len() + && #[trigger] new_v[a].id == #[trigger] new_v[b].id + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_v[b] == old_v[b]); + } else if b == target_idx { + assert(new_v[a] == old_v[a]); + } else { + assert(new_v[a] == old_v[a]); + assert(new_v[b] == old_v[b]); + } + } + + } + return Ok(()); + } + i += 1; + } + // Not found: prove !dom.contains(p) + proof { + assert forall|q: PurseId| q == p implies !old_m.dom().contains(q) by { + if old_m.dom().contains(p) { + let w = choose|k: int| 0 <= k < old_v.len() && #[trigger] old_v[k].id == p; + assert(0 <= w < self.purses@.len()); + assert(self.purses@[w].id != p); + } + } + } + Err(Error::PurseNotFound(p)) + } + + + /// Safe variant of [`Self::delete_purse`]: runs the safety checks + /// first and returns a typed error if the purse can't be removed, + /// rather than tripping a hard precondition. Composes with the + /// existing exec pre-flight guards (`check_has_live_coin_in`, + /// `has_op_targeting_purse`). + /// + /// Errors surface (in the order checked): + /// - PurseHasInFlightOperations — at least one op targets `p`. + /// - InsufficientFunds — `p` still has at least one live coin. + /// - Then anything delete_purse itself can return. + pub fn delete_purse_safe(&mut self, p: PurseId) -> (res: Result<(), Error>) + requires + old(self).invariant(), + ensures + final(self).invariant(), + match res { + Ok(()) => + !old(self).has_live_coin_in(p) + && (forall|h: OpHandle| + #[trigger] old(self).operations().dom().contains(h) + ==> old(self).operations()[h].purse != p) + && old(self).purses().dom().contains(p) + && p != MAIN_PURSE + && final(self).purses() == old(self).purses().remove(p) + && final(self).coins() == old(self).coins().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ) + && final(self).entries() == old(self).entries().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ), + Err(_) => true, + }, + final(self).operations() == old(self).operations(), + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + if self.has_op_targeting_purse(p) { + return Err(Error::PurseHasInFlightOperations); + } + if self.check_has_live_coin_in(p) { + return Err(Error::InsufficientFunds { + requested: 0, + available: 0, + }); + } + self.delete_purse(p) + } + + + pub fn delete_purse(&mut self, p: PurseId) -> (res: Result<(), Error>) + requires + old(self).invariant(), + !old(self).has_live_coin_in(p), + // No operation targets purse p (operations subsystem refint). + forall|h: OpHandle| #[trigger] old(self).operations().dom().contains(h) + ==> old(self).operations()[h].purse != p, + ensures + final(self).invariant(), + match res { + Ok(()) => + old(self).purses().dom().contains(p) + && p != MAIN_PURSE + && final(self).purses() == old(self).purses().remove(p) + && final(self).coins() == old(self).coins().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ) + && final(self).entries() == old(self).entries().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ), + Err(Error::CannotDeleteMainPurse) => + p == MAIN_PURSE + && final(self).purses() == old(self).purses() + && final(self).coins() == old(self).coins() + && final(self).entries() == old(self).entries(), + Err(Error::PurseNotFound(q)) => + p != MAIN_PURSE + && !old(self).purses().dom().contains(p) + && q == p + && final(self).purses() == old(self).purses() + && final(self).coins() == old(self).coins().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ) + && final(self).entries() == old(self).entries().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ), + Err(_) => false, + }, + final(self).operations() == old(self).operations(), + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + if p == MAIN_PURSE { + return Err(Error::CannotDeleteMainPurse); + } + + // Purge coins, then entries belonging to p. If p isn't a known + // purse, invariants (j)/(p) ensure no coin/entry has purse == p so + // these are no-ops for the maps. + self.purge_coins_of_purse(p); + self.purge_entries_of_purse(p); + + let ghost old_v = self.purses@; + let ghost old_m = self.spec_purses@; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + + let mut i: usize = 0; + while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + self.purses@ == old_v, + self.spec_purses@ == old_m, + self.spec_coins@ == old_coins, + self.coins@ == old_coins_vec, + self.spec_entries@ == old_entries, + self.entries@ == old_entries_vec, + self.spec_operations@ == old_operations, + self.operations@ == old_operations_vec, + self.spec_operations@ == old_operations, + self.operations@ == old_operations_vec, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + old_m == old(self).spec_purses@, + old_v == old(self).purses@, + old_coins == old(self).coins().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ), + old_entries == old(self).entries().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ), + old_operations == old(self).operations(), + self.next_purse_id == old(self).next_purse_id, + p != MAIN_PURSE, + forall|k: (PurseId, u64)| #[trigger] old_coins.dom().contains(k) ==> k.0 != p, + forall|k: (PurseId, u64)| #[trigger] old_entries.dom().contains(k) ==> k.0 != p, + forall|h: OpHandle| #[trigger] old_operations.dom().contains(h) + ==> old_operations[h].purse != p, + forall|j: int| 0 <= j < i ==> (#[trigger] self.purses@[j]).id != p, + decreases self.purses.len() - i, + { + if self.purses[i].id == p { + let ghost target_idx = i as int; + let _removed = self.purses.swap_remove(i); + proof { + self.spec_purses = Ghost(self.spec_purses@.remove(p)); + // No coin removal needed: precondition forbids any coin in p. + + let new_v = self.purses@; + let new_m = self.spec_purses@; + let new_coins_map = self.spec_coins@; + let last_idx = old_v.len() - 1; + + // Vec contents after swap_remove: + // - new_v[k] == old_v[k] for k != target_idx, k < new_v.len() + // - new_v[target_idx] == old_v[last_idx] if target_idx < last_idx + assert(new_v.len() == old_v.len() - 1); + assert forall|k: int| 0 <= k < new_v.len() && k != target_idx implies + #[trigger] new_v[k] == old_v[k] + by {} + assert(target_idx < new_v.len() ==> new_v[target_idx] == old_v[last_idx]); + + // The removed id was p; by uniqueness, no other Vec entry had id == p. + assert(old_v[target_idx].id == p); + assert forall|k: int| 0 <= k < old_v.len() && k != target_idx implies + (#[trigger] old_v[k]).id != p + by {} + + // p was in old_m.dom; remove(p) decreases dom by exactly {p}. + assert(old_m.dom().contains(p)); + assert(new_m.dom() =~= old_m.dom().remove(p)); + + // (a) next_purse_id != MAIN_PURSE — unchanged. + assert(self.next_purse_id != MAIN_PURSE); + // (b) MAIN_PURSE in dom — p != MAIN_PURSE so removal preserves it. + assert(new_m.dom().contains(MAIN_PURSE)); + + // (c) forall q in dom. m[q].id == q + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies new_m[q].id == q + by { + assert(old_m.dom().contains(q)); + } + + // (d) forall q in dom. q < next_purse_id + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies q < self.next_purse_id + by { + assert(old_m.dom().contains(q)); + } + + // (e) every Vec entry's id is in dom + assert forall|k: int| 0 <= k < new_v.len() implies + new_m.dom().contains(#[trigger] new_v[k].id) + by { + if k == target_idx { + assert(new_v[k] == old_v[last_idx]); + assert(last_idx != target_idx); + assert(old_v[last_idx].id != p); + assert(old_m.dom().contains(old_v[last_idx].id)); + } else { + assert(new_v[k] == old_v[k]); + assert(k != target_idx); + assert(old_v[k].id != p); + assert(old_m.dom().contains(old_v[k].id)); + } + } + + // (f) every Vec entry's spec view matches its dom entry + assert forall|k: int| 0 <= k < new_v.len() implies + new_m[(#[trigger] new_v[k]).id] == new_v[k]@ + by { + if k == target_idx { + assert(new_v[k] == old_v[last_idx]); + assert(old_v[last_idx].id != p); + assert(old_m[old_v[last_idx].id] == old_v[last_idx]@); + } else { + assert(new_v[k] == old_v[k]); + assert(old_v[k].id != p); + assert(old_m[old_v[k].id] == old_v[k]@); + } + } + + // (g) every dom key has a Vec witness + assert forall|q: PurseId| #[trigger] new_m.dom().contains(q) + implies exists|k: int| 0 <= k < new_v.len() && #[trigger] new_v[k].id == q + by { + assert(old_m.dom().contains(q)); + let w_old = choose|k: int| 0 <= k < old_v.len() && #[trigger] old_v[k].id == q; + assert(old_v[w_old].id == q); + assert(q != p); + assert(w_old != target_idx); + if w_old == last_idx { + // The last element was moved to target_idx by swap_remove. + assert(target_idx < new_v.len()); + assert(new_v[target_idx] == old_v[last_idx]); + assert(new_v[target_idx].id == q); + } else { + // Non-last, non-target: still at its original index in new_v. + assert(w_old < last_idx); + assert(w_old < new_v.len()); + assert(new_v[w_old] == old_v[w_old]); + } + } + + // (h) no duplicates + assert forall|a: int, b: int| + 0 <= a < new_v.len() && 0 <= b < new_v.len() + && #[trigger] new_v[a].id == #[trigger] new_v[b].id + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_v[a] == old_v[last_idx]); + assert(new_v[b] == old_v[b]); + assert(b != last_idx); + } else if b == target_idx { + assert(new_v[b] == old_v[last_idx]); + assert(new_v[a] == old_v[a]); + assert(a != last_idx); + } else { + assert(new_v[a] == old_v[a]); + assert(new_v[b] == old_v[b]); + } + } + + // Coins are unchanged in this branch (purge happened pre-loop). + // Post-purge no coin in p remains, so removing p from + // purse map preserves (j): every coin's purse != p. + assert(self.spec_coins@ == old_coins); + assert(self.coins@ == old_coins_vec); + assert forall|k: (PurseId, u64)| + #[trigger] new_coins_map.dom().contains(k) + implies + new_m.dom().contains(k.0) + by { + assert(old_coins.dom().contains(k)); + assert(k.0 != p); + assert(old_m.dom().contains(k.0)); + } + + // (k) unchanged: purses untouched for non-p; no coin has purse == p. + assert forall|k: (PurseId, u64)| + #[trigger] new_coins_map.dom().contains(k) + implies + k.1 < new_m[k.0].next_coin_idx + by { + assert(old_coins.dom().contains(k)); + assert(k.0 != p); + assert(new_m[k.0] == old_m[k.0]); + } + + // Entries-side: spec_entries is post-purge (no key with k.0 == p); + // self.entries Vec unchanged in this scan loop. Invariant (p) holds + // because remaining entries' purses are all != p, and removing p + // from spec_purses leaves them in dom. + assert(self.spec_entries@ == old_entries); + assert forall|k: (PurseId, u64)| + #[trigger] self.spec_entries@.dom().contains(k) + implies + new_m.dom().contains(k.0) + by { + assert(old_entries.dom().contains(k)); + assert(k.0 != p); + assert(old_m.dom().contains(k.0)); + } + assert forall|k: (PurseId, u64)| + #[trigger] self.spec_entries@.dom().contains(k) + implies + k.1 < new_m[k.0].next_entry_idx + by { + assert(old_entries.dom().contains(k)); + assert(k.0 != p); + assert(new_m[k.0] == old_m[k.0]); + } + + // Operations-side: spec_operations untouched; no op's + // purse equals p (loop invariant), so refint to new + // purses dom holds. + assert(self.spec_operations@ == old_operations); + assert forall|h: OpHandle| + #[trigger] self.spec_operations@.dom().contains(h) + implies + new_m.dom().contains(self.spec_operations@[h].purse) + by { + assert(old_operations.dom().contains(h)); + assert(old_operations[h].purse != p); + assert(old_m.dom().contains(old_operations[h].purse)); + } + } + return Ok(()); + } + i += 1; + } + // Not found + proof { + if old_m.dom().contains(p) { + let w = choose|k: int| 0 <= k < old_v.len() && #[trigger] old_v[k].id == p; + assert(0 <= w < self.purses@.len()); + assert(self.purses@[w].id != p); + } + } + Err(Error::PurseNotFound(p)) + } + + + /// Internal: scan the coin Vec for the first entry with `purse == p`. + /// Returns its index, or `None` if no such coin remains. + pub(crate) fn find_coin_with_purse(&self, p: PurseId) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(i) => + (i as int) < self.coins@.len() + && self.coins@[i as int].purse == p, + None => + forall|j: int| 0 <= j < self.coins@.len() + ==> (#[trigger] self.coins@[j]).purse != p, + }, + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != p, + decreases self.coins.len() - j, + { + if self.coins[j].purse == p { + return Some(j); + } + j += 1; + } + None + } + + + /// Internal: remove the coin at exec-Vec index `idx`. Vec shrinks by 1 + /// (via `swap_remove`); the ghost map drops exactly the key that + /// belonged to the removed entry. + pub(crate) fn remove_coin_at(&mut self, idx: usize) + requires + old(self).invariant(), + (idx as int) < old(self).coins@.len(), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).next_purse_id == old(self).next_purse_id, + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + ({ + let removed = old(self).coins@[idx as int]; + final(self).coins() + == old(self).coins().remove((removed.purse, removed.idx)) + }), + final(self).coins@.len() == old(self).coins@.len() - 1, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_next_purse_id = self.next_purse_id; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost target_idx = idx as int; + let ghost removed_entry = old_coins_vec[target_idx]; + let ghost removed_key = (removed_entry.purse, removed_entry.idx); + let ghost last_idx = old_coins_vec.len() - 1; + + let _ = self.coins.swap_remove(idx); + proof { + self.spec_coins = Ghost(self.spec_coins@.remove(removed_key)); + + let new_coins_vec = self.coins@; + let new_coins = self.spec_coins@; + + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.next_purse_id == old_next_purse_id); + + // Vec post-state, from swap_remove spec: + // new_coins_vec == old_coins_vec.update(target_idx, last).drop_last() + assert(new_coins_vec.len() == old_coins_vec.len() - 1); + assert forall|k: int| 0 <= k < new_coins_vec.len() && k != target_idx implies + #[trigger] new_coins_vec[k] == old_coins_vec[k] + by {} + assert(target_idx < new_coins_vec.len() ==> + new_coins_vec[target_idx] == old_coins_vec[last_idx]); + + // Old key at target_idx == removed_key; by (n) old, no other Vec + // entry had the same (purse, idx). + assert(old_coins_vec[target_idx].purse == removed_key.0); + assert(old_coins_vec[target_idx].idx == removed_key.1); + assert forall|k: int| 0 <= k < old_coins_vec.len() && k != target_idx implies + (#[trigger] old_coins_vec[k]).purse != removed_key.0 + || old_coins_vec[k].idx != removed_key.1 + by {} + + // removed_key was in old ghost dom (by old (l)); remove decreases dom by exactly {removed_key}. + assert(old_coins.dom().contains(removed_key)); + assert(new_coins.dom() =~= old_coins.dom().remove(removed_key)); + + // (i) coin key consistency. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies new_coins[k].purse == k.0 && new_coins[k].idx == k.1 + by { + assert(old_coins.dom().contains(k)); + } + + // (j) coin referential integrity. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies self.spec_purses@.dom().contains(k.0) + by { + assert(old_coins.dom().contains(k)); + } + + // (k) coin idx below allocator. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies k.1 < self.spec_purses@[k.0].next_coin_idx + by { + assert(old_coins.dom().contains(k)); + } + + // (l) every new Vec entry's (purse, idx) is in new ghost. + assert forall|jj: int| 0 <= jj < new_coins_vec.len() implies + new_coins.dom().contains( + (#[trigger] new_coins_vec[jj].purse, new_coins_vec[jj].idx) + ) + && new_coins[(new_coins_vec[jj].purse, new_coins_vec[jj].idx)] + == new_coins_vec[jj] + by { + if jj == target_idx { + assert(new_coins_vec[jj] == old_coins_vec[last_idx]); + assert(last_idx != target_idx); + assert(old_coins_vec[last_idx].purse != removed_key.0 + || old_coins_vec[last_idx].idx != removed_key.1); + let oc = old_coins_vec[last_idx]; + assert(old_coins.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != removed_key); + assert(old_coins[(oc.purse, oc.idx)] == oc); + } else { + assert(new_coins_vec[jj] == old_coins_vec[jj]); + let oc = old_coins_vec[jj]; + assert(old_coins.dom().contains((oc.purse, oc.idx))); + assert((oc.purse, oc.idx) != removed_key); + assert(old_coins[(oc.purse, oc.idx)] == oc); + } + } + + // (m) every new ghost key has a Vec witness. + assert forall|k: (PurseId, u64)| #[trigger] new_coins.dom().contains(k) + implies exists|jj: int| + 0 <= jj < new_coins_vec.len() + && #[trigger] new_coins_vec[jj].purse == k.0 + && new_coins_vec[jj].idx == k.1 + by { + assert(old_coins.dom().contains(k)); + assert(k != removed_key); + let w_old = choose|jj: int| + 0 <= jj < old_coins_vec.len() + && #[trigger] old_coins_vec[jj].purse == k.0 + && old_coins_vec[jj].idx == k.1; + assert(w_old != target_idx); + if w_old == last_idx { + // Element moved to target_idx by swap_remove. + assert(target_idx < new_coins_vec.len()); + assert(new_coins_vec[target_idx] == old_coins_vec[last_idx]); + } else { + assert(w_old < last_idx); + assert(w_old < new_coins_vec.len()); + assert(new_coins_vec[w_old] == old_coins_vec[w_old]); + } + } + + // (n) no duplicates in new_coins_vec. + assert forall|a: int, b: int| + 0 <= a < new_coins_vec.len() && 0 <= b < new_coins_vec.len() + && (#[trigger] new_coins_vec[a]).purse + == (#[trigger] new_coins_vec[b]).purse + && new_coins_vec[a].idx == new_coins_vec[b].idx + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_coins_vec[a] == old_coins_vec[last_idx]); + assert(new_coins_vec[b] == old_coins_vec[b]); + assert(b != last_idx); + } else if b == target_idx { + assert(new_coins_vec[b] == old_coins_vec[last_idx]); + assert(new_coins_vec[a] == old_coins_vec[a]); + assert(a != last_idx); + } else { + assert(new_coins_vec[a] == old_coins_vec[a]); + assert(new_coins_vec[b] == old_coins_vec[b]); + } + } + } + } + + + /// Remove every coin in purse `p` (any state) from both the exec Vec + /// and the ghost map. Purses themselves are not touched. + pub fn purge_coins_of_purse(&mut self, p: PurseId) + requires + old(self).invariant(), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).next_purse_id == old(self).next_purse_id, + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).coins() == old(self).coins().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ), + forall|k: (PurseId, u64)| + #[trigger] final(self).coins().dom().contains(k) ==> k.0 != p, + { + let ghost initial_coins = self.spec_coins@; + + loop + invariant + self.invariant(), + self.purses() == old(self).purses(), + self.purses@ == old(self).purses@, + self.next_purse_id == old(self).next_purse_id, + self.entries@ == old(self).entries@, + self.spec_entries@ == old(self).spec_entries@, + self.operations@ == old(self).operations@, + self.spec_operations@ == old(self).spec_operations@, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + // Current spec_coins is a subset of initial that preserves all + // entries with purse != p. + forall|k: (PurseId, u64)| #[trigger] self.spec_coins@.dom().contains(k) + ==> initial_coins.dom().contains(k) + && self.spec_coins@[k] == initial_coins[k], + forall|k: (PurseId, u64)| + #[trigger] initial_coins.dom().contains(k) && k.0 != p + ==> self.spec_coins@.dom().contains(k), + initial_coins == old(self).coins(), + decreases self.coins.len(), + { + match self.find_coin_with_purse(p) { + None => { + // find-None postcondition: forall j. coins@[j].purse != p. + proof { + // No spec_coins key has k.0 == p: if any did, (m) would + // give a Vec witness with purse == p — contradiction. + assert forall|k: (PurseId, u64)| + #[trigger] self.spec_coins@.dom().contains(k) + implies k.0 != p + by { + if k.0 == p { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].purse == p); + } + } + // Combined with loop invariants, current spec_coins is + // exactly initial_coins minus all keys with k.0 == p. + assert(self.spec_coins@ + =~= initial_coins.remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + )); + } + return; + } + Some(idx) => { + let ghost removed_entry = self.coins@[idx as int]; + let ghost removed_key = (removed_entry.purse, removed_entry.idx); + proof { + assert(self.spec_coins@.dom().contains(removed_key)); + } + self.remove_coin_at(idx); + } + } + } + } + + + /// Internal: scan the entry Vec for the first entry with `purse == p`. + pub(crate) fn find_entry_with_purse(&self, p: PurseId) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(i) => + (i as int) < self.entries@.len() + && self.entries@[i as int].purse == p, + None => + forall|j: int| 0 <= j < self.entries@.len() + ==> (#[trigger] self.entries@[j]).purse != p, + }, + { + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != p, + decreases self.entries.len() - j, + { + if self.entries[j].purse == p { + return Some(j); + } + j += 1; + } + None + } + + + /// Internal: remove the entry at exec-Vec index `idx`. Vec shrinks by 1 + /// (via `swap_remove`); the ghost entry map drops exactly the key that + /// belonged to the removed Vec entry. + pub(crate) fn remove_entry_at(&mut self, idx: usize) + requires + old(self).invariant(), + (idx as int) < old(self).entries@.len(), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).next_purse_id == old(self).next_purse_id, + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + ({ + let removed = old(self).entries@[idx as int]; + final(self).entries() + == old(self).entries().remove((removed.purse, removed.idx)) + }), + final(self).entries@.len() == old(self).entries@.len() - 1, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_next_purse_id = self.next_purse_id; + let ghost old_coins = self.spec_coins@; + let ghost old_coins_vec = self.coins@; + let ghost old_entries = self.spec_entries@; + let ghost old_entries_vec = self.entries@; + let ghost old_operations = self.spec_operations@; + let ghost old_operations_vec = self.operations@; + let ghost target_idx = idx as int; + let ghost removed_e = old_entries_vec[target_idx]; + let ghost removed_key = (removed_e.purse, removed_e.idx); + let ghost last_idx = old_entries_vec.len() - 1; + + let _ = self.entries.swap_remove(idx); + proof { + self.spec_entries = Ghost(self.spec_entries@.remove(removed_key)); + + let new_entries_vec = self.entries@; + let new_entries = self.spec_entries@; + let new_m = self.spec_purses@; + + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.next_purse_id == old_next_purse_id); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_coins); + + assert(new_entries_vec.len() == old_entries_vec.len() - 1); + assert forall|k: int| 0 <= k < new_entries_vec.len() && k != target_idx implies + #[trigger] new_entries_vec[k] == old_entries_vec[k] + by {} + assert(target_idx < new_entries_vec.len() ==> + new_entries_vec[target_idx] == old_entries_vec[last_idx]); + + assert(old_entries_vec[target_idx].purse == removed_key.0); + assert(old_entries_vec[target_idx].idx == removed_key.1); + assert forall|k: int| + 0 <= k < old_entries_vec.len() && k != target_idx implies + (#[trigger] old_entries_vec[k]).purse != removed_key.0 + || old_entries_vec[k].idx != removed_key.1 + by {} + + assert(old_entries.dom().contains(removed_key)); + assert(new_entries.dom() =~= old_entries.dom().remove(removed_key)); + + // (o) entry key consistency. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies new_entries[k].purse == k.0 && new_entries[k].idx == k.1 + by { assert(old_entries.dom().contains(k)); } + + // (p) entry refint. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies new_m.dom().contains(k.0) + by { assert(old_entries.dom().contains(k)); } + + // (q) entry idx < next_entry_idx. + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies k.1 < new_m[k.0].next_entry_idx + by { assert(old_entries.dom().contains(k)); } + + // (r) Vec → ghost + assert forall|jj: int| 0 <= jj < new_entries_vec.len() implies + new_entries.dom().contains( + (#[trigger] new_entries_vec[jj].purse, new_entries_vec[jj].idx) + ) + && new_entries[(new_entries_vec[jj].purse, new_entries_vec[jj].idx)] + == new_entries_vec[jj] + by { + if jj == target_idx { + assert(new_entries_vec[jj] == old_entries_vec[last_idx]); + assert(last_idx != target_idx); + let oe = old_entries_vec[last_idx]; + assert(old_entries.dom().contains((oe.purse, oe.idx))); + assert((oe.purse, oe.idx) != removed_key); + assert(old_entries[(oe.purse, oe.idx)] == oe); + } else { + assert(new_entries_vec[jj] == old_entries_vec[jj]); + let oe = old_entries_vec[jj]; + assert(old_entries.dom().contains((oe.purse, oe.idx))); + assert((oe.purse, oe.idx) != removed_key); + assert(old_entries[(oe.purse, oe.idx)] == oe); + } + } + + // (s) ghost → Vec + assert forall|k: (PurseId, u64)| #[trigger] new_entries.dom().contains(k) + implies exists|jj: int| + 0 <= jj < new_entries_vec.len() + && #[trigger] new_entries_vec[jj].purse == k.0 + && new_entries_vec[jj].idx == k.1 + by { + assert(old_entries.dom().contains(k)); + assert(k != removed_key); + let w_old = choose|jj: int| + 0 <= jj < old_entries_vec.len() + && #[trigger] old_entries_vec[jj].purse == k.0 + && old_entries_vec[jj].idx == k.1; + assert(w_old != target_idx); + if w_old == last_idx { + assert(target_idx < new_entries_vec.len()); + assert(new_entries_vec[target_idx] == old_entries_vec[last_idx]); + } else { + assert(w_old < last_idx); + assert(w_old < new_entries_vec.len()); + assert(new_entries_vec[w_old] == old_entries_vec[w_old]); + } + } + + // (t) no duplicates + assert forall|a: int, b: int| + 0 <= a < new_entries_vec.len() && 0 <= b < new_entries_vec.len() + && (#[trigger] new_entries_vec[a]).purse + == (#[trigger] new_entries_vec[b]).purse + && new_entries_vec[a].idx == new_entries_vec[b].idx + implies a == b + by { + if a == target_idx && b == target_idx { + } else if a == target_idx { + assert(new_entries_vec[a] == old_entries_vec[last_idx]); + assert(new_entries_vec[b] == old_entries_vec[b]); + assert(b != last_idx); + } else if b == target_idx { + assert(new_entries_vec[b] == old_entries_vec[last_idx]); + assert(new_entries_vec[a] == old_entries_vec[a]); + assert(a != last_idx); + } else { + assert(new_entries_vec[a] == old_entries_vec[a]); + assert(new_entries_vec[b] == old_entries_vec[b]); + } + } + } + } + + + /// Remove every entry in purse `p` (any on-chain state) from the + /// exec Vec and the ghost map. Purses and coins untouched. + pub fn purge_entries_of_purse(&mut self, p: PurseId) + requires + old(self).invariant(), + ensures + final(self).invariant(), + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).next_purse_id == old(self).next_purse_id, + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + final(self).entries() == old(self).entries().remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + ), + forall|k: (PurseId, u64)| + #[trigger] final(self).entries().dom().contains(k) ==> k.0 != p, + { + let ghost initial_entries = self.spec_entries@; + + loop + invariant + self.invariant(), + self.purses() == old(self).purses(), + self.purses@ == old(self).purses@, + self.next_purse_id == old(self).next_purse_id, + self.coins@ == old(self).coins@, + self.spec_coins@ == old(self).spec_coins@, + self.operations@ == old(self).operations@, + self.spec_operations@ == old(self).spec_operations@, + self.next_handle == old(self).next_handle, + self.next_age == old(self).next_age, + self.fee_balance == old(self).fee_balance, + self.next_extrinsic_id == old(self).next_extrinsic_id, + self.events@ == old(self).events@, + self.paid_ring_membership == old(self).paid_ring_membership, + self.total_in == old(self).total_in, + self.total_out == old(self).total_out, + self.tokens@ == old(self).tokens@, + self.chain_coins@ == old(self).chain_coins@, + self.chain_entries@ == old(self).chain_entries@, + forall|k: (PurseId, u64)| #[trigger] self.spec_entries@.dom().contains(k) + ==> initial_entries.dom().contains(k) + && self.spec_entries@[k] == initial_entries[k], + forall|k: (PurseId, u64)| + #[trigger] initial_entries.dom().contains(k) && k.0 != p + ==> self.spec_entries@.dom().contains(k), + initial_entries == old(self).entries(), + decreases self.entries.len(), + { + match self.find_entry_with_purse(p) { + None => { + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.spec_entries@.dom().contains(k) + implies k.0 != p + by { + if k.0 == p { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == k.0 + && self.entries@[jj].idx == k.1; + assert(self.entries@[w].purse == p); + } + } + assert(self.spec_entries@ + =~= initial_entries.remove_keys( + Set::new(|k: (PurseId, u64)| k.0 == p) + )); + } + return; + } + Some(idx) => { + let ghost removed_e = self.entries@[idx as int]; + let ghost removed_key = (removed_e.purse, removed_e.idx); + proof { + assert(self.spec_entries@.dom().contains(removed_key)); + } + self.remove_entry_at(idx); + } + } + } + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_queries.rs b/rust/crates/coinage-layer/src/state_queries.rs new file mode 100644 index 00000000..1fb8afa9 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_queries.rs @@ -0,0 +1,851 @@ +//! Read-only queries: `*_record`, `query_*`, `op_meta`, `op_status`, `coin_state`, `entry_*_state`, has-* checks. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Exec witness for the [`Self::has_live_coin_in`] spec predicate: + /// `true` iff at least one coin in purse `p` is in any non-`Spent` + /// state. Pair with [`Self::has_in_flight_op_for_purse`] before + /// `delete_purse` to surface "purse not empty" as an early bail + /// instead of a precondition trap. + pub fn check_has_live_coin_in(&self, p: PurseId) -> (res: bool) + requires + self.invariant(), + ensures + res == self.has_live_coin_in(p), + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != p + || self.coins@[jj].state == CoinState::Spent, + decreases self.coins.len() - j, + { + let c = &self.coins[j]; + let is_spent = matches!(c.state, CoinState::Spent); + if c.purse == p && !is_spent { + #[allow(unused_variables)] + let key = (c.purse, c.idx); + proof { + assert(self.spec_coins@.dom().contains(key)); + assert(self.coins()[key].state == self.coins@[j as int].state); + } + return true; + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + implies self.coins()[k].state == CoinState::Spent + by { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].purse == p); + assert(self.coins@[w].state == self.coins()[k].state); + } + } + false + } + + + /// Read the **real** entry value for `key` (Quint `coinValue` over + /// the entry's exponent). Entry parallel of + /// [`Self::read_coin_value_real`]. + pub fn read_entry_value_real(&self, key: (PurseId, u64)) -> (res: Option) + requires + self.invariant(), + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + ==> self.entries()[k].exponent <= MAX_EXPONENT, + ensures + match res { + Some(v) => + self.entries().dom().contains(key) + && v as nat == coin_value_pow2(self.entries()[key].exponent), + None => !self.entries().dom().contains(key), + }, + { + match self.entry_record(key) { + Some(e) => { + proof { + assert(self.entries()[key].exponent <= MAX_EXPONENT); + assert(e.exponent == self.entries()[key].exponent); + } + Some(pow2_u64_exec(e.exponent)) + } + None => None, + } + } + + + /// Read the **real** coin value for `key` using `2^exp` arithmetic + /// (Quint `coinValue`). Requires the coin's exponent to satisfy the + /// `MAX_EXPONENT` bound. Returns `None` if no such coin exists. + /// + /// Companion to the pilot-scheme aggregations (which use + /// `coin_value(exp) = exp + 1`) — this one reflects the production + /// scheme. Callers wiring up the real arithmetic switch can compose + /// this with their own sums; the existing per-purse aggregations + /// (sum_available_in etc.) still use the pilot scheme. + pub fn read_coin_value_real(&self, key: (PurseId, u64)) -> (res: Option) + requires + self.invariant(), + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + ==> self.coins()[k].exponent <= MAX_EXPONENT, + ensures + match res { + Some(v) => + self.coins().dom().contains(key) + && v as nat == coin_value_pow2(self.coins()[key].exponent), + None => !self.coins().dom().contains(key), + }, + { + match self.coin_record(key) { + Some(c) => { + proof { + assert(self.coins()[key].exponent <= MAX_EXPONENT); + assert(c.exponent == self.coins()[key].exponent); + } + Some(pow2_u64_exec(c.exponent)) + } + None => None, + } + } + + + /// Synchronous read: state of the coin keyed `key`, or `None` if + /// no such coin exists. Quint analog: `coins.get(key).state`. + pub fn coin_state(&self, key: (PurseId, u64)) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(s) => + self.coins().dom().contains(key) + && s == self.coins()[key].state, + None => !self.coins().dom().contains(key), + }, + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != key.0 + || self.coins@[jj].idx != key.1, + decreases self.coins.len() - j, + { + if self.coins[j].purse == key.0 && self.coins[j].idx == key.1 { + proof { + assert(self.spec_coins@.dom().contains(key)); + } + return Some(self.coins[j].state); + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + implies k != key + by { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].purse == k.0); + } + } + None + } + + + /// Synchronous read: local state of the entry keyed `key`, or + /// `None` if no such entry exists. Quint analog: + /// `entries.get(key).local`. + pub fn entry_local_state(&self, key: (PurseId, u64)) + -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(s) => + self.entries().dom().contains(key) + && s == self.entries()[key].local, + None => !self.entries().dom().contains(key), + }, + { + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != key.0 + || self.entries@[jj].idx != key.1, + decreases self.entries.len() - j, + { + if self.entries[j].purse == key.0 && self.entries[j].idx == key.1 { + proof { + assert(self.spec_entries@.dom().contains(key)); + } + return Some(self.entries[j].local); + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + implies k != key + by { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == k.0 + && self.entries@[jj].idx == k.1; + assert(self.entries@[w].purse == k.0); + } + } + None + } + + + /// Synchronous read: on-chain state of the entry keyed `key`, or + /// `None` if no such entry exists. Quint analog: + /// `entries.get(key).onChain`. + pub fn entry_on_chain_state(&self, key: (PurseId, u64)) + -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(s) => + self.entries().dom().contains(key) + && s == self.entries()[key].on_chain, + None => !self.entries().dom().contains(key), + }, + { + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != key.0 + || self.entries@[jj].idx != key.1, + decreases self.entries.len() - j, + { + if self.entries[j].purse == key.0 && self.entries[j].idx == key.1 { + proof { + assert(self.spec_entries@.dom().contains(key)); + } + return Some(self.entries[j].on_chain); + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + implies k != key + by { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == k.0 + && self.entries@[jj].idx == k.1; + assert(self.entries@[w].purse == k.0); + } + } + None + } + + + /// Synchronous read: the full `CoinRec` for `key`, or `None` if the + /// coin doesn't exist. Avoids repeated per-field lookup calls. + pub fn coin_record(&self, key: (PurseId, u64)) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(c) => + self.coins().dom().contains(key) + && c == self.coins()[key], + None => !self.coins().dom().contains(key), + }, + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != key.0 + || self.coins@[jj].idx != key.1, + decreases self.coins.len() - j, + { + if self.coins[j].purse == key.0 && self.coins[j].idx == key.1 { + proof { + assert(self.spec_coins@.dom().contains(key)); + } + return Some(self.coins[j]); + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + implies k != key + by { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].purse == k.0); + } + } + None + } + + + /// Synchronous read: the full `EntryRec` for `key`, or `None` if + /// the entry doesn't exist. + pub fn entry_record(&self, key: (PurseId, u64)) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(e) => + self.entries().dom().contains(key) + && e == self.entries()[key], + None => !self.entries().dom().contains(key), + }, + { + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != key.0 + || self.entries@[jj].idx != key.1, + decreases self.entries.len() - j, + { + if self.entries[j].purse == key.0 && self.entries[j].idx == key.1 { + proof { + assert(self.spec_entries@.dom().contains(key)); + } + return Some(self.entries[j]); + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + implies k != key + by { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == k.0 + && self.entries@[jj].idx == k.1; + assert(self.entries@[w].purse == k.0); + } + } + None + } + + + /// Result-returning variant of `op_status`. Returns + /// `Err(OperationNotFound(handle))` when the op handle is unknown + /// — the surface a host's RPC layer typically needs. + pub fn query_op_status(&self, handle: OpHandle) -> (res: Result) + requires + self.invariant(), + ensures + match res { + Ok(s) => + self.operations().dom().contains(handle) + && s == self.operations()[handle].status, + Err(Error::OperationNotFound(h)) => + !self.operations().dom().contains(handle) && h == handle, + Err(_) => false, + }, + { + match self.op_status(handle) { + Some(s) => Ok(s), + None => Err(Error::OperationNotFound(handle)), + } + } + + + /// Result-returning variant of `coin_record`. Errors with + /// `Internal` when the coin doesn't exist (callers that want a + /// distinguishing error variant should match on `None` from + /// `coin_record` directly). + pub fn query_coin_record(&self, key: (PurseId, u64)) + -> (res: Result) + requires + self.invariant(), + ensures + match res { + Ok(c) => + self.coins().dom().contains(key) + && c == self.coins()[key], + Err(_) => !self.coins().dom().contains(key), + }, + { + match self.coin_record(key) { + Some(c) => Ok(c), + None => Err(Error::Internal(Vec::new())), + } + } + + + /// Result-returning variant of `entry_record`. + pub fn query_entry_record(&self, key: (PurseId, u64)) + -> (res: Result) + requires + self.invariant(), + ensures + match res { + Ok(e) => + self.entries().dom().contains(key) + && e == self.entries()[key], + Err(_) => !self.entries().dom().contains(key), + }, + { + match self.entry_record(key) { + Some(e) => Ok(e), + None => Err(Error::Internal(Vec::new())), + } + } + + + /// Check: does any *non-terminal* operation target purse `p`? + /// Returns `true` iff at least one operation has `purse == p` and a + /// status in {Preparing, Submitted, InBlock, Finalized, Waiting(_)}. + /// Useful for delete-purse readiness checks where terminal ops can + /// be ignored. + pub fn has_in_flight_op_for_purse(&self, p: PurseId) -> (res: bool) + requires + self.invariant(), + ensures + res == exists|h: OpHandle| + #[trigger] self.operations().dom().contains(h) + && self.operations()[h].purse == p + && !is_terminal_op_status(self.operations()[h].status), + { + let mut j: usize = 0; + while j < self.operations.len() + invariant + 0 <= j <= self.operations.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.operations@[jj]).purse != p + || is_terminal_op_status(self.operations@[jj].status), + decreases self.operations.len() - j, + { + let op = &self.operations[j]; + let is_terminal = match op.status { + OpStatus::Done => true, + OpStatus::Failed => true, + _ => false, + }; + if op.purse == p && !is_terminal { + #[allow(unused_variables)] + let h = op.handle; + proof { + assert(self.spec_operations@.dom().contains(h)); + assert(self.operations()[h].purse == p); + assert(!is_terminal_op_status(self.operations()[h].status)); + } + return true; + } + j = j + 1; + } + proof { + assert forall|h: OpHandle| + #[trigger] self.operations().dom().contains(h) + && self.operations()[h].purse == p + implies is_terminal_op_status(self.operations()[h].status) + by { + let w = choose|jj: int| + 0 <= jj < self.operations@.len() + && #[trigger] self.operations@[jj].handle == h; + assert(self.operations@[w].handle == h); + } + } + false + } + + + /// Check: does any operation target purse `p`? Returns `true` iff + /// at least one operation has `op.purse == p`. Useful as a pre-flight + /// guard before `delete_purse`, which requires no targeting ops. + pub fn has_op_targeting_purse(&self, p: PurseId) -> (res: bool) + requires + self.invariant(), + ensures + res == exists|h: OpHandle| + #[trigger] self.operations().dom().contains(h) + && self.operations()[h].purse == p, + { + let mut j: usize = 0; + while j < self.operations.len() + invariant + 0 <= j <= self.operations.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.operations@[jj]).purse != p, + decreases self.operations.len() - j, + { + if self.operations[j].purse == p { + #[allow(unused_variables)] + let h = self.operations[j].handle; + proof { + assert(self.spec_operations@.dom().contains(h)); + assert(self.operations()[h].purse == p); + } + return true; + } + j = j + 1; + } + proof { + assert forall|h: OpHandle| + #[trigger] self.operations().dom().contains(h) + implies self.operations()[h].purse != p + by { + let w = choose|jj: int| + 0 <= jj < self.operations@.len() + && #[trigger] self.operations@[jj].handle == h; + assert(self.operations@[w].handle == h); + } + } + false + } + + + /// Result-returning variant of `op_meta`. + pub fn query_op_meta(&self, handle: OpHandle) + -> (res: Result<(OpKind, PurseId), Error>) + requires + self.invariant(), + ensures + match res { + Ok((k, p)) => + self.operations().dom().contains(handle) + && k == self.operations()[handle].kind + && p == self.operations()[handle].purse, + Err(Error::OperationNotFound(h)) => + !self.operations().dom().contains(handle) && h == handle, + Err(_) => false, + }, + { + match self.op_meta(handle) { + Some(m) => Ok(m), + None => Err(Error::OperationNotFound(handle)), + } + } + + + /// Synchronous read: the `(kind, purse)` pair of the operation + /// `handle`, or `None` if no such operation exists. Used to route + /// chain events back to the right purse / op-kind handler. + pub fn op_meta(&self, handle: OpHandle) -> (res: Option<(OpKind, PurseId)>) + requires + self.invariant(), + ensures + match res { + Some((k, p)) => + self.operations().dom().contains(handle) + && k == self.operations()[handle].kind + && p == self.operations()[handle].purse, + None => !self.operations().dom().contains(handle), + }, + { + let mut j: usize = 0; + while j < self.operations.len() + invariant + 0 <= j <= self.operations.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.operations@[jj]).handle != handle, + decreases self.operations.len() - j, + { + if self.operations[j].handle == handle { + proof { + assert(self.spec_operations@.dom().contains(handle)); + } + return Some((self.operations[j].kind, self.operations[j].purse)); + } + j = j + 1; + } + proof { + assert forall|h: OpHandle| + #[trigger] self.operations().dom().contains(h) + implies h != handle + by { + let w = choose|jj: int| + 0 <= jj < self.operations@.len() + && #[trigger] self.operations@[jj].handle == h; + assert(self.operations@[w].handle == h); + } + } + None + } + + + /// Synchronous read: status of the operation `handle`, or `None` + /// if no such operation exists. Quint analog: `operations.get(h).status`. + pub fn op_status(&self, handle: OpHandle) -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(s) => + self.operations().dom().contains(handle) + && s == self.operations()[handle].status, + None => !self.operations().dom().contains(handle), + }, + { + let mut j: usize = 0; + while j < self.operations.len() + invariant + 0 <= j <= self.operations.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.operations@[jj]).handle != handle, + decreases self.operations.len() - j, + { + if self.operations[j].handle == handle { + proof { + assert(self.spec_operations@.dom().contains(handle)); + } + return Some(self.operations[j].status); + } + j = j + 1; + } + proof { + assert forall|h: OpHandle| + #[trigger] self.operations().dom().contains(h) + implies h != handle + by { + let w = choose|jj: int| + 0 <= jj < self.operations@.len() + && #[trigger] self.operations@[jj].handle == h; + assert(self.operations@[w].handle == h); + } + } + None + } + + + /// Convenience: sum of `Available` coins + ALL LocalAvailable + /// entries (Ready + Waiting + Missing), using real `2^exp` values. + /// Quint analog: `spendableWhenReady(p) = purseSpendable(p) + + /// pursePending(p)`. + /// + /// Used to distinguish "insufficient funds now" from "insufficient + /// even if all in-flight top-ups mature". + pub fn spendable_when_ready_real(&self, p: PurseId) -> (total: u64) + requires + self.invariant(), + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + ==> self.coins()[k].exponent <= MAX_EXPONENT, + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + ==> self.entries()[k].exponent <= MAX_EXPONENT, + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + (self.coins@.len() as nat + self.entries@.len() as nat) + <= (u64::MAX / 1073741824) as nat, + ensures + total as nat == + sum_avail_real_prefix(self.coins@, p, self.coins@.len() as nat) + + sum_pending_real_prefix(self.entries@, p, self.entries@.len() as nat), + { + let spendable = self.sum_available_real_in(p); + let pending = self.sum_pending_real_in(p); + proof { + assert(spendable as nat <= self.coins@.len() as nat * 1073741824); + assert(pending as nat <= self.entries@.len() as nat * 1073741824); + } + spendable + pending + } + + + /// Real-value (2^exp) variant of [`Self::query_purse`]. Reports + /// `spendable`, `spendable_strict`, and `pending` using Quint's + /// production `coinValue = 2^exp` arithmetic via the + /// `sum_*_real_in` aggregations. Requires all exponents in state + /// to satisfy MAX_EXPONENT and the Vec sizes to fit cumulative + /// u64 sums. + pub fn query_purse_real(&self, p: PurseId) -> (info: Result) + requires + self.invariant(), + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + ==> self.coins()[k].exponent <= MAX_EXPONENT, + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + ==> self.entries()[k].exponent <= MAX_EXPONENT, + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + (self.coins@.len() as nat + self.entries@.len() as nat) + <= (u64::MAX / 1073741824) as nat, + ensures + match info { + Ok(i) => + self.purses().dom().contains(p) + && i.id == p + && i.name@ == self.purses()[p].name + && i.spendable as nat + == sum_avail_real_prefix(self.coins@, p, self.coins@.len() as nat) + && i.spendable_strict as nat + == sum_avail_real_prefix(self.coins@, p, self.coins@.len() as nat) + + sum_ready_real_prefix(self.entries@, p, + self.entries@.len() as nat) + && i.pending as nat + == sum_pending_real_prefix(self.entries@, p, + self.entries@.len() as nat), + Err(Error::PurseNotFound(q)) => + !self.purses().dom().contains(p) && q == p, + Err(_) => false, + }, + { + let mut i: usize = 0; + while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + ==> self.coins()[k].exponent <= MAX_EXPONENT, + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + ==> self.entries()[k].exponent <= MAX_EXPONENT, + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + (self.coins@.len() as nat + self.entries@.len() as nat) + <= (u64::MAX / 1073741824) as nat, + forall|j: int| 0 <= j < i ==> (#[trigger] self.purses@[j]).id != p, + decreases self.purses.len() - i, + { + if self.purses[i].id == p { + let spendable = self.sum_available_real_in(p); + let ready = self.sum_ready_real_in(p); + let pending = self.sum_pending_real_in(p); + proof { + assert(spendable as nat <= self.coins@.len() as nat * 1073741824); + assert(ready as nat <= self.entries@.len() as nat * 1073741824); + } + let rec = &self.purses[i]; + let name_copy: Vec = rec.name.clone(); + assert(name_copy@ == rec.name@); + return Ok(PurseInfo { + id: rec.id, + name: name_copy, + spendable, + spendable_strict: spendable + ready, + pending, + }); + } + i += 1; + } + Err(Error::PurseNotFound(p)) + } + + + /// 6.1 `queryPurse` (Quint lines 603-612; design §8.1 `query_purse`). + /// + /// Returns a synchronous snapshot: + /// - `spendable` — sum of Available-coin values in `p`. + /// - `spendable_strict` — `spendable + sum of Ready-entry values` + /// (entries fully matured into the + /// anonymity ring). + /// - `pending` — sum of LocalAvailable entries in `p` + /// that are Waiting or Missing on-chain + /// (in-flight top-ups not yet matured). + /// + /// Preconditions bound coin / entry Vec sizes so the cumulative + /// `u64` aggregations don't overflow under the pilot value scheme. + pub fn query_purse(&self, p: PurseId) -> (info: Result) + requires + self.invariant(), + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + // spendable + ready_entries must fit in u64. + (self.coins@.len() as nat + self.entries@.len() as nat) + <= (u64::MAX / 1073741824) as nat, + ensures + match info { + Ok(i) => + self.purses().dom().contains(p) + && i.id == p + && i.name@ == self.purses()[p].name + && i.spendable as nat + == sum_avail_prefix(self.coins@, p, self.coins@.len() as nat) + && i.spendable_strict as nat + == sum_avail_prefix(self.coins@, p, self.coins@.len() as nat) + + sum_ready_prefix(self.entries@, p, + self.entries@.len() as nat) + && i.pending as nat + == sum_pending_prefix(self.entries@, p, + self.entries@.len() as nat), + Err(Error::PurseNotFound(q)) => + !self.purses().dom().contains(p) && q == p, + Err(_) => false, + }, + { + let mut i: usize = 0; + while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + self.entries@.len() <= (u64::MAX / 1073741824) as nat, + (self.coins@.len() as nat + self.entries@.len() as nat) + <= (u64::MAX / 1073741824) as nat, + forall|j: int| 0 <= j < i ==> (#[trigger] self.purses@[j]).id != p, + decreases + self.purses.len() - i, + { + if self.purses[i].id == p { + let spendable = self.sum_available_in(p); + let ready = self.sum_ready_in(p); + let pending = self.sum_pending_in(p); + proof { + // sum_avail_prefix is bounded by len * 2^30; same for ready. + // Together they fit in u64 because (coins.len + entries.len) + // <= u64::MAX/2^30 was given by the precondition. + assert(spendable as nat <= self.coins@.len() as nat * 1073741824); + assert(ready as nat <= self.entries@.len() as nat * 1073741824); + } + let rec = &self.purses[i]; + let name_copy: Vec = rec.name.clone(); + assert(name_copy@ == rec.name@); + return Ok(PurseInfo { + id: rec.id, + name: name_copy, + spendable, + spendable_strict: spendable + ready, + pending, + }); + } + i += 1; + } + Err(Error::PurseNotFound(p)) + } +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_selectors.rs b/rust/crates/coinage-layer/src/state_selectors.rs new file mode 100644 index 00000000..72c6babb --- /dev/null +++ b/rust/crates/coinage-layer/src/state_selectors.rs @@ -0,0 +1,2806 @@ +//! Selectors: `find_*_coin*`, `find_*_entry*`, subset-sum covers, top-priority, classify-payment. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Autonomous maintenance trigger: scan purses, return the first + /// one whose `Available` coin count strictly exceeds `threshold`. + /// Returns `None` if no purse is over-fragmented. Quint analog: + /// maintenance scheduler that decides which purse to consolidate next. + pub fn find_purse_needing_maintenance(&self, threshold: usize) + -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(p) => self.purses().dom().contains(p), + None => true, + }, + { + let mut i: usize = 0; + while i < self.purses.len() + invariant + 0 <= i <= self.purses.len(), + self.invariant(), + decreases self.purses.len() - i, + { + let pid = self.purses[i].id; + let count = self.coin_count_available(pid); + if count > threshold { + proof { + assert(self.spec_purses@.dom().contains(pid)); + } + return Some(pid); + } + i = i + 1; + } + None + } + + + /// Select the first `Available` coin in purse `p` whose `exponent` + /// meets or exceeds `min_exponent`. Returns `None` if no such coin + /// exists. + pub fn select_coin(&self, p: PurseId, min_exponent: u8) + -> (res: Option<(PurseId, u64)>) + requires + self.invariant(), + ensures + match res { + Some(key) => + self.coins().dom().contains(key) + && key.0 == p + && self.coins()[key].state == CoinState::Available + && self.coins()[key].exponent >= min_exponent, + None => + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + ==> self.coins()[k].exponent < min_exponent, + }, + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != p + || self.coins@[jj].state != CoinState::Available + || self.coins@[jj].exponent < min_exponent, + decreases self.coins.len() - j, + { + let is_avail = matches!(self.coins[j].state, CoinState::Available); + if self.coins[j].purse == p + && is_avail + && self.coins[j].exponent >= min_exponent + { + let key = (self.coins[j].purse, self.coins[j].idx); + proof { + // (l) gives us key in dom and ghost matches Vec entry. + assert(self.spec_coins@.dom().contains(key)); + } + return Some(key); + } + j = j + 1; + } + // Not found in the Vec scan; lift to "no such ghost key" via (m). + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + implies self.coins()[k].exponent < min_exponent + by { + // (m) gives a Vec witness w; the loop's "not found" fact then + // forces w to have either wrong purse, wrong state, or smaller + // exponent. The first two are ruled out by the ghost record's + // values (which match the Vec entry by (l)), leaving exponent. + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].purse == p); + assert(self.coins@[w].state == self.coins()[k].state); + assert(self.coins@[w].exponent == self.coins()[k].exponent); + } + } + None + } + + + /// Degenerate exact-cover: find an `Available` coin in purse `p` whose + /// `coin_value(exp)` equals `requested` exactly. Returns `None` if no + /// single coin matches. + /// + /// **Pilot scope:** Tier-1 exact-cover in the design (§6.3) considers + /// multi-coin subsets summing to `requested`. This single-coin form is + /// the simplest case. Multi-coin exact subset-sum (powerset enumeration + /// with lex-min disambiguation) is the natural extension; deferred. + pub fn find_exact_single_coin(&self, p: PurseId, requested: u64) + -> (res: Option<(PurseId, u64)>) + requires + self.invariant(), + ensures + match res { + Some(key) => + self.coins().dom().contains(key) + && key.0 == p + && self.coins()[key].state == CoinState::Available + && coin_value(self.coins()[key].exponent) == requested as nat, + None => + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + ==> coin_value(self.coins()[k].exponent) != requested as nat, + }, + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != p + || self.coins@[jj].state != CoinState::Available + || coin_value(self.coins@[jj].exponent) != requested as nat, + decreases self.coins.len() - j, + { + let is_avail = matches!(self.coins[j].state, CoinState::Available); + proof { + let coin_key = (self.coins@[j as int].purse, self.coins@[j as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[j as int]); + assert(self.coins@[j as int].exponent <= MAX_EXPONENT); + } + let value: u64 = pow2_u64_exec(self.coins[j].exponent); + if self.coins[j].purse == p && is_avail && value == requested { + let key = (self.coins[j].purse, self.coins[j].idx); + proof { + assert(self.spec_coins@.dom().contains(key)); + } + return Some(key); + } + j = j + 1; + } + // None: lift Vec-scan "not found" to a universal claim over the ghost + // map via invariant (m), same as `select_coin`. + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + implies coin_value(self.coins()[k].exponent) != requested as nat + by { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].purse == p); + assert(self.coins@[w].state == self.coins()[k].state); + assert(self.coins@[w].exponent == self.coins()[k].exponent); + } + } + None + } + + + /// Entry analog of [`Self::find_exact_single_coin`]: find a single + /// `Ready + LocalAvailable` entry in purse `p` whose + /// `coin_value(exp)` equals `requested` exactly. Sharp `None`. + pub fn find_exact_single_entry(&self, p: PurseId, requested: u64) + -> (res: Option<(PurseId, u64)>) + requires + self.invariant(), + ensures + match res { + Some(key) => + self.entries().dom().contains(key) + && key.0 == p + && self.entries()[key].on_chain == EntryOnChain::Ready + && self.entries()[key].local == EntryLocal::LocalAvailable + && coin_value(self.entries()[key].exponent) == requested as nat, + None => + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + && k.0 == p + && self.entries()[k].on_chain == EntryOnChain::Ready + && self.entries()[k].local == EntryLocal::LocalAvailable + ==> coin_value(self.entries()[k].exponent) != requested as nat, + }, + { + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != p + || self.entries@[jj].on_chain != EntryOnChain::Ready + || self.entries@[jj].local != EntryLocal::LocalAvailable + || coin_value(self.entries@[jj].exponent) != requested as nat, + decreases self.entries.len() - j, + { + let e = &self.entries[j]; + let is_ready = matches!(e.on_chain, EntryOnChain::Ready); + let is_local_avail = matches!(e.local, EntryLocal::LocalAvailable); + proof { + let entry_key = (self.entries@[j as int].purse, self.entries@[j as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.spec_entries@[entry_key] == self.entries@[j as int]); + assert(self.entries@[j as int].exponent <= MAX_EXPONENT); + } + let value: u64 = pow2_u64_exec(e.exponent); + if e.purse == p && is_ready && is_local_avail && value == requested { + let key = (e.purse, e.idx); + proof { + assert(self.spec_entries@.dom().contains(key)); + } + return Some(key); + } + j = j + 1; + } + proof { + // Lift Vec-scan "not found" to a universal claim over the ghost map + // via entry invariant (s). + assert forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + && k.0 == p + && self.entries()[k].on_chain == EntryOnChain::Ready + && self.entries()[k].local == EntryLocal::LocalAvailable + implies coin_value(self.entries()[k].exponent) != requested as nat + by { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == k.0 + && self.entries@[jj].idx == k.1; + assert(self.entries@[w].purse == p); + assert(self.entries@[w].on_chain == self.entries()[k].on_chain); + assert(self.entries@[w].local == self.entries()[k].local); + assert(self.entries@[w].exponent == self.entries()[k].exponent); + } + } + None + } + + + /// Entry analog of [`Self::find_two_coin_exact_cover`]: find any + /// pair of distinct `Ready + LocalAvailable` entries in purse `p` + /// whose values sum exactly to `amount`. Sharp `None`. + pub fn find_two_entry_exact_cover(&self, p: PurseId, amount: u64) + -> (res: Option<((PurseId, u64), (PurseId, u64))>) + requires + self.invariant(), + ensures + match res { + Some((k1, k2)) => + self.entries().dom().contains(k1) + && self.entries().dom().contains(k2) + && k1 != k2 + && k1.0 == p && k2.0 == p + && self.entries()[k1].on_chain == EntryOnChain::Ready + && self.entries()[k1].local == EntryLocal::LocalAvailable + && self.entries()[k2].on_chain == EntryOnChain::Ready + && self.entries()[k2].local == EntryLocal::LocalAvailable + && coin_value(self.entries()[k1].exponent) + + coin_value(self.entries()[k2].exponent) + == amount as nat, + None => + forall|i1: int, i2: int| + 0 <= i1 < self.entries@.len() + && 0 <= i2 < self.entries@.len() + && i1 != i2 + ==> { + let e1 = #[trigger] self.entries@[i1]; + let e2 = #[trigger] self.entries@[i2]; + e1.purse != p + || e1.on_chain != EntryOnChain::Ready + || e1.local != EntryLocal::LocalAvailable + || e2.purse != p + || e2.on_chain != EntryOnChain::Ready + || e2.local != EntryLocal::LocalAvailable + || (coin_value(e1.exponent) + coin_value(e2.exponent) + != amount as nat) + }, + }, + { + let n = self.entries.len(); + let mut i: usize = 0; + while i < n + invariant + 0 <= i <= n, + n == self.entries.len(), + self.invariant(), + forall|i1: int, i2: int| + 0 <= i1 < i as int && 0 <= i2 < n as int && i1 != i2 ==> { + let e1 = #[trigger] self.entries@[i1]; + let e2 = #[trigger] self.entries@[i2]; + e1.purse != p + || e1.on_chain != EntryOnChain::Ready + || e1.local != EntryLocal::LocalAvailable + || e2.purse != p + || e2.on_chain != EntryOnChain::Ready + || e2.local != EntryLocal::LocalAvailable + || (coin_value(e1.exponent) + coin_value(e2.exponent) + != amount as nat) + }, + decreases n - i, + { + let e1_ref = &self.entries[i]; + let is_ready1 = matches!(e1_ref.on_chain, EntryOnChain::Ready); + let is_local_avail1 = matches!(e1_ref.local, EntryLocal::LocalAvailable); + if e1_ref.purse == p && is_ready1 && is_local_avail1 { + proof { + let entry_key = (self.entries@[i as int].purse, self.entries@[i as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.spec_entries@[entry_key] == self.entries@[i as int]); + assert(self.entries@[i as int].exponent <= MAX_EXPONENT); + } + let vi: u64 = pow2_u64_exec(self.entries[i].exponent); + if vi <= amount { + let mut k: usize = 0; + while k < n + invariant + 0 <= k <= n, + n == self.entries.len(), + i < n, + self.invariant(), + self.entries@[i as int].purse == p, + self.entries@[i as int].on_chain == EntryOnChain::Ready, + self.entries@[i as int].local == EntryLocal::LocalAvailable, + vi as nat == coin_value(self.entries@[i as int].exponent), + vi <= 1073741824u64, + vi <= amount, + forall|i1: int, i2: int| + 0 <= i1 < i as int + && 0 <= i2 < n as int + && i1 != i2 ==> { + let e1 = #[trigger] self.entries@[i1]; + let e2 = #[trigger] self.entries@[i2]; + e1.purse != p + || e1.on_chain != EntryOnChain::Ready + || e1.local != EntryLocal::LocalAvailable + || e2.purse != p + || e2.on_chain != EntryOnChain::Ready + || e2.local != EntryLocal::LocalAvailable + || (coin_value(e1.exponent) + coin_value(e2.exponent) + != amount as nat) + }, + forall|k2: int| + 0 <= k2 < k as int && k2 != i as int ==> + (#[trigger] self.entries@[k2]).purse != p + || self.entries@[k2].on_chain != EntryOnChain::Ready + || self.entries@[k2].local != EntryLocal::LocalAvailable + || (coin_value(self.entries@[i as int].exponent) + + coin_value(self.entries@[k2].exponent) + != amount as nat), + decreases n - k, + { + if k != i { + let e2_ref = &self.entries[k]; + let is_ready2 = matches!(e2_ref.on_chain, EntryOnChain::Ready); + let is_local_avail2 = matches!(e2_ref.local, + EntryLocal::LocalAvailable); + if e2_ref.purse == p && is_ready2 && is_local_avail2 { + proof { + let entry_key = (self.entries@[k as int].purse, + self.entries@[k as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.spec_entries@[entry_key] + == self.entries@[k as int]); + assert(self.entries@[k as int].exponent <= MAX_EXPONENT); + } + let vk: u64 = pow2_u64_exec(self.entries[k].exponent); + if vi + vk == amount { + let k1 = (self.entries[i].purse, self.entries[i].idx); + let k2_key = (self.entries[k].purse, self.entries[k].idx); + proof { + assert(self.spec_entries@.dom().contains(k1)); + assert(self.spec_entries@.dom().contains(k2_key)); + assert(k1 != k2_key); + } + return Some((k1, k2_key)); + } + } + } + k = k + 1; + } + } + } + i = i + 1; + } + None + } + + + /// Find the highest-priority selectable entry in purse `p` — + /// Ready on-chain, LocalAvailable locally — per the §6.3 + /// `entryOrderLT` ordering. Returns `None` if no such entry + /// exists. Tiebreakers: ring_idx ascending, then idx ascending. + pub fn find_top_priority_entry(&self, p: PurseId) + -> (res: Option<(PurseId, u64)>) + requires + self.invariant(), + ensures + match res { + Some(key) => + self.entries().dom().contains(key) + && key.0 == p + && self.entries()[key].on_chain == EntryOnChain::Ready + && self.entries()[key].local == EntryLocal::LocalAvailable + && forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + && k.0 == p + && self.entries()[k].on_chain == EntryOnChain::Ready + && self.entries()[k].local == EntryLocal::LocalAvailable + && k != key + ==> entry_priority_lt(self.entries()[key], self.entries()[k]) + || self.entries()[key] == self.entries()[k], + None => + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + && k.0 == p + ==> self.entries()[k].on_chain != EntryOnChain::Ready + || self.entries()[k].local != EntryLocal::LocalAvailable, + }, + { + let mut best: Option = None; + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + match best { + Some(bi) => + 0 <= bi < j + && self.entries@[bi as int].purse == p + && self.entries@[bi as int].on_chain == EntryOnChain::Ready + && self.entries@[bi as int].local == EntryLocal::LocalAvailable + && forall|jj: int| 0 <= jj < j ==> + #[trigger] self.entries@[jj].purse != p + || self.entries@[jj].on_chain != EntryOnChain::Ready + || self.entries@[jj].local != EntryLocal::LocalAvailable + || entry_priority_lt(self.entries@[bi as int], self.entries@[jj]) + || self.entries@[bi as int] == self.entries@[jj], + None => + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != p + || self.entries@[jj].on_chain != EntryOnChain::Ready + || self.entries@[jj].local != EntryLocal::LocalAvailable, + }, + decreases self.entries.len() - j, + { + let e = &self.entries[j]; + let is_ready = matches!(e.on_chain, EntryOnChain::Ready); + let is_local_avail = matches!(e.local, EntryLocal::LocalAvailable); + if e.purse == p && is_ready && is_local_avail { + match best { + None => { best = Some(j); } + Some(bi) => { + let cur_better = self.entries[bi].exponent < e.exponent + || (self.entries[bi].exponent == e.exponent + && self.entries[bi].ring_idx > e.ring_idx) + || (self.entries[bi].exponent == e.exponent + && self.entries[bi].ring_idx == e.ring_idx + && self.entries[bi].idx > e.idx); + if cur_better { + best = Some(j); + } + } + } + } + j = j + 1; + } + match best { + None => { + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + && k.0 == p + implies self.entries()[k].on_chain != EntryOnChain::Ready + || self.entries()[k].local != EntryLocal::LocalAvailable + by { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == k.0 + && self.entries@[jj].idx == k.1; + assert(self.entries@[w].purse == p); + assert(self.entries@[w] == self.entries()[k]); + } + } + None + } + Some(bi) => { + let key = (self.entries[bi].purse, self.entries[bi].idx); + proof { + assert(self.spec_entries@.dom().contains(key)); + assert(self.entries()[key] == self.entries@[bi as int]); + assert forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + && k.0 == p + && self.entries()[k].on_chain == EntryOnChain::Ready + && self.entries()[k].local == EntryLocal::LocalAvailable + && k != key + implies entry_priority_lt(self.entries()[key], self.entries()[k]) + || self.entries()[key] == self.entries()[k] + by { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == k.0 + && self.entries@[jj].idx == k.1; + assert(self.entries@[w] == self.entries()[k]); + } + } + Some(key) + } + } + } + + + /// Find any recycler entry in purse `p` that is `Ready` on-chain and + /// `LocalAvailable` locally — i.e., selectable for unload or + /// transfer-via-entry. Returns the first match in Vec order, or + /// `None` if no such entry exists. + /// + /// Quint analog: a witness for `selectableEntriesIn(p, false)` — + /// the strict (non-degraded) form of the §6.3 entry selectability + /// predicate. + pub fn find_entry_ready(&self, p: PurseId) -> (res: Option<(PurseId, u64)>) + requires + self.invariant(), + ensures + match res { + Some(key) => + self.entries().dom().contains(key) + && key.0 == p + && self.entries()[key].on_chain == EntryOnChain::Ready + && self.entries()[key].local == EntryLocal::LocalAvailable, + None => + forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + && k.0 == p + ==> self.entries()[k].on_chain != EntryOnChain::Ready + || self.entries()[k].local != EntryLocal::LocalAvailable, + }, + { + let mut j: usize = 0; + while j < self.entries.len() + invariant + 0 <= j <= self.entries.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.entries@[jj]).purse != p + || self.entries@[jj].on_chain != EntryOnChain::Ready + || self.entries@[jj].local != EntryLocal::LocalAvailable, + decreases self.entries.len() - j, + { + let e = &self.entries[j]; + let is_ready = matches!(e.on_chain, EntryOnChain::Ready); + let is_local_avail = matches!(e.local, EntryLocal::LocalAvailable); + if e.purse == p && is_ready && is_local_avail { + let key = (e.purse, e.idx); + proof { + assert(self.spec_entries@.dom().contains(key)); + } + return Some(key); + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + && k.0 == p + implies self.entries()[k].on_chain != EntryOnChain::Ready + || self.entries()[k].local != EntryLocal::LocalAvailable + by { + let w = choose|jj: int| + 0 <= jj < self.entries@.len() + && #[trigger] self.entries@[jj].purse == k.0 + && self.entries@[jj].idx == k.1; + assert(self.entries@[w].purse == p); + assert(self.entries@[w].on_chain == self.entries()[k].on_chain); + assert(self.entries@[w].local == self.entries()[k].local); + } + } + None + } + + + /// Exec witness for [`classify_incoming_payment`]: scan the memo + /// list, count how many recipients map to a known local coin via + /// [`Self::find_coin_with_account`], and apply the §8.8 + /// classification rule. + pub fn classify_incoming_payment_exec(&self, memos: &Vec) + -> (res: PaymentClassification) + requires + self.invariant(), + memos@.len() <= u64::MAX as nat, + ensures + res == classify_incoming_payment(memos@, self.coins()), + { + let n = memos.len(); + let mut matched: u64 = 0; + let mut i: usize = 0; + while i < n + invariant + 0 <= i <= n, + n == memos@.len(), + n <= u64::MAX as nat, + matched as nat <= i as nat, + self.invariant(), + matched as nat == count_matched_memos(memos@, self.coins(), i as nat), + decreases n - i, + { + let m = memos[i]; + match self.find_coin_with_account(m.recipient_account) { + Some(_) => { + matched = matched + 1; + } + None => {} + } + i = i + 1; + } + if n == 0 { + PaymentClassification::Unmatched + } else if matched == 0 { + PaymentClassification::Unmatched + } else if matched as usize == n { + PaymentClassification::Matched + } else { + PaymentClassification::Received + } + } + + + /// Find the highest-priority `Available` coin in purse `p`, + /// breaking ties per the §6.3 coin priority order: + /// `(MaxExp - exp, MaxAge - age, idx)` (lex-smallest wins). + /// Returns `None` if `p` has no Available coins. + pub fn find_top_priority_coin(&self, p: PurseId) + -> (res: Option<(PurseId, u64)>) + requires + self.invariant(), + ensures + match res { + Some(key) => + self.coins().dom().contains(key) + && key.0 == p + && self.coins()[key].state == CoinState::Available + && forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + && k != key + ==> coin_priority_lt(self.coins()[key], self.coins()[k]) + || self.coins()[key] == self.coins()[k], + None => + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + ==> self.coins()[k].state != CoinState::Available, + }, + { + let mut best: Option = None; + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + match best { + Some(bi) => + 0 <= bi < j + && self.coins@[bi as int].purse == p + && self.coins@[bi as int].state == CoinState::Available + && forall|jj: int| 0 <= jj < j ==> + #[trigger] self.coins@[jj].purse != p + || self.coins@[jj].state != CoinState::Available + || coin_priority_lt(self.coins@[bi as int], self.coins@[jj]) + || self.coins@[bi as int] == self.coins@[jj], + None => + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != p + || self.coins@[jj].state != CoinState::Available, + }, + decreases self.coins.len() - j, + { + let is_avail = matches!(self.coins[j].state, CoinState::Available); + if self.coins[j].purse == p && is_avail { + match best { + None => { best = Some(j); } + Some(bi) => { + let cur = &self.coins[j]; + let cur_better = self.coins[bi].exponent < cur.exponent + || (self.coins[bi].exponent == cur.exponent + && self.coins[bi].age > cur.age) + || (self.coins[bi].exponent == cur.exponent + && self.coins[bi].age == cur.age + && self.coins[bi].idx > cur.idx); + if cur_better { + best = Some(j); + } + } + } + } + j = j + 1; + } + match best { + None => { + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + implies self.coins()[k].state != CoinState::Available + by { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].purse == p); + assert(self.coins@[w].state == self.coins()[k].state); + } + } + None + } + Some(bi) => { + let key = (self.coins[bi].purse, self.coins[bi].idx); + proof { + assert(self.spec_coins@.dom().contains(key)); + assert(self.coins()[key] == self.coins@[bi as int]); + assert forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + && k != key + implies coin_priority_lt(self.coins()[key], self.coins()[k]) + || self.coins()[key] == self.coins()[k] + by { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w] == self.coins()[k]); + } + } + Some(key) + } + } + } + + + /// Find any coin (of any state) whose `account` matches `target`. + /// Returns `(purse, idx)` of the first match in Vec order, or + /// `None`. Used by `classify_incoming_payment` to test whether a + /// memo's `recipient_account` is known locally. + pub fn find_coin_with_account(&self, target: u64) + -> (res: Option<(PurseId, u64)>) + requires + self.invariant(), + ensures + match res { + Some(key) => + self.coins().dom().contains(key) + && self.coins()[key].account == target, + None => + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + ==> self.coins()[k].account != target, + }, + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).account != target, + decreases self.coins.len() - j, + { + if self.coins[j].account == target { + let key = (self.coins[j].purse, self.coins[j].idx); + proof { + assert(self.spec_coins@.dom().contains(key)); + } + return Some(key); + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + implies self.coins()[k].account != target + by { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].account == self.coins()[k].account); + } + } + None + } + + + /// Tier-3 (entry-supplemented cover, §6.3): find any pair of one + /// `Available` coin and one `Ready + LocalAvailable` entry in + /// purse `p` whose values sum exactly to `amount`. + /// + /// This is the simplest 1-coin + 1-entry case of the powerset-based + /// existsUnloadCover. Full tier-3 with arbitrary coin and entry + /// subsets remains task #88; this case unblocks the common + /// "single coin not enough but one mature entry tips it over" + /// pattern. + pub fn find_coin_entry_exact_cover(&self, p: PurseId, amount: u64) + -> (res: Option<((PurseId, u64), (PurseId, u64))>) + requires + self.invariant(), + ensures + match res { + Some((coin_key, entry_key)) => + self.coins().dom().contains(coin_key) + && self.entries().dom().contains(entry_key) + && coin_key.0 == p + && entry_key.0 == p + && self.coins()[coin_key].state == CoinState::Available + && self.entries()[entry_key].on_chain == EntryOnChain::Ready + && self.entries()[entry_key].local == EntryLocal::LocalAvailable + && coin_value(self.coins()[coin_key].exponent) + + coin_value(self.entries()[entry_key].exponent) + == amount as nat, + None => + // Sharp: no (coin, entry) pair satisfies the cover. + forall|i: int, k: int| + 0 <= i < self.coins@.len() + && 0 <= k < self.entries@.len() + ==> { + let c = #[trigger] self.coins@[i]; + let e = #[trigger] self.entries@[k]; + c.purse != p + || c.state != CoinState::Available + || e.purse != p + || e.on_chain != EntryOnChain::Ready + || e.local != EntryLocal::LocalAvailable + || (coin_value(c.exponent) + coin_value(e.exponent) + != amount as nat) + }, + }, + { + let nc = self.coins.len(); + let ne = self.entries.len(); + let mut i: usize = 0; + while i < nc + invariant + 0 <= i <= nc, + nc == self.coins.len(), + ne == self.entries.len(), + self.invariant(), + // Outer accumulator: no (coin, entry) pair with coin index < i. + forall|i1: int, k: int| + 0 <= i1 < i as int + && 0 <= k < ne as int + ==> { + let c = #[trigger] self.coins@[i1]; + let e = #[trigger] self.entries@[k]; + c.purse != p + || c.state != CoinState::Available + || e.purse != p + || e.on_chain != EntryOnChain::Ready + || e.local != EntryLocal::LocalAvailable + || (coin_value(c.exponent) + coin_value(e.exponent) + != amount as nat) + }, + decreases nc - i, + { + let ci_avail = matches!(self.coins[i].state, CoinState::Available); + if self.coins[i].purse == p && ci_avail { + proof { + let coin_key = (self.coins@[i as int].purse, self.coins@[i as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[i as int]); + assert(self.coins@[i as int].exponent <= MAX_EXPONENT); + } + let vi: u64 = pow2_u64_exec(self.coins[i].exponent); + if vi <= amount { + let mut k: usize = 0; + while k < ne + invariant + 0 <= k <= ne, + nc == self.coins.len(), + ne == self.entries.len(), + i < nc, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vi <= 1073741824u64, + vi <= amount, + // Outer accumulator carried. + forall|i1: int, kk: int| + 0 <= i1 < i as int + && 0 <= kk < ne as int + ==> { + let c = #[trigger] self.coins@[i1]; + let e = #[trigger] self.entries@[kk]; + c.purse != p + || c.state != CoinState::Available + || e.purse != p + || e.on_chain != EntryOnChain::Ready + || e.local != EntryLocal::LocalAvailable + || (coin_value(c.exponent) + coin_value(e.exponent) + != amount as nat) + }, + // Inner accumulator: for all checked k2 < k, + // the pair (i, k2) doesn't satisfy. + forall|k2: int| + 0 <= k2 < k as int + ==> + (#[trigger] self.entries@[k2]).purse != p + || self.entries@[k2].on_chain != EntryOnChain::Ready + || self.entries@[k2].local != EntryLocal::LocalAvailable + || (coin_value(self.coins@[i as int].exponent) + + coin_value(self.entries@[k2].exponent) + != amount as nat), + decreases ne - k, + { + let e = &self.entries[k]; + let is_ready = matches!(e.on_chain, EntryOnChain::Ready); + let is_local_avail = matches!(e.local, EntryLocal::LocalAvailable); + if e.purse == p && is_ready && is_local_avail { + proof { + let entry_key = (self.entries@[k as int].purse, + self.entries@[k as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.spec_entries@[entry_key] == self.entries@[k as int]); + assert(self.entries@[k as int].exponent <= MAX_EXPONENT); + } + let ve: u64 = pow2_u64_exec(e.exponent); + if vi + ve == amount { + let ck = (self.coins[i].purse, self.coins[i].idx); + let ek = (self.entries[k].purse, self.entries[k].idx); + proof { + assert(self.spec_coins@.dom().contains(ck)); + assert(self.spec_entries@.dom().contains(ek)); + } + return Some((ck, ek)); + } + } + k = k + 1; + } + } + } + i = i + 1; + } + None + } + + + /// Tier-3 (entry-supplemented cover, §6.3, 2-coin + 1-entry): find + /// any pair of distinct `Available` coins and one `Ready + + /// LocalAvailable` entry in purse `p` whose values sum exactly + /// to `amount`. Sharp `None` postcondition. + pub fn find_two_coin_one_entry_cover(&self, p: PurseId, amount: u64) + -> (res: Option<((PurseId, u64), (PurseId, u64), (PurseId, u64))>) + requires + self.invariant(), + ensures + match res { + Some((c1, c2, e)) => + self.coins().dom().contains(c1) + && self.coins().dom().contains(c2) + && self.entries().dom().contains(e) + && c1 != c2 + && c1.0 == p && c2.0 == p && e.0 == p + && self.coins()[c1].state == CoinState::Available + && self.coins()[c2].state == CoinState::Available + && self.entries()[e].on_chain == EntryOnChain::Ready + && self.entries()[e].local == EntryLocal::LocalAvailable + && coin_value(self.coins()[c1].exponent) + + coin_value(self.coins()[c2].exponent) + + coin_value(self.entries()[e].exponent) + == amount as nat, + None => + forall|i1: int, i2: int, k: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && 0 <= k < self.entries@.len() + && i1 != i2 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let e = #[trigger] self.entries@[k]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || e.purse != p + || e.on_chain != EntryOnChain::Ready + || e.local != EntryLocal::LocalAvailable + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(e.exponent) + != amount as nat) + }, + }, + { + let nc = self.coins.len(); + let ne = self.entries.len(); + let mut i: usize = 0; + while i < nc + invariant + 0 <= i <= nc, + nc == self.coins.len(), + ne == self.entries.len(), + self.invariant(), + // Outer accumulator: no (i1, i2, k) with i1 < i works. + forall|i1: int, i2: int, k: int| + 0 <= i1 < i as int + && 0 <= i2 < nc as int + && 0 <= k < ne as int + && i1 != i2 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let e = #[trigger] self.entries@[k]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || e.purse != p + || e.on_chain != EntryOnChain::Ready + || e.local != EntryLocal::LocalAvailable + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(e.exponent) + != amount as nat) + }, + decreases nc - i, + { + let ci_avail = matches!(self.coins[i].state, CoinState::Available); + if self.coins[i].purse == p && ci_avail { + proof { + let coin_key = (self.coins@[i as int].purse, self.coins@[i as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[i as int]); + assert(self.coins@[i as int].exponent <= MAX_EXPONENT); + } + let vi: u64 = pow2_u64_exec(self.coins[i].exponent); + if vi <= amount { + let mut j: usize = 0; + while j < nc + invariant + 0 <= j <= nc, + nc == self.coins.len(), + ne == self.entries.len(), + i < nc, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vi <= 1073741824u64, + vi <= amount, + forall|i1: int, i2: int, k: int| + 0 <= i1 < i as int + && 0 <= i2 < nc as int + && 0 <= k < ne as int + && i1 != i2 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let e = #[trigger] self.entries@[k]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || e.purse != p + || e.on_chain != EntryOnChain::Ready + || e.local != EntryLocal::LocalAvailable + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(e.exponent) + != amount as nat) + }, + // Middle accumulator: forall (i, j1, k) with j1 < j, j1 != i. + forall|j1: int, k: int| + 0 <= j1 < j as int + && 0 <= k < ne as int + && j1 != i as int + ==> { + let c2 = #[trigger] self.coins@[j1]; + let e = #[trigger] self.entries@[k]; + c2.purse != p + || c2.state != CoinState::Available + || e.purse != p + || e.on_chain != EntryOnChain::Ready + || e.local != EntryLocal::LocalAvailable + || (coin_value(self.coins@[i as int].exponent) + + coin_value(c2.exponent) + + coin_value(e.exponent) + != amount as nat) + }, + decreases nc - j, + { + if j != i { + let cj_avail = matches!(self.coins[j].state, CoinState::Available); + if self.coins[j].purse == p && cj_avail { + proof { + let coin_key = (self.coins@[j as int].purse, + self.coins@[j as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[j as int]); + assert(self.coins@[j as int].exponent <= MAX_EXPONENT); + } + let vj: u64 = pow2_u64_exec(self.coins[j].exponent); + if vi + vj <= amount { + let mut k: usize = 0; + while k < ne + invariant + 0 <= k <= ne, + nc == self.coins.len(), + ne == self.entries.len(), + i < nc, + j < nc, + i != j as usize, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + self.coins@[j as int].purse == p, + self.coins@[j as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vj as nat == coin_value(self.coins@[j as int].exponent), + vi <= 1073741824u64, + vj <= 1073741824u64, + vi + vj <= amount, + // Inner accumulator: forall k2 < k checked, triple fails. + forall|k2: int| + 0 <= k2 < k as int + ==> + (#[trigger] self.entries@[k2]).purse != p + || self.entries@[k2].on_chain != EntryOnChain::Ready + || self.entries@[k2].local != EntryLocal::LocalAvailable + || (coin_value(self.coins@[i as int].exponent) + + coin_value(self.coins@[j as int].exponent) + + coin_value(self.entries@[k2].exponent) + != amount as nat), + decreases ne - k, + { + let e = &self.entries[k]; + let is_ready = matches!(e.on_chain, EntryOnChain::Ready); + let is_local_avail = matches!(e.local, + EntryLocal::LocalAvailable); + if e.purse == p && is_ready && is_local_avail { + proof { + let entry_key = (self.entries@[k as int].purse, + self.entries@[k as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.spec_entries@[entry_key] + == self.entries@[k as int]); + assert(self.entries@[k as int].exponent + <= MAX_EXPONENT); + } + let ve: u64 = pow2_u64_exec(e.exponent); + if vi + vj + ve == amount { + let ck1 = (self.coins[i].purse, self.coins[i].idx); + let ck2 = (self.coins[j].purse, self.coins[j].idx); + let ek = (self.entries[k].purse, self.entries[k].idx); + proof { + assert(self.spec_coins@.dom().contains(ck1)); + assert(self.spec_coins@.dom().contains(ck2)); + assert(self.spec_entries@.dom().contains(ek)); + assert(ck1 != ck2); + } + return Some((ck1, ck2, ek)); + } + } + k = k + 1; + } + } + } + } + j = j + 1; + } + } + } + i = i + 1; + } + None + } + + + /// Tier-3 (entry-supplemented cover, §6.3, 1-coin + 2-entry): find + /// any single `Available` coin and a pair of distinct `Ready + + /// LocalAvailable` entries in purse `p` whose values sum exactly + /// to `amount`. Sharp `None` postcondition. + pub fn find_one_coin_two_entry_cover(&self, p: PurseId, amount: u64) + -> (res: Option<((PurseId, u64), (PurseId, u64), (PurseId, u64))>) + requires + self.invariant(), + ensures + match res { + Some((c, e1, e2)) => + self.coins().dom().contains(c) + && self.entries().dom().contains(e1) + && self.entries().dom().contains(e2) + && e1 != e2 + && c.0 == p && e1.0 == p && e2.0 == p + && self.coins()[c].state == CoinState::Available + && self.entries()[e1].on_chain == EntryOnChain::Ready + && self.entries()[e1].local == EntryLocal::LocalAvailable + && self.entries()[e2].on_chain == EntryOnChain::Ready + && self.entries()[e2].local == EntryLocal::LocalAvailable + && coin_value(self.coins()[c].exponent) + + coin_value(self.entries()[e1].exponent) + + coin_value(self.entries()[e2].exponent) + == amount as nat, + None => + forall|i: int, k1: int, k2: int| + 0 <= i < self.coins@.len() + && 0 <= k1 < self.entries@.len() + && 0 <= k2 < self.entries@.len() + && k1 != k2 + ==> { + let c = #[trigger] self.coins@[i]; + let e1 = #[trigger] self.entries@[k1]; + let e2 = #[trigger] self.entries@[k2]; + c.purse != p + || c.state != CoinState::Available + || e1.purse != p + || e1.on_chain != EntryOnChain::Ready + || e1.local != EntryLocal::LocalAvailable + || e2.purse != p + || e2.on_chain != EntryOnChain::Ready + || e2.local != EntryLocal::LocalAvailable + || (coin_value(c.exponent) + + coin_value(e1.exponent) + + coin_value(e2.exponent) + != amount as nat) + }, + }, + { + let nc = self.coins.len(); + let ne = self.entries.len(); + let mut i: usize = 0; + while i < nc + invariant + 0 <= i <= nc, + nc == self.coins.len(), + ne == self.entries.len(), + self.invariant(), + forall|i1: int, k1: int, k2: int| + 0 <= i1 < i as int + && 0 <= k1 < ne as int + && 0 <= k2 < ne as int + && k1 != k2 + ==> { + let c = #[trigger] self.coins@[i1]; + let e1 = #[trigger] self.entries@[k1]; + let e2 = #[trigger] self.entries@[k2]; + c.purse != p + || c.state != CoinState::Available + || e1.purse != p + || e1.on_chain != EntryOnChain::Ready + || e1.local != EntryLocal::LocalAvailable + || e2.purse != p + || e2.on_chain != EntryOnChain::Ready + || e2.local != EntryLocal::LocalAvailable + || (coin_value(c.exponent) + + coin_value(e1.exponent) + + coin_value(e2.exponent) + != amount as nat) + }, + decreases nc - i, + { + let ci_avail = matches!(self.coins[i].state, CoinState::Available); + if self.coins[i].purse == p && ci_avail { + proof { + let coin_key = (self.coins@[i as int].purse, self.coins@[i as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[i as int]); + assert(self.coins@[i as int].exponent <= MAX_EXPONENT); + } + let vi: u64 = pow2_u64_exec(self.coins[i].exponent); + if vi <= amount { + let mut j: usize = 0; + while j < ne + invariant + 0 <= j <= ne, + nc == self.coins.len(), + ne == self.entries.len(), + i < nc, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vi <= 1073741824u64, + vi <= amount, + forall|i1: int, k1: int, k2: int| + 0 <= i1 < i as int + && 0 <= k1 < ne as int + && 0 <= k2 < ne as int + && k1 != k2 + ==> { + let c = #[trigger] self.coins@[i1]; + let e1 = #[trigger] self.entries@[k1]; + let e2 = #[trigger] self.entries@[k2]; + c.purse != p + || c.state != CoinState::Available + || e1.purse != p + || e1.on_chain != EntryOnChain::Ready + || e1.local != EntryLocal::LocalAvailable + || e2.purse != p + || e2.on_chain != EntryOnChain::Ready + || e2.local != EntryLocal::LocalAvailable + || (coin_value(c.exponent) + + coin_value(e1.exponent) + + coin_value(e2.exponent) + != amount as nat) + }, + forall|j1: int, k2: int| + 0 <= j1 < j as int + && 0 <= k2 < ne as int + && j1 != k2 + ==> { + let e1 = #[trigger] self.entries@[j1]; + let e2 = #[trigger] self.entries@[k2]; + e1.purse != p + || e1.on_chain != EntryOnChain::Ready + || e1.local != EntryLocal::LocalAvailable + || e2.purse != p + || e2.on_chain != EntryOnChain::Ready + || e2.local != EntryLocal::LocalAvailable + || (coin_value(self.coins@[i as int].exponent) + + coin_value(e1.exponent) + + coin_value(e2.exponent) + != amount as nat) + }, + decreases ne - j, + { + let e1 = &self.entries[j]; + let is_ready1 = matches!(e1.on_chain, EntryOnChain::Ready); + let is_local_avail1 = matches!(e1.local, EntryLocal::LocalAvailable); + if e1.purse == p && is_ready1 && is_local_avail1 { + proof { + let entry_key = (self.entries@[j as int].purse, + self.entries@[j as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.spec_entries@[entry_key] + == self.entries@[j as int]); + assert(self.entries@[j as int].exponent <= MAX_EXPONENT); + } + let ve1: u64 = pow2_u64_exec(e1.exponent); + if vi + ve1 <= amount { + let mut k: usize = 0; + while k < ne + invariant + 0 <= k <= ne, + nc == self.coins.len(), + ne == self.entries.len(), + i < nc, + j < ne, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + self.entries@[j as int].purse == p, + self.entries@[j as int].on_chain == EntryOnChain::Ready, + self.entries@[j as int].local == EntryLocal::LocalAvailable, + vi as nat == coin_value(self.coins@[i as int].exponent), + ve1 as nat == coin_value(self.entries@[j as int].exponent), + vi <= 1073741824u64, + ve1 <= 1073741824u64, + vi + ve1 <= amount, + forall|k2: int| + 0 <= k2 < k as int + && k2 != j as int + ==> + (#[trigger] self.entries@[k2]).purse != p + || self.entries@[k2].on_chain != EntryOnChain::Ready + || self.entries@[k2].local != EntryLocal::LocalAvailable + || (coin_value(self.coins@[i as int].exponent) + + coin_value(self.entries@[j as int].exponent) + + coin_value(self.entries@[k2].exponent) + != amount as nat), + decreases ne - k, + { + if k != j { + let e2 = &self.entries[k]; + let is_ready2 = matches!(e2.on_chain, EntryOnChain::Ready); + let is_local_avail2 = matches!(e2.local, + EntryLocal::LocalAvailable); + if e2.purse == p && is_ready2 && is_local_avail2 { + proof { + let entry_key = (self.entries@[k as int].purse, + self.entries@[k as int].idx); + assert(self.spec_entries@.dom().contains(entry_key)); + assert(self.spec_entries@[entry_key] + == self.entries@[k as int]); + assert(self.entries@[k as int].exponent + <= MAX_EXPONENT); + } + let ve2: u64 = pow2_u64_exec(e2.exponent); + if vi + ve1 + ve2 == amount { + let ck = (self.coins[i].purse, self.coins[i].idx); + let ek1 = (self.entries[j].purse, + self.entries[j].idx); + let ek2 = (self.entries[k].purse, + self.entries[k].idx); + proof { + assert(self.spec_coins@.dom().contains(ck)); + assert(self.spec_entries@.dom().contains(ek1)); + assert(self.spec_entries@.dom().contains(ek2)); + assert(ek1 != ek2); + } + return Some((ck, ek1, ek2)); + } + } + } + k = k + 1; + } + } + } + j = j + 1; + } + } + } + i = i + 1; + } + None + } + + + /// Composite tier-3 entry-supplemented cover (§6.3) search up to + /// total subset size 3. Tries 1-coin, 1-entry, 2-coin, 1-coin+1-entry, + /// 2-entry, 3-coin, 2-coin+1-entry, 1-coin+2-entry in order and + /// returns the first hit as a tagged enum (Tier3Cover). The `None` + /// branch carries the conjoined sharp postconditions from all 8 + /// underlying primitives — no subset of total size 1, 2, or 3 + /// (any coin/entry split) in the purse sums to `amount`. + /// + /// Closes the practical slice of task #88. The remaining open piece + /// — arbitrary-size powerset over the coin/entry product space — + /// would extend coverage to larger subsets at the cost of new spec + /// scaffolding. Sizes 1, 2, 3 cover the realistic cases. + pub fn find_tier3_cover_up_to_3(&self, p: PurseId, amount: u64) + -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(Tier3Cover::C1(k)) => + self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + && coin_value(self.coins()[k].exponent) == amount as nat, + Some(Tier3Cover::E1(k)) => + self.entries().dom().contains(k) + && k.0 == p + && self.entries()[k].on_chain == EntryOnChain::Ready + && self.entries()[k].local == EntryLocal::LocalAvailable + && coin_value(self.entries()[k].exponent) == amount as nat, + Some(Tier3Cover::C2(k1, k2)) => + self.coins().dom().contains(k1) + && self.coins().dom().contains(k2) + && k1 != k2 && k1.0 == p && k2.0 == p + && self.coins()[k1].state == CoinState::Available + && self.coins()[k2].state == CoinState::Available + && coin_value(self.coins()[k1].exponent) + + coin_value(self.coins()[k2].exponent) + == amount as nat, + Some(Tier3Cover::C1E1(ck, ek)) => + self.coins().dom().contains(ck) + && self.entries().dom().contains(ek) + && ck.0 == p && ek.0 == p + && self.coins()[ck].state == CoinState::Available + && self.entries()[ek].on_chain == EntryOnChain::Ready + && self.entries()[ek].local == EntryLocal::LocalAvailable + && coin_value(self.coins()[ck].exponent) + + coin_value(self.entries()[ek].exponent) + == amount as nat, + Some(Tier3Cover::E2(k1, k2)) => + self.entries().dom().contains(k1) + && self.entries().dom().contains(k2) + && k1 != k2 && k1.0 == p && k2.0 == p + && self.entries()[k1].on_chain == EntryOnChain::Ready + && self.entries()[k1].local == EntryLocal::LocalAvailable + && self.entries()[k2].on_chain == EntryOnChain::Ready + && self.entries()[k2].local == EntryLocal::LocalAvailable + && coin_value(self.entries()[k1].exponent) + + coin_value(self.entries()[k2].exponent) + == amount as nat, + Some(Tier3Cover::C3(k1, k2, k3)) => + self.coins().dom().contains(k1) + && self.coins().dom().contains(k2) + && self.coins().dom().contains(k3) + && k1 != k2 && k1 != k3 && k2 != k3 + && k1.0 == p && k2.0 == p && k3.0 == p + && self.coins()[k1].state == CoinState::Available + && self.coins()[k2].state == CoinState::Available + && self.coins()[k3].state == CoinState::Available + && coin_value(self.coins()[k1].exponent) + + coin_value(self.coins()[k2].exponent) + + coin_value(self.coins()[k3].exponent) + == amount as nat, + Some(Tier3Cover::C2E1(c1, c2, e)) => + self.coins().dom().contains(c1) + && self.coins().dom().contains(c2) + && self.entries().dom().contains(e) + && c1 != c2 + && c1.0 == p && c2.0 == p && e.0 == p + && self.coins()[c1].state == CoinState::Available + && self.coins()[c2].state == CoinState::Available + && self.entries()[e].on_chain == EntryOnChain::Ready + && self.entries()[e].local == EntryLocal::LocalAvailable + && coin_value(self.coins()[c1].exponent) + + coin_value(self.coins()[c2].exponent) + + coin_value(self.entries()[e].exponent) + == amount as nat, + Some(Tier3Cover::C1E2(c, e1, e2)) => + self.coins().dom().contains(c) + && self.entries().dom().contains(e1) + && self.entries().dom().contains(e2) + && e1 != e2 + && c.0 == p && e1.0 == p && e2.0 == p + && self.coins()[c].state == CoinState::Available + && self.entries()[e1].on_chain == EntryOnChain::Ready + && self.entries()[e1].local == EntryLocal::LocalAvailable + && self.entries()[e2].on_chain == EntryOnChain::Ready + && self.entries()[e2].local == EntryLocal::LocalAvailable + && coin_value(self.coins()[c].exponent) + + coin_value(self.entries()[e1].exponent) + + coin_value(self.entries()[e2].exponent) + == amount as nat, + None => { + // Conjoined sharp Nones from all 8 underlying primitives. + &&& forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + ==> coin_value(self.coins()[k].exponent) != amount as nat + &&& forall|k: (PurseId, u64)| + #[trigger] self.entries().dom().contains(k) + && k.0 == p + && self.entries()[k].on_chain == EntryOnChain::Ready + && self.entries()[k].local == EntryLocal::LocalAvailable + ==> coin_value(self.entries()[k].exponent) != amount as nat + &&& forall|i1: int, i2: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && i1 != i2 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || (coin_value(c1.exponent) + coin_value(c2.exponent) + != amount as nat) + } + &&& forall|i: int, k: int| + 0 <= i < self.coins@.len() + && 0 <= k < self.entries@.len() + ==> { + let c = #[trigger] self.coins@[i]; + let e = #[trigger] self.entries@[k]; + c.purse != p + || c.state != CoinState::Available + || e.purse != p + || e.on_chain != EntryOnChain::Ready + || e.local != EntryLocal::LocalAvailable + || (coin_value(c.exponent) + coin_value(e.exponent) + != amount as nat) + } + &&& forall|i1: int, i2: int| + 0 <= i1 < self.entries@.len() + && 0 <= i2 < self.entries@.len() + && i1 != i2 + ==> { + let e1 = #[trigger] self.entries@[i1]; + let e2 = #[trigger] self.entries@[i2]; + e1.purse != p + || e1.on_chain != EntryOnChain::Ready + || e1.local != EntryLocal::LocalAvailable + || e2.purse != p + || e2.on_chain != EntryOnChain::Ready + || e2.local != EntryLocal::LocalAvailable + || (coin_value(e1.exponent) + coin_value(e2.exponent) + != amount as nat) + } + &&& forall|i1: int, i2: int, i3: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && 0 <= i3 < self.coins@.len() + && i1 != i2 && i1 != i3 && i2 != i3 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let c3 = #[trigger] self.coins@[i3]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + != amount as nat) + } + &&& forall|i1: int, i2: int, k: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && 0 <= k < self.entries@.len() + && i1 != i2 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let e = #[trigger] self.entries@[k]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || e.purse != p + || e.on_chain != EntryOnChain::Ready + || e.local != EntryLocal::LocalAvailable + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(e.exponent) + != amount as nat) + } + &&& forall|i: int, k1: int, k2: int| + 0 <= i < self.coins@.len() + && 0 <= k1 < self.entries@.len() + && 0 <= k2 < self.entries@.len() + && k1 != k2 + ==> { + let c = #[trigger] self.coins@[i]; + let e1 = #[trigger] self.entries@[k1]; + let e2 = #[trigger] self.entries@[k2]; + c.purse != p + || c.state != CoinState::Available + || e1.purse != p + || e1.on_chain != EntryOnChain::Ready + || e1.local != EntryLocal::LocalAvailable + || e2.purse != p + || e2.on_chain != EntryOnChain::Ready + || e2.local != EntryLocal::LocalAvailable + || (coin_value(c.exponent) + + coin_value(e1.exponent) + + coin_value(e2.exponent) + != amount as nat) + } + }, + }, + { + match self.find_exact_single_coin(p, amount) { + Some(k) => return Some(Tier3Cover::C1(k)), + None => {} + } + match self.find_exact_single_entry(p, amount) { + Some(k) => return Some(Tier3Cover::E1(k)), + None => {} + } + match self.find_two_coin_exact_cover(p, amount) { + Some((k1, k2)) => return Some(Tier3Cover::C2(k1, k2)), + None => {} + } + match self.find_coin_entry_exact_cover(p, amount) { + Some((ck, ek)) => return Some(Tier3Cover::C1E1(ck, ek)), + None => {} + } + match self.find_two_entry_exact_cover(p, amount) { + Some((k1, k2)) => return Some(Tier3Cover::E2(k1, k2)), + None => {} + } + match self.find_three_coin_exact_cover(p, amount) { + Some((k1, k2, k3)) => return Some(Tier3Cover::C3(k1, k2, k3)), + None => {} + } + match self.find_two_coin_one_entry_cover(p, amount) { + Some((c1, c2, e)) => return Some(Tier3Cover::C2E1(c1, c2, e)), + None => {} + } + match self.find_one_coin_two_entry_cover(p, amount) { + Some((c, e1, e2)) => Some(Tier3Cover::C1E2(c, e1, e2)), + None => None, + } + } + + + /// Tier-1 multi-coin (§6.3): find any pair of distinct `Available` + /// coins in purse `p` whose values sum exactly to `amount`. Returns + /// the two keys in Vec order, or `None` if no such pair exists. + /// + /// This is the 2-coin special case of the powerset-based + /// selectExactCoverDeterministic. Full powerset enumeration remains + /// open (task #87); 2-coin already covers many cases that + /// single-coin tier-1 misses (e.g. requesting amount = max_exp + 2 + /// with two coins of value max_exp + 1 / 1). + pub fn find_two_coin_exact_cover(&self, p: PurseId, amount: u64) + -> (res: Option<((PurseId, u64), (PurseId, u64))>) + requires + self.invariant(), + ensures + match res { + Some((k1, k2)) => + self.coins().dom().contains(k1) + && self.coins().dom().contains(k2) + && k1 != k2 + && k1.0 == p + && k2.0 == p + && self.coins()[k1].state == CoinState::Available + && self.coins()[k2].state == CoinState::Available + && coin_value(self.coins()[k1].exponent) + + coin_value(self.coins()[k2].exponent) + == amount as nat, + None => + // Sharp: no two distinct Vec indices satisfy the pair-sum + // predicate. Combined with the dedup invariant (n), this + // is equivalent to "no two distinct coin keys with the + // pair-sum predicate". + forall|i1: int, i2: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && i1 != i2 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || (coin_value(c1.exponent) + coin_value(c2.exponent) + != amount as nat) + }, + }, + { + let n = self.coins.len(); + let mut i: usize = 0; + while i < n + invariant + 0 <= i <= n, + n == self.coins.len(), + self.invariant(), + // No earlier outer index i1 < i forms a valid pair with any k. + forall|i1: int, i2: int| + 0 <= i1 < i as int && 0 <= i2 < n as int && i1 != i2 ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || (coin_value(c1.exponent) + coin_value(c2.exponent) + != amount as nat) + }, + decreases n - i, + { + let ci_avail = matches!(self.coins[i].state, CoinState::Available); + if self.coins[i].purse == p && ci_avail { + proof { + let coin_key = (self.coins@[i as int].purse, self.coins@[i as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[i as int]); + assert(self.coins@[i as int].exponent <= MAX_EXPONENT); + } + let vi: u64 = pow2_u64_exec(self.coins[i].exponent); + if vi <= amount { + let mut k: usize = 0; + while k < n + invariant + 0 <= k <= n, + n == self.coins.len(), + i < n, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vi <= 1073741824u64, + vi <= amount, + // Same outer accumulator from before this inner loop. + forall|i1: int, i2: int| + 0 <= i1 < i as int && 0 <= i2 < n as int + && i1 != i2 ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || (coin_value(c1.exponent) + coin_value(c2.exponent) + != amount as nat) + }, + // Inner-loop accumulator: for all checked k2 < k, + // the pair (i, k2) doesn't satisfy the predicate. + forall|i2: int| + 0 <= i2 < k as int && i2 != i as int ==> + (#[trigger] self.coins@[i2]).purse != p + || self.coins@[i2].state != CoinState::Available + || (coin_value(self.coins@[i as int].exponent) + + coin_value(self.coins@[i2].exponent) + != amount as nat), + decreases n - k, + { + if k != i { + let ck_avail = matches!(self.coins[k].state, CoinState::Available); + proof { + let coin_key = (self.coins@[k as int].purse, + self.coins@[k as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[k as int]); + assert(self.coins@[k as int].exponent <= MAX_EXPONENT); + } + let vk: u64 = pow2_u64_exec(self.coins[k].exponent); + if self.coins[k].purse == p && ck_avail && vi + vk == amount { + let k1 = (self.coins[i].purse, self.coins[i].idx); + let k2 = (self.coins[k].purse, self.coins[k].idx); + proof { + assert(self.spec_coins@.dom().contains(k1)); + assert(self.spec_coins@.dom().contains(k2)); + assert(k1 != k2); + } + return Some((k1, k2)); + } + } + k = k + 1; + } + } + // If vi > amount, the pair-sum is also > amount and can't equal. + // The outer-loop accumulator extends by this fact for i. + } + i = i + 1; + } + None + } + + + /// Tier-1 multi-coin (§6.3, 3-coin extension): find any triple of + /// distinct `Available` coins in purse `p` whose values sum exactly + /// to `amount`. Returns the three keys in Vec order, or `None` if + /// no such triple exists. + /// + /// One step closer to full powerset (task #87): handles 3-coin + /// subsets with sharp None. Full N-coin (bitmask enumeration over + /// the first K Available coins) is still open. + pub fn find_three_coin_exact_cover(&self, p: PurseId, amount: u64) + -> (res: Option<((PurseId, u64), (PurseId, u64), (PurseId, u64))>) + requires + self.invariant(), + ensures + match res { + Some((k1, k2, k3)) => + self.coins().dom().contains(k1) + && self.coins().dom().contains(k2) + && self.coins().dom().contains(k3) + && k1 != k2 && k1 != k3 && k2 != k3 + && k1.0 == p && k2.0 == p && k3.0 == p + && self.coins()[k1].state == CoinState::Available + && self.coins()[k2].state == CoinState::Available + && self.coins()[k3].state == CoinState::Available + && coin_value(self.coins()[k1].exponent) + + coin_value(self.coins()[k2].exponent) + + coin_value(self.coins()[k3].exponent) + == amount as nat, + None => + // Sharp: no three pairwise-distinct Vec indices form + // a triple summing to amount. + forall|i1: int, i2: int, i3: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && 0 <= i3 < self.coins@.len() + && i1 != i2 && i1 != i3 && i2 != i3 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let c3 = #[trigger] self.coins@[i3]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + != amount as nat) + }, + }, + { + let n = self.coins.len(); + let mut i: usize = 0; + while i < n + invariant + 0 <= i <= n, + n == self.coins.len(), + self.invariant(), + // Outer accumulator: no triple with first index < i works. + forall|i1: int, i2: int, i3: int| + 0 <= i1 < i as int + && 0 <= i2 < n as int + && 0 <= i3 < n as int + && i1 != i2 && i1 != i3 && i2 != i3 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let c3 = #[trigger] self.coins@[i3]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + != amount as nat) + }, + decreases n - i, + { + let ci_avail = matches!(self.coins[i].state, CoinState::Available); + if self.coins[i].purse == p && ci_avail { + proof { + let coin_key = (self.coins@[i as int].purse, self.coins@[i as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[i as int]); + assert(self.coins@[i as int].exponent <= MAX_EXPONENT); + } + let vi: u64 = pow2_u64_exec(self.coins[i].exponent); + if vi <= amount { + let mut j: usize = 0; + while j < n + invariant + 0 <= j <= n, + n == self.coins.len(), + i < n, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vi <= 1073741824u64, + vi <= amount, + // Outer accumulator carried. + forall|i1: int, i2: int, i3: int| + 0 <= i1 < i as int + && 0 <= i2 < n as int + && 0 <= i3 < n as int + && i1 != i2 && i1 != i3 && i2 != i3 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let c3 = #[trigger] self.coins@[i3]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + != amount as nat) + }, + // Middle accumulator: forall (i, j1, j3) with j1 < j, distinct. + forall|j1: int, j3: int| + 0 <= j1 < j as int + && 0 <= j3 < n as int + && j1 != i as int && j3 != i as int && j1 != j3 + ==> { + let c2 = #[trigger] self.coins@[j1]; + let c3 = #[trigger] self.coins@[j3]; + c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || (coin_value(self.coins@[i as int].exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + != amount as nat) + }, + decreases n - j, + { + if j != i { + let cj_avail = matches!(self.coins[j].state, CoinState::Available); + if self.coins[j].purse == p && cj_avail { + proof { + let coin_key = (self.coins@[j as int].purse, + self.coins@[j as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[j as int]); + assert(self.coins@[j as int].exponent <= MAX_EXPONENT); + } + let vj: u64 = pow2_u64_exec(self.coins[j].exponent); + if vi + vj <= amount { + let mut k: usize = 0; + while k < n + invariant + 0 <= k <= n, + n == self.coins.len(), + i < n, + j < n, + i != j as usize, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + self.coins@[j as int].purse == p, + self.coins@[j as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vj as nat == coin_value(self.coins@[j as int].exponent), + vi <= 1073741824u64, + vj <= 1073741824u64, + vi + vj <= amount, + // Inner accumulator: forall k2 < k checked, triple fails. + forall|k2: int| + 0 <= k2 < k as int + && k2 != i as int && k2 != j as int + ==> + (#[trigger] self.coins@[k2]).purse != p + || self.coins@[k2].state != CoinState::Available + || (coin_value(self.coins@[i as int].exponent) + + coin_value(self.coins@[j as int].exponent) + + coin_value(self.coins@[k2].exponent) + != amount as nat), + decreases n - k, + { + if k != i && k != j { + let ck_avail = matches!(self.coins[k].state, + CoinState::Available); + if self.coins[k].purse == p && ck_avail { + proof { + let coin_key = (self.coins@[k as int].purse, + self.coins@[k as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] + == self.coins@[k as int]); + assert(self.coins@[k as int].exponent + <= MAX_EXPONENT); + } + let vk: u64 = pow2_u64_exec(self.coins[k].exponent); + if vi + vj + vk == amount { + let k1 = (self.coins[i].purse, + self.coins[i].idx); + let k2 = (self.coins[j].purse, + self.coins[j].idx); + let k3 = (self.coins[k].purse, + self.coins[k].idx); + proof { + assert(self.spec_coins@.dom().contains(k1)); + assert(self.spec_coins@.dom().contains(k2)); + assert(self.spec_coins@.dom().contains(k3)); + assert(k1 != k2); + assert(k1 != k3); + assert(k2 != k3); + } + return Some((k1, k2, k3)); + } + } + } + k = k + 1; + } + } + } + } + j = j + 1; + } + } + } + i = i + 1; + } + None + } + + + /// Tier-1 multi-coin (§6.3, 4-coin extension): find any quadruple of + /// pairwise-distinct `Available` coins in purse `p` whose values sum + /// exactly to `amount`. Sharp `None` postcondition. + /// + /// Same structural shape as `find_three_coin_exact_cover`, one more + /// dimension. Continues partial closure of task #87. + pub fn find_four_coin_exact_cover(&self, p: PurseId, amount: u64) + -> (res: Option<((PurseId, u64), (PurseId, u64), (PurseId, u64), (PurseId, u64))>) + requires + self.invariant(), + ensures + match res { + Some((k1, k2, k3, k4)) => + self.coins().dom().contains(k1) + && self.coins().dom().contains(k2) + && self.coins().dom().contains(k3) + && self.coins().dom().contains(k4) + && k1 != k2 && k1 != k3 && k1 != k4 + && k2 != k3 && k2 != k4 && k3 != k4 + && k1.0 == p && k2.0 == p && k3.0 == p && k4.0 == p + && self.coins()[k1].state == CoinState::Available + && self.coins()[k2].state == CoinState::Available + && self.coins()[k3].state == CoinState::Available + && self.coins()[k4].state == CoinState::Available + && coin_value(self.coins()[k1].exponent) + + coin_value(self.coins()[k2].exponent) + + coin_value(self.coins()[k3].exponent) + + coin_value(self.coins()[k4].exponent) + == amount as nat, + None => + // Sharp: no four pairwise-distinct Vec indices form a + // quadruple summing to amount. + forall|i1: int, i2: int, i3: int, i4: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && 0 <= i3 < self.coins@.len() + && 0 <= i4 < self.coins@.len() + && i1 != i2 && i1 != i3 && i1 != i4 + && i2 != i3 && i2 != i4 && i3 != i4 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let c3 = #[trigger] self.coins@[i3]; + let c4 = #[trigger] self.coins@[i4]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || c4.purse != p + || c4.state != CoinState::Available + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + + coin_value(c4.exponent) + != amount as nat) + }, + }, + { + let n = self.coins.len(); + let mut i: usize = 0; + while i < n + invariant + 0 <= i <= n, + n == self.coins.len(), + self.invariant(), + forall|i1: int, i2: int, i3: int, i4: int| + 0 <= i1 < i as int + && 0 <= i2 < n as int + && 0 <= i3 < n as int + && 0 <= i4 < n as int + && i1 != i2 && i1 != i3 && i1 != i4 + && i2 != i3 && i2 != i4 && i3 != i4 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let c3 = #[trigger] self.coins@[i3]; + let c4 = #[trigger] self.coins@[i4]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || c4.purse != p + || c4.state != CoinState::Available + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + + coin_value(c4.exponent) + != amount as nat) + }, + decreases n - i, + { + let ci_avail = matches!(self.coins[i].state, CoinState::Available); + if self.coins[i].purse == p && ci_avail { + proof { + let coin_key = (self.coins@[i as int].purse, self.coins@[i as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[i as int]); + assert(self.coins@[i as int].exponent <= MAX_EXPONENT); + } + let vi: u64 = pow2_u64_exec(self.coins[i].exponent); + if vi <= amount { + let mut j: usize = 0; + while j < n + invariant + 0 <= j <= n, + n == self.coins.len(), + i < n, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vi <= 1073741824u64, + vi <= amount, + forall|i1: int, i2: int, i3: int, i4: int| + 0 <= i1 < i as int + && 0 <= i2 < n as int + && 0 <= i3 < n as int + && 0 <= i4 < n as int + && i1 != i2 && i1 != i3 && i1 != i4 + && i2 != i3 && i2 != i4 && i3 != i4 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let c3 = #[trigger] self.coins@[i3]; + let c4 = #[trigger] self.coins@[i4]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || c4.purse != p + || c4.state != CoinState::Available + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + + coin_value(c4.exponent) + != amount as nat) + }, + forall|j1: int, j3: int, j4: int| + 0 <= j1 < j as int + && 0 <= j3 < n as int + && 0 <= j4 < n as int + && j1 != i as int && j3 != i as int && j4 != i as int + && j1 != j3 && j1 != j4 && j3 != j4 + ==> { + let c2 = #[trigger] self.coins@[j1]; + let c3 = #[trigger] self.coins@[j3]; + let c4 = #[trigger] self.coins@[j4]; + c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || c4.purse != p + || c4.state != CoinState::Available + || (coin_value(self.coins@[i as int].exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + + coin_value(c4.exponent) + != amount as nat) + }, + decreases n - j, + { + if j != i { + let cj_avail = matches!(self.coins[j].state, CoinState::Available); + if self.coins[j].purse == p && cj_avail { + proof { + let coin_key = (self.coins@[j as int].purse, + self.coins@[j as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[j as int]); + assert(self.coins@[j as int].exponent <= MAX_EXPONENT); + } + let vj: u64 = pow2_u64_exec(self.coins[j].exponent); + if vi + vj <= amount { + let mut k: usize = 0; + while k < n + invariant + 0 <= k <= n, + n == self.coins.len(), + i < n, + j < n, + i != j as usize, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + self.coins@[j as int].purse == p, + self.coins@[j as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vj as nat == coin_value(self.coins@[j as int].exponent), + vi <= 1073741824u64, + vj <= 1073741824u64, + vi + vj <= amount, + forall|k1: int, k4: int| + 0 <= k1 < k as int + && 0 <= k4 < n as int + && k1 != i as int && k1 != j as int + && k4 != i as int && k4 != j as int + && k1 != k4 + ==> { + let c3 = #[trigger] self.coins@[k1]; + let c4 = #[trigger] self.coins@[k4]; + c3.purse != p + || c3.state != CoinState::Available + || c4.purse != p + || c4.state != CoinState::Available + || (coin_value(self.coins@[i as int].exponent) + + coin_value(self.coins@[j as int].exponent) + + coin_value(c3.exponent) + + coin_value(c4.exponent) + != amount as nat) + }, + decreases n - k, + { + if k != i && k != j { + let ck_avail = matches!(self.coins[k].state, + CoinState::Available); + if self.coins[k].purse == p && ck_avail { + proof { + let coin_key = (self.coins@[k as int].purse, + self.coins@[k as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] + == self.coins@[k as int]); + assert(self.coins@[k as int].exponent + <= MAX_EXPONENT); + } + let vk: u64 = pow2_u64_exec(self.coins[k].exponent); + if vi + vj + vk <= amount { + let mut m: usize = 0; + while m < n + invariant + 0 <= m <= n, + n == self.coins.len(), + i < n, + j < n, + k < n, + i != j as usize, + i != k as usize, + j != k as usize, + self.invariant(), + self.coins@[i as int].purse == p, + self.coins@[i as int].state == CoinState::Available, + self.coins@[j as int].purse == p, + self.coins@[j as int].state == CoinState::Available, + self.coins@[k as int].purse == p, + self.coins@[k as int].state == CoinState::Available, + vi as nat == coin_value(self.coins@[i as int].exponent), + vj as nat == coin_value(self.coins@[j as int].exponent), + vk as nat == coin_value(self.coins@[k as int].exponent), + vi <= 1073741824u64, + vj <= 1073741824u64, + vk <= 1073741824u64, + vi + vj + vk <= amount, + forall|m2: int| + 0 <= m2 < m as int + && m2 != i as int + && m2 != j as int + && m2 != k as int + ==> + (#[trigger] self.coins@[m2]).purse != p + || self.coins@[m2].state != CoinState::Available + || (coin_value(self.coins@[i as int].exponent) + + coin_value(self.coins@[j as int].exponent) + + coin_value(self.coins@[k as int].exponent) + + coin_value(self.coins@[m2].exponent) + != amount as nat), + decreases n - m, + { + if m != i && m != j && m != k { + let cm_avail = matches!( + self.coins[m].state, + CoinState::Available); + if self.coins[m].purse == p && cm_avail { + proof { + let coin_key = ( + self.coins@[m as int].purse, + self.coins@[m as int].idx); + assert(self.spec_coins@.dom() + .contains(coin_key)); + assert(self.spec_coins@[coin_key] + == self.coins@[m as int]); + assert(self.coins@[m as int].exponent + <= MAX_EXPONENT); + } + let vm: u64 = pow2_u64_exec( + self.coins[m].exponent); + if vi + vj + vk + vm == amount { + let k1 = (self.coins[i].purse, + self.coins[i].idx); + let k2 = (self.coins[j].purse, + self.coins[j].idx); + let k3 = (self.coins[k].purse, + self.coins[k].idx); + let k4 = (self.coins[m].purse, + self.coins[m].idx); + proof { + assert(self.spec_coins@.dom() + .contains(k1)); + assert(self.spec_coins@.dom() + .contains(k2)); + assert(self.spec_coins@.dom() + .contains(k3)); + assert(self.spec_coins@.dom() + .contains(k4)); + assert(k1 != k2); + assert(k1 != k3); + assert(k1 != k4); + assert(k2 != k3); + assert(k2 != k4); + assert(k3 != k4); + } + return Some((k1, k2, k3, k4)); + } + } + } + m = m + 1; + } + } + } + } + k = k + 1; + } + } + } + } + j = j + 1; + } + } + } + i = i + 1; + } + None + } + + + /// Composite multi-coin subset-sum search: tries 1-, 2-, 3-, 4-coin + /// exact covers in order and returns the first hit. The `None` + /// branch carries the *conjoined* sharp postconditions from all + /// four primitives — i.e. no subset of size 1, 2, 3, or 4 in the + /// purse sums to `amount`. + /// + /// Practical multi-coin selector for task #87. Full N-coin powerset + /// (any size) remains open; this covers the realistic small-K case + /// that almost all transfers actually hit. + pub fn find_subset_sum_up_to_4(&self, p: PurseId, amount: u64) + -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(SubsetSumCover::One(k1)) => + self.coins().dom().contains(k1) + && k1.0 == p + && self.coins()[k1].state == CoinState::Available + && coin_value(self.coins()[k1].exponent) == amount as nat, + Some(SubsetSumCover::Two(k1, k2)) => + self.coins().dom().contains(k1) + && self.coins().dom().contains(k2) + && k1 != k2 + && k1.0 == p && k2.0 == p + && self.coins()[k1].state == CoinState::Available + && self.coins()[k2].state == CoinState::Available + && coin_value(self.coins()[k1].exponent) + + coin_value(self.coins()[k2].exponent) + == amount as nat, + Some(SubsetSumCover::Three(k1, k2, k3)) => + self.coins().dom().contains(k1) + && self.coins().dom().contains(k2) + && self.coins().dom().contains(k3) + && k1 != k2 && k1 != k3 && k2 != k3 + && k1.0 == p && k2.0 == p && k3.0 == p + && self.coins()[k1].state == CoinState::Available + && self.coins()[k2].state == CoinState::Available + && self.coins()[k3].state == CoinState::Available + && coin_value(self.coins()[k1].exponent) + + coin_value(self.coins()[k2].exponent) + + coin_value(self.coins()[k3].exponent) + == amount as nat, + Some(SubsetSumCover::Four(k1, k2, k3, k4)) => + self.coins().dom().contains(k1) + && self.coins().dom().contains(k2) + && self.coins().dom().contains(k3) + && self.coins().dom().contains(k4) + && k1 != k2 && k1 != k3 && k1 != k4 + && k2 != k3 && k2 != k4 && k3 != k4 + && k1.0 == p && k2.0 == p && k3.0 == p && k4.0 == p + && self.coins()[k1].state == CoinState::Available + && self.coins()[k2].state == CoinState::Available + && self.coins()[k3].state == CoinState::Available + && self.coins()[k4].state == CoinState::Available + && coin_value(self.coins()[k1].exponent) + + coin_value(self.coins()[k2].exponent) + + coin_value(self.coins()[k3].exponent) + + coin_value(self.coins()[k4].exponent) + == amount as nat, + None => { + // Conjoined sharp Nones from the four primitives. + &&& forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + ==> coin_value(self.coins()[k].exponent) != amount as nat + &&& forall|i1: int, i2: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && i1 != i2 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || (coin_value(c1.exponent) + coin_value(c2.exponent) + != amount as nat) + } + &&& forall|i1: int, i2: int, i3: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && 0 <= i3 < self.coins@.len() + && i1 != i2 && i1 != i3 && i2 != i3 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let c3 = #[trigger] self.coins@[i3]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + != amount as nat) + } + &&& forall|i1: int, i2: int, i3: int, i4: int| + 0 <= i1 < self.coins@.len() + && 0 <= i2 < self.coins@.len() + && 0 <= i3 < self.coins@.len() + && 0 <= i4 < self.coins@.len() + && i1 != i2 && i1 != i3 && i1 != i4 + && i2 != i3 && i2 != i4 && i3 != i4 + ==> { + let c1 = #[trigger] self.coins@[i1]; + let c2 = #[trigger] self.coins@[i2]; + let c3 = #[trigger] self.coins@[i3]; + let c4 = #[trigger] self.coins@[i4]; + c1.purse != p + || c1.state != CoinState::Available + || c2.purse != p + || c2.state != CoinState::Available + || c3.purse != p + || c3.state != CoinState::Available + || c4.purse != p + || c4.state != CoinState::Available + || (coin_value(c1.exponent) + + coin_value(c2.exponent) + + coin_value(c3.exponent) + + coin_value(c4.exponent) + != amount as nat) + } + }, + }, + { + match self.find_exact_single_coin(p, amount) { + Some(k1) => return Some(SubsetSumCover::One(k1)), + None => {} + } + match self.find_two_coin_exact_cover(p, amount) { + Some((k1, k2)) => return Some(SubsetSumCover::Two(k1, k2)), + None => {} + } + match self.find_three_coin_exact_cover(p, amount) { + Some((k1, k2, k3)) => return Some(SubsetSumCover::Three(k1, k2, k3)), + None => {} + } + match self.find_four_coin_exact_cover(p, amount) { + Some((k1, k2, k3, k4)) => + Some(SubsetSumCover::Four(k1, k2, k3, k4)), + None => None, + } + } + + + /// Tier-2 (split cover, §6.3): find any `Available` coin in purse `p` + /// whose `coin_value(exp)` strictly exceeds `amount`. Such a coin can + /// be split into two coins of strictly smaller exponent (one of which + /// covers `amount`); the remainder becomes change. Returns the first + /// matching coin in Vec order, or `None` if none exists. + /// + /// Quint analog: the witness for `existsSplitCover(p, amount)`. + pub fn find_split_cover_coin(&self, p: PurseId, amount: u64) + -> (res: Option<(PurseId, u64)>) + requires + self.invariant(), + ensures + match res { + Some(key) => + self.coins().dom().contains(key) + && key.0 == p + && self.coins()[key].state == CoinState::Available + && coin_value(self.coins()[key].exponent) > amount as nat, + None => + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + ==> coin_value(self.coins()[k].exponent) <= amount as nat, + }, + { + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + forall|jj: int| 0 <= jj < j ==> + (#[trigger] self.coins@[jj]).purse != p + || self.coins@[jj].state != CoinState::Available + || coin_value(self.coins@[jj].exponent) <= amount as nat, + decreases self.coins.len() - j, + { + let is_avail = matches!(self.coins[j].state, CoinState::Available); + proof { + let coin_key = (self.coins@[j as int].purse, self.coins@[j as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[j as int]); + assert(self.coins@[j as int].exponent <= MAX_EXPONENT); + } + let value: u64 = pow2_u64_exec(self.coins[j].exponent); + if self.coins[j].purse == p && is_avail && value > amount { + let key = (self.coins[j].purse, self.coins[j].idx); + proof { + assert(self.spec_coins@.dom().contains(key)); + } + return Some(key); + } + j = j + 1; + } + proof { + assert forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + implies coin_value(self.coins()[k].exponent) <= amount as nat + by { + let w = choose|jj: int| + 0 <= jj < self.coins@.len() + && #[trigger] self.coins@[jj].purse == k.0 + && self.coins@[jj].idx == k.1; + assert(self.coins@[w].purse == p); + assert(self.coins@[w].state == self.coins()[k].state); + assert(self.coins@[w].exponent == self.coins()[k].exponent); + } + } + None + } + + + /// Composite single-coin selector (§6.3 tier-1 + tier-2, single-coin + /// case). Tries the exact-cover branch first (Quint + /// `existsExactCover`'s single-coin witness), then falls back to the + /// split-cover branch (Quint `existsSplitCover`'s witness). Returns + /// `None` only when no single `Available` coin in `p` has value at + /// least `amount`. + /// + /// Multi-coin exact subset-sum (Quint + /// `selectExactCoverDeterministic`) and tier-3 entry-supplemented + /// cover are not yet wired in; their dedicated exec implementations + /// will compose with this in later phases. + pub fn select_single_coin_cover(&self, p: PurseId, amount: u64) + -> (res: Option) + requires + self.invariant(), + ensures + match res { + Some(CoinSelection::Exact { coin: key }) => + self.coins().dom().contains(key) + && key.0 == p + && self.coins()[key].state == CoinState::Available + && coin_value(self.coins()[key].exponent) == amount as nat, + Some(CoinSelection::Split { coin: key }) => + self.coins().dom().contains(key) + && key.0 == p + && self.coins()[key].state == CoinState::Available + && coin_value(self.coins()[key].exponent) > amount as nat, + None => + forall|k: (PurseId, u64)| + #[trigger] self.coins().dom().contains(k) + && k.0 == p + && self.coins()[k].state == CoinState::Available + ==> coin_value(self.coins()[k].exponent) < amount as nat, + }, + { + match self.find_exact_single_coin(p, amount) { + Some(key) => Some(CoinSelection::Exact { coin: key }), + None => match self.find_split_cover_coin(p, amount) { + Some(key) => Some(CoinSelection::Split { coin: key }), + None => None, + }, + } + } + + + /// Greedy multi-coin selection. Scans `Available` coins in purse `p` in + /// Vec order, accumulating until the running total meets or exceeds + /// `requested`. Returns the selected key list, or `None` if the total + /// Available value in `p` is insufficient. + /// + /// **Pilot scope:** this is NOT the design's three-tier exact-cover + /// selection (§6.3). Greedy may overshoot `requested` (returning more + /// value than asked). Real exact-subset-sum requires powerset + /// enumeration with lex-min disambiguation (Quint + /// `selectExactCoverDeterministic`); deferred. + pub fn select_coins_for_amount(&self, p: PurseId, requested: u64) + -> (res: Option>) + requires + self.invariant(), + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + // Bound `requested` so `accumulated + value` doesn't overflow when + // `accumulated < requested` and `value <= 2^30`. + requested <= u64::MAX - 1073741824, + requested >= 1, + ensures + match res { + Some(keys) => { + &&& forall|i: int| 0 <= i < keys@.len() ==> + self.coins().dom().contains(#[trigger] keys@[i]) + && keys@[i].0 == p + && self.coins()[keys@[i]].state == CoinState::Available + &&& sum_of_coin_values(self.coins(), keys@) >= requested as nat + }, + None => + sum_avail_prefix(self.coins@, p, self.coins@.len() as nat) + < requested as nat, + }, + { + let mut selected: Vec<(PurseId, u64)> = Vec::new(); + let mut accumulated: u64 = 0; + let mut j: usize = 0; + while j < self.coins.len() + invariant + 0 <= j <= self.coins.len(), + self.invariant(), + self.coins@.len() <= (u64::MAX / 1073741824) as nat, + requested <= u64::MAX - 1073741824, + accumulated < requested, + accumulated as nat == sum_avail_prefix(self.coins@, p, j as nat), + accumulated as nat == sum_of_coin_values(self.coins(), selected@), + forall|i: int| 0 <= i < selected@.len() ==> + self.coins().dom().contains(#[trigger] selected@[i]) + && selected@[i].0 == p + && self.coins()[selected@[i]].state == CoinState::Available, + decreases self.coins.len() - j, + { + let is_avail = matches!(self.coins[j].state, CoinState::Available); + proof { + // Bound the per-step delta for cumulative overflow safety. + // Per-step coin value is at most coin_value(MAX_EXPONENT) = 2^30. + let coin_key = (self.coins@[j as int].purse, self.coins@[j as int].idx); + assert(self.spec_coins@.dom().contains(coin_key)); + assert(self.spec_coins@[coin_key] == self.coins@[j as int]); + assert(self.coins@[j as int].exponent <= MAX_EXPONENT); + lemma_pow2_at_30(); + lemma_pow2_monotone(self.coins@[j as int].exponent as nat, + MAX_EXPONENT as nat); + assert(sum_avail_prefix(self.coins@, p, (j + 1) as nat) + <= sum_avail_prefix(self.coins@, p, j as nat) + 1073741824); + } + if self.coins[j].purse == p && is_avail { + let key = (self.coins[j].purse, self.coins[j].idx); + proof { + assert(self.spec_coins@.dom().contains(key)); + assert(self.spec_coins@[key] == self.coins@[j as int]); + assert(self.coins@[j as int].exponent <= MAX_EXPONENT); + } + let value: u64 = pow2_u64_exec(self.coins[j].exponent); + let ghost selected_before = selected@; + selected.push(key); + assert(value <= 1073741824); + assert(accumulated < requested); + assert(requested <= u64::MAX - 1073741824); + accumulated = accumulated + value; + proof { + // (l) gives ghost-map record matches Vec entry. + assert(self.spec_coins@.dom().contains(key)); + assert(self.coins()[key].state == CoinState::Available); + // Append-decomposition for sum_of_coin_values. + assert(selected@ =~= selected_before.push(key)); + assert(selected@.subrange(0, selected_before.len() as int) + =~= selected_before); + assert(sum_of_coin_values(self.coins(), selected@) + == sum_of_coin_values(self.coins(), selected_before) + + coin_value(self.coins()[key].exponent)); + } + if accumulated >= requested { + return Some(selected); + } + } + j = j + 1; + } + None + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_tokens.rs b/rust/crates/coinage-layer/src/state_tokens.rs new file mode 100644 index 00000000..1cda7e87 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_tokens.rs @@ -0,0 +1,164 @@ +//! Unload-token mint / consume / count. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Mint a new unload token (chain emit). Pushed to the tokens + /// Vec with `consumed: false`. Quint analog: any `tokens' = + /// tokens.put(...)` in a chain-mint step. + pub fn mint_token(&mut self, period: u64, class: UnloadTokenClass, counter: u64) + -> (idx: usize) + requires + old(self).invariant(), + old(self).tokens@.len() < u64::MAX as nat, + ensures + final(self).invariant(), + idx == old(self).tokens@.len(), + final(self).tokens@.len() == old(self).tokens@.len() + 1, + final(self).tokens@[idx as int] == (UnloadToken { + period, class, counter, consumed: false, + }), + forall|i: int| 0 <= i < old(self).tokens@.len() ==> + #[trigger] final(self).tokens@[i] == old(self).tokens@[i], + // Everything else untouched. + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + let ghost old_events = self.events@; + let idx = self.tokens.len(); + self.tokens.push(UnloadToken { period, class, counter, consumed: false }); + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + assert(self.events@ == old_events); + } + idx + } + + + /// Consume an unload token (mark consumed). Idempotent against + /// already-consumed tokens (silently no-op). Quint analog: the + /// chain side flipping the `consumed` flag. + pub fn consume_token(&mut self, idx: usize) -> (res: Result<(), Error>) + requires + old(self).invariant(), + ensures + final(self).invariant(), + match res { + Ok(()) => + idx < old(self).tokens@.len() + && !old(self).tokens@[idx as int].consumed + && final(self).tokens@.len() == old(self).tokens@.len() + && final(self).tokens@[idx as int].consumed + && forall|i: int| 0 <= i < old(self).tokens@.len() && i != idx as int + ==> #[trigger] final(self).tokens@[i] == old(self).tokens@[i], + Err(_) => + (idx >= old(self).tokens@.len() + || old(self).tokens@[idx as int].consumed) + && final(self).tokens@ == old(self).tokens@, + }, + final(self).purses() == old(self).purses(), + final(self).purses@ == old(self).purses@, + final(self).spec_purses@ == old(self).spec_purses@, + final(self).coins() == old(self).coins(), + final(self).coins@ == old(self).coins@, + final(self).spec_coins@ == old(self).spec_coins@, + final(self).entries() == old(self).entries(), + final(self).entries@ == old(self).entries@, + final(self).spec_entries@ == old(self).spec_entries@, + final(self).operations() == old(self).operations(), + final(self).operations@ == old(self).operations@, + final(self).spec_operations@ == old(self).spec_operations@, + final(self).next_handle == old(self).next_handle, + final(self).next_age == old(self).next_age, + final(self).next_purse_id == old(self).next_purse_id, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let ghost old_purses_vec = self.purses@; + let ghost old_spec_purses = self.spec_purses@; + let ghost old_coins_vec = self.coins@; + let ghost old_spec_coins = self.spec_coins@; + let ghost old_entries_vec = self.entries@; + let ghost old_spec_entries = self.spec_entries@; + let ghost old_operations_vec = self.operations@; + let ghost old_spec_operations = self.spec_operations@; + let ghost old_events = self.events@; + if idx >= self.tokens.len() { + return Err(Error::Internal(Vec::new())); + } + if self.tokens[idx].consumed { + return Err(Error::Internal(Vec::new())); + } + self.tokens[idx].consumed = true; + proof { + assert(self.purses@ == old_purses_vec); + assert(self.spec_purses@ == old_spec_purses); + assert(self.coins@ == old_coins_vec); + assert(self.spec_coins@ == old_spec_coins); + assert(self.entries@ == old_entries_vec); + assert(self.spec_entries@ == old_spec_entries); + assert(self.operations@ == old_operations_vec); + assert(self.spec_operations@ == old_spec_operations); + assert(self.events@ == old_events); + } + Ok(()) + } + + + /// Number of unload tokens minted. + pub fn token_count(&self) -> (n: usize) + requires self.invariant(), + ensures n == self.tokens@.len(), + { + self.tokens.len() + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/state_tracked.rs b/rust/crates/coinage-layer/src/state_tracked.rs new file mode 100644 index 00000000..795d14d4 --- /dev/null +++ b/rust/crates/coinage-layer/src/state_tracked.rs @@ -0,0 +1,701 @@ +//! `tracked_*` wrappers: same effect as the unwrapped op, plus an `OpHandle`. + +use vstd::prelude::*; + +use crate::*; + +verus! { + +impl State { + /// Tracked transfer: same effect as `transfer`, but wrapped in an + /// operation handle so the upper layer can correlate the transfer + /// with chain confirmation, cancellation, and status streams. + /// + /// Lifecycle: an operation record is created in `Preparing`, walked + /// through `Submitted`, and ends in `Done` (on Some) or `Failed` + /// (on None — no Available coin met the threshold). + pub fn tracked_transfer(&mut self, from: PurseId, to: PurseId, min_exp: u8) + -> (res: (OpHandle, Option<(PurseId, u64)>)) + requires + old(self).invariant(), + old(self).purses().dom().contains(from), + old(self).purses().dom().contains(to), + old(self).purses()[to].next_coin_idx < u64::MAX, + old(self).next_handle < u64::MAX, + old(self).next_age < u64::MAX, + old(self).events@.len() + 3 <= u64::MAX as nat, + ensures + final(self).invariant(), + res.0 == old(self).next_handle, + !old(self).operations().dom().contains(res.0), + final(self).next_handle == old(self).next_handle + 1, + final(self).entries() == old(self).entries(), + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + match res.1 { + Some(new_key) => + new_key.0 == to + && new_key.1 == old(self).purses()[to].next_coin_idx + && final(self).next_age == old(self).next_age + 1 + && final(self).operations() == old(self).operations().insert(res.0, OperationRec { + handle: res.0, + kind: OpKind::Transfer, + purse: from, + status: OpStatus::Done, + }) + && final(self).purses().dom() =~= old(self).purses().dom() + && final(self).purses()[to].id == to + && final(self).purses()[to].name == old(self).purses()[to].name + && final(self).purses()[to].next_coin_idx + == old(self).purses()[to].next_coin_idx + 1 + && final(self).purses()[to].next_entry_idx + == old(self).purses()[to].next_entry_idx + && (forall|q: PurseId| q != to + && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q]) + && (exists|src_key: (PurseId, u64)| + #[trigger] old(self).coins().dom().contains(src_key) + && src_key.0 == from + && old(self).coins()[src_key].state == CoinState::Available + && old(self).coins()[src_key].exponent >= min_exp + && final(self).coins() == old(self).coins() + .insert(src_key, CoinRec { + purse: old(self).coins()[src_key].purse, + idx: old(self).coins()[src_key].idx, + exponent: old(self).coins()[src_key].exponent, + age: old(self).coins()[src_key].age, + account: old(self).coins()[src_key].account, + state: CoinState::Spent, + }) + .insert(new_key, CoinRec { + purse: to, + idx: new_key.1, + exponent: old(self).coins()[src_key].exponent, + state: CoinState::Available, + age: old(self).next_age, + account: 0, + }) + && final(self).events@ == old(self).events@ + .push(Event::OperationStarted { + handle: res.0, + kind: OpKind::Transfer, + purse: from, + }) + .push(Event::CoinSpent { + purse: from, + exponent: old(self).coins()[src_key].exponent, + }) + .push(Event::CoinAvailable { + purse: to, + exponent: old(self).coins()[src_key].exponent, + })), + None => + final(self).next_age == old(self).next_age + && final(self).operations() == old(self).operations().insert(res.0, OperationRec { + handle: res.0, + kind: OpKind::Transfer, + purse: from, + status: OpStatus::Failed, + }) + && final(self).purses() == old(self).purses() + && final(self).coins() == old(self).coins() + && final(self).events@ == old(self).events@ + .push(Event::OperationStarted { + handle: res.0, + kind: OpKind::Transfer, + purse: from, + }) + && (forall|k: (PurseId, u64)| + #[trigger] old(self).coins().dom().contains(k) + && k.0 == from + && old(self).coins()[k].state == CoinState::Available + ==> old(self).coins()[k].exponent < min_exp), + }, + { + let handle = self.start_op(OpKind::Transfer, from); + proof { + assert(self.operations()[handle].kind == OpKind::Transfer); + assert(self.operations()[handle].purse == from); + } + self.set_op_status(handle, OpStatus::Submitted); + proof { + assert(self.operations()[handle].kind == OpKind::Transfer); + assert(self.operations()[handle].purse == from); + } + let result = self.transfer(from, to, min_exp); + proof { + assert(self.operations()[handle].kind == OpKind::Transfer); + assert(self.operations()[handle].purse == from); + } + match result { + Some(_) => self.set_op_status(handle, OpStatus::Done), + None => self.set_op_status(handle, OpStatus::Failed), + } + proof { + assert(self.operations()[handle].kind == OpKind::Transfer); + assert(self.operations()[handle].purse == from); + } + (handle, result) + } + + + /// Tracked export: wraps [`Self::export_coin`] in a `KExport` + /// operation. Returns the op handle so the caller can correlate + /// later chain events to this op. + pub fn tracked_export_coin(&mut self, key: (PurseId, u64)) + -> (handle: OpHandle) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Available, + old(self).next_handle < u64::MAX, + old(self).events@.len() + 3 <= u64::MAX as nat, + ensures + final(self).invariant(), + handle == old(self).next_handle, + !old(self).operations().dom().contains(handle), + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle, + kind: OpKind::Export, + purse: key.0, + status: OpStatus::Submitted, + }), + final(self).coins() == old(self).coins().insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Spent, + }), + final(self).purses() == old(self).purses(), + final(self).entries() == old(self).entries(), + final(self).next_handle == old(self).next_handle + 1, + final(self).next_age == old(self).next_age, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@ + .push(Event::OperationStarted { + handle, + kind: OpKind::Export, + purse: key.0, + }) + .push(Event::CoinSpent { + purse: key.0, + exponent: old(self).coins()[key].exponent, + }) + .push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let h = self.start_op(OpKind::Export, key.0); + proof { + assert(self.operations()[h].kind == OpKind::Export); + assert(self.operations()[h].purse == key.0); + } + self.export_coin(key); + proof { + assert(self.operations()[h].kind == OpKind::Export); + assert(self.operations()[h].purse == key.0); + } + self.mark_op_submitted(h); + h + } + + + /// Tracked import: wraps [`Self::import_coin`] in a `KImport` + /// operation. Returns `(handle, new_coin_key)`. + pub fn tracked_import_coin(&mut self, p: PurseId, exponent: u8, account: u64) + -> (res: (OpHandle, (PurseId, u64))) + requires + old(self).invariant(), + old(self).purses().dom().contains(p), + old(self).purses()[p].next_coin_idx < u64::MAX, + old(self).next_age < u64::MAX, + old(self).next_handle < u64::MAX, + old(self).events@.len() + 3 <= u64::MAX as nat, + exponent <= MAX_EXPONENT, + ensures + final(self).invariant(), + res.0 == old(self).next_handle, + !old(self).operations().dom().contains(res.0), + res.1.0 == p, + res.1.1 == old(self).purses()[p].next_coin_idx, + final(self).operations() == old(self).operations().insert(res.0, OperationRec { + handle: res.0, + kind: OpKind::Import, + purse: p, + status: OpStatus::Submitted, + }), + final(self).coins() == old(self).coins().insert(res.1, CoinRec { + purse: p, + idx: res.1.1, + exponent, + state: CoinState::Available, + age: old(self).next_age, + account, + }), + final(self).next_age == old(self).next_age + 1, + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx + 1, + final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).entries() == old(self).entries(), + final(self).next_handle == old(self).next_handle + 1, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@ + .push(Event::OperationStarted { + handle: res.0, + kind: OpKind::Import, + purse: p, + }) + .push(Event::CoinAvailable { purse: p, exponent }) + .push(Event::OperationProgress { + handle: res.0, + status: OpStatus::Submitted, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let h = self.start_op(OpKind::Import, p); + proof { + assert(self.operations()[h].kind == OpKind::Import); + assert(self.operations()[h].purse == p); + } + let new_key = self.import_coin(p, exponent, account); + proof { + assert(self.operations()[h].kind == OpKind::Import); + assert(self.operations()[h].purse == p); + } + self.mark_op_submitted(h); + (h, new_key) + } + + + /// Tracked rebalance: wraps [`Self::rebalance`] in a `KRebalance` + /// operation. Allocates the op handle, runs the rebalance (src + /// coin → spent, dst coin minted), advances the op to `Submitted`. + /// Returns `(handle, new_coin_key)` so the caller can correlate + /// later chain events to this op. + pub fn tracked_rebalance( + &mut self, + src: PurseId, + dst: PurseId, + key: (PurseId, u64), + ) -> (res: (OpHandle, (PurseId, u64))) + requires + old(self).invariant(), + src != dst, + key.0 == src, + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Available, + old(self).purses().dom().contains(src), + old(self).purses().dom().contains(dst), + old(self).purses()[dst].next_coin_idx < u64::MAX, + old(self).next_age < u64::MAX, + old(self).next_handle < u64::MAX, + old(self).events@.len() + 4 <= u64::MAX as nat, + ensures + final(self).invariant(), + res.0 == old(self).next_handle, + !old(self).operations().dom().contains(res.0), + res.1.0 == dst, + res.1.1 == old(self).purses()[dst].next_coin_idx, + final(self).operations() == old(self).operations().insert(res.0, OperationRec { + handle: res.0, + kind: OpKind::Rebalance, + purse: src, + status: OpStatus::Submitted, + }), + final(self).coins() == old(self).coins() + .insert(key, CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Spent, + }) + .insert(res.1, CoinRec { + purse: dst, + idx: res.1.1, + exponent: old(self).coins()[key].exponent, + state: CoinState::Available, + age: old(self).next_age, + account: 0, + }), + final(self).next_age == old(self).next_age + 1, + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[dst].id == dst, + final(self).purses()[dst].name == old(self).purses()[dst].name, + final(self).purses()[dst].next_coin_idx + == old(self).purses()[dst].next_coin_idx + 1, + final(self).purses()[dst].next_entry_idx + == old(self).purses()[dst].next_entry_idx, + forall|q: PurseId| q != dst && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).entries() == old(self).entries(), + final(self).next_handle == old(self).next_handle + 1, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@ + .push(Event::OperationStarted { + handle: res.0, + kind: OpKind::Rebalance, + purse: src, + }) + .push(Event::CoinSpent { + purse: src, + exponent: old(self).coins()[key].exponent, + }) + .push(Event::CoinAvailable { + purse: dst, + exponent: old(self).coins()[key].exponent, + }) + .push(Event::OperationProgress { + handle: res.0, + status: OpStatus::Submitted, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let handle = self.start_op(OpKind::Rebalance, src); + proof { + assert(self.operations()[handle].kind == OpKind::Rebalance); + assert(self.operations()[handle].purse == src); + } + let new_key = self.rebalance(src, dst, key); + proof { + assert(self.operations()[handle].kind == OpKind::Rebalance); + assert(self.operations()[handle].purse == src); + } + self.mark_op_submitted(handle); + (handle, new_key) + } + + + /// Tracked split: wraps [`Self::split_coin`] in a `KMaintenance` + /// operation. Returns the op handle. Used when the host wants the + /// chain to settle the split before the new coins are committed. + pub fn tracked_split_coin( + &mut self, + key: (PurseId, u64), + new_exponents: Vec, + ) -> (handle: OpHandle) + requires + old(self).invariant(), + old(self).coins().dom().contains(key), + old(self).coins()[key].state == CoinState::Available, + old(self).purses().dom().contains(key.0), + old(self).purses()[key.0].next_coin_idx as nat + new_exponents@.len() + <= u64::MAX as nat, + old(self).next_age as nat + new_exponents@.len() <= u64::MAX as nat, + old(self).next_handle < u64::MAX, + old(self).events@.len() + 3 <= u64::MAX as nat, + forall|j: int| 0 <= j < new_exponents@.len() ==> + (#[trigger] new_exponents@[j]) <= MAX_EXPONENT, + ensures + final(self).invariant(), + handle == old(self).next_handle, + !old(self).operations().dom().contains(handle), + final(self).operations() == old(self).operations().insert(handle, OperationRec { + handle, + kind: OpKind::Maintenance, + purse: key.0, + status: OpStatus::Submitted, + }), + final(self).coins()[key] == (CoinRec { + purse: old(self).coins()[key].purse, + idx: old(self).coins()[key].idx, + exponent: old(self).coins()[key].exponent, + age: old(self).coins()[key].age, + account: old(self).coins()[key].account, + state: CoinState::Spent, + }), + forall|j: int| 0 <= j < new_exponents@.len() ==> + #[trigger] final(self).coins()[ + (key.0, (old(self).purses()[key.0].next_coin_idx + j) as u64) + ] == (CoinRec { + purse: key.0, + idx: (old(self).purses()[key.0].next_coin_idx + j) as u64, + exponent: new_exponents@[j], + state: CoinState::Pending, + age: (old(self).next_age + j) as u64, + account: 0, + }), + final(self).coins().dom() =~= old(self).coins().dom().union( + Set::new(|k: (PurseId, u64)| + k.0 == key.0 + && (old(self).purses()[key.0].next_coin_idx as int) <= (k.1 as int) + && (k.1 as int) < (old(self).purses()[key.0].next_coin_idx as int) + + new_exponents@.len() as int) + ), + forall|k: (PurseId, u64)| #[trigger] old(self).coins().dom().contains(k) + && k != key + ==> final(self).coins()[k] == old(self).coins()[k], + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[key.0].id == key.0, + final(self).purses()[key.0].name == old(self).purses()[key.0].name, + final(self).purses()[key.0].next_coin_idx + == old(self).purses()[key.0].next_coin_idx + new_exponents@.len(), + final(self).purses()[key.0].next_entry_idx + == old(self).purses()[key.0].next_entry_idx, + forall|q: PurseId| q != key.0 && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).next_age == old(self).next_age + new_exponents@.len(), + final(self).next_handle == old(self).next_handle + 1, + final(self).entries() == old(self).entries(), + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@ + .push(Event::OperationStarted { + handle, + kind: OpKind::Maintenance, + purse: key.0, + }) + .push(Event::CoinSpent { + purse: key.0, + exponent: old(self).coins()[key].exponent, + }) + .push(Event::OperationProgress { + handle, + status: OpStatus::Submitted, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let h = self.start_op(OpKind::Maintenance, key.0); + proof { + assert(self.operations()[h].kind == OpKind::Maintenance); + assert(self.operations()[h].purse == key.0); + assert(self.coins()[key].state == CoinState::Available); + } + self.split_coin(key, new_exponents); + proof { + assert(self.operations()[h].kind == OpKind::Maintenance); + assert(self.operations()[h].purse == key.0); + } + self.mark_op_submitted(h); + h + } + + + /// Tracked unload via entry: wraps [`Self::unload_via_entry`] in a + /// `KExternalOffload` operation. Allocates the op handle, runs the + /// unload (entry → coin), then advances the op to `Submitted`. + /// Returns `(handle, new_coin_key)` so callers can correlate later + /// chain events to this operation. + /// + /// Quint analog: the full lifecycle of `startExternalOffload` + /// reduced to its local-state effects. + pub fn tracked_unload_via_entry(&mut self, key: (PurseId, u64)) + -> (res: (OpHandle, (PurseId, u64))) + requires + old(self).invariant(), + old(self).entries().dom().contains(key), + old(self).entries()[key].local == EntryLocal::LocalAvailable, + old(self).entries()[key].on_chain == EntryOnChain::Ready, + old(self).purses().dom().contains(key.0), + old(self).purses()[key.0].next_coin_idx < u64::MAX, + old(self).next_age < u64::MAX, + old(self).next_handle < u64::MAX, + old(self).events@.len() + 3 <= u64::MAX as nat, + ensures + final(self).invariant(), + res.0 == old(self).next_handle, + !old(self).operations().dom().contains(res.0), + res.1.0 == key.0, + res.1.1 == old(self).purses()[key.0].next_coin_idx, + final(self).operations() == old(self).operations().insert(res.0, OperationRec { + handle: res.0, + kind: OpKind::ExternalOffload, + purse: key.0, + status: OpStatus::Submitted, + }), + final(self).entries() == old(self).entries().insert(key, EntryRec { + local: EntryLocal::LocalConsumed, + ..old(self).entries()[key] + }), + final(self).coins() == old(self).coins().insert(res.1, CoinRec { + purse: key.0, + idx: res.1.1, + exponent: old(self).entries()[key].exponent, + state: CoinState::Available, + age: old(self).next_age, + account: 0, + }), + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[key.0].id == key.0, + final(self).purses()[key.0].name == old(self).purses()[key.0].name, + final(self).purses()[key.0].next_coin_idx + == old(self).purses()[key.0].next_coin_idx + 1, + final(self).purses()[key.0].next_entry_idx + == old(self).purses()[key.0].next_entry_idx, + forall|q: PurseId| q != key.0 && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).next_age == old(self).next_age + 1, + final(self).next_handle == old(self).next_handle + 1, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@ + .push(Event::OperationStarted { + handle: res.0, + kind: OpKind::ExternalOffload, + purse: key.0, + }) + .push(Event::CoinAvailable { + purse: key.0, + exponent: old(self).entries()[key].exponent, + }) + .push(Event::OperationProgress { + handle: res.0, + status: OpStatus::Submitted, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let handle = self.start_op(OpKind::ExternalOffload, key.0); + proof { + assert(self.operations()[handle].kind == OpKind::ExternalOffload); + assert(self.operations()[handle].purse == key.0); + } + let new_coin_key = self.unload_via_entry(key, handle); + proof { + assert(self.operations()[handle].kind == OpKind::ExternalOffload); + assert(self.operations()[handle].purse == key.0); + } + self.mark_op_submitted(handle); + (handle, new_coin_key) + } + + + /// Tracked top-up via entry: wraps [`Self::top_up_via_entry`] in + /// a `KTopUp` operation that starts in `Preparing` and immediately + /// advances to `Submitted` (the extrinsic creating the entry has + /// been broadcast to the chain). The op's later transitions + /// (`InBlock`, `Finalized`, `Waiting(ready_at)`, `Done`) fire as + /// chain notifications arrive — those are driven by the host via + /// the `mark_op_*` primitives. + /// + /// Quint analog: the combination of `startTopUp` + `opCommitTopUp`. + pub fn tracked_top_up_via_entry( + &mut self, + p: PurseId, + exponent: u8, + member_key: u64, + allocated_at: u64, + ready_at: u64, + ring_idx: u64, + ) -> (res: (OpHandle, (PurseId, u64))) + requires + old(self).invariant(), + old(self).purses().dom().contains(p), + old(self).purses()[p].next_entry_idx < u64::MAX, + old(self).next_handle < u64::MAX, + old(self).events@.len() + 3 <= u64::MAX as nat, + exponent <= MAX_EXPONENT, + ensures + final(self).invariant(), + res.0 == old(self).next_handle, + !old(self).operations().dom().contains(res.0), + res.1.0 == p, + res.1.1 == old(self).purses()[p].next_entry_idx, + final(self).operations() == old(self).operations().insert(res.0, OperationRec { + handle: res.0, + kind: OpKind::TopUp, + purse: p, + status: OpStatus::Submitted, + }), + final(self).entries() == old(self).entries().insert(res.1, EntryRec { + purse: p, + idx: res.1.1, + exponent, + on_chain: EntryOnChain::Waiting, + local: EntryLocal::LocalAvailable, + member_key, + allocated_at, + ready_at, + ring_idx, + }), + final(self).coins() == old(self).coins(), + final(self).purses().dom() =~= old(self).purses().dom(), + final(self).purses()[p].id == p, + final(self).purses()[p].name == old(self).purses()[p].name, + final(self).purses()[p].next_coin_idx + == old(self).purses()[p].next_coin_idx, + final(self).purses()[p].next_entry_idx + == old(self).purses()[p].next_entry_idx + 1, + forall|q: PurseId| q != p && #[trigger] old(self).purses().dom().contains(q) + ==> final(self).purses()[q] == old(self).purses()[q], + final(self).next_age == old(self).next_age, + final(self).next_handle == old(self).next_handle + 1, + final(self).fee_balance == old(self).fee_balance, + final(self).next_extrinsic_id == old(self).next_extrinsic_id, + final(self).events@ == old(self).events@ + .push(Event::OperationStarted { + handle: res.0, + kind: OpKind::TopUp, + purse: p, + }) + .push(Event::EntryAllocated { purse: p, exponent }) + .push(Event::OperationProgress { + handle: res.0, + status: OpStatus::Submitted, + }), + final(self).paid_ring_membership == old(self).paid_ring_membership, + final(self).total_in == old(self).total_in, + final(self).total_out == old(self).total_out, + final(self).tokens@ == old(self).tokens@, + final(self).chain_coins@ == old(self).chain_coins@, + final(self).chain_entries@ == old(self).chain_entries@, + { + let handle = self.start_op(OpKind::TopUp, p); + let key = self.top_up_via_entry( + p, exponent, member_key, allocated_at, ready_at, ring_idx, + ); + proof { + assert(self.operations()[handle].kind == OpKind::TopUp); + assert(self.operations()[handle].purse == p); + } + self.mark_op_submitted(handle); + (handle, key) + } + +} + +} // verus! diff --git a/rust/crates/coinage-layer/src/types.rs b/rust/crates/coinage-layer/src/types.rs new file mode 100644 index 00000000..f3fb11a0 --- /dev/null +++ b/rust/crates/coinage-layer/src/types.rs @@ -0,0 +1,404 @@ +//! Core types, constants, and tag enums for the coinage layer. +//! +//! Contains: +//! - Public constants (`MAIN_PURSE`, `MAX_EXPONENT`, jitter/recovery sizes). +//! - The four executable record types (`PurseRec`, `CoinRec`, `EntryRec`, +//! `OperationRec`) and their spec twins. +//! - Lifecycle tag enums (`CoinState`, `EntryOnChain`, `EntryLocal`, +//! `OpStatus`, `OpKind`). +//! - The result and error types (`PurseInfo`, `Error`, `CoinSelection`, +//! `SubsetSumCover`, `Tier3Cover`, `PaymentClassification`, `FeeMode`, +//! `UnloadToken`, `Event`). +//! - The central `State` struct. +//! +//! No methods on `State` live here — see the `state_*` modules. + +use vstd::prelude::*; + +verus! { + +/// Stable purse identifier (Quint `PurseId`, design §3.1). +pub type PurseId = u64; + +/// Reserved identifier of the main purse (Quint `MAIN_PURSE`). +pub const MAIN_PURSE: PurseId = 0; + +/// Maximum coin exponent (Quint `MaxExponent`). The pilot scheme +/// `coin_value(exp) = exp + 1` requires no specific upper bound, but +/// the Quint spec caps exponents at this value to keep the design's +/// `2^exp` arithmetic in u64. Callers should reject creation requests +/// with `exponent > MAX_EXPONENT`. +pub const MAX_EXPONENT: u8 = 30; + +/// Anonymity-floor jitter window (Quint `JitterMax`). After a top-up +/// entry is allocated, the chain takes between 0 and `JITTER_MAX` +/// blocks before it can be promoted to `Ready`. Hosts use this to +/// compute `ready_at = allocated_at + JITTER_MAX`. +pub const JITTER_MAX: u64 = 16; + +/// Gap-limit batch size for recovery scans (Quint `BatchSize`). A +/// recovery scan iterates through coin/entry indices in batches of +/// this many slots; if every slot in `GAP_LIMIT` consecutive batches +/// is empty, the scan terminates. +pub const RECOVERY_BATCH_SIZE: u64 = 8; + +/// Number of consecutive empty batches that terminate a recovery scan +/// (Quint `GapLimit`). +pub const RECOVERY_GAP_LIMIT: u64 = 4; + +/// Executable purse record (mirrors Quint `PurseRec`, spec lines 89-94). +pub struct PurseRec { + pub id: PurseId, + pub name: Vec, + pub next_coin_idx: u64, + pub next_entry_idx: u64, +} + +/// Spec-level twin of `PurseRec` used in contracts. +pub struct PurseRecSpec { + pub id: PurseId, + pub name: Seq, + pub next_coin_idx: nat, + pub next_entry_idx: nat, +} + +impl PurseRec { + /// Lift an executable record into its spec twin. + pub open spec fn view(&self) -> PurseRecSpec { + PurseRecSpec { + id: self.id, + name: self.name@, + next_coin_idx: self.next_coin_idx as nat, + next_entry_idx: self.next_entry_idx as nat, + } + } +} + +/// Coin lifecycle state (Quint `CoinState`). +/// * `Pending` — coin has been allocated but is not yet observed as +/// existing on chain. Cannot be selected. +/// * `Available` — observed on chain; eligible for selection. +/// * `LockedFor(handle)` — coin has been reserved by operation `handle`; +/// can be released back to `Available` (cancel) or advanced to +/// `PendingSpend` (commit). +/// * `PendingSpend` — coin has been chosen by an in-flight operation. +/// * `Spent` — coin is terminally consumed; counts neither for selection +/// nor as "live" for purse-deletion purposes. +pub type OpHandle = u64; + +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum CoinState { + Pending, + Available, + LockedFor(OpHandle), + PendingSpend, + Spent, +} + +/// Coin record (Quint `CoinRec`, design §3.2). `age` is the monotonic +/// allocation timestamp used by the §6.3 priority ordering — older +/// coins (smaller `age`) outrank newer ones at equal exponent. +/// `account` is the chain-account identifier the coin lives under. +/// In this pilot it is a `u64` placeholder set to 0 on allocation; +/// account-aware operations (top-up funding origin, transfer destination) +/// will populate it once the chain abstraction lands. +#[derive(Copy, Clone)] +pub struct CoinRec { + pub purse: PurseId, + pub idx: u64, + pub exponent: u8, + pub state: CoinState, + pub age: u64, + pub account: u64, +} + +/// Recycler entry on-chain state (Quint `EntryOnChain`, design §5.2). +/// The `OnDegraded` payload is omitted in the pilot (it carries a +/// post-submission detection epoch in the design). +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum EntryOnChain { + Missing, + Waiting, + Ready, + Degraded, +} + +/// Recycler entry local-side state (Quint `EntryLocal`, design §5.4). +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum EntryLocal { + LocalAvailable, + LocalLockedFor(OpHandle), + LocalConsumed, +} + +/// Recycler entry record (Quint `EntryRec`, design §3.3). +/// +/// Recycler entry record (Quint `EntryRec`, design §5.2). Carries the +/// chain-side bookkeeping fields needed by the §6.3 selection ordering +/// and the §8 lifecycle: +/// - `member_key` — ring-membership identifier (`u64` placeholder). +/// - `allocated_at` — block height when the entry was reserved. +/// - `ready_at` — block height when the anonymity floor was reached. +/// - `ring_idx` — index within the anonymity ring; used as the +/// tiebreaker between equal-exponent entries by §6.3 +/// `entryPriorityRank`. +#[derive(Copy, Clone)] +pub struct EntryRec { + pub purse: PurseId, + pub idx: u64, + pub exponent: u8, + pub on_chain: EntryOnChain, + pub local: EntryLocal, + pub member_key: u64, + pub allocated_at: u64, + pub ready_at: u64, + pub ring_idx: u64, +} + +/// Operation kind (Quint `OpKind`, design §3.4). Each kind drives a +/// distinct top-level operation flavor; `OpStatus` then walks every +/// kind through the same lifecycle (Preparing → Submitted → InBlock → +/// Finalized → Done | Failed). +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum OpKind { + Transfer, + TopUp, + Rebalance, + DeletePurse, + ExternalOffload, + Export, + Import, + Maintenance, + Recover, +} + +/// Operation status (Quint `OpStatus`, design §5.5). Mirrors the full +/// Quint phase order Preparing → Submitted → InBlock → Finalized → +/// (Waiting →)? Done, with `Failed` reachable from any pre-terminal +/// state. The `Waiting(t)` arm carries a `u64` placeholder for the +/// Quint `Time` payload (entry-ready timestamp). +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum OpStatus { + Preparing, + Submitted, + InBlock, + Finalized, + Waiting(u64), + Done, + Failed, +} + +/// Operation record (Quint `OperationRec`). Pilot scope: handle, kind, +/// status, owning purse. The Quint record also carries `lockedCoins` +/// and `lockedEntries` sets — deferred until cross-state locking lands. +#[derive(Copy, Clone)] +pub struct OperationRec { + pub handle: OpHandle, + pub kind: OpKind, + pub purse: PurseId, + pub status: OpStatus, +} + +/// Incoming-payment memo entry (Quint `MemoEntry`, §8.3). The layer +/// treats memos opaquely; only `recipient_account` is used by +/// `classify_incoming_payment`. +#[derive(Copy, Clone)] +pub struct MemoEntry { + pub sender_account: u64, + pub recipient_account: u64, + pub derivation_index: u64, +} + +/// Classification of an incoming chain payment (Quint +/// `PaymentClassification`, §8.8). +/// +/// - `Matched`: every memo's recipient is a known local coin account. +/// The payment is fully accounted for by existing coins. +/// - `Received`: some — but not all — memos match local coins. The +/// recipient has new funds beyond what's locally tracked. +/// - `Unmatched`: no memos match (or the list is empty). The payment +/// isn't for this host or originates from an unknown sender. +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum PaymentClassification { + Matched, + Received, + Unmatched, +} + +/// Single-coin selection result (§6.3 single-coin tier-1 / tier-2 cases). +/// `Exact` is the design's tier-1 single-coin form (coin value matches +/// the requested amount). `Split` is the tier-2 form (coin value +/// strictly exceeds the amount; caller must split the coin and emit +/// change). Multi-coin tier-1 selections and tier-3 entry-supplemented +/// selections will be carried by separate variants when their exec +/// paths land. +pub enum CoinSelection { + Exact { coin: (PurseId, u64) }, + Split { coin: (PurseId, u64) }, +} + +/// Result of a bounded subset-sum search over `Available` coins: +/// either a single coin, a pair, a triple, or a quadruple of distinct +/// coin keys whose values sum exactly to the requested amount. Returned +/// by [`State::find_subset_sum_up_to_4`]. +pub enum SubsetSumCover { + One((PurseId, u64)), + Two((PurseId, u64), (PurseId, u64)), + Three((PurseId, u64), (PurseId, u64), (PurseId, u64)), + Four((PurseId, u64), (PurseId, u64), (PurseId, u64), (PurseId, u64)), +} + +/// Result of a tier-3 entry-supplemented cover search. Carries either +/// a pure-coin subset, a pure-entry subset, or a mixed coin+entry +/// subset whose values sum exactly to the requested amount. Returned +/// by [`State::find_tier3_cover_up_to_3`]. +/// +/// Naming convention: `CkEm` denotes k coins and m entries. +pub enum Tier3Cover { + C1((PurseId, u64)), + E1((PurseId, u64)), + C2((PurseId, u64), (PurseId, u64)), + C1E1((PurseId, u64), (PurseId, u64)), + E2((PurseId, u64), (PurseId, u64)), + C3((PurseId, u64), (PurseId, u64), (PurseId, u64)), + C2E1((PurseId, u64), (PurseId, u64), (PurseId, u64)), + C1E2((PurseId, u64), (PurseId, u64), (PurseId, u64)), +} + +/// Snapshot returned by `query_purse` (design §8.1 `PurseInfo`). +/// Pilot scope: `spendable`, `spendable_strict`, `pending` are always 0 +/// (no coins/entries in state yet). +pub struct PurseInfo { + pub id: PurseId, + pub name: Vec, + pub spendable: u64, + pub spendable_strict: u64, + pub pending: u64, +} + +/// Layer error enum (design §10). String payloads are modeled as +/// `Vec` for Verus-compat; `ExtrinsicHash` is a `u64` placeholder. +/// `OperationHandle` is a `u64` placeholder. +pub enum Error { + // Pre-submission + PurseNotFound(PurseId), + OperationNotFound(u64), + CannotDeleteMainPurse, + PurseHasInFlightOperations, + OutputsDoNotSumToAmount, + InsufficientFunds { requested: u64, available: u64 }, + InsufficientExternalFunds, + NoReadyEntries { requested: u64, available_when_ready: u64 }, + NoUnloadToken, + BadCoinSecret, + // Post-submission / chain + SnipedCoin, + ChainRejected { extrinsic_hash: u64, reason: Vec }, + // Lifecycle + Cancelled, + InterruptedPreSubmission, + // Internal + StorageError(Vec), + SubscriptionError(Vec), + RecoveryFailed(Vec), + Internal(Vec), +} + +/// Layer state. Pilot scope: purses only. +/// +/// Fields are public so that the `open spec fn` accessors can read them at +/// call sites outside this crate (Verus treats any struct with even one +/// private field as fully opaque externally). External writes to these +/// fields will break the invariant, which makes any further method call +/// reject via `requires`; the invariant remains the only valid entry point. +pub struct State { + pub purses: Vec, + pub coins: Vec, + pub entries: Vec, + pub operations: Vec, + pub next_purse_id: u64, + pub next_handle: OpHandle, + pub next_age: u64, + /// Quint `feeAccountBalance`. Reservoir of pre-paid chain-fee funds. + pub fee_balance: u64, + /// Quint `nextExtrinsicId`. Monotonically increasing counter for + /// chain-extrinsic identifiers — bumped by every chain-bound op + /// when its extrinsic is broadcast (Submitted transition). + pub next_extrinsic_id: u64, + /// Quint event stream. Append-only sequence of observations. Hosts + /// consume this for UI notifications, test assertions, and audit + /// trails. Every state-mutating op declares its emissions in its + /// postcondition. + pub events: Vec, + /// Quint `paidRingMembership`. Total amount paid for anonymity-ring + /// membership fees — accumulated as top-ups land. + pub paid_ring_membership: u64, + /// Quint `totalIn`. Total amount of funds that have entered the + /// system (top-ups, imports). Monotonically non-decreasing. + pub total_in: u64, + /// Quint `totalOut`. Total amount of funds that have exited the + /// system (transfers out, exports). Monotonically non-decreasing. + pub total_out: u64, + /// Quint `tokens`. Vec of unload tokens; indexed by allocation + /// order. The chain mints these (with `consumed: false`); the + /// layer marks consumed when the corresponding unload op commits. + pub tokens: Vec, + /// Quint `chainCoins`. Mirror of on-chain coin state, used by the + /// gap-limit recovery scan to rebuild local `coins` after partial + /// state loss. The chain side acts as the source of truth. + pub chain_coins: Vec, + /// Quint `chainEntries`. Mirror of on-chain entry state. + pub chain_entries: Vec, + #[allow(dead_code)] + pub spec_purses: Ghost>, + #[allow(dead_code)] + pub spec_coins: Ghost>, + #[allow(dead_code)] + pub spec_entries: Ghost>, + #[allow(dead_code)] + pub spec_operations: Ghost>, +} + +/// Quint `FeeMode`. The layer picks automatically: prepaid if the fee +/// account has funds, from-output otherwise. +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum FeeMode { + Prepaid, + FromOutput, +} + +/// Quint `UnloadTokenClass`. Free tokens are granted by the chain; +/// paid tokens come from the fee account or from-output. +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum UnloadTokenClass { + Free, + Paid, +} + +/// Quint `UnloadToken` (design §6.5). Identifies a single unload +/// authorization. The chain tracks `consumed` flags; the layer +/// mirrors them. +#[derive(Copy, Clone)] +pub struct UnloadToken { + pub period: u64, + pub class: UnloadTokenClass, + pub counter: u64, + pub consumed: bool, +} + +/// Layer-level event (Quint `Event`, design §11). Append-only stream +/// of observations consumed by host UIs and tests. Each state-mutating +/// op declares its emissions in its contract; queries emit nothing. +#[derive(Copy, Clone)] +pub enum Event { + CoinAvailable { purse: PurseId, exponent: u8 }, + CoinSpent { purse: PurseId, exponent: u8 }, + EntryAllocated { purse: PurseId, exponent: u8 }, + EntryReadinessChanged { purse: PurseId, exponent: u8, new_state: EntryOnChain }, + EntryConsumed { purse: PurseId, exponent: u8 }, + OperationStarted { handle: OpHandle, kind: OpKind, purse: PurseId }, + OperationProgress { handle: OpHandle, status: OpStatus }, + OperationCompleted { handle: OpHandle, status: OpStatus }, +} + +} // verus!