From ed4c735987327bebcfa51e95200f47c279fa4976 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:42:03 -0400 Subject: [PATCH 01/11] docs(specs): SoFi Amendment S21, escrow vaults released by a canonical verdict An application needed two parties to lock equal stakes against one agreed match, with the application as referee, and nothing in DSM could hold value under a condition other than a SoFi market. The owner ruled (2026-10-04) for a generic escrow vault and no wager logic in Core, and (2026-10-05) that one external commitment has one admissible verdict, made so by the protocol rather than by the referee's bookkeeping. SoFi section 19.9 specifies it on the vault machinery: - an escrow vault is a vault whose three policy slots name one EscrowTerms object (class 0x0063): a token, Y = H(DSM/external/v1 || X) and 1 to 16 branches, each an outcome, the exact set of signers that decides it, and a recipient; - it releases its whole amount once, by the recipient's own Release position (classes 0x0065 and 0x0066), and has no market and no owner close; - the verdict occupies K_verdict = H(DSM/escrow/verdict-cell/v1; Y || tau) first at its leader and proves its own authority from its bytes (EscrowVerdict, class 0x0064); tau is the digest of the outcome table, so only the agreed signers can occupy the cell; - every vault bound to the cell settles only on the outcome it holds: ConsumedRoute requires the verdict final on the release's outcome, and RouteImpossible arm (v) skips a release that lost it, which resolves Void; - creation is operation variant 38, EscrowVaultCreate. MR-SOFI-0363 to 0384 are added with source amendment, all Missing; CONFORMANCE section 6.72 records the finding; section 1 is re-pinned and the totals regenerated. The implementation follows after the owner's review of the amendment. --- specs/SoFi_Settlement_Specification.md | 132 ++++++++++++++++++++++ specs/requirements/CONFORMANCE_GAPS.md | 40 ++++++- specs/requirements/MASTER_REQUIREMENTS.md | 29 ++++- 3 files changed, 196 insertions(+), 5 deletions(-) diff --git a/specs/SoFi_Settlement_Specification.md b/specs/SoFi_Settlement_Specification.md index 779279424..9ed21d399 100644 --- a/specs/SoFi_Settlement_Specification.md +++ b/specs/SoFi_Settlement_Specification.md @@ -672,6 +672,8 @@ is Unavailable, never Invalid. | `preimage_locator(E)` | P(E) | | `vault_genesis_locator(v)` | the vault genesis preimage | | `vault_token_locator(t)` | the genesis preimage of each vault whose market pairs token `t` (Amendment S16) | +| `escrow_commitment_locator(Y)` | the genesis preimage of each escrow vault whose terms name `Y` (Amendment S21) | +| `escrow_statement_locator(K, o)` | gathered signatures deciding outcome `o` at verdict cell `K` (Amendment S21) | | ρ | the setup body | | PolicyFulfillmentIdj | Gj | | the auxiliary reference | auxiliary evidence candidates | @@ -781,6 +783,17 @@ TAG_DSM_ECONOMIC_LEAF_STATE DSM/economic-leaf-state/v1 Retired, never reused DSM/sofi/route-outcome/v2 +Escrow vaults (Amendment S21), constants in CORE/common/domain_tags/dsm/misc/escrow.rs +TAG_DSM_EXTERNAL DSM/external/v1 Y = H(DSM/external/v1 ∥ X) (Explainer §60) +TAG_DSM_ESCROW_TERMS_OBJECT DSM/escrow/terms-object/v1 A_T, address of EscrowTerms +TAG_DSM_ESCROW_OUTCOME_TABLE DSM/escrow/outcome-table/v1 τ +TAG_DSM_ESCROW_VERDICT_CELL DSM/escrow/verdict-cell/v1 K_verdict +TAG_DSM_ESCROW_VERDICT_SEED DSM/escrow/verdict-seed/v1 s_verdict, the seed of the verdict cell +TAG_DSM_ESCROW_STATEMENT DSM/escrow/statement/v1 m(o) +TAG_DSM_ESCROW_VERDICT_OBJECT DSM/escrow/verdict-object/v1 address of a gathered EscrowVerdict +TAG_DSM_ESCROW_COMMITMENT_LOCATOR DSM/escrow/commitment-locator/v1 locator of the escrow vaults bound to Y +TAG_DSM_ESCROW_STATEMENT_LOCATOR DSM/escrow/statement-locator/v1 locator of gathered signatures for (K_verdict, o) + #### 14.2 Object classes @@ -822,6 +835,10 @@ pre 0x005A SOFI_VAULT_GENESIS_PREIMAGE vault genesis preimage 0x005B SOFI_VAULT_CREATION vault creation leaf 0x0061 SOFI_TRADER_PRE_BALANCE a trader's balance before the trade (Amendment S12) +0x0063 ESCROW_TERMS an escrow vault's terms (Amendment S21) +0x0064 ESCROW_VERDICT a verdict on an external commitment (Amendment S21) +0x0065 SOFI_SETTLEMENT_RELEASE B ◦ , Release branch (Amendment S21) +0x0066 SOFI_ROUTE_DIGEST_RELEASE route digest preimage, Release (Amendment S21) @@ -893,6 +910,9 @@ preimage locator H(preimage-locator/v1; E) No attempt index, availability view, routing order, member identity or witness enters E. +The escrow derivations (`A_T`, `τ`, `K_verdict`, `s_verdict`, `m(o)` and the two escrow locators) are defined in §19.9 +(Amendment S21). A Release's E takes the single-leg form above, and its verdict is not an input of E. + ### 16 Setup A trader sets up once per vault before its first operation against that vault. @@ -1342,6 +1362,118 @@ at p is validated. Locator writes are attributed to the owner. GenesisStored is An owner MAY trade against its own vault; no special case applies, and the fee stays in the reserves. + +#### 19.9 Escrow vaults (Amendment S21) + +> **Amendment S21 (owner, 2026-10-04 and 2026-10-05) — escrow vaults: a vault released by the canonical verdict on an external commitment.** An application needed two parties to lock equal stakes against one agreed match, with the application as referee deciding who takes both. Owner rulings, 2026-10-04: "The wager should be expressed by composing existing DSM primitives, not by teaching Core what a “PvP wager” or “battle winner” is", "Do not add wager-specific or battle-specific logic to Core", and "You need to make the generic escrow vaults, and then we just use that, but that's one of the primitives we have. We haven't finished yet." 2026-10-05, on two vaults bound to one commitment: "make the verdict for a given external commitment Y a canonical first-commit-wins object. Both linked escrow vaults must settle against that same canonical verdict. Do not rely only on the app host's persisted compare-and-set to prevent the referee from signing conflicting outcomes. The protocol itself should make one Y have one admissible verdict." + +An escrow vault is Explainer §59's escrow: a DLV with precommitted branches (§58), each guarded by signatures (§59) over a statement about an external commitment (§60). It is built on the vault machinery of this specification: a vault holds the value, one exercise per generation consumes it (§23), and the payout lands in the recipient's own transition (§19.6). Nothing in it knows what an application's commitment means. No rule for a vault whose terms are a market changes. + +**What an escrow vault is** + +- An escrow vault holds one amount of one token. It releases that whole amount once, along one of its precommitted branches, to that branch's recipient, when the canonical verdict on its external commitment names that branch's outcome. It has no market, no owner close and no partial release. +- **The slot rule.** `VaultStateLeaf` is unchanged byte for byte. In an escrow vault, `market_policy`, `fee_policy` and `release_policy` all hold `A_T`, the address of the vault's `EscrowTerms` bytes. `reserve_a` is the held amount and `reserve_b` is 0. The vault is Active while `reserve_a > 0` and `reserve_b = 0`. It is Retired when both are zero, and Retired is terminal, as for every vault. A vault's terms are resolved from the bytes its slots address: three slots naming one object of class `0x0063` make an escrow vault. Any other combination that includes such an object is neither a market nor an escrow, and its genesis is refused. + +**`EscrowTerms`**, class `0x0063`, schema 1. Its address is `A_T = immutable_addr(DSM/escrow/terms-object/v1, CCB bytes)`. + +| Field | Content | +|---|---| +| `token` | digest32: the policy commit of the held token | +| `external_commitment` | digest32: `Y = H(DSM/external/v1 ∥ X)` (Explainer §60). DSM never reads `X`. | +| `branches` | 1 to 16 branches, strictly ascending by `outcome` bytes | + +| Branch field | Content | +|---|---| +| `outcome` | 1 to 64 bytes: the label a verdict names | +| `signers` | 1 to 4 signers, strictly ascending: the exact set whose signatures decide this outcome. A signer is `(signature_alg, public_key)`, ordered by `u16be(alg) ∥ u32be(|key|) ∥ key`. | +| `recipient_genesis`, `recipient_device_id` | digest32 each: the identity the branch pays | + +- A branch is decided by all of its signers. No duplicate signer, no empty set and no threshold can be expressed. Labels are unique, so a verdict names at most one branch. +- The amount is always the whole held amount. Nothing is chosen at release. +- **The outcome table** `O` is the branches with their recipients removed: the list of `(outcome, signers)` in branch order. Its digest is `τ = H(DSM/escrow/outcome-table/v1; u8(|O|) ∥ ⨁ entries)`, each entry `u32be(|o|) ∥ o ∥ u8(|signers|) ∥ ⨁ (u16be(alg) ∥ u32be(|key|) ∥ key)`. Two vaults with the same `Y` and the same table are linked: they share one verdict cell. Their recipients may differ. + +**The verdict cell: one admissible verdict** + +- **The key.** Every escrow vault whose terms name `Y` and whose table hashes to `τ` is bound to one cell, `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)`. Its leader is `FisherYates(s_verdict, S)[0]` (§7) over the network's pinned set, with `s_verdict = H(DSM/escrow/verdict-seed/v1; K_verdict)`, so a reader that knows only the key finds its leader. The cell is written by the procedure of §8 with route-chain finality (Amendment S4), and anyone may write it (§9). +- **The statement.** A signer decides outcome `o` for the cell by signing `m(o) = H(DSM/escrow/statement/v1; K_verdict ∥ u32be(|o|) ∥ o)`. The statement names the cell, so a signature decides nothing anywhere else: not another commitment, and not another table over the same commitment. It names no vault, so one signature serves every vault bound to the cell. +- **`EscrowVerdict`**, class `0x0064`, schema 1: `external_commitment` (`Y`), `table` (`O`, with the bounds of the terms' branches), `outcome` (1 to 64 bytes), and `signatures`: 1 to 4 entries `(signature_alg, public_key, signature)`, strictly ascending by signer. With four SPHINCS+ signatures it is within a cell's value bound (`route_chain::MAX_VALUE_LEN`). +- **What occupies the cell.** The value that counts at `K_verdict` is the first object at its leader that is an `EscrowVerdict` recognized there. Everything else at the cell counts as nothing. A verdict is recognized at `K` when, from its own bytes alone: + 1. `H(DSM/escrow/verdict-cell/v1; Y ∥ τ(table)) = K`; + 2. `outcome` is a label of `table`; + 3. its signers are exactly the signers `table` assigns to `outcome`; + 4. every signature verifies over `m(outcome)` under its key (SPHINCS+, §14.3). + + The verdict proves its own authority for the cell, as Amendment S20 requires of every occupant. No vault, member or reader's position enters the decision. +- **The facts.** `VerdictHeld(K, o)` holds when the cell's leader link is held by a recognized verdict on `o`, in any state: leader-held, preserved or final. `VerdictFinal(K, o)` holds when that verdict is final at `K` (Amendment S4), shown by a completion proof (Amendment S10). By §8, consequences 2 and 3, the cell holds at most one outcome, ever. A second verdict, even one validly signed by the same signers for another outcome, never becomes the cell's value: conflicting adjudications contend for one deterministic key (Explainer §40, §58), and the first at the leader wins. +- **Why the table is in the key.** If the key were `Y` alone, the first object at the cell would have to be judged against signers the cell does not know. Anyone could occupy it first with a verdict signed under keys of its own choosing, and freeze every stake bound to `Y`. With `τ` in the key, only the agreed signers can occupy it. Parties that link vaults agree on `Y` and on the table. A vault with another table is another agreement, with its own cell, and it never touches theirs. +- **The leader.** The parties agree on `Y` and `τ`. A party that proposes `X` can influence which member leads by its choice of `X`. A member never equivocates (§5.1), so that touches liveness only (§5.3, condition 2), never which verdict is canonical. +- **Gathering signatures.** A branch with more than one signer needs each signer's signature. A signer MAY put an `EscrowVerdict` holding the signatures it has as an object, under `immutable_addr(DSM/escrow/verdict-object/v1, CCB bytes)`, and index it under `escrow_statement_locator(K, o) = H(DSM/escrow/statement-locator/v1; K ∥ u32be(|o|) ∥ o)`. A reader keeps each signature that verifies over `m(o)` under a signer the table assigns to `o`, and nothing else. The index carries no authority (§11). Only a recognized verdict at the cell decides anything. + +**Creation** + +- `EscrowVaultCreate`, variant 38 of `Operation`, carries the vault genesis preimage, the `VaultCreation` leaf and the exact `EscrowTerms` bytes, signed by the owner, as `SofiVaultCreate` carries its market policy (§28). The owner's transition debits the held amount of `terms.token` once and inserts `VaultCreation{vault_id, genesis_root, amount_a, amount_b = 0}`, with `amount_a` the held amount. The record is insert only. The verifier admits exactly one debit and one creation record. +- **GenesisAccepted, escrow form** (§19.8). All of the following hold: + - `R0` recomputes, and `V0` holds exactly the initial state: generation zero, Active, no relationship leaves; + - the three policy slots hold `A_T`, and the carried bytes decode as `EscrowTerms` and re-derive `A_T`; + - `reserve_a` equals the funded amount and the debit, and `reserve_b = 0`; + - `v = vault_id(Go, DevIDo, pcreate)`, with `pcreate` the inserting position; + - the storage set is the network's pinned set; + - the owner's root at `p` is validated; + - `terms.token` passes its token policy for a transfer (§49). +- **Publication.** The terms are put as an object under `A_T`. The genesis preimage is indexed under `vault_genesis_locator(v)` (§28) and under `escrow_commitment_locator(Y) = H(DSM/escrow/commitment-locator/v1; Y)`, so a counterparty finds the vaults locked against `Y`. It is not indexed under `vault_token_locator` (Amendment S16), because an escrow vault is not a market. Discovery carries no authority: a candidate counts only when it is GenesisAccepted and its terms name `Y`. + +**Release** + +- **The branch.** `B°` gains a third branch: `Release{vault_id, parent_root, setup_ref, verdict_cell, outcome, amount, trader_core, dlv_core, closure}`, class `0x0065`. It has one leg, and `E` takes its single-leg form (§15). Its route digest preimage is `Release{vault_id, parent_root, setup_ref, verdict_cell, outcome, amount}`, class `0x0066`. The trader is the branch's recipient. A release is the recipient's own position, set up with the vault like any other (§16, Amendment S16), and its credit is the recipient's realized root (§19.6). No verdict enters `E` (§15): `B°` names the outcome and the cell, and the verdict is a fact resolution reads (below). +- **Closed write set** (§19.3). `T°` credits `terms.token` by `amount`, advances the trader's relationship with the vault, and debits nothing. `V°` takes `VaultState` from Active, with exactly `reserve_a = amount` and `reserve_b = 0`, to Retired, with both reserves zero and generation plus one, and makes the matching relationship advance. No other entry is permitted. +- **Static validity** (RouteValidation for a Release, §19.5). Each of the following must hold; a failure of any is Invalid: + - there is one leg; + - the vault's terms resolve to `EscrowTerms` by the slot rule; + - `verdict_cell = K_verdict(terms.external_commitment, τ(terms))`; + - `outcome` names a branch of the terms; + - `P`'s trader `(G, DevID)` is that branch's recipient; + - `amount = reserve_a` and `reserve_b = 0` at the parent; + - the write set above holds; + - per-token conservation holds; + - `terms.token` passes its token policy for a transfer. + + The static budget is at most 16 label comparisons. No signature is verified here; the verdict's signatures are verified where it occupies its cell. +- **Close and Swap need a market.** A Close needs the release policy `OWNER_LOCAL_FULL_CLOSE` (§19.5), and a Swap is priced by the market and fee policies. An escrow vault's slots hold `A_T`, which is neither. So a Close or a Swap against an escrow vault is Invalid, and so is a Release against a market vault. An escrow vault has no owner close: its owner gets the stake back only through a branch that names the owner as recipient, decided by that branch's signers. + +**Resolution** + +- **The facts** (§13): `VerdictHeld(K, o)` and `VerdictFinal(K, o)`, above. +- **Consumed route** (§23.2): for a Release leg, `ConsumedRoute` also requires `VerdictFinal(B°.verdict_cell, B°.outcome)`. +- **Impossibility** (§23.5): `RouteImpossible(P, E)` gains arm (v): `B°` is a Release, and `VerdictHeld(B°.verdict_cell, o)` for some `o ≠ B°.outcome`. + - Like arms (ii) to (iv), it needs no validation evidence: only the cell's raw read and the verdict's own bytes. + - It is permanent, because the cell's leader never holds another value. + - A release exercise that lost the verdict is skipped, and the vault's next attempt goes live for the branch that won. +- **The ladder** (§24). Rung 7 is Realized through `ConsumedRoute` as amended. Rung 8 (Void, both predicates Valid) also holds when `B°` is a Release whose verdict cell holds another outcome. A Void release moves nothing, and the vault stays Active at its parent. Until the cell's verdict is final on the release's outcome, or held on another, a release has no result (rung 9) and the SDK waits. +- **Linked vaults settle on one outcome.** Every vault bound to `K` settles only on the outcome the cell holds: a release naming another outcome is Void and its key is skipped, whichever vault it is in. The vaults are still independent DLVs with no shared parent (Explainer §60). Each is released by its own recipient's position, and one vault's release does not release the other. What the cell adds is that they cannot release on different outcomes. This refines Explainer §60, whose guards verify predicates bound to `Y`: the canonical verdict is one such predicate, a storage-finality fact keyed by `Y` and the agreed table. +- **Another trader's position** (Amendment S15). The public objects that resolve a Release position include the verdict at its cell, with its finality evidence. + +**Liveness, with no clock** + +- A stake is released only by a verdict. If no recognized verdict ever reaches the cell, the stake stays locked. That is a stated boundary (§5.4), not a failure. +- An application that wants a way out commits one as a branch: for example a cancel outcome decided by both parties together. It contends for the same cell as every other outcome, so a cancel and a result never both settle. +- A refund after a deadline needs an iteration budget (Explainer §59) and is not part of this amendment. +- A release written before the verdict holds its vault key until the cell holds a verdict, and is then Realized or skipped. Only a branch's recipient can write a release that is not Invalid. Any other release is Invalid, and its key is skipped once final (§23.5). So no third party holds an escrow vault's key past the verdict. + +**Routes** (§27). The app reaches escrow vaults only through these routes, in `SDK/handlers/escrow_routes.rs`: + +| Route | What it does | +|---|---| +| `escrow.party` | this device's genesis, device id and signing key, for naming in terms | +| `escrow.create` | derives `Y` from `X`, puts the terms and admits `EscrowVaultCreate`. Given a counterpart vault, it locks only once that vault is accepted, Active and bound to the same `K_verdict`. | +| `escrow.sign` | signs `m(o)` for a cell and indexes the signatures under `escrow_statement_locator(K, o)` | +| `escrow.adjudicate` | assembles a recognized verdict from the signatures under the locator and its own, and writes it to `K_verdict` by §8. It returns the verdict the cell holds, which may be another verdict that got there first. | +| `escrow.verdict` | reads `K_verdict` and returns the verdict it holds, or that none is held yet | +| `escrow.release` | builds a release only once the cell's verdict is final on the outcome of a branch that pays this device. It sets up with the vault if needed (Amendment S16), walks the head (§30), drafts the Release, and runs §31 stages 2 to 10. | +| `escrow.locked` | the vaults indexed under `escrow_commitment_locator(Y)`, each accepted and walked to its head | +| `escrow.vaults` | the escrow vaults this device created, each walked to its head | + +This amends §4 (SoFi also creates and releases escrow vaults), §11 (two indexes), §13 (the verdict facts), §14.1 and §14.2 (the escrow tags and classes `0x0063` to `0x0066`), §15, §19.1 (the slot rule and escrow status), §19.3 to §19.5 (the Release branch), §19.7 (an escrow vault has no close authority), §19.8 (the escrow form of GenesisAccepted), §23.2, §23.5 (arm (v)), §24 (rungs 7 and 8), §27 (the escrow routes), §28 (`EscrowVaultCreate`, variant 38 of `Operation`), §32 (a Close against an escrow vault is Invalid) and Amendment S15 (the verdict is among a Release position's public objects). + ## Part IV — Predicates and resolution diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index b1cb6d5a4..4f75cfd02 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2725,16 +2725,30 @@ Outside this round: the anchor firmware's signing call sites turn an error into **Open.** On BLE the terms ride beside the operation in the clear, as the prepare always has: BLE is a direct link between the two parties, and the ruling chose it. `TokenSDK`'s generic transfer and its token-creation fee transfer commit to terms that nothing carries, so a recipient could not open them. Neither is reached today: `TokenOperation::Transfer` is built nowhere outside `TokenSDK`, and the one `TokenOperation::Create` the SDK builds (dBTC registration) charges no fee. +### 6.72 No vault could hold value for anyone but a market: escrow vaults specified (`feat/escrow-vaults`, SoFi Amendment S21, 2026-10-05) + +**The finding.** An application needed two parties to lock equal stakes against one agreed match, with the application as referee deciding who takes both. Nothing in the backend can hold value under a condition other than a SoFi market: +- the economic tree has no encumbered leaf, and the legacy DLV claim operations are refused as value transitions (`DlvClaim { .. } | DlvInvalidate { .. } => UnsupportedValueTransition`, `dsm::economic::classifier`); +- the legacy DLV's `FulfillmentMechanism::MultiSignature` (`dsm::vault::limbo_vault`) counts the same key's signature as often as it appears, passes with a threshold of 0, and verifies over `signed_data` the claimant's proof supplies. It is not a basis for anything that holds value. + +**The ruling.** Owner, 2026-10-04: compose existing primitives, add only the generic one that is missing, and put no wager or battle logic in Core: "You need to make the generic escrow vaults, and then we just use that". 2026-10-05: one external commitment has one admissible verdict, made so by the protocol, not by the referee's bookkeeping (quoted in Amendment S21). + +**The specification.** SoFi §19.9 (Amendment S21): an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object. It releases its whole amount once, by a Release position of the branch's recipient, when the canonical verdict on its external commitment names that branch's outcome. The verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes. Every vault bound to the cell settles only on the outcome it holds: a release naming another is Void, and its key is skipped by `RouteImpossible` arm (v). An escrow vault has no market and no owner close. + +**Status.** Specification and rows only (MR-SOFI-0363 to MR-SOFI-0384, all Missing). The implementation follows on this branch after the owner reviews the amendment. + +**Open.** `FulfillmentMechanism::MultiSignature` stays in the legacy DLV as found. Escrow vaults neither use nor replace it; whether that code is reachable on a production path is a separate sweep. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 277 | 96 | 95 | 39 | 0 | 29 | 18 | -| SoFi (MR-SOFI) | 362 | 237 | 86 | 18 | 4 | 17 | 0 | +| SoFi (MR-SOFI) | 384 | 237 | 86 | 40 | 4 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 65 | 18 | 56 | 0 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **943** | **407** | **200** | **114** | **4** | **64** | **154** | +| **All** | **965** | **407** | **200** | **136** | **4** | **64** | **154** | ## 8 Per-requirement results @@ -3386,6 +3400,28 @@ Outside this round: the anchor firmware's signing call sites turn an error into | MR-SOFI-0360 | Met | `dsm::sofi::wire::objects::SignedSofiResolutionClaim`; `dsm::sofi::signature::verify_resolution_claim`; `dsm::sofi::signature::sign_resolution_claim`; `dsm::economic::register::read_root_cell` (body identity); `dsm_sdk::sdk::sofi_register` (producer); `dsm_sdk::sdk::sofi_relay::carry_pair`; `dsm::sofi::registration::RegistrationRead::standing_of`; `dsm::sofi::resolve::Verifier::peer_position` | `dsm::economic::claim_envelope::tests::a_conditional_cell_decodes_by_class`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_held_root_cell_carries_the_exact_bytes_that_hold_it`; `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own` | SoFi Amendment S20 (§6.65). `K_root(q)` holds the trader-signed `C_q` (`0x0062`); a bare `C_q` is refused by name, and a conditional claim is identified by its derived body. S20 extended 2026-10-01 (§6.65, §6.66 12j): registration matches the pair by `FulfillmentId(F)`; where `P` is in hand the body is compared with `derive(P, F)`. In the facts a difference loses `F` (`standing_of`); in a position's resolution it is Invalid (`peer_position`) (`51359a6b6`). Mutation: the body check skipped for a registered pair → the unit test red. The resolution check has no test of its own yet. | | MR-SOFI-0361 | Met | `dsm::sofi::wire::objects::TraderFulfillmentBody`; `dsm::sofi::registration::fulfillment_proves_the_device`; `dsm::sofi::registration::names_fulfillment_key`; `dsm_sdk::sdk::sofi_sdk::build_fulfillment` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell`; `dsm::sofi::registration::tests::only_a_fulfillment_of_this_trader_at_this_position_names_the_key`; `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published` | SoFi Amendment S20 (§6.65). The binding is decided from `F`'s bytes before `P` is looked up. Mutation: the check removed → that test red. S20 extended 2026-10-01 (§6.65, §6.66 12j): `names_fulfillment_key` decides from `F`'s bytes alone and reads no `P`, so which `F` holds `K_ful(q)` is the same at every time (`51359a6b6`). | | MR-SOFI-0362 | Met | `dsm::sofi::exercise::recognize_exercise`; `dsm::sofi::wire::objects::SofiExercise`; `dsm_sdk::sdk::sofi_exercise::build_exercise`; `dsm_sdk::sdk::sofi_relay::relay_exercise`; `dsm_sdk::sdk::sofi_flow::walk_for_attempt` | `dsm_sdk::handlers::node_e2e_tests::an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes`; `dsm_sdk::handlers::node_e2e_tests::a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair`; `dsm::sofi::exercise::tests::an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f`; `dsm::sofi::exercise::tests::an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing` | §6.66 12e. The exercise carries the trader's signed `C_q`, recognized as `K_root(q)` recognizes it with the body `derive(P, F)`; the next operation at a vault registers a withheld pair from it before passing the key (owner ruling, 2026-10-01). | +| MR-SOFI-0363 | Missing | — | — | Added by Amendment S21 (§6.72): the slot rule; `VaultTerms` does not exist yet, and terms resolve as a market only. | +| MR-SOFI-0364 | Missing | — | — | Added by Amendment S21 (§6.72): `EscrowTerms` does not exist yet. | +| MR-SOFI-0365 | Missing | — | — | Added by Amendment S21 (§6.72): the outcome table and `τ` do not exist yet. | +| MR-SOFI-0366 | Missing | — | — | Added by Amendment S21 (§6.72): the verdict cell key, seed and leader do not exist yet. | +| MR-SOFI-0367 | Missing | — | — | Added by Amendment S21 (§6.72): the escrow statement does not exist yet. | +| MR-SOFI-0368 | Missing | — | — | Added by Amendment S21 (§6.72): recognition at the verdict cell does not exist yet. | +| MR-SOFI-0369 | Missing | — | — | Added by Amendment S21 (§6.72): `VerdictHeld` and `VerdictFinal` do not exist yet. | +| MR-SOFI-0370 | Missing | — | — | Added by Amendment S21 (§6.72): the statement locator and gathered verdict objects do not exist yet. | +| MR-SOFI-0371 | Missing | — | — | Added by Amendment S21 (§6.72): operation tag 38 does not exist yet. | +| MR-SOFI-0372 | Missing | — | — | Added by Amendment S21 (§6.72): `creation_accepted` has no escrow form. | +| MR-SOFI-0373 | Missing | — | — | Added by Amendment S21 (§6.72): the commitment locator does not exist yet. | +| MR-SOFI-0374 | Missing | — | — | Added by Amendment S21 (§6.72): `SettlementBody` has no Release branch. | +| MR-SOFI-0375 | Missing | — | — | Added by Amendment S21 (§6.72): the Release write set does not exist yet. | +| MR-SOFI-0376 | Missing | — | — | Added by Amendment S21 (§6.72): `validate_release` does not exist yet. | +| MR-SOFI-0377 | Missing | — | — | Added by Amendment S21 (§6.72): `Policies::resolve` re-addresses each slot under its market, fee or release class, so terms at `A_T` would leave a Close or Swap waiting on a non-verifying object (`Missing::NonVerifyingObject`), not Invalid; the refusal by terms lands with `VaultTerms`, and no Release exists. | +| MR-SOFI-0378 | Missing | — | — | Added by Amendment S21 (§6.72): `consumed_route` has no verdict conjunct. | +| MR-SOFI-0379 | Missing | — | — | Added by Amendment S21 (§6.72): `route_impossible` has four arms. | +| MR-SOFI-0380 | Missing | — | — | Added by Amendment S21 (§6.72): `resolve_position` has no verdict fact. | +| MR-SOFI-0381 | Missing | — | — | Added by Amendment S21 (§6.72): no vault is bound to a verdict cell yet. | +| MR-SOFI-0382 | Missing | — | — | Added by Amendment S21 (§6.72): S15 resolution reads no verdict cell. | +| MR-SOFI-0383 | Missing | — | — | Added by Amendment S21 (§6.72): no escrow vault exists, so no stake is locked. | +| MR-SOFI-0384 | Missing | — | — | Added by Amendment S21 (§6.72): the escrow routes do not exist yet. | ### 8.3 dBTC native specification diff --git a/specs/requirements/MASTER_REQUIREMENTS.md b/specs/requirements/MASTER_REQUIREMENTS.md index 3b4d66a4b..53e975656 100644 --- a/specs/requirements/MASTER_REQUIREMENTS.md +++ b/specs/requirements/MASTER_REQUIREMENTS.md @@ -15,11 +15,11 @@ Every extraction in this round is taken against exactly these bytes: | File | `git hash-object` | Lines | |---|---|---| | `specs/DSM_High_Level_Explainer.md` | `871fb1a7f24cb8e7e29ba7152aabcc4f2819e397` | 4323 | -| `specs/SoFi_Settlement_Specification.md` | `77927942403122d917a3d3c040e17c38e099451f` | 2694 | +| `specs/SoFi_Settlement_Specification.md` | `9ed21d3999e8cf94b0ff525ea52b6f3f9892312d` | 2826 | | `specs/dBTC_Native_Specification.md` | `233a3e72a5b16a023af830f4c8ffaad4ba9391a8` | 2160 | | `specs/DSM_Storage_Node_Specification.md` | `415a9b9c67c7a2b4b6df78b0af85fb3bc7282ae8` | 636 | -Pins updated 2026-10-01 after SoFi Amendment S20 gained the owner's ruling on relaying a withheld pair (pre-audit 12e: the next operation at a vault registers it from the exercise's signed `C_q` before passing the key) and named §17.5 and §33 among what it amends; before that, the same day, after DSM Amendment A8's correction for credit sources (a source is held to account through its own segment) and the matching SoFi S15 SetupValid wording, and after DSM Amendment A10 and SoFi Amendment S20 (an occupant of a position cell proves its own authority), with S20 extended the same day (the position pair decided and matched from its own bytes); before that, the same day, after SoFi Amendments S16 (vaults found by their tokens; the setup inside the first operation through a vault; another trader's conditional parent resolved by a walk) and S19 (a route chains or splits) and S18 (ERA has two decimals); before that, 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. +Pins updated 2026-10-05 after SoFi Amendment S21 (escrow vaults: a vault released by the canonical verdict on an external commitment, §19.9). Before that, pins updated 2026-10-01 after SoFi Amendment S20 gained the owner's ruling on relaying a withheld pair (pre-audit 12e: the next operation at a vault registers it from the exercise's signed `C_q` before passing the key) and named §17.5 and §33 among what it amends; before that, the same day, after DSM Amendment A8's correction for credit sources (a source is held to account through its own segment) and the matching SoFi S15 SetupValid wording, and after DSM Amendment A10 and SoFi Amendment S20 (an occupant of a position cell proves its own authority), with S20 extended the same day (the position pair decided and matched from its own bytes); before that, the same day, after SoFi Amendments S16 (vaults found by their tokens; the setup inside the first operation through a vault; another trader's conditional parent resolved by a walk) and S19 (a route chains or splits) and S18 (ERA has two decimals); before that, 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. The DSM and SoFi specifications were amended on 2026-09-22 (marked "Amendment" in their text). The storage-node specification was added to the corpus on 2026-09-22, before any other extractor started. The owner accepted it in full the same day. Extract its items marked **Open** with Flags `ambiguous` and a Requirement text that says so, never as settled requirements. @@ -161,6 +161,7 @@ Each extraction also has a Findings table: - **Credit sources held to account (2026-10-01).** Security pre-audit item 3: under A8's one-hop rule a credit's source step was validated, but the source's earlier positions were authenticated as root claims only, so a source could register an invented root and pay out of it through a second wallet of its own to an honest receiver. Owner ruling (2026-10-01, quoted in A8): "Apply A8 transitively to credit sources", every transition in the source's segment verified back to the latest frontier the receiver trusts for that identity, a frontier trusted only after the complete segment to it passed, "admitted" or "signed" never proof of valid ancestry, budget exhaustion before a trusted frontier incomplete and never accepted, validated frontiers cached so later interactions validate only the suffix; succinct recursive validity proofs are the longer-term replacement. DSM Amendment A8 corrected (the One hop bullet replaced by Credit sources; the frontier and never-read bullets follow it) and SoFi S15's SetupValid wording aligned. MR-DSM-0273, 0274 and 0275 and MR-SOFI-0347 rewritten; §1 re-pinned. - **The relationship-key tag (2026-09-30).** Conformance finding (CONFORMANCE §6.14, MR-DSM-0115, MR-DSM-0249): the explainer derived a relationship's SMT key under `DSM/smt-key/v1`, while the code and its golden vector use `DSM/smt-key`. Owner ruling: `DSM/smt-key` is the canonical relationship-SMT domain tag for beta, and `/v1` in the specification was an error (DSM Amendment A9). The formula in §26 and the example in §16 are corrected; no key migrates. MR-DSM-0115 rewritten; §1 re-pinned. - **Vaults found by their tokens, setup inside the trade, and routes that split (2026-10-01).** Phone-rig findings: `sofi.findRoute` searched only the vaults the trader had set up with, so the rig's first trade went through only because the trader set up by hand; and the owner ruled that two vaults of one pair are no different from two of different pairs, so one order may fill through both. Owner decisions: a vault is indexed under each of its tokens and found there with no authority taken from the index, path search reads the two tokens' indexes, the first operation through a vault admits its setup first, `sofi.vaults` replaces `sofi.setup` among the eight routes, a walk resolves another trader's conditional parent through frontier-relative verification (SoFi Amendment S16); and a Swap route's hops chain or split (SoFi Amendment S19). MR-SOFI-0350–0359 added with source `amendment`; MR-SOFI-0255 updated; §1 re-pinned. +- **Escrow vaults (2026-10-05).** An application needed two parties to lock equal stakes against one agreed match, with the application as referee, and nothing could hold value under a condition other than a SoFi market (CONFORMANCE §6.72). Owner rulings (2026-10-04, 2026-10-05, quoted in SoFi Amendment S21): build the generic escrow vault with no wager logic in Core, and make one external commitment have one admissible verdict in the protocol itself. Owner decisions as specified: an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object; it releases its whole amount once to the recipient of the branch whose outcome the canonical verdict names; the verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes; linked vaults settle on that one outcome; there is no owner close and no market (SoFi Amendment S21, §19.9). MR-SOFI-0363–0384 added with source `amendment`; §1 re-pinned. - **Post-reconciliation amendment (2026-09-22): route-chain finality.** For finding GPT-4, finality was redefined as a route chain (storage spec §9, §12.6, §14, §22; DSM Amendment A6; SoFi Amendment S4). §1 pins the amended files. Canonical rows restating the old rule were rewritten, and rows for the new rules were added at the end of §8.1, §8.2 and §8.4 with source `amendment`. The extraction files in `extractions/` remain as extracted against the earlier hashes. ## 8 Canonical requirements @@ -175,7 +176,7 @@ Reconciled on 2026-09-22 from two extractions: `claude-chat` (798 rows) and `cha | DSM_Storage_Node_Specification.md | 128 | 122 | 6 | | **Total** | **859** | **652** | **207** | -Added afterwards by amendment (§7.1): DSM 8, SoFi 35, storage 30, for 932 canonical requirements in all. +Added afterwards by amendment (§7.1): DSM 8, SoFi 53, storage 30, for 954 canonical requirements in all. Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sources** are the extraction IDs merged into the row (`cc:` claude-chat, `gpt:` chatgpt); the first source locates the quote. **Flags** carry the findings in §8.7 that bear on the row. @@ -827,6 +828,28 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0360 | invariant | explicit | `K_root(q)` holds the trader-signed C_q (class 0x0062): the derived C_q, the signing key, the trader device's AttA and a signature under `DSM/sofi/resolution-claim-sign/v1`; it occupies the cell only when the signature verifies and the key with that AttA derives the trader's DevID, and a bare C_q occupies nothing. The pair is matched by the body's fulfillment_id; wherever P is in hand (an exercise's facts, a position's resolution) the body is compared with derive(P, F), and a difference loses F and makes the position Invalid. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | | MR-SOFI-0361 | invariant | explicit | F carries the trader device's AttA. Which F holds `K_ful(q)` is decided from F's bytes alone: it recognizes under its own key, its position is q, and its key with that AttA derives the trader's DevID; P is never read to decide it. | amendment: SoFi Amendment S20 (2026-10-01) | owner | none | | MR-SOFI-0362 | obligation | explicit | The exercise carries the trader's signed C_q, whose body is the C_q of the exercise's P and F under the same bound key; a relayer completes the position pair with the trader's signed C_q from the exercise or the cell and never authors C_q. Before advancing past a vault key held by an exercise whose pair is not yet registered, the next operation at the vault MUST register that pair with the C_q the exercise carries, exactly as signed, and then retry; an exercise whose C_q fails recognition counts as nothing. | amendment: SoFi Amendment S20 (2026-10-01; the relaying bullet is the owner's ruling of the same day) | owner | none | +| MR-SOFI-0363 | invariant | explicit | An escrow vault's `VaultStateLeaf` is unchanged byte for byte: `market_policy`, `fee_policy` and `release_policy` all hold `A_T`, the address of its `EscrowTerms` (class 0x0063); `reserve_a` is the held amount and `reserve_b` is 0; it is Active while `reserve_a > 0` and `reserve_b = 0`, and Retired (both zero) is terminal. Slots that include an `EscrowTerms` object other than as all three naming one are neither a market nor an escrow, and the genesis is refused. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0364 | invariant | explicit | `EscrowTerms` (class 0x0063, schema 1) holds the held token's policy commit, `Y = H(DSM/external/v1 ∥ X)`, and 1 to 16 branches strictly ascending by `outcome` (1 to 64 bytes), each with 1 to 4 signers strictly ascending by `u16be(alg) ∥ u32be(|key|) ∥ key` and a recipient (genesis, device id); its address is `immutable_addr(DSM/escrow/terms-object/v1, CCB bytes)`. A branch is decided by all of its signers: no duplicate signer, empty set or threshold is expressible, and labels are unique. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0365 | obligation | explicit | The outcome table `O` is the branches with their recipients removed, in branch order, and `τ = H(DSM/escrow/outcome-table/v1; u8(|O|) ∥ ⨁ entries)`, each entry `u32be(|o|) ∥ o ∥ u8(|signers|) ∥ ⨁ (u16be(alg) ∥ u32be(|key|) ∥ key)`. Escrow vaults with the same `Y` and the same table are linked and share one verdict cell; their recipients may differ. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0366 | obligation | explicit | `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)`. Its leader is `FisherYates(s_verdict, S)[0]` over the network's pinned set with `s_verdict = H(DSM/escrow/verdict-seed/v1; K_verdict)`; it is written by the procedure of §8 with route-chain finality, and anyone may write it. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0367 | invariant | explicit | A signer decides outcome `o` for a verdict cell by signing `m(o) = H(DSM/escrow/statement/v1; K_verdict ∥ u32be(|o|) ∥ o)`: the statement names the cell, so a signature decides nothing at another commitment or another table, and it names no vault, so one signature serves every vault bound to the cell. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0368 | invariant | explicit | The value that counts at `K_verdict` is the first object at its leader that is an `EscrowVerdict` (class 0x0064) recognized there from its own bytes alone: the cell key recomputes from its `Y` and table, its outcome is a label of the table, its signers are exactly the signers the table assigns to that outcome, and every signature verifies over `m(outcome)` under its key. Everything else at the cell counts as nothing, and no vault, member or reader's position enters the decision. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0369 | invariant | explicit | A verdict cell holds at most one outcome, ever. `VerdictHeld(K, o)` holds when its leader link is held by a recognized verdict on `o` in any state (leader-held, preserved or final); `VerdictFinal(K, o)` when that verdict is final, shown by a completion proof. A second verdict, even one validly signed by the same signers for another outcome, never becomes the cell's value. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0370 | obligation | explicit | Signatures for a branch with more than one signer may be gathered as `EscrowVerdict` objects put under `immutable_addr(DSM/escrow/verdict-object/v1, CCB bytes)` and indexed under `escrow_statement_locator(K, o) = H(DSM/escrow/statement-locator/v1; K ∥ u32be(|o|) ∥ o)`; a reader keeps only signatures that verify over `m(o)` under a signer the table assigns to `o`. The index carries no authority; only a recognized verdict at the cell decides anything. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0371 | transition | explicit | `EscrowVaultCreate`, variant 38 of `Operation`, carries the vault genesis preimage, the `VaultCreation` leaf and the exact `EscrowTerms` bytes, signed by the owner. The owner's transition debits the held amount of the terms' token once and inserts `VaultCreation{vault_id, genesis_root, amount_a, amount_b = 0}`, insert only, with `amount_a` the held amount; the verifier admits exactly one debit and one creation record. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0372 | evidence | explicit | An escrow vault's genesis is accepted only if `R0` recomputes and `V0` is the initial state (generation zero, Active, no relationship leaves); the three policy slots hold `A_T` and the carried bytes decode as `EscrowTerms` and re-derive `A_T`; `reserve_a` equals the funded amount and the debit and `reserve_b = 0`; `v = vault_id(Go, DevIDo, pcreate)`; the storage set is the network's pinned set; the owner's root at `p` is validated; and the terms' token passes its token policy for a transfer. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0373 | obligation | explicit | The escrow terms are put as an object under `A_T`, and the genesis preimage is indexed under `vault_genesis_locator(v)` and `escrow_commitment_locator(Y) = H(DSM/escrow/commitment-locator/v1; Y)`, never under `vault_token_locator`. Discovery carries no authority: a candidate counts only when its genesis is accepted and its terms name `Y`. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0374 | transition | explicit | `B°` has a Release branch `{vault_id, parent_root, setup_ref, verdict_cell, outcome, amount, trader_core, dlv_core, closure}` (class 0x0065) with one leg and `E` in its single-leg form; its route digest preimage is `Release{vault_id, parent_root, setup_ref, verdict_cell, outcome, amount}` (class 0x0066). The trader is the branch's recipient, the release is the recipient's own position set up with the vault, its credit is the recipient's realized root, and no verdict enters `E`. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0375 | invariant | explicit | A Release's closed write set: `T°` credits the terms' token by `amount`, advances the trader's relationship with the vault and debits nothing; `V°` takes the vault state from Active with exactly `reserve_a = amount` and `reserve_b = 0` to Retired with both reserves zero and generation plus one, with the matching relationship advance. No other entry is permitted. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0376 | prohibition | explicit | A Release is Invalid unless it has one leg; the vault's terms resolve to `EscrowTerms` by the slot rule; `verdict_cell = K_verdict(Y, τ)` of those terms; `outcome` names a branch; `P`'s trader is that branch's recipient; `amount = reserve_a` and `reserve_b = 0` at the parent; the write set holds; per-token conservation holds; and the token passes its policy for a transfer. Its static budget is at most 16 label comparisons, and no signature is verified there. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0377 | prohibition | explicit | A Close or a Swap against an escrow vault is Invalid, and so is a Release against a market vault. An escrow vault has no owner close: its owner gets the stake back only through a branch naming the owner as recipient, decided by that branch's signers. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0378 | invariant | explicit | For a Release leg, `ConsumedRoute` also requires `VerdictFinal(B°.verdict_cell, B°.outcome)`. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0379 | obligation | explicit | `RouteImpossible(P, E)` holds by arm (v) when `B°` is a Release and `VerdictHeld(B°.verdict_cell, o)` for some `o ≠ B°.outcome`. The arm needs no validation evidence (only the cell's raw read and the verdict's own bytes), is permanent, and skips a release exercise that lost the verdict so the vault's next attempt goes live. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0380 | invariant | explicit | A Release whose verdict cell holds another outcome resolves Void when both predicates are Valid (rung 8): it moves nothing and the vault stays Active at its parent. Until the cell's verdict is final on its outcome or held on another, a Release has no result and the SDK waits. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0381 | invariant | explicit | Every escrow vault bound to one verdict cell settles only on the outcome that cell holds: a release naming another outcome is Void and its key skipped, whichever vault it is in. The linked vaults remain independent DLVs with no shared parent, each released by its own recipient's position; one vault's release does not release the other. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0382 | obligation | explicit | The public objects that resolve another trader's Release position (Amendment S15) include the verdict at its verdict cell, with its finality evidence. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0383 | liveness-boundary | explicit | An escrow stake is released only by a verdict: if no recognized verdict reaches the cell, the stake stays locked. A way out is a committed branch (for example a cancel decided by both parties), which contends for the same cell as every other outcome. A refund after a deadline needs an iteration budget and is not part of Amendment S21. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0384 | obligation | explicit | The app reaches escrow vaults only through `escrow.party`, `escrow.create`, `escrow.sign`, `escrow.adjudicate`, `escrow.verdict`, `escrow.release`, `escrow.locked` and `escrow.vaults` (`SDK/handlers/escrow_routes.rs`). `escrow.create` locks against a counterpart vault only once that vault is accepted, Active and bound to the same `K_verdict`; `escrow.adjudicate` writes a recognized verdict to the cell and returns the verdict the cell holds; `escrow.release` builds a release only once the cell's verdict is final on the outcome of a branch that pays the device. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | ### 8.3 dBTC native specification From 51c363b16ecadd922e21e78e848a38bd5b0dbe54 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:43:13 -0400 Subject: [PATCH 02/11] docs(requirements): number the escrow finding 6.73, after A11's 6.72 on #1112 --- specs/requirements/CONFORMANCE_GAPS.md | 46 +++++++++++------------ specs/requirements/MASTER_REQUIREMENTS.md | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 4f75cfd02..d9b1b7b97 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2725,7 +2725,7 @@ Outside this round: the anchor firmware's signing call sites turn an error into **Open.** On BLE the terms ride beside the operation in the clear, as the prepare always has: BLE is a direct link between the two parties, and the ruling chose it. `TokenSDK`'s generic transfer and its token-creation fee transfer commit to terms that nothing carries, so a recipient could not open them. Neither is reached today: `TokenOperation::Transfer` is built nowhere outside `TokenSDK`, and the one `TokenOperation::Create` the SDK builds (dBTC registration) charges no fee. -### 6.72 No vault could hold value for anyone but a market: escrow vaults specified (`feat/escrow-vaults`, SoFi Amendment S21, 2026-10-05) +### 6.73 No vault could hold value for anyone but a market: escrow vaults specified (`feat/escrow-vaults`, SoFi Amendment S21, 2026-10-05) **The finding.** An application needed two parties to lock equal stakes against one agreed match, with the application as referee deciding who takes both. Nothing in the backend can hold value under a condition other than a SoFi market: - the economic tree has no encumbered leaf, and the legacy DLV claim operations are refused as value transitions (`DlvClaim { .. } | DlvInvalidate { .. } => UnsupportedValueTransition`, `dsm::economic::classifier`); @@ -3400,28 +3400,28 @@ Outside this round: the anchor firmware's signing call sites turn an error into | MR-SOFI-0360 | Met | `dsm::sofi::wire::objects::SignedSofiResolutionClaim`; `dsm::sofi::signature::verify_resolution_claim`; `dsm::sofi::signature::sign_resolution_claim`; `dsm::economic::register::read_root_cell` (body identity); `dsm_sdk::sdk::sofi_register` (producer); `dsm_sdk::sdk::sofi_relay::carry_pair`; `dsm::sofi::registration::RegistrationRead::standing_of`; `dsm::sofi::resolve::Verifier::peer_position` | `dsm::economic::claim_envelope::tests::a_conditional_cell_decodes_by_class`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_held_root_cell_carries_the_exact_bytes_that_hold_it`; `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own` | SoFi Amendment S20 (§6.65). `K_root(q)` holds the trader-signed `C_q` (`0x0062`); a bare `C_q` is refused by name, and a conditional claim is identified by its derived body. S20 extended 2026-10-01 (§6.65, §6.66 12j): registration matches the pair by `FulfillmentId(F)`; where `P` is in hand the body is compared with `derive(P, F)`. In the facts a difference loses `F` (`standing_of`); in a position's resolution it is Invalid (`peer_position`) (`51359a6b6`). Mutation: the body check skipped for a registered pair → the unit test red. The resolution check has no test of its own yet. | | MR-SOFI-0361 | Met | `dsm::sofi::wire::objects::TraderFulfillmentBody`; `dsm::sofi::registration::fulfillment_proves_the_device`; `dsm::sofi::registration::names_fulfillment_key`; `dsm_sdk::sdk::sofi_sdk::build_fulfillment` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell`; `dsm::sofi::registration::tests::only_a_fulfillment_of_this_trader_at_this_position_names_the_key`; `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published` | SoFi Amendment S20 (§6.65). The binding is decided from `F`'s bytes before `P` is looked up. Mutation: the check removed → that test red. S20 extended 2026-10-01 (§6.65, §6.66 12j): `names_fulfillment_key` decides from `F`'s bytes alone and reads no `P`, so which `F` holds `K_ful(q)` is the same at every time (`51359a6b6`). | | MR-SOFI-0362 | Met | `dsm::sofi::exercise::recognize_exercise`; `dsm::sofi::wire::objects::SofiExercise`; `dsm_sdk::sdk::sofi_exercise::build_exercise`; `dsm_sdk::sdk::sofi_relay::relay_exercise`; `dsm_sdk::sdk::sofi_flow::walk_for_attempt` | `dsm_sdk::handlers::node_e2e_tests::an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes`; `dsm_sdk::handlers::node_e2e_tests::a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair`; `dsm::sofi::exercise::tests::an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f`; `dsm::sofi::exercise::tests::an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing` | §6.66 12e. The exercise carries the trader's signed `C_q`, recognized as `K_root(q)` recognizes it with the body `derive(P, F)`; the next operation at a vault registers a withheld pair from it before passing the key (owner ruling, 2026-10-01). | -| MR-SOFI-0363 | Missing | — | — | Added by Amendment S21 (§6.72): the slot rule; `VaultTerms` does not exist yet, and terms resolve as a market only. | -| MR-SOFI-0364 | Missing | — | — | Added by Amendment S21 (§6.72): `EscrowTerms` does not exist yet. | -| MR-SOFI-0365 | Missing | — | — | Added by Amendment S21 (§6.72): the outcome table and `τ` do not exist yet. | -| MR-SOFI-0366 | Missing | — | — | Added by Amendment S21 (§6.72): the verdict cell key, seed and leader do not exist yet. | -| MR-SOFI-0367 | Missing | — | — | Added by Amendment S21 (§6.72): the escrow statement does not exist yet. | -| MR-SOFI-0368 | Missing | — | — | Added by Amendment S21 (§6.72): recognition at the verdict cell does not exist yet. | -| MR-SOFI-0369 | Missing | — | — | Added by Amendment S21 (§6.72): `VerdictHeld` and `VerdictFinal` do not exist yet. | -| MR-SOFI-0370 | Missing | — | — | Added by Amendment S21 (§6.72): the statement locator and gathered verdict objects do not exist yet. | -| MR-SOFI-0371 | Missing | — | — | Added by Amendment S21 (§6.72): operation tag 38 does not exist yet. | -| MR-SOFI-0372 | Missing | — | — | Added by Amendment S21 (§6.72): `creation_accepted` has no escrow form. | -| MR-SOFI-0373 | Missing | — | — | Added by Amendment S21 (§6.72): the commitment locator does not exist yet. | -| MR-SOFI-0374 | Missing | — | — | Added by Amendment S21 (§6.72): `SettlementBody` has no Release branch. | -| MR-SOFI-0375 | Missing | — | — | Added by Amendment S21 (§6.72): the Release write set does not exist yet. | -| MR-SOFI-0376 | Missing | — | — | Added by Amendment S21 (§6.72): `validate_release` does not exist yet. | -| MR-SOFI-0377 | Missing | — | — | Added by Amendment S21 (§6.72): `Policies::resolve` re-addresses each slot under its market, fee or release class, so terms at `A_T` would leave a Close or Swap waiting on a non-verifying object (`Missing::NonVerifyingObject`), not Invalid; the refusal by terms lands with `VaultTerms`, and no Release exists. | -| MR-SOFI-0378 | Missing | — | — | Added by Amendment S21 (§6.72): `consumed_route` has no verdict conjunct. | -| MR-SOFI-0379 | Missing | — | — | Added by Amendment S21 (§6.72): `route_impossible` has four arms. | -| MR-SOFI-0380 | Missing | — | — | Added by Amendment S21 (§6.72): `resolve_position` has no verdict fact. | -| MR-SOFI-0381 | Missing | — | — | Added by Amendment S21 (§6.72): no vault is bound to a verdict cell yet. | -| MR-SOFI-0382 | Missing | — | — | Added by Amendment S21 (§6.72): S15 resolution reads no verdict cell. | -| MR-SOFI-0383 | Missing | — | — | Added by Amendment S21 (§6.72): no escrow vault exists, so no stake is locked. | -| MR-SOFI-0384 | Missing | — | — | Added by Amendment S21 (§6.72): the escrow routes do not exist yet. | +| MR-SOFI-0363 | Missing | — | — | Added by Amendment S21 (§6.73): the slot rule; `VaultTerms` does not exist yet, and terms resolve as a market only. | +| MR-SOFI-0364 | Missing | — | — | Added by Amendment S21 (§6.73): `EscrowTerms` does not exist yet. | +| MR-SOFI-0365 | Missing | — | — | Added by Amendment S21 (§6.73): the outcome table and `τ` do not exist yet. | +| MR-SOFI-0366 | Missing | — | — | Added by Amendment S21 (§6.73): the verdict cell key, seed and leader do not exist yet. | +| MR-SOFI-0367 | Missing | — | — | Added by Amendment S21 (§6.73): the escrow statement does not exist yet. | +| MR-SOFI-0368 | Missing | — | — | Added by Amendment S21 (§6.73): recognition at the verdict cell does not exist yet. | +| MR-SOFI-0369 | Missing | — | — | Added by Amendment S21 (§6.73): `VerdictHeld` and `VerdictFinal` do not exist yet. | +| MR-SOFI-0370 | Missing | — | — | Added by Amendment S21 (§6.73): the statement locator and gathered verdict objects do not exist yet. | +| MR-SOFI-0371 | Missing | — | — | Added by Amendment S21 (§6.73): operation tag 38 does not exist yet. | +| MR-SOFI-0372 | Missing | — | — | Added by Amendment S21 (§6.73): `creation_accepted` has no escrow form. | +| MR-SOFI-0373 | Missing | — | — | Added by Amendment S21 (§6.73): the commitment locator does not exist yet. | +| MR-SOFI-0374 | Missing | — | — | Added by Amendment S21 (§6.73): `SettlementBody` has no Release branch. | +| MR-SOFI-0375 | Missing | — | — | Added by Amendment S21 (§6.73): the Release write set does not exist yet. | +| MR-SOFI-0376 | Missing | — | — | Added by Amendment S21 (§6.73): `validate_release` does not exist yet. | +| MR-SOFI-0377 | Missing | — | — | Added by Amendment S21 (§6.73): `Policies::resolve` re-addresses each slot under its market, fee or release class, so terms at `A_T` would leave a Close or Swap waiting on a non-verifying object (`Missing::NonVerifyingObject`), not Invalid; the refusal by terms lands with `VaultTerms`, and no Release exists. | +| MR-SOFI-0378 | Missing | — | — | Added by Amendment S21 (§6.73): `consumed_route` has no verdict conjunct. | +| MR-SOFI-0379 | Missing | — | — | Added by Amendment S21 (§6.73): `route_impossible` has four arms. | +| MR-SOFI-0380 | Missing | — | — | Added by Amendment S21 (§6.73): `resolve_position` has no verdict fact. | +| MR-SOFI-0381 | Missing | — | — | Added by Amendment S21 (§6.73): no vault is bound to a verdict cell yet. | +| MR-SOFI-0382 | Missing | — | — | Added by Amendment S21 (§6.73): S15 resolution reads no verdict cell. | +| MR-SOFI-0383 | Missing | — | — | Added by Amendment S21 (§6.73): no escrow vault exists, so no stake is locked. | +| MR-SOFI-0384 | Missing | — | — | Added by Amendment S21 (§6.73): the escrow routes do not exist yet. | ### 8.3 dBTC native specification diff --git a/specs/requirements/MASTER_REQUIREMENTS.md b/specs/requirements/MASTER_REQUIREMENTS.md index 53e975656..568d9f7e4 100644 --- a/specs/requirements/MASTER_REQUIREMENTS.md +++ b/specs/requirements/MASTER_REQUIREMENTS.md @@ -161,7 +161,7 @@ Each extraction also has a Findings table: - **Credit sources held to account (2026-10-01).** Security pre-audit item 3: under A8's one-hop rule a credit's source step was validated, but the source's earlier positions were authenticated as root claims only, so a source could register an invented root and pay out of it through a second wallet of its own to an honest receiver. Owner ruling (2026-10-01, quoted in A8): "Apply A8 transitively to credit sources", every transition in the source's segment verified back to the latest frontier the receiver trusts for that identity, a frontier trusted only after the complete segment to it passed, "admitted" or "signed" never proof of valid ancestry, budget exhaustion before a trusted frontier incomplete and never accepted, validated frontiers cached so later interactions validate only the suffix; succinct recursive validity proofs are the longer-term replacement. DSM Amendment A8 corrected (the One hop bullet replaced by Credit sources; the frontier and never-read bullets follow it) and SoFi S15's SetupValid wording aligned. MR-DSM-0273, 0274 and 0275 and MR-SOFI-0347 rewritten; §1 re-pinned. - **The relationship-key tag (2026-09-30).** Conformance finding (CONFORMANCE §6.14, MR-DSM-0115, MR-DSM-0249): the explainer derived a relationship's SMT key under `DSM/smt-key/v1`, while the code and its golden vector use `DSM/smt-key`. Owner ruling: `DSM/smt-key` is the canonical relationship-SMT domain tag for beta, and `/v1` in the specification was an error (DSM Amendment A9). The formula in §26 and the example in §16 are corrected; no key migrates. MR-DSM-0115 rewritten; §1 re-pinned. - **Vaults found by their tokens, setup inside the trade, and routes that split (2026-10-01).** Phone-rig findings: `sofi.findRoute` searched only the vaults the trader had set up with, so the rig's first trade went through only because the trader set up by hand; and the owner ruled that two vaults of one pair are no different from two of different pairs, so one order may fill through both. Owner decisions: a vault is indexed under each of its tokens and found there with no authority taken from the index, path search reads the two tokens' indexes, the first operation through a vault admits its setup first, `sofi.vaults` replaces `sofi.setup` among the eight routes, a walk resolves another trader's conditional parent through frontier-relative verification (SoFi Amendment S16); and a Swap route's hops chain or split (SoFi Amendment S19). MR-SOFI-0350–0359 added with source `amendment`; MR-SOFI-0255 updated; §1 re-pinned. -- **Escrow vaults (2026-10-05).** An application needed two parties to lock equal stakes against one agreed match, with the application as referee, and nothing could hold value under a condition other than a SoFi market (CONFORMANCE §6.72). Owner rulings (2026-10-04, 2026-10-05, quoted in SoFi Amendment S21): build the generic escrow vault with no wager logic in Core, and make one external commitment have one admissible verdict in the protocol itself. Owner decisions as specified: an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object; it releases its whole amount once to the recipient of the branch whose outcome the canonical verdict names; the verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes; linked vaults settle on that one outcome; there is no owner close and no market (SoFi Amendment S21, §19.9). MR-SOFI-0363–0384 added with source `amendment`; §1 re-pinned. +- **Escrow vaults (2026-10-05).** An application needed two parties to lock equal stakes against one agreed match, with the application as referee, and nothing could hold value under a condition other than a SoFi market (CONFORMANCE §6.73). Owner rulings (2026-10-04, 2026-10-05, quoted in SoFi Amendment S21): build the generic escrow vault with no wager logic in Core, and make one external commitment have one admissible verdict in the protocol itself. Owner decisions as specified: an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object; it releases its whole amount once to the recipient of the branch whose outcome the canonical verdict names; the verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes; linked vaults settle on that one outcome; there is no owner close and no market (SoFi Amendment S21, §19.9). MR-SOFI-0363–0384 added with source `amendment`; §1 re-pinned. - **Post-reconciliation amendment (2026-09-22): route-chain finality.** For finding GPT-4, finality was redefined as a route chain (storage spec §9, §12.6, §14, §22; DSM Amendment A6; SoFi Amendment S4). §1 pins the amended files. Canonical rows restating the old rule were rewritten, and rows for the new rules were added at the end of §8.1, §8.2 and §8.4 with source `amendment`. The extraction files in `extractions/` remain as extracted against the earlier hashes. ## 8 Canonical requirements From 24bca67013d2388c9401e25a014fe8741ec6c77a Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:51:56 -0400 Subject: [PATCH 03/11] docs(specs): S21 links escrow vaults only by the cell their terms derive Review of S21 asked that linking be an explicit invariant, so that two parties cannot believe their vaults share a verdict when they do not. Section 19.9 now states it: - the outcome table has exactly one encoding, so the same outcomes with the same signer sets give the same tau, and the table is the verdict authority (there is no other); - two vaults are linked exactly when they name the same Y and byte-identical tables, and then they share K_verdict; a vault that differs in any byte is bound to another cell and is not linked; - a link is derived from each vault's accepted terms, never asserted by a match id, an index entry or an application, and is checked by whoever relies on it: escrow.create against a counterpart, and any reader waiting on both stakes; - discovery is by cell, so escrow_commitment_locator(Y) becomes escrow_cell_locator(K_verdict), and a vault bound to another cell is never found among the linked ones; - Core accepts each genesis on its own terms and compares no two vaults; the section says why, and what protects the party that locks first. MR-SOFI-0365, 0373 and 0384 rewritten; MR-SOFI-0385 and 0386 added, Missing; section 1 re-pinned and the totals regenerated. --- specs/SoFi_Settlement_Specification.md | 21 ++++++++++++++++----- specs/requirements/CONFORMANCE_GAPS.md | 14 ++++++++------ specs/requirements/MASTER_REQUIREMENTS.md | 14 ++++++++------ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/specs/SoFi_Settlement_Specification.md b/specs/SoFi_Settlement_Specification.md index 9ed21d399..c30f6585c 100644 --- a/specs/SoFi_Settlement_Specification.md +++ b/specs/SoFi_Settlement_Specification.md @@ -672,7 +672,7 @@ is Unavailable, never Invalid. | `preimage_locator(E)` | P(E) | | `vault_genesis_locator(v)` | the vault genesis preimage | | `vault_token_locator(t)` | the genesis preimage of each vault whose market pairs token `t` (Amendment S16) | -| `escrow_commitment_locator(Y)` | the genesis preimage of each escrow vault whose terms name `Y` (Amendment S21) | +| `escrow_cell_locator(K)` | the genesis preimage of each escrow vault whose terms derive verdict cell `K` (Amendment S21) | | `escrow_statement_locator(K, o)` | gathered signatures deciding outcome `o` at verdict cell `K` (Amendment S21) | | ρ | the setup body | | PolicyFulfillmentIdj | Gj | @@ -791,7 +791,7 @@ TAG_DSM_ESCROW_VERDICT_CELL DSM/escrow/verdict-cell/v1 TAG_DSM_ESCROW_VERDICT_SEED DSM/escrow/verdict-seed/v1 s_verdict, the seed of the verdict cell TAG_DSM_ESCROW_STATEMENT DSM/escrow/statement/v1 m(o) TAG_DSM_ESCROW_VERDICT_OBJECT DSM/escrow/verdict-object/v1 address of a gathered EscrowVerdict -TAG_DSM_ESCROW_COMMITMENT_LOCATOR DSM/escrow/commitment-locator/v1 locator of the escrow vaults bound to Y +TAG_DSM_ESCROW_CELL_LOCATOR DSM/escrow/cell-locator/v1 locator of the escrow vaults bound to K_verdict TAG_DSM_ESCROW_STATEMENT_LOCATOR DSM/escrow/statement-locator/v1 locator of gathered signatures for (K_verdict, o) @@ -1390,7 +1390,7 @@ An escrow vault is Explainer §59's escrow: a DLV with precommitted branches (§ - A branch is decided by all of its signers. No duplicate signer, no empty set and no threshold can be expressed. Labels are unique, so a verdict names at most one branch. - The amount is always the whole held amount. Nothing is chosen at release. -- **The outcome table** `O` is the branches with their recipients removed: the list of `(outcome, signers)` in branch order. Its digest is `τ = H(DSM/escrow/outcome-table/v1; u8(|O|) ∥ ⨁ entries)`, each entry `u32be(|o|) ∥ o ∥ u8(|signers|) ∥ ⨁ (u16be(alg) ∥ u32be(|key|) ∥ key)`. Two vaults with the same `Y` and the same table are linked: they share one verdict cell. Their recipients may differ. +- **The outcome table** `O` is the branches with their recipients removed: the list of `(outcome, signers)` in branch order. Its digest is `τ = H(DSM/escrow/outcome-table/v1; u8(|O|) ∥ ⨁ entries)`, each entry `u32be(|o|) ∥ o ∥ u8(|signers|) ∥ ⨁ (u16be(alg) ∥ u32be(|key|) ∥ key)`. The table is the verdict authority: each outcome's signer set. There is no other authority object. Which vaults share a verdict cell is fixed under "Linked vaults", below. **The verdict cell: one admissible verdict** @@ -1409,6 +1409,17 @@ An escrow vault is Explainer §59's escrow: a DLV with precommitted branches (§ - **The leader.** The parties agree on `Y` and `τ`. A party that proposes `X` can influence which member leads by its choice of `X`. A member never equivocates (§5.1), so that touches liveness only (§5.3, condition 2), never which verdict is canonical. - **Gathering signatures.** A branch with more than one signer needs each signer's signature. A signer MAY put an `EscrowVerdict` holding the signatures it has as an object, under `immutable_addr(DSM/escrow/verdict-object/v1, CCB bytes)`, and index it under `escrow_statement_locator(K, o) = H(DSM/escrow/statement-locator/v1; K ∥ u32be(|o|) ∥ o)`. A reader keeps each signature that verifies over `m(o)` under a signer the table assigns to `o`, and nothing else. The index carries no authority (§11). Only a recognized verdict at the cell decides anything. +**Linked vaults: same commitment, same table, same cell** + +- **One table, one encoding.** The table has exactly one encoding: branches strictly ascending by outcome, each signer set strictly ascending, and every length prefixed. Bytes in any other order do not decode. So parties that agree on the same outcomes, and on the same signer set for each outcome, derive byte-identical tables and the same `τ`. +- **The rule.** Two escrow vaults are linked exactly when their terms name the same `Y` and byte-identical tables. Since the table is the verdict authority, these are also the same authority. Linked vaults have the same `K_verdict`. Conversely, a vault whose `Y` or table differs in any byte is bound to another cell. It is not linked, and a verdict at its cell decides nothing for the others. +- **Linking is derived, never asserted.** Two vaults are linked only when the `K_verdict` derived from each one's accepted terms is the same. A match id, an index entry or an application's word never links them. +- **Checked by whoever relies on it.** + - A party that locks against a counterpart vault creates its own vault only when the counterpart is GenesisAccepted, Active, and derives the same `K_verdict` as the terms it is about to commit (`escrow.create`, below). + - A reader that relies on two vaults being linked, such as an application waiting for both stakes before it starts, checks the same derivation on both. + - Discovery is by cell (`escrow_cell_locator(K)`, under Publication), so a vault bound to another cell is never found among the linked ones. +- **Each genesis stands on its own terms.** Core accepts a vault's genesis without comparing it with any other vault. A creation that named its counterpart would make one vault's acceptance depend on another vault's evidence, and it would protect only the party that can already check before it locks. The party that locks first is protected by what it committed: no verdict at its cell can pay any branch other than its own table's, decided by any signers other than its own table's. + **Creation** - `EscrowVaultCreate`, variant 38 of `Operation`, carries the vault genesis preimage, the `VaultCreation` leaf and the exact `EscrowTerms` bytes, signed by the owner, as `SofiVaultCreate` carries its market policy (§28). The owner's transition debits the held amount of `terms.token` once and inserts `VaultCreation{vault_id, genesis_root, amount_a, amount_b = 0}`, with `amount_a` the held amount. The record is insert only. The verifier admits exactly one debit and one creation record. @@ -1420,7 +1431,7 @@ An escrow vault is Explainer §59's escrow: a DLV with precommitted branches (§ - the storage set is the network's pinned set; - the owner's root at `p` is validated; - `terms.token` passes its token policy for a transfer (§49). -- **Publication.** The terms are put as an object under `A_T`. The genesis preimage is indexed under `vault_genesis_locator(v)` (§28) and under `escrow_commitment_locator(Y) = H(DSM/escrow/commitment-locator/v1; Y)`, so a counterparty finds the vaults locked against `Y`. It is not indexed under `vault_token_locator` (Amendment S16), because an escrow vault is not a market. Discovery carries no authority: a candidate counts only when it is GenesisAccepted and its terms name `Y`. +- **Publication.** The terms are put as an object under `A_T`. The genesis preimage is indexed under `vault_genesis_locator(v)` (§28) and under `escrow_cell_locator(K_verdict) = H(DSM/escrow/cell-locator/v1; K_verdict)`, so a counterparty finds exactly the vaults bound to its own cell. It is not indexed under `vault_token_locator` (Amendment S16), because an escrow vault is not a market. Discovery carries no authority: a candidate counts only when it is GenesisAccepted and its terms derive that `K_verdict`. **Release** @@ -1469,7 +1480,7 @@ An escrow vault is Explainer §59's escrow: a DLV with precommitted branches (§ | `escrow.adjudicate` | assembles a recognized verdict from the signatures under the locator and its own, and writes it to `K_verdict` by §8. It returns the verdict the cell holds, which may be another verdict that got there first. | | `escrow.verdict` | reads `K_verdict` and returns the verdict it holds, or that none is held yet | | `escrow.release` | builds a release only once the cell's verdict is final on the outcome of a branch that pays this device. It sets up with the vault if needed (Amendment S16), walks the head (§30), drafts the Release, and runs §31 stages 2 to 10. | -| `escrow.locked` | the vaults indexed under `escrow_commitment_locator(Y)`, each accepted and walked to its head | +| `escrow.locked` | the vaults bound to a verdict cell, read from `escrow_cell_locator(K)`, each accepted, checked to derive `K`, and walked to its head | | `escrow.vaults` | the escrow vaults this device created, each walked to its head | This amends §4 (SoFi also creates and releases escrow vaults), §11 (two indexes), §13 (the verdict facts), §14.1 and §14.2 (the escrow tags and classes `0x0063` to `0x0066`), §15, §19.1 (the slot rule and escrow status), §19.3 to §19.5 (the Release branch), §19.7 (an escrow vault has no close authority), §19.8 (the escrow form of GenesisAccepted), §23.2, §23.5 (arm (v)), §24 (rungs 7 and 8), §27 (the escrow routes), §28 (`EscrowVaultCreate`, variant 38 of `Operation`), §32 (a Close against an escrow vault is Invalid) and Amendment S15 (the verdict is among a Release position's public objects). diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index d9b1b7b97..18cce09f3 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2733,9 +2733,9 @@ Outside this round: the anchor firmware's signing call sites turn an error into **The ruling.** Owner, 2026-10-04: compose existing primitives, add only the generic one that is missing, and put no wager or battle logic in Core: "You need to make the generic escrow vaults, and then we just use that". 2026-10-05: one external commitment has one admissible verdict, made so by the protocol, not by the referee's bookkeeping (quoted in Amendment S21). -**The specification.** SoFi §19.9 (Amendment S21): an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object. It releases its whole amount once, by a Release position of the branch's recipient, when the canonical verdict on its external commitment names that branch's outcome. The verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes. Every vault bound to the cell settles only on the outcome it holds: a release naming another is Void, and its key is skipped by `RouteImpossible` arm (v). An escrow vault has no market and no owner close. +**The specification.** SoFi §19.9 (Amendment S21): an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object. It releases its whole amount once, by a Release position of the branch's recipient, when the canonical verdict on its external commitment names that branch's outcome. The verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes. Every vault bound to the cell settles only on the outcome it holds: a release naming another is Void, and its key is skipped by `RouteImpossible` arm (v). An escrow vault has no market and no owner close. Vaults are linked exactly when they name the same `Y` and byte-identical outcome tables (the table is the authority). The link is derived from each vault's accepted terms and checked by whoever relies on it, and discovery is by cell, so a vault bound to another cell is never found among the linked ones. -**Status.** Specification and rows only (MR-SOFI-0363 to MR-SOFI-0384, all Missing). The implementation follows on this branch after the owner reviews the amendment. +**Status.** Specification and rows only (MR-SOFI-0363 to MR-SOFI-0386, all Missing). The implementation follows on this branch after the owner reviews the amendment. **Open.** `FulfillmentMechanism::MultiSignature` stays in the legacy DLV as found. Escrow vaults neither use nor replace it; whether that code is reachable on a production path is a separate sweep. @@ -2744,11 +2744,11 @@ Outside this round: the anchor firmware's signing call sites turn an error into | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 277 | 96 | 95 | 39 | 0 | 29 | 18 | -| SoFi (MR-SOFI) | 384 | 237 | 86 | 40 | 4 | 17 | 0 | +| SoFi (MR-SOFI) | 386 | 237 | 86 | 42 | 4 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 65 | 18 | 56 | 0 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **965** | **407** | **200** | **136** | **4** | **64** | **154** | +| **All** | **967** | **407** | **200** | **138** | **4** | **64** | **154** | ## 8 Per-requirement results @@ -3402,7 +3402,7 @@ Outside this round: the anchor firmware's signing call sites turn an error into | MR-SOFI-0362 | Met | `dsm::sofi::exercise::recognize_exercise`; `dsm::sofi::wire::objects::SofiExercise`; `dsm_sdk::sdk::sofi_exercise::build_exercise`; `dsm_sdk::sdk::sofi_relay::relay_exercise`; `dsm_sdk::sdk::sofi_flow::walk_for_attempt` | `dsm_sdk::handlers::node_e2e_tests::an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes`; `dsm_sdk::handlers::node_e2e_tests::a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair`; `dsm::sofi::exercise::tests::an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f`; `dsm::sofi::exercise::tests::an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing` | §6.66 12e. The exercise carries the trader's signed `C_q`, recognized as `K_root(q)` recognizes it with the body `derive(P, F)`; the next operation at a vault registers a withheld pair from it before passing the key (owner ruling, 2026-10-01). | | MR-SOFI-0363 | Missing | — | — | Added by Amendment S21 (§6.73): the slot rule; `VaultTerms` does not exist yet, and terms resolve as a market only. | | MR-SOFI-0364 | Missing | — | — | Added by Amendment S21 (§6.73): `EscrowTerms` does not exist yet. | -| MR-SOFI-0365 | Missing | — | — | Added by Amendment S21 (§6.73): the outcome table and `τ` do not exist yet. | +| MR-SOFI-0365 | Missing | — | — | Added by Amendment S21 (§6.73): the outcome table, its one encoding and `τ` do not exist yet. | | MR-SOFI-0366 | Missing | — | — | Added by Amendment S21 (§6.73): the verdict cell key, seed and leader do not exist yet. | | MR-SOFI-0367 | Missing | — | — | Added by Amendment S21 (§6.73): the escrow statement does not exist yet. | | MR-SOFI-0368 | Missing | — | — | Added by Amendment S21 (§6.73): recognition at the verdict cell does not exist yet. | @@ -3410,7 +3410,7 @@ Outside this round: the anchor firmware's signing call sites turn an error into | MR-SOFI-0370 | Missing | — | — | Added by Amendment S21 (§6.73): the statement locator and gathered verdict objects do not exist yet. | | MR-SOFI-0371 | Missing | — | — | Added by Amendment S21 (§6.73): operation tag 38 does not exist yet. | | MR-SOFI-0372 | Missing | — | — | Added by Amendment S21 (§6.73): `creation_accepted` has no escrow form. | -| MR-SOFI-0373 | Missing | — | — | Added by Amendment S21 (§6.73): the commitment locator does not exist yet. | +| MR-SOFI-0373 | Missing | — | — | Added by Amendment S21 (§6.73): the cell locator does not exist yet. | | MR-SOFI-0374 | Missing | — | — | Added by Amendment S21 (§6.73): `SettlementBody` has no Release branch. | | MR-SOFI-0375 | Missing | — | — | Added by Amendment S21 (§6.73): the Release write set does not exist yet. | | MR-SOFI-0376 | Missing | — | — | Added by Amendment S21 (§6.73): `validate_release` does not exist yet. | @@ -3422,6 +3422,8 @@ Outside this round: the anchor firmware's signing call sites turn an error into | MR-SOFI-0382 | Missing | — | — | Added by Amendment S21 (§6.73): S15 resolution reads no verdict cell. | | MR-SOFI-0383 | Missing | — | — | Added by Amendment S21 (§6.73): no escrow vault exists, so no stake is locked. | | MR-SOFI-0384 | Missing | — | — | Added by Amendment S21 (§6.73): the escrow routes do not exist yet. | +| MR-SOFI-0385 | Missing | — | — | Added by Amendment S21 (§6.73): no escrow vault is bound to a verdict cell yet, so nothing derives a link. | +| MR-SOFI-0386 | Missing | — | — | Added by Amendment S21 (§6.73): `escrow.create` and the escrow genesis acceptance do not exist yet. | ### 8.3 dBTC native specification diff --git a/specs/requirements/MASTER_REQUIREMENTS.md b/specs/requirements/MASTER_REQUIREMENTS.md index 568d9f7e4..7fb27c8b1 100644 --- a/specs/requirements/MASTER_REQUIREMENTS.md +++ b/specs/requirements/MASTER_REQUIREMENTS.md @@ -15,7 +15,7 @@ Every extraction in this round is taken against exactly these bytes: | File | `git hash-object` | Lines | |---|---|---| | `specs/DSM_High_Level_Explainer.md` | `871fb1a7f24cb8e7e29ba7152aabcc4f2819e397` | 4323 | -| `specs/SoFi_Settlement_Specification.md` | `9ed21d3999e8cf94b0ff525ea52b6f3f9892312d` | 2826 | +| `specs/SoFi_Settlement_Specification.md` | `c30f6585caf2700f46997db0f658fc915400afbf` | 2837 | | `specs/dBTC_Native_Specification.md` | `233a3e72a5b16a023af830f4c8ffaad4ba9391a8` | 2160 | | `specs/DSM_Storage_Node_Specification.md` | `415a9b9c67c7a2b4b6df78b0af85fb3bc7282ae8` | 636 | @@ -161,7 +161,7 @@ Each extraction also has a Findings table: - **Credit sources held to account (2026-10-01).** Security pre-audit item 3: under A8's one-hop rule a credit's source step was validated, but the source's earlier positions were authenticated as root claims only, so a source could register an invented root and pay out of it through a second wallet of its own to an honest receiver. Owner ruling (2026-10-01, quoted in A8): "Apply A8 transitively to credit sources", every transition in the source's segment verified back to the latest frontier the receiver trusts for that identity, a frontier trusted only after the complete segment to it passed, "admitted" or "signed" never proof of valid ancestry, budget exhaustion before a trusted frontier incomplete and never accepted, validated frontiers cached so later interactions validate only the suffix; succinct recursive validity proofs are the longer-term replacement. DSM Amendment A8 corrected (the One hop bullet replaced by Credit sources; the frontier and never-read bullets follow it) and SoFi S15's SetupValid wording aligned. MR-DSM-0273, 0274 and 0275 and MR-SOFI-0347 rewritten; §1 re-pinned. - **The relationship-key tag (2026-09-30).** Conformance finding (CONFORMANCE §6.14, MR-DSM-0115, MR-DSM-0249): the explainer derived a relationship's SMT key under `DSM/smt-key/v1`, while the code and its golden vector use `DSM/smt-key`. Owner ruling: `DSM/smt-key` is the canonical relationship-SMT domain tag for beta, and `/v1` in the specification was an error (DSM Amendment A9). The formula in §26 and the example in §16 are corrected; no key migrates. MR-DSM-0115 rewritten; §1 re-pinned. - **Vaults found by their tokens, setup inside the trade, and routes that split (2026-10-01).** Phone-rig findings: `sofi.findRoute` searched only the vaults the trader had set up with, so the rig's first trade went through only because the trader set up by hand; and the owner ruled that two vaults of one pair are no different from two of different pairs, so one order may fill through both. Owner decisions: a vault is indexed under each of its tokens and found there with no authority taken from the index, path search reads the two tokens' indexes, the first operation through a vault admits its setup first, `sofi.vaults` replaces `sofi.setup` among the eight routes, a walk resolves another trader's conditional parent through frontier-relative verification (SoFi Amendment S16); and a Swap route's hops chain or split (SoFi Amendment S19). MR-SOFI-0350–0359 added with source `amendment`; MR-SOFI-0255 updated; §1 re-pinned. -- **Escrow vaults (2026-10-05).** An application needed two parties to lock equal stakes against one agreed match, with the application as referee, and nothing could hold value under a condition other than a SoFi market (CONFORMANCE §6.73). Owner rulings (2026-10-04, 2026-10-05, quoted in SoFi Amendment S21): build the generic escrow vault with no wager logic in Core, and make one external commitment have one admissible verdict in the protocol itself. Owner decisions as specified: an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object; it releases its whole amount once to the recipient of the branch whose outcome the canonical verdict names; the verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes; linked vaults settle on that one outcome; there is no owner close and no market (SoFi Amendment S21, §19.9). MR-SOFI-0363–0384 added with source `amendment`; §1 re-pinned. +- **Escrow vaults (2026-10-05).** An application needed two parties to lock equal stakes against one agreed match, with the application as referee, and nothing could hold value under a condition other than a SoFi market (CONFORMANCE §6.73). Owner rulings (2026-10-04, 2026-10-05, quoted in SoFi Amendment S21): build the generic escrow vault with no wager logic in Core, and make one external commitment have one admissible verdict in the protocol itself. Owner decisions as specified: an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object; it releases its whole amount once to the recipient of the branch whose outcome the canonical verdict names; the verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes; linked vaults settle on that one outcome; there is no owner close and no market (SoFi Amendment S21, §19.9). Owner review (2026-10-05) asked that linking be an explicit invariant: same `Y`, same canonical table (which is the authority), same cell; linking is derived from each vault's accepted terms and checked by whoever relies on it, and discovery is by cell. MR-SOFI-0363–0386 added with source `amendment`; §1 re-pinned. - **Post-reconciliation amendment (2026-09-22): route-chain finality.** For finding GPT-4, finality was redefined as a route chain (storage spec §9, §12.6, §14, §22; DSM Amendment A6; SoFi Amendment S4). §1 pins the amended files. Canonical rows restating the old rule were rewritten, and rows for the new rules were added at the end of §8.1, §8.2 and §8.4 with source `amendment`. The extraction files in `extractions/` remain as extracted against the earlier hashes. ## 8 Canonical requirements @@ -176,7 +176,7 @@ Reconciled on 2026-09-22 from two extractions: `claude-chat` (798 rows) and `cha | DSM_Storage_Node_Specification.md | 128 | 122 | 6 | | **Total** | **859** | **652** | **207** | -Added afterwards by amendment (§7.1): DSM 8, SoFi 53, storage 30, for 954 canonical requirements in all. +Added afterwards by amendment (§7.1): DSM 8, SoFi 55, storage 30, for 956 canonical requirements in all. Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sources** are the extraction IDs merged into the row (`cc:` claude-chat, `gpt:` chatgpt); the first source locates the quote. **Flags** carry the findings in §8.7 that bear on the row. @@ -830,7 +830,7 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0362 | obligation | explicit | The exercise carries the trader's signed C_q, whose body is the C_q of the exercise's P and F under the same bound key; a relayer completes the position pair with the trader's signed C_q from the exercise or the cell and never authors C_q. Before advancing past a vault key held by an exercise whose pair is not yet registered, the next operation at the vault MUST register that pair with the C_q the exercise carries, exactly as signed, and then retry; an exercise whose C_q fails recognition counts as nothing. | amendment: SoFi Amendment S20 (2026-10-01; the relaying bullet is the owner's ruling of the same day) | owner | none | | MR-SOFI-0363 | invariant | explicit | An escrow vault's `VaultStateLeaf` is unchanged byte for byte: `market_policy`, `fee_policy` and `release_policy` all hold `A_T`, the address of its `EscrowTerms` (class 0x0063); `reserve_a` is the held amount and `reserve_b` is 0; it is Active while `reserve_a > 0` and `reserve_b = 0`, and Retired (both zero) is terminal. Slots that include an `EscrowTerms` object other than as all three naming one are neither a market nor an escrow, and the genesis is refused. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0364 | invariant | explicit | `EscrowTerms` (class 0x0063, schema 1) holds the held token's policy commit, `Y = H(DSM/external/v1 ∥ X)`, and 1 to 16 branches strictly ascending by `outcome` (1 to 64 bytes), each with 1 to 4 signers strictly ascending by `u16be(alg) ∥ u32be(|key|) ∥ key` and a recipient (genesis, device id); its address is `immutable_addr(DSM/escrow/terms-object/v1, CCB bytes)`. A branch is decided by all of its signers: no duplicate signer, empty set or threshold is expressible, and labels are unique. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | -| MR-SOFI-0365 | obligation | explicit | The outcome table `O` is the branches with their recipients removed, in branch order, and `τ = H(DSM/escrow/outcome-table/v1; u8(|O|) ∥ ⨁ entries)`, each entry `u32be(|o|) ∥ o ∥ u8(|signers|) ∥ ⨁ (u16be(alg) ∥ u32be(|key|) ∥ key)`. Escrow vaults with the same `Y` and the same table are linked and share one verdict cell; their recipients may differ. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0365 | obligation | explicit | The outcome table `O` is the branches with their recipients removed, in branch order, and `τ = H(DSM/escrow/outcome-table/v1; u8(|O|) ∥ ⨁ entries)`, each entry `u32be(|o|) ∥ o ∥ u8(|signers|) ∥ ⨁ (u16be(alg) ∥ u32be(|key|) ∥ key)`. The table has exactly one encoding (branches strictly ascending by outcome, signer sets strictly ascending, every length prefixed; any other order does not decode). The table is the verdict authority, and there is no other authority object. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0366 | obligation | explicit | `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)`. Its leader is `FisherYates(s_verdict, S)[0]` over the network's pinned set with `s_verdict = H(DSM/escrow/verdict-seed/v1; K_verdict)`; it is written by the procedure of §8 with route-chain finality, and anyone may write it. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0367 | invariant | explicit | A signer decides outcome `o` for a verdict cell by signing `m(o) = H(DSM/escrow/statement/v1; K_verdict ∥ u32be(|o|) ∥ o)`: the statement names the cell, so a signature decides nothing at another commitment or another table, and it names no vault, so one signature serves every vault bound to the cell. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0368 | invariant | explicit | The value that counts at `K_verdict` is the first object at its leader that is an `EscrowVerdict` (class 0x0064) recognized there from its own bytes alone: the cell key recomputes from its `Y` and table, its outcome is a label of the table, its signers are exactly the signers the table assigns to that outcome, and every signature verifies over `m(outcome)` under its key. Everything else at the cell counts as nothing, and no vault, member or reader's position enters the decision. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | @@ -838,7 +838,7 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0370 | obligation | explicit | Signatures for a branch with more than one signer may be gathered as `EscrowVerdict` objects put under `immutable_addr(DSM/escrow/verdict-object/v1, CCB bytes)` and indexed under `escrow_statement_locator(K, o) = H(DSM/escrow/statement-locator/v1; K ∥ u32be(|o|) ∥ o)`; a reader keeps only signatures that verify over `m(o)` under a signer the table assigns to `o`. The index carries no authority; only a recognized verdict at the cell decides anything. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0371 | transition | explicit | `EscrowVaultCreate`, variant 38 of `Operation`, carries the vault genesis preimage, the `VaultCreation` leaf and the exact `EscrowTerms` bytes, signed by the owner. The owner's transition debits the held amount of the terms' token once and inserts `VaultCreation{vault_id, genesis_root, amount_a, amount_b = 0}`, insert only, with `amount_a` the held amount; the verifier admits exactly one debit and one creation record. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0372 | evidence | explicit | An escrow vault's genesis is accepted only if `R0` recomputes and `V0` is the initial state (generation zero, Active, no relationship leaves); the three policy slots hold `A_T` and the carried bytes decode as `EscrowTerms` and re-derive `A_T`; `reserve_a` equals the funded amount and the debit and `reserve_b = 0`; `v = vault_id(Go, DevIDo, pcreate)`; the storage set is the network's pinned set; the owner's root at `p` is validated; and the terms' token passes its token policy for a transfer. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | -| MR-SOFI-0373 | obligation | explicit | The escrow terms are put as an object under `A_T`, and the genesis preimage is indexed under `vault_genesis_locator(v)` and `escrow_commitment_locator(Y) = H(DSM/escrow/commitment-locator/v1; Y)`, never under `vault_token_locator`. Discovery carries no authority: a candidate counts only when its genesis is accepted and its terms name `Y`. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0373 | obligation | explicit | The escrow terms are put as an object under `A_T`, and the genesis preimage is indexed under `vault_genesis_locator(v)` and `escrow_cell_locator(K_verdict) = H(DSM/escrow/cell-locator/v1; K_verdict)`, never under `vault_token_locator`. Discovery carries no authority: a candidate counts only when its genesis is accepted and its terms derive that `K_verdict`. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0374 | transition | explicit | `B°` has a Release branch `{vault_id, parent_root, setup_ref, verdict_cell, outcome, amount, trader_core, dlv_core, closure}` (class 0x0065) with one leg and `E` in its single-leg form; its route digest preimage is `Release{vault_id, parent_root, setup_ref, verdict_cell, outcome, amount}` (class 0x0066). The trader is the branch's recipient, the release is the recipient's own position set up with the vault, its credit is the recipient's realized root, and no verdict enters `E`. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0375 | invariant | explicit | A Release's closed write set: `T°` credits the terms' token by `amount`, advances the trader's relationship with the vault and debits nothing; `V°` takes the vault state from Active with exactly `reserve_a = amount` and `reserve_b = 0` to Retired with both reserves zero and generation plus one, with the matching relationship advance. No other entry is permitted. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0376 | prohibition | explicit | A Release is Invalid unless it has one leg; the vault's terms resolve to `EscrowTerms` by the slot rule; `verdict_cell = K_verdict(Y, τ)` of those terms; `outcome` names a branch; `P`'s trader is that branch's recipient; `amount = reserve_a` and `reserve_b = 0` at the parent; the write set holds; per-token conservation holds; and the token passes its policy for a transfer. Its static budget is at most 16 label comparisons, and no signature is verified there. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | @@ -849,7 +849,9 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0381 | invariant | explicit | Every escrow vault bound to one verdict cell settles only on the outcome that cell holds: a release naming another outcome is Void and its key skipped, whichever vault it is in. The linked vaults remain independent DLVs with no shared parent, each released by its own recipient's position; one vault's release does not release the other. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0382 | obligation | explicit | The public objects that resolve another trader's Release position (Amendment S15) include the verdict at its verdict cell, with its finality evidence. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | | MR-SOFI-0383 | liveness-boundary | explicit | An escrow stake is released only by a verdict: if no recognized verdict reaches the cell, the stake stays locked. A way out is a committed branch (for example a cancel decided by both parties), which contends for the same cell as every other outcome. A refund after a deadline needs an iteration budget and is not part of Amendment S21. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | -| MR-SOFI-0384 | obligation | explicit | The app reaches escrow vaults only through `escrow.party`, `escrow.create`, `escrow.sign`, `escrow.adjudicate`, `escrow.verdict`, `escrow.release`, `escrow.locked` and `escrow.vaults` (`SDK/handlers/escrow_routes.rs`). `escrow.create` locks against a counterpart vault only once that vault is accepted, Active and bound to the same `K_verdict`; `escrow.adjudicate` writes a recognized verdict to the cell and returns the verdict the cell holds; `escrow.release` builds a release only once the cell's verdict is final on the outcome of a branch that pays the device. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0384 | obligation | explicit | The app reaches escrow vaults only through `escrow.party`, `escrow.create`, `escrow.sign`, `escrow.adjudicate`, `escrow.verdict`, `escrow.release`, `escrow.locked` and `escrow.vaults` (`SDK/handlers/escrow_routes.rs`). `escrow.create` locks against a counterpart vault only once that vault is accepted, Active and bound to the same `K_verdict`; `escrow.adjudicate` writes a recognized verdict to the cell and returns the verdict the cell holds; `escrow.release` builds a release only once the cell's verdict is final on the outcome of a branch that pays the device; `escrow.locked` reads a cell's locator and keeps only accepted vaults that derive that cell. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0385 | invariant | explicit | Two escrow vaults are linked exactly when their terms name the same `Y` and byte-identical outcome tables, and then they have the same `K_verdict`. Linking is established only by deriving `K_verdict` from each vault's accepted terms: a match id, an index entry or an application's word never links them, and a verdict at one cell decides nothing for a vault bound to another. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | +| MR-SOFI-0386 | obligation | explicit | A party that locks against a counterpart vault creates its own only when the counterpart is GenesisAccepted, Active, and derives the same `K_verdict` as the terms it is about to commit; a reader that relies on two vaults being linked checks the same derivation on both. Core accepts each escrow vault's genesis on its own terms and does not compare it with another vault. | amendment: SoFi Amendment S21 (2026-10-04, 2026-10-05) | owner | none | ### 8.3 dBTC native specification From b52df1cb598461413f082e1947b1f4e6a5f2155a Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:51:48 -0400 Subject: [PATCH 04/11] feat(escrow): the terms, the verdict and the verdict cell (SoFi S21) The wire layer of escrow vaults (SoFi section 19.9), and nothing that spends yet: - CCB classes 0x0063 to 0x0066: EscrowTerms, EscrowVerdict, and the Release branch and its route digest, allocated for the next commit. - Nine domain tags in common/domain_tags/dsm/misc/escrow.rs: DSM/external/v1 and the eight DSM/escrow/* domains; the registry count goes from 353 to 362. - EscrowTerms, EscrowOutcome, OutcomeTable, EscrowBranch, EscrowSigner, EscrowVerdict and VerdictSignature, with strict codecs. A signer set is 1 to 4 signers strictly ascending by their canonical bytes, and an outcome table 1 to 16 entries strictly ascending by outcome, so a duplicate, a threshold or a second encoding of one table cannot be expressed. - sofi::escrow: Y, A_T, tau, K_verdict, the verdict seed, the statement m(o), the two locators, signing a statement, and verdict_authority: a verdict occupies its cell only when its Y and table derive the cell, its outcome is in the table, its signers are exactly that outcome's, and every signature verifies over m(o). The verdict cell is read with route_chain::evaluate; the read keeps why each value ahead of the occupant counts as nothing, and standing_for gives a release Final, Unsettled or Lost. Tests (dsm --lib, release): 8 in sofi::escrow, covering the derivations against an independent hasher, the field-table bytes with frozen digests, linked vaults sharing one cell exactly when Y and the table agree, every refusal of a malformed table, and a signature deciding only the cell it was made for. Domain tag registry 8/8. clippy clean. --- .../dsm/src/ccb/mod.rs | 16 + .../src/common/domain_tags/dsm/misc/escrow.rs | 58 ++ .../src/common/domain_tags/dsm/misc/mod.rs | 8 + .../dsm/src/common/domain_tags/dsm/mod.rs | 1 + .../dsm/src/common/domain_tags/mod.rs | 4 +- .../dsm/src/sofi/escrow.rs | 937 ++++++++++++++++++ .../dsm/src/sofi/mod.rs | 1 + .../dsm/src/sofi/signature.rs | 6 +- .../dsm/src/sofi/wire/mod.rs | 13 + .../dsm/src/sofi/wire/objects.rs | 421 ++++++++ 10 files changed, 1462 insertions(+), 3 deletions(-) create mode 100644 dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/escrow.rs create mode 100644 dsm_client/deterministic_state_machine/dsm/src/sofi/escrow.rs diff --git a/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs index 224ef71e6..9c4542fe3 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs @@ -243,6 +243,22 @@ pub mod class { /// trader's signature over it, under a key the claim's own `AttA` binds /// to its `DevID` (DSM Amendment A10, SoFi Amendment S20). pub const SOFI_SIGNED_RESOLUTION_CLAIM: u16 = 0x0062; + /// `0x0063` — an escrow vault's terms (SoFi Amendment S21): the held + /// token, the external commitment `Y`, and the branches, each an outcome, + /// the exact signer set that decides it, and the identity it pays. The + /// three policy slots of an escrow vault's state name this object. + pub const ESCROW_TERMS: u16 = 0x0063; + /// `0x0064` — a verdict on an external commitment (SoFi Amendment S21): + /// `Y`, the outcome table, the outcome, and its signers' signatures over + /// the statement for the verdict cell. It occupies the cell only when it + /// proves its own authority from these bytes. + pub const ESCROW_VERDICT: u16 = 0x0064; + /// `B°`, the Release branch (SoFi Amendment S21): an escrow vault's whole + /// amount to the recipient of the branch whose outcome the canonical + /// verdict names. + pub const SOFI_SETTLEMENT_RELEASE: u16 = 0x0065; + /// `X_route` preimage branch: a release. + pub const SOFI_ROUTE_DIGEST_RELEASE: u16 = 0x0066; } /// Discriminants **allocated but not encodable** — see [`class`] for the ones diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/escrow.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/escrow.rs new file mode 100644 index 000000000..5d2e29229 --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/escrow.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! DSM namespace tags: escrow vaults (SoFi Amendment S21). +//! +//! An escrow vault is a SoFi vault whose terms are a set of precommitted +//! branches, released by the canonical verdict on an external commitment +//! `Y = H(DSM/external/v1 ‖ X)` (Explainer §60). The derivations live in +//! `crate::sofi::escrow`; this file only allocates the domains. + +use crate::crypto::domain::TaggedHashDomain; + +/// `Y = H(DSM/external/v1 ‖ X)` — an external commitment (Explainer §60). +/// DSM never reads `X`; it verifies only predicates bound to `Y`. +pub const TAG_DSM_EXTERNAL: TaggedHashDomain<'static> = crate::tagged_domain!(b"DSM/external/v1"); +/// `A_T = immutable_addr(tag, CCB(EscrowTerms))` — the address an escrow +/// vault's three policy slots name. +pub const TAG_DSM_ESCROW_TERMS_OBJECT: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/escrow/terms-object/v1"); +/// `τ = H(tag ‖ u8(|O|) ‖ entries)` — the outcome table: each outcome with the +/// exact signer set that decides it. +pub const TAG_DSM_ESCROW_OUTCOME_TABLE: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/escrow/outcome-table/v1"); +/// `K_verdict = H(tag ‖ Y ‖ τ)` — the one cell every vault bound to `Y` under +/// table `τ` settles against. +pub const TAG_DSM_ESCROW_VERDICT_CELL: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/escrow/verdict-cell/v1"); +/// `s_verdict = H(tag ‖ K_verdict)` — the seed of the verdict cell's route. +pub const TAG_DSM_ESCROW_VERDICT_SEED: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/escrow/verdict-seed/v1"); +/// `m(o) = H(tag ‖ K_verdict ‖ u32be(|o|) ‖ o)` — what a signer signs to +/// decide outcome `o` at the cell. +pub const TAG_DSM_ESCROW_STATEMENT: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/escrow/statement/v1"); +/// `immutable_addr(tag, CCB(EscrowVerdict))` — a gathered verdict, put as an +/// object while its signers' signatures are collected. +pub const TAG_DSM_ESCROW_VERDICT_OBJECT: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/escrow/verdict-object/v1"); +/// `H(tag ‖ K_verdict)` — where the genesis of every escrow vault bound to a +/// verdict cell is indexed. +pub const TAG_DSM_ESCROW_CELL_LOCATOR: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/escrow/cell-locator/v1"); +/// `H(tag ‖ K_verdict ‖ u32be(|o|) ‖ o)` — where gathered signatures deciding +/// outcome `o` at a cell are indexed. +pub const TAG_DSM_ESCROW_STATEMENT_LOCATOR: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/escrow/statement-locator/v1"); + +#[cfg(test)] +pub(crate) const ESCROW_TAGS: &[TaggedHashDomain<'static>] = &[ + TAG_DSM_EXTERNAL, + TAG_DSM_ESCROW_TERMS_OBJECT, + TAG_DSM_ESCROW_OUTCOME_TABLE, + TAG_DSM_ESCROW_VERDICT_CELL, + TAG_DSM_ESCROW_VERDICT_SEED, + TAG_DSM_ESCROW_STATEMENT, + TAG_DSM_ESCROW_VERDICT_OBJECT, + TAG_DSM_ESCROW_CELL_LOCATOR, + TAG_DSM_ESCROW_STATEMENT_LOCATOR, +]; diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/mod.rs index 9d350dfaa..b8e76e587 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/mod.rs @@ -9,6 +9,7 @@ use crate::crypto::domain::TaggedHashDomain; mod addressing; mod economic; +mod escrow; mod protocol; mod sofi; mod testing; @@ -16,6 +17,7 @@ mod token_ops; pub use addressing::*; pub use economic::*; +pub use escrow::*; pub use protocol::*; pub use sofi::*; pub use testing::*; @@ -28,6 +30,12 @@ pub(super) fn sofi_tags() -> &'static [TaggedHashDomain<'static>] { sofi::SOFI_TAGS } +/// The escrow-vault tags (SoFi Amendment S21), collected the same way. +#[cfg(test)] +pub(super) fn escrow_tags() -> &'static [TaggedHashDomain<'static>] { + escrow::ESCROW_TAGS +} + #[cfg(test)] #[cfg(test)] pub(super) const TAGS: &[TaggedHashDomain<'static>] = &[ diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/mod.rs index d59a60c2e..4ca5973b0 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/mod.rs @@ -33,6 +33,7 @@ pub(super) fn all_tags() -> Vec> { tags.extend_from_slice(genesis_identity::TAGS); tags.extend_from_slice(misc::TAGS); tags.extend_from_slice(misc::sofi_tags()); + tags.extend_from_slice(misc::escrow_tags()); tags.extend_from_slice(policy_registry::TAGS); tags.extend_from_slice(recovery::TAGS); tags.extend_from_slice(vault_dbtc::TAGS); diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs index 45a3fa41a..6f90d81cc 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/mod.rs @@ -173,7 +173,9 @@ mod tests { // +1 with the vault token locator (SoFi Amendment S16). // +1 with the signed resolution claim's digest (SoFi Amendment S20), and // +1 with the relationship-scoped transfer nonce (pre-audit item 5). - const EXPECTED_TAG_COUNT: usize = 353; + // +9 with escrow vaults (SoFi Amendment S21): `DSM/external/v1` and the + // eight `DSM/escrow/*` domains. + const EXPECTED_TAG_COUNT: usize = 362; /// Scan the crate source for every declared domain-tag constant. /// diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/escrow.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/escrow.rs new file mode 100644 index 000000000..1696d5261 --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/escrow.rs @@ -0,0 +1,937 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Escrow vaults (SoFi §19.9, Amendment S21): the derivations, the verdict +//! cell, and what occupies it. +//! +//! An escrow vault is a SoFi vault whose three policy slots name one +//! [`EscrowTerms`] object. It releases its whole amount once, by a Release +//! position of the branch's recipient, when the canonical verdict on its +//! external commitment `Y` names that branch's outcome. Every vault whose +//! terms name `Y` and the same outcome table `τ` is bound to one cell, +//! `K_verdict = H(DSM/escrow/verdict-cell/v1 ‖ Y ‖ τ)`, and the first verdict +//! at that cell's leader that proves its own authority from its own bytes is +//! the only one any of them settles against. A second verdict, even one +//! validly signed by the same signers for another outcome, never becomes the +//! cell's value. +//! +//! Nothing here knows what an application's commitment means: `X` is never +//! read, only `Y`. + +use crate::common::domain_tags::{ + TAG_DSM_ESCROW_CELL_LOCATOR, TAG_DSM_ESCROW_OUTCOME_TABLE, TAG_DSM_ESCROW_STATEMENT, + TAG_DSM_ESCROW_STATEMENT_LOCATOR, TAG_DSM_ESCROW_TERMS_OBJECT, TAG_DSM_ESCROW_VERDICT_CELL, + TAG_DSM_ESCROW_VERDICT_OBJECT, TAG_DSM_ESCROW_VERDICT_SEED, TAG_DSM_EXTERNAL, +}; +use crate::crypto::blake3::dsm_domain_hasher; +use crate::crypto::domain::TaggedHashDomain; +use crate::route_chain::{ + check_completion_proof, completion_proof, evaluate, CellError, CellEvidence, CellFact, + CellReading, ChainState, CompletionProof, Missing, ProofRefusal, RoutedCell, +}; +use crate::storage_object::immutable_addr; + +use super::signature::{verify_bytes, SignatureError}; +use super::wire::{EscrowSigner, EscrowTerms, EscrowVerdict, OutcomeTable, VerdictSignature}; + +type D32 = [u8; 32]; + +fn h(tag: TaggedHashDomain<'static>, parts: &[&[u8]]) -> D32 { + let mut hasher = dsm_domain_hasher(tag); + for p in parts { + hasher.update(p); + } + *hasher.finalize().as_bytes() +} + +// ── derivations ──────────────────────────────────────────────────────────── + +/// `Y = H(DSM/external/v1 ‖ X)` (Explainer §60). DSM never reads `X`. +pub fn external_commitment(x: &[u8]) -> D32 { + h(TAG_DSM_EXTERNAL, &[x]) +} + +/// `A_T = immutable_addr(DSM/escrow/terms-object/v1, CCB(EscrowTerms))`: what +/// an escrow vault's three policy slots name. +pub fn terms_address(terms: &EscrowTerms) -> D32 { + terms_address_of(&terms.encode()) +} + +/// The address `bytes` take as escrow terms. Bytes that do not re-derive the +/// address a vault names are not its terms. +pub fn terms_address_of(bytes: &[u8]) -> D32 { + immutable_addr(TAG_DSM_ESCROW_TERMS_OBJECT, bytes) +} + +/// `τ = H(DSM/escrow/outcome-table/v1 ‖ u8(|O|) ‖ entries)`. +pub fn table_digest(table: &OutcomeTable) -> D32 { + h(TAG_DSM_ESCROW_OUTCOME_TABLE, &[&table.digest_preimage()]) +} + +/// `K_verdict = H(DSM/escrow/verdict-cell/v1 ‖ Y ‖ τ)`. +pub fn verdict_cell_key(external_commitment: &D32, table_digest: &D32) -> D32 { + h( + TAG_DSM_ESCROW_VERDICT_CELL, + &[external_commitment, table_digest], + ) +} + +/// The verdict cell an escrow vault's terms bind it to. +pub fn verdict_cell_of(terms: &EscrowTerms) -> D32 { + verdict_cell_key( + terms.external_commitment(), + &table_digest(&terms.outcome_table()), + ) +} + +/// `s_verdict = H(DSM/escrow/verdict-seed/v1 ‖ K_verdict)`: the seed of the +/// verdict cell's route, so a reader that knows only the key finds its +/// leader. +pub fn verdict_seed(verdict_cell: &D32) -> D32 { + h(TAG_DSM_ESCROW_VERDICT_SEED, &[verdict_cell]) +} + +/// `m(o) = H(DSM/escrow/statement/v1 ‖ K_verdict ‖ u32be(|o|) ‖ o)`: what a +/// signer signs to decide outcome `o` at the cell. It names the cell and no +/// vault, so a signature decides nothing anywhere else, and one signature +/// serves every vault bound to the cell. +pub fn statement(verdict_cell: &D32, outcome: &[u8]) -> D32 { + // An outcome is at most ESCROW_MAX_OUTCOME_BYTES long. + let len = (outcome.len() as u32).to_be_bytes(); + h(TAG_DSM_ESCROW_STATEMENT, &[verdict_cell, &len, outcome]) +} + +/// `immutable_addr(DSM/escrow/verdict-object/v1, CCB(EscrowVerdict))`: where a +/// gathered verdict is put while its signatures are collected. +pub fn verdict_object_address(verdict: &EscrowVerdict) -> D32 { + immutable_addr(TAG_DSM_ESCROW_VERDICT_OBJECT, &verdict.encode()) +} + +/// `H(DSM/escrow/cell-locator/v1 ‖ K_verdict)`: where the genesis of every +/// escrow vault bound to the cell is indexed. Discovery by cell, so a vault +/// bound to another cell is never found among the linked ones. +pub fn cell_locator(verdict_cell: &D32) -> D32 { + h(TAG_DSM_ESCROW_CELL_LOCATOR, &[verdict_cell]) +} + +/// `H(DSM/escrow/statement-locator/v1 ‖ K_verdict ‖ u32be(|o|) ‖ o)`: where +/// gathered signatures deciding outcome `o` at the cell are indexed. +pub fn statement_locator(verdict_cell: &D32, outcome: &[u8]) -> D32 { + let len = (outcome.len() as u32).to_be_bytes(); + h( + TAG_DSM_ESCROW_STATEMENT_LOCATOR, + &[verdict_cell, &len, outcome], + ) +} + +// ── signing and recognition ──────────────────────────────────────────────── + +/// One signer's signature over `m(outcome)` for the cell. The producer half +/// of [`verdict_authority`]: what a signer contributes to a verdict. +pub fn sign_statement( + signer: &EscrowSigner, + secret_key: &[u8], + verdict_cell: &D32, + outcome: &[u8], +) -> Result { + let digest = statement(verdict_cell, outcome); + let signature = crate::crypto::sphincs::sphincs_sign(secret_key, &digest)?; + // Refuse a key that does not sign as the signer it names: a contribution + // that would never verify is not one. + verify_bytes( + "EscrowStatement", + signer.signature_alg(), + signer.public_key(), + &digest, + &signature, + )?; + VerdictSignature::new(signer.clone(), &signature) + .map_err(|e| crate::types::error::DsmError::invalid_operation(e.to_string())) +} + +/// Why a verdict does not occupy a cell. Every variant is decided from the +/// verdict's own bytes and the cell's key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerdictRefusal { + /// The bytes are not a canonical `EscrowVerdict`. + DoesNotDecode(crate::ccb::decode::DecodeError), + /// Its `Y` and table derive another cell. + NotThisCell, + /// Its outcome is not a label of its table. + OutcomeNotInTable, + /// Its signers are not exactly the signers its table assigns to the + /// outcome: one is missing, or one is not among them. + NotTheOutcomesSigners, + /// A signature does not verify over the statement for this cell. + Signature(SignatureError), +} + +/// Whether `verdict` proves its own authority for the cell at `verdict_cell` +/// (SoFi §19.9, "What occupies the cell"): +/// 1. its `Y` and table derive the cell; +/// 2. its outcome is a label of the table; +/// 3. its signers are exactly the signers the table assigns to the outcome; +/// 4. every signature verifies over `m(outcome)` under its key. +/// +/// No vault, member or reader's position enters it, so every reader reaches +/// the same answer. At most `ESCROW_MAX_SIGNERS` signatures are verified, +/// and none before the cheap conjuncts hold. +pub fn verdict_authority( + verdict: &EscrowVerdict, + verdict_cell: &D32, +) -> Result<(), VerdictRefusal> { + if verdict_cell_key( + verdict.external_commitment(), + &table_digest(verdict.table()), + ) != *verdict_cell + { + return Err(VerdictRefusal::NotThisCell); + } + let decided_by = verdict + .table() + .signers_of(verdict.outcome()) + .ok_or(VerdictRefusal::OutcomeNotInTable)?; + // Both sides are strictly ascending by the signer's canonical bytes, so + // the sets are equal exactly when the sequences are. + let signed_by: Vec<&EscrowSigner> = verdict.signatures().iter().map(|s| s.signer()).collect(); + if signed_by.len() != decided_by.len() || signed_by.iter().zip(decided_by).any(|(a, b)| *a != b) + { + return Err(VerdictRefusal::NotTheOutcomesSigners); + } + let digest = statement(verdict_cell, verdict.outcome()); + for s in verdict.signatures() { + verify_bytes( + "EscrowVerdict", + s.signer().signature_alg(), + s.signer().public_key(), + &digest, + s.signature(), + ) + .map_err(VerdictRefusal::Signature)?; + } + Ok(()) +} + +/// The verdict `bytes` are, when they prove their own authority for the cell +/// at `verdict_cell`, or why they do not: anything that does not counts as +/// nothing at the cell. +pub fn verdict_occupying( + bytes: &[u8], + verdict_cell: &D32, +) -> Result { + let verdict = EscrowVerdict::decode(bytes).map_err(VerdictRefusal::DoesNotDecode)?; + verdict_authority(&verdict, verdict_cell)?; + Ok(verdict) +} + +// ── the verdict cell ─────────────────────────────────────────────────────── + +/// The verdict cell at `K_verdict` as Core derives it: the key, and the route +/// seeded by `s_verdict` over the network's pinned set. Built only by +/// [`VerdictCell::new`], which refuses members that are not the committed +/// set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerdictCell { + key: D32, + cell: RoutedCell, +} + +impl VerdictCell { + /// `members` must re-derive `committed_set_id`, the storage set the + /// vaults bound to the cell commit (the network's pinned set). + pub fn new( + verdict_cell: &D32, + members: &crate::ccb::StorageSetMembers, + committed_set_id: &D32, + ) -> Result { + let cell = RoutedCell::new( + TAG_DSM_ESCROW_VERDICT_CELL.source_bytes(), + *verdict_cell, + &verdict_seed(verdict_cell), + members, + committed_set_id, + )?; + Ok(Self { + key: *verdict_cell, + cell, + }) + } + + pub fn key(&self) -> &D32 { + &self.key + } + + pub fn routed(&self) -> &RoutedCell { + &self.cell + } +} + +/// Where a Release stands at its verdict cell (SoFi §19.9, "The facts"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerdictStanding { + /// The cell holds no recognized verdict, or holds one on this outcome + /// whose chain is not final yet: nothing is settled. + Unsettled, + /// `VerdictFinal(K, o)`: the cell's verdict is final on this outcome. + Final, + /// `VerdictHeld(K, o′)` for another outcome, in any state: this outcome + /// can never be the cell's, because the leader holds one value for good. + Lost, +} + +/// What the ladder reads at a verdict cell, bound to the key it was read at: +/// the storage fact, and the verdict holding the cell with its exact bytes. +/// Built by [`verdict_resolution`] over the seats' reads and by nothing +/// else. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerdictCellRead { + key: D32, + fact: CellFact, + held: Option<(EscrowVerdict, Vec)>, + passed_over: Vec, +} + +impl VerdictCellRead { + pub fn key(&self) -> &D32 { + &self.key + } + + pub fn fact(&self) -> CellFact { + self.fact + } + + /// The verdict holding the cell, if any. + pub fn verdict(&self) -> Option<&EscrowVerdict> { + self.held.as_ref().map(|(verdict, _)| verdict) + } + + /// The exact bytes of the verdict holding the cell: what a relay carries. + pub fn value(&self) -> Option<&[u8]> { + self.held.as_ref().map(|(_, value)| value.as_slice()) + } + + /// Why each value the leader holds ahead of the one deciding the cell + /// counts as nothing there, in the leader's arrival order: a verdict for + /// another cell, under signers its table does not assign, or with a + /// signature that does not verify. What a reader reports when a cell it + /// expects decided is still open. + pub fn passed_over(&self) -> &[VerdictRefusal] { + &self.passed_over + } + + /// Where a release naming `outcome` stands at this cell. + pub fn standing_for(&self, outcome: &[u8]) -> VerdictStanding { + match (&self.held, self.fact) { + (Some((verdict, _)), CellFact::Held { state, .. }) => { + if verdict.outcome() != outcome { + VerdictStanding::Lost + } else if state == ChainState::Final { + VerdictStanding::Final + } else { + VerdictStanding::Unsettled + } + } + (None, _) | (Some(..), CellFact::Open) => VerdictStanding::Unsettled, + } + } +} + +/// The recognizer of a verdict cell: verdicts that prove their own authority +/// for its key, identified by their statement `m(o)`. Every value it passes +/// over is recorded in `refused` with the reason. +fn verdict_at<'c>( + cell: &'c VerdictCell, + refused: &'c std::cell::RefCell>, +) -> impl Fn(&[u8]) -> Option<(D32, EscrowVerdict)> + 'c { + move |bytes| match verdict_occupying(bytes, &cell.key) { + Ok(verdict) => Some((statement(&cell.key, verdict.outcome()), verdict)), + Err(refusal) => { + refused.borrow_mut().push(refusal); + None + } + } +} + +/// The recognizer of the one verdict a read found holding the cell: its exact +/// bytes. The first copy of those bytes in the leader's log is the first +/// value the read recognized there, so a completion proof built or checked +/// with it is about the same leader link. +fn held_verdict(read: &VerdictCellRead) -> impl Fn(&[u8]) -> Option<(D32, EscrowVerdict)> + '_ { + move |bytes| match &read.held { + Some((verdict, value)) if value.as_slice() == bytes => { + Some((statement(&read.key, verdict.outcome()), verdict.clone())) + } + Some(..) | None => None, + } +} + +/// The route-chain reading of a verdict cell (storage spec §9): open, or the +/// first recognized verdict at its leader and how far its chain has gone, +/// with why every value ahead of it counts as nothing. Evidence that does not +/// decide the cell yet is [`Missing`], a network status and never an answer. +pub fn verdict_resolution( + cell: &VerdictCell, + evidence: &CellEvidence, +) -> Result { + let refused = std::cell::RefCell::new(Vec::new()); + let reading = evaluate(&cell.cell, evidence, verdict_at(cell, &refused))?; + let fact = reading.fact(); + let held = match reading { + CellReading::Held { object, value, .. } => Some((object, value)), + CellReading::Open => None, + }; + Ok(VerdictCellRead { + key: cell.key, + fact, + held, + passed_over: refused.into_inner(), + }) +} + +/// The completion proof of the verdict `read` found final at the cell (SoFi +/// Amendment S10), with the verdict; `None` while no chain of it has three +/// links, or when the read found the cell open. +pub fn verdict_completion( + cell: &VerdictCell, + read: &VerdictCellRead, + evidence: &CellEvidence, +) -> Result, Missing> { + completion_proof(&cell.cell, evidence, held_verdict(read)) +} + +/// Check a kept completion proof of a verdict cell against the reads in +/// `evidence`: the verdict it proves final, which must be the one `read` +/// found holding the cell. +pub fn check_verdict_completion( + cell: &VerdictCell, + read: &VerdictCellRead, + evidence: &CellEvidence, + proof: &CompletionProof, +) -> Result { + check_completion_proof(&cell.cell, evidence, proof, held_verdict(read)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ccb::sigalg::SPHINCS_PLUS_SPX256F as ALG; + use crate::crypto::sphincs::{generate_keypair_from_seed, SphincsVariant}; + use crate::sofi::wire::{ + EscrowBranch, EscrowOutcome, SofiWireError, ESCROW_MAX_BRANCHES, ESCROW_MAX_SIGNERS, + }; + + /// A signer and its secret key, from a fixed seed. + fn party(seed: u8) -> (EscrowSigner, Vec) { + let kp = generate_keypair_from_seed(SphincsVariant::SPX256f, &[seed; 32]).expect("keypair"); + ( + EscrowSigner::new(ALG, &kp.public_key).expect("a declared key"), + kp.secret_key.clone(), + ) + } + + /// `signers` in their canonical order. + fn set(mut signers: Vec) -> Vec { + signers.sort_by_key(EscrowSigner::canonical); + signers + } + + struct Match { + referee: (EscrowSigner, Vec), + a: (EscrowSigner, Vec), + b: (EscrowSigner, Vec), + y: D32, + } + + fn the_match() -> Match { + Match { + referee: party(0x51), + a: party(0x52), + b: party(0x53), + y: external_commitment(b"match 7: A v B, 25 each, referee R"), + } + } + + /// The terms of one player's stake: A wins → A, B wins → B, cancel (both + /// players) → `owner`, void (referee) → `owner`. + fn terms(m: &Match, owner: (D32, D32)) -> EscrowTerms { + let referee = vec![m.referee.0.clone()]; + let both = set(vec![m.a.0.clone(), m.b.0.clone()]); + let branch = |outcome: &[u8], signers: Vec, to: (D32, D32)| { + EscrowBranch::new( + EscrowOutcome::new(outcome, signers).expect("outcome"), + to.0, + to.1, + ) + }; + EscrowTerms::new( + [0xE7; 32], + m.y, + vec![ + branch(b"a-wins", referee.clone(), ([0xA1; 32], [0xA2; 32])), + branch(b"b-wins", referee.clone(), ([0xB1; 32], [0xB2; 32])), + branch(b"cancel", both, owner), + branch(b"void", referee, owner), + ], + ) + .expect("terms") + } + + fn verdict( + m: &Match, + terms: &EscrowTerms, + outcome: &[u8], + by: &[&(EscrowSigner, Vec)], + ) -> EscrowVerdict { + let cell = verdict_cell_of(terms); + let mut signatures: Vec = by + .iter() + .map(|(signer, sk)| sign_statement(signer, sk, &cell, outcome).expect("signs")) + .collect(); + signatures.sort_by_key(|s| s.signer().canonical()); + EscrowVerdict::new(m.y, terms.outcome_table(), outcome, signatures).expect("verdict") + } + + /// `BLAKE3(tag ‖ 0x00 ‖ parts)` with the tag typed from the specification, + /// not read from the constants. + fn independent(tag: &str, parts: &[&[u8]]) -> D32 { + let mut hasher = ::blake3::Hasher::new(); + hasher.update(tag.as_bytes()); + hasher.update(&[0x00]); + for p in parts { + hasher.update(p); + } + *hasher.finalize().as_bytes() + } + + #[test] + fn the_derivations_are_the_specified_hashes() { + let m = the_match(); + let t = terms(&m, ([0x01; 32], [0x02; 32])); + assert_eq!( + external_commitment(b"x"), + independent("DSM/external/v1", &[b"x"]) + ); + let tau = table_digest(&t.outcome_table()); + assert_eq!( + tau, + independent( + "DSM/escrow/outcome-table/v1", + &[&t.outcome_table().digest_preimage()] + ) + ); + let k = verdict_cell_of(&t); + assert_eq!(k, independent("DSM/escrow/verdict-cell/v1", &[&m.y, &tau])); + assert_eq!( + verdict_seed(&k), + independent("DSM/escrow/verdict-seed/v1", &[&k]) + ); + assert_eq!( + statement(&k, b"void"), + independent( + "DSM/escrow/statement/v1", + &[&k, &4u32.to_be_bytes(), b"void"] + ) + ); + assert_eq!( + cell_locator(&k), + independent("DSM/escrow/cell-locator/v1", &[&k]) + ); + assert_eq!( + statement_locator(&k, b"void"), + independent( + "DSM/escrow/statement-locator/v1", + &[&k, &4u32.to_be_bytes(), b"void"] + ) + ); + } + + /// The table τ hashes is exactly `u8(|O|) ‖ ⨁ (u32be(|o|) ‖ o ‖ + /// u8(|signers|) ‖ ⨁ (u16be(alg) ‖ u32be(|key|) ‖ key))`, built here by + /// hand from the branches. + #[test] + fn the_outcome_table_has_the_specified_bytes() { + let m = the_match(); + let t = terms(&m, ([0x01; 32], [0x02; 32])); + let mut want = vec![t.branches().len() as u8]; + for b in t.branches() { + want.extend_from_slice(&(b.outcome().len() as u32).to_be_bytes()); + want.extend_from_slice(b.outcome()); + want.push(b.signers().len() as u8); + for s in b.signers() { + want.extend_from_slice(&s.signature_alg().to_be_bytes()); + want.extend_from_slice(&(s.public_key().len() as u32).to_be_bytes()); + want.extend_from_slice(s.public_key()); + } + } + assert_eq!(t.outcome_table().digest_preimage(), want); + } + + /// Linked vaults: the same Y and the same table derive one cell whatever + /// the recipients; a table that differs in one signer, or another Y, + /// derives another (SoFi §19.9, "Linked vaults"). + #[test] + fn vaults_share_a_cell_exactly_when_they_share_y_and_the_table() { + let m = the_match(); + let a_stake = terms(&m, ([0xA1; 32], [0xA2; 32])); + let b_stake = terms(&m, ([0xB1; 32], [0xB2; 32])); + assert_ne!(a_stake, b_stake, "the two stakes pay their own owners"); + assert_eq!(verdict_cell_of(&a_stake), verdict_cell_of(&b_stake)); + assert_ne!(terms_address(&a_stake), terms_address(&b_stake)); + + let other_y = Match { + y: external_commitment(b"match 8"), + ..the_match() + }; + assert_ne!( + verdict_cell_of(&terms(&other_y, ([0xA1; 32], [0xA2; 32]))), + verdict_cell_of(&a_stake) + ); + + // The same outcomes with one signer changed: another authority, so + // another cell. + let mut branches: Vec = a_stake.branches().to_vec(); + branches[3] = EscrowBranch::new( + EscrowOutcome::new(b"void", vec![party(0x54).0]).expect("outcome"), + [0xA1; 32], + [0xA2; 32], + ); + let other_table = EscrowTerms::new(*a_stake.token(), m.y, branches).expect("terms"); + assert_ne!(verdict_cell_of(&other_table), verdict_cell_of(&a_stake)); + } + + #[test] + fn terms_round_trip_and_what_has_no_canonical_order_has_no_encoding() { + let m = the_match(); + let t = terms(&m, ([0x01; 32], [0x02; 32])); + let bytes = t.encode(); + assert_eq!(EscrowTerms::decode(&bytes).expect("decodes"), t); + assert_eq!(terms_address_of(&bytes), terms_address(&t)); + + let mut trailing = bytes.clone(); + trailing.push(0); + assert!(matches!( + EscrowTerms::decode(&trailing), + Err(crate::ccb::decode::DecodeError::TrailingBytes { .. }) + )); + + // Signers out of order, or one signer twice, is no outcome. + let both = set(vec![m.a.0.clone(), m.b.0.clone()]); + let reversed = vec![both[1].clone(), both[0].clone()]; + assert!(matches!( + EscrowOutcome::new(b"cancel", reversed), + Err(SofiWireError::NotStrictlyAscending { .. }) + )); + assert!(matches!( + EscrowOutcome::new(b"cancel", vec![both[0].clone(), both[0].clone()]), + Err(SofiWireError::NotStrictlyAscending { .. }) + )); + assert!(matches!( + EscrowOutcome::new(b"cancel", Vec::new()), + Err(SofiWireError::Cardinality { got: 0, .. }) + )); + let five: Vec = set((0..=ESCROW_MAX_SIGNERS as u8) + .map(|i| party(0x60 + i).0) + .collect()); + assert!(matches!( + EscrowOutcome::new(b"cancel", five), + Err(SofiWireError::Cardinality { got: 5, .. }) + )); + assert!(matches!( + EscrowOutcome::new(b"", vec![m.referee.0.clone()]), + Err(SofiWireError::Cardinality { got: 0, .. }) + )); + assert!(matches!( + EscrowOutcome::new(&[0x61; 65], vec![m.referee.0.clone()]), + Err(SofiWireError::Cardinality { got: 65, .. }) + )); + + // Branches out of order, or two with one outcome, are no terms. + let mut swapped: Vec = t.branches().to_vec(); + swapped.swap(0, 1); + assert!(matches!( + EscrowTerms::new(*t.token(), m.y, swapped), + Err(SofiWireError::NotStrictlyAscending { .. }) + )); + let mut twice: Vec = t.branches().to_vec(); + twice[1] = twice[0].clone(); + assert!(matches!( + EscrowTerms::new(*t.token(), m.y, twice), + Err(SofiWireError::NotStrictlyAscending { .. }) + )); + assert!(matches!( + EscrowTerms::new(*t.token(), m.y, Vec::new()), + Err(SofiWireError::Cardinality { got: 0, .. }) + )); + let seventeen: Vec = (0..=ESCROW_MAX_BRANCHES as u8) + .map(|i| { + EscrowBranch::new( + EscrowOutcome::new(&[0x40 + i], vec![m.referee.0.clone()]).expect("outcome"), + [0x01; 32], + [0x02; 32], + ) + }) + .collect(); + assert!(matches!( + EscrowTerms::new(*t.token(), m.y, seventeen), + Err(SofiWireError::Cardinality { got: 17, .. }) + )); + } + + #[test] + fn a_verdict_proves_its_own_authority_for_its_cell() { + let m = the_match(); + let t = terms(&m, ([0x01; 32], [0x02; 32])); + let k = verdict_cell_of(&t); + + let won = verdict(&m, &t, b"a-wins", &[&m.referee]); + assert_eq!( + won.encode(), + EscrowVerdict::decode(&won.encode()) + .expect("decodes") + .encode() + ); + assert_eq!(verdict_occupying(&won.encode(), &k), Ok(won.clone())); + + // A joint cancel needs both players' signatures, and only theirs. + let cancel = verdict(&m, &t, b"cancel", &[&m.a, &m.b]); + assert_eq!(verdict_occupying(&cancel.encode(), &k), Ok(cancel)); + let half = verdict(&m, &t, b"cancel", &[&m.a]); + assert_eq!( + verdict_occupying(&half.encode(), &k), + Err(VerdictRefusal::NotTheOutcomesSigners) + ); + let with_referee = verdict(&m, &t, b"cancel", &[&m.a, &m.b, &m.referee]); + assert_eq!( + verdict_occupying(&with_referee.encode(), &k), + Err(VerdictRefusal::NotTheOutcomesSigners) + ); + + // The referee cannot decide the players' cancel, nor a player the + // referee's outcome. + let by_referee = verdict(&m, &t, b"cancel", &[&m.referee]); + assert_eq!( + verdict_occupying(&by_referee.encode(), &k), + Err(VerdictRefusal::NotTheOutcomesSigners) + ); + let by_a = verdict(&m, &t, b"a-wins", &[&m.a]); + assert_eq!( + verdict_occupying(&by_a.encode(), &k), + Err(VerdictRefusal::NotTheOutcomesSigners) + ); + + // An outcome the table does not have decides nothing. + let off_table = verdict(&m, &t, b"draw", &[&m.referee]); + assert_eq!( + verdict_occupying(&off_table.encode(), &k), + Err(VerdictRefusal::OutcomeNotInTable) + ); + } + + /// The statement names the cell: the referee's signature for one cell is + /// no verdict at another, even for the same outcome and the same signers. + #[test] + fn a_signature_decides_only_the_cell_it_was_made_for() { + let m = the_match(); + let t = terms(&m, ([0x01; 32], [0x02; 32])); + let other = Match { + y: external_commitment(b"match 8"), + ..the_match() + }; + let t8 = terms(&other, ([0x01; 32], [0x02; 32])); + let k8 = verdict_cell_of(&t8); + + let for_7 = verdict(&m, &t, b"a-wins", &[&m.referee]); + // Presented at match 8's cell as it is: its own Y and table derive + // match 7's cell. + assert_eq!( + verdict_occupying(&for_7.encode(), &k8), + Err(VerdictRefusal::NotThisCell) + ); + // Restated for match 8 with match 7's signature: the signature is over + // match 7's statement, so it does not verify. + let moved = EscrowVerdict::new( + other.y, + t8.outcome_table(), + b"a-wins", + for_7.signatures().to_vec(), + ) + .expect("verdict"); + assert!(matches!( + verdict_occupying(&moved.encode(), &k8), + Err(VerdictRefusal::Signature( + SignatureError::DoesNotVerify { .. } + )) + )); + // And a signature over another outcome is not this outcome's. + let b_won = verdict(&m, &t, b"b-wins", &[&m.referee]); + let relabeled = EscrowVerdict::new( + m.y, + t.outcome_table(), + b"a-wins", + b_won.signatures().to_vec(), + ) + .expect("verdict"); + assert!(matches!( + verdict_occupying(&relabeled.encode(), &verdict_cell_of(&t)), + Err(VerdictRefusal::Signature( + SignatureError::DoesNotVerify { .. } + )) + )); + } + + /// Where a release stands, from what the cell holds: open or not final + /// on its outcome is unsettled, final on its outcome is final, and the + /// other outcome held in any state is lost. + #[test] + fn a_release_stands_on_the_verdict_the_cell_holds() { + let m = the_match(); + let t = terms(&m, ([0x01; 32], [0x02; 32])); + let k = verdict_cell_of(&t); + let a_won = verdict(&m, &t, b"a-wins", &[&m.referee]); + let read = |state: Option| VerdictCellRead { + key: k, + fact: match state { + Some(state) => CellFact::Held { + id: statement(&k, b"a-wins"), + state, + }, + None => CellFact::Open, + }, + held: state.map(|_| (a_won.clone(), a_won.encode())), + passed_over: Vec::new(), + }; + assert_eq!( + read(None).standing_for(b"a-wins"), + VerdictStanding::Unsettled + ); + assert_eq!( + read(None).standing_for(b"b-wins"), + VerdictStanding::Unsettled + ); + for state in [ChainState::LeaderHeld, ChainState::Preserved] { + assert_eq!( + read(Some(state)).standing_for(b"a-wins"), + VerdictStanding::Unsettled + ); + assert_eq!( + read(Some(state)).standing_for(b"b-wins"), + VerdictStanding::Lost + ); + } + assert_eq!( + read(Some(ChainState::Final)).standing_for(b"a-wins"), + VerdictStanding::Final + ); + assert_eq!( + read(Some(ChainState::Final)).standing_for(b"b-wins"), + VerdictStanding::Lost + ); + } + + /// The fixed terms the golden digests are frozen over: synthetic keys + /// of the declared width (encoding verifies no key), two branches. + fn golden_terms() -> EscrowTerms { + let r = EscrowSigner::new(ALG, &[0x5A; 64]).expect("a declared key"); + let p1 = EscrowSigner::new(ALG, &[0x31; 64]).expect("a declared key"); + let p2 = EscrowSigner::new(ALG, &[0x32; 64]).expect("a declared key"); + EscrowTerms::new( + [0x7E; 32], + [0x59; 32], + vec![ + EscrowBranch::new( + EscrowOutcome::new(b"cancel", vec![p1, p2]).expect("outcome"), + [0xC1; 32], + [0xC2; 32], + ), + EscrowBranch::new( + EscrowOutcome::new(b"void", vec![r]).expect("outcome"), + [0xD1; 32], + [0xD2; 32], + ), + ], + ) + .expect("terms") + } + + /// `u16be(alg) ‖ u32be(|key|) ‖ key`, built by hand. + fn key_bytes(alg: u16, key: &[u8]) -> Vec { + [ + alg.to_be_bytes().to_vec(), + (key.len() as u32).to_be_bytes().to_vec(), + key.to_vec(), + ] + .concat() + } + + /// The CCB bytes of both objects, built here from the field tables in + /// `sofi::wire`'s module docs and not by the encoders, and the digests + /// over them frozen: a change to either is a change to the wire. + #[test] + fn the_wire_bytes_follow_the_field_tables_and_the_digests_are_frozen() { + let t = golden_terms(); + let part = |b: &[u8]| [(b.len() as u32).to_be_bytes().to_vec(), b.to_vec()].concat(); + let mut want = vec![0x00, 0x63, 0x00, 0x01]; + want.extend_from_slice(&[0x7E; 32]); + want.extend_from_slice(&[0x59; 32]); + want.extend_from_slice(&2u32.to_be_bytes()); + want.extend_from_slice(&part(b"cancel")); + want.extend_from_slice(&2u32.to_be_bytes()); + want.extend_from_slice(&key_bytes(ALG, &[0x31; 64])); + want.extend_from_slice(&key_bytes(ALG, &[0x32; 64])); + want.extend_from_slice(&[0xC1; 32]); + want.extend_from_slice(&[0xC2; 32]); + want.extend_from_slice(&part(b"void")); + want.extend_from_slice(&1u32.to_be_bytes()); + want.extend_from_slice(&key_bytes(ALG, &[0x5A; 64])); + want.extend_from_slice(&[0xD1; 32]); + want.extend_from_slice(&[0xD2; 32]); + assert_eq!(t.encode(), want); + + let k = verdict_cell_of(&t); + let signer = EscrowSigner::new(ALG, &[0x5A; 64]).expect("a declared key"); + let v = EscrowVerdict::new( + *t.external_commitment(), + t.outcome_table(), + b"void", + vec![VerdictSignature::new(signer, &[0x99; 3]).expect("signature")], + ) + .expect("verdict"); + let mut want = vec![0x00, 0x64, 0x00, 0x01]; + want.extend_from_slice(&[0x59; 32]); + want.extend_from_slice(&2u32.to_be_bytes()); + want.extend_from_slice(&part(b"cancel")); + want.extend_from_slice(&2u32.to_be_bytes()); + want.extend_from_slice(&key_bytes(ALG, &[0x31; 64])); + want.extend_from_slice(&key_bytes(ALG, &[0x32; 64])); + want.extend_from_slice(&part(b"void")); + want.extend_from_slice(&1u32.to_be_bytes()); + want.extend_from_slice(&key_bytes(ALG, &[0x5A; 64])); + want.extend_from_slice(&part(b"void")); + want.extend_from_slice(&1u32.to_be_bytes()); + want.extend_from_slice(&key_bytes(ALG, &[0x5A; 64])); + want.extend_from_slice(&part(&[0x99; 3])); + assert_eq!(v.encode(), want); + assert_eq!(EscrowVerdict::decode(&want).expect("decodes"), v); + + let b32 = crate::utils::text_id::encode_base32_crockford; + assert_eq!( + b32(&terms_address(&t)), + "RE1WCSK9TWW7CRDGVR8B680KV579N0C7TXQE9J199K9DQ1DMMVJ0" + ); + assert_eq!( + b32(&table_digest(&t.outcome_table())), + "4V1EDH3RSS5KXEBDHXC6J9S2NPFQ5115H42JGZV62EPSC632QEHG" + ); + assert_eq!( + b32(&k), + "WNJGZV7JPY4GDMP17W0JPKMAR21YG6Z06M9YR6EHR18YSA257VC0" + ); + assert_eq!( + b32(&statement(&k, b"void")), + "K0P13J70J0N3KXNAM1HJEHSFG8PDEKWXQ460MVDDB0TPCJG8ADSG" + ); + assert_eq!( + b32(&cell_locator(&k)), + "6H86QMFR7WZQ32S59TY4BJ4A919SB86Y2M6SRH82N3XB3G7HAGTG" + ); + } +} diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/mod.rs index 925f6d280..f3e64555e 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/mod.rs @@ -37,6 +37,7 @@ pub mod admission; pub mod conformance; pub mod derive; +pub mod escrow; pub mod exercise; pub mod facts; pub mod fisher_yates; diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs index fffada9a2..07d4f420c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs @@ -147,8 +147,10 @@ impl From for crate::types::error::DsmError { } } -/// One verification, over exact bytes, under a declared algorithm. -fn verify_bytes( +/// One verification, over exact bytes, under a declared algorithm. Shared +/// with the escrow verdict's signatures (`sofi::escrow`), so every SoFi +/// signature is checked by this one rule. +pub(crate) fn verify_bytes( what: &'static str, alg: u16, key: &[u8], diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs index 5fd0707db..386ee166e 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs @@ -174,6 +174,19 @@ //! 3 `create_position` u64 · 4 `state` nested `0x004B`. //! `0x005B VaultCreation`: 1 `vault_id` · 2 `genesis_root` digest32 (`R_0`) · //! 3 `amount_a` u64 · 4 `amount_b` u64. +//! +//! Escrow vaults (SoFi Amendment S21, §19.9): +//! `0x0063 EscrowTerms`: 1 `token` digest32 (the held token's policy commit) +//! · 2 `external_commitment` digest32 (`Y`) · 3 `branches` `seq`, +//! 1..=16, strictly ascending by outcome bytes. A branch is 1 `outcome` +//! `u32 len ‖ bytes`, 1..=64 · 2 `signers` `seq<(signature_alg u16 ‖ key +//! u32 len ‖ bytes)>`, 1..=4, strictly ascending by those bytes · 3 +//! `recipient_genesis` · 4 `recipient_device_id`. +//! `0x0064 EscrowVerdict`: 1 `external_commitment` · 2 `table` +//! `seq<(outcome ‖ signers)>`, the outcome table, under the same bounds and +//! order · 3 `outcome` · 4 `signatures` `seq<(signer ‖ signature u32 len ‖ +//! bytes)>`, 1..=4, strictly ascending by signer. A verdict holding only some +//! of its outcome's signatures encodes; it occupies no cell. pub mod objects; diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs index d98b107d5..9d3c3323a 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs @@ -2477,3 +2477,424 @@ impl VaultCreation { finish(&c, v) } } + +// ── 0x0063 EscrowTerms · 0x0064 EscrowVerdict (SoFi Amendment S21) ───────── + +/// The longest outcome label an escrow branch or verdict carries. +pub const ESCROW_MAX_OUTCOME_BYTES: usize = 64; +/// The most branches one escrow vault's terms carry. +pub const ESCROW_MAX_BRANCHES: usize = 16; +/// The most signers one outcome is decided by, and one verdict carries. +pub const ESCROW_MAX_SIGNERS: usize = 4; + +/// One signer of an escrow outcome: a declared algorithm and a key of its +/// width. Signers are ordered by [`EscrowSigner::canonical`], the bytes the +/// outcome table commits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowSigner { + signature_alg: u16, + public_key: Vec, +} + +impl EscrowSigner { + pub fn new(signature_alg: u16, public_key: &[u8]) -> Result { + check_key(signature_alg, public_key)?; + Ok(Self { + signature_alg, + public_key: public_key.to_vec(), + }) + } + + pub fn signature_alg(&self) -> u16 { + self.signature_alg + } + + pub fn public_key(&self) -> &[u8] { + &self.public_key + } + + /// `u16be(alg) ‖ u32be(|key|) ‖ key`: what a signer set is ordered by and + /// what the outcome table commits for it. + pub fn canonical(&self) -> Vec { + let mut out = Vec::with_capacity(6 + self.public_key.len()); + push_key(&mut out, self.signature_alg, &self.public_key); + out + } + + fn at(c: &mut Cursor<'_>) -> Result { + let (signature_alg, public_key) = read_key(c)?; + Ok(Self { + signature_alg, + public_key, + }) + } +} + +fn check_outcome(outcome: &[u8]) -> Result<(), SofiWireError> { + check_count("outcome bytes", 1, ESCROW_MAX_OUTCOME_BYTES, outcome.len()) +} + +fn check_signer_set(field: &'static str, signers: &[EscrowSigner]) -> Result<(), SofiWireError> { + check_count(field, 1, ESCROW_MAX_SIGNERS, signers.len())?; + let keys: Vec> = signers.iter().map(EscrowSigner::canonical).collect(); + check_strictly_ascending(field, &keys) +} + +fn read_signers(c: &mut Cursor<'_>, field: &'static str) -> Result, DecodeError> { + let n = read_count(c, field, 1, ESCROW_MAX_SIGNERS)?; + let signers: Vec = (0..n) + .map(|_| EscrowSigner::at(c)) + .collect::>()?; + check_signer_set(field, &signers).map_err(wire_invalid)?; + Ok(signers) +} + +/// One outcome and the exact signer set that decides it: an entry of the +/// outcome table (SoFi §19.9). Every signer of the set must sign; there is no +/// threshold, and a set with a duplicate or no signer has no encoding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowOutcome { + outcome: Vec, + signers: Vec, +} + +impl EscrowOutcome { + pub fn new(outcome: &[u8], signers: Vec) -> Result { + check_outcome(outcome)?; + check_signer_set("outcome signers", &signers)?; + Ok(Self { + outcome: outcome.to_vec(), + signers, + }) + } + + pub fn outcome(&self) -> &[u8] { + &self.outcome + } + + pub fn signers(&self) -> &[EscrowSigner] { + &self.signers + } + + fn push(&self, out: &mut Vec) { + push_part(out, &self.outcome); + push_u32(out, self.signers.len() as u32); + for signer in &self.signers { + push_key(out, signer.signature_alg, &signer.public_key); + } + } + + fn at(c: &mut Cursor<'_>) -> Result { + let outcome = read_var_bytes(c, ESCROW_MAX_OUTCOME_BYTES)?; + let signers = read_signers(c, "outcome signers")?; + Ok(Self { outcome, signers }) + } +} + +/// The outcome table `O`: an escrow vault's branches with their recipients +/// removed, strictly ascending by outcome. It is the verdict authority, and it +/// has exactly one encoding, so parties that agree on the same outcomes and +/// signers derive the same `τ` (SoFi §19.9, "Linked vaults"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutcomeTable { + entries: Vec, +} + +impl OutcomeTable { + pub fn new(entries: Vec) -> Result { + check_count("outcome table", 1, ESCROW_MAX_BRANCHES, entries.len())?; + let labels: Vec<&[u8]> = entries.iter().map(EscrowOutcome::outcome).collect(); + check_strictly_ascending("outcome table", &labels)?; + Ok(Self { entries }) + } + + pub fn entries(&self) -> &[EscrowOutcome] { + &self.entries + } + + /// The exact signer set that decides `outcome`, when the table has it. + pub fn signers_of(&self, outcome: &[u8]) -> Option<&[EscrowSigner]> { + self.entries + .iter() + .find(|entry| entry.outcome == outcome) + .map(EscrowOutcome::signers) + } + + /// `u8(|O|) ‖ ⨁ (u32be(|o|) ‖ o ‖ u8(|signers|) ‖ ⨁ signer)`: what `τ` + /// hashes. Its own encoding, not the CCB one: the count bounds fix every + /// width, so a table has exactly these bytes. + pub fn digest_preimage(&self) -> Vec { + let mut out = Vec::new(); + // At most ESCROW_MAX_BRANCHES entries and ESCROW_MAX_SIGNERS signers, + // so both counts fit a byte. + out.push(self.entries.len() as u8); + for entry in &self.entries { + push_part(&mut out, &entry.outcome); + out.push(entry.signers.len() as u8); + for signer in &entry.signers { + push_key(&mut out, signer.signature_alg, &signer.public_key); + } + } + out + } + + fn push(&self, out: &mut Vec) { + push_u32(out, self.entries.len() as u32); + for entry in &self.entries { + entry.push(out); + } + } + + fn at(c: &mut Cursor<'_>) -> Result { + let n = read_count(c, "outcome table", 1, ESCROW_MAX_BRANCHES)?; + let entries: Vec = (0..n) + .map(|_| EscrowOutcome::at(c)) + .collect::>()?; + Self::new(entries).map_err(wire_invalid) + } +} + +/// One branch of an escrow vault's terms: an outcome, the exact signer set +/// that decides it, and the identity it pays. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowBranch { + decided: EscrowOutcome, + recipient_genesis: D32, + recipient_device_id: D32, +} + +impl EscrowBranch { + pub fn new(decided: EscrowOutcome, recipient_genesis: D32, recipient_device_id: D32) -> Self { + Self { + decided, + recipient_genesis, + recipient_device_id, + } + } + + pub fn outcome(&self) -> &[u8] { + self.decided.outcome() + } + + pub fn signers(&self) -> &[EscrowSigner] { + self.decided.signers() + } + + pub fn recipient_genesis(&self) -> &D32 { + &self.recipient_genesis + } + + pub fn recipient_device_id(&self) -> &D32 { + &self.recipient_device_id + } +} + +/// `0x0063 EscrowTerms` — what an escrow vault's three policy slots name (SoFi +/// §19.9): the held token, the external commitment `Y`, and the branches, +/// strictly ascending by outcome. The vault releases its whole amount once, +/// along the branch whose outcome the canonical verdict names. Nothing is +/// chosen at release. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowTerms { + token: D32, + external_commitment: D32, + branches: Vec, +} + +impl EscrowTerms { + pub fn new( + token: D32, + external_commitment: D32, + branches: Vec, + ) -> Result { + check_count("escrow branches", 1, ESCROW_MAX_BRANCHES, branches.len())?; + let labels: Vec<&[u8]> = branches.iter().map(EscrowBranch::outcome).collect(); + check_strictly_ascending("escrow branches", &labels)?; + Ok(Self { + token, + external_commitment, + branches, + }) + } + + /// The policy commit of the held token. + pub fn token(&self) -> &D32 { + &self.token + } + + /// `Y = H(DSM/external/v1 ‖ X)`. + pub fn external_commitment(&self) -> &D32 { + &self.external_commitment + } + + pub fn branches(&self) -> &[EscrowBranch] { + &self.branches + } + + /// The branch whose outcome is `outcome`, when the terms have one. + pub fn branch(&self, outcome: &[u8]) -> Option<&EscrowBranch> { + self.branches.iter().find(|b| b.outcome() == outcome) + } + + /// The outcome table: the branches with their recipients removed, in + /// branch order, so it is canonical whenever the terms are. + pub fn outcome_table(&self) -> OutcomeTable { + OutcomeTable { + entries: self.branches.iter().map(|b| b.decided.clone()).collect(), + } + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + push_env(&mut out, class::ESCROW_TERMS); + push_digest32(&mut out, &self.token); + push_digest32(&mut out, &self.external_commitment); + push_u32(&mut out, self.branches.len() as u32); + for branch in &self.branches { + branch.decided.push(&mut out); + push_digest32(&mut out, &branch.recipient_genesis); + push_digest32(&mut out, &branch.recipient_device_id); + } + out + } + + pub fn decode(bytes: &[u8]) -> Result { + let mut c = Cursor { b: bytes, i: 0 }; + c.envelope(class::ESCROW_TERMS, SCHEMA_V1)?; + let token = c.digest32()?; + let external_commitment = c.digest32()?; + let n = read_count(&mut c, "escrow branches", 1, ESCROW_MAX_BRANCHES)?; + let mut branches = Vec::with_capacity(n); + for _ in 0..n { + let decided = EscrowOutcome::at(&mut c)?; + branches.push(EscrowBranch { + decided, + recipient_genesis: c.digest32()?, + recipient_device_id: c.digest32()?, + }); + } + let v = Self::new(token, external_commitment, branches).map_err(wire_invalid)?; + finish(&c, v) + } +} + +/// One signature of a verdict: its signer and the signature over the +/// statement `m(o)` for the verdict cell. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerdictSignature { + signer: EscrowSigner, + signature: Vec, +} + +impl VerdictSignature { + /// Refuses an empty or oversized signature. It verifies nothing. + pub fn new(signer: EscrowSigner, signature: &[u8]) -> Result { + if signature.is_empty() || signature.len() > MAX_SIGNATURE_BYTES { + return Err(SofiWireError::ObjectTooLarge { + field: "verdict signature", + bytes: signature.len(), + max: MAX_SIGNATURE_BYTES, + }); + } + Ok(Self { + signer, + signature: signature.to_vec(), + }) + } + + pub fn signer(&self) -> &EscrowSigner { + &self.signer + } + + pub fn signature(&self) -> &[u8] { + &self.signature + } +} + +/// `0x0064 EscrowVerdict` — a verdict on an external commitment (SoFi §19.9): +/// `Y`, the outcome table, the outcome, and signatures over `m(outcome)` for +/// the verdict cell `Y` and the table derive. It carries its table so that it +/// proves its own authority for the cell from its own bytes; whether it does +/// is `sofi::escrow::recognize_verdict`'s, not this type's. A verdict holding +/// only some of an outcome's signatures has an encoding, because signatures +/// are gathered before one is written to the cell; it occupies nothing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowVerdict { + external_commitment: D32, + table: OutcomeTable, + outcome: Vec, + signatures: Vec, +} + +impl EscrowVerdict { + pub fn new( + external_commitment: D32, + table: OutcomeTable, + outcome: &[u8], + signatures: Vec, + ) -> Result { + check_outcome(outcome)?; + check_count( + "verdict signatures", + 1, + ESCROW_MAX_SIGNERS, + signatures.len(), + )?; + let keys: Vec> = signatures.iter().map(|s| s.signer.canonical()).collect(); + check_strictly_ascending("verdict signatures", &keys)?; + Ok(Self { + external_commitment, + table, + outcome: outcome.to_vec(), + signatures, + }) + } + + pub fn external_commitment(&self) -> &D32 { + &self.external_commitment + } + + pub fn table(&self) -> &OutcomeTable { + &self.table + } + + pub fn outcome(&self) -> &[u8] { + &self.outcome + } + + pub fn signatures(&self) -> &[VerdictSignature] { + &self.signatures + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + push_env(&mut out, class::ESCROW_VERDICT); + push_digest32(&mut out, &self.external_commitment); + self.table.push(&mut out); + push_part(&mut out, &self.outcome); + push_u32(&mut out, self.signatures.len() as u32); + for s in &self.signatures { + push_key(&mut out, s.signer.signature_alg, &s.signer.public_key); + push_part(&mut out, &s.signature); + } + out + } + + pub fn decode(bytes: &[u8]) -> Result { + let mut c = Cursor { b: bytes, i: 0 }; + c.envelope(class::ESCROW_VERDICT, SCHEMA_V1)?; + let external_commitment = c.digest32()?; + let table = OutcomeTable::at(&mut c)?; + let outcome = read_var_bytes(&mut c, ESCROW_MAX_OUTCOME_BYTES)?; + let n = read_count(&mut c, "verdict signatures", 1, ESCROW_MAX_SIGNERS)?; + let mut signatures = Vec::with_capacity(n); + for _ in 0..n { + let signer = EscrowSigner::at(&mut c)?; + let signature = read_var_bytes(&mut c, MAX_SIGNATURE_BYTES)?; + signatures.push(VerdictSignature { signer, signature }); + } + let v = + Self::new(external_commitment, table, &outcome, signatures).map_err(wire_invalid)?; + finish(&c, v) + } +} From 0882b3a3dbf1009084c5a56d70526046d4c6656b Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:26:46 -0400 Subject: [PATCH 05/11] feat(escrow): release by the canonical verdict, and the escrow creation (SoFi S21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core of escrow vaults, per SoFi section 19.9. Release: - B° gains the Release branch (class 0x0065) and its route digest (0x0066): one leg, E in its single-leg form, naming the vault, the verdict cell and the outcome; no verdict enters E. - VaultTerms resolves a vault's slots to a market's policies or, when all three name one object, to its escrow terms (authenticated under the terms namespace). Close and Swap require a market, so against an escrow vault they are Invalid (TermsAreNotAMarket); a Release requires escrow terms (TermsAreNotEscrow). - validate_release: one leg; the vault's own derivation, pinned set, Active; the whole stake (reserve_a, reserve_b = 0); the cell the terms derive; a branch for the outcome that pays the trader; the closed write set with the vault retired; the realize root. - market_legs_permitted becomes vault_tokens_permitted and checks the escrow vault's one token too; close_vault_post becomes retire_vault_post. The verdict in resolution: - RouteFacts, GroundFacts and EstablishedFacts carry a VerdictFact. ConsumedRoute needs the verdict final on the release's outcome; RouteImpossible gains arm (v), VerdictOnAnotherOutcome, which skips the key without validation evidence; rung 5 voids a release that lost. - The verifier reads a Release's verdict cell beside its legs, keeps its completion proof when final, and reports an undecided cell as NotEstablished::VerdictCell. - An acquisition fetches a token policy the predicate named missing on the next round, which is how an escrow vault's token is fetched. Creation: - Operation variant 38, EscrowVaultCreate {genesis_preimage, creation, terms, signature}, signed over the operation like SofiVaultCreate; egress, ClosedWriteSet, and verified in DeviceState::advance. - Its write set: one debit of the stake of the terms' token and the creation record, insert-only; the terms must be the object all three slots name. Conservation holds the deltas to that one debit. - GenesisAccepted has its escrow form (GenesisTerms::Escrow); vaults_of_token passes over escrow vaults, and vaults_of_cell finds the vaults bound to a verdict cell under escrow_cell_locator. - Publication: the terms, the escrow genesis (indexed by vault and by cell), and gathered verdicts (by statement). History: TX_TYPE_ESCROW_LOCK and TX_TYPE_ESCROW_RELEASE, the SDK's Realized kinds and the wallet's labels; the frontend proto regenerated. Tests (release): dsm --lib sofi/economic/types 619 passed, then the new escrow tests: 9 release validation, 3 ladder, 5 write set, 4 genesis acceptance, 2 publication, 1 conservation; sofi_v8_operations covers tag 38's signature arms and round trip. dsm_sdk wallet_routes 26/26. Frontend mapper and wallet tests 35/35, tsc clean. clippy -D warnings clean on dsm and dsm_sdk; the real-code guard clean; conformance_evidence clean. CONFORMANCE section renumbered 6.74 (6.72 and 6.73 are taken by #1112 and #1113). --- .../dsm/src/economic/classifier.rs | 2 +- .../dsm/src/economic/write_set.rs | 425 +++++++++- .../dsm/src/sofi/derive.rs | 8 +- .../dsm/src/sofi/facts.rs | 47 +- .../dsm/src/sofi/lineage.rs | 316 +++++++- .../dsm/src/sofi/publication.rs | 172 +++- .../dsm/src/sofi/resolution.rs | 166 +++- .../dsm/src/sofi/resolve.rs | 153 +++- .../dsm/src/sofi/signature.rs | 9 + .../dsm/src/sofi/validation.rs | 755 ++++++++++++++++-- .../dsm/src/sofi/wire/mod.rs | 14 + .../dsm/src/sofi/wire/objects.rs | 112 ++- .../dsm/src/types/device_state.rs | 93 +++ .../dsm/src/types/operations.rs | 54 +- .../dsm/tests/sofi_v8_operations.rs | 14 +- .../dsm_sdk/src/handlers/node_e2e_tests.rs | 12 +- .../dsm_sdk/src/handlers/wallet_routes.rs | 6 + .../dsm_sdk/src/sdk/realized_records.rs | 7 + .../dsm_sdk/src/sdk/sofi_advance.rs | 1 + .../dsm_sdk/src/sdk/sofi_flow.rs | 4 +- .../dsm_sdk/src/sdk/sofi_reads.rs | 19 + .../src/components/screens/wallet/helpers.ts | 4 + dsm_client/frontend/src/domain/mappers.ts | 4 + dsm_client/frontend/src/domain/types.ts | 4 +- dsm_client/frontend/src/proto/dsm_app_pb.ts | 18 + proto/dsm_app.proto | 3 + specs/requirements/CONFORMANCE_GAPS.md | 64 +- specs/requirements/INTENT_MANIFEST.tsv | 6 +- specs/requirements/MASTER_REQUIREMENTS.md | 2 +- 29 files changed, 2341 insertions(+), 153 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/classifier.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/classifier.rs index 8059676f7..674340e03 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/classifier.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/classifier.rs @@ -118,7 +118,7 @@ pub fn classify(operation: &Operation) -> EconomicEffect { // Creation debits the funding into a vault's reserves, and a // fulfillment commits the trader's position: both move value under a // write set fixed by the operation's own preimage. - SofiVaultCreate { .. } | SofiFulfill { .. } => ClosedWriteSet, + SofiVaultCreate { .. } | SofiFulfill { .. } | EscrowVaultCreate { .. } => ClosedWriteSet, } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs index 5804bdbbe..9b1fde6c2 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs @@ -107,6 +107,12 @@ pub enum WriteSetError { /// non-canonical or duplicate legs, a zero leg, a vault id that is not /// 32 bytes, or a generation step that is not exactly one. MalformedVaultOperation { detail: &'static str }, + /// An escrow vault creation carries an object that does not decode: the + /// object, and why (SoFi Amendment S21). + MalformedEscrowObject { + object: &'static str, + reason: String, + }, /// A mutation or state constructor refused (zero-amount leaf, sibling /// arity, ...) — carried through from the CCB layer. Ccb(String), @@ -157,6 +163,10 @@ impl core::fmt::Display for WriteSetError { Self::MalformedVaultOperation { detail } => { write!(f, "the DLV operation cannot state a write set: {detail}") } + Self::MalformedEscrowObject { object, reason } => write!( + f, + "the escrow vault creation's {object} is not canonical: {reason}" + ), Self::Ccb(e) => write!(f, "write set: {e}"), } } @@ -270,6 +280,14 @@ enum SemanticWriteSet { leg_b: ([u8; 32], u64), creation: crate::sofi::wire::VaultCreation, }, + /// `EscrowVaultCreate` (SoFi Amendment S21): one balance debit, the + /// stake of the one token the terms name, and the creation record + /// inserted FROM ZERO, as ONE write set. + EscrowVaultCreate { + vault_id: [u8; 32], + stake: ([u8; 32], u64), + creation: crate::sofi::wire::VaultCreation, + }, /// `CreateToken` (SoFi §51, Amendment S8): the ERA fee debit, the /// creator's credit of the new token's whole genesis supply, and the /// creation record inserted FROM ZERO, as ONE write set. The credit's @@ -565,6 +583,21 @@ fn semantic_write_set( creation: record, }) } + // An ESCROW creation (SoFi Amendment S21): the same bindings as a + // market's, its stake the one debit, and the terms the ones all three + // of the genesis state's slots name. + Operation::EscrowVaultCreate { + genesis_preimage, + creation, + terms, + .. + } => escrow_creation_write_set( + genesis_preimage, + creation, + terms, + (local_genesis, local_devid), + economic_position, + ), other => match crate::economic::classifier::classify(other) { crate::economic::classifier::EconomicEffect::UnsupportedValueTransition => { Err(WriteSetError::UnsupportedValueTransition) @@ -574,6 +607,99 @@ fn semantic_write_set( } } +/// The write set of an escrow vault's creation (SoFi Amendment S21), every +/// conjunct a separate reason to refuse: the preimage is the owner's own and +/// agrees with itself, `p_create` is the position the operation lands at, +/// `R_0` is derived, the record names the vault the preimage derives and funds +/// exactly its stake, and the carried terms are the object all three of the +/// genesis state's slots name, whose token is the one debited. +fn escrow_creation_write_set( + genesis_preimage: &[u8], + creation: &[u8], + terms: &[u8], + (local_genesis, local_devid): (&[u8; 32], &[u8; 32]), + economic_position: u64, +) -> Result { + let malformed = |detail| WriteSetError::MalformedVaultOperation { detail }; + let preimage = + crate::sofi::wire::VaultGenesisPreimage::decode(genesis_preimage).map_err(|e| { + WriteSetError::MalformedEscrowObject { + object: "genesis preimage", + reason: e.to_string(), + } + })?; + let record = crate::sofi::wire::VaultCreation::decode(creation).map_err(|e| { + WriteSetError::MalformedEscrowObject { + object: "creation record", + reason: e.to_string(), + } + })?; + let parsed = crate::sofi::wire::EscrowTerms::decode(terms).map_err(|e| { + WriteSetError::MalformedEscrowObject { + object: "terms", + reason: e.to_string(), + } + })?; + if preimage.owner_genesis != *local_genesis || preimage.owner_device_id != *local_devid { + return Err(malformed("a creation debits its own owner's balances")); + } + let state = &preimage.state; + if state.owner_genesis != preimage.owner_genesis + || state.owner_device_id != preimage.owner_device_id + || state.create_position != preimage.create_position + { + return Err(malformed( + "the genesis state names different owner coordinates than the preimage it sits in", + )); + } + if preimage.create_position != economic_position { + return Err(malformed( + "the creation names a position other than the one it lands at", + )); + } + let vault_id = preimage.vault_id(); + if record.vault_id != vault_id { + return Err(malformed( + "the creation record names another vault than the preimage derives", + )); + } + // The stake IS the genesis reserve, held in `reserve_a`; an escrow vault + // holds nothing else. + if record.amount_a == 0 + || record.amount_a != state.reserve_a + || record.amount_b != 0 + || state.reserve_b != 0 + { + return Err(malformed( + "an escrow creation funds a non-zero stake in reserve_a and nothing else", + )); + } + let derived_root = crate::sofi::lineage::genesis_root(&vault_id, state).map_err(|e| { + WriteSetError::MalformedEscrowObject { + object: "genesis state", + reason: e.to_string(), + } + })?; + if record.genesis_root != derived_root { + return Err(malformed( + "the creation record states a genesis root the state does not derive", + )); + } + // THE TERMS ARE THE ONES ALL THREE SLOTS NAME, re-addressed before + // anything is read out of them. + let addr = crate::sofi::escrow::terms_address_of(terms); + if state.market_policy != addr || state.fee_policy != addr || state.release_policy != addr { + return Err(malformed( + "the carried terms are not the object all three of the genesis state's slots name", + )); + } + Ok(SemanticWriteSet::EscrowVaultCreate { + vault_id, + stake: (*parsed.token(), record.amount_a), + creation: record, + }) +} + fn balance_state( policy_commit: [u8; 32], amount: u64, @@ -791,6 +917,43 @@ pub fn build_write_set( source: None, }); } + // SoFi Amendment S21: the stake's debit and the record, as one write + // set. + SemanticWriteSet::EscrowVaultCreate { + vault_id, + stake, + creation, + } => { + if *facts != CreditSourceFacts::None { + return Err(WriteSetError::FactsDoNotMatchOperation); + } + planned.push(plan_balance_debit( + genesis, + device_id, + pre_balances, + stake.0, + stake.1, + )?); + if creation.vault_id != vault_id { + return Err(WriteSetError::WrongWriteSet { + detail: "the creation record names another vault than the operation", + }); + } + let state = EconomicLeafState::VaultCreation(creation); + let key = state.leaf_key(genesis, device_id); + // Insert-only: a vault id is created once (P15-12). + if tree.get(&key).is_some() { + return Err(WriteSetError::WrongWriteSet { + detail: "a creation record for this vault already exists", + }); + } + planned.push(PlannedLeaf { + key, + pre: None, + post: Some(state), + source: None, + }); + } // SoFi §51: the ERA fee debit and the whole genesis supply credited // to the creator, as one write set. SemanticWriteSet::CreateTokenRelease { fee, release } => { @@ -961,7 +1124,10 @@ pub fn verify_operation_write_set( // a creation record, or a creation carrying a relationship leaf, is // refused here by class. let relationships_legal = matches!(semantic, SemanticWriteSet::SofiSetup { .. }); - let creations_legal = matches!(semantic, SemanticWriteSet::SofiVaultCreate { .. }); + let creations_legal = matches!( + semantic, + SemanticWriteSet::SofiVaultCreate { .. } | SemanticWriteSet::EscrowVaultCreate { .. } + ); let token_creations_legal = matches!(semantic, SemanticWriteSet::CreateTokenRelease { .. }); let mut balances: Vec = Vec::new(); let mut consumed: Vec<(u32, EconomicConsumedSourceState)> = Vec::new(); @@ -1234,6 +1400,46 @@ pub fn verify_operation_write_set( } Ok(()) } + // SoFi Amendment S21: the stake's debit and the record, and nothing + // else. + SemanticWriteSet::EscrowVaultCreate { + vault_id, + stake, + creation, + } => { + if !consumed.is_empty() { + return Err(WriteSetError::WrongWriteSet { + detail: "a creation consumes no external source: it is funded from the \ + owner's own balances", + }); + } + if balances.len() != 1 || creations.len() != 1 || witness.mutations.len() != 2 { + return Err(WriteSetError::WrongWriteSet { + detail: "an escrow creation is exactly one balance debit and one creation \ + record", + }); + } + let observed = expect_one_balance(&balances, stake.0)?; + let expected = + observed + .pre_amount + .checked_sub(stake.1) + .ok_or(WriteSetError::WrongWriteSet { + detail: "the stake's debit underflows the owner's balance", + })?; + if observed.post_amount != expected { + return Err(WriteSetError::WrongWriteSet { + detail: "the debit is not the stake", + }); + } + let (_, c) = &creations[0]; + if *c != creation || c.vault_id != vault_id { + return Err(WriteSetError::WrongWriteSet { + detail: "the creation record is not the one the operation carries", + }); + } + Ok(()) + } // SoFi §51: the fee debit, if any, and the release credit, funded by // the one genesis-release source. The source's arm establishes what // the release funds; this layer pins that the witness is exactly this @@ -1705,3 +1911,220 @@ mod vault_create_binding_tests { ); } } + +#[cfg(test)] +mod escrow_create_binding_tests { + //! An escrow vault's creation (SoFi Amendment S21): each test breaks + //! exactly one binding of a valid creation and names the rule that + //! refuses it. + use super::*; + use crate::economic::tree::EconomicSmt; + use crate::sofi::wire::{ + EscrowBranch, EscrowOutcome, EscrowSigner, EscrowTerms, VaultCreation, + VaultGenesisPreimage, VaultStateLeaf, VAULT_STATUS_ACTIVE, + }; + + const G: [u8; 32] = [0x11; 32]; + const DEV: [u8; 32] = [0x22; 32]; + const POS: u64 = 7; + const STAKE: u64 = 2_500; + const TOKEN: [u8; 32] = [0x40; 32]; + + fn terms() -> EscrowTerms { + let referee = EscrowSigner::new(crate::ccb::sigalg::SPHINCS_PLUS_SPX256F, &[0x5A; 64]) + .expect("a declared key"); + EscrowTerms::new( + TOKEN, + crate::sofi::escrow::external_commitment(b"a match"), + vec![EscrowBranch::new( + EscrowOutcome::new(b"void", vec![referee]).expect("an outcome"), + G, + DEV, + )], + ) + .expect("terms") + } + + /// The genesis state of an escrow vault whose slots all name `addr`. + fn state(addr: [u8; 32]) -> VaultStateLeaf { + VaultStateLeaf { + owner_genesis: G, + owner_device_id: DEV, + create_position: POS, + market_policy: addr, + fee_policy: addr, + release_policy: addr, + storage_set_id: [0x77; 32], + generation: 0, + reserve_a: STAKE, + reserve_b: 0, + status: VAULT_STATUS_ACTIVE, + } + } + + /// The creation from its parts, with the record funding `amounts` and the + /// record's root and vault the ones the preimage derives. + fn op_from(state: &VaultStateLeaf, terms_bytes: &[u8], amounts: (u64, u64)) -> Operation { + let preimage = VaultGenesisPreimage { + owner_genesis: G, + owner_device_id: DEV, + create_position: POS, + state: state.clone(), + }; + let vault_id = preimage.vault_id(); + let creation = VaultCreation { + vault_id, + genesis_root: crate::sofi::lineage::genesis_root(&vault_id, state).expect("a root"), + amount_a: amounts.0, + amount_b: amounts.1, + }; + Operation::EscrowVaultCreate { + genesis_preimage: preimage.encode().expect("a preimage"), + creation: creation.encode(), + terms: terms_bytes.to_vec(), + signature: vec![0xA1; 8], + } + } + + fn good() -> Operation { + let t = terms(); + op_from( + &state(crate::sofi::escrow::terms_address(&t)), + &t.encode(), + (STAKE, 0), + ) + } + + fn refusal(op: &Operation) -> String { + match semantic_write_set(op, &G, &DEV, POS) { + Err(e) => e.to_string(), + Ok(_) => panic!("expected a refusal, got a write set"), + } + } + + #[test] + fn an_escrow_creation_debits_its_stake_and_inserts_its_record() { + let op = good(); + assert_eq!( + crate::economic::classifier::classify(&op), + crate::economic::classifier::EconomicEffect::ClosedWriteSet + ); + let Ok(SemanticWriteSet::EscrowVaultCreate { + stake, creation, .. + }) = semantic_write_set(&op, &G, &DEV, POS) + else { + panic!("a valid escrow creation has a write set") + }; + assert_eq!( + stake, + (TOKEN, STAKE), + "the one debit is the stake of the terms' token" + ); + assert_eq!((creation.amount_a, creation.amount_b), (STAKE, 0)); + + // The witness the producer builds: the stake leaves the owner's + // balance and the record is inserted from nothing, and that is all. + let balances = BTreeMap::from([(TOKEN, STAKE + 40)]); + let mut tree = EconomicSmt::new(); + tree.insert( + crate::economic::keys::balance_key(&G, &DEV, &TOKEN), + EconomicLeafState::Balance(crate::economic::state::EconomicBalanceState { + policy_commit: TOKEN, + amount: STAKE + 40, + }) + .leaf_value() + .expect("a leaf"), + ); + let built = build_write_set( + &op, + &G, + &DEV, + &[0x0E; 32], + &EconomicPreState::new(&balances, POS), + &mut tree, + &CreditSourceFacts::None, + ) + .expect("a write set"); + assert_eq!(built.mutations.len(), 2); + let posts: Vec<&Option> = + built.mutations.iter().map(|m| &m.post_state).collect(); + assert!(posts.iter().any(|p| matches!( + p, + Some(EconomicLeafState::Balance(b)) if b.policy_commit == TOKEN && b.amount == 40 + ))); + assert!(posts.iter().any( + |p| matches!(p, Some(EconomicLeafState::VaultCreation(c)) if c.amount_a == STAKE) + )); + } + + #[test] + fn terms_other_than_the_ones_all_three_slots_name_are_refused() { + let t = terms(); + let addr = crate::sofi::escrow::terms_address(&t); + // Carried terms that are another object than the slots name. + let other = EscrowTerms::new([0x41; 32], *t.external_commitment(), t.branches().to_vec()) + .expect("terms"); + let op = op_from(&state(addr), &other.encode(), (STAKE, 0)); + assert!(refusal(&op).contains("all three of the genesis state's slots")); + // One slot naming something else is not an escrow vault. + let one_off = VaultStateLeaf { + fee_policy: [0x0F; 32], + ..state(addr) + }; + let op = op_from(&one_off, &t.encode(), (STAKE, 0)); + assert!(refusal(&op).contains("all three of the genesis state's slots")); + } + + #[test] + fn terms_that_do_not_decode_are_refused_by_name() { + let garbage = b"not escrow terms".to_vec(); + let op = op_from( + &state(crate::sofi::escrow::terms_address_of(&garbage)), + &garbage, + (STAKE, 0), + ); + assert!(matches!( + semantic_write_set(&op, &G, &DEV, POS), + Err(WriteSetError::MalformedEscrowObject { + object: "terms", + .. + }) + )); + } + + #[test] + fn a_creation_funding_anything_but_its_stake_is_refused() { + let t = terms(); + let addr = crate::sofi::escrow::terms_address(&t); + let rule = "funds a non-zero stake in reserve_a and nothing else"; + // A record that funds less than the reserve. + assert!(refusal(&op_from(&state(addr), &t.encode(), (STAKE - 1, 0))).contains(rule)); + // A second reserve. + let two = VaultStateLeaf { + reserve_b: 5, + ..state(addr) + }; + assert!(refusal(&op_from(&two, &t.encode(), (STAKE, 5))).contains(rule)); + // No stake at all. + let empty = VaultStateLeaf { + reserve_a: 0, + ..state(addr) + }; + assert!(refusal(&op_from(&empty, &t.encode(), (0, 0))).contains(rule)); + } + + #[test] + fn a_creation_at_another_position_or_by_another_owner_is_refused() { + let op = good(); + assert!(matches!( + semantic_write_set(&op, &G, &DEV, POS + 1), + Err(WriteSetError::MalformedVaultOperation { detail }) + if detail.contains("position other than the one it lands at") + )); + assert!(matches!( + semantic_write_set(&op, &G, &[0x23; 32], POS), + Err(WriteSetError::MalformedVaultOperation { detail }) + if detail.contains("its own owner's balances") + )); + } +} diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/derive.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/derive.rs index 817edea7a..07fe92cca 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/derive.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/derive.rs @@ -391,6 +391,12 @@ pub fn canonical_legs(preimage: &SettlementPreimage) -> Result (*vault_id, *parent_root, *setup_ref), }; return Ok(vec![RouteLegEntry { @@ -414,7 +420,7 @@ pub fn canonical_legs(preimage: &SettlementPreimage) -> Result { + SettlementBody::Close { .. } | SettlementBody::Release { .. } => { return Err(SofiWireError::Cardinality { field: "close legs", min: 1, diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs index ccd3f0931..a8a563436 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs @@ -34,14 +34,15 @@ use super::conformance::{ Validation, }; use super::derive; +use super::escrow::VerdictCellRead; use super::exercise::{AttemptCellRead, RecognizedExercise}; use super::registration::{PairStanding, RegistrationRead}; use super::resolution::{ AttemptWalk, GroundRouteFacts, LegFacts, ParentPosition, ParentStatus, RefutedInHand, - RouteFacts, VaultChain, + RouteFacts, VaultChain, VerdictFact, }; use super::validation::{route_invalid_in_hand, route_validation, vault_post_states, Evidence, Missing}; -use super::wire::{ParentClaimRef, SettlementPreimage}; +use super::wire::{ParentClaimRef, SettlementBody, SettlementPreimage}; type D32 = [u8; 32]; @@ -115,6 +116,12 @@ pub enum NotEstablished { /// A leg's earlier keys were not all classified: past the walk budget or /// the chain depth, or a key among them is not resolved. AttemptLiveness { vault_id: D32, attempt: u64 }, + /// A Release's verdict cell is not decided by its reads yet (SoFi + /// Amendment S21). + VerdictCell { + verdict_cell: D32, + missing: CellMissing, + }, /// The reads handed over are not about this exercise: a registration of /// another position, a cell of another key, evidence of another /// operation. Not a network status — the caller mis-assembled them — @@ -153,6 +160,9 @@ pub struct ExerciseReads<'a> { pub parent: Option, /// One entry per leg of `P`, in P's leg order. pub legs: &'a [LegReads<'a>], + /// For a Release, its verdict cell as Core read it (SoFi Amendment S21); + /// `None` for every other operation. + pub verdict: Option<&'a VerdictCellRead>, } /// What this verifier established about the trader's lineage at `p`: the @@ -224,6 +234,8 @@ pub struct GroundReads<'a> { pub registration: &'a RegistrationRead, pub parent: Option, pub legs: &'a [LegReads<'a>], + /// For a Release, its verdict cell (SoFi Amendment S21). + pub verdict: Option<&'a VerdictCellRead>, } /// The facts of one exercise that stand without its validation evidence: @@ -240,6 +252,8 @@ pub struct GroundFacts { pub(crate) pair: PairStanding, pub(crate) parent: ParentPosition, pub(crate) parent_pre_root: D32, + /// A Release's standing at its verdict cell (SoFi Amendment S21). + pub(crate) verdict: VerdictFact, /// One per leg of `P`, in P's leg order. pub(crate) legs: Vec, /// The key each leg's facts are about: `(vault, parent root, attempt)`. @@ -257,6 +271,7 @@ impl GroundFacts { pair: self.pair, parent: self.parent, parent_pre_root: self.parent_pre_root, + verdict: self.verdict, legs: &self.legs, } } @@ -290,6 +305,8 @@ pub struct EstablishedFacts { pub(crate) parent_pre_root: D32, pub(crate) validation: Validation, pub(crate) storage_resolved: bool, + /// A Release's standing at its verdict cell (SoFi Amendment S21). + pub(crate) verdict: VerdictFact, /// One per leg of `P`, in P's leg order. pub(crate) legs: Vec, /// The key each leg's facts are about: `(vault, parent root, attempt)`. @@ -327,6 +344,7 @@ impl EstablishedFacts { parent_pre_root: self.parent_pre_root, validation: self.validation, storage_resolved: self.storage_resolved, + verdict: self.verdict, legs: &self.legs, } } @@ -441,6 +459,7 @@ pub fn establish(reads: &ExerciseReads<'_>) -> Result) -> Result) -> Result { + let read = reads.verdict.ok_or(NotEstablished::NotThisExercise( + "a release is read with its verdict cell", + ))?; + if read.key() != verdict_cell { + return Err(NotEstablished::NotThisExercise( + "the verdict cell read is not the one the release names", + )); + } + VerdictFact::Release(read.standing_for(outcome)) + } + SettlementBody::Swap { .. } | SettlementBody::Close { .. } => VerdictFact::NotARelease, + }; Ok(GroundFacts { external_commitment: e, pair, parent, parent_pre_root: *precommit.void_root(), + verdict, legs, keys, }) @@ -707,6 +748,7 @@ mod tests { evidence: &self.evidence, parent: self.parent, legs, + verdict: None, }) } } @@ -1373,6 +1415,7 @@ mod tests { parent_pre_root: root, validation: Validation::Invalid, storage_resolved: true, + verdict: VerdictFact::NotARelease, legs: &rejected_legs, }, rejected, diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs index 3a3281089..bf055cf85 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs @@ -554,16 +554,25 @@ impl core::fmt::Display for GenesisError { impl std::error::Error for GenesisError {} +/// What an accepted genesis commits its vault to: a market, or escrow terms +/// (SoFi Amendment S21). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GenesisTerms { + Market(crate::ccb::state::MarketPolicy), + Escrow(super::wire::EscrowTerms), +} + /// A vault genesis a verifier accepted (SoFi §19.8 `GenesisAccepted`): the /// genesis preimage that the owner's validated transition at `p_create` -/// carried, with the market it commits. Only [`genesis_accepted`] constructs -/// it, and a walk of a vault starts from nothing else (§30 step 1). +/// carried, with the market or the escrow terms it commits. Only +/// [`genesis_accepted`] constructs it, and a walk of a vault starts from +/// nothing else (§30 step 1). #[derive(Debug, Clone, PartialEq, Eq)] pub struct AcceptedVaultGenesis { vault_id: D32, preimage: VaultGenesisPreimage, genesis_root: D32, - market: crate::ccb::state::MarketPolicy, + terms: GenesisTerms, } impl AcceptedVaultGenesis { @@ -587,9 +596,25 @@ impl AcceptedVaultGenesis { &self.genesis_root } - /// The market policy `V_0` commits. - pub fn market(&self) -> &crate::ccb::state::MarketPolicy { - &self.market + /// What `V_0` commits the vault to. + pub fn terms(&self) -> &GenesisTerms { + &self.terms + } + + /// The market policy `V_0` commits; an escrow vault has none. + pub fn market(&self) -> Option<&crate::ccb::state::MarketPolicy> { + match &self.terms { + GenesisTerms::Market(market) => Some(market), + GenesisTerms::Escrow(..) => None, + } + } + + /// The escrow terms `V_0` commits; a market vault has none. + pub fn escrow(&self) -> Option<&super::wire::EscrowTerms> { + match &self.terms { + GenesisTerms::Escrow(terms) => Some(terms), + GenesisTerms::Market(..) => None, + } } } @@ -630,6 +655,13 @@ pub enum GenesisInvalid { /// A token's policy refuses it as a market leg (§49: `transferable` /// binds vault creation). TokenNotTransferable { token: D32 }, + /// The carried escrow terms are not the object all three of `V_0`'s + /// slots name (SoFi Amendment S21). + EscrowTermsNotCommitted, + /// The carried escrow terms do not decode. + EscrowTermsDoNotDecode(crate::ccb::decode::DecodeError), + /// An escrow vault's `V_0` holds no stake, or holds a second reserve. + NotAnEscrowStake { reserve_a: u64, reserve_b: u64 }, } /// Why genesis acceptance has no answer yet, or its answer is no. @@ -681,6 +713,12 @@ struct OwnerCreation<'a> { operation: &'a crate::types::operations::Operation, } +/// The terms object the owner's creation carried beside its genesis. +enum Carried<'a> { + Market(&'a [u8]), + Escrow(&'a [u8]), +} + fn creation_accepted( network_id: &[u8], preimage_bytes: &[u8], @@ -696,13 +734,20 @@ fn creation_accepted( { return Err(invalid(GenesisInvalid::NotTheOwnersCreationPosition)); } - let crate::types::operations::Operation::SofiVaultCreate { - genesis_preimage, - market_policy_preimage, - .. - } = owner.operation - else { - return Err(invalid(GenesisInvalid::NotTheCreationTheOwnerMade)); + // The creation the owner made: a market's, carrying its market policy, + // or an escrow vault's, carrying its terms (SoFi Amendment S21). + let (genesis_preimage, carried) = match owner.operation { + crate::types::operations::Operation::SofiVaultCreate { + genesis_preimage, + market_policy_preimage, + .. + } => (genesis_preimage, Carried::Market(market_policy_preimage)), + crate::types::operations::Operation::EscrowVaultCreate { + genesis_preimage, + terms, + .. + } => (genesis_preimage, Carried::Escrow(terms)), + _ => return Err(invalid(GenesisInvalid::NotTheCreationTheOwnerMade)), }; if genesis_preimage.as_slice() != preimage_bytes { return Err(invalid(GenesisInvalid::NotTheCreationTheOwnerMade)); @@ -730,31 +775,58 @@ fn creation_accepted( let vault_id = preimage.vault_id(); let genesis_root = genesis_root(&vault_id, state).map_err(|_| invalid(GenesisInvalid::StateDoesNotEncode))?; - let committed = crate::ccb::decode::policy_object_address( - crate::ccb::class::MARKET_POLICY, - market_policy_preimage, - ); - if committed != Some(state.market_policy) { - return Err(invalid(GenesisInvalid::Market( - GenesisError::MarketPolicyIsNotTheCommittedOne, - ))); - } - // `token_a < token_b`: a `MarketPolicy` with an unordered pair does not - // decode. - let market = - crate::ccb::decode::decode_market_policy(market_policy_preimage).map_err(|_| { - invalid(GenesisInvalid::Market( - GenesisError::MarketPolicyDoesNotDecode, - )) - })?; - for token in [*market.token_a(), *market.token_b()] { - token_is_a_market_leg(&token, token_policies)?; - } + let terms = match carried { + Carried::Market(market_policy_preimage) => { + let committed = crate::ccb::decode::policy_object_address( + crate::ccb::class::MARKET_POLICY, + market_policy_preimage, + ); + if committed != Some(state.market_policy) { + return Err(invalid(GenesisInvalid::Market( + GenesisError::MarketPolicyIsNotTheCommittedOne, + ))); + } + // `token_a < token_b`: a `MarketPolicy` with an unordered pair does not + // decode. + let market = + crate::ccb::decode::decode_market_policy(market_policy_preimage).map_err(|_| { + invalid(GenesisInvalid::Market( + GenesisError::MarketPolicyDoesNotDecode, + )) + })?; + for token in [*market.token_a(), *market.token_b()] { + token_is_a_market_leg(&token, token_policies)?; + } + GenesisTerms::Market(market) + } + // An escrow vault (SoFi Amendment S21): its three slots name the + // carried terms, it holds a stake and nothing else, and the token + // its terms name passes its policy for a transfer (§49). + Carried::Escrow(terms_bytes) => { + let addr = super::escrow::terms_address_of(terms_bytes); + if state.market_policy != addr + || state.fee_policy != addr + || state.release_policy != addr + { + return Err(invalid(GenesisInvalid::EscrowTermsNotCommitted)); + } + let terms = super::wire::EscrowTerms::decode(terms_bytes) + .map_err(|e| invalid(GenesisInvalid::EscrowTermsDoNotDecode(e)))?; + if state.reserve_a == 0 || state.reserve_b != 0 { + return Err(invalid(GenesisInvalid::NotAnEscrowStake { + reserve_a: state.reserve_a, + reserve_b: state.reserve_b, + })); + } + token_is_a_market_leg(terms.token(), token_policies)?; + GenesisTerms::Escrow(terms) + } + }; Ok(AcceptedVaultGenesis { vault_id, preimage, genesis_root, - market, + terms, }) } @@ -955,6 +1027,7 @@ mod tests { Shape::Realized | Shape::Void => Validation::Valid, }, storage_resolved: true, + verdict: crate::sofi::resolution::VerdictFact::NotARelease, legs, keys, preimage: preimage.clone(), @@ -1175,7 +1248,8 @@ mod tests { exact_out, .. } => (*token_in, *token_out, *exact_out), - crate::sofi::wire::SettlementBody::Close { .. } => { + crate::sofi::wire::SettlementBody::Close { .. } + | crate::sofi::wire::SettlementBody::Release { .. } => { panic!("the realized rig is a swap") } } @@ -1844,7 +1918,8 @@ mod tests { let f = fulfillment(&p); let hops = match fx.preimage.settlement() { crate::sofi::wire::SettlementBody::Swap { hops, .. } => hops.clone(), - crate::sofi::wire::SettlementBody::Close { .. } => unreachable!("a swap fixture"), + crate::sofi::wire::SettlementBody::Close { .. } + | crate::sofi::wire::SettlementBody::Release { .. } => unreachable!("a swap fixture"), }; assert_eq!(hops.len(), 2); let intermediate = hops[0].token_out; @@ -2052,7 +2127,10 @@ pub(crate) mod genesis_acceptance { &genesis_root(&preimage.vault_id(), &preimage.state).unwrap() ); assert_eq!(accepted.state(), &preimage.state); - assert_eq!(accepted.market().token_a(), &tokens()[0].0); + assert_eq!( + accepted.market().expect("a market vault").token_a(), + &tokens()[0].0 + ); } /// Fetched bytes are accepted only as the bytes the owner's creation @@ -2204,4 +2282,168 @@ pub(crate) mod genesis_acceptance { .retain(|commit, _| *commit == tokens()[0].0); assert!(accept(&c).is_ok()); } + // ── escrow vaults (SoFi Amendment S21) ──────────────────────────────── + + /// Terms over `token`: one "void" branch the referee decides, paying the + /// owner. + fn escrow_terms_over(token: D32) -> crate::sofi::wire::EscrowTerms { + let referee = crate::sofi::wire::EscrowSigner::new( + crate::ccb::sigalg::SPHINCS_PLUS_SPX256F, + &[0x5A; 64], + ) + .unwrap(); + crate::sofi::wire::EscrowTerms::new( + token, + crate::sofi::escrow::external_commitment(b"a match"), + vec![crate::sofi::wire::EscrowBranch::new( + crate::sofi::wire::EscrowOutcome::new(b"void", vec![referee]).unwrap(), + G, + dev(), + )], + ) + .unwrap() + } + + /// `V_0` of an escrow vault whose three slots name `addr`. + fn escrow_state(addr: D32, reserve_a: u64, reserve_b: u64) -> VaultStateLeaf { + VaultStateLeaf { + owner_genesis: G, + owner_device_id: dev(), + create_position: P_CREATE, + market_policy: addr, + fee_policy: addr, + release_policy: addr, + storage_set_id: pinned_set(), + generation: 0, + reserve_a, + reserve_b, + status: VAULT_STATUS_ACTIVE, + } + } + + /// The escrow creation of `state`, carrying `terms_bytes`, as the owner + /// signs it. + fn escrow_creation_of(state: VaultStateLeaf, terms_bytes: Vec) -> Creation { + let preimage = VaultGenesisPreimage { + owner_genesis: G, + owner_device_id: dev(), + create_position: P_CREATE, + state, + }; + let vault_id = preimage.vault_id(); + let record = VaultCreation { + vault_id, + genesis_root: genesis_root(&vault_id, &preimage.state).unwrap(), + amount_a: preimage.state.reserve_a, + amount_b: preimage.state.reserve_b, + }; + let preimage_bytes = preimage.encode().unwrap(); + let build = |signature: Vec| Operation::EscrowVaultCreate { + genesis_preimage: preimage_bytes.clone(), + creation: record.encode(), + terms: terms_bytes.clone(), + signature, + }; + let signing = build(Vec::new()).signing_bytes(); + let operation = + build(crate::crypto::sphincs::sphincs_sign(&trader_keys().1, &signing).unwrap()); + Creation { + preimage_bytes, + operation, + token_policies: tokens().iter().cloned().collect(), + } + } + + fn escrow_valid() -> (Creation, crate::sofi::wire::EscrowTerms) { + let terms = escrow_terms_over(tokens()[0].0); + let state = escrow_state(crate::sofi::escrow::terms_address(&terms), 2_500, 0); + (escrow_creation_of(state, terms.encode()), terms) + } + + #[test] + fn an_escrow_genesis_the_owner_created_is_accepted_with_its_terms() { + let (c, terms) = escrow_valid(); + assert_eq!( + crate::sofi::signature::verify_operation(&c.operation, &trader_keys().0), + Ok(()), + "the escrow creation is signed under the owner's key" + ); + let accepted = accept(&c).expect("accepted"); + assert_eq!(accepted.escrow(), Some(&terms)); + assert_eq!(accepted.market(), None, "an escrow vault has no market"); + assert_eq!(accepted.state().reserve_a, 2_500); + } + + #[test] + fn an_escrow_genesis_is_refused_for_terms_its_slots_do_not_name() { + let terms = escrow_terms_over(tokens()[0].0); + let addr = crate::sofi::escrow::terms_address(&terms); + // The slots name one object and the creation carried another. + let other = escrow_terms_over(tokens()[1].0); + let c = escrow_creation_of(escrow_state(addr, 2_500, 0), other.encode()); + assert_eq!( + accept(&c), + Err(GenesisRefusal::Invalid( + GenesisInvalid::EscrowTermsNotCommitted + )) + ); + // One slot naming something else is no escrow vault. + let one_off = VaultStateLeaf { + release_policy: [0x0F; 32], + ..escrow_state(addr, 2_500, 0) + }; + let c = escrow_creation_of(one_off, terms.encode()); + assert_eq!( + accept(&c), + Err(GenesisRefusal::Invalid( + GenesisInvalid::EscrowTermsNotCommitted + )) + ); + // Bytes the slots name that are not escrow terms. + let garbage = b"no terms".to_vec(); + let c = escrow_creation_of( + escrow_state(crate::sofi::escrow::terms_address_of(&garbage), 2_500, 0), + garbage, + ); + assert!(matches!( + accept(&c), + Err(GenesisRefusal::Invalid( + GenesisInvalid::EscrowTermsDoNotDecode(..) + )) + )); + } + + #[test] + fn an_escrow_genesis_holding_a_second_reserve_or_no_stake_is_refused() { + let terms = escrow_terms_over(tokens()[0].0); + let addr = crate::sofi::escrow::terms_address(&terms); + for (a, b) in [(2_500, 7), (0, 0)] { + let c = escrow_creation_of(escrow_state(addr, a, b), terms.encode()); + assert_eq!( + accept(&c), + Err(GenesisRefusal::Invalid(GenesisInvalid::NotAnEscrowStake { + reserve_a: a, + reserve_b: b + })) + ); + } + } + + /// §49: the held token passes its policy for a transfer. + #[test] + fn an_escrow_vault_over_a_token_that_forbids_transfer_is_refused() { + let locked = committed(token_policy_bytes_with(9, 0)); + let terms = escrow_terms_over(locked.0); + let mut c = escrow_creation_of( + escrow_state(crate::sofi::escrow::terms_address(&terms), 2_500, 0), + terms.encode(), + ); + c.token_policies.insert(locked.0, locked.1); + assert_eq!( + accept(&c), + Err(GenesisRefusal::Invalid( + GenesisInvalid::TokenNotTransferable { token: locked.0 } + )) + ); + } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs index 1b44b2576..3baa0555c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/publication.rs @@ -36,14 +36,20 @@ use crate::common::domain_tags::{ TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR, TAG_DSM_SOFI_VAULT_GENESIS_OBJECT, TAG_DSM_SOFI_VAULT_TOKEN_LOCATOR, }; +use crate::common::domain_tags::{ + TAG_DSM_ESCROW_CELL_LOCATOR, TAG_DSM_ESCROW_STATEMENT_LOCATOR, TAG_DSM_ESCROW_TERMS_OBJECT, + TAG_DSM_ESCROW_VERDICT_OBJECT, +}; use crate::crypto::domain::TaggedHashDomain; use crate::storage_object::immutable_addr; use super::derive; +use super::escrow; use super::signature::{verify_fulfillment, verify_precommit, verify_setup}; use super::wire::{ - DlvPolicyFulfillmentBody, SettlementPreimage, SignedSofiObject, SofiSetupBody, SofiWireError, - TraderFulfillmentBody, TraderPreBalance, TraderPrecommitBody, VaultGenesisPreimage, + DlvPolicyFulfillmentBody, EscrowTerms, EscrowVerdict, SettlementPreimage, SignedSofiObject, + SofiSetupBody, SofiWireError, TraderFulfillmentBody, TraderPreBalance, TraderPrecommitBody, + VaultGenesisPreimage, }; type D32 = [u8; 32]; @@ -123,6 +129,22 @@ pub enum Publication<'a> { /// found by the address `𝒞_E^pre` names. The exercise carries it too; /// publishing it lets a reader that holds only the closure fetch it. TraderPreBalance(&'a TraderPreBalance), + /// An escrow vault's terms, bare, found by the address all three of its + /// state's slots name (SoFi Amendment S21). + EscrowTerms(&'a EscrowTerms), + /// An escrow vault's genesis preimage, bare, indexed under + /// `vault_genesis_locator(v)` and under the cell locator of the verdict + /// cell its terms bind it to, so a counterparty finds exactly the vaults + /// linked to its own (SoFi Amendment S21). Its acceptance binds it to the + /// owner's validated creation, so publishing it asserts nothing. + EscrowVaultGenesis { + preimage: &'a VaultGenesisPreimage, + terms: &'a EscrowTerms, + }, + /// A verdict holding the signatures gathered so far, bare, indexed under + /// the statement locator of its cell and outcome (SoFi Amendment S21). It + /// carries no authority: only a verdict recognized at the cell decides. + EscrowVerdict(&'a EscrowVerdict), } fn envelope( @@ -162,6 +184,9 @@ impl Publication<'_> { Self::VaultGenesis { preimage, .. } => preimage.encode(), Self::VaultPolicy { bytes, .. } => Ok(bytes.to_vec()), Self::TraderPreBalance(balance) => Ok(balance.encode()), + Self::EscrowTerms(terms) => Ok(terms.encode()), + Self::EscrowVaultGenesis { preimage, .. } => preimage.encode(), + Self::EscrowVerdict(verdict) => Ok(verdict.encode()), } } @@ -180,6 +205,11 @@ impl Publication<'_> { VaultPolicyClass::Release => TAG_DSM_RELEASE_POLICY_OBJECT, }, Self::TraderPreBalance(_) => TAG_DSM_SOFI_TRADER_PRE_BALANCE_OBJECT, + Self::EscrowTerms(_) => TAG_DSM_ESCROW_TERMS_OBJECT, + // An escrow vault's genesis is a vault genesis preimage like any + // other, found under the same namespace. + Self::EscrowVaultGenesis { .. } => TAG_DSM_SOFI_VAULT_GENESIS_OBJECT, + Self::EscrowVerdict(_) => TAG_DSM_ESCROW_VERDICT_OBJECT, } } @@ -246,7 +276,42 @@ impl Publication<'_> { token(market.token_b()), ] } - Self::VaultPolicy { .. } | Self::TraderPreBalance(_) => Vec::new(), + Self::EscrowVaultGenesis { preimage, terms } => { + // The cell a vault is found by is the one its slots commit it + // to: other terms would index it under a cell it is not + // bound to. + let addr = escrow::terms_address(terms); + let state = &preimage.state; + if state.market_policy != addr + || state.fee_policy != addr + || state.release_policy != addr + { + return Err(SofiWireError::EscrowTermsNotCommitted); + } + vec![ + Locator { + index_namespace: TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR.source_bytes(), + locator: derive::vault_genesis_locator(&preimage.vault_id()), + }, + Locator { + index_namespace: TAG_DSM_ESCROW_CELL_LOCATOR.source_bytes(), + locator: escrow::cell_locator(&escrow::verdict_cell_of(terms)), + }, + ] + } + Self::EscrowVerdict(verdict) => { + let cell = escrow::verdict_cell_key( + verdict.external_commitment(), + &escrow::table_digest(verdict.table()), + ); + vec![Locator { + index_namespace: TAG_DSM_ESCROW_STATEMENT_LOCATOR.source_bytes(), + locator: escrow::statement_locator(&cell, verdict.outcome()), + }] + } + Self::VaultPolicy { .. } | Self::TraderPreBalance(_) | Self::EscrowTerms(_) => { + Vec::new() + } }) } } @@ -676,4 +741,105 @@ mod tests { Err(SofiWireError::MarketNotCommitted) ); } + + // ── escrow vaults (SoFi Amendment S21) ──────────────────────────────── + + fn escrow_terms(token: u8) -> EscrowTerms { + let referee = crate::sofi::wire::EscrowSigner::new(ALG, &[0x5A; 64]).unwrap(); + EscrowTerms::new( + d(token), + escrow::external_commitment(b"a match"), + vec![crate::sofi::wire::EscrowBranch::new( + crate::sofi::wire::EscrowOutcome::new(b"void", vec![referee]).unwrap(), + G, + dev(), + )], + ) + .unwrap() + } + + fn escrow_genesis(terms: &EscrowTerms) -> VaultGenesisPreimage { + let addr = escrow::terms_address(terms); + VaultGenesisPreimage { + owner_genesis: G, + owner_device_id: dev(), + create_position: 7, + state: crate::sofi::wire::VaultStateLeaf { + owner_genesis: G, + owner_device_id: dev(), + create_position: 7, + market_policy: addr, + fee_policy: addr, + release_policy: addr, + storage_set_id: d(0x77), + generation: 0, + reserve_a: 2_500, + reserve_b: 0, + status: crate::sofi::wire::VAULT_STATUS_ACTIVE, + }, + } + } + + /// An escrow vault's genesis is found by its vault id and by the verdict + /// cell its terms bind it to, and its terms by the address its slots name. + #[test] + fn an_escrow_vault_is_found_by_its_verdict_cell() { + let terms = escrow_terms(0x40); + let genesis = escrow_genesis(&terms); + let published = Publication::EscrowVaultGenesis { + preimage: &genesis, + terms: &terms, + }; + assert_eq!( + published.locators().unwrap(), + vec![ + Locator { + index_namespace: TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR.source_bytes(), + locator: derive::vault_genesis_locator(&genesis.vault_id()), + }, + Locator { + index_namespace: TAG_DSM_ESCROW_CELL_LOCATOR.source_bytes(), + locator: escrow::cell_locator(&escrow::verdict_cell_of(&terms)), + }, + ] + ); + assert_eq!(published.object_bytes().unwrap(), genesis.encode().unwrap()); + assert_eq!( + Publication::EscrowTerms(&terms).address().unwrap(), + escrow::terms_address(&terms) + ); + // Terms other than the ones the slots name would index the vault + // under a cell it is not bound to. + let other = escrow_terms(0x41); + assert_eq!( + Publication::EscrowVaultGenesis { + preimage: &genesis, + terms: &other, + } + .locators(), + Err(SofiWireError::EscrowTermsNotCommitted) + ); + } + + /// A gathered verdict is indexed under its cell's statement locator for + /// its outcome. + #[test] + fn a_gathered_verdict_is_found_by_its_statement() { + let terms = escrow_terms(0x40); + let signer = crate::sofi::wire::EscrowSigner::new(ALG, &[0x5A; 64]).unwrap(); + let verdict = EscrowVerdict::new( + *terms.external_commitment(), + terms.outcome_table(), + b"void", + vec![crate::sofi::wire::VerdictSignature::new(signer, &[0x99; 3]).unwrap()], + ) + .unwrap(); + assert_eq!( + Publication::EscrowVerdict(&verdict).locators().unwrap(), + vec![Locator { + index_namespace: TAG_DSM_ESCROW_STATEMENT_LOCATOR.source_bytes(), + locator: escrow::statement_locator(&escrow::verdict_cell_of(&terms), b"void"), + }] + ); + } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs index 127a8db65..3a4caa168 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs @@ -414,6 +414,34 @@ impl LegFacts { } } +/// What a Release reads at its verdict cell (SoFi Amendment S21); every other +/// operation reads none. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerdictFact { + /// Not a Release: no verdict bears on the operation. + NotARelease, + /// A Release, and where it stands at its verdict cell. + Release(super::escrow::VerdictStanding), +} + +impl VerdictFact { + /// Whether the verdict lets a consumption count: an operation that is no + /// release reads none, and a release counts only once the cell's verdict + /// is final on its outcome (`ConsumedRoute`, SoFi §19.9). + fn permits_consumption(self) -> bool { + matches!( + self, + Self::NotARelease | Self::Release(super::escrow::VerdictStanding::Final) + ) + } + + /// `VerdictHeld(K, o′)` for another outcome: the release can never + /// realize (`RouteImpossible` arm (v)). + fn lost(self) -> bool { + self == Self::Release(super::escrow::VerdictStanding::Lost) + } +} + /// Everything a verifier needs about one trader position `q` and the /// fulfillment `F` that claims it. /// @@ -450,6 +478,8 @@ pub struct RouteFacts<'legs> { pub(crate) validation: Validation, /// `StorageResolved(q)`: registration and every successor key. pub(crate) storage_resolved: bool, + /// A Release's standing at its verdict cell (SoFi Amendment S21). + pub(crate) verdict: VerdictFact, /// One entry per leg of `P`, in P's leg order. A single-vault trade is the /// one-leg case. pub(crate) legs: &'legs [LegFacts], @@ -465,6 +495,9 @@ pub struct GroundRouteFacts<'legs> { pub(crate) pair: PairStanding, pub(crate) parent: ParentPosition, pub(crate) parent_pre_root: [u8; 32], + /// A Release's standing at its verdict cell: the cell's raw read and the + /// verdict's own bytes, no validation evidence (SoFi §19.9, arm (v)). + pub(crate) verdict: VerdictFact, pub(crate) legs: &'legs [LegFacts], } @@ -531,7 +564,8 @@ pub fn trader_parent_impossible(parent: &ParentPosition, parent_pre_root: &[u8; /// `ConsumedRoute(F, E)` (Section 23.2): registered, conforming, statically /// valid, built on the branch the parent actually took, and every required /// leg finally consumed its exact canonical parent on this `E` at a live -/// attempt. A single-vault trade is the one-leg case. +/// attempt. A single-vault trade is the one-leg case. A Release also needs +/// its verdict cell final on its outcome (SoFi Amendment S21). /// /// This is where parent canonicality lives. `RouteValidation` never looks at /// it, so a static verdict cannot depend on who won a race. And @@ -541,6 +575,7 @@ pub fn consumed_route(facts: &RouteFacts<'_>) -> bool { facts.pair == PairStanding::Registered && facts.conformance == Validation::Valid && facts.validation == Validation::Valid + && facts.verdict.permits_consumption() && trader_parent_compatible(&facts.parent, &facts.parent_pre_root) && !facts.legs.is_empty() && facts.legs.iter().all(|l| { @@ -566,6 +601,9 @@ pub enum ImpossibleArm { /// (iv) `TraderParentImpossible(P)`: the trader parent is terminal on the /// other branch, or on none. TraderParentImpossible, + /// (v) a Release whose verdict cell holds a verdict on another outcome + /// (SoFi Amendment S21). Permanent: the cell's leader keeps one value. + VerdictOnAnotherOutcome, } /// The arm of `RouteImpossible(P, E)` that holds, in arm order. @@ -593,6 +631,9 @@ pub fn route_impossible(facts: &RouteFacts<'_>) -> Option { if trader_parent_impossible(&facts.parent, &facts.parent_pre_root) { return Some(ImpossibleArm::TraderParentImpossible); } + if facts.verdict.lost() { + return Some(ImpossibleArm::VerdictOnAnotherOutcome); + } None } @@ -619,7 +660,7 @@ pub enum Incomplete { /// | 2 | Invalid | `FulfillmentConformance(F) = Invalid` | /// | 3 | Realized | `ConsumedRoute(F, E)` | /// | 4 | Invalid | `RouteValidation = Invalid` | -/// | 5 | Void | storage-resolved, and a reserved key or a parent is lost | +/// | 5 | Void | storage-resolved, and a reserved key, a parent, or a release's verdict is lost | /// | 6 | not yet (`Incomplete`) | otherwise: a required storage fact is not final | /// /// The predecessor's resolution is part of the complete facts: the caller @@ -656,8 +697,11 @@ pub(crate) fn resolve_position(facts: &RouteFacts<'_>) -> Result(legs: &'l [LegFacts], standing: VerdictStanding) -> RouteFacts<'l> { + RouteFacts { + verdict: VerdictFact::Release(standing), + ..realized(legs) + } + } + + use crate::sofi::escrow::VerdictStanding; + + /// A release whose leg consumed its parent realizes only once its verdict + /// cell is final on its outcome; unsettled, it is no result yet; lost to + /// another outcome, it is Void and nothing moves. + #[test] + fn a_release_realizes_only_on_the_verdict_final_on_its_outcome() { + let legs = [good_leg()]; + assert_eq!( + resolve_position(&release_facts(&legs, VerdictStanding::Final)), + Ok(Resolution::Realized) + ); + assert_eq!( + resolve_position(&release_facts(&legs, VerdictStanding::Unsettled)), + Err(Incomplete::StorageNotFinal) + ); + assert_eq!( + resolve_position(&release_facts(&legs, VerdictStanding::Lost)), + Ok(Resolution::Void) + ); + // Another operation reads no verdict, so the arm never touches it. + assert_eq!(resolve_position(&realized(&legs)), Ok(Resolution::Realized)); + } + + /// Arm (v): the release can never realize once another outcome holds the + /// cell, and that is attributable; the key it holds is skipped, so the + /// vault's next attempt goes live for the branch that won. + #[test] + fn a_release_that_lost_the_verdict_frees_its_key() { + let legs = [good_leg()]; + let lost = release_facts(&legs, VerdictStanding::Lost); + assert_eq!( + route_impossible(&lost), + Some(ImpossibleArm::VerdictOnAnotherOutcome) + ); + assert_eq!( + classify_attempt(&lost, &legs[0]), + ( + AttemptClass::Skipped, + Some(SkipReason::RejectedFinalRoute( + ImpossibleArm::VerdictOnAnotherOutcome + )) + ) + ); + // Decided without validation evidence: the walk skips the key on the + // cell's read and the verdict's own bytes. + assert_eq!( + skip_without_evidence(&ground_of(&lost), &legs[0]), + ( + AttemptClass::Skipped, + Some(SkipReason::RejectedFinalRoute( + ImpossibleArm::VerdictOnAnotherOutcome + )) + ) + ); + // Unsettled holds the key; final on its outcome consumes the parent. + let unsettled = release_facts(&legs, VerdictStanding::Unsettled); + assert_eq!(route_impossible(&unsettled), None); + assert_eq!( + classify_attempt(&unsettled, &legs[0]), + (AttemptClass::Unresolved, None) + ); + assert_eq!( + skip_without_evidence(&ground_of(&unsettled), &legs[0]), + (AttemptClass::Unresolved, None) + ); + assert_eq!( + classify_attempt(&release_facts(&legs, VerdictStanding::Final), &legs[0]), + (AttemptClass::Consumed, None) + ); + } + + /// An invalid release is Invalid whatever its verdict: the verdict decides + /// between Realized and Void for a valid release only. + #[test] + fn an_invalid_release_is_invalid_whatever_the_verdict() { + let legs = [good_leg()]; + for standing in [ + VerdictStanding::Final, + VerdictStanding::Unsettled, + VerdictStanding::Lost, + ] { + let facts = RouteFacts { + validation: Invalid, + ..release_facts(&legs, standing) + }; + assert_eq!(resolve_position(&facts), Ok(Resolution::Invalid)); + } + } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs index 62c1cf32b..ecc74e489 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs @@ -38,6 +38,7 @@ use super::conformance::{ Validation, }; use super::derive; +use super::escrow::{verdict_completion, verdict_resolution, VerdictCell, VerdictCellRead}; use super::exercise::{ attempt_completion, attempt_resolution, AttemptCell, AttemptCellRead, RecognizedExercise, }; @@ -64,7 +65,7 @@ use super::validation::{ SetupLineage, VaultLeafPre, VaultPostState, }; use super::wire::{ - CoreEntry, ParentClaimRef, SettlementPreimage, SofiResolutionClaim, TraderCore, + CoreEntry, ParentClaimRef, SettlementBody, SettlementPreimage, SofiResolutionClaim, TraderCore, TraderFulfillmentBody, TraderPreBalance, TraderPrecommitBody, ValidationRef, VaultGenesisPreimage, VaultStateLeaf, }; @@ -186,6 +187,12 @@ pub trait SofiReads { /// accepts each genesis and checks its market before it is a vault of /// `t`. fn vault_token_candidates(&self, token: &D32) -> Result, ReadFailure>; + /// The vault named by every preimage published under verdict cell + /// `verdict_cell`'s cell locator that decodes as a vault genesis + /// preimage, in append order (SoFi Amendment S21). Candidates only: + /// [`Verifier::vaults_of_cell`] accepts each genesis and checks its terms + /// derive the cell before it is a vault bound to it. + fn escrow_cell_candidates(&self, verdict_cell: &D32) -> Result, ReadFailure>; /// The owner's transition at its creation position, validated by the /// peer lineage walk. fn vault_owner( @@ -561,6 +568,8 @@ struct LegsRead { registration: RegistrationRead, cells: Vec, walks: Vec>, + /// For a Release, its verdict cell (SoFi Amendment S21). + verdict: Option, } impl LegsRead { @@ -629,8 +638,50 @@ impl Verifier<'_, R> { .map_err(|e| VerifierFailure::Refused(format!("position cells: {e:?}"))) } + /// The verdict cell at `verdict_cell`, routed over the set (SoFi + /// Amendment S21). + pub fn verdict_cell(&self, verdict_cell: &D32) -> Result { + VerdictCell::new(verdict_cell, self.members, &self.set_id) + .map_err(|e| VerifierFailure::Refused(format!("verdict cell: {e:?}"))) + } + // ── reads ─────────────────────────────────────────────────────────── + /// The verdict cell at `verdict_cell` as a Release reads it (SoFi + /// Amendment S21): its route chains evaluated from every seat's reads into + /// a read bound to the key. A verdict final at the cell has its + /// completion proof kept (Amendment S10). Reads that do not decide the + /// cell yet are the inner `Err`: a network status, never an open cell. + pub fn read_verdict_cell( + &self, + verdict_cell: &D32, + ) -> Result, VerifierFailure> { + let cell = self.verdict_cell(verdict_cell)?; + let evidence = self.reads.cell(cell.routed())?; + let read = match verdict_resolution(&cell, &evidence) { + Ok(read) => read, + Err(missing) => return Ok(Err(missing)), + }; + if let CellFact::Held { + state: ChainState::Final, + .. + } = read.fact() + { + let (.., proof) = verdict_completion(&cell, &read, &evidence) + .map_err(|missing| { + VerifierFailure::Read(format!("verdict completion: {missing:?}")) + })? + .ok_or_else(|| { + VerifierFailure::Read( + "verdict completion: a final verdict has no completion proof".to_string(), + ) + })?; + self.reads + .keep_completion(cell.routed(), &evidence, &proof)?; + } + Ok(Ok(read)) + } + /// `SuccessorResolution(K^(attempt))` of `vault_id` at `parent_root`, as /// the ladder reads it (Section 23.1): the cell's route chains evaluated /// from every seat's reads into a read bound to the key. An exercise @@ -869,8 +920,12 @@ impl Verifier<'_, R> { ) -> Result, VerifierFailure> { let needs = EvidenceNeeds::of(precommit, preimage); let mut missing = Vec::new(); + // Token policies the predicate named as missing in an earlier round: + // an escrow vault's token is named by its terms, which the round + // before fetched (SoFi Amendment S21). + let mut named_tokens: BTreeSet = BTreeSet::new(); for round in 1..=ACQUIRE_ROUNDS { - let evidence = self.gather(precommit, preimage, &needs, carried)?; + let evidence = self.gather(precommit, preimage, &needs, carried, &named_tokens)?; match route_validation(precommit, preimage, &evidence) { Ok(Validation::Valid | Validation::Invalid) => { return Ok(Acquired::Complete(evidence)) @@ -879,6 +934,9 @@ impl Verifier<'_, R> { log::info!( "[sofi verifier] evidence round {round}/{ACQUIRE_ROUNDS}: not in hand: {what:?}" ); + if let Missing::TokenPolicy { commit } = &what { + named_tokens.insert(*commit); + } missing = vec![what]; } } @@ -900,6 +958,7 @@ impl Verifier<'_, R> { preimage: &SettlementPreimage, needs: &EvidenceNeeds, carried: &BTreeMap>, + named_tokens: &BTreeSet, ) -> Result { let mut objects: BTreeMap> = BTreeMap::new(); for addr in &needs.trader_pre_balances { @@ -953,6 +1012,13 @@ impl Verifier<'_, R> { objects.insert(addr, bytes); } } + // The token policies the predicate named in an earlier round. A read + // that cannot be made is a network status for the whole acquisition. + for commit in named_tokens { + if !token_policies.contains_key(commit) { + token_policies.insert(*commit, self.reads.token_policy_bytes(commit)?); + } + } let mut setups: BTreeMap> = BTreeMap::new(); let mut setup_lineages: BTreeMap = BTreeMap::new(); @@ -1135,10 +1201,13 @@ impl Verifier<'_, R> { continue; } match self.vault_genesis(&vault_id) { + // A market that pairs `token`. An escrow vault has no market + // and is never a vault of a token (SoFi Amendment S21). Ok(VaultGenesis::Accepted(accepted)) => { - let market = accepted.market(); - if market.token_a() == token || market.token_b() == token { - vaults.push(*accepted); + if let Some(market) = accepted.market() { + if market.token_a() == token || market.token_b() == token { + vaults.push(*accepted); + } } } // Not a vault anyone created, or a genesis refused: not a @@ -1157,6 +1226,57 @@ impl Verifier<'_, R> { }) } + /// SoFi Amendment S21: the escrow vaults bound to `verdict_cell`, found + /// under its cell locator, in the order the index names them. + /// + /// Discovery carries no authority. A candidate is kept only when its + /// genesis is accepted — bound to the owner's validated creation — and the + /// escrow terms that acceptance resolves derive `verdict_cell`; anything + /// else appended under the locator is passed over, so a vault bound to + /// another cell is never found among the linked ones. A candidate the + /// reads cannot establish yet, or a scan that stopped short, makes the + /// discovery `Partial`; an index read that could not be made is a + /// `VerifierFailure::Read`. + pub fn vaults_of_cell( + &self, + verdict_cell: &D32, + ) -> Result, VerifierFailure> { + // An index read that could not be made is the caller's network status: + // nothing is established and nothing refuted (storage §4). + let (candidates, mut scan) = match self.reads.escrow_cell_candidates(verdict_cell)? { + Discovered::Complete(candidates) => (candidates, ScanExtent::Whole), + Discovered::Partial(candidates) => (candidates, ScanExtent::Short), + }; + let mut examined = BTreeSet::new(); + let mut vaults = Vec::new(); + for vault_id in candidates { + if !examined.insert(vault_id) { + continue; + } + match self.vault_genesis(&vault_id) { + Ok(VaultGenesis::Accepted(accepted)) => { + if let Some(terms) = accepted.escrow() { + if super::escrow::verdict_cell_of(terms) == *verdict_cell { + vaults.push(*accepted); + } + } + } + // Not a vault anyone created, or a genesis refused: not a + // vault bound to the cell. + Ok(VaultGenesis::NotPublished | VaultGenesis::Refused(_)) + | Err(VerifierFailure::Refused(_)) => {} + // Not established yet: it may be one. + Ok(VaultGenesis::OwnerUnresolved(_)) | Err(VerifierFailure::Read(_)) => { + scan = ScanExtent::Short + } + } + } + Ok(match scan { + ScanExtent::Whole => Discovered::Complete(vaults), + ScanExtent::Short => Discovered::Partial(vaults), + }) + } + /// `GenesisAccepted` over one candidate, reading the token policies the /// predicate names as missing. It consults at most the two tokens of the /// market, and a policy it names again after it was supplied is not the @@ -1614,6 +1734,7 @@ impl Verifier<'_, R> { registration: &read.registration, parent: self.parent_for(exercise), legs: &legs, + verdict: read.verdict.as_ref(), }) { Ok(ground) => ground, Err(why) => return Ok(Err(why)), @@ -1766,10 +1887,28 @@ impl Verifier<'_, R> { cells.push(cell); walks.push(walk); } + // A Release stands on the verdict its cell holds (SoFi Amendment S21): + // the cell is read like any other, and nothing is read for any other + // operation. + let verdict = match exercise.preimage().settlement() { + SettlementBody::Release { verdict_cell, .. } => { + match self.read_verdict_cell(verdict_cell)? { + Ok(read) => Some(read), + Err(missing) => { + return Ok(Err(NotEstablished::VerdictCell { + verdict_cell: *verdict_cell, + missing, + })) + } + } + } + SettlementBody::Swap { .. } | SettlementBody::Close { .. } => None, + }; Ok(Ok(LegsRead { registration, cells, walks, + verdict, })) } @@ -1816,6 +1955,7 @@ impl Verifier<'_, R> { evidence: &evidence, parent: self.parent_for(exercise), legs, + verdict: read.verdict.as_ref(), }; Ok(establish(&reads)) } @@ -2062,7 +2202,8 @@ impl Verifier<'_, R> { NotEstablished::Registration(..) | NotEstablished::ConformanceEvidence(..) | NotEstablished::RouteEvidence(..) - | NotEstablished::AttemptCell { .. } => { + | NotEstablished::AttemptCell { .. } + | NotEstablished::VerdictCell { .. } => { Incomplete(format!("position {q}: the facts: {why:?}")) } NotEstablished::ParentUnresolved { .. } diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs index 07d4f420c..1baa32d54 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs @@ -9,6 +9,7 @@ //! | `SofiSetup` | `m_setup = H(setup-sign/v1 ‖ CCB(body))` | the body's committed key | //! | `SofiFulfill` | `m_F = H(fulfillment-sign/v1 ‖ CCB(body))` | the body's committed key | //! | `SofiVaultCreate` | the operation's canonical unsigned bytes | the owner's device key | +//! | `EscrowVaultCreate` | the operation's canonical unsigned bytes | the owner's device key | //! //! A setup and a fulfillment sign their PROTOCOL OBJECT, not the operation //! that carries it, and they do not additionally carry a generic operation @@ -349,6 +350,14 @@ pub fn verify_operation( &operation.signing_bytes(), signature, ), + // The same rule for an escrow vault's creation (SoFi Amendment S21). + Operation::EscrowVaultCreate { signature, .. } => verify_bytes( + "EscrowVaultCreate", + sigalg::SPHINCS_PLUS_SPX256F, + device_public_key, + &operation.signing_bytes(), + signature, + ), // Not a SoFi operation. Refused rather than silently passed: a caller // that routes something else here has already lost track of which // rule applies. diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs index 77dfc7acb..56a596e43 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs @@ -37,12 +37,13 @@ use crate::economic::state::{EconomicBalanceState, EconomicLeafState}; use super::conformance::Validation; use super::derive; +use super::escrow; use super::smt::{verify_batch, FoldEntry, FoldError}; use super::wire::{ - next_position, CoreEntry, DlvCore, OwnerAuthority, SettlementBody, SettlementPreimage, SwapHop, - TraderCore, TraderPreBalance, TraderPrecommitBody, TraderRelationshipLeaf, ValidationRef, - VaultRelationshipLeaf, VaultStateLeaf, MAX_SETTLEMENT_PREIMAGE_BYTES, VAULT_STATUS_ACTIVE, - VAULT_STATUS_RETIRED, + next_position, CoreEntry, DlvCore, EscrowTerms, OwnerAuthority, SettlementBody, + SettlementPreimage, SwapHop, TraderCore, TraderPreBalance, TraderPrecommitBody, + TraderRelationshipLeaf, ValidationRef, VaultRelationshipLeaf, VaultStateLeaf, + MAX_SETTLEMENT_PREIMAGE_BYTES, VAULT_STATUS_ACTIVE, VAULT_STATUS_RETIRED, }; type D32 = [u8; 32]; @@ -151,6 +152,25 @@ pub enum Invalid { TraderPreBalanceDoesNotDecode(crate::ccb::decode::DecodeError), /// A `TraderPreBalance` names another trader than `P`'s. TraderPreBalanceNotThisTrader, + /// A Close or a Swap names a vault whose terms are not a market: an + /// escrow vault has no market and no owner close (SoFi Amendment S21). + TermsAreNotAMarket, + /// A Release names a vault whose terms are not escrow terms (SoFi + /// Amendment S21). + TermsAreNotEscrow, + /// Bytes that re-derive the address an escrow vault's slots name are not + /// `EscrowTerms`: the vault committed terms that have no reading. + EscrowTermsDoNotDecode(crate::ccb::decode::DecodeError), + /// A Release names a verdict cell other than the one its vault's terms + /// derive. + VerdictCellIsNotTheTerms, + /// A Release names an outcome no branch of its vault's terms has. + OutcomeHasNoBranch, + /// A Release's trader is not the recipient of the branch it names. + NotTheBranchRecipient, + /// A Release's amount is not the vault's whole held amount, or the vault + /// holds a second reserve. + ReleaseIsNotTheWholeAmount, } /// The conjunction, as an accumulator: any Invalid dominates, and only in its @@ -345,7 +365,9 @@ impl EvidenceNeeds { keys.insert(derive::vault_state_key(core.vault_id())); keys.extend(core.entries().iter().map(CoreEntry::key)); } - if let SettlementBody::Close { vault_id, .. } = preimage.settlement() { + if let SettlementBody::Close { vault_id, .. } | SettlementBody::Release { vault_id, .. } = + preimage.settlement() + { vaults .entry(*vault_id) .or_default() @@ -358,15 +380,20 @@ impl EvidenceNeeds { } } - /// The three policy objects a vault state commits, by class and address. + /// The objects a vault state's slots name, by class and address: a + /// market's three policies, or an escrow vault's one terms object (SoFi + /// Amendment S21). /// The two token policies a vault's market names: what the transferable /// check of SoFi §49 is decided over, for both tokens of every hop. pub fn token_policies_of(market: &crate::ccb::state::MarketPolicy) -> [D32; 2] { [*market.token_a(), *market.token_b()] } - pub fn policies_of(state: &VaultStateLeaf) -> [(u16, D32); 3] { - [ + pub fn policies_of(state: &VaultStateLeaf) -> Vec<(u16, D32)> { + if VaultTerms::slots_name_escrow(state) { + return vec![(crate::ccb::class::ESCROW_TERMS, state.market_policy)]; + } + vec![ (crate::ccb::class::MARKET_POLICY, state.market_policy), (crate::ccb::class::FEE_POLICY, state.fee_policy), (crate::ccb::class::RELEASE_POLICY, state.release_policy), @@ -478,6 +505,23 @@ impl Evidence { Ok(bytes) } + /// The escrow terms at `addr`, AUTHENTICATED against it under the terms + /// namespace (SoFi Amendment S21). Bytes not in hand, or that do not + /// re-derive `addr`, supply nothing (note 9). + fn terms_bytes(&self, addr: &D32) -> Result<&[u8], Refusal> { + let bytes = self + .objects + .get(addr) + .map(Vec::as_slice) + .ok_or(Refusal::Incomplete(Missing::Policy { addr: *addr }))?; + if escrow::terms_address_of(bytes) != *addr { + return Err(Refusal::Incomplete(Missing::NonVerifyingObject { + addr: *addr, + })); + } + Ok(bytes) + } + /// The `TraderPreBalance` at `addr`, AUTHENTICATED against it. Bytes not /// in hand, or that do not re-derive `addr`, supply nothing (note 9). /// Bytes that do re-derive it are the object `E` committed, so one that @@ -570,6 +614,63 @@ impl Policies { } } +/// What a vault's three policy slots name (SoFi §19.9, the slot rule): a +/// market's three policies, or one escrow terms object, which all three slots +/// name. The slots of a market name objects of three classes, each addressed +/// under its own namespace, so they can never all be equal: equal slots are +/// an escrow vault's, and the bytes they name must authenticate as its terms. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VaultTerms { + Market(Policies), + Escrow(EscrowTerms), +} + +impl VaultTerms { + /// Whether `state`'s three slots name one object: an escrow vault's. + pub fn slots_name_escrow(state: &VaultStateLeaf) -> bool { + state.market_policy == state.fee_policy && state.fee_policy == state.release_policy + } + + /// The terms `state` commits, from the objects `evidence` holds, each + /// re-addressed before it is decoded. + pub fn resolve(evidence: &Evidence, state: &VaultStateLeaf) -> Result { + if Self::slots_name_escrow(state) { + let bytes = evidence.terms_bytes(&state.market_policy)?; + let terms = EscrowTerms::decode(bytes) + .map_err(|why| Refusal::Invalid(Invalid::EscrowTermsDoNotDecode(why)))?; + return Ok(Self::Escrow(terms)); + } + Ok(Self::Market(Policies::resolve(evidence, state)?)) + } + + /// A market's policies; an escrow vault has none, so a Close or a Swap + /// against one is Invalid. + pub fn market(self) -> Result { + match self { + Self::Market(policies) => Ok(policies), + Self::Escrow(..) => Err(Refusal::Invalid(Invalid::TermsAreNotAMarket)), + } + } + + /// An escrow vault's terms; a market has none, so a Release against one + /// is Invalid. + pub fn escrow(self) -> Result { + match self { + Self::Escrow(terms) => Ok(terms), + Self::Market(..) => Err(Refusal::Invalid(Invalid::TermsAreNotEscrow)), + } + } + + /// The tokens the vault holds: a market's pair, or an escrow vault's one + /// token. + pub fn tokens(&self) -> Vec { + match self { + Self::Market(policies) => EvidenceNeeds::token_policies_of(&policies.market).to_vec(), + Self::Escrow(terms) => vec![*terms.token()], + } + } +} + /// `SetupValid` for one leg of P (SoFi §16, §20.1): the setup the leg names by /// `ρ` is the canonical envelope whose body re-derives `ρ`, names P's trader /// and the leg's vault, is signed by P's trader key, and names by @@ -648,18 +749,18 @@ fn setup_valid( Ok(()) } -/// Both tokens of a vault the operation touches pass their policies as market -/// legs (SoFi §19.5, §49; MR-SOFI-0311), an intermediate token of a route -/// included. ERA and dBTC are pre-rooted and never consult a policy. Any -/// other token's `TokenPolicyV3` bytes are re-hashed to its commit before -/// they establish anything. -fn market_legs_permitted( +/// Every token of a vault the operation touches passes its policy for a +/// transfer (SoFi §19.5, §19.9, §49; MR-SOFI-0311): both tokens of a market, +/// an intermediate token of a route included, and an escrow vault's one +/// token. ERA and dBTC are pre-rooted and never consult a policy. Any other +/// token's `TokenPolicyV3` bytes are re-hashed to its commit before they +/// establish anything. +fn vault_tokens_permitted( core: &crate::sofi::wire::DlvCore, evidence: &Evidence, ) -> Result<(), Refusal> { let state = evidence.vault_state(core.vault_id())?; - let policies = Policies::resolve(evidence, &state)?; - for commit in EvidenceNeeds::token_policies_of(&policies.market) { + for commit in VaultTerms::resolve(evidence, &state)?.tokens() { if crate::core::token::builtin_token_id_for_policy_commit(&commit).is_some() { continue; } @@ -822,22 +923,11 @@ pub fn vault_post_states( .enumerate() .find(|(_, h)| h.vault_id == vault_id) .ok_or(Refusal::Invalid(Invalid::LegsDoNotMatchPrecommit))?; - let policies = Policies::resolve(evidence, &pre_state)?; + let policies = VaultTerms::resolve(evidence, &pre_state)?.market()?; swap_vault_post(&pre_state, &policies, hop, index)? } - SettlementBody::Close { .. } => { - let generation = pre_state.generation.checked_add(1).ok_or(Refusal::Invalid( - Invalid::CheckedArithmetic { - what: "vault generation", - }, - ))?; - VaultStateLeaf { - generation, - reserve_a: 0, - reserve_b: 0, - status: VAULT_STATUS_RETIRED, - ..pre_state.clone() - } + SettlementBody::Close { .. } | SettlementBody::Release { .. } => { + retire_vault_post(&pre_state)? } }; // The state the core STATES must be the state the arithmetic reaches. @@ -899,7 +989,8 @@ pub fn vault_post_states( /// held by the DLVs across the hop rather than by the trader. So `B` never /// becomes a trader balance leaf, and no rule about the trader's tokens /// reaches it. A close is different: BOTH of the vault's reserve assets are -/// credited back to the owner. +/// credited back to the owner. A release credits an escrow vault's one token +/// to the branch's recipient. pub fn trader_movements( preimage: &SettlementPreimage, evidence: &Evidence, @@ -919,12 +1010,21 @@ pub fn trader_movements( .. } => { let state = evidence.vault_state(vault_id)?; - let policies = Policies::resolve(evidence, &state)?; + let policies = VaultTerms::resolve(evidence, &state)?.market()?; vec![ (*policies.market.token_a(), *reserve_a, 0), (*policies.market.token_b(), *reserve_b, 0), ] } + // A release pays the whole amount of the vault's one token to the + // branch's recipient, and debits nothing (SoFi Amendment S21). + SettlementBody::Release { + vault_id, amount, .. + } => { + let state = evidence.vault_state(vault_id)?; + let terms = VaultTerms::resolve(evidence, &state)?.escrow()?; + vec![(*terms.token(), *amount, 0)] + } }) } @@ -1249,7 +1349,7 @@ pub fn validate( // included (SoFi §19.5, §49; MR-SOFI-0311): both tokens of every vault // the operation touches must be transferable. for core in preimage.dlv_cores() { - verdict.note(market_legs_permitted(core, evidence)); + verdict.note(vault_tokens_permitted(core, evidence)); } match preimage.settlement() { @@ -1289,12 +1389,30 @@ pub fn validate( *reserve_a, *reserve_b, ), + SettlementBody::Release { + vault_id, + verdict_cell, + outcome, + amount, + .. + } => validate_release( + &mut verdict, + &ReleaseCheck { + precommit, + preimage, + evidence, + vault_id, + verdict_cell, + outcome, + amount: *amount, + }, + ), } verdict.finish() } /// `B°`'s core references must be the cores `P(E)` actually carries, and a -/// close's parent must be the leg `P` actually names. +/// close's or a release's parent must be the leg `P` actually names. /// /// Without this the references are decoration: the body could name one core /// while the preimage carried another, and every later check would read the @@ -1352,6 +1470,14 @@ fn check_settlement_core_references( trader_core, dlv_core, .. + } + | SettlementBody::Release { + vault_id, + parent_root, + setup_ref, + trader_core, + dlv_core, + .. } => { require( *trader_core == actual_trader, @@ -1370,7 +1496,7 @@ fn check_settlement_core_references( field: "B°.dlv_core", }, )?; - // A close's own parent must be the leg P names, and the core's. + // Its own parent must be the leg P names, and the core's. let leg = precommit .legs() .iter() @@ -1637,9 +1763,9 @@ pub fn realize_root(core: &TraderCore, external_commitment: &D32) -> Result Result { +/// A vault's state after its owner's full close, or an escrow vault's release +/// (SoFi Amendment S21): the next generation, every reserve released, retired. +pub fn retire_vault_post(pre_state: &VaultStateLeaf) -> Result { let generation = pre_state.generation.checked_add(1).ok_or(Refusal::Invalid( Invalid::CheckedArithmetic { what: "vault generation", @@ -1990,9 +2116,9 @@ fn validate_swap( Invalid::NetworkScopeMismatch, )); } - let policies = state - .as_ref() - .and_then(|state| verdict.get(Policies::resolve(evidence, state))); + let policies = state.as_ref().and_then(|state| { + verdict.get(VaultTerms::resolve(evidence, state).and_then(VaultTerms::market)) + }); if let (Some(state), Some(policies)) = (state.as_ref(), policies.as_ref()) { match swap_vault_post(state, policies, hop, i) { Ok(post_state) => verdict.note(check_vault_write_set(core, &post_state, state)), @@ -2158,7 +2284,7 @@ fn validate_close( preimage.dlv_cores().len() == 1 && *core.vault_id() == *vault_id, Invalid::LegsDoNotMatchPrecommit, )); - match close_vault_post(pre_state) { + match retire_vault_post(pre_state) { Ok(retired) => verdict.note(check_vault_write_set(core, &retired, pre_state)), Err(refusal) => verdict.note(Err(refusal)), } @@ -2173,9 +2299,9 @@ fn validate_close( // The trader takes back exactly both reserves, in the pair's own tokens. // No debit: a close pays out, and constant-product pricing never applies. let trader_core = preimage.trader_core(); - let policies = state - .as_ref() - .and_then(|state| verdict.get(Policies::resolve(evidence, state))); + let policies = state.as_ref().and_then(|state| { + verdict.get(VaultTerms::resolve(evidence, state).and_then(VaultTerms::market)) + }); if let Some(policies) = policies.as_ref() { verdict.note( trader_pre_balances(precommit, preimage, evidence).and_then(|pre| { @@ -2219,6 +2345,134 @@ fn validate_close( })); } +/// What a Release names, for [`validate_release`]. +struct ReleaseCheck<'a> { + precommit: &'a TraderPrecommitBody, + preimage: &'a SettlementPreimage, + evidence: &'a Evidence, + vault_id: &'a D32, + verdict_cell: &'a D32, + outcome: &'a [u8], + amount: u64, +} + +/// RouteValidation for a Release (SoFi §19.9): one leg; the vault's terms are +/// escrow terms, whose cell is the one the release names and whose branch for +/// its outcome pays its trader; the whole amount of an Active escrow vault; +/// the closed write set, with the vault retired; and the realize root the +/// fold gives. No signature is verified here: the verdict proves its +/// authority where it occupies its cell, and resolution reads that fact. +fn validate_release(verdict: &mut Verdict, r: &ReleaseCheck<'_>) { + let e = *r.precommit.external_commitment(); + verdict.note(require( + r.precommit.legs().len() == 1 && r.precommit.legs()[0].vault_id == *r.vault_id, + Invalid::LegsDoNotMatchPrecommit, + )); + + let state = verdict.get(r.evidence.vault_state(r.vault_id)); + if let Some(pre_state) = state.as_ref() { + // The vault id is the owner's own derivation; a release cannot name + // another vault's state. + verdict.note(require( + derive::vault_id( + &pre_state.owner_genesis, + &pre_state.owner_device_id, + pre_state.create_position, + ) == *r.vault_id, + Invalid::VaultIdIsNotTheOwnersDerivation, + )); + verdict.note(require( + pre_state.storage_set_id == *r.precommit.storage_set_id(), + Invalid::NetworkScopeMismatch, + )); + verdict.note(require( + pre_state.status == VAULT_STATUS_ACTIVE, + Invalid::VaultIsNotActive, + )); + // The whole held amount, and nothing chosen at release. + verdict.note(require( + r.amount > 0 && pre_state.reserve_a == r.amount && pre_state.reserve_b == 0, + Invalid::ReleaseIsNotTheWholeAmount, + )); + } + + let terms = state + .as_ref() + .and_then(|s| verdict.get(VaultTerms::resolve(r.evidence, s).and_then(VaultTerms::escrow))); + let trader_core = r.preimage.trader_core(); + if let Some(terms) = terms.as_ref() { + verdict.note(require( + escrow::verdict_cell_of(terms) == *r.verdict_cell, + Invalid::VerdictCellIsNotTheTerms, + )); + match terms.branch(r.outcome) { + None => verdict.note(Err(Refusal::Invalid(Invalid::OutcomeHasNoBranch))), + Some(branch) => verdict.note(require( + branch.recipient_genesis() == r.precommit.genesis() + && branch.recipient_device_id() == r.precommit.device_id(), + Invalid::NotTheBranchRecipient, + )), + } + // The recipient takes exactly the whole amount, in the vault's one + // token, and is debited nothing. + verdict.note( + trader_pre_balances(r.precommit, r.preimage, r.evidence).and_then(|pre| { + check_trader_balances( + r.precommit, + trader_core, + &pre, + &[(*terms.token(), r.amount, 0)], + 1, + ) + }), + ); + } + + let core = r.preimage.dlv_cores().first(); + if let (Some(core), Some(pre_state)) = (core, state.as_ref()) { + verdict.note(require( + r.preimage.dlv_cores().len() == 1 && *core.vault_id() == *r.vault_id, + Invalid::LegsDoNotMatchPrecommit, + )); + match retire_vault_post(pre_state) { + Ok(retired) => verdict.note(check_vault_write_set(core, &retired, pre_state)), + Err(refusal) => verdict.note(Err(refusal)), + } + } + if let Some(core) = core { + match dlv_fold_entries(core, &e, r.evidence) { + Ok(entries) => verdict.note(fold_core(&entries, core.pre_root()).and(Ok(()))), + Err(refusal) => verdict.note(Err(refusal)), + } + } + + let bases = relationship_bases(trader_core.entries()); + match bases.get(r.vault_id) { + None => verdict.note(Err(Refusal::Invalid(Invalid::WriteSetNotExact { + core: "T°", + }))), + Some((genesis, device_id, base)) => { + verdict.note(require( + genesis == r.precommit.genesis() && device_id == r.precommit.device_id(), + Invalid::CoreIdentityMismatch, + )); + if let Some(core) = core { + verdict.note(require( + *core.relationship_base() == *base, + Invalid::RelationshipBaseMismatch, + )); + } + } + } + + verdict.note(realize_root(trader_core, &e).and_then(|post_root| { + require( + post_root == *r.precommit.realize_root(), + Invalid::RealizeRootIsNotTheFold, + ) + })); +} + #[cfg(test)] #[allow(clippy::disallowed_methods)] // fixtures; a failure here is the signal pub(crate) mod fixtures { @@ -3078,6 +3332,27 @@ mod tests { dlv_core, closure, }, + SettlementBody::Release { + vault_id, + parent_root, + setup_ref, + verdict_cell, + outcome, + amount, + dlv_core, + closure, + .. + } => SettlementBody::Release { + vault_id, + parent_root, + setup_ref, + verdict_cell, + outcome, + amount, + trader_core: reference, + dlv_core, + closure, + }, }; let preimage = SettlementPreimage::new(settlement, core, f.preimage.dlv_cores().to_vec()).unwrap(); @@ -5177,7 +5452,9 @@ mod tests { dlv_cores, closure, }, - SettlementBody::Close { .. } => panic!("a swap fixture"), + SettlementBody::Close { .. } | SettlementBody::Release { .. } => { + panic!("a swap fixture") + } }; let preimage = SettlementPreimage::new( settlement, @@ -5416,4 +5693,390 @@ mod tests { Err(Refusal::Invalid(Invalid::LeafPostValueMismatch)) ); } + + // ── escrow vaults (SoFi Amendment S21) ──────────────────────────────── + + use crate::sofi::wire::{EscrowBranch, EscrowOutcome, EscrowSigner}; + + /// What an escrow fixture settles. + enum EscrowOp { + Release { + outcome: &'static [u8], + amount: u64, + verdict_cell: Option, + }, + Close, + } + + /// The amount the fixture's escrow vault holds. + const STAKE: u64 = 2_500; + /// The escrow vault's owner when it is not the trader. + const OTHER_OWNER: D32 = [0x0B; 32]; + + /// The referee, the one signer of every fixture outcome. Static validity + /// verifies no signature, so its key signs nothing here. + fn referee() -> EscrowSigner { + EscrowSigner::new(SIG_ALG, &[0x5A; 64]).unwrap() + } + + /// The fixture's terms: "a-wins" pays the trader, "b-wins" another + /// identity, "void" the vault's owner; the referee decides each. + fn escrow_terms(owner_device: D32) -> EscrowTerms { + let branch = |outcome: &[u8], to: (D32, D32)| { + EscrowBranch::new( + EscrowOutcome::new(outcome, vec![referee()]).unwrap(), + to.0, + to.1, + ) + }; + EscrowTerms::new( + tokens()[0].0, + escrow::external_commitment(b"fixture match"), + vec![ + branch(b"a-wins", (G, dev())), + branch(b"b-wins", (token(0xB1), token(0xB2))), + branch(b"void", (G, owner_device)), + ], + ) + .unwrap() + } + + /// An escrow vault's state at its parent: all three slots name its terms, + /// the stake in `reserve_a`, nothing in `reserve_b`. + fn escrow_state(owner_device: D32, terms: &EscrowTerms) -> VaultStateLeaf { + let addr = escrow::terms_address(terms); + VaultStateLeaf { + owner_genesis: G, + owner_device_id: owner_device, + create_position: P_CREATE, + market_policy: addr, + fee_policy: addr, + release_policy: addr, + storage_set_id: token(0x77), + generation: 0, + reserve_a: STAKE, + reserve_b: 0, + status: VAULT_STATUS_ACTIVE, + } + } + + /// One operation against an escrow vault of `owner_device`, by the + /// fixture trader, built the way a trader builds one: the vault retires, + /// the trader takes the stake and is debited nothing. + fn escrow_fixture(owner_device: D32, op: EscrowOp) -> Fixture { + let terms = escrow_terms(owner_device); + let held = *terms.token(); + let vault_id = derive::vault_id(&G, &owner_device, P_CREATE); + let rel_key = derive::relationship_key(&G, &dev(), &vault_id); + let base = + derive::relationship_leaf_genesis(&derive::setup_id(&G, &dev(), P_POS, &vault_id)); + let state = escrow_state(owner_device, &terms); + let state_key = derive::vault_state_key(&vault_id); + let relationship = VaultRelationshipLeaf { + trader_genesis: G, + trader_device_id: dev(), + leaf: base, + }; + let mut vault_tree = EconomicSmt::new(); + vault_tree.insert(state_key, derive::vault_state_leaf_value(&state).unwrap()); + vault_tree.insert( + rel_key, + derive::vault_relationship_leaf_value(&relationship), + ); + let retired = retire_vault_post(&state).unwrap(); + let mut vault_entries = vec![ + CoreEntry::Mutation { + key: state_key, + pre: derive::vault_state_leaf_value(&state).unwrap(), + post: derive::vault_state_leaf_value(&retired).unwrap(), + path: path_of(&vault_tree, &state_key), + }, + CoreEntry::Relationship { + genesis: G, + device_id: dev(), + vault_id, + base, + path: path_of(&vault_tree, &rel_key), + }, + ]; + vault_entries.sort_by_key(|e| e.key()); + let dlv_core = + DlvCore::new(vault_id, vault_tree.root(), G, dev(), base, vault_entries).unwrap(); + + let held_key = balance_key(&G, &dev(), &held); + let mut trader_tree = EconomicSmt::new(); + trader_tree.insert( + rel_key, + derive::trader_relationship_leaf_value(&TraderRelationshipLeaf { + vault_id, + leaf: base, + }), + ); + let mut trader_entries = vec![ + CoreEntry::Mutation { + key: held_key, + pre: crate::economic::tree::ABSENT_LEAF, + post: balance_leaf_value(held, STAKE), + path: path_of(&trader_tree, &held_key), + }, + CoreEntry::Relationship { + genesis: G, + device_id: dev(), + vault_id, + base, + path: path_of(&trader_tree, &rel_key), + }, + ]; + trader_entries.sort_by_key(|e| e.key()); + let trader_core = + TraderCore::new(G, dev(), P_POS + 1, trader_tree.root(), trader_entries).unwrap(); + let parent_claim = parent_claim_envelope(trader_tree.root()); + let trader_core_ref = derive::trader_core_digest(&trader_core.encode().unwrap()); + let dlv_core_ref = derive::dlv_core_digest(&dlv_core.encode().unwrap()); + let closure = with_parent(PreEClosureIndex::new(Vec::new()).unwrap(), &parent_claim); + let settlement = match op { + EscrowOp::Release { + outcome, + amount, + verdict_cell, + } => SettlementBody::Release { + vault_id, + parent_root: vault_tree.root(), + setup_ref: setup_ref_for(vault_id), + verdict_cell: match verdict_cell { + Some(cell) => cell, + None => escrow::verdict_cell_of(&terms), + }, + outcome: outcome.to_vec(), + amount, + trader_core: trader_core_ref, + dlv_core: dlv_core_ref, + closure, + }, + EscrowOp::Close => SettlementBody::Close { + vault_id, + parent_root: vault_tree.root(), + setup_ref: setup_ref_for(vault_id), + owner_authority: OwnerAuthority::Origin, + reserve_a: STAKE, + reserve_b: 0, + trader_core: trader_core_ref, + dlv_core: dlv_core_ref, + closure, + }, + }; + let preimage = + SettlementPreimage::new(settlement, trader_core.clone(), vec![dlv_core]).unwrap(); + let e = derive::recompute_e(&preimage).unwrap(); + let evidence = Evidence { + objects: BTreeMap::from([(escrow::terms_address(&terms), terms.encode())]), + vault_leaves: BTreeMap::from([ + ((vault_id, state_key), VaultLeafPre::State(state)), + ( + (vault_id, rel_key), + VaultLeafPre::Relationship(relationship), + ), + ]), + setups: BTreeMap::from([(setup_ref_for(vault_id), setup_envelope_for(vault_id))]), + token_policies: tokens()[..1].iter().cloned().collect(), + setup_lineages: BTreeMap::from([( + SETUP_POS, + SetupLineage::Accepted(accepted_setup_claim()), + )]), + }; + let realize_root = { + let entries = trader_fold_entries(&trader_core, &e).unwrap(); + batch_fold(&entries).unwrap().post_root + }; + let precommit = TraderPrecommitBody::new( + G, + dev(), + P_POS, + crate::sofi::wire::ParentClaimRef::SingleRoot { + claim_ref: derive::claim_ref(&parent_claim), + }, + e, + vec![PrecommitLeg { + vault_id, + parent_root: vault_tree.root(), + setup_ref: setup_ref_for(vault_id), + }], + realize_root, + trader_tree.root(), + token(0x77), + SIG_ALG, + &trader_keys().0, + ) + .unwrap(); + Fixture { + precommit, + preimage, + evidence, + parent_claim, + } + } + + fn release(outcome: &'static [u8]) -> EscrowOp { + EscrowOp::Release { + outcome, + amount: STAKE, + verdict_cell: None, + } + } + + fn refusal_of(f: &Fixture) -> Result<(), Refusal> { + validate(&f.precommit, &f.preimage, &f.evidence) + } + + #[test] + fn a_release_to_its_branchs_recipient_is_valid_and_retires_the_vault() { + let f = escrow_fixture(OTHER_OWNER, release(b"a-wins")); + assert_eq!(refusal_of(&f), Ok(())); + // The recipient takes the whole stake in the vault's one token, and + // the vault retires at the next generation with nothing left. + assert_eq!( + trader_credits(&f.preimage, &f.evidence), + Ok(vec![tokens()[0].0]) + ); + let posts = vault_post_states(&f.precommit, &f.preimage, &f.evidence).unwrap(); + assert_eq!(posts.len(), 1); + assert_eq!(posts[0].state().status, VAULT_STATUS_RETIRED); + assert_eq!( + (posts[0].state().reserve_a, posts[0].state().reserve_b), + (0, 0) + ); + assert_eq!(posts[0].generation(), 1); + } + + #[test] + fn a_release_of_a_branch_that_pays_someone_else_is_invalid() { + let f = escrow_fixture(OTHER_OWNER, release(b"b-wins")); + assert_eq!( + refusal_of(&f), + Err(Refusal::Invalid(Invalid::NotTheBranchRecipient)) + ); + } + + #[test] + fn a_release_of_an_outcome_no_branch_has_is_invalid() { + let f = escrow_fixture(OTHER_OWNER, release(b"draw")); + assert_eq!( + refusal_of(&f), + Err(Refusal::Invalid(Invalid::OutcomeHasNoBranch)) + ); + } + + #[test] + fn a_release_naming_another_verdict_cell_is_invalid() { + let f = escrow_fixture( + OTHER_OWNER, + EscrowOp::Release { + outcome: b"a-wins", + amount: STAKE, + verdict_cell: Some(token(0xCE)), + }, + ); + assert_eq!( + refusal_of(&f), + Err(Refusal::Invalid(Invalid::VerdictCellIsNotTheTerms)) + ); + } + + #[test] + fn a_release_of_less_than_the_whole_stake_is_invalid() { + let f = escrow_fixture( + OTHER_OWNER, + EscrowOp::Release { + outcome: b"a-wins", + amount: STAKE - 1, + verdict_cell: None, + }, + ); + assert_eq!( + refusal_of(&f), + Err(Refusal::Invalid(Invalid::ReleaseIsNotTheWholeAmount)) + ); + } + + /// An escrow vault has no owner close: its owner gets the stake back only + /// through a branch that pays it. + #[test] + fn the_owner_of_an_escrow_vault_cannot_close_it() { + let f = escrow_fixture(dev(), EscrowOp::Close); + assert_eq!( + refusal_of(&f), + Err(Refusal::Invalid(Invalid::TermsAreNotAMarket)) + ); + // Its own "void" branch, decided by the referee, is how it does. + let void = escrow_fixture(dev(), release(b"void")); + assert_eq!(refusal_of(&void), Ok(())); + } + + /// A Release against a market vault: the slots name a market's three + /// policies, so there are no escrow terms to release by. + #[test] + fn a_release_against_a_market_vault_is_invalid() { + let mut f = escrow_fixture(OTHER_OWNER, release(b"a-wins")); + let vault_id = derive::vault_id(&G, &OTHER_OWNER, P_CREATE); + let state_key = derive::vault_state_key(&vault_id); + let market = VaultStateLeaf { + owner_device_id: OTHER_OWNER, + create_position: P_CREATE, + generation: 0, + reserve_a: STAKE, + reserve_b: 0, + ..vault_state(0, STAKE, 0, VAULT_STATUS_ACTIVE) + }; + f.evidence + .vault_leaves + .insert((vault_id, state_key), VaultLeafPre::State(market)); + f.evidence.objects.extend(policy_objects(1)); + f.evidence.token_policies = tokens()[..=1].iter().cloned().collect(); + assert_eq!( + refusal_of(&f), + Err(Refusal::Invalid(Invalid::TermsAreNotEscrow)) + ); + } + + /// A Swap through an escrow vault: it has no market to price by. + #[test] + fn a_swap_through_an_escrow_vault_is_invalid() { + let mut f = swap_fixture(); + let vault_id = vault_id_of(0); + let state_key = derive::vault_state_key(&vault_id); + let terms = escrow_terms(dev()); + let addr = escrow::terms_address(&terms); + let VaultLeafPre::State(market) = f.evidence.vault_leaves[&(vault_id, state_key)].clone() + else { + panic!("the swap fixture's vault has a state") + }; + let escrowed = VaultStateLeaf { + market_policy: addr, + fee_policy: addr, + release_policy: addr, + ..market + }; + f.evidence + .vault_leaves + .insert((vault_id, state_key), VaultLeafPre::State(escrowed)); + f.evidence.objects.insert(addr, terms.encode()); + assert_eq!( + refusal_of(&f), + Err(Refusal::Invalid(Invalid::TermsAreNotAMarket)) + ); + } + + /// Terms that authenticate to the address the slots name but are not + /// escrow terms are Invalid; bytes that do not authenticate prove nothing + /// and are still missing (note 9). + #[test] + fn escrow_terms_are_taken_only_from_bytes_that_authenticate() { + let mut f = escrow_fixture(OTHER_OWNER, release(b"a-wins")); + let addr = escrow::terms_address(&escrow_terms(OTHER_OWNER)); + f.evidence.objects.insert(addr, b"not the terms".to_vec()); + assert_eq!( + refusal_of(&f), + Err(Refusal::Incomplete(Missing::NonVerifyingObject { addr })) + ); + } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs index 386ee166e..af0f6c2fe 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/mod.rs @@ -187,6 +187,12 @@ //! order · 3 `outcome` · 4 `signatures` `seq<(signer ‖ signature u32 len ‖ //! bytes)>`, 1..=4, strictly ascending by signer. A verdict holding only some //! of its outcome's signatures encodes; it occupies no cell. +//! `0x0065 SettlementRelease` (`B°`, Release branch): 1 `vault_id` · +//! 2 `parent_root` · 3 `setup_ref` · 4 `verdict_cell` · 5 `outcome` `u32 len ‖ +//! bytes`, 1..=64 · 6 `amount` u64 · 7 `trader_core` · 8 `dlv_core` · +//! 9 `closure`. One leg. +//! `0x0066 RouteDigestRelease`: 1 `vault_id` · 2 `parent_root` · 3 `setup_ref` +//! · 4 `verdict_cell` · 5 `outcome` · 6 `amount` u64. pub mod objects; @@ -302,6 +308,10 @@ pub enum SofiWireError { /// its state commits: the vault would be indexed under tokens it does /// not trade (SoFi Amendment S16). MarketNotCommitted, + /// An escrow vault genesis published with terms that are not the object + /// all three of its state's slots name: it would be indexed under a + /// verdict cell it is not bound to (SoFi Amendment S21). + EscrowTermsNotCommitted, } impl core::fmt::Display for SofiWireError { @@ -369,6 +379,10 @@ impl core::fmt::Display for SofiWireError { f, "the market policy is not the one the vault genesis commits" ), + Self::EscrowTermsNotCommitted => write!( + f, + "the escrow terms are not the object all three of the vault genesis's slots name" + ), } } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs index 9d3c3323a..9f1053f81 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/wire/objects.rs @@ -1197,6 +1197,7 @@ pub const CLOSURE_FORBIDDEN_CONTENT_CLASSES: &[u16] = &[ // the closure E itself commits. class::SOFI_SETTLEMENT_SWAP, class::SOFI_SETTLEMENT_CLOSE, + class::SOFI_SETTLEMENT_RELEASE, class::SOFI_TRADER_CORE, class::SOFI_DLV_CORE, class::SOFI_SETTLEMENT_PREIMAGE, @@ -2080,6 +2081,15 @@ pub enum RouteDigestPreimage { reserve_a: u64, reserve_b: u64, }, + /// An escrow vault's release (SoFi Amendment S21). + Release { + vault_id: D32, + parent_root: D32, + setup_ref: D32, + verdict_cell: D32, + outcome: Vec, + amount: u64, + }, } impl RouteDigestPreimage { @@ -2105,6 +2115,23 @@ impl RouteDigestPreimage { push_u64(&mut out, *reserve_a); push_u64(&mut out, *reserve_b); } + Self::Release { + vault_id, + parent_root, + setup_ref, + verdict_cell, + outcome, + amount, + } => { + check_outcome(outcome)?; + push_env(&mut out, class::SOFI_ROUTE_DIGEST_RELEASE); + push_digest32(&mut out, vault_id); + push_digest32(&mut out, parent_root); + push_digest32(&mut out, setup_ref); + push_digest32(&mut out, verdict_cell); + push_part(&mut out, outcome); + push_u64(&mut out, *amount); + } } Ok(out) } @@ -2128,6 +2155,17 @@ impl RouteDigestPreimage { reserve_b: c.u64()?, } } + class::SOFI_ROUTE_DIGEST_RELEASE => { + c.envelope(class::SOFI_ROUTE_DIGEST_RELEASE, SCHEMA_V1)?; + Self::Release { + vault_id: c.digest32()?, + parent_root: c.digest32()?, + setup_ref: c.digest32()?, + verdict_cell: c.digest32()?, + outcome: read_var_bytes(&mut c, ESCROW_MAX_OUTCOME_BYTES)?, + amount: c.u64()?, + } + } got => return Err(DecodeError::WrongClass { got }), }; finish(&c, v) @@ -2161,6 +2199,21 @@ pub enum SettlementBody { dlv_core: D32, closure: PreEClosureIndex, }, + /// An escrow vault's whole amount to the recipient of the branch whose + /// outcome the verdict at `verdict_cell` names (SoFi Amendment S21). It + /// names the outcome and the cell; the verdict is a fact resolution + /// reads, never an input of `E`. + Release { + vault_id: D32, + parent_root: D32, + setup_ref: D32, + verdict_cell: D32, + outcome: Vec, + amount: u64, + trader_core: D32, + dlv_core: D32, + closure: PreEClosureIndex, + }, } impl SettlementBody { @@ -2169,7 +2222,7 @@ impl SettlementBody { pub fn leg_count(&self) -> usize { match self { Self::Swap { hops, .. } => hops.len(), - Self::Close { .. } => 1, + Self::Close { .. } | Self::Release { .. } => 1, } } @@ -2177,7 +2230,9 @@ impl SettlementBody { /// commits, whatever the branch. pub fn closure(&self) -> &PreEClosureIndex { match self { - Self::Swap { closure, .. } | Self::Close { closure, .. } => closure, + Self::Swap { closure, .. } + | Self::Close { closure, .. } + | Self::Release { closure, .. } => closure, } } @@ -2200,6 +2255,22 @@ impl SettlementBody { reserve_a: *reserve_a, reserve_b: *reserve_b, }, + Self::Release { + vault_id, + parent_root, + setup_ref, + verdict_cell, + outcome, + amount, + .. + } => RouteDigestPreimage::Release { + vault_id: *vault_id, + parent_root: *parent_root, + setup_ref: *setup_ref, + verdict_cell: *verdict_cell, + outcome: outcome.clone(), + amount: *amount, + }, } } @@ -2258,6 +2329,29 @@ impl SettlementBody { push_digest32(&mut out, dlv_core); out.extend_from_slice(&closure.encode()); } + Self::Release { + vault_id, + parent_root, + setup_ref, + verdict_cell, + outcome, + amount, + trader_core, + dlv_core, + closure, + } => { + check_outcome(outcome)?; + push_env(&mut out, class::SOFI_SETTLEMENT_RELEASE); + push_digest32(&mut out, vault_id); + push_digest32(&mut out, parent_root); + push_digest32(&mut out, setup_ref); + push_digest32(&mut out, verdict_cell); + push_part(&mut out, outcome); + push_u64(&mut out, *amount); + push_digest32(&mut out, trader_core); + push_digest32(&mut out, dlv_core); + out.extend_from_slice(&closure.encode()); + } } Ok(out) } @@ -2300,6 +2394,20 @@ impl SettlementBody { closure: PreEClosureIndex::at(c)?, }) } + class::SOFI_SETTLEMENT_RELEASE => { + c.envelope(class::SOFI_SETTLEMENT_RELEASE, SCHEMA_V1)?; + Ok(Self::Release { + vault_id: c.digest32()?, + parent_root: c.digest32()?, + setup_ref: c.digest32()?, + verdict_cell: c.digest32()?, + outcome: read_var_bytes(c, ESCROW_MAX_OUTCOME_BYTES)?, + amount: c.u64()?, + trader_core: c.digest32()?, + dlv_core: c.digest32()?, + closure: PreEClosureIndex::at(c)?, + }) + } got => Err(DecodeError::WrongClass { got }), } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs b/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs index 5a083a24b..05c5300e9 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs @@ -403,6 +403,8 @@ pub struct OfflineSpend { /// - `CreateToken`: the ERA fee debit, if any, then the release of the whole /// genesis supply. /// - `SofiVaultCreate`: exactly the debits of its two funded legs. +/// - `EscrowVaultCreate`: exactly the debit of the held amount of the terms' +/// token (SoFi Amendment S21). /// - Every other operation: no balance deltas, and no `offline_spend`. fn validate_conservation( local_devid: &[u8; 32], @@ -592,6 +594,38 @@ fn validate_conservation( } Ok(()) } + + // An escrow vault's creation (SoFi Amendment S21) debits exactly the + // held amount, of the one token its terms name, and nothing else. + // Whether those terms are the ones the genesis state commits is the + // economic write set's check; this arm holds the deltas to them. + Operation::EscrowVaultCreate { + creation, terms, .. + } => { + let record = crate::sofi::wire::VaultCreation::decode(creation).map_err(|e| { + DsmError::invalid_operation(format!( + "conservation: an escrow creation record that is not canonical moves \ + nothing: {e}" + )) + })?; + let terms = crate::sofi::wire::EscrowTerms::decode(terms).map_err(|e| { + DsmError::invalid_operation(format!( + "conservation: escrow terms that are not canonical name no token: {e}" + )) + })?; + let expected = [BalanceDelta { + policy_commit: *terms.token(), + direction: BalanceDirection::Debit, + amount: record.amount_a, + }]; + if deltas != expected.as_slice() { + return Err(DsmError::invalid_operation( + "conservation: an escrow vault creation must apply exactly the debit of its \ + stake", + )); + } + Ok(()) + } _ => { if !deltas.is_empty() { return Err(DsmError::invalid_operation( @@ -1164,6 +1198,7 @@ impl DeviceState { Operation::SofiSetup { .. } | Operation::SofiVaultCreate { .. } | Operation::SofiFulfill { .. } + | Operation::EscrowVaultCreate { .. } ) { crate::sofi::signature::verify_operation(&operation, &self.public_key)?; } @@ -2020,6 +2055,64 @@ mod tests { } } + /// An escrow vault's creation (SoFi Amendment S21) moves exactly its + /// stake out of the owner's balance: the record's amount, of the one + /// token its terms name. Anything more, less, or of another asset is + /// refused. + #[test] + fn an_escrow_creation_debits_exactly_its_stake() { + let me = devid(0xAA); + let held = pc(0x41); + let referee = crate::sofi::wire::EscrowSigner::new( + crate::ccb::sigalg::SPHINCS_PLUS_SPX256F, + &[0x5A; 64], + ) + .expect("a declared key"); + let terms = crate::sofi::wire::EscrowTerms::new( + held, + [0x59; 32], + vec![crate::sofi::wire::EscrowBranch::new( + crate::sofi::wire::EscrowOutcome::new(b"void", vec![referee]).expect("outcome"), + [0x01; 32], + me, + )], + ) + .expect("terms"); + let creation = crate::sofi::wire::VaultCreation { + vault_id: [0x51; 32], + genesis_root: [0x52; 32], + amount_a: 700, + amount_b: 0, + }; + let op = Operation::EscrowVaultCreate { + genesis_preimage: Vec::new(), + creation: creation.encode(), + terms: terms.encode(), + signature: Vec::new(), + }; + let debit = |amount: u64, policy_commit: [u8; 32]| BalanceDelta { + policy_commit, + direction: BalanceDirection::Debit, + amount, + }; + assert_eq!( + validate_conservation(&me, &op, &[debit(700, held)], None).map_err(|e| e.to_string()), + Ok(()) + ); + for (why, deltas) in [ + ("no deltas", Vec::new()), + ("an amount changed", vec![debit(699, held)]), + ("another asset", vec![debit(700, pc(0x43))]), + ("a second debit", vec![debit(700, held), debit(1, pc(0x43))]), + ] { + let refused = validate_conservation(&me, &op, &deltas, None).map_err(|e| e.to_string()); + assert!( + matches!(&refused, Err(why) if why.contains("exactly the debit of its stake")), + "{why}: an escrow creation applies exactly its stake's debit, got {refused:?}" + ); + } + } + /// A vault creation moves exactly its two funded legs out of the owner's /// balances: the record's amounts, of the operation's two assets, in /// order. Anything more, less, or of another asset is refused. diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs b/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs index a869611b1..eed7dbca9 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs @@ -736,6 +736,27 @@ pub enum Operation { /// exactly this digest on the bare object. signature: Vec, }, + /// An escrow vault's creation at `p_create` (SoFi Amendment S21). It + /// debits the held amount of the terms' token and inserts the creation + /// record, as `SofiVaultCreate` does for a market; the vault's genesis + /// lives in its tree, not in this operation. + EscrowVaultCreate { + /// Canonical `VaultGenesisPreimage` bytes (class `0x005A`), whose + /// state's three policy slots all name `terms`. + genesis_preimage: Vec, + /// Canonical `VaultCreation` bytes (class `0x005B`). + creation: Vec, + /// Canonical `EscrowTerms` bytes (class `0x0063`): the EXACT object + /// the genesis state's slots name, carried for the reason + /// `SofiVaultCreate` carries its market policy, so acceptance is a + /// function of the operation's bytes. Core re-addresses them under the + /// terms namespace and refuses unless the address is the one the state + /// commits; the token they name is the one debited. + terms: Vec, + /// SPHINCS+ over the operation's canonical unsigned bytes, as for + /// `SofiVaultCreate`: a creation has no object digest of its own. + signature: Vec, + }, DlvInvalidate { /// 32-byte vault identifier. vault_id: Vec, @@ -815,6 +836,9 @@ impl Operation { // writes a relationship leaf and nothing else. | SofiVaultCreate { .. } | SofiFulfill { .. } + // An escrow vault's creation moves the stake out of the owner's + // spendable balance (SoFi Amendment S21). + | EscrowVaultCreate { .. } // Token creation DESTROYS ERA to pay its fee, so it moves the // owner's existing funds outward — egress, despite also issuing a // new asset. Classifying it as ingress (as it was while nothing @@ -939,7 +963,9 @@ impl Operation { // spend gate therefore cannot name them here, and saying // otherwise would be inventing a token id the operation does not // carry. - SofiVaultCreate { .. } | SofiFulfill { .. } => EgressAsset::Unidentified, + SofiVaultCreate { .. } | SofiFulfill { .. } | EscrowVaultCreate { .. } => { + EgressAsset::Unidentified + } // Token creation: the asset that LEAVES is ERA (the burned fee) — // NOT the new token, which is issued, not spent. Naming the new @@ -1039,6 +1065,18 @@ impl Operation { put_bytes(&mut out, precommit_id); put_bytes(&mut out, signature); } + EscrowVaultCreate { + genesis_preimage, + creation, + terms, + signature, + } => { + put_u8(&mut out, 38); + put_bytes(&mut out, genesis_preimage); + put_bytes(&mut out, creation); + put_bytes(&mut out, terms); + put_bytes(&mut out, signature); + } Genesis => { put_u8(&mut out, 0); } @@ -2111,6 +2149,12 @@ impl Operation { precommit_id: get_bytes(&mut input)?, signature: get_bytes(&mut input)?, }, + 38 => EscrowVaultCreate { + genesis_preimage: get_bytes(&mut input)?, + creation: get_bytes(&mut input)?, + terms: get_bytes(&mut input)?, + signature: get_bytes(&mut input)?, + }, _ => return Err(DsmError::invalid_operation("unknown op tag")), }; // Canonical decode requires full byte exhaustion: a valid operation must @@ -2145,6 +2189,7 @@ impl Operation { | Operation::SofiSetup { signature, .. } | Operation::SofiVaultCreate { signature, .. } | Operation::SofiFulfill { signature, .. } + | Operation::EscrowVaultCreate { signature, .. } if !signature.is_empty() => { Some(signature.clone()) @@ -2186,6 +2231,7 @@ impl Operation { Operation::SofiSetup { .. } => "sofi_setup", Operation::SofiVaultCreate { .. } => "sofi_vault_create", Operation::SofiFulfill { .. } => "sofi_fulfill", + Operation::EscrowVaultCreate { .. } => "escrow_vault_create", } } @@ -2219,7 +2265,8 @@ impl Operation { | Operation::DlvInvalidate { signature, .. } | Operation::SofiSetup { signature, .. } | Operation::SofiVaultCreate { signature, .. } - | Operation::SofiFulfill { signature, .. } => { + | Operation::SofiFulfill { signature, .. } + | Operation::EscrowVaultCreate { signature, .. } => { signature.clear(); } _ => {} @@ -2248,7 +2295,8 @@ impl Operation { | Operation::DlvInvalidate { signature, .. } | Operation::SofiSetup { signature, .. } | Operation::SofiVaultCreate { signature, .. } - | Operation::SofiFulfill { signature, .. } => { + | Operation::SofiFulfill { signature, .. } + | Operation::EscrowVaultCreate { signature, .. } => { *signature = sig; } _ => {} diff --git a/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs b/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs index 8e251fa51..c20e1c73b 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs @@ -34,6 +34,13 @@ fn sofi_operations() -> Vec { precommit_id: vec![0x11; 32], signature: Vec::new(), }, + // An escrow vault's creation (SoFi Amendment S21). + Operation::EscrowVaultCreate { + genesis_preimage: vec![0x5A, 0x00], + creation: vec![0x5B, 0x00], + terms: vec![0x00, 0x63, 0x00, 0x01], + signature: Vec::new(), + }, ] } @@ -88,8 +95,8 @@ fn sofi_operations_have_distinct_canonical_tags() { let bytes = op.to_bytes(); let tag = bytes.first().copied().expect("a tagged encoding"); assert!( - (34..=36).contains(&tag), - "{}: SoFi operations are tags 34-36, got {tag}", + matches!(tag, 34..=36 | 38), + "{}: SoFi operations are tags 34-36 and 38, got {tag}", op.get_operation_type() ); for (name, other) in &seen { @@ -102,7 +109,7 @@ fn sofi_operations_have_distinct_canonical_tags() { } seen.push((op.get_operation_type().to_string(), bytes)); } - assert_eq!(seen.len(), 3); + assert_eq!(seen.len(), 4); } /// The economic classification of each SoFi operation, and the egress gate's @@ -132,6 +139,7 @@ fn sofi_operations_are_classified_and_gated() { assert!(!ops[0].is_value_egress(), "a setup is not value egress"); assert!(ops[1].is_value_egress(), "a funded creation is egress"); assert!(ops[2].is_value_egress(), "a fulfillment is egress"); + assert!(ops[3].is_value_egress(), "an escrow creation is egress"); } /// Beta executes at most two hops, and never the reserved authority — while diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs index b7d479d9f..a991e28e2 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs @@ -2862,7 +2862,7 @@ async fn discovery_passes_over_what_is_not_a_vault_of_the_token() { &set, &Publication::VaultGenesis { preimage: &forged, - market: real.market(), + market: real.market().expect("a market vault"), }, ) .await @@ -2872,7 +2872,7 @@ async fn discovery_passes_over_what_is_not_a_vault_of_the_token() { let other = accepted(&second); let other_addr = Publication::VaultGenesis { preimage: other.preimage(), - market: other.market(), + market: other.market().expect("a market vault"), } .address() .expect("an address"); @@ -2909,7 +2909,7 @@ async fn discovery_passes_over_what_is_not_a_vault_of_the_token() { &set, &Publication::VaultGenesis { preimage: &ahead, - market: real.market(), + market: real.market().expect("a market vault"), }, ) .await @@ -3475,6 +3475,12 @@ impl dsm::sofi::resolve::SofiReads for CountingReads<'_> { ) -> Result, dsm::sofi::resolve::ReadFailure> { self.live.vault_token_candidates(token) } + fn escrow_cell_candidates( + &self, + verdict_cell: &[u8; 32], + ) -> Result, dsm::sofi::resolve::ReadFailure> { + self.live.escrow_cell_candidates(verdict_cell) + } fn vault_owner( &self, genesis: &[u8; 32], diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs index 82b64ba6d..76470912d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs @@ -48,6 +48,8 @@ fn event_type(stored: &str) -> Option { Realized::Setup, Realized::Trade, Realized::Close, + Realized::EscrowLock, + Realized::EscrowRelease, ] .into_iter() .find(|kind| kind.tx_type() == stored) @@ -57,6 +59,8 @@ fn event_type(stored: &str) -> Option { Realized::Setup => generated::TransactionType::TxTypeSofiSetup, Realized::Trade => generated::TransactionType::TxTypeSofiTrade, Realized::Close => generated::TransactionType::TxTypeSofiClose, + Realized::EscrowLock => generated::TransactionType::TxTypeEscrowLock, + Realized::EscrowRelease => generated::TransactionType::TxTypeEscrowRelease, }) } @@ -70,6 +74,8 @@ fn event_type_name(kind: generated::TransactionType) -> Option<&'static str> { generated::TransactionType::TxTypeSofiSetup => Some(Realized::Setup.tx_type()), generated::TransactionType::TxTypeSofiTrade => Some(Realized::Trade.tx_type()), generated::TransactionType::TxTypeSofiClose => Some(Realized::Close.tx_type()), + generated::TransactionType::TxTypeEscrowLock => Some(Realized::EscrowLock.tx_type()), + generated::TransactionType::TxTypeEscrowRelease => Some(Realized::EscrowRelease.tx_type()), generated::TransactionType::TxTypeUnspecified | generated::TransactionType::TxTypeFaucet | generated::TransactionType::TxTypeBilateralOffline diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/realized_records.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/realized_records.rs index fce838bbf..f2bfc1c1c 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/realized_records.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/realized_records.rs @@ -44,6 +44,11 @@ pub(crate) enum Realized { Trade, /// The owner's close: both reserves credited. Close, + /// An escrow vault created: its stake locked (SoFi Amendment S21). + EscrowLock, + /// An escrow vault released: its whole stake credited to the recipient + /// of the branch the verdict named (SoFi Amendment S21). + EscrowRelease, } impl Realized { @@ -55,6 +60,8 @@ impl Realized { Self::Setup => "sofi_setup", Self::Trade => "sofi_trade", Self::Close => "sofi_close", + Self::EscrowLock => "escrow_lock", + Self::EscrowRelease => "escrow_release", } } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs index fdcb38786..3fc7a41f9 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs @@ -871,6 +871,7 @@ pub async fn resolve_pending_position( let what = match facts.preimage().settlement() { SettlementBody::Close { .. } => Realized::Close, SettlementBody::Swap { .. } => Realized::Trade, + SettlementBody::Release { .. } => Realized::EscrowRelease, }; let mut moved = Vec::new(); for change in trader_balance_changes(&precommit, facts.preimage(), facts.evidence()) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs index 2317480d6..e4f4b9601 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs @@ -27,7 +27,7 @@ use dsm::sofi::resolution::{VaultChain, WalkOutcome}; use dsm::sofi::resolve::{AcceptedGeneses, Acquired, LocalLeaves, VaultGenesis, Verifier, WALK_BUDGET}; use dsm::sofi::storage::Discovered; use dsm::sofi::validation::{ - close_vault_post, movement_shape, route_endpoints, swap_vault_post, Evidence, EvidenceNeeds, + movement_shape, retire_vault_post, route_endpoints, swap_vault_post, Evidence, EvidenceNeeds, HopMovement, Policies, RouteShape, }; use dsm::sofi::wire::{ @@ -1828,7 +1828,7 @@ pub async fn close( .await?; let base = relationship_base(&standing, &intent.vault_id)?; let retired = - close_vault_post(&vault.state).map_err(|refusal| refuse(format!("{refusal:?}")))?; + retire_vault_post(&vault.state).map_err(|refusal| refuse(format!("{refusal:?}")))?; let dlv = vault_core(&standing, &vault, &retired, base)?; let (token_a, token_b) = ( *vault.policies.market.token_a(), diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_reads.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_reads.rs index 5253bb921..65c0a5380 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_reads.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_reads.rs @@ -429,6 +429,25 @@ impl SofiReads for LiveSofiReads<'_> { ) } + fn escrow_cell_candidates(&self, verdict_cell: &D32) -> Result, ReadFailure> { + let locator = dsm::sofi::escrow::cell_locator(verdict_cell); + self.read( + "escrow cell candidates", + resolve_locator_all( + self.set, + dsm::common::domain_tags::TAG_DSM_ESCROW_CELL_LOCATOR.source_bytes(), + &locator, + LOCATOR_BUDGET, + // Every genesis preimage under the locator is a candidate; + // Core accepts it and checks its terms derive the cell (SoFi + // Amendment S21). + |bytes| { + recognize_genesis(bytes).map(|(preimage, ..)| (locator, preimage.vault_id())) + }, + ), + ) + } + fn vault_owner( &self, genesis: &D32, diff --git a/dsm_client/frontend/src/components/screens/wallet/helpers.ts b/dsm_client/frontend/src/components/screens/wallet/helpers.ts index 5af7d6071..863dc24fb 100644 --- a/dsm_client/frontend/src/components/screens/wallet/helpers.ts +++ b/dsm_client/frontend/src/components/screens/wallet/helpers.ts @@ -15,6 +15,8 @@ export function txTypeLabel(txType: DomainTxType): string { case 'sofi_setup': return 'SETUP'; case 'sofi_trade': return 'TRADE'; case 'sofi_close': return 'CLOSE'; + case 'escrow_lock': return 'LOCK'; + case 'escrow_release': return 'RELEASE'; } } @@ -31,6 +33,8 @@ export function txTypeDetail(txType: DomainTxType): string { case 'sofi_setup': return 'Set up with a liquidity vault'; case 'sofi_trade': return 'Trade'; case 'sofi_close': return 'Liquidity vault closed'; + case 'escrow_lock': return 'Stake locked in an escrow vault'; + case 'escrow_release': return 'Escrow vault released'; } } diff --git a/dsm_client/frontend/src/domain/mappers.ts b/dsm_client/frontend/src/domain/mappers.ts index c9f9bab6c..3ccab6c5f 100644 --- a/dsm_client/frontend/src/domain/mappers.ts +++ b/dsm_client/frontend/src/domain/mappers.ts @@ -96,6 +96,8 @@ const TX_TYPES: Record = { [TransactionType.TX_TYPE_SOFI_SETUP]: 'sofi_setup', [TransactionType.TX_TYPE_SOFI_TRADE]: 'sofi_trade', [TransactionType.TX_TYPE_SOFI_CLOSE]: 'sofi_close', + [TransactionType.TX_TYPE_ESCROW_LOCK]: 'escrow_lock', + [TransactionType.TX_TYPE_ESCROW_RELEASE]: 'escrow_release', }; /** The token and SoFi events: rows that name every token they moved. */ @@ -105,6 +107,8 @@ const EVENT_TYPES: ReadonlySet = new Set([ 'sofi_setup', 'sofi_trade', 'sofi_close', + 'escrow_lock', + 'escrow_release', ]); function txBytes32(t: TransactionInfo, field: string, bytes: Uint8Array): string { diff --git a/dsm_client/frontend/src/domain/types.ts b/dsm_client/frontend/src/domain/types.ts index b98fd3952..c820ebcbc 100644 --- a/dsm_client/frontend/src/domain/types.ts +++ b/dsm_client/frontend/src/domain/types.ts @@ -50,7 +50,9 @@ export type DomainTxType = | 'vault_create' | 'sofi_setup' | 'sofi_trade' - | 'sofi_close'; + | 'sofi_close' + | 'escrow_lock' + | 'escrow_release'; /** One token a token or SoFi event moved, as Rust reported it. */ export type DomainTokenMove = { diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts index c329c3485..ef8b66559 100644 --- a/dsm_client/frontend/src/proto/dsm_app_pb.ts +++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts @@ -158,6 +158,22 @@ export enum TransactionType { * @generated from enum value: TX_TYPE_SOFI_CLOSE = 11; */ TX_TYPE_SOFI_CLOSE = 11, + + /** + * Escrow vaults (SoFi Amendment S21). + * + * an escrow vault created; its stake locked + * + * @generated from enum value: TX_TYPE_ESCROW_LOCK = 12; + */ + TX_TYPE_ESCROW_LOCK = 12, + + /** + * an escrow vault released; its stake credited to the branch's recipient + * + * @generated from enum value: TX_TYPE_ESCROW_RELEASE = 13; + */ + TX_TYPE_ESCROW_RELEASE = 13, } // Retrieve enum metadata with: proto3.getEnumType(TransactionType) proto3.util.setEnumType(TransactionType, "dsm.TransactionType", [ @@ -172,6 +188,8 @@ proto3.util.setEnumType(TransactionType, "dsm.TransactionType", [ { no: 9, name: "TX_TYPE_SOFI_SETUP" }, { no: 10, name: "TX_TYPE_SOFI_TRADE" }, { no: 11, name: "TX_TYPE_SOFI_CLOSE" }, + { no: 12, name: "TX_TYPE_ESCROW_LOCK" }, + { no: 13, name: "TX_TYPE_ESCROW_RELEASE" }, ]); /** diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index 80f6a5a5d..912685a0d 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -83,6 +83,9 @@ enum TransactionType { TX_TYPE_SOFI_SETUP = 9; // set up with a vault; no token moves TX_TYPE_SOFI_TRADE = 10; // a trade or route realized TX_TYPE_SOFI_CLOSE = 11; // the owner's close realized; both reserves credited + // Escrow vaults (SoFi Amendment S21). + TX_TYPE_ESCROW_LOCK = 12; // an escrow vault created; its stake locked + TX_TYPE_ESCROW_RELEASE = 13; // an escrow vault released; its stake credited to the branch's recipient } message EvidenceOracle { diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 18cce09f3..aa022ff38 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -222,7 +222,7 @@ The skeleton (step 6 of the working flow) removes what the specifications forbid | [x] | Device directory | The node's first-write-wins device table, its routes, and the SDK's quorum lookup and token registration | Self-signed entries in a keyed cell (`dsm::core::identity::directory`, `sdk::device_directory`): each device publishes its own AK-signed entry; senders keep the entry whose AK hashes to the device id and whose signature verifies, highest counter first; every caller of the old lookup moved to `read_entry` / `publish_entry` | `dsm::core::identity::directory::tests::the_devices_own_entry_is_chosen_and_a_squatters_is_ignored`; `dsm::core::identity::directory::tests::a_higher_counter_replaces_a_lower_one`; `dsm_sdk::test_support::two_device::tests::a_peer_holds_the_keys_the_device_uses`; `dsm_sdk::handlers::storage_routes::tests::the_sender_ak_is_the_one_the_contact_book_holds` | | [x] | Sealing's key and store | — | `b0x_sdk::seal_for` seals once with the recipient's Kyber key from its verified directory entry and keeps the bytes (`client_db::b0x_sealed`); `open_sealed` decapsulates with this device's Kyber secret | `dsm_sdk::storage::client_db::b0x_sealed::tests::the_first_seal_of_a_message_stands`; `dsm_sdk::handlers::bilateral_finality_tests::r7_a_frozen_checkpoint_is_replayed_byte_identically_after_the_fleet_returns`; `dsm_sdk::handlers::node_e2e_tests::a_transfer_reaches_the_nodes_only_sealed_and_arrives`. The key is not the per-step key of Amendment A7 (see Encrypted spool payloads). | | [x] | Vault genesis acceptance | Never built before #976: SoFi §28 step 5 names `genesis_accepted` | `sofi::lineage::genesis_accepted`, called from `fetch_vault_genesis`: the signed creation bound to the owner transition, the committed market policy re-derived, the pair ordered, both token policies permitting a market leg; `Resolved::Unavailable` there becomes a network failure, not "no genesis" | `dsm::sofi::lineage::genesis_acceptance::the_genesis_the_owners_creation_carried_is_accepted`; `dsm::sofi::lineage::genesis_acceptance::a_genesis_the_owner_did_not_create_is_refused`; `dsm::sofi::lineage::genesis_acceptance::a_carried_market_that_is_not_the_committed_one_is_refused`; `dsm::sofi::lineage::genesis_acceptance::a_vault_over_a_token_that_forbids_transfer_is_refused`; `dsm_sdk::handlers::node_e2e_tests::a_sofi_trade_executes_end_to_end` | -| [x] | Transferable check on every leg | Never called | Token policies in `Evidence.token_policies` (both tokens of every vault, intermediate ones included); `market_legs_permitted` per DLV core in `validate` | `dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg`; `dsm::sofi::validation::tests::an_intermediate_token_must_permit_transfer_too`; `dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing`; `dsm_sdk::handlers::node_e2e_tests::a_sofi_trade_executes_end_to_end` | +| [x] | Transferable check on every leg | Never called | Token policies in `Evidence.token_policies` (both tokens of every vault, intermediate ones included); `vault_tokens_permitted` per DLV core in `validate` | `dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg`; `dsm::sofi::validation::tests::an_intermediate_token_must_permit_transfer_too`; `dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing`; `dsm_sdk::handlers::node_e2e_tests::a_sofi_trade_executes_end_to_end` | | [ ] | Fleet redeploy (GCP) | The deployed fleet runs the old node: device registration, read acknowledgement, plaintext spool, node-side tables | Rebuild the `dsm-storage-node` image and redeploy every node (`dsm_storage_node/deploy/`: Docker Compose with Postgres 15 on the GCP VMs) in the same release as the app; the migrations drop the devices, auth, payment and registry tables and the spool's ack columns on the live databases. Wipe or keep the Postgres volumes is not yet decided; a wipe gives every node a new register incarnation, so the pinned set in the node configs and in the app's `dsm_env_config.gcp_beta.toml` is regenerated and shipped with the app. Terraform provisions 5 nodes (`terraform/gcp/main.tf`, `node_count = 5`), matching the 5 configs; the "6 nodes" in `provision_gcp.sh`'s banner is stale text to correct | Out of scope (fleet operations). provision_gcp.sh still prints six nodes; the env file named is untracked (scripts/dsm_env_config.gcp_beta.toml). | | [ ] | Formal models (G15) | The successor-cell, fulfillment and reserve models encode the copy rule ("LeaderHeld and two other members hold x"); `lean4/DSMTokenIssuance.lean` models the old two-leg creation, including a zero-supply creation the beta refuses | Route-chain models written: `tla/DSM_RouteChain.tla` (chain uniqueness, nesting, stability, survival of two lost seats; three mutation configs that must fail, listed in `tla/README.md`) and `lean4/DSMRouteChain.lean` (compiles on the pinned Lean 4.23.0; chain uniqueness proved; stability and loss survival stated). Remaining: `DSM_SofiSuccessorCells`, `DSM_SofiFulfillment`, `DSM_NativeReserveRelease` and their Lean files take finality from the route chain; `DSMTokenIssuance.lean` rewritten for release at creation; the stated properties proved and every config run under TLC | `lean4/DSMRouteChain.lean::chain_uniqueness`; `lean4/DSMRouteChain.lean::leader_held_stable`; `lean4/DSMRouteChain.lean::final_stable`; `lean4/DSMTokenIssuance.lean::create_with_no_supply_is_refused`. `DSM_RouteChain` runs in CI with its three falsifications (#982). Still open: `FinalSurvivesTwoLosses` is stated, checked by TLC, not proved in Lean; `DSM_SofiSuccessorCells`, `DSM_SofiFulfillment` and `DSM_NativeReserveRelease` and their Lean files still encode the copy rule. | @@ -269,7 +269,7 @@ What the backend does that no requirement asks for, or that a requirement forbid | `dsm_sdk` · sdk/dlv_pre_commitment_sdk.rs · `DlvPreCommitmentSdk` | No handler callers. Deleted (§6.10). | | `dsm_sdk` · sdk/smart_commitment_sdk.rs · `SmartCommitmentSdk` | No callers. | | `dsm` · core/verification/identity_verifier.rs; types/identity.rs; types/state_types.rs (second `IdentityAnchor`) | `IdentityVerifier`, `IdentityClaim`, `IdentityAnchor`. No production callers. | -| `dsm` · economic/issuance.rs · `check_market_leg_permitted` | Had no callers; since #976 it is called by `sofi::validation::market_legs_permitted` and `sofi::lineage::genesis_accepted`. A transfer is still checked on the sending device only (MR-SOFI-0311). | +| `dsm` · economic/issuance.rs · `check_market_leg_permitted` | Had no callers; since #976 it is called by `sofi::validation::vault_tokens_permitted` and `sofi::lineage::genesis_accepted`. A transfer is still checked on the sending device only (MR-SOFI-0311). | | `dsm` · ccb/state.rs · `VaultStateV2.iteration_budget` | Only ever `None`; never read (MR-DSM-0181). | | `dsm_storage_node` · api/identity/authenticate.rs · `authenticate_envelope_smart_policy` | No production caller. | @@ -292,7 +292,7 @@ Against the branch, not the §1 pin. Resolved items state what replaced them; op | Location | Finding | State | |---|---|---| | `dsm` · economic/provenance.rs · `verify_genesis_release` | A policy commit named no creator, so any device holding published policy bytes could `CreateToken` the same token and release its whole genesis supply again (MR-SOFI-0307). | Resolved by SoFi Amendment S8: the policy commits its creator, only the creator's transition releases it, and the creation inserts a `0x0060` record from zero. Named tests with mutation controls: `a_genesis_release_of_another_creators_policy_is_refused`, `a_token_is_created_once_on_its_creators_lineage`, `a_creation_witness_without_its_record_is_refused`. | -| `dsm` · sofi/validation.rs · `setup_valid` | The ClaimRef conjunct of SetupValid (SoFi §16) was not checked; validation evidence carried no claim. | Resolved by SoFi Amendment S9: `AcceptedClaim`, produced only by lineage validation, is carried in `Evidence.accepted_claims`. Named tests with mutation controls for every conjunct of `setup_valid` and of `market_legs_permitted`. | +| `dsm` · sofi/validation.rs · `setup_valid` | The ClaimRef conjunct of SetupValid (SoFi §16) was not checked; validation evidence carried no claim. | Resolved by SoFi Amendment S9: `AcceptedClaim`, produced only by lineage validation, is carried in `Evidence.accepted_claims`. Named tests with mutation controls for every conjunct of `setup_valid` and of `market_legs_permitted` (renamed `vault_tokens_permitted` by SoFi Amendment S21, when it took escrow vaults' one token). | | `dsm` · economic/lineage.rs · `advance_validated` | Never checked that the registered claim names the trader under validation. | Resolved: `RegisteredClaimNamesAnotherTrader`, test `a_registered_claim_of_another_trader_is_refused`. | | `dsm` · route_chain.rs · `evaluate` | Reported two impossible states as missing evidence, counted only the first copy per seat, and its callers re-recognized the held bytes and invented an answer when that failed. | Resolved: the recognized object is returned; every rule of storage §9 has a named test and a mutation control. | | `dsm` · route_chain.rs · `CellEvidence`; economic/native_reserve.rs, economic/register.rs, sofi/exercise.rs, sofi/registration.rs | The route, namespace and key of a cell came inside the evidence the reader handed Core, so Core evaluated whatever route the caller named (storage §9 route rule 1: the writer and Core compute the route). | Resolved: `RoutedCell::new` derives the cell from the seed and the members, refusing members that do not re-derive the committed set id; each cell kind has a handle only Core builds (`SuccessorCell`, `RootCell`, `AttemptCell`, `PositionCells`); evidence carries seat reads only. Mutation controls: `a_cell_is_routed_only_over_the_committed_set`, `a_successor_cell_is_routed_over_the_set_its_parent_commits`. | @@ -1435,7 +1435,7 @@ The State column says what this branch did; "Open" rows are holes left visible, **H. Stale rows corrected in this pass** -The node crate on main is `api/cells.rs`, `api/objects/{immutable,bytecommit}.rs`, `api/transport/b0x.rs` and `db/`: the legacy object store, its routes, `device_auth`, PaidK, `replication.rs`, the registry (`api/registry/scaling.rs`), the tip mirror and the vault routes are all deleted (#977, #992). Rows that still classified the node on that code are re-examined below: G4's Violated rows on overwrite, delete, writer authentication and payload decoding are Met on the negative tests that hold the served assembly to the storage contract; the PaidK, credit and node-side registry rows are Missing, since nothing replaced a mechanism the specification asks for (§5: the spend gate is removed rather than fixed; storage credits and Part III are out of beta). MR-SOFI-0311's "no callers" was stale since #976: `check_market_leg_permitted` is called by `sofi::validation::market_legs_permitted` and `sofi::lineage::genesis_accepted`; a transfer is checked on the sending device only. §6.3, §6.9 and §6.34 C4 are corrected in place. +The node crate on main is `api/cells.rs`, `api/objects/{immutable,bytecommit}.rs`, `api/transport/b0x.rs` and `db/`: the legacy object store, its routes, `device_auth`, PaidK, `replication.rs`, the registry (`api/registry/scaling.rs`), the tip mirror and the vault routes are all deleted (#977, #992). Rows that still classified the node on that code are re-examined below: G4's Violated rows on overwrite, delete, writer authentication and payload decoding are Met on the negative tests that hold the served assembly to the storage contract; the PaidK, credit and node-side registry rows are Missing, since nothing replaced a mechanism the specification asks for (§5: the spend gate is removed rather than fixed; storage credits and Part III are out of beta). MR-SOFI-0311's "no callers" was stale since #976: `check_market_leg_permitted` is called by `sofi::validation::vault_tokens_permitted` and `sofi::lineage::genesis_accepted`; a transfer is checked on the sending device only. §6.3, §6.9 and §6.34 C4 are corrected in place. **I. Hardware crates (out of this round; recorded, not audited)** — the secure-monitor reset entry is a bring-up placeholder and the non-secure image a bring-up stub; the non-secure side is granted the reset and watchdog peripherals; the Pico re-enrols on every boot and R-memory persistence is not built; an example carries a fixed handshake secret. @@ -2725,7 +2725,7 @@ Outside this round: the anchor firmware's signing call sites turn an error into **Open.** On BLE the terms ride beside the operation in the clear, as the prepare always has: BLE is a direct link between the two parties, and the ruling chose it. `TokenSDK`'s generic transfer and its token-creation fee transfer commit to terms that nothing carries, so a recipient could not open them. Neither is reached today: `TokenOperation::Transfer` is built nowhere outside `TokenSDK`, and the one `TokenOperation::Create` the SDK builds (dBTC registration) charges no fee. -### 6.73 No vault could hold value for anyone but a market: escrow vaults specified (`feat/escrow-vaults`, SoFi Amendment S21, 2026-10-05) +### 6.74 No vault could hold value for anyone but a market: escrow vaults specified (`feat/escrow-vaults`, SoFi Amendment S21, 2026-10-05) **The finding.** An application needed two parties to lock equal stakes against one agreed match, with the application as referee deciding who takes both. Nothing in the backend can hold value under a condition other than a SoFi market: - the economic tree has no encumbered leaf, and the legacy DLV claim operations are refused as value transitions (`DlvClaim { .. } | DlvInvalidate { .. } => UnsupportedValueTransition`, `dsm::economic::classifier`); @@ -3341,21 +3341,21 @@ Outside this round: the anchor firmware's signing call sites turn an error into | MR-SOFI-0301 | Partial | dsm · ccb/state.rs (MarketPolicy names token policy commits, no own token policy) | no test found | Not independently re-verified in depth | | MR-SOFI-0302 | Met | `dsm_sdk::handlers::token_routes::build_policy_v3_bytes`; `dsm::economic::token_policy::parse_token_policy_blob` | `dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from`; `dsm_sdk::handlers::token_routes::tests::a_multi_signer_threshold_round_trips` | token_create_anchor_integrity.rs was deleted in #977; the packer is the only production packer, and its in-file tests round-trip every field through Core's big-endian parser. | | MR-SOFI-0303 | Partial | `dsm::economic::token_policy::parse_token_policy_blob`; `dsm_sdk::handlers::token_routes::build_policy_v3_bytes` | `dsm_sdk::handlers::token_routes::tests::a_bad_ticker_or_decimals_is_refused`; `dsm_sdk::handlers::token_routes::tests::an_empty_or_oversized_signer_set_is_refused`; `dsm_sdk::handlers::token_routes::tests::an_unsatisfiable_threshold_is_refused`; `dsm::economic::token_policy::tests::every_rule_refuses_its_violation` | The bounds are enforced, and tests refuse 0 and 17 signers, a 1-character ticker and 19 decimals, but no test refuses a 9-character ticker, and the signer-set authorization clause is violated (see MR-SOFI-0310). | -| MR-SOFI-0304 | Met | `dsm::core::token::policy::TokenPolicySystem::register_policy`; `dsm::core::token::policy::TokenPolicySystem::commitment_of`; `dsm::sofi::validation::market_legs_permitted` | `dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone`; `dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for`; `dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from` | A token's identity is `BLAKE3(TAG_DSM_POLICY, TokenPolicyV3 bytes)`, recomputed by Core wherever the bytes are registered or read: the enforcer is keyed by it, and bytes at another commitment are no policy (§6.32). | +| MR-SOFI-0304 | Met | `dsm::core::token::policy::TokenPolicySystem::register_policy`; `dsm::core::token::policy::TokenPolicySystem::commitment_of`; `dsm::sofi::validation::vault_tokens_permitted` | `dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone`; `dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for`; `dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from` | A token's identity is `BLAKE3(TAG_DSM_POLICY, TokenPolicyV3 bytes)`, recomputed by Core wherever the bytes are registered or read: the enforcer is keyed by it, and bytes at another commitment are no policy (§6.32). | | MR-SOFI-0305 | Violated | `dsm_sdk::storage::client_db::token_registry::get_token_by_ticker`; `dsm_sdk::handlers::token_routes` | — | Core identity is policy_commit, but the SDK token registry has a UNIQUE ticker index, and token.create and tokens.addByAnchor refuse a second token whose ticker is already held (TICKER_CONFLICT), so two tokens cannot share a ticker on one device. | | MR-SOFI-0306 | Met | `dsm::types::policy_types::PolicyCondition`; `dsm::economic::token_policy::parse_token_policy_blob` | `dsm::economic::token_policy::tests::a_zero_genesis_supply_is_refused`; `dsm::economic::token_policy::tests::every_rule_refuses_its_violation`; `dsm_sdk::supply_cap_enforcement::supply_cap_denies_a_creation_that_would_exceed_it` | The supply class is a field of the committed blob, parsed strictly; `SupplyCap` carries only `max_supply` (the unlimited flag is a reserved proto field since a592900a), derived from the parsed blob whose zero genesis supply does not parse (the `PolicyFile` validator is deleted, §6.32). The earlier Violated stood on `POLICY_FLAG_UNLIMITED_SUPPLY` and `check_issuance_permitted`, both deleted. | | MR-SOFI-0307 | Partial | `dsm::economic::write_set::build_write_set`; `dsm::types::device_state::validate_conservation`; `dsm::economic::provenance::verify_genesis_release` | `dsm_sdk::handlers::node_e2e_tests::a_created_token_releases_its_whole_genesis_supply_to_its_creator`; `dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage`; `dsm::economic_write_set::a_burn_round_trips_and_removes_a_zeroed_balance` | No mint operation exists: the whole genesis supply is released to the creator in the creating transition (so nothing is unreleased), a creation happens once per lineage, and a burn is a debit under the conservation guard. The identity genesis = unreleased + balances + burned is not asserted as a sum by any test, and emission as a release under a policy other than AllAtCreation has no producer. The earlier Violated stood on `token.mint` and `handle_token_mint`, deleted in #976/#977. | | MR-SOFI-0308 | Missing | dsm · economic/issuance.rs (module doc explicitly: "DOES NOT PROVE... redeemable for, or collateralized by... no backing condition in the token-policy vocabulary") | — | Confirmed by direct read: no backing/lock/reserve concept anywhere | | MR-SOFI-0309 | Missing | dsm · economic/issuance.rs (module doc explicitly: "DOES NOT PROVE... redeemable for, or collateralized by... no backing condition in the token-policy vocabulary") | — | Confirmed by direct read: no backing/lock/reserve concept anywhere | | MR-SOFI-0310 | Met | `dsm::core::token::policy::policy_enforcement::permitted_operations`; `dsm::core::token::policy::policy_enforcement::enforced_policy`; `dsm::core::token::policy::policy_enforcement::PolicyEnforcer::check_condition` | `dsm::core::token::policy::policy_enforcement::tests::the_policy_permits_exactly_what_its_flags_name`; `dsm_sdk::handlers::sender_admission_tests::a_burn_disabled_token_refuses_its_burn` | The signer set authorizes nothing in beta: the standard release rule names it for nothing (§47). A token's conditions are its supply cap and the operation restriction its two flags define; the signer-set condition and its witness are deleted (§6.17). | -| MR-SOFI-0311 | Partial | `dsm::economic::issuance::check_market_leg_permitted`; `dsm::sofi::validation::market_legs_permitted`; `dsm::sofi::lineage::genesis_accepted` | `dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg`; `dsm::sofi::lineage::genesis_acceptance::a_vault_over_a_token_that_forbids_transfer_is_refused`; `dsm_sdk::handlers::sender_admission_tests::a_non_transferable_token_refuses_its_transfer`; `dsm_sdk::handlers::sender_admission_tests::a_transfer_its_policy_refuses_is_refused_before_the_senders_register_is_read` | Called at vault creation and on every SoFi leg since #976 (the earlier "zero callers" was stale). An online transfer is checked on both devices (§6.51): the sender refuses it and nothing moves, and the receiver refuses it at its canonical apply and in its sync before any read. Partial: offline transfers are outside this round, and vault creation and SoFi legs are tested in Core only. | +| MR-SOFI-0311 | Partial | `dsm::economic::issuance::check_market_leg_permitted`; `dsm::sofi::validation::vault_tokens_permitted`; `dsm::sofi::lineage::genesis_accepted` | `dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg`; `dsm::sofi::lineage::genesis_acceptance::a_vault_over_a_token_that_forbids_transfer_is_refused`; `dsm_sdk::handlers::sender_admission_tests::a_non_transferable_token_refuses_its_transfer`; `dsm_sdk::handlers::sender_admission_tests::a_transfer_its_policy_refuses_is_refused_before_the_senders_register_is_read` | Called at vault creation and on every SoFi leg since #976 (the earlier "zero callers" was stale). An online transfer is checked on both devices (§6.51): the sender refuses it and nothing moves, and the receiver refuses it at its canonical apply and in its sync before any read. Partial: offline transfers are outside this round, and vault creation and SoFi legs are tested in Core only. | | MR-SOFI-0312 | Partial | `dsm::economic::issuance::check_market_leg_permitted`; `dsm::economic::token_policy::parse_token_policy_blob` | — | check_issuance_permitted was deleted in #976, and nothing now reads allowlist_device_ids on issuance (verify_genesis_release ignores it); only the no-market-meaning half holds, because check_market_leg_permitted never consults the allowlist, and no test covers either half. | | MR-SOFI-0313 | Partial | dsm_sdk · handlers/token_routes.rs · `parse_token_policy` (fail-closed on every field) | inline parse checks verified | No explicit supply-class field (only the unlimited flag), no separate native/backed sub-shapes. ERA states no policy at all: `ERA_POLICY_COMMIT` has no preimage, so no ERA transfer or burn passes enforcement (§6.32). | | MR-SOFI-0314 | Met | `dsm_sdk::handlers::token_routes::build_policy_v3_bytes`; `dsm::economic::token_policy::parse_token_policy_blob` | `dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from`; `dsm_sdk::handlers::node_e2e_tests::a_created_token_releases_its_whole_genesis_supply_to_its_creator` | The genesis supply is a field of the blob whose hash is the policy commit, and the creating transition releases exactly it. The earlier Partial stood on the capped-creation refusal, deleted in a592900a. | | MR-SOFI-0315 | Met | `dsm::core::token::policy::TokenPolicySystem::register_policy`; `dsm::core::token::policy::TokenPolicySystem::enforce_policy`; `dsm::types::device_state::validate_conservation` | `dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for`; `dsm_sdk::handlers::sender_admission_tests::a_burn_disabled_token_refuses_its_burn` | No path changes a committed policy: the enforcer's view is derived from the bytes at the commitment and from nothing a caller states, and units move only under its operation restriction and the conservation guard. | | MR-SOFI-0316 | Partial | `dsm::economic::token_policy::ReleaseRule`; `dsm::economic::write_set::build_write_set`; `dsm::economic::native_reserve::release_constructible` | `dsm_sdk::handlers::node_e2e_tests::a_created_token_releases_its_whole_genesis_supply_to_its_creator`; `dsm::economic::native_reserve::tests::a_release_of_any_amount_but_the_payout_never_holds_the_cell`; `dsm_sdk::handlers::faucet_flow_tests::a_release_of_the_whole_supply_holds_nothing_and_the_claim_lands`; `dsm::native_reserve_wire::credits_funded_along_a_lineage_sum_to_what_the_reserve_released`; `dsm::economic::native_reserve::tests::a_release_signed_by_any_key_but_the_recipient_devices_never_holds_the_cell`; `dsm_sdk::handlers::faucet_flow_tests::a_release_naming_a_device_its_key_does_not_derive_holds_nothing` | The only token release rule is `AllAtCreation`, under which no unit stays unreleased; a release formula for any other rule, recomputable by anyone, has no producer. The earlier citation of `handle_token_mint` names code deleted in #977. The native reserve's construction predicate enforces the beta claim policy's amount: a faucet release is constructible only for `ERA_FAUCET_PAYOUT`, so a release the policy does not allow never holds a cell (§6.24). The signer is bound to the recipient device at the cell (`derive_devid(claimant_public_key, AttA) == recipient_devid`, §6.26); that the device belongs to `recipient_genesis` is proven by P0–P6 when a credit consumes the release. | | MR-SOFI-0317 | Met | `dsm::economic::write_set::build_write_set`; `dsm::economic::provenance::verify_genesis_release` | `dsm::economic_write_set::a_burn_round_trips_and_removes_a_zeroed_balance`; `dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage` | There is no mint operation and no unreleased pool, a burn is a debit, and the whole supply is released once because a second creation of the same commit cannot build its write set. | -| MR-SOFI-0318 | Met | `dsm::sofi::validation::market_legs_permitted`; `dsm::economic::token_policy::parse_token_policy`; `dsm::economic::provenance::verify_genesis_release` | `dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing`; `dsm::sofi::validation::tests::a_token_whose_committed_policy_does_not_parse_cannot_be_a_market_leg`; `dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg` | parse_issuance_policy was deleted in #976; each non-builtin token's policy bytes are re-hashed to its own commit and parsed strictly, while ERA and dBTC are pre-rooted and read no policy bytes — the same absence the enforcer refuses on (§6.32). | +| MR-SOFI-0318 | Met | `dsm::sofi::validation::vault_tokens_permitted`; `dsm::economic::token_policy::parse_token_policy`; `dsm::economic::provenance::verify_genesis_release` | `dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing`; `dsm::sofi::validation::tests::a_token_whose_committed_policy_does_not_parse_cannot_be_a_market_leg`; `dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg` | parse_issuance_policy was deleted in #976; each non-builtin token's policy bytes are re-hashed to its own commit and parsed strictly, while ERA and dBTC are pre-rooted and read no policy bytes — the same absence the enforcer refuses on (§6.32). | | MR-SOFI-0319 | Missing | — | — | No backing/lock-consumption concept — same absence as 0308 | | MR-SOFI-0320 | Missing | — | — | No backing/lock-consumption concept — same absence as 0308 | | MR-SOFI-0321 | Missing | — | — | No redemption transition anywhere | @@ -3400,30 +3400,30 @@ Outside this round: the anchor firmware's signing call sites turn an error into | MR-SOFI-0360 | Met | `dsm::sofi::wire::objects::SignedSofiResolutionClaim`; `dsm::sofi::signature::verify_resolution_claim`; `dsm::sofi::signature::sign_resolution_claim`; `dsm::economic::register::read_root_cell` (body identity); `dsm_sdk::sdk::sofi_register` (producer); `dsm_sdk::sdk::sofi_relay::carry_pair`; `dsm::sofi::registration::RegistrationRead::standing_of`; `dsm::sofi::resolve::Verifier::peer_position` | `dsm::economic::claim_envelope::tests::a_conditional_cell_decodes_by_class`; `dsm::economic::claim_envelope::tests::an_unsigned_conditional_claim_names_no_cell`; `dsm::economic::claim_envelope::tests::a_conditional_claim_signed_by_another_device_names_no_cell`; `dsm::economic::register::registered_root_construction_tests::a_held_root_cell_carries_the_exact_bytes_that_hold_it`; `dsm::sofi::registration::tests::a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own` | SoFi Amendment S20 (§6.65). `K_root(q)` holds the trader-signed `C_q` (`0x0062`); a bare `C_q` is refused by name, and a conditional claim is identified by its derived body. S20 extended 2026-10-01 (§6.65, §6.66 12j): registration matches the pair by `FulfillmentId(F)`; where `P` is in hand the body is compared with `derive(P, F)`. In the facts a difference loses `F` (`standing_of`); in a position's resolution it is Invalid (`peer_position`) (`51359a6b6`). Mutation: the body check skipped for a registered pair → the unit test red. The resolution check has no test of its own yet. | | MR-SOFI-0361 | Met | `dsm::sofi::wire::objects::TraderFulfillmentBody`; `dsm::sofi::registration::fulfillment_proves_the_device`; `dsm::sofi::registration::names_fulfillment_key`; `dsm_sdk::sdk::sofi_sdk::build_fulfillment` | `dsm::sofi::registration::tests::a_fulfillment_under_a_key_that_does_not_derive_the_trader_names_no_cell`; `dsm::sofi::registration::tests::only_a_fulfillment_of_this_trader_at_this_position_names_the_key`; `dsm_sdk::handlers::node_e2e_tests::a_position_reads_the_same_before_and_after_a_precommit_is_published` | SoFi Amendment S20 (§6.65). The binding is decided from `F`'s bytes before `P` is looked up. Mutation: the check removed → that test red. S20 extended 2026-10-01 (§6.65, §6.66 12j): `names_fulfillment_key` decides from `F`'s bytes alone and reads no `P`, so which `F` holds `K_ful(q)` is the same at every time (`51359a6b6`). | | MR-SOFI-0362 | Met | `dsm::sofi::exercise::recognize_exercise`; `dsm::sofi::wire::objects::SofiExercise`; `dsm_sdk::sdk::sofi_exercise::build_exercise`; `dsm_sdk::sdk::sofi_relay::relay_exercise`; `dsm_sdk::sdk::sofi_flow::walk_for_attempt` | `dsm_sdk::handlers::node_e2e_tests::an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes`; `dsm_sdk::handlers::node_e2e_tests::a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair`; `dsm::sofi::exercise::tests::an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f`; `dsm::sofi::exercise::tests::an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing` | §6.66 12e. The exercise carries the trader's signed `C_q`, recognized as `K_root(q)` recognizes it with the body `derive(P, F)`; the next operation at a vault registers a withheld pair from it before passing the key (owner ruling, 2026-10-01). | -| MR-SOFI-0363 | Missing | — | — | Added by Amendment S21 (§6.73): the slot rule; `VaultTerms` does not exist yet, and terms resolve as a market only. | -| MR-SOFI-0364 | Missing | — | — | Added by Amendment S21 (§6.73): `EscrowTerms` does not exist yet. | -| MR-SOFI-0365 | Missing | — | — | Added by Amendment S21 (§6.73): the outcome table, its one encoding and `τ` do not exist yet. | -| MR-SOFI-0366 | Missing | — | — | Added by Amendment S21 (§6.73): the verdict cell key, seed and leader do not exist yet. | -| MR-SOFI-0367 | Missing | — | — | Added by Amendment S21 (§6.73): the escrow statement does not exist yet. | -| MR-SOFI-0368 | Missing | — | — | Added by Amendment S21 (§6.73): recognition at the verdict cell does not exist yet. | -| MR-SOFI-0369 | Missing | — | — | Added by Amendment S21 (§6.73): `VerdictHeld` and `VerdictFinal` do not exist yet. | -| MR-SOFI-0370 | Missing | — | — | Added by Amendment S21 (§6.73): the statement locator and gathered verdict objects do not exist yet. | -| MR-SOFI-0371 | Missing | — | — | Added by Amendment S21 (§6.73): operation tag 38 does not exist yet. | -| MR-SOFI-0372 | Missing | — | — | Added by Amendment S21 (§6.73): `creation_accepted` has no escrow form. | -| MR-SOFI-0373 | Missing | — | — | Added by Amendment S21 (§6.73): the cell locator does not exist yet. | -| MR-SOFI-0374 | Missing | — | — | Added by Amendment S21 (§6.73): `SettlementBody` has no Release branch. | -| MR-SOFI-0375 | Missing | — | — | Added by Amendment S21 (§6.73): the Release write set does not exist yet. | -| MR-SOFI-0376 | Missing | — | — | Added by Amendment S21 (§6.73): `validate_release` does not exist yet. | -| MR-SOFI-0377 | Missing | — | — | Added by Amendment S21 (§6.73): `Policies::resolve` re-addresses each slot under its market, fee or release class, so terms at `A_T` would leave a Close or Swap waiting on a non-verifying object (`Missing::NonVerifyingObject`), not Invalid; the refusal by terms lands with `VaultTerms`, and no Release exists. | -| MR-SOFI-0378 | Missing | — | — | Added by Amendment S21 (§6.73): `consumed_route` has no verdict conjunct. | -| MR-SOFI-0379 | Missing | — | — | Added by Amendment S21 (§6.73): `route_impossible` has four arms. | -| MR-SOFI-0380 | Missing | — | — | Added by Amendment S21 (§6.73): `resolve_position` has no verdict fact. | -| MR-SOFI-0381 | Missing | — | — | Added by Amendment S21 (§6.73): no vault is bound to a verdict cell yet. | -| MR-SOFI-0382 | Missing | — | — | Added by Amendment S21 (§6.73): S15 resolution reads no verdict cell. | -| MR-SOFI-0383 | Missing | — | — | Added by Amendment S21 (§6.73): no escrow vault exists, so no stake is locked. | -| MR-SOFI-0384 | Missing | — | — | Added by Amendment S21 (§6.73): the escrow routes do not exist yet. | -| MR-SOFI-0385 | Missing | — | — | Added by Amendment S21 (§6.73): no escrow vault is bound to a verdict cell yet, so nothing derives a link. | -| MR-SOFI-0386 | Missing | — | — | Added by Amendment S21 (§6.73): `escrow.create` and the escrow genesis acceptance do not exist yet. | +| MR-SOFI-0363 | Missing | — | — | Added by Amendment S21 (§6.74): the slot rule; `VaultTerms` does not exist yet, and terms resolve as a market only. | +| MR-SOFI-0364 | Missing | — | — | Added by Amendment S21 (§6.74): `EscrowTerms` does not exist yet. | +| MR-SOFI-0365 | Missing | — | — | Added by Amendment S21 (§6.74): the outcome table, its one encoding and `τ` do not exist yet. | +| MR-SOFI-0366 | Missing | — | — | Added by Amendment S21 (§6.74): the verdict cell key, seed and leader do not exist yet. | +| MR-SOFI-0367 | Missing | — | — | Added by Amendment S21 (§6.74): the escrow statement does not exist yet. | +| MR-SOFI-0368 | Missing | — | — | Added by Amendment S21 (§6.74): recognition at the verdict cell does not exist yet. | +| MR-SOFI-0369 | Missing | — | — | Added by Amendment S21 (§6.74): `VerdictHeld` and `VerdictFinal` do not exist yet. | +| MR-SOFI-0370 | Missing | — | — | Added by Amendment S21 (§6.74): the statement locator and gathered verdict objects do not exist yet. | +| MR-SOFI-0371 | Missing | — | — | Added by Amendment S21 (§6.74): operation tag 38 does not exist yet. | +| MR-SOFI-0372 | Missing | — | — | Added by Amendment S21 (§6.74): `creation_accepted` has no escrow form. | +| MR-SOFI-0373 | Missing | — | — | Added by Amendment S21 (§6.74): the cell locator does not exist yet. | +| MR-SOFI-0374 | Missing | — | — | Added by Amendment S21 (§6.74): `SettlementBody` has no Release branch. | +| MR-SOFI-0375 | Missing | — | — | Added by Amendment S21 (§6.74): the Release write set does not exist yet. | +| MR-SOFI-0376 | Missing | — | — | Added by Amendment S21 (§6.74): `validate_release` does not exist yet. | +| MR-SOFI-0377 | Missing | — | — | Added by Amendment S21 (§6.74): `Policies::resolve` re-addresses each slot under its market, fee or release class, so terms at `A_T` would leave a Close or Swap waiting on a non-verifying object (`Missing::NonVerifyingObject`), not Invalid; the refusal by terms lands with `VaultTerms`, and no Release exists. | +| MR-SOFI-0378 | Missing | — | — | Added by Amendment S21 (§6.74): `consumed_route` has no verdict conjunct. | +| MR-SOFI-0379 | Missing | — | — | Added by Amendment S21 (§6.74): `route_impossible` has four arms. | +| MR-SOFI-0380 | Missing | — | — | Added by Amendment S21 (§6.74): `resolve_position` has no verdict fact. | +| MR-SOFI-0381 | Missing | — | — | Added by Amendment S21 (§6.74): no vault is bound to a verdict cell yet. | +| MR-SOFI-0382 | Missing | — | — | Added by Amendment S21 (§6.74): S15 resolution reads no verdict cell. | +| MR-SOFI-0383 | Missing | — | — | Added by Amendment S21 (§6.74): no escrow vault exists, so no stake is locked. | +| MR-SOFI-0384 | Missing | — | — | Added by Amendment S21 (§6.74): the escrow routes do not exist yet. | +| MR-SOFI-0385 | Missing | — | — | Added by Amendment S21 (§6.74): no escrow vault is bound to a verdict cell yet, so nothing derives a link. | +| MR-SOFI-0386 | Missing | — | — | Added by Amendment S21 (§6.74): `escrow.create` and the escrow genesis acceptance do not exist yet. | ### 8.3 dBTC native specification diff --git a/specs/requirements/INTENT_MANIFEST.tsv b/specs/requirements/INTENT_MANIFEST.tsv index 3ef7ec4c7..ade593b9f 100644 --- a/specs/requirements/INTENT_MANIFEST.tsv +++ b/specs/requirements/INTENT_MANIFEST.tsv @@ -475,7 +475,7 @@ MR-SOFI-0303 dsm::economic::token_policy::parse_token_policy_blob android MUST_R MR-SOFI-0303 dsm_sdk::handlers::token_routes::build_policy_v3_bytes android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm_sdk::handlers::token_routes::tests::a_bad_ticker_or_decimals_is_refused;dsm_sdk::handlers::token_routes::tests::an_empty_or_oversized_signer_set_is_refused;dsm_sdk::handlers::token_routes::tests::an_unsatisfiable_threshold_is_refused;dsm::economic::token_policy::tests::every_rule_refuses_its_violation - CONFORMANCE §8 MR-SOFI-0303 (Partial) MR-SOFI-0304 dsm::core::token::policy::TokenPolicySystem::commitment_of android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone;dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for;dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from - CONFORMANCE §8 MR-SOFI-0304 (Met) MR-SOFI-0304 dsm::core::token::policy::TokenPolicySystem::register_policy android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone;dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for;dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from - CONFORMANCE §8 MR-SOFI-0304 (Met) -MR-SOFI-0304 dsm::sofi::validation::market_legs_permitted android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone;dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for;dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from - CONFORMANCE §8 MR-SOFI-0304 (Met) +MR-SOFI-0304 dsm::sofi::validation::vault_tokens_permitted android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::core::token::policy::tests::a_policy_is_registered_from_its_committed_bytes_alone;dsm::core::token::policy::tests::bytes_at_another_commitment_are_not_the_policy_asked_for;dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from - CONFORMANCE §8 MR-SOFI-0304 (Met) MR-SOFI-0306 dsm::economic::token_policy::parse_token_policy_blob android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::economic::token_policy::tests::a_zero_genesis_supply_is_refused;dsm::economic::token_policy::tests::every_rule_refuses_its_violation;dsm_sdk::supply_cap_enforcement::supply_cap_denies_a_creation_that_would_exceed_it - CONFORMANCE §8 MR-SOFI-0306 (Met) MR-SOFI-0306 dsm::types::policy_types::PolicyCondition android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::economic::token_policy::tests::a_zero_genesis_supply_is_refused;dsm::economic::token_policy::tests::every_rule_refuses_its_violation;dsm_sdk::supply_cap_enforcement::supply_cap_denies_a_creation_that_would_exceed_it - CONFORMANCE §8 MR-SOFI-0306 (Met) MR-SOFI-0307 dsm::economic::provenance::verify_genesis_release android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm_sdk::handlers::node_e2e_tests::a_created_token_releases_its_whole_genesis_supply_to_its_creator;dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage;dsm::economic_write_set::a_burn_round_trips_and_removes_a_zeroed_balance - CONFORMANCE §8 MR-SOFI-0307 (Partial) @@ -486,7 +486,7 @@ MR-SOFI-0310 dsm::core::token::policy::policy_enforcement::enforced_policy andro MR-SOFI-0310 dsm::core::token::policy::policy_enforcement::permitted_operations android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::core::token::policy::policy_enforcement::tests::the_policy_permits_exactly_what_its_flags_name;dsm_sdk::handlers::sender_admission_tests::a_burn_disabled_token_refuses_its_burn - CONFORMANCE §8 MR-SOFI-0310 (Met) MR-SOFI-0311 dsm::economic::issuance::check_market_leg_permitted android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg;dsm::sofi::lineage::genesis_acceptance::a_vault_over_a_token_that_forbids_transfer_is_refused - CONFORMANCE §8 MR-SOFI-0311 (Partial) MR-SOFI-0311 dsm::sofi::lineage::genesis_accepted android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg;dsm::sofi::lineage::genesis_acceptance::a_vault_over_a_token_that_forbids_transfer_is_refused - CONFORMANCE §8 MR-SOFI-0311 (Partial) -MR-SOFI-0311 dsm::sofi::validation::market_legs_permitted android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg;dsm::sofi::lineage::genesis_acceptance::a_vault_over_a_token_that_forbids_transfer_is_refused - CONFORMANCE §8 MR-SOFI-0311 (Partial) +MR-SOFI-0311 dsm::sofi::validation::vault_tokens_permitted android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg;dsm::sofi::lineage::genesis_acceptance::a_vault_over_a_token_that_forbids_transfer_is_refused - CONFORMANCE §8 MR-SOFI-0311 (Partial) MR-SOFI-0312 dsm::economic::issuance::check_market_leg_permitted android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress - - CONFORMANCE §8 MR-SOFI-0312 (Partial) MR-SOFI-0312 dsm::economic::token_policy::parse_token_policy_blob android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress - - CONFORMANCE §8 MR-SOFI-0312 (Partial) MR-SOFI-0314 dsm::economic::token_policy::parse_token_policy_blob android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm_sdk::handlers::token_routes::tests::the_packed_policy_parses_to_every_field_it_was_packed_from;dsm_sdk::handlers::node_e2e_tests::a_created_token_releases_its_whole_genesis_supply_to_its_creator - CONFORMANCE §8 MR-SOFI-0314 (Met) @@ -501,7 +501,7 @@ MR-SOFI-0317 dsm::economic::provenance::verify_genesis_release android MUST_REAC MR-SOFI-0317 dsm::economic::write_set::build_write_set android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::economic_write_set::a_burn_round_trips_and_removes_a_zeroed_balance;dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage - CONFORMANCE §8 MR-SOFI-0317 (Met) MR-SOFI-0318 dsm::economic::provenance::verify_genesis_release android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing;dsm::sofi::validation::tests::a_token_whose_committed_policy_does_not_parse_cannot_be_a_market_leg;dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg - CONFORMANCE §8 MR-SOFI-0318 (Met) MR-SOFI-0318 dsm::economic::token_policy::parse_token_policy android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing;dsm::sofi::validation::tests::a_token_whose_committed_policy_does_not_parse_cannot_be_a_market_leg;dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg - CONFORMANCE §8 MR-SOFI-0318 (Met) -MR-SOFI-0318 dsm::sofi::validation::market_legs_permitted android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing;dsm::sofi::validation::tests::a_token_whose_committed_policy_does_not_parse_cannot_be_a_market_leg;dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg - CONFORMANCE §8 MR-SOFI-0318 (Met) +MR-SOFI-0318 dsm::sofi::validation::vault_tokens_permitted android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::sofi::validation::tests::a_market_tokens_policy_not_in_hand_is_missing;dsm::sofi::validation::tests::a_token_whose_committed_policy_does_not_parse_cannot_be_a_market_leg;dsm::sofi::validation::tests::a_token_whose_policy_forbids_transfer_cannot_be_a_market_leg - CONFORMANCE §8 MR-SOFI-0318 (Met) MR-SOFI-0322 dsm::economic::keys::token_creation_key android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage;dsm::economic_provenance_semantics::a_genesis_release_of_another_creators_policy_is_refused - CONFORMANCE §8 MR-SOFI-0322 (Partial) MR-SOFI-0322 dsm::economic::provenance::verify_genesis_release android MUST_REACH active dsm_sdk::jni::unified_protobuf_bridge::Java_com_dsm_wallet_bridge_UnifiedNativeApi_dispatchIngress dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage;dsm::economic_provenance_semantics::a_genesis_release_of_another_creators_policy_is_refused - CONFORMANCE §8 MR-SOFI-0322 (Partial) MR-SOFI-0330 dsm::sofi::exercise::check_attempt_completion android MUST_REACH active - dsm::sofi::registration::tests::a_final_fulfillment_has_a_completion_proof_that_checks;dsm::sofi::exercise::tests::a_final_exercise_has_a_completion_proof_that_checks - CONFORMANCE §8 MR-SOFI-0330 (Partial) diff --git a/specs/requirements/MASTER_REQUIREMENTS.md b/specs/requirements/MASTER_REQUIREMENTS.md index 7fb27c8b1..8d67e2a79 100644 --- a/specs/requirements/MASTER_REQUIREMENTS.md +++ b/specs/requirements/MASTER_REQUIREMENTS.md @@ -161,7 +161,7 @@ Each extraction also has a Findings table: - **Credit sources held to account (2026-10-01).** Security pre-audit item 3: under A8's one-hop rule a credit's source step was validated, but the source's earlier positions were authenticated as root claims only, so a source could register an invented root and pay out of it through a second wallet of its own to an honest receiver. Owner ruling (2026-10-01, quoted in A8): "Apply A8 transitively to credit sources", every transition in the source's segment verified back to the latest frontier the receiver trusts for that identity, a frontier trusted only after the complete segment to it passed, "admitted" or "signed" never proof of valid ancestry, budget exhaustion before a trusted frontier incomplete and never accepted, validated frontiers cached so later interactions validate only the suffix; succinct recursive validity proofs are the longer-term replacement. DSM Amendment A8 corrected (the One hop bullet replaced by Credit sources; the frontier and never-read bullets follow it) and SoFi S15's SetupValid wording aligned. MR-DSM-0273, 0274 and 0275 and MR-SOFI-0347 rewritten; §1 re-pinned. - **The relationship-key tag (2026-09-30).** Conformance finding (CONFORMANCE §6.14, MR-DSM-0115, MR-DSM-0249): the explainer derived a relationship's SMT key under `DSM/smt-key/v1`, while the code and its golden vector use `DSM/smt-key`. Owner ruling: `DSM/smt-key` is the canonical relationship-SMT domain tag for beta, and `/v1` in the specification was an error (DSM Amendment A9). The formula in §26 and the example in §16 are corrected; no key migrates. MR-DSM-0115 rewritten; §1 re-pinned. - **Vaults found by their tokens, setup inside the trade, and routes that split (2026-10-01).** Phone-rig findings: `sofi.findRoute` searched only the vaults the trader had set up with, so the rig's first trade went through only because the trader set up by hand; and the owner ruled that two vaults of one pair are no different from two of different pairs, so one order may fill through both. Owner decisions: a vault is indexed under each of its tokens and found there with no authority taken from the index, path search reads the two tokens' indexes, the first operation through a vault admits its setup first, `sofi.vaults` replaces `sofi.setup` among the eight routes, a walk resolves another trader's conditional parent through frontier-relative verification (SoFi Amendment S16); and a Swap route's hops chain or split (SoFi Amendment S19). MR-SOFI-0350–0359 added with source `amendment`; MR-SOFI-0255 updated; §1 re-pinned. -- **Escrow vaults (2026-10-05).** An application needed two parties to lock equal stakes against one agreed match, with the application as referee, and nothing could hold value under a condition other than a SoFi market (CONFORMANCE §6.73). Owner rulings (2026-10-04, 2026-10-05, quoted in SoFi Amendment S21): build the generic escrow vault with no wager logic in Core, and make one external commitment have one admissible verdict in the protocol itself. Owner decisions as specified: an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object; it releases its whole amount once to the recipient of the branch whose outcome the canonical verdict names; the verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes; linked vaults settle on that one outcome; there is no owner close and no market (SoFi Amendment S21, §19.9). Owner review (2026-10-05) asked that linking be an explicit invariant: same `Y`, same canonical table (which is the authority), same cell; linking is derived from each vault's accepted terms and checked by whoever relies on it, and discovery is by cell. MR-SOFI-0363–0386 added with source `amendment`; §1 re-pinned. +- **Escrow vaults (2026-10-05).** An application needed two parties to lock equal stakes against one agreed match, with the application as referee, and nothing could hold value under a condition other than a SoFi market (CONFORMANCE §6.74). Owner rulings (2026-10-04, 2026-10-05, quoted in SoFi Amendment S21): build the generic escrow vault with no wager logic in Core, and make one external commitment have one admissible verdict in the protocol itself. Owner decisions as specified: an escrow vault is a SoFi vault whose three policy slots name one `EscrowTerms` object; it releases its whole amount once to the recipient of the branch whose outcome the canonical verdict names; the verdict occupies `K_verdict = H(DSM/escrow/verdict-cell/v1; Y ∥ τ)` first at its leader and proves its own authority from its bytes; linked vaults settle on that one outcome; there is no owner close and no market (SoFi Amendment S21, §19.9). Owner review (2026-10-05) asked that linking be an explicit invariant: same `Y`, same canonical table (which is the authority), same cell; linking is derived from each vault's accepted terms and checked by whoever relies on it, and discovery is by cell. MR-SOFI-0363–0386 added with source `amendment`; §1 re-pinned. - **Post-reconciliation amendment (2026-09-22): route-chain finality.** For finding GPT-4, finality was redefined as a route chain (storage spec §9, §12.6, §14, §22; DSM Amendment A6; SoFi Amendment S4). §1 pins the amended files. Canonical rows restating the old rule were rewritten, and rows for the new rules were added at the end of §8.1, §8.2 and §8.4 with source `amendment`. The extraction files in `extractions/` remain as extracted against the earlier hashes. ## 8 Canonical requirements From 8bdaa72421e984c0af4ab1e4df7a38807ede69a3 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:37:52 -0400 Subject: [PATCH 06/11] feat(escrow): the SDK flow: create, sign, adjudicate, verdict, release (SoFi S21) escrow_flow runs each escrow route over the vault machinery of sofi_flow: - create publishes the terms and the genesis Stored, checks a named counterpart vault is accepted, Active and bound to the same verdict cell, then admits the creation with its one debit; - sign puts this device's signature for an outcome under the cell's statement locator; adjudicate gathers the outcome's signatures there, assembles the verdict Core recognizes, writes it to the cell leader first and returns what the cell holds, which may be an earlier verdict; - release builds only on a final verdict whose branch pays this device, sets up with the vault, walks it to its head, retires it and exercises the release through Core; - locked and vaults list escrow vaults by cell and by this device's own creations. Core gains gathered_signatures and assemble_verdict, with their test. sofi_flow's VaultAtHead carries the vault's terms, so market routes take a market and escrow routes an escrow vault, and the helpers the flow shares are crate-visible. sofi_sdk gains build_escrow_vault_create and draft_release. --- .../dsm/src/sofi/escrow.rs | 152 ++++ .../dsm_sdk/src/sdk/escrow_flow.rs | 654 ++++++++++++++++++ .../dsm_sdk/src/sdk/mod.rs | 2 + .../dsm_sdk/src/sdk/sofi_flow.rs | 138 ++-- .../dsm_sdk/src/sdk/sofi_sdk.rs | 80 +++ 5 files changed, 967 insertions(+), 59 deletions(-) create mode 100644 dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/escrow_flow.rs diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/escrow.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/escrow.rs index 1696d5261..1f603825f 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/escrow.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/escrow.rs @@ -163,6 +163,11 @@ pub enum VerdictRefusal { NotTheOutcomesSigners, /// A signature does not verify over the statement for this cell. Signature(SignatureError), + /// A gathered verdict for another outcome than the one being gathered. + AnotherOutcome, + /// The signatures in hand form no verdict with canonical bytes: none, or + /// more than an outcome's signers. + NoEncoding(super::wire::SofiWireError), } /// Whether `verdict` proves its own authority for the cell at `verdict_cell` @@ -223,6 +228,78 @@ pub fn verdict_occupying( Ok(verdict) } +/// The signatures a gathered verdict holds for `outcome` at the cell at +/// `verdict_cell`, each from a signer the table assigns to that outcome and +/// verifying over `m(outcome)`; or why the bytes are no gathered verdict for +/// that cell and outcome. Gathering carries no authority: only a verdict +/// recognized at the cell decides anything ([`verdict_authority`]). +pub fn gathered_signatures( + bytes: &[u8], + verdict_cell: &D32, + outcome: &[u8], +) -> Result, VerdictRefusal> { + let verdict = EscrowVerdict::decode(bytes).map_err(VerdictRefusal::DoesNotDecode)?; + if verdict_cell_key( + verdict.external_commitment(), + &table_digest(verdict.table()), + ) != *verdict_cell + { + return Err(VerdictRefusal::NotThisCell); + } + if verdict.outcome() != outcome { + return Err(VerdictRefusal::AnotherOutcome); + } + let decided_by = verdict + .table() + .signers_of(outcome) + .ok_or(VerdictRefusal::OutcomeNotInTable)?; + let digest = statement(verdict_cell, outcome); + let mut kept = Vec::with_capacity(verdict.signatures().len()); + for s in verdict.signatures() { + if !decided_by.contains(s.signer()) { + return Err(VerdictRefusal::NotTheOutcomesSigners); + } + verify_bytes( + "EscrowVerdict", + s.signer().signature_alg(), + s.signer().public_key(), + &digest, + s.signature(), + ) + .map_err(VerdictRefusal::Signature)?; + kept.push(s.clone()); + } + Ok(kept) +} + +/// A verdict on `outcome` from `signatures`, one per signer in canonical +/// order, once they are exactly the signers `table` assigns to `outcome` and +/// the verdict proves its own authority for its cell: what a producer writes +/// to the cell. Fewer signatures than the outcome needs is +/// `NotTheOutcomesSigners`: the gathering is not done. +pub fn assemble_verdict( + external_commitment: D32, + table: OutcomeTable, + outcome: &[u8], + signatures: Vec, +) -> Result { + let mut by_signer: std::collections::BTreeMap, VerdictSignature> = + std::collections::BTreeMap::new(); + for s in signatures { + by_signer.entry(s.signer().canonical()).or_insert(s); + } + let cell = verdict_cell_key(&external_commitment, &table_digest(&table)); + let verdict = EscrowVerdict::new( + external_commitment, + table, + outcome, + by_signer.into_values().collect(), + ) + .map_err(VerdictRefusal::NoEncoding)?; + verdict_authority(&verdict, &cell)?; + Ok(verdict) +} + // ── the verdict cell ─────────────────────────────────────────────────────── /// The verdict cell at `K_verdict` as Core derives it: the key, and the route @@ -778,6 +855,81 @@ mod tests { )); } + /// A joint cancel gathered one signature at a time: each signer's gathered + /// verdict yields only that outcome's verifying signatures, and the + /// verdict is assembled only once they are exactly the outcome's signers. + #[test] + fn a_verdict_is_assembled_only_from_the_outcomes_signatures() { + let m = the_match(); + let t = terms(&m, ([0x01; 32], [0x02; 32])); + let k = verdict_cell_of(&t); + let from_a = verdict(&m, &t, b"cancel", &[&m.a]).encode(); + let from_b = verdict(&m, &t, b"cancel", &[&m.b]).encode(); + + let mut gathered = gathered_signatures(&from_a, &k, b"cancel").expect("a's signature"); + assert_eq!(gathered.len(), 1); + assert_eq!( + assemble_verdict(m.y, t.outcome_table(), b"cancel", gathered.clone()), + Err(VerdictRefusal::NotTheOutcomesSigners), + "one player's signature is not a cancel" + ); + gathered.extend(gathered_signatures(&from_b, &k, b"cancel").expect("b's signature")); + // The same signature gathered twice is one signature. + gathered.extend(gathered_signatures(&from_a, &k, b"cancel").expect("a's again")); + let cancel = + assemble_verdict(m.y, t.outcome_table(), b"cancel", gathered).expect("both players"); + assert_eq!(verdict_occupying(&cancel.encode(), &k), Ok(cancel.clone())); + assert_eq!(cancel, verdict(&m, &t, b"cancel", &[&m.a, &m.b])); + + // Gathering for another outcome, at another cell, or from a signer + // the outcome does not name, yields nothing. + assert_eq!( + gathered_signatures(&from_a, &k, b"void"), + Err(VerdictRefusal::AnotherOutcome) + ); + let other = Match { + y: external_commitment(b"match 8"), + ..the_match() + }; + assert_eq!( + gathered_signatures( + &from_a, + &verdict_cell_of(&terms(&other, ([0x01; 32], [0x02; 32]))), + b"cancel" + ), + Err(VerdictRefusal::NotThisCell) + ); + let by_referee = verdict(&m, &t, b"cancel", &[&m.referee]).encode(); + assert_eq!( + gathered_signatures(&by_referee, &k, b"cancel"), + Err(VerdictRefusal::NotTheOutcomesSigners) + ); + // A gathered signature over another statement does not verify. + let b_won = verdict(&m, &t, b"b-wins", &[&m.referee]); + let relabeled = EscrowVerdict::new( + m.y, + t.outcome_table(), + b"a-wins", + b_won.signatures().to_vec(), + ) + .expect("verdict") + .encode(); + assert!(matches!( + gathered_signatures(&relabeled, &k, b"a-wins"), + Err(VerdictRefusal::Signature( + SignatureError::DoesNotVerify { .. } + )) + )); + // No signatures at all have no encoding. + assert!(matches!( + assemble_verdict(m.y, t.outcome_table(), b"void", Vec::new()), + Err(VerdictRefusal::NoEncoding(SofiWireError::Cardinality { + got: 0, + .. + })) + )); + } + /// Where a release stands, from what the cell holds: open or not final /// on its outcome is unsettled, final on its outcome is final, and the /// other outcome held in any state is lost. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/escrow_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/escrow_flow.rs new file mode 100644 index 000000000..f2f961d42 --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/escrow_flow.rs @@ -0,0 +1,654 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Escrow vaults (SoFi §19.9, Amendment S21): one entry per escrow route, +//! over the vault machinery of `sofi_flow` and the producers in `sofi_sdk`. +//! +//! An escrow vault holds one token's stake and releases it whole, once, to the +//! recipient of the branch whose outcome the canonical verdict on its external +//! commitment names. The verdict lives in its own cell, `K_verdict`, which +//! every vault linked to it shares: the first verdict at the cell's leader that +//! proves its own authority is the only one any of them settles against. +//! +//! Producers assemble and publish; Core decides. A route here never takes a +//! storage read, a counterpart's word or an application's word as a verdict. + +use dsm::common::domain_tags::TAG_DSM_ESCROW_STATEMENT_LOCATOR; +use dsm::economic::write_set::CreditSourceFacts; +use dsm::route_chain::{CellFact, ChainState}; +use dsm::sofi::escrow::{self, VerdictCell, VerdictCellRead, VerdictRefusal}; +use dsm::sofi::publication::Publication; +use dsm::sofi::resolve::{AcceptedGeneses, VaultGenesis, Verifier}; +use dsm::sofi::storage::Discovered; +use dsm::sofi::validation::{retire_vault_post, VaultTerms}; +use dsm::sofi::wire::{ + next_position, EscrowBranch, EscrowSigner, EscrowTerms, EscrowVerdict, VaultGenesisPreimage, + VaultStateLeaf, VerdictSignature, VAULT_STATUS_ACTIVE, +}; +use dsm::types::device_state::{BalanceDelta, BalanceDirection}; +use dsm::types::error::DsmError; + +use crate::sdk::core_sdk::CoreSDK; +use crate::sdk::economic_admission_flow::{ + admitted_self_loop_operation, validated_root_or_activate, BuiltOn, +}; +use crate::sdk::realized_records::{record_realized, Moved, Realized}; +use crate::sdk::route_seats::write_recorded; +use crate::sdk::sofi_flow::{ + chain_past_withheld_pairs, context, exercise_draft, head_of, identity, own_setup_ref, refuse, + relationship_base, require_stored, set_up_with, sign, standing, storage, trader_core, + vault_at_head, vault_core, PositionOutcome, Search, SIGNATURE_ALG, +}; +use crate::sdk::sofi_publish::{publish, LOCATOR_BUDGET}; +use crate::sdk::sofi_reads::{verifier_error, LiveSofiReads, VerifierContext}; +use crate::sdk::sofi_sdk::{build_escrow_vault_create, draft_release, ReleaseNames}; +use crate::sdk::storage_io::resolve_locator_all; +use crate::sdk::storage_set::{as_ccb_members, StorageSet}; + +type D32 = [u8; 32]; + +// ── escrow.party ──────────────────────────────────────────────────────────── + +/// What a device names itself by in escrow terms: its identity, which a +/// branch pays, and its signing key, which a branch is decided by. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowParty { + pub genesis: D32, + pub device_id: D32, + pub signer: EscrowSigner, +} + +/// `escrow.party`: this device as an escrow party. +pub fn party(core: &CoreSDK) -> Result { + let (genesis, device_id) = identity(core)?; + let public_key = crate::sdk::signing_authority::current_public_key()?; + let signer = EscrowSigner::new(SIGNATURE_ALG, &public_key).map_err(refuse)?; + Ok(EscrowParty { + genesis, + device_id, + signer, + }) +} + +// ── escrow.create ─────────────────────────────────────────────────────────── + +/// `escrow.create`: a stake locked in a new escrow vault of this device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateEscrowIntent { + /// `X`: the bytes the parties agreed on. DSM never reads them; the vault + /// commits `Y = H(DSM/external/v1 ‖ X)`. + pub external: Vec, + /// The held token's policy commit. + pub token: D32, + /// The stake, in base units. + pub amount: u64, + /// The precommitted branches, strictly ascending by outcome. + pub branches: Vec, + /// A vault this one is locked against. The vault is created only once + /// that one is accepted, Active and bound to the same verdict cell (SoFi + /// §19.9, "Linked vaults"). + pub counterpart: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowCreated { + pub vault_id: D32, + pub verdict_cell: D32, + pub external_commitment: D32, + /// The owner's economic position whose transition carries the creation. + pub position: u64, +} + +/// The escrow terms `vault_id`'s accepted genesis commits. +fn escrow_terms_of( + verifier: &Verifier<'_, LiveSofiReads<'_>>, + vault_id: &D32, +) -> Result { + match verifier.vault_genesis(vault_id).map_err(verifier_error)? { + VaultGenesis::Accepted(accepted) => accepted + .escrow() + .cloned() + .ok_or_else(|| refuse("the vault is not an escrow vault")), + VaultGenesis::NotPublished => Err(refuse( + "no genesis the owner's creation carried is published", + )), + VaultGenesis::OwnerUnresolved(why) => Err(storage( + "escrow vault genesis", + format!("the owner's lineage is unresolved: {why}"), + )), + VaultGenesis::Refused(why) => Err(refuse(format!("vault genesis refused: {why}"))), + } +} + +/// The counterpart is linked: accepted, bound to `verdict_cell` (its `Y` and +/// outcome table are this one's), and Active at its walked head. +async fn counterpart_linked( + set: &StorageSet, + verifier: &Verifier<'_, LiveSofiReads<'_>>, + counterpart: &D32, + verdict_cell: &D32, +) -> Result<(), DsmError> { + let terms = escrow_terms_of(verifier, counterpart)?; + if escrow::verdict_cell_of(&terms) != *verdict_cell { + return Err(refuse( + "the counterpart vault is bound to another verdict cell: its commitment or its \ + outcome table differs", + )); + } + let (vault, ..) = vault_at_head(set, verifier, counterpart).await?; + if vault.state.status != VAULT_STATUS_ACTIVE { + return Err(refuse("the counterpart vault is no longer Active")); + } + Ok(()) +} + +/// `escrow.create` (§19.9, Creation): the terms and the genesis are published +/// and read back `Stored` first, so the vault is findable by its id and by +/// its verdict cell the moment it exists; then the creation runs through the +/// Core transition, debiting the stake as one write set. +pub async fn create( + core: &CoreSDK, + set: &StorageSet, + intent: &CreateEscrowIntent, +) -> Result { + if intent.amount == 0 { + return Err(refuse("an escrow vault holds a non-zero stake")); + } + let (genesis, device_id) = identity(core)?; + let validated = validated_root_or_activate(core)?; + let create_position = next_position(validated.economic_position()).map_err(refuse)?; + let external_commitment = escrow::external_commitment(&intent.external); + let terms = EscrowTerms::new(intent.token, external_commitment, intent.branches.clone()) + .map_err(refuse)?; + let verdict_cell = escrow::verdict_cell_of(&terms); + if let Some(counterpart) = intent.counterpart { + let ctx = VerifierContext::new(set, Some((genesis, device_id)), None)?; + counterpart_linked(set, &ctx.verifier(), &counterpart, &verdict_cell).await?; + } + + let published = publish(set, &Publication::EscrowTerms(&terms)).await?; + require_stored("escrow terms", &published)?; + let addr = published.addr; + let preimage = VaultGenesisPreimage { + owner_genesis: genesis, + owner_device_id: device_id, + create_position, + state: VaultStateLeaf { + owner_genesis: genesis, + owner_device_id: device_id, + create_position, + market_policy: addr, + fee_policy: addr, + release_policy: addr, + storage_set_id: set.id(), + generation: 0, + reserve_a: intent.amount, + reserve_b: 0, + status: VAULT_STATUS_ACTIVE, + }, + }; + let produced = build_escrow_vault_create(&preimage, &terms).map_err(refuse)?; + let published = publish( + set, + &Publication::EscrowVaultGenesis { + preimage: &preimage, + terms: &terms, + }, + ) + .await?; + require_stored("escrow vault genesis", &published)?; + + let operation = produced + .operation + .clone() + .with_signature(sign(produced.signs.bytes())?); + let deltas = [BalanceDelta { + policy_commit: intent.token, + direction: BalanceDirection::Debit, + amount: intent.amount, + }]; + let (outcome, admitted) = admitted_self_loop_operation( + core, + operation, + &deltas, + CreditSourceFacts::None, + Vec::new(), + None, + Some(BuiltOn::of(&validated)), + ) + .await?; + let vault_id = preimage.vault_id(); + record_realized( + &outcome.new_device_state, + Realized::EscrowLock, + &vault_id, + admitted.economic_position, + &[vault_id], + &[Moved { + policy_commit: intent.token, + direction: BalanceDirection::Debit, + amount: intent.amount, + }], + ); + Ok(EscrowCreated { + vault_id, + verdict_cell, + external_commitment, + position: admitted.economic_position, + }) +} + +// ── escrow.sign and escrow.adjudicate ─────────────────────────────────────── + +/// An outcome of the escrow vault `vault_id`'s terms. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutcomeIntent { + pub vault_id: D32, + pub outcome: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutcomeSigned { + pub verdict_cell: D32, + /// The address of the gathered verdict holding this device's signature. + pub gathered: D32, +} + +/// This device's signer, once it is one the terms assign to `outcome`. +fn own_signer_of( + core: &CoreSDK, + terms: &EscrowTerms, + outcome: &[u8], +) -> Result, DsmError> { + let me = party(core)?.signer; + let decided_by = terms + .outcome_table() + .signers_of(outcome) + .map(<[EscrowSigner]>::to_vec) + .ok_or_else(|| refuse("the terms have no such outcome"))?; + Ok(decided_by.contains(&me).then_some(me)) +} + +/// This device's signature over `m(outcome)` for `verdict_cell`. +fn own_signature( + signer: &EscrowSigner, + verdict_cell: &D32, + outcome: &[u8], +) -> Result { + let secret_key = crate::sdk::signing_authority::current_secret_key()?; + escrow::sign_statement(signer, &secret_key, verdict_cell, outcome) +} + +/// `escrow.sign`: this device's signature deciding `outcome` for the vault's +/// verdict cell, put as a gathered verdict under the cell's statement locator +/// for the other signers of the outcome to find. It decides nothing by +/// itself: only a verdict recognized at the cell does. +pub async fn sign_outcome( + core: &CoreSDK, + set: &StorageSet, + intent: &OutcomeIntent, +) -> Result { + let ctx = VerifierContext::new(set, Some(identity(core)?), None)?; + let terms = escrow_terms_of(&ctx.verifier(), &intent.vault_id)?; + let signer = own_signer_of(core, &terms, &intent.outcome)? + .ok_or_else(|| refuse("this device is not a signer of that outcome"))?; + let verdict_cell = escrow::verdict_cell_of(&terms); + let signature = own_signature(&signer, &verdict_cell, &intent.outcome)?; + let gathered = EscrowVerdict::new( + *terms.external_commitment(), + terms.outcome_table(), + &intent.outcome, + vec![signature], + ) + .map_err(refuse)?; + let published = publish(set, &Publication::EscrowVerdict(&gathered)).await?; + require_stored("gathered verdict", &published)?; + Ok(OutcomeSigned { + verdict_cell, + gathered: published.addr, + }) +} + +/// What a verdict cell holds, as Core read it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerdictView { + pub verdict_cell: D32, + /// The outcome the cell's verdict names, and how far its chain has gone; + /// `None` while no verdict holds the cell. + pub held: Option<(Vec, ChainState)>, + /// Why each value the leader holds ahead of the deciding one counts as + /// nothing there. + pub passed_over: Vec, +} + +impl VerdictView { + fn of(read: &VerdictCellRead) -> Self { + let held = match (read.verdict(), read.fact()) { + (Some(verdict), CellFact::Held { state, .. }) => { + Some((verdict.outcome().to_vec(), state)) + } + (None, _) | (Some(..), CellFact::Open) => None, + }; + Self { + verdict_cell: *read.key(), + held, + passed_over: read.passed_over().to_vec(), + } + } +} + +/// The read of `verdict_cell`, as Core evaluates it over every seat. +fn read_cell( + verifier: &Verifier<'_, LiveSofiReads<'_>>, + verdict_cell: &D32, +) -> Result { + verifier + .read_verdict_cell(verdict_cell) + .map_err(verifier_error)? + .map_err(|missing| storage("verdict cell", format!("not decided yet: {missing:?}"))) +} + +/// The signatures gathered for `outcome` at `verdict_cell`: every one a +/// gathered verdict under the statement locator holds that verifies under a +/// signer of the outcome, and why each other candidate counts as nothing. +async fn gathered_signatures( + set: &StorageSet, + verdict_cell: &D32, + outcome: &[u8], +) -> Result<(Vec, Vec), DsmError> { + let locator = escrow::statement_locator(verdict_cell, outcome); + let refused = std::cell::RefCell::new(Vec::new()); + let found = resolve_locator_all( + set, + TAG_DSM_ESCROW_STATEMENT_LOCATOR.source_bytes(), + &locator, + LOCATOR_BUDGET, + |bytes| match escrow::gathered_signatures(bytes, verdict_cell, outcome) { + Ok(signatures) => Some((locator, signatures)), + Err(refusal) => { + refused.borrow_mut().push(refusal); + None + } + }, + ) + .await?; + let signatures = match found { + Discovered::Complete(found) | Discovered::Partial(found) => { + found.into_iter().flatten().collect() + } + }; + Ok((signatures, refused.into_inner())) +} + +/// `escrow.adjudicate` (§19.9): assemble the verdict on `outcome` from the +/// signatures gathered under the cell's statement locator and this device's +/// own when it is a signer, write it to the verdict cell leader first, and +/// return what the cell holds — which may be another verdict that got there +/// first. A verdict the gathered signatures do not complete is refused, and +/// nothing is written. +pub async fn adjudicate( + core: &CoreSDK, + set: &StorageSet, + intent: &OutcomeIntent, +) -> Result { + let ctx = VerifierContext::new(set, Some(identity(core)?), None)?; + let verifier = ctx.verifier(); + let terms = escrow_terms_of(&verifier, &intent.vault_id)?; + let verdict_cell = escrow::verdict_cell_of(&terms); + let (mut signatures, refused) = + gathered_signatures(set, &verdict_cell, &intent.outcome).await?; + if let Some(signer) = own_signer_of(core, &terms, &intent.outcome)? { + signatures.push(own_signature(&signer, &verdict_cell, &intent.outcome)?); + } + let verdict = escrow::assemble_verdict( + *terms.external_commitment(), + terms.outcome_table(), + &intent.outcome, + signatures, + ) + .map_err(|refusal| { + refuse(format!( + "the gathered signatures do not decide {:?}: {refusal:?}; gathered candidates passed \ + over: {refused:?}", + String::from_utf8_lossy(&intent.outcome) + )) + })?; + let cell = VerdictCell::new(&verdict_cell, &as_ccb_members(set)?, &set.id()) + .map_err(|e| refuse(format!("verdict cell: {e:?}")))?; + let write = write_recorded(set, cell.routed(), &verdict.encode()).await?; + if !write.reached_leader() { + return Err(storage( + "verdict cell", + "the cell's leader did not take the verdict; nothing is decided", + )); + } + Ok(VerdictView::of(&read_cell(&verifier, &verdict_cell)?)) +} + +/// `escrow.verdict`: what the verdict cell of `vault_id` holds. +pub async fn verdict( + core: &CoreSDK, + set: &StorageSet, + vault_id: &D32, +) -> Result { + let ctx = VerifierContext::new(set, Some(identity(core)?), None)?; + let verifier = ctx.verifier(); + let terms = escrow_terms_of(&verifier, vault_id)?; + Ok(VerdictView::of(&read_cell( + &verifier, + &escrow::verdict_cell_of(&terms), + )?)) +} + +// ── escrow.release ────────────────────────────────────────────────────────── + +/// `escrow.release` (§19.9, Release): the whole stake of `vault_id` to this +/// device, the recipient of the branch whose outcome the verdict cell holds, +/// final. A release is built only then: a release before the verdict, or +/// of a branch that pays someone else, could never realize, so nothing is +/// admitted for it. The setup with the vault is admitted first when this +/// device has none (Amendment S16). +pub async fn release( + core: &CoreSDK, + set: &StorageSet, + vault_id: &D32, +) -> Result { + let accepted = &AcceptedGeneses::default(); + let (terms, outcome) = { + let standing = standing(core)?; + let ctx = standing.context(set, accepted)?; + let verifier = ctx.verifier(); + let terms = escrow_terms_of(&verifier, vault_id)?; + let read = read_cell(&verifier, &escrow::verdict_cell_of(&terms))?; + let view = VerdictView::of(&read); + let outcome = match view.held { + Some((outcome, ChainState::Final)) => outcome, + Some((.., state)) => { + return Err(storage( + "verdict", + format!("the verdict holding the cell is {state:?}, not final yet"), + )) + } + None => return Err(storage("verdict", "no verdict holds the cell yet")), + }; + let branch = terms + .branch(&outcome) + .ok_or_else(|| refuse("the verdict names no branch of the vault's terms"))?; + if *branch.recipient_genesis() != standing.genesis + || *branch.recipient_device_id() != standing.device_id + { + return Err(refuse(format!( + "the verdict {:?} pays another identity", + String::from_utf8_lossy(&outcome) + ))); + } + let head = core + .device_head() + .ok_or_else(|| storage("device head", "none"))?; + if !head.has_adopted(terms.token()) { + return Err(refuse( + "the escrow token is not adopted: adopt it before receiving it", + )); + } + (terms, outcome) + }; + set_up_with(core, set, &[*vault_id], accepted).await?; + let standing = standing(core)?; + let ctx = standing.context(set, accepted)?; + let verifier = ctx.verifier(); + let chain = chain_past_withheld_pairs( + set, + &verifier, + vault_id, + verifier.chain(vault_id).map_err(verifier_error)?, + ) + .await?; + let (vault, ..) = head_of(set, &verifier, vault_id, chain).await?; + if !matches!(vault.terms, VaultTerms::Escrow(..)) { + return Err(refuse("the vault is not an escrow vault")); + } + if vault.state.status != VAULT_STATUS_ACTIVE { + return Err(refuse("the escrow vault is already released")); + } + let setup_ref = own_setup_ref(set, &standing.genesis, &standing.device_id, vault_id).await?; + let base = relationship_base(&standing, vault_id)?; + let retired = + retire_vault_post(&vault.state).map_err(|refusal| refuse(format!("{refusal:?}")))?; + let dlv = vault_core(&standing, &vault, &retired, base)?; + let trader = trader_core( + &standing, + &[(*terms.token(), vault.state.reserve_a, 0)], + &[(*vault_id, base)], + )?; + let public_key = crate::sdk::signing_authority::current_public_key()?; + let trader_ctx = context(&standing, set, &public_key, trader)?; + let draft = draft_release( + ReleaseNames { + vault_id: *vault_id, + parent_root: vault.root, + setup_ref, + verdict_cell: escrow::verdict_cell_of(&terms), + outcome, + amount: vault.state.reserve_a, + }, + dlv, + &trader_ctx, + &standing.local, + ) + .map_err(refuse)?; + exercise_draft(core, set, &standing, draft, accepted).await +} + +// ── escrow.locked and escrow.vaults ───────────────────────────────────────── + +/// One escrow vault at its walked head. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowVaultView { + pub vault_id: D32, + pub owner_genesis: D32, + pub owner_device_id: D32, + pub verdict_cell: D32, + pub external_commitment: D32, + pub token: D32, + /// What the vault holds at its head: the stake while Active, nothing once + /// released. + pub amount: u64, + pub generation: u64, + pub status: u16, +} + +async fn view_of( + set: &StorageSet, + verifier: &Verifier<'_, LiveSofiReads<'_>>, + vault_id: &D32, +) -> Result { + let (vault, ..) = vault_at_head(set, verifier, vault_id).await?; + let VaultTerms::Escrow(terms) = &vault.terms else { + return Err(refuse("the vault is not an escrow vault")); + }; + Ok(EscrowVaultView { + vault_id: *vault_id, + owner_genesis: vault.state.owner_genesis, + owner_device_id: vault.state.owner_device_id, + verdict_cell: escrow::verdict_cell_of(terms), + external_commitment: *terms.external_commitment(), + token: *terms.token(), + amount: vault.state.reserve_a, + generation: vault.state.generation, + status: vault.state.status, + }) +} + +/// `escrow.locked`: the escrow vaults bound to `verdict_cell`, each accepted, +/// checked to derive the cell, and walked to its head. Discovery carries no +/// authority; a candidate not established yet makes the search partial. +pub async fn locked( + core: &CoreSDK, + set: &StorageSet, + verdict_cell: &D32, +) -> Result<(Vec, Search), DsmError> { + let ctx = VerifierContext::new(set, Some(identity(core)?), None)?; + let verifier = ctx.verifier(); + let (vaults, mut search) = match verifier + .vaults_of_cell(verdict_cell) + .map_err(verifier_error)? + { + Discovered::Complete(vaults) => (vaults, Search::Complete), + Discovered::Partial(vaults) => (vaults, Search::Partial), + }; + let mut out = Vec::with_capacity(vaults.len()); + for accepted in vaults { + match view_of(set, &verifier, accepted.vault_id()).await { + Ok(view) => out.push(view), + // A vault whose head is not established is not shown as it + // stands, so the search is partial, and says why. + Err(why) => { + search = Search::Partial; + out_of_reach(accepted.vault_id(), &why); + } + } + } + Ok((out, search)) +} + +/// A vault `escrow.locked` could not show at its head: the search is partial. +fn out_of_reach(vault_id: &D32, why: &DsmError) { + log::info!( + "[escrow] vault {} not shown at its head: {why}", + dsm::utils::text_id::encode_base32_crockford(vault_id) + ); +} + +/// `escrow.vaults`: the escrow vaults this device created — the creation +/// records its validated root commits — each walked to its head. +pub async fn own_vaults( + core: &CoreSDK, + set: &StorageSet, +) -> Result, DsmError> { + let accepted = &AcceptedGeneses::default(); + let standing = standing(core)?; + let ctx = standing.context(set, accepted)?; + let verifier = ctx.verifier(); + let mut out = Vec::new(); + for creation in standing.local.vault_creations() { + match verifier + .vault_genesis(&creation.vault_id) + .map_err(verifier_error)? + { + VaultGenesis::Accepted(genesis) if genesis.escrow().is_some() => { + out.push(view_of(set, &verifier, &creation.vault_id).await?); + } + // A market vault is `sofi.vaults`'. + VaultGenesis::Accepted(..) => {} + VaultGenesis::NotPublished => { + return Err(refuse( + "a vault this device created has no published genesis", + )) + } + VaultGenesis::OwnerUnresolved(why) => return Err(storage("escrow vault genesis", why)), + VaultGenesis::Refused(why) => { + return Err(refuse(format!("vault genesis refused: {why}"))) + } + } + } + Ok(out) +} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/mod.rs index 043b5d589..f4c168ada 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/mod.rs @@ -21,6 +21,8 @@ //! pub mod economic_admission_flow; pub mod economic_registers; +/// Escrow vaults (SoFi §19.9): stakes released by a canonical verdict. +pub mod escrow_flow; pub mod faucet_claim_flow; pub mod native_reserve; pub mod runtime_config; diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs index e4f4b9601..f4f1b699a 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs @@ -28,7 +28,7 @@ use dsm::sofi::resolve::{AcceptedGeneses, Acquired, LocalLeaves, VaultGenesis, V use dsm::sofi::storage::Discovered; use dsm::sofi::validation::{ movement_shape, retire_vault_post, route_endpoints, swap_vault_post, Evidence, EvidenceNeeds, - HopMovement, Policies, RouteShape, + HopMovement, Policies, RouteShape, VaultTerms, }; use dsm::sofi::wire::{ next_attempt, next_position, CoreEntry, DlvCore, SwapHop, TraderCore, VaultGenesisPreimage, @@ -63,7 +63,7 @@ use crate::storage::client_db::{economic_lineage, sofi_vault_head}; type D32 = [u8; 32]; /// The signature algorithm of every SoFi object this device signs: its AK. -const SIGNATURE_ALG: u16 = dsm::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F; +pub(crate) const SIGNATURE_ALG: u16 = dsm::ccb::genesis::sigalg::SPHINCS_PLUS_SPX256F; /// How many times one route reads its position's resolution before it /// reports `RetriesExhausted`: the network status, not a verdict. @@ -208,23 +208,23 @@ pub struct PositionOutcome { pub state: PositionState, } -fn refuse(what: impl std::fmt::Display) -> DsmError { +pub(crate) fn refuse(what: impl std::fmt::Display) -> DsmError { DsmError::invalid_operation(format!("sofi: {what}")) } -fn storage(what: &str, e: impl std::fmt::Display) -> DsmError { +pub(crate) fn storage(what: &str, e: impl std::fmt::Display) -> DsmError { DsmError::storage(format!("sofi: {what}: {e}"), None::) } /// Sign `message` with this device's AK. -fn sign(message: &[u8]) -> Result, DsmError> { +pub(crate) fn sign(message: &[u8]) -> Result, DsmError> { let secret_key = crate::sdk::signing_authority::current_secret_key()?; dsm::crypto::sphincs::sphincs_sign(&secret_key, message) } /// The published object must be `Stored`, read back from the members, before /// anything is built on it. -fn require_stored(what: &str, published: &Published) -> Result<(), DsmError> { +pub(crate) fn require_stored(what: &str, published: &Published) -> Result<(), DsmError> { if published.stored { Ok(()) } else { @@ -236,7 +236,7 @@ fn require_stored(what: &str, published: &Published) -> Result<(), DsmError> { } /// This device's identity. -fn identity(core: &CoreSDK) -> Result<(D32, D32), DsmError> { +pub(crate) fn identity(core: &CoreSDK) -> Result<(D32, D32), DsmError> { let head = core .device_head() .ok_or_else(|| storage("device head", "none"))?; @@ -489,29 +489,40 @@ async fn setup( // ── §30 Finding the head of a vault ──────────────────────────────────────── /// A vault at its walked head: the root the next hop is built on, the whole -/// tree there, its state, and its policies. -struct VaultAtHead { - vault_id: D32, - root: D32, - state: VaultStateLeaf, - tree: EconomicSmt, - policies: Policies, +/// tree there, its state, and its terms: a market's policies, or an escrow +/// vault's terms (SoFi Amendment S21). +pub(crate) struct VaultAtHead { + pub(crate) vault_id: D32, + pub(crate) root: D32, + pub(crate) state: VaultStateLeaf, + pub(crate) tree: EconomicSmt, + pub(crate) terms: VaultTerms, +} + +impl VaultAtHead { + /// The market's policies; an escrow vault has no market to price by. + fn market(&self) -> Result<&Policies, DsmError> { + match &self.terms { + VaultTerms::Market(policies) => Ok(policies), + VaultTerms::Escrow(..) => Err(refuse("an escrow vault has no market")), + } + } } /// What this device stands on: its identity, its validated predecessor and /// the leaves that form it, and the conditional positions it resolved. -struct Standing { - genesis: D32, - device_id: D32, - validated: ValidatedEconomicRoot, - admitted: AdmittedEconomicPosition, - local: LocalLeaves, - tree: EconomicSmt, - balances: BTreeMap, +pub(crate) struct Standing { + pub(crate) genesis: D32, + pub(crate) device_id: D32, + pub(crate) validated: ValidatedEconomicRoot, + pub(crate) admitted: AdmittedEconomicPosition, + pub(crate) local: LocalLeaves, + pub(crate) tree: EconomicSmt, + pub(crate) balances: BTreeMap, } /// Stage 0 of §31: no pending position, and a resolved predecessor. -fn standing(core: &CoreSDK) -> Result { +pub(crate) fn standing(core: &CoreSDK) -> Result { let head = core .device_head() .ok_or_else(|| storage("device head", "none"))?; @@ -539,9 +550,10 @@ fn standing(core: &CoreSDK) -> Result { }) } -/// The policies `state` commits, fetched by the addresses it names and -/// decoded by Core, which re-addresses each first. -async fn vault_policies(set: &StorageSet, state: &VaultStateLeaf) -> Result { +/// The terms `state` commits — a market's three policies, or an escrow +/// vault's terms — fetched by the addresses it names and decoded by Core, +/// which re-addresses each first. +async fn vault_terms(set: &StorageSet, state: &VaultStateLeaf) -> Result { let mut objects = BTreeMap::new(); for (class, addr) in EvidenceNeeds::policies_of(state) { let bytes = crate::sdk::storage_io::read_stored_bytes(set, &addr) @@ -549,7 +561,7 @@ async fn vault_policies(set: &StorageSet, state: &VaultStateLeaf) -> Result Result>, vault_id: &D32, @@ -606,7 +618,7 @@ fn chains_at_once( } /// `vault_id` at the head of its walked `chain`. -async fn head_of( +pub(crate) async fn head_of( set: &StorageSet, verifier: &Verifier<'_, LiveSofiReads<'_>>, vault_id: &D32, @@ -656,14 +668,14 @@ async fn head_of( "the vault's leaves do not recompute its walked head", )); } - let policies = vault_policies(set, &state).await?; + let terms = vault_terms(set, &state).await?; Ok(( VaultAtHead { vault_id: *vault_id, root, state, tree, - policies, + terms, }, chain, )) @@ -672,7 +684,7 @@ async fn head_of( impl Standing { /// This device as the verifier: its identity and the position it /// resolved itself, over the pinned set. - fn context<'a>( + pub(crate) fn context<'a>( &'a self, set: &'a StorageSet, accepted: &AcceptedGeneses, @@ -686,8 +698,12 @@ impl Standing { } } -/// The other token of a vault's pair, and whether `token_in` is its `a`. -fn other_token(policies: &Policies, token_in: &D32) -> Option<(D32, bool)> { +/// The other token of a vault's pair, and whether `token_in` is its `a`. An +/// escrow vault has no pair, and trades nothing. +fn other_token(terms: &VaultTerms, token_in: &D32) -> Option<(D32, bool)> { + let VaultTerms::Market(policies) = terms else { + return None; + }; let (a, b) = (*policies.market.token_a(), *policies.market.token_b()); if *token_in == a { Some((b, true)) @@ -706,7 +722,7 @@ fn quote( amount_in: u64, index: usize, ) -> Result<(D32, u64), DsmError> { - let (token_out, in_is_a) = other_token(&vault.policies, token_in) + let (token_out, in_is_a) = other_token(&vault.terms, token_in) .ok_or_else(|| refuse(format!("hop {index}: the vault does not trade that token")))?; let (reserve_in, reserve_out) = if in_is_a { (vault.state.reserve_a, vault.state.reserve_b) @@ -717,7 +733,7 @@ fn quote( amount_in, reserve_in, reserve_out, - vault.policies.fee.fee_bps(), + vault.market()?.fee.fee_bps(), ) .map_err(|e| refuse(format!("hop {index}: {e:?}")))?; Ok((token_out, amount_out)) @@ -742,7 +758,7 @@ fn price_hop( token_out, amount_out, }; - let post = swap_vault_post(&vault.state, &vault.policies, &hop, index) + let post = swap_vault_post(&vault.state, vault.market()?, &hop, index) .map_err(|refusal| refuse(format!("hop {index}: {refusal:?}")))?; Ok((hop, post)) } @@ -770,7 +786,7 @@ impl Planned { /// What `vault` gives for `amount_in` of `token_in` at its head: `None` for /// an amount too small to move it. Any other refusal is an error. fn leg_out(vault: &VaultAtHead, token_in: &D32, amount_in: u64) -> Result, DsmError> { - let (_, in_is_a) = other_token(&vault.policies, token_in) + let (_, in_is_a) = other_token(&vault.terms, token_in) .ok_or_else(|| refuse("the vault does not trade that token"))?; let (reserve_in, reserve_out) = if in_is_a { (vault.state.reserve_a, vault.state.reserve_b) @@ -781,7 +797,7 @@ fn leg_out(vault: &VaultAtHead, token_in: &D32, amount_in: u64) -> Result