From f9789a38ccad10c74aaa607fca6359698a50229f Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:18:25 -0400 Subject: [PATCH 1/3] =?UTF-8?q?docs(spec):=20SoFi=20Amendment=20S11=20?= =?UTF-8?q?=E2=80=94=20ERA's=20canonical=20policy,=20and=20a=20network-anc?= =?UTF-8?q?hored=20native=20policy=20names=20no=20creator=20and=20no=20sig?= =?UTF-8?q?ner=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERA's former commitment was raw BLAKE3 of empty input — the hash of a 0-byte file — and committed to no policy. Owner decisions (2026-09-26): define ERA's policy for the first time and derive its commitment from those exact bytes. - A native token whose releases are anchored to the network names no creator and no signer set; exactly one such policy exists, ERA's, fixed in Core and checked by its commitment. Device-created layouts are unchanged. - ERA: version 3, fungible, native, transferable and burnable, no allowlist, release rule the beta faucet, ticker/alias ERA, decimals 0, genesis supply 80,000,000,000, no description or icon; commitment JXPMPGJH45HDTE0ARWE2CTB9E9BWTQZ3T78CE5RFF1RXMR9VKK80 (Crockford base32 of BLAKE3(DSM/policy || 0x00 || TokenPolicyV3 bytes), computed from the layout). - The faucet payout is not committed; the reserve's accounting is. Join emission after beta is a different ERA identity (Open). - Stale §47 packer line and §51 "Code" line fixed. MASTER_REQUIREMENTS re-pinned; MR-SOFI-0300, 0303, 0315, 0332 scoped to device-created policies; MR-SOFI-0334–0337 added (Missing in CONFORMANCE_GAPS until built in this branch). --- specs/SoFi_Settlement_Specification.md | 12 +++++++++--- specs/requirements/CONFORMANCE_GAPS.md | 8 ++++++-- specs/requirements/MASTER_REQUIREMENTS.md | 19 ++++++++++++------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/specs/SoFi_Settlement_Specification.md b/specs/SoFi_Settlement_Specification.md index ebe9d7cfe..ad5394a91 100644 --- a/specs/SoFi_Settlement_Specification.md +++ b/specs/SoFi_Settlement_Specification.md @@ -2418,7 +2418,7 @@ of their own only if they created the token they provide liquidity for, which is ### 47 What a token policy is Rust packs one canonical blob, the only packer for the format (build_policy_v3_bytes, SDK/handlers/token_ -routes.rs:129). All integers are big endian. +routes.rs). All integers are big endian. Field Meaning @@ -2444,6 +2444,12 @@ The ticker is display only; two tokens may share one. > **Amendment S8 (owner, 2026-09-23) — the policy names its creator, and a token is created once.** The policy blob also commits the creator: the genesis `G` and device id `DevID` of the device that creates the token, placed after the release rule. A native token's genesis release is admissible only in a `CreateToken` of that device, so anyone else holding the same policy bytes releases nothing. The creating transition also inserts a creation record for the policy commit into the creator's economic tree, from zero (class `0x0060`, key `H(DSM/economic-token-creation-key/v1; G ∥ DevID ∥ policy_commit)`). Its presence under a validated root proves the creation, and a second creation of the same commit on that lineage cannot build its write set. The economic-root register keeps the lineage unforked, so the genesis supply is released exactly once. +> **Amendment S11 (owner, 2026-09-26) — a network-anchored native token names no creator and no signer set; ERA's policy.** A native token whose releases are anchored to the network rather than to a creating device names no creator and no signer set: its blob omits the creator of Amendment S8 and the signer set and threshold of the table above, going from the release rule straight to the ticker, and its release rule alone governs every release. Exactly one such policy exists, ERA's, fixed in Core and checked by its commitment; any other network-anchored blob has no reserve and releases nothing, and it is never registered, adopted or published. The creator and the signer set belong to device-created tokens (release rule all-at-creation), whose layout is unchanged. +> +> ERA's policy: version 3, fungible, native; transferable and burnable; no recipient allowlist; release rule the beta faucet, under which units come out of the network's reserve by faucet claims; ticker and alias `ERA`; decimals 0; genesis supply 80,000,000,000 (owner, 2026-09-26); no description and no icon. Every device holds these bytes by construction. Their commitment, `BLAKE3(DSM/policy ‖ 0x00 ‖ TokenPolicyV3 bytes)`, is `JXPMPGJH45HDTE0ARWE2CTB9E9BWTQZ3T78CE5RFF1RXMR9VKK80` (Crockford base32). The faucet's per-claim payout is fixed by Core's beta claim policy and is not committed in the blob. The reserve's accounting is: the reserve starts at the genesis supply, every release is counted against it, the reserve is exhausted exactly when all of it has been released, and at exhaustion a claim is refused before anything is signed or written. ERA's previous commitment was the hash of empty input and committed to no policy; it is replaced. +> +> Open: join-triggered emission (DJTE) replaces the faucet after beta. That is a different release rule, so a different blob, and by the rule above that any differing field makes a different token, a different ERA identity. + **Code** Balances are keyed by policy_commit (the economic balance leaf; CORE/sofi/validation.rs:553). Issuance binds it. A vault’s market policy names its two tokens by token_a_policy_commit and token_b_policy_commit @@ -2530,7 +2536,7 @@ an externally backed token by what is proven locked. the standard policy the issuer locks itself out completely: it cannot change the policy and cannot take units outside its rules. 3. Units not yet released come out only when the conditions committed in the policy are met, and anyone can -verify them by recomputing. For ERA, the conditions are its emission schedule. +verify them by recomputing. For ERA, the conditions are its emission schedule (after beta; in beta, the faucet — Amendment S11). 4. A burn destroys the units it burns. They never return to the unreleased supply, so the total ever released only grows. @@ -2550,7 +2556,7 @@ anyone accepting the token knows the most that can ever exist and how it can com the only number that matters is what remains unreleased. **Code** -ERA is native: its emission schedule fixes its total (EmissionsSchedule.total_supply), and emission is release. +ERA is native: its policy (Amendment S11) fixes its genesis supply, and every release, the beta faucet's and later emission's, comes out of the network's reserve. ### 52 Externally backed supply diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index d5d7a2356..1fab3e128 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1196,11 +1196,11 @@ Not in the manifest: `dsm_sdk::sdk::sofi_reads::tests::an_unestablished_genesis_ | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 272 | 66 | 116 | 34 | 9 | 29 | 18 | -| SoFi (MR-SOFI) | 333 | 206 | 84 | 18 | 8 | 17 | 0 | +| SoFi (MR-SOFI) | 337 | 206 | 84 | 22 | 8 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 32 | 41 | 50 | 16 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **909** | **313** | **242** | **103** | **33** | **64** | **154** | +| **All** | **913** | **313** | **242** | **107** | **33** | **64** | **154** | ## 8 Per-requirement results @@ -1818,6 +1818,10 @@ Not in the manifest: `dsm_sdk::sdk::sofi_reads::tests::an_unestablished_genesis_ | MR-SOFI-0331 | Met | `dsm::sofi::validation::setup_valid`; `dsm::sofi::validation::Evidence` | `dsm::sofi::validation::tests::a_setup_naming_another_claim_than_the_accepted_one_is_invalid`; `dsm::sofi::validation::tests::a_setup_whose_accepted_claim_is_not_in_hand_is_missing` | Evidence carries the accepted claims lineage validation produced; a setup naming another claim is Invalid, and one whose accepted claim is not in hand is Missing. | | MR-SOFI-0332 | Met | `dsm::economic::token_policy::TokenPolicy`; `dsm::economic::provenance::verify_genesis_release` | `dsm::economic_provenance_semantics::a_genesis_release_funds_its_creators_whole_supply`; `dsm::economic_provenance_semantics::a_genesis_release_of_another_creators_policy_is_refused` | The policy blob carries creator_genesis and creator_device_id; a genesis release of another creator's policy is refused. | | MR-SOFI-0333 | Met | `dsm::economic::keys::token_creation_key`; `dsm::economic::write_set::build_write_set` | `dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage`; `dsm::sofi::lineage::tests::the_creation_record_is_an_economic_leaf_with_its_own_key` | Creation inserts the 0x0060 record under its own key from zero; a second creation of the same commit cannot build its write set. | +| MR-SOFI-0334 | Missing | — | — | Amendment S11 (2026-09-26): the network-anchored grammar, built in this PR. | +| MR-SOFI-0335 | Missing | — | — | Amendment S11 (2026-09-26): ERA's canonical policy in Core, built in this PR. | +| MR-SOFI-0336 | Missing | — | — | Amendment S11 (2026-09-26): confinement of the network-anchored shape to ERA, built in this PR. | +| MR-SOFI-0337 | Missing | — | — | Amendment S11 (2026-09-26): the reserve's supply from ERA's policy and exhaustion refused before signing, built in this PR. | ### 8.3 dBTC native specification diff --git a/specs/requirements/MASTER_REQUIREMENTS.md b/specs/requirements/MASTER_REQUIREMENTS.md index 601d1457e..bf7d3b754 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` | `bc34c8f8a64f772471625d14d4d728e80e4cc02f` | 4305 | -| `specs/SoFi_Settlement_Specification.md` | `ebe9d7cfe904ba25449e5e8b91744a04e216382a` | 2614 | +| `specs/SoFi_Settlement_Specification.md` | `ad5394a91b0b32dda0863390f1d8d92a43dbbf3e` | 2620 | | `specs/dBTC_Native_Specification.md` | `233a3e72a5b16a023af830f4c8ffaad4ba9391a8` | 2160 | | `specs/DSM_Storage_Node_Specification.md` | `415a9b9c67c7a2b4b6df78b0af85fb3bc7282ae8` | 636 | -Pins updated 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-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. @@ -151,6 +151,7 @@ Each extraction also has a Findings table: - **Replication amendment (2026-09-23).** Conformance finding on MR-STOR-0082 (ChatGPT CG-07): the storage spec still said an operator must replicate a role's memory before acknowledging, which predates route chains. Owner: the route chain is the replication (leader's link plus two further links, each committing the one before). Storage §12.5 amended; the row rewritten. - **Set identity amendment (2026-09-23).** Conformance finding on MR-STOR-0055 / MR-SOFI-0068: the code commits each member's register incarnation in `storage_set_id` (CCB storage-set schema 3) and the specs said member ids only. Owner decision: the specs follow the code (SoFi Amendment S6, storage §10). Both rows rewritten. - **Completion proofs, ClaimRef and the token creator (2026-09-23, landed in #977 without rows; added 2026-09-24).** Owner decisions: storage §9 gains the rule on the leader first, one chain in route order and the completion proof; SoFi Amendment S10 requires a completion proof for every Final a DLV unlock relies on, S9 checks ClaimRef against the verifier's own accepted claim, and S8 commits the creator in the policy blob and records a token's creation once. MR-SOFI-0330–0333 and MR-STOR-0148–0158 added with source `amendment`; §1 re-pinned. +- **ERA's canonical policy (2026-09-26).** Owner decisions: ERA's former commitment was the hash of empty input and committed to no policy. ERA's policy is defined for the first time and its commitment derived from those bytes (SoFi Amendment S11). A network-anchored native policy names no creator and no signer set, and exactly one exists, ERA's. The genesis supply is 80,000,000,000 (the owner's number). The beta faucet's payout is not committed, while the reserve's accounting is. Join emission after beta is a different ERA identity (Open). MR-SOFI-0300, 0303, 0315 and 0332 are scoped to device-created policies; MR-SOFI-0334–0337 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 @@ -165,7 +166,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 3, SoFi 6, storage 30, for 898 canonical requirements in all. +Added afterwards by amendment (§7.1): DSM 3, SoFi 10, storage 30, for 902 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. @@ -749,10 +750,10 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0297 | dependency-boundary | derived | Coordinate burn surfaces outside SoFi that accept unauthenticated writes (dlv slot, recovery authority anchor, tips, devtree root, bytecommit publish, device register) are triaged separately. | cc: SOFI-046/L2377 | 1/2: claude-chat | none | | MR-SOFI-0298 | obligation | derived | Use the reserved membership-handover tag for the storage specification's handover encoding. | gpt: SOFI-046/L2381 | 1/2: chatgpt | none | | MR-SOFI-0299 | invariant | derived | Every balance, issuance and vault names a token by the hash of its whole policy, so no transition can move a token under rules other than its own. | cc: SOFI-046/L2386 | 1/2: claude-chat | none | -| MR-SOFI-0300 | authority | explicit | A token policy creates a tokenized asset and is anchored to its creator's state, but the creator does not own it; under the standard policy the creator locks itself out completely. | cc: SOFI-046/L2390; gpt: SOFI-046/L2390 | 2/2 | none | +| MR-SOFI-0300 | authority | explicit | A token policy creates a tokenized asset and is anchored to its creator's state, but the creator does not own it; under the standard policy the creator locks itself out completely. A network-anchored policy has no creator and is anchored to the network (Amendment S11). | cc: SOFI-046/L2390; gpt: SOFI-046/L2390 | 2/2 | none | | MR-SOFI-0301 | invariant | explicit | A DLV needs no token policy of its own; its market policy points to the policy commits of the tokens it trades. | cc: SOFI-046/L2393; gpt: SOFI-046/L2392 | 2/2 | none | | MR-SOFI-0302 | obligation | derived | One canonical packer produces the token policy blob, with all integers big-endian. | cc: SOFI-047/L2400; gpt: SOFI-047/L2400 | 2/2 | none | -| MR-SOFI-0303 | invariant | derived | Policy field bounds: signer set k of n with 1 ≤ n ≤ 16, authorizing only what the policy's own rules name; ticker 2 to 8 characters; decimals 0 to 18. | cc: SOFI-047/L2410; gpt: SOFI-047/L2410, SOFI-047/L2412 | 2/2 | none | +| MR-SOFI-0303 | invariant | derived | Policy field bounds: for a device-created policy, a signer set k of n with 1 ≤ n ≤ 16, authorizing only what the policy's own rules name (a network-anchored policy has none, Amendment S11); ticker 2 to 8 characters; decimals 0 to 18. | cc: SOFI-047/L2410; gpt: SOFI-047/L2410, SOFI-047/L2412 | 2/2 | none | | MR-SOFI-0304 | invariant | explicit | A token's identity is policy_commit, the hash of the whole policy blob; any differing field makes a different token. | cc: SOFI-047/L2422; gpt: SOFI-047/L2422 | 2/2 | none | | MR-SOFI-0305 | authority | explicit | The ticker is display only; two tokens may share one. | cc: SOFI-047/L2423; gpt: SOFI-047/L2423 | 2/2 | none | | MR-SOFI-0306 | invariant | derived | Every token belongs to exactly one supply class fixed in its policy, native or externally backed, and neither class has an unlimited option. | cc: SOFI-048/L2436; gpt: SOFI-048/L2436 | 2/2 | none | @@ -764,7 +765,7 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0312 | invariant | derived | The recipient allowlist governs only who may receive issuance; it has no market meaning, and a token trades freely once issued. | cc: SOFI-049/L2482; gpt: SOFI-049/L2482 | 2/2 | none | | MR-SOFI-0313 | obligation | explicit | Every token policy states its version and kind, supply class, decimals, transferability and recipient allowlist (or none); a native policy also states genesis supply and release rule, an externally backed policy its backing rule; the constructor refuses to create a token whose policy omits any of these. | cc: SOFI-050/L2497; gpt: SOFI-050/L2496, SOFI-050/L2495 | 2/2 | none | | MR-SOFI-0314 | invariant | explicit | A native token's genesis supply is fixed in its policy, and so in its identity. | cc: SOFI-051/L2506 | 1/2: claude-chat | none | -| MR-SOFI-0315 | authority | explicit | Under the standard policy the issuer locks itself out: it cannot change the policy or take units outside its rules. | cc: SOFI-051/L2508; gpt: SOFI-051/L2507 | 2/2 | none | +| MR-SOFI-0315 | authority | explicit | Under the standard policy the issuer locks itself out: it cannot change the policy or take units outside its rules. A network-anchored policy has no issuer at all (Amendment S11). | cc: SOFI-051/L2508; gpt: SOFI-051/L2507 | 2/2 | none | | MR-SOFI-0316 | transition | explicit | Unreleased native units come out only when the conditions committed in the policy are met, verifiable by anyone by recomputing. | cc: SOFI-051/L2510 | 1/2: claude-chat | none | | MR-SOFI-0317 | invariant | explicit | A burn destroys the units it burns; they never return to the unreleased supply, so the total ever released only grows. | cc: SOFI-051/L2512; gpt: SOFI-051/L2512 | 2/2 | none | | MR-SOFI-0318 | obligation | explicit | A verifier reads each token's own committed policy and recomputes what it says; no rule is assumed from another token, a default, or the standard. | cc: SOFI-051/L2519; gpt: SOFI-051/L2517 | 2/2 | none | @@ -781,8 +782,12 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0329 | transition | explicit | A position whose drop claim won under the challenge rule resolves Void (ladder step 3a) unless it is shown Invalid on evidence in hand; nothing executes, no balance moves, the lineage continues from the previous root, and it can never move to Invalid. | amendment: SoFi Amendment S5 (2026-09-22) | amendment | none | | MR-SOFI-0330 | evidence | explicit | Every Final a DLV unlock relies on is shown by a completion proof: FulfillmentRegistered(F) by the proofs at K_ful(q) and K_root(q), and each StorageFinalE(K(F.a_j), E) in ConsumedRoute by the proof at that successor key; the client keeps the proof of each Final it relies on, Core checks each proof against its own reads of the seats, and an unlock whose proofs do not check does not unlock. | amendment: SoFi Amendment S10 (2026-09-23) | owner | none | | MR-SOFI-0331 | evidence | explicit | SetupValid compares the setup's claim_ref with the digest of the claim the verifier accepted at the setup's position p when it validated the trader's lineage (the registered root claim of an ordinary position, or C_p of a resolved SoFi position); RouteValidation's evidence carries that accepted claim as a value only lineage validation (or the device's own admitted store, for its own positions) produces, so RouteValidation reads no storage for it; a setup naming any other claim is Invalid, and until the accepted claim is in hand the setup is not evaluated. | amendment: SoFi Amendment S9 (2026-09-23) | owner | none | -| MR-SOFI-0332 | authority | explicit | A token policy blob commits its creator, the genesis G and device id DevID of the creating device, after the release rule; a native token's genesis release is admissible only in a CreateToken of that device, so anyone else holding the same policy bytes releases nothing. | amendment: SoFi Amendment S8 (2026-09-23) | owner | none | +| MR-SOFI-0332 | authority | explicit | A device-created token's policy blob commits its creator, the genesis G and device id DevID of the creating device, after the release rule (a network-anchored policy names none, Amendment S11); a native token's genesis release is admissible only in a CreateToken of that device, so anyone else holding the same policy bytes releases nothing. | amendment: SoFi Amendment S8 (2026-09-23) | owner | none | | MR-SOFI-0333 | invariant | explicit | The creating transition inserts a creation record for the policy commit into the creator's economic tree from zero (class 0x0060, key H(DSM/economic-token-creation-key/v1; G ‖ DevID ‖ policy_commit)); its presence under a validated root proves the creation, a second creation of the same commit on that lineage cannot build its write set, and so the genesis supply is released exactly once. | amendment: SoFi Amendment S8 (2026-09-23) | owner | none | +| MR-SOFI-0334 | invariant | explicit | A network-anchored native policy names no creator and no signer set: its blob omits both after the release rule, and its release rule alone governs every release; a device-created policy's layout is unchanged, and a blob whose shape does not match its release rule does not parse. | amendment: SoFi Amendment S11 (2026-09-26) | owner | none | +| MR-SOFI-0335 | invariant | explicit | ERA's policy is fixed in Core (version 3, fungible, native, transferable and burnable, no recipient allowlist, the beta faucet release rule, ticker and alias ERA, decimals 0, genesis supply 80,000,000,000, no description or icon), and ERA's policy commitment is BLAKE3(DSM/policy ‖ 0x00 ‖ its TokenPolicyV3 bytes); every device holds it by construction, never from storage or a registry. | amendment: SoFi Amendment S11 (2026-09-26) | owner | none | +| MR-SOFI-0336 | prohibition | explicit | Exactly one network-anchored policy exists, ERA's; any other network-anchored blob has no reserve, releases nothing, and is never registered, adopted or published. | amendment: SoFi Amendment S11 (2026-09-26) | owner | none | +| MR-SOFI-0337 | invariant | explicit | ERA's reserve starts at ERA's committed genesis supply and every release is counted against it: remaining plus released equals the genesis supply at every state, the reserve is exhausted exactly when all of it has been released, and at exhaustion a claim is refused before anything is signed or written. | amendment: SoFi Amendment S11 (2026-09-26) | owner | none | ### 8.3 dBTC native specification From 938a83afdb0aa1a9a5011c15d687b16d2e53332d Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:40:27 -0400 Subject: [PATCH 2/3] feat(token-policy): a network-anchored policy names no creator and no signer set; one policy, one commitment SoFi Amendment S11, the grammar. - TokenPolicy.release is Release::AllAtCreation { creator_genesis, creator_device_id, threshold, signers } or Release::Faucet, so a creator or signer set is readable only where the policy has one. The parser branches on the rule byte: device-created blobs are byte-identical to before; a network-anchored blob goes from the rule byte straight to the ticker. - parse_token_policy requires the canonical TokenPolicyV3 wrapper: an unknown field or a repeated blob field would have given one policy a second commitment. - verify_genesis_release is one match admitting only device-created policies. - The enforcer's register_policy, tokens.addByAnchor and tokens.publishPolicy (via adoptable_policy) refuse network-anchored blobs: the one that exists, ERA's, is fixed in Core; any other is a lookalike with no reserve. - The one packer lays out both shapes. - Tests: the created-token fixture's commitment pinned and held before and after the change; network-anchored parse; shape mismatch refused both ways; non-canonical wrappers refused; lookalike refused; the provenance rule test rebuilt on a well-formed network-anchored blob with its message asserted. --- .../dsm/src/core/token/policy/mod.rs | 11 + .../core/token/policy/policy_enforcement.rs | 13 +- .../dsm/src/economic/provenance.rs | 18 +- .../dsm/src/economic/token_policy.rs | 257 +++++++++++++----- .../tests/economic_provenance_semantics.rs | 34 ++- .../dsm_sdk/src/handlers/token_routes.rs | 225 +++++++++++---- .../dsm_sdk/src/handlers/wallet_routes.rs | 21 +- 7 files changed, 424 insertions(+), 155 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs index 7d16f3946..b41806d21 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs @@ -98,6 +98,17 @@ impl TokenPolicySystem { "token policy: the committed bytes do not parse: {e}" )) })?; + // Exactly one network-anchored policy exists, ERA's, and it is fixed + // in Core; no other is registered (SoFi Amendment S11). + if !matches!( + parsed.release, + crate::economic::token_policy::Release::AllAtCreation { .. } + ) { + return Err(DsmError::invalid_operation( + "token policy: a network-anchored policy is registered by nobody — the one \ + that exists, ERA's, is fixed in Core (Amendment S11)", + )); + } let anchor = PolicyAnchor::from_bytes(commit); self.policy_cache.store_policy( anchor.clone(), diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs index 9407d134c..48bbf597e 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs @@ -260,24 +260,25 @@ impl PolicyEnforcer { #[cfg(test)] mod tests { use super::*; - use crate::economic::token_policy::{ReleaseRule, TokenPolicy as ParsedTokenPolicy}; + use crate::economic::token_policy::{Release, TokenPolicy as ParsedTokenPolicy}; use crate::types::policy_types::{PolicyAnchor, PolicyCondition, PolicyFile, TokenPolicy}; fn fungible_fixture() -> ParsedTokenPolicy { ParsedTokenPolicy { - creator_genesis: [0x31; 32], - creator_device_id: [0x32; 32], ticker: "DSM".into(), alias: "DSM Token".into(), decimals: 8, genesis_supply: 1_000_000, - release_rule: ReleaseRule::AllAtCreation, + release: Release::AllAtCreation { + creator_genesis: [0x31; 32], + creator_device_id: [0x32; 32], + threshold: 1, + signers: vec![vec![0xAB; 64]], + }, description: Some("A test token".into()), icon_url: Some("dsm:icon".into()), burn_enabled: true, transferable: true, - threshold: 1, - signers: vec![vec![0xAB; 64]], allowlist_device_ids: Vec::new(), } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs index 3134370c2..fb639a111 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs @@ -688,14 +688,22 @@ fn verify_genesis_release( } let policy = crate::economic::token_policy::parse_token_policy(&bytes) .map_err(|e| ProvenanceError::GenesisReleaseInvalid(format!("policy: {e}")))?; - if (policy.creator_genesis, policy.creator_device_id) != (*ctx.genesis, *ctx.device_id) { + // Only a device-created policy releases its supply at creation, and only + // to the creator it names (Amendment S8). A network-anchored policy names + // no creator and releases only through the network's reserve (S11). + let crate::economic::token_policy::Release::AllAtCreation { + creator_genesis, + creator_device_id, + .. + } = &policy.release + else { return Err(ProvenanceError::GenesisReleaseInvalid( - "the token's policy names another creator".into(), + "the token's release rule does not release its supply at creation".into(), )); - } - if policy.release_rule != crate::economic::token_policy::ReleaseRule::AllAtCreation { + }; + if (*creator_genesis, *creator_device_id) != (*ctx.genesis, *ctx.device_id) { return Err(ProvenanceError::GenesisReleaseInvalid( - "the token's release rule does not release its supply at creation".into(), + "the token's policy names another creator".into(), )); } let amount = u64::try_from(policy.genesis_supply).map_err(|_| { diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/token_policy.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/token_policy.rs index c9c609cf1..8aba82366 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/token_policy.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/token_policy.rs @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! The token policy blob (SoFi §47–§51): its constants, its rules, and its one -//! parser. Every reader of a policy — Core's verifier, the SDK's routes, a -//! foreign verifier — goes through [`parse_token_policy`], so no two readers -//! can disagree about one blob. The one packer is the SDK's -//! `build_policy_v3_bytes` (SoFi §47), and it parses its own output here -//! before returning it. +//! The token policy blob (SoFi §47–§51, Amendments S8 and S11): its +//! constants, its rules, and its one parser. Every reader of a policy — Core's +//! verifier, the SDK's routes, a foreign verifier — goes through +//! [`parse_token_policy`], so no two readers can disagree about one blob. The +//! one packer is the SDK's `build_policy_v3_bytes` (SoFi §47), and it parses +//! its own output here before returning it. //! //! Layout (all integers big-endian): //! @@ -15,11 +15,13 @@ //! u8 supply_class = 0 (NATIVE) //! u8 flags: 0x01 burn | 0x02 transferable | 0x04 allowlist //! u8 release_rule: 0 all-at-creation | 1 faucet -//! 32B creator_genesis (SoFi Amendment S8) +//! ── release rule 0 only (device-created, Amendment S8) ── +//! 32B creator_genesis //! 32B creator_device_id //! u8 threshold k (1..=n) //! u8 signer_count n (1..=16) //! n x { u16 pk_len (> 0), pk } (no duplicates) +//! ── every policy ── //! u8 ticker_len, ticker (UTF-8, 2..=8 bytes) //! u16 alias_len, alias (UTF-8, not blank) //! u8 decimals (0..=18) @@ -30,6 +32,12 @@ //! u16 allowlist_count, count x 32B device_id //! ``` //! +//! A network-anchored policy (release rule 1, Amendment S11) names no creator +//! and no signer set: it goes from the release rule straight to the ticker. +//! Exactly one exists, ERA's, fixed in Core; no other is registered, adopted or +//! published. The `TokenPolicyV3` wrapper must be the canonical encoding of +//! its blob, so one policy has one commitment. +//! //! There is no minting after genesis and no unlimited supply (§48, §50). An //! externally backed class has no specified backing-rule encoding yet (dBTC is //! deferred), so a blob of that class is refused rather than read without it. @@ -63,17 +71,15 @@ pub const MAX_TICKER_LEN: usize = 8; pub const MAX_DECIMALS: u32 = 18; -/// How a native token's units not yet released come out (SoFi §47, §51). A -/// named rule of the token's committed policy: every release is a transition -/// the constructor builds under it, and anyone recomputes it. +/// The byte a policy blob commits for how a native token's units not yet +/// released come out (SoFi §47, §51). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReleaseRule { /// The whole genesis supply is released to the creator in the transition - /// that creates the token (user-created tokens in beta, owner 2026-09-23). + /// that creates the token (device-created tokens, owner 2026-09-23). AllAtCreation, - /// Units come out of the token's reserve through faucet claims (ERA in - /// beta). An emission schedule replaces it later as another rule over the - /// same release path. + /// Units come out of the network's reserve through faucet claims (ERA in + /// beta, Amendment S11). Faucet, } @@ -95,21 +101,50 @@ impl ReleaseRule { } } +/// A native token's release rule with what it names, so a creator or a +/// signer set can be read only where the policy has one (Amendments S8, S11). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Release { + /// Device-created (Amendment S8): the whole genesis supply is released to + /// the creator in the transition that creates the token, and only that + /// device's `CreateToken` releases it. The signer set authorizes only what + /// the policy's own rules name, and never issuance. + AllAtCreation { + /// The genesis of the device that creates the token. + creator_genesis: [u8; 32], + /// The creating device's id. + creator_device_id: [u8; 32], + /// `k` of the signer set. + threshold: u8, + /// `n` — the raw SPHINCS+ keys the policy names. + signers: Vec>, + }, + /// Network-anchored (Amendment S11): units come out of the network's + /// reserve through faucet claims. No creating device and no signer set; + /// the release rule alone governs every release. + Faucet, +} + +impl Release { + /// The rule byte this release commits. + pub fn rule(&self) -> ReleaseRule { + match self { + Release::AllAtCreation { .. } => ReleaseRule::AllAtCreation, + Release::Faucet => ReleaseRule::Faucet, + } + } +} + /// A committed token policy, every field of the blob, validated. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TokenPolicy { - /// The genesis of the device that creates the token. Only that device's - /// `CreateToken` releases a native token's supply (SoFi Amendment S8). - pub creator_genesis: [u8; 32], - /// The creating device's id. - pub creator_device_id: [u8; 32], pub ticker: String, pub alias: String, pub decimals: u32, /// The whole supply that will ever exist, in base units. pub genesis_supply: u128, - /// How units not yet released come out. - pub release_rule: ReleaseRule, + /// How units not yet released come out, and what the rule names. + pub release: Release, pub description: Option, pub icon_url: Option, /// Whether holders may burn. Governs burns only (§54). @@ -117,11 +152,6 @@ pub struct TokenPolicy { /// Whether the token may move between holders: checked on every transfer, /// vault creation and SoFi leg (§49). pub transferable: bool, - /// `k` of the signer set. The set authorizes only what the policy's own - /// rules name, and never issuance. - pub threshold: u8, - /// `n` — the raw SPHINCS+ keys the policy names. - pub signers: Vec>, /// Device ids that may receive issuance; empty when unrestricted. No /// market meaning: a token trades freely once issued. pub allowlist_device_ids: Vec<[u8; 32]>, @@ -171,6 +201,13 @@ pub fn parse_token_policy(policy_proto: &[u8]) -> Result { use prost::Message; let policy = crate::types::proto::TokenPolicyV3::decode(policy_proto) .map_err(|_| "policy proto does not decode".to_string())?; + // One policy, one encoding. The decoder skips unknown fields and keeps the + // last of a repeated one, so without this one blob could arrive in many + // wrappers and so under many commitments (§47: the identity is the hash + // of the whole policy). + if policy.encode_to_vec() != policy_proto { + return Err("policy proto is not the canonical encoding of its blob".into()); + } parse_token_policy_blob(&policy.policy_bytes) } @@ -203,36 +240,14 @@ pub fn parse_token_policy_blob(blob: &[u8]) -> Result { return Err("policy blob sets a flag bit that has no meaning".into()); } let rule = r.u8()?; - let release_rule = ReleaseRule::from_code(rule) - .ok_or_else(|| format!("policy blob release rule {rule} is unknown"))?; - let mut creator_genesis = [0u8; 32]; - creator_genesis.copy_from_slice(r.bytes(32)?); - let mut creator_device_id = [0u8; 32]; - creator_device_id.copy_from_slice(r.bytes(32)?); - - let threshold = r.u8()?; - let signer_count = r.u8()? as usize; - if signer_count == 0 || signer_count > MAX_POLICY_SIGNERS { - return Err(format!( - "policy blob signer count {signer_count} is outside 1..={MAX_POLICY_SIGNERS}" - )); - } - if threshold == 0 || threshold as usize > signer_count { - return Err("policy blob threshold is not satisfiable by its own signer set".into()); - } - let mut signers: Vec> = Vec::with_capacity(signer_count); - for _ in 0..signer_count { - let pk_len = r.u16be()?; - if pk_len == 0 { - return Err("policy blob names an empty signer key".into()); - } - let pk = r.bytes(pk_len)?.to_vec(); - if signers.contains(&pk) { - // One key must never satisfy a threshold above one. - return Err("policy blob names a signer twice".into()); - } - signers.push(pk); - } + let release = match ReleaseRule::from_code(rule) + .ok_or_else(|| format!("policy blob release rule {rule} is unknown"))? + { + ReleaseRule::AllAtCreation => creator_and_signers(&mut r)?, + // Network-anchored (Amendment S11): no creator and no signer set + // follow; the next field is the ticker. + ReleaseRule::Faucet => Release::Faucet, + }; let ticker_len = r.u8()? as usize; let ticker = r.utf8(ticker_len)?; @@ -299,34 +314,70 @@ pub fn parse_token_policy_blob(blob: &[u8]) -> Result { } Ok(TokenPolicy { - creator_genesis, - creator_device_id, ticker, alias, decimals, genesis_supply, - release_rule, + release, description, icon_url, burn_enabled: flags & POLICY_FLAG_BURN != 0, transferable: flags & POLICY_FLAG_TRANSFERABLE != 0, + allowlist_device_ids, + }) +} + +/// The creator (Amendment S8) and the signer set of a device-created policy. +fn creator_and_signers(r: &mut Reader<'_>) -> Result { + let mut creator_genesis = [0u8; 32]; + creator_genesis.copy_from_slice(r.bytes(32)?); + let mut creator_device_id = [0u8; 32]; + creator_device_id.copy_from_slice(r.bytes(32)?); + + let threshold = r.u8()?; + let signer_count = r.u8()? as usize; + if signer_count == 0 || signer_count > MAX_POLICY_SIGNERS { + return Err(format!( + "policy blob signer count {signer_count} is outside 1..={MAX_POLICY_SIGNERS}" + )); + } + if threshold == 0 || threshold as usize > signer_count { + return Err("policy blob threshold is not satisfiable by its own signer set".into()); + } + let mut signers: Vec> = Vec::with_capacity(signer_count); + for _ in 0..signer_count { + let pk_len = r.u16be()?; + if pk_len == 0 { + return Err("policy blob names an empty signer key".into()); + } + let pk = r.bytes(pk_len)?.to_vec(); + if signers.contains(&pk) { + // One key must never satisfy a threshold above one. + return Err("policy blob names a signer twice".into()); + } + signers.push(pk); + } + Ok(Release::AllAtCreation { + creator_genesis, + creator_device_id, threshold, signers, - allowlist_device_ids, }) } #[cfg(test)] mod tests { use super::*; + use prost::Message; const CREATOR_GENESIS: [u8; 32] = [0x11; 32]; const CREATOR_DEVICE: [u8; 32] = [0x22; 32]; /// Offset of the threshold byte: five header bytes, then the creator. const THRESHOLD_AT: usize = 5 + 64; - /// A well-formed blob, built field by field from the layout above — not - /// from the SDK packer, so the parser is checked against the layout. + /// A well-formed device-created blob, built field by field from the + /// layout above — not from the SDK packer, so the parser is checked + /// against the layout. fn blob() -> Vec { let mut b = vec![ TOKEN_POLICY_VERSION, @@ -341,7 +392,27 @@ mod tests { b.push(1); // signers b.extend_from_slice(&3u16.to_be_bytes()); b.extend_from_slice(b"key"); - b.push(3); + b.extend_from_slice(&tail()); + b + } + + /// A well-formed network-anchored blob: the release rule, then straight + /// to the ticker (Amendment S11). + fn network_anchored_blob() -> Vec { + let mut b = vec![ + TOKEN_POLICY_VERSION, + TOKEN_KIND_FUNGIBLE, + SUPPLY_CLASS_NATIVE, + POLICY_FLAG_BURN | POLICY_FLAG_TRANSFERABLE, + ReleaseRule::Faucet.code(), + ]; + b.extend_from_slice(&tail()); + b + } + + /// Everything after the release section: ticker through allowlist. + fn tail() -> Vec { + let mut b = vec![3]; b.extend_from_slice(b"TKN"); b.extend_from_slice(&5u16.to_be_bytes()); b.extend_from_slice(b"Token"); @@ -358,20 +429,72 @@ mod tests { fn a_well_formed_blob_parses_to_its_fields() { let p = parse_token_policy_blob(&blob()).expect("parses"); assert_eq!( - (p.creator_genesis, p.creator_device_id), - (CREATOR_GENESIS, CREATOR_DEVICE) + p.release, + Release::AllAtCreation { + creator_genesis: CREATOR_GENESIS, + creator_device_id: CREATOR_DEVICE, + threshold: 1, + signers: vec![b"key".to_vec()], + } ); assert_eq!(p.ticker, "TKN"); assert_eq!(p.alias, "Token"); assert_eq!(p.decimals, 6); assert_eq!(p.genesis_supply, 1_000_000); - assert_eq!(p.release_rule, ReleaseRule::AllAtCreation); assert!(p.burn_enabled && p.transferable); - assert_eq!((p.threshold, p.signers.len()), (1, 1)); assert!(p.description.is_none() && p.icon_url.is_none()); assert!(p.allowlist_device_ids.is_empty()); } + /// Amendment S11: a network-anchored blob names no creator and no signer + /// set, and parses to every other field as a device-created one does. + #[test] + fn a_network_anchored_blob_names_no_creator_and_no_signer_set() { + let p = parse_token_policy_blob(&network_anchored_blob()).expect("parses"); + assert_eq!(p.release, Release::Faucet); + assert_eq!(p.release.rule(), ReleaseRule::Faucet); + assert_eq!((p.ticker.as_str(), p.alias.as_str()), ("TKN", "Token")); + assert_eq!((p.decimals, p.genesis_supply), (6, 1_000_000)); + assert!(p.burn_enabled && p.transferable); + } + + /// A blob's shape follows its release rule: a device-created blob + /// relabelled network-anchored reads its creator as a ticker, and a + /// network-anchored blob relabelled device-created runs out before its + /// creator — neither parses. + #[test] + fn a_blob_whose_shape_does_not_match_its_release_rule_does_not_parse() { + let mut created_as_faucet = blob(); + created_as_faucet[4] = ReleaseRule::Faucet.code(); + assert!(parse_token_policy_blob(&created_as_faucet).is_err()); + + let mut faucet_as_created = network_anchored_blob(); + faucet_as_created[4] = ReleaseRule::AllAtCreation.code(); + assert!(parse_token_policy_blob(&faucet_as_created).is_err()); + } + + /// §47: one policy, one commitment. The wrapper is the canonical encoding + /// of its blob or it is not a policy — an unknown field, or the blob field + /// repeated, would give the same blob another commitment. + #[test] + fn a_non_canonical_wrapper_does_not_parse() { + let canonical = crate::types::proto::TokenPolicyV3 { + policy_bytes: blob(), + } + .encode_to_vec(); + assert!(parse_token_policy(&canonical).is_ok()); + + let mut unknown_field = canonical.clone(); + unknown_field.extend_from_slice(&[0x10, 0x01]); + assert!(crate::types::proto::TokenPolicyV3::decode(unknown_field.as_slice()).is_ok()); + assert!(parse_token_policy(&unknown_field).is_err()); + + let mut repeated = canonical.clone(); + repeated.extend_from_slice(&canonical); + assert!(crate::types::proto::TokenPolicyV3::decode(repeated.as_slice()).is_ok()); + assert!(parse_token_policy(&repeated).is_err()); + } + /// A named edit that breaks one rule of a policy's bytes. type Violation = (&'static str, Box)>); diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs index 04decbd4a..4ae49166e 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs @@ -236,15 +236,18 @@ impl ProvenanceResolver for Anchors { } } -/// `TokenPolicyV3` bytes of a native token created by `creator`, laid out as -/// SoFi §47 packs it. +/// `TokenPolicyV3` bytes of a native token under `release_rule`, laid out as +/// SoFi §47 packs it: a device-created policy names `creator` and a signer +/// set (Amendment S8); a network-anchored one names neither (S11). fn native_policy(creator: ([u8; 32], [u8; 32]), release_rule: u8, supply: u128) -> Vec { let mut blob = vec![3, 0, 0, 0x02, release_rule]; - blob.extend_from_slice(&creator.0); - blob.extend_from_slice(&creator.1); - blob.extend_from_slice(&[1, 1]); - blob.extend_from_slice(&3u16.to_be_bytes()); - blob.extend_from_slice(b"key"); + if release_rule == RELEASE_AT_CREATION { + blob.extend_from_slice(&creator.0); + blob.extend_from_slice(&creator.1); + blob.extend_from_slice(&[1, 1]); + blob.extend_from_slice(&3u16.to_be_bytes()); + blob.extend_from_slice(b"key"); + } blob.push(3); blob.extend_from_slice(b"TKN"); blob.extend_from_slice(&5u16.to_be_bytes()); @@ -356,15 +359,24 @@ fn a_genesis_release_rides_only_its_creating_operation() { } } +/// A network-anchored policy (Amendment S11) is a well-formed policy that +/// releases nothing at creation: the refusal is the release rule's, not a +/// parse error. #[test] fn a_genesis_release_needs_the_all_at_creation_rule() { let policy = native_policy((G, DEV), FAUCET_RELEASE, 1_000); + assert!(dsm::economic::token_policy::parse_token_policy(&policy).is_ok()); let pc = policy_commit_of(&policy); let op = create_token(pc, 1_000); - assert!(matches!( - verify_release(&Anchors(policy), &release_witness(pc, 1_000), Some(&op)), - Err(ProvenanceError::GenesisReleaseInvalid(_)) - )); + match verify_release(&Anchors(policy), &release_witness(pc, 1_000), Some(&op)) { + Err(ProvenanceError::GenesisReleaseInvalid(why)) => { + assert!( + why.contains("release rule"), + "refused for another reason: {why}" + ) + } + other => panic!("a network-anchored policy released at creation: {other:?}"), + } } #[test] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs index 8530de8c4..76015abe2 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs @@ -18,9 +18,9 @@ use super::response_helpers::{err, pack_envelope_ok}; // (`dsm::economic::token_policy`), so no two readers can disagree about one // blob. This module keeps the one packer (SoFi §47). use dsm::economic::token_policy::{ - ReleaseRule, ALLOWLIST_KIND_INLINE, ALLOWLIST_KIND_NONE, MAX_POLICY_SIGNERS, - POLICY_FLAG_ALLOWLIST, POLICY_FLAG_BURN, POLICY_FLAG_TRANSFERABLE, SUPPLY_CLASS_NATIVE, - TOKEN_KIND_FUNGIBLE, TOKEN_POLICY_VERSION, + Release, ALLOWLIST_KIND_INLINE, ALLOWLIST_KIND_NONE, MAX_POLICY_SIGNERS, POLICY_FLAG_ALLOWLIST, + POLICY_FLAG_BURN, POLICY_FLAG_TRANSFERABLE, SUPPLY_CLASS_NATIVE, TOKEN_KIND_FUNGIBLE, + TOKEN_POLICY_VERSION, }; /// A committed token policy, as Core parses it. @@ -43,11 +43,13 @@ pub(crate) type ParsedTokenPolicy = dsm::economic::token_policy::TokenPolicy; /// backing rule has an encoding) /// u8 flags: 0x01 burn | 0x02 transferable | 0x04 allowlist /// u8 release_rule: 0 all-at-creation | 1 faucet -/// 32B creator_genesis (SoFi Amendment S8) +/// ── release rule 0 only (device-created, SoFi Amendment S8) ── +/// 32B creator_genesis /// 32B creator_device_id /// u8 threshold k (1..=n) /// u8 signer_count n (1..=16) /// n x { u16 pk_len, pk } +/// ── every policy (a network-anchored one, Amendment S11, names neither) ── /// u8 ticker_len, ticker /// u16 alias_len, alias /// u8 decimals @@ -58,18 +60,23 @@ pub(crate) type ParsedTokenPolicy = dsm::economic::token_policy::TokenPolicy; /// u16 allowlist_count, count x 32B device_id /// ``` pub(crate) fn build_policy_v3_bytes(p: &ParsedTokenPolicy) -> Result, String> { - if p.signers.is_empty() || p.signers.len() > MAX_POLICY_SIGNERS { - return Err(format!( - "policy: signer count must be 1..={MAX_POLICY_SIGNERS}, got {}", - p.signers.len() - )); - } - if p.threshold == 0 || (p.threshold as usize) > p.signers.len() { - return Err(format!( - "policy: threshold {} must be 1..={} (the signer count)", - p.threshold, - p.signers.len() - )); + if let Release::AllAtCreation { + threshold, signers, .. + } = &p.release + { + if signers.is_empty() || signers.len() > MAX_POLICY_SIGNERS { + return Err(format!( + "policy: signer count must be 1..={MAX_POLICY_SIGNERS}, got {}", + signers.len() + )); + } + if *threshold == 0 || (*threshold as usize) > signers.len() { + return Err(format!( + "policy: threshold {} must be 1..={} (the signer count)", + threshold, + signers.len() + )); + } } if p.genesis_supply == 0 { return Err("policy: a token's genesis supply must be positive".into()); @@ -108,18 +115,29 @@ pub(crate) fn build_policy_v3_bytes(p: &ParsedTokenPolicy) -> Result, St TOKEN_KIND_FUNGIBLE, SUPPLY_CLASS_NATIVE, flags, - p.release_rule.code(), + p.release.rule().code(), ]; - out.extend_from_slice(&p.creator_genesis); - out.extend_from_slice(&p.creator_device_id); - out.push(p.threshold); - out.push(p.signers.len() as u8); - for pk in &p.signers { - if pk.len() > u16::MAX as usize { - return Err("policy: signer public key too long".into()); + match &p.release { + Release::AllAtCreation { + creator_genesis, + creator_device_id, + threshold, + signers, + } => { + out.extend_from_slice(creator_genesis); + out.extend_from_slice(creator_device_id); + out.push(*threshold); + out.push(signers.len() as u8); + for pk in signers { + if pk.len() > u16::MAX as usize { + return Err("policy: signer public key too long".into()); + } + out.extend_from_slice(&(pk.len() as u16).to_be_bytes()); + out.extend_from_slice(pk); + } } - out.extend_from_slice(&(pk.len() as u16).to_be_bytes()); - out.extend_from_slice(pk); + // Network-anchored (Amendment S11): no creator and no signer set. + Release::Faucet => {} } out.push(ticker.len() as u8); out.extend_from_slice(ticker); @@ -153,6 +171,23 @@ pub(crate) fn parse_token_policy(raw_proto: &[u8]) -> Option dsm::economic::token_policy::parse_token_policy(raw_proto).ok() } +/// A policy a device may adopt or publish: one Core's parser accepts, and +/// device-created. Exactly one network-anchored policy exists, ERA's, fixed +/// in Core; any other is a lookalike with no reserve behind it (SoFi +/// Amendment S11), and is neither adopted nor published. +pub(crate) fn adoptable_policy(raw_proto: &[u8]) -> Result { + let parsed = dsm::economic::token_policy::parse_token_policy(raw_proto) + .map_err(|e| format!("not a token policy: {e}"))?; + if !matches!(parsed.release, Release::AllAtCreation { .. }) { + return Err( + "a network-anchored policy: the one that exists, ERA's, is fixed in Core, and \ + any other has no reserve behind it (Amendment S11)" + .into(), + ); + } + Ok(parsed) +} + /// The network's pinned storage set, where token policies live as immutable /// objects under `TAG_DSM_POLICY`. fn policy_set() -> Result { @@ -655,11 +690,17 @@ impl AppRouterImpl { ); } - let Some(parsed) = parse_token_policy(&policy_bytes) else { - return err( - "tokens.addByAnchor: policy is not a readable v3 token policy".into(), - ); + let parsed = match adoptable_policy(&policy_bytes) { + Ok(parsed) => parsed, + Err(e) => return err(format!("tokens.addByAnchor: {e}")), }; + let Release::AllAtCreation { + creator_device_id, .. + } = &parsed.release + else { + return err("tokens.addByAnchor: the policy names no creating device".into()); + }; + let creator_device_id = *creator_device_id; let mut id_hasher = dsm::crypto::blake3::dsm_domain_hasher( dsm::common::domain_tags::TAG_DSM_TOKEN_ID, @@ -773,7 +814,7 @@ impl AppRouterImpl { alias: parsed.alias.clone(), decimals: parsed.decimals, genesis_supply: parsed.genesis_supply, - creator_device_id: parsed.creator_device_id, + creator_device_id, }; if let Err(e) = crate::storage::client_db::token_registry::insert_token(&row) { // Already present is the idempotent case, not a failure. @@ -992,19 +1033,20 @@ impl AppRouterImpl { }; let (creator_genesis, creator_device_id) = (head.genesis_digest(), head.devid()); let parsed = ParsedTokenPolicy { - creator_genesis, - creator_device_id, ticker: ticker.clone(), alias: req.alias.trim().to_string(), decimals: req.decimals, genesis_supply, - release_rule: ReleaseRule::AllAtCreation, + release: Release::AllAtCreation { + creator_genesis, + creator_device_id, + threshold, + signers: vec![creator_pk], + }, description: Some(req.description.trim().to_string()).filter(|s| !s.is_empty()), icon_url: Some(req.icon_url.trim().to_string()).filter(|s| !s.is_empty()), burn_enabled: req.burn_enabled, transferable: req.transferable, - threshold, - signers: vec![creator_pk], allowlist_device_ids, }; @@ -1164,7 +1206,9 @@ impl AppRouterImpl { fields.insert("kind".to_string(), "FUNGIBLE".to_string()); fields.insert("burn_enabled".to_string(), parsed.burn_enabled.to_string()); fields.insert("transferable".to_string(), parsed.transferable.to_string()); - fields.insert("threshold".to_string(), parsed.threshold.to_string()); + if let Release::AllAtCreation { threshold, .. } = &parsed.release { + fields.insert("threshold".to_string(), threshold.to_string()); + } let metadata = TokenMetadata { token_id: token_id.clone(), @@ -1381,10 +1425,10 @@ impl AppRouterImpl { if body.is_empty() { return err("tokens.publishPolicy: empty body".into()); } - // Only a policy Core's one parser accepts is published: the - // network holds it under the anchor devices adopt a token by. - if let Err(e) = dsm::economic::token_policy::parse_token_policy(body) { - return err(format!("tokens.publishPolicy: not a token policy: {e}")); + // Only a policy a device may adopt is published: the network + // holds it under the anchor devices adopt a token by. + if let Err(e) = adoptable_policy(body) { + return err(format!("tokens.publishPolicy: {e}")); } // The anchor is the content hash, always. Publication is @@ -1648,27 +1692,52 @@ mod tests { .encode_to_vec() } - fn fungible_fixture() -> ParsedTokenPolicy { - ParsedTokenPolicy { + /// The fixture's release: device-created by 0x31/0x32, `threshold` of + /// `signers`. + fn created(threshold: u8, signers: Vec>) -> Release { + Release::AllAtCreation { creator_genesis: [0x31; 32], creator_device_id: [0x32; 32], + threshold, + signers, + } + } + + fn fungible_fixture() -> ParsedTokenPolicy { + ParsedTokenPolicy { ticker: "DSM".into(), alias: "DSM Token".into(), decimals: 8, genesis_supply: 1_000_000, - release_rule: ReleaseRule::AllAtCreation, + release: created(1, vec![vec![0xAB; 64]]), description: Some("A test token".into()), icon_url: Some("dsm:icon".into()), burn_enabled: true, transferable: true, - threshold: 1, - signers: vec![vec![0xAB; 64]], allowlist_device_ids: Vec::new(), } } // ── The one packer against Core's one parser ───────────────────── + /// A device-created policy's bytes are exactly the layout it has always + /// had: SoFi Amendment S11 changed only network-anchored policies, so no + /// created token's identity moved. The fixture's commitment is pinned — + /// the value was computed from the documented layout and held by the + /// packer before the grammar changed. + #[test] + fn a_device_created_policy_keeps_its_layout_and_commitment() { + let proto = v3_policy(&fungible_fixture()); + let commit = dsm::crypto::blake3::domain_hash_bytes( + dsm::common::domain_tags::TAG_DSM_POLICY, + &proto, + ); + assert_eq!( + crate::util::text_id::encode_base32_crockford(&commit), + "E1RX7V2A1G9XS9T3K1JDGGPXXXS173YH5HWM7DD388DH05XD493G" + ); + } + #[test] fn the_packed_policy_parses_to_every_field_it_was_packed_from() { let src = fungible_fixture(); @@ -1689,13 +1758,11 @@ mod tests { #[test] fn a_multi_signer_threshold_round_trips() { let src = ParsedTokenPolicy { - threshold: 2, - signers: vec![vec![0x01; 64], vec![0x02; 64], vec![0x03; 64]], + release: created(2, vec![vec![0x01; 64], vec![0x02; 64], vec![0x03; 64]]), ..fungible_fixture() }; let parsed = parse_token_policy(&v3_policy(&src)).expect("parses"); - assert_eq!(parsed.threshold, 2); - assert_eq!(parsed.signers, src.signers); + assert_eq!(parsed.release, src.release); } #[test] @@ -1717,8 +1784,7 @@ mod tests { #[test] fn duplicate_signers_are_refused() { let src = ParsedTokenPolicy { - threshold: 2, - signers: vec![vec![0x07; 64], vec![0x07; 64]], + release: created(2, vec![vec![0x07; 64], vec![0x07; 64]]), ..fungible_fixture() }; assert!(build_policy_v3_bytes(&src).is_err()); @@ -1750,13 +1816,12 @@ mod tests { #[test] fn an_unsatisfiable_threshold_is_refused() { let bad = ParsedTokenPolicy { - threshold: 3, - signers: vec![vec![0x01; 64]], + release: created(3, vec![vec![0x01; 64]]), ..fungible_fixture() }; assert!(build_policy_v3_bytes(&bad).is_err()); let zero = ParsedTokenPolicy { - threshold: 0, + release: created(0, vec![vec![0xAB; 64]]), ..fungible_fixture() }; assert!(build_policy_v3_bytes(&zero).is_err()); @@ -1765,16 +1830,18 @@ mod tests { #[test] fn an_empty_or_oversized_signer_set_is_refused() { let none = ParsedTokenPolicy { - signers: Vec::new(), + release: created(1, Vec::new()), ..fungible_fixture() }; assert!(build_policy_v3_bytes(&none).is_err()); let too_many = ParsedTokenPolicy { - threshold: 1, - signers: (0..(MAX_POLICY_SIGNERS + 1)) - .map(|i| vec![i as u8; 64]) - .collect(), + release: created( + 1, + (0..(MAX_POLICY_SIGNERS + 1)) + .map(|i| vec![i as u8; 64]) + .collect(), + ), ..fungible_fixture() }; assert!(build_policy_v3_bytes(&too_many).is_err()); @@ -1847,6 +1914,44 @@ mod tests { ); } + /// SoFi Amendment S11: the one packer lays out a network-anchored policy + /// with no creator and no signer set, and Core's parser reads it back. + #[test] + fn a_network_anchored_policy_packs_without_a_creator_or_signers() { + let src = ParsedTokenPolicy { + release: Release::Faucet, + ..fungible_fixture() + }; + let parsed = parse_token_policy(&v3_policy(&src)).expect("Core parses it"); + assert_eq!(parsed, src); + let device_created = build_policy_v3_bytes(&fungible_fixture()).expect("packs"); + let network_anchored = build_policy_v3_bytes(&src).expect("packs"); + assert_eq!( + device_created.len() - network_anchored.len(), + 32 + 32 + 1 + 1 + 2 + 64, + "exactly the creator and the one-key signer set are left out" + ); + } + + /// SoFi Amendment S11: exactly one network-anchored policy exists, ERA's, + /// fixed in Core. Any other is a lookalike with no reserve behind it and is + /// neither adopted nor published; a device-created policy is. + #[test] + fn a_network_anchored_lookalike_is_neither_adopted_nor_published() { + let lookalike = ParsedTokenPolicy { + ticker: "ERA".into(), + alias: "ERA".into(), + release: Release::Faucet, + ..fungible_fixture() + }; + let refused = adoptable_policy(&v3_policy(&lookalike)).expect_err("refused"); + assert!(refused.contains("network-anchored"), "{refused}"); + assert_eq!( + adoptable_policy(&v3_policy(&fungible_fixture())).expect("adoptable"), + fungible_fixture() + ); + } + #[test] fn a_proto_that_is_not_a_policy_does_not_parse() { let empty = generated::TokenPolicyV3 { 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 1b1e728df..21044f295 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 @@ -1413,19 +1413,20 @@ mod tests { let (signer_pk, _secret) = dsm::crypto::sphincs::generate_sphincs_keypair().expect("a signer key"); let policy = super::super::token_routes::ParsedTokenPolicy { - creator_genesis: [0x11; 32], - creator_device_id: [0x22; 32], ticker: ticker.to_string(), alias: format!("{ticker} token"), decimals, genesis_supply, - release_rule: dsm::economic::token_policy::ReleaseRule::AllAtCreation, + release: dsm::economic::token_policy::Release::AllAtCreation { + creator_genesis: [0x11; 32], + creator_device_id: [0x22; 32], + threshold: 1, + signers: vec![signer_pk], + }, description: None, icon_url: Some("dsm:coin:v1:ABC".to_string()), burn_enabled, transferable, - threshold: 1, - signers: vec![signer_pk], allowlist_device_ids: vec![], }; let bytes = generated::TokenPolicyV3 { @@ -1457,7 +1458,15 @@ mod tests { alias: policy.alias.clone(), decimals: policy.decimals, genesis_supply, - creator_device_id: policy.creator_device_id, + creator_device_id: match &policy.release { + dsm::economic::token_policy::Release::AllAtCreation { + creator_device_id, + .. + } => *creator_device_id, + dsm::economic::token_policy::Release::Faucet => { + panic!("a registered token is device-created") + } + }, }, ) .expect("the token is registered"); From 5025c66ec6071fdbf90dd1290f07be54741c2628 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:00:28 -0400 Subject: [PATCH 3/3] feat(era): ERA's committed policy in Core, its commitment derived from its bytes, pre-rooted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SoFi Amendment S11. ERA's commitment was raw BLAKE3 of empty input (a 0-byte file "verified" against itself); it committed to no policy, so every ERA transfer and burn was refused. This turns them back on under ERA's own rules. - dsm/src/core/token/era_policy.rs (new): ERA's 40-byte TokenPolicyV3 — version 3, fungible, native, transferable and burnable, no allowlist, the beta faucet release rule (no creator, no signers), ticker/alias ERA, decimals 0, genesis supply 80,000,000,000. era_policy_commit() = BLAKE3(DSM/policy || 0x00 || bytes), computed once; the literal is deleted. Golden test: the specification's check value JXPMPGJH45HDTE0ARWE2CTB9E9BWTQZ3T78CE5RFF1RXMR9VKK80. The one packer reproduces the bytes (SDK test). - The enforcer answers ERA from Core's bytes before the LRU cache and the resolver (never evicted, never fetched); ERA's bytes are never registered. A creation naming ERA's commitment is refused from ERA's own policy. - SDK: native.ctpa.bin, native.commit32 and the native half of builtins.rs deleted; builtin_policy_commit("ERA") reads Core; tokens.addByAnchor refuses protocol assets; load_policy_bytes and anchored_policy_bytes answer ERA from Core and never fetch or store it; the wallet's ERA facts (symbol, decimals, supply, permissions, anchor) are the policy's. - CONFORMANCE_GAPS §6.33; MR-SOFI-0334-0336 Met; the §6.32 manifest discharged: its 45 tests pass (dsm_sdk lib 183/0 across the manifest modules and the token, policy, wallet and faucet tests; dsm lib 73/0, integration 66/0). ERA is re-keyed (balance keys, reserve id); R_0 is computed, never stored, so the fleet needs no reset; wallets holding the old ERA start fresh. --- .../dsm/src/core/token/era_policy.rs | 119 ++++++++++++++++++ .../dsm/src/core/token/mod.rs | 1 + .../dsm/src/core/token/policy/mod.rs | 65 ++++++++-- .../dsm/src/core/token/token_state_manager.rs | 43 +++---- .../dsm/src/economic/provenance.rs | 12 +- .../tests/economic_provenance_semantics.rs | 22 ++++ .../src/handlers/token_adoption_tests.rs | 20 +++ .../dsm_sdk/src/handlers/token_routes.rs | 25 ++++ .../dsm_sdk/src/handlers/wallet_routes.rs | 58 +++++---- .../dsm_sdk/src/policies/native.ctpa.bin | 0 .../dsm_sdk/src/policy/builtins.rs | 69 +++------- .../dsm_sdk/src/policy/mod.rs | 2 +- .../src/policy_commits/native.commit32 | 1 - .../dsm_sdk/src/sdk/core_sdk.rs | 5 +- .../dsm_sdk/src/sdk/economic_registers.rs | 5 + specs/requirements/CONFORMANCE_GAPS.md | 43 +++++-- 16 files changed, 366 insertions(+), 124 deletions(-) create mode 100644 dsm_client/deterministic_state_machine/dsm/src/core/token/era_policy.rs delete mode 100644 dsm_client/deterministic_state_machine/dsm_sdk/src/policies/native.ctpa.bin delete mode 100644 dsm_client/deterministic_state_machine/dsm_sdk/src/policy_commits/native.commit32 diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/era_policy.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/era_policy.rs new file mode 100644 index 000000000..cb33682c4 --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/era_policy.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! ERA's canonical token policy (SoFi Amendment S11): the one network-anchored +//! policy, fixed here, held by every device by construction. +//! +//! ERA's identity is derived from these exact bytes and from nothing else: +//! [`era_policy_commit`] is `BLAKE3(DSM/policy ‖ 0x00 ‖ TokenPolicyV3 bytes)`, +//! the commitment every token has (§47). A different byte is a different +//! ERA — every ERA balance key and the reserve id move with it — so the value +//! is pinned by this module's tests and by the specification's check value. + +use std::sync::LazyLock; + +use crate::economic::token_policy::{parse_token_policy, TokenPolicy}; +use crate::types::error::DsmError; + +/// ERA's `TokenPolicyV3` bytes, field by field (SoFi §47, Amendment S11). +const ERA_POLICY_PROTO: [u8; 40] = [ + // TokenPolicyV3 { policy_bytes (field 1) }: the 38-byte blob. + 0x0A, 0x26, // + // version 3, fungible, native. + 0x03, 0x00, 0x00, // + // flags: burn | transferable; no recipient allowlist. + 0x03, // + // release rule: the beta faucet. Network-anchored, so no creator and no + // signer set follow (Amendment S11). + 0x01, // + // ticker "ERA". + 0x03, b'E', b'R', b'A', // + // alias "ERA". + 0x00, 0x03, b'E', b'R', b'A', // + // decimals: whole ERA. + 0x00, // + // genesis supply: 80,000,000,000 (u128, big-endian; owner, 2026-09-26). + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x12, 0xA0, 0x5F, 0x20, 0x00, // + // description: none; icon: none. + 0x00, 0x00, 0x00, 0x00, // + // recipient allowlist: none (kind NONE, count 0). + 0x00, 0x00, 0x00, +]; + +static ERA_POLICY_COMMIT: LazyLock<[u8; 32]> = LazyLock::new(|| { + crate::core::token::policy::TokenPolicySystem::commitment_of(&ERA_POLICY_PROTO) +}); + +static ERA_POLICY: LazyLock> = + LazyLock::new(|| parse_token_policy(&ERA_POLICY_PROTO)); + +/// ERA's policy bytes, exactly as committed. +pub fn era_policy_bytes() -> &'static [u8] { + &ERA_POLICY_PROTO +} + +/// ERA's policy commitment, derived from its bytes. +pub fn era_policy_commit() -> [u8; 32] { + *ERA_POLICY_COMMIT +} + +/// ERA's policy, read by the one parser. The compiled bytes parse — this +/// module's tests pin it — and Core does not panic on its own data, so a +/// failure reaches the caller as an error. +pub fn era_policy() -> Result<&'static TokenPolicy, DsmError> { + ERA_POLICY.as_ref().map_err(|e| { + DsmError::invalid_operation(format!("ERA's compiled policy does not parse: {e}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::token::policy::TokenPolicySystem; + use crate::economic::token_policy::Release; + use prost::Message; + + /// SoFi Amendment S11: ERA's commitment is derived from its bytes, and it + /// is the check value the specification states. Any byte changed is a + /// different ERA, and it goes red here. + #[test] + fn eras_commitment_is_derived_from_its_bytes_and_is_the_specifications() { + assert_eq!( + era_policy_commit(), + TokenPolicySystem::commitment_of(era_policy_bytes()) + ); + assert_eq!( + crate::utils::text_id::encode_base32_crockford(&era_policy_commit()), + "JXPMPGJH45HDTE0ARWE2CTB9E9BWTQZ3T78CE5RFF1RXMR9VKK80" + ); + } + + /// One policy, one commitment: ERA's bytes are the canonical encoding of + /// the blob they carry. + #[test] + fn eras_bytes_are_the_canonical_encoding_of_its_blob() { + let decoded = crate::types::proto::TokenPolicyV3::decode(era_policy_bytes()) + .expect("ERA's wrapper decodes"); + assert_eq!(decoded.encode_to_vec(), era_policy_bytes()); + } + + /// Every field of ERA's policy, as SoFi Amendment S11 fixes it. + #[test] + fn eras_policy_states_what_the_specification_fixes() { + assert_eq!( + era_policy().expect("ERA's policy parses"), + &TokenPolicy { + ticker: "ERA".into(), + alias: "ERA".into(), + decimals: 0, + genesis_supply: 80_000_000_000, + release: Release::Faucet, + description: None, + icon_url: None, + burn_enabled: true, + transferable: true, + allowlist_device_ids: Vec::new(), + } + ); + } +} diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/mod.rs index 8460ad475..5d920b8d2 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/mod.rs @@ -2,6 +2,7 @@ //! src/core/token/mod.rs +pub mod era_policy; pub mod policy; pub mod token_state_manager; diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs index b41806d21..3810302d8 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/mod.rs @@ -9,9 +9,11 @@ //! any other way: registration takes the bytes, recomputes the commitment //! from them and derives the enforcer's view from what the blob says //! (`policy_enforcement::enforced_policy`), and enforcement is keyed by the -//! commitment an operation names. A token with no committed policy — ERA, -//! whose `policy_commit` constant has no preimage yet — has no policy here, -//! and an operation naming it is refused for that reason. +//! commitment an operation names. ERA's policy is pre-rooted: every device +//! holds its bytes by construction ([`crate::core::token::era_policy`], SoFi +//! Amendment S11), so it is answered from them, never from the cache, which +//! could evict it, nor from the durable store, which never holds it. A +//! commitment no committed policy is in hand for permits nothing. //! //! Determinism rules: no wall-clock; enforcement reads only what the //! operation carries and what Core derived from canonical state. @@ -124,6 +126,13 @@ impl TokenPolicySystem { /// commitment, but there is nothing to evaluate against either way. pub async fn policy_at(&self, commit: &[u8; 32]) -> Result, DsmError> { let anchor = PolicyAnchor::from_bytes(*commit); + if *commit == crate::core::token::era_policy::era_policy_commit() { + let era = crate::core::token::era_policy::era_policy()?; + return Ok(Some(TokenPolicy::new_with_anchor( + enforced_policy(era), + anchor, + ))); + } if let Some(policy) = self.policy_cache.get_policy(&anchor).await? { return Ok(Some(policy)); } @@ -254,15 +263,15 @@ mod tests { ); } - /// A commitment no committed policy is in hand for — ERA's today, whose - /// constant has no preimage — permits nothing: the operation is denied - /// for the absence, never allowed by a default. + /// A commitment no committed policy is in hand for permits nothing: the + /// operation is denied for the absence, never allowed by a default. #[tokio::test] async fn an_operation_naming_a_commitment_without_a_policy_is_denied() { let system = TokenPolicySystem::new(); - let era = crate::core::token::token_state_manager::era_policy_commit(); + let unregistered = + TokenPolicySystem::commitment_of(&token_policy_bytes_with(9, POLICY_FLAG_TRANSFERABLE)); let result = system - .enforce_policy(&era, "transfer", &context(1)) + .enforce_policy(&unregistered, "transfer", &context(1)) .await .expect("enforced"); assert!(!result.allowed); @@ -272,6 +281,46 @@ mod tests { ); } + /// SoFi Amendment S11: ERA's policy is pre-rooted. A transfer and a burn of + /// ERA are permitted by ERA's own committed rules with nothing registered, + /// nothing cached, and a resolver that is never asked. + #[tokio::test] + async fn eras_policy_is_answered_from_cores_bytes_never_cached_or_resolved() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let system = TokenPolicySystem::new(); + let asked = Arc::new(AtomicUsize::new(0)); + let counter = asked.clone(); + system.set_policy_resolver(Arc::new(move |_commit: &[u8; 32]| { + counter.fetch_add(1, Ordering::SeqCst); + None + })); + let era = crate::core::token::era_policy::era_policy_commit(); + for operation in ["transfer", "burn"] { + let result = system + .enforce_policy(&era, operation, &context(1)) + .await + .expect("enforced"); + assert!(result.allowed, "ERA {operation}: {}", result.reason); + } + assert_eq!( + asked.load(Ordering::SeqCst), + 0, + "the resolver was asked for ERA" + ); + assert!(system.policy_cache.is_empty(), "ERA took a cache slot"); + } + + /// ERA's own bytes are network-anchored, so they are registered by nobody: + /// its policy is Core's, fixed (Amendment S11). + #[tokio::test] + async fn eras_own_bytes_are_never_registered() { + let system = TokenPolicySystem::new(); + assert!(system + .register_policy(crate::core::token::era_policy::era_policy_bytes()) + .is_err()); + assert!(system.policy_cache.is_empty()); + } + /// The durable bytes at a commitment are taken on a cache miss when they /// re-hash to it; the policy is then the one those bytes commit. #[tokio::test] diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs index 8436032ce..fa8b72868 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs @@ -2,9 +2,10 @@ //! Token policy commits and canonical balance keys. //! -//! A token is named in hashing by its 32-byte CPTA `policy_commit`: builtins -//! (ERA, dBTC) carry fixed commits, every other token resolves through a -//! registered policy. Balance keys are derived under that commit. +//! A token is named in hashing by its 32-byte `policy_commit`: ERA's is +//! derived from its committed policy ([`crate::core::token::era_policy`]), +//! dBTC's is fixed, and every other token resolves through a registered +//! policy. Balance keys are derived under that commit. use std::collections::HashMap; @@ -91,23 +92,16 @@ pub fn derive_canonical_balance_key( format!("{prefix}|{token_id}") } +/// ERA's policy commitment, derived from ERA's committed policy bytes (SoFi +/// Amendment S11). Callers that mean ERA use this and carry no +/// string-keyed lookup. +pub use crate::core::token::era_policy::era_policy_commit; + /// Deterministic policy_commit lookup for builtin token types. /// Used by state machine core to apply token operations deterministically. -/// The builtin ERA policy commit, infallibly. -/// -/// `builtin_policy_commit_for_token("ERA")` returns `Option` only because it -/// is a string-keyed lookup; the "ERA" arm is a constant that cannot miss. -/// Callers that mean ERA specifically should use this and carry no -/// panic-or-error path for an impossibility. -pub fn era_policy_commit() -> [u8; 32] { - ERA_POLICY_COMMIT -} - pub fn builtin_policy_commit_for_token(token_id: &str) -> Option<[u8; 32]> { - // These values must match the SDK's policy/builtins.rs for consistency. - // Era/dBTC are the canonical builtin tokens for DSM. match token_id { - "ERA" => Some(ERA_POLICY_COMMIT), + "ERA" => Some(era_policy_commit()), "dBTC" => Some(DBTC_POLICY_COMMIT), _ => None, } @@ -123,7 +117,7 @@ pub fn builtin_policy_commit_for_token(token_id: &str) -> Option<[u8; 32]> { /// balance keys in the canonical `{prefix}|{token_id}` format produced by /// [`derive_canonical_balance_key`]. pub fn builtin_token_id_for_policy_commit(policy_commit: &[u8; 32]) -> Option<&'static str> { - if *policy_commit == ERA_POLICY_COMMIT { + if *policy_commit == era_policy_commit() { Some("ERA") } else if *policy_commit == DBTC_POLICY_COMMIT { Some("dBTC") @@ -132,11 +126,6 @@ pub fn builtin_token_id_for_policy_commit(policy_commit: &[u8; 32]) -> Option<&' } } -const ERA_POLICY_COMMIT: [u8; 32] = [ - 0xaf, 0x13, 0x49, 0xb9, 0xf5, 0xf9, 0xa1, 0xa6, 0xa0, 0x40, 0x4d, 0xea, 0x36, 0xdc, 0xc9, 0x49, - 0x9b, 0xcb, 0x25, 0xc9, 0xad, 0xc1, 0x12, 0xb7, 0xcc, 0x9a, 0x93, 0xca, 0xe4, 0x1f, 0x32, 0x62, -]; - const DBTC_POLICY_COMMIT: [u8; 32] = [ 0x03, 0xa4, 0x2b, 0x67, 0x19, 0x17, 0xaf, 0x84, 0x2f, 0x07, 0x3d, 0x87, 0xcf, 0xa4, 0x59, 0xd8, 0x45, 0xb9, 0x68, 0xfd, 0xb1, 0xab, 0xcb, 0x03, 0x31, 0x2d, 0x91, 0x4e, 0x35, 0x01, 0x62, 0x22, @@ -144,11 +133,11 @@ const DBTC_POLICY_COMMIT: [u8; 32] = [ /// Resolve policy_commit for a token by ticker. /// -/// §9.1: all TokenOps MUST include `policy_commit`. Builtins (ERA, dBTC) -/// resolve to their precomputed constants. For CPTA-anchored custom tokens -/// the canonical policy_commit is `BLAKE3-256("DSM/cpta\0" || canonical_cpta_bytes)` -/// and can only be produced by reading the registered `TokenPolicyV3` — not -/// derived from the ticker string. +/// §9.1: all TokenOps MUST include `policy_commit`. Builtins resolve to +/// theirs: ERA's derived from its committed policy, dBTC's fixed. For every +/// other token the canonical policy_commit is +/// `BLAKE3(DSM/policy ‖ 0x00 ‖ TokenPolicyV3 bytes)` and can only be produced +/// by reading the registered policy — not derived from the ticker string. /// /// This function therefore strict-fails for any non-builtin token. Callers /// that handle custom tokens MUST carry `policy_commit` explicitly on the diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs index fb639a111..8e2b923c4 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs @@ -672,9 +672,15 @@ fn verify_genesis_release( )); }; let policy_commit = *policy_commit; - let bytes = resolver - .anchored_policy_bytes(&policy_commit) - .map_err(ProvenanceError::GenesisReleasePolicy)?; + // ERA's policy is Core's own (Amendment S11): answered from its bytes, + // never asked of a resolver, so a creation naming it is refused at once. + let bytes = if policy_commit == crate::core::token::era_policy::era_policy_commit() { + crate::core::token::era_policy::era_policy_bytes().to_vec() + } else { + resolver + .anchored_policy_bytes(&policy_commit) + .map_err(ProvenanceError::GenesisReleasePolicy)? + }; // Bytes that do not re-hash to the commit are not the policy; they supply // nothing and prove nothing about the token. if crate::crypto::blake3::domain_hash_bytes(crate::common::domain_tags::TAG_DSM_POLICY, &bytes) diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs index 4ae49166e..f2290b66f 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs @@ -359,6 +359,28 @@ fn a_genesis_release_rides_only_its_creating_operation() { } } +/// A creation naming ERA's commitment is refused at once from ERA's own +/// policy, which Core holds (Amendment S11): no resolver is asked, so it is +/// Invalid, never an availability failure waiting on a fetch. +#[test] +fn a_creation_naming_eras_commitment_is_refused_from_eras_own_policy() { + let era = dsm::core::token::era_policy::era_policy_commit(); + let op = create_token(era, 1_000); + match verify_release( + &Anchors(Vec::new()), + &release_witness(era, 1_000), + Some(&op), + ) { + Err(ProvenanceError::GenesisReleaseInvalid(why)) => { + assert!( + why.contains("release rule"), + "refused for another reason: {why}" + ) + } + other => panic!("a creation of ERA was not refused from ERA's policy: {other:?}"), + } +} + /// A network-anchored policy (Amendment S11) is a well-formed policy that /// releases nothing at creation: the refusal is the release rule's, not a /// parse error. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_adoption_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_adoption_tests.rs index 8ff528233..7c2871da9 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_adoption_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_adoption_tests.rs @@ -170,6 +170,26 @@ async fn builtins_resolve_and_an_unknown_token_fails_closed() { assert!(d.core().resolve_policy_commit_strict(b"NEVERSEEN").is_err()); } +/// A protocol asset is held by every device by construction and is never +/// adopted by its anchor: ERA's commitment is refused as what it is, and no +/// registry row is written for it (SoFi Amendment S11). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_protocol_asset_is_never_adopted_by_its_anchor() { + let d = Device::start(0xE7).await; + let era = dsm::core::token::token_state_manager::era_policy_commit(); + let refused = adopt(&d.router, &anchor_text(&era)).await; + assert!(!refused.success, "ERA was adopted by its anchor"); + let why = refused.error_message.unwrap_or_default(); + assert!( + why.contains("protocol asset"), + "refused for another reason: {why}" + ); + assert!(token_registry::get_token_by_ticker("ERA") + .expect("the registry is readable") + .is_none()); +} + /// EVERY token query route is reachable through the production dispatcher: /// the failure where a handler arm exists but the dispatch table does not name /// it has happened twice. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs index 76015abe2..5e4a29512 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs @@ -554,6 +554,13 @@ impl AppRouterImpl { /// The table read re-verifies that the bytes hash to the anchor; a /// corrupted row, or a table that cannot be read, is an error. async fn load_policy_bytes(&self, anchor: [u8; 32]) -> Result>, String> { + // ERA's policy is Core's own (SoFi Amendment S11): answered from its + // bytes, never fetched and never stored. + if anchor == dsm::core::token::token_state_manager::era_policy_commit() { + return Ok(Some( + dsm::core::token::era_policy::era_policy_bytes().to_vec(), + )); + } if let Some(bytes) = self.policy_cache.lock().await.get(&anchor).cloned() { return Ok(Some(bytes)); } @@ -667,6 +674,13 @@ impl AppRouterImpl { Err(e) => return err(format!("tokens.addByAnchor: {e}")), }; let anchor = input.anchor; + if let Some(builtin) = dsm::core::token::builtin_token_id_for_policy_commit(&anchor) + { + return err(format!( + "tokens.addByAnchor: {builtin} is a protocol asset — every device already \ + has it" + )); + } let policy_bytes = match self.load_policy_bytes(anchor).await { Ok(Some(b)) if !b.is_empty() => b, @@ -1933,6 +1947,17 @@ mod tests { ); } + /// SoFi §47, Amendment S11: the one packer reproduces ERA's policy bytes + /// exactly from ERA's fields — Core's compiled bytes are the packer's. + #[test] + fn the_one_packer_reproduces_eras_policy_bytes() { + let era = dsm::core::token::era_policy::era_policy().expect("ERA's policy parses"); + assert_eq!( + v3_policy(era), + dsm::core::token::era_policy::era_policy_bytes() + ); + } + /// SoFi Amendment S11: exactly one network-anchored policy exists, ERA's, /// fixed in Core. Any other is a lookalike with no reserve behind it and is /// neither adopted nor published; a device-created policy is. 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 21044f295..d1ec837c5 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 @@ -26,7 +26,9 @@ pub fn token_decimals(token_id: &str) -> Result { let canonical = canonicalize_token_id(token_id); match canonical.to_ascii_uppercase().as_str() { "" => Err("a token's decimals were asked for with no token named".to_string()), - "ERA" => Ok(0), + "ERA" => dsm::core::token::era_policy::era_policy() + .map(|era| era.decimals) + .map_err(|e| e.to_string()), "DBTC" | "BTC" => Ok(8), _ => crate::storage::client_db::token_registry::get_token_by_ticker(&canonical) .map_err(|e| format!("token registry unreadable for {canonical}: {e}"))? @@ -74,23 +76,24 @@ pub(crate) fn enrich_balance_metadata( let token_id = reply.token_id.trim().to_uppercase(); match token_id.as_str() { "ERA" => { - reply.symbol = "ERA".to_string(); - reply.decimals = 0; - reply.token_name = "ERA".to_string(); - reply.display_amount = format_base_units_for_display(reply.available, 0); - if let Some(c) = crate::policy::builtin_policy_commit("ERA") { - set_anchor(reply, &c); - } - // The protocol defines ERA, and its supply is the native reserve's, - // from which every unit in circulation was released. ERA's policy - // blob does not exist yet (§6.32), so nothing is stated about what - // it permits: absent, never a defaulted "not permitted". - reply.protocol_defined = true; - reply.genesis_supply_display = format_base_units_for_display( - dsm::economic::native_reserve::ERA_RESERVE_GENESIS_SUPPLY, - 0, + // ERA's facts are its committed policy's (SoFi Amendment S11), + // which every device holds by construction. + let era = dsm::core::token::era_policy::era_policy().map_err(|e| e.to_string())?; + reply.symbol = era.ticker.clone(); + reply.decimals = era.decimals; + reply.token_name = era.alias.clone(); + reply.display_amount = format_base_units_for_display(reply.available, era.decimals); + set_anchor( + reply, + &dsm::core::token::token_state_manager::era_policy_commit(), ); - reply.permissions = None; + reply.protocol_defined = true; + reply.genesis_supply_display = + format_supply_for_display(era.genesis_supply, era.decimals); + reply.permissions = Some(generated::TokenPolicyPermissions { + burn_enabled: era.burn_enabled, + transferable: era.transferable, + }); } "DBTC" => { reply.token_id = "dBTC".to_string(); @@ -1472,18 +1475,29 @@ mod tests { .expect("the token is registered"); } - /// A protocol asset is one on Rust's word, not its ticker's. ERA's supply - /// is the reserve's; its policy blob does not exist yet (§6.32), so - /// nothing is stated about what it permits. + /// A protocol asset is one on Rust's word, not its ticker's. ERA's facts + /// are its committed policy's (SoFi Amendment S11): the supply, what + /// holders may do, and the anchor its bytes commit to. #[test] #[serial_test::serial] - fn era_is_protocol_defined_with_the_reserve_supply_and_no_stated_permissions() { + fn era_reports_its_committed_policy_supply_permissions_and_anchor() { fresh_db(); let mut era = seed("ERA", 264, 0); super::enrich_balance_metadata(&mut era).expect("ERA is named"); assert!(era.protocol_defined); + assert_eq!((era.symbol.as_str(), era.decimals), ("ERA", 0)); assert_eq!(era.genesis_supply_display, "80000000000"); - assert_eq!(era.permissions, None, "no policy blob: nothing stated"); + assert_eq!( + era.permissions, + Some(generated::TokenPolicyPermissions { + burn_enabled: true, + transferable: true, + }) + ); + assert_eq!( + era.policy_anchor_b32, + "JXPMPGJH45HDTE0ARWE2CTB9E9BWTQZ3T78CE5RFF1RXMR9VKK80" + ); let mut dbtc = seed("dBTC", 0, 0); super::enrich_balance_metadata(&mut dbtc).expect("dBTC is named"); assert!(dbtc.protocol_defined); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/policies/native.ctpa.bin b/dsm_client/deterministic_state_machine/dsm_sdk/src/policies/native.ctpa.bin deleted file mode 100644 index e69de29bb..000000000 diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/policy/builtins.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/policy/builtins.rs index 9587053d8..7bf251759 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/policy/builtins.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/policy/builtins.rs @@ -1,26 +1,22 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Built-in CPTA for the native token: immutable bytes + fixed 32-byte commit. -//! Protobuf-only, no JSON/base64, no clocks. +//! Built-in policy bytes for dBTC: immutable bytes + fixed 32-byte commit. +//! ERA's policy is Core's (`dsm::core::token::era_policy`). Protobuf-only, +//! no JSON/base64, no clocks. use blake3::hash; #[derive(Copy, Clone, Debug)] pub enum BuiltinPolicy { - Native, Dbtc, } -pub const NATIVE_POLICY_COMMIT: &[u8; 32] = include_bytes!("../policy_commits/native.commit32"); // 32 RAW BYTES -pub const NATIVE_POLICY_BYTES: &[u8] = include_bytes!("../policies/native.ctpa.bin"); // OPAQUE PROTOBUF BYTES - pub const DBTC_POLICY_COMMIT: &[u8; 32] = include_bytes!("../policy_commits/dbtc.commit32"); // 32 RAW BYTES pub const DBTC_POLICY_BYTES: &[u8] = include_bytes!("../policies/dbtc.ctpa.bin"); // OPAQUE PROTOBUF BYTES #[inline] pub fn bytes_and_commit(p: BuiltinPolicy) -> (&'static [u8], &'static [u8; 32]) { match p { - BuiltinPolicy::Native => (NATIVE_POLICY_BYTES, NATIVE_POLICY_COMMIT), BuiltinPolicy::Dbtc => (DBTC_POLICY_BYTES, DBTC_POLICY_COMMIT), } } @@ -29,49 +25,32 @@ pub fn bytes_and_commit(p: BuiltinPolicy) -> (&'static [u8], &'static [u8; 32]) /// STRICT: zero-commit is forbidden; mismatch panics. /// This is aligned with "strict-fail" policy (no dev defaults). pub fn assert_builtins_sound() { - for (label, policy) in [ - ("native", BuiltinPolicy::Native), - ("dbtc", BuiltinPolicy::Dbtc), - ] { - let (bytes, commit) = bytes_and_commit(policy); - - // Forbid all-zero commit - let zero = [0u8; 32]; - assert_ne!( - commit, &zero, - "{label}.commit32 is all zeros — provide real commit bytes" - ); - - let got = hash(bytes); - assert_eq!( - got.as_bytes(), - commit, - "CPTA builtin mismatch: blake3({label}.ctpa.bin) != {label}.commit32", - ); - } + let (bytes, commit) = bytes_and_commit(BuiltinPolicy::Dbtc); + + // Forbid all-zero commit + let zero = [0u8; 32]; + assert_ne!( + commit, &zero, + "dbtc.commit32 is all zeros — provide real commit bytes" + ); + + let got = hash(bytes); + assert_eq!( + got.as_bytes(), + commit, + "CPTA builtin mismatch: blake3(dbtc.ctpa.bin) != dbtc.commit32", + ); } #[cfg(test)] mod tests { use super::*; - #[test] - fn native_policy_commit_is_32_bytes() { - assert_eq!(NATIVE_POLICY_COMMIT.len(), 32); - } - #[test] fn dbtc_policy_commit_is_32_bytes() { assert_eq!(DBTC_POLICY_COMMIT.len(), 32); } - #[test] - fn bytes_and_commit_native_matches_constants() { - let (bytes, commit) = bytes_and_commit(BuiltinPolicy::Native); - assert_eq!(bytes, NATIVE_POLICY_BYTES); - assert_eq!(commit, NATIVE_POLICY_COMMIT); - } - #[test] fn bytes_and_commit_dbtc_matches_constants() { let (bytes, commit) = bytes_and_commit(BuiltinPolicy::Dbtc); @@ -79,11 +58,6 @@ mod tests { assert_eq!(commit, DBTC_POLICY_COMMIT); } - #[test] - fn native_commit_not_all_zeros() { - assert_ne!(NATIVE_POLICY_COMMIT, &[0u8; 32]); - } - #[test] fn dbtc_commit_not_all_zeros() { assert_ne!(DBTC_POLICY_COMMIT, &[0u8; 32]); @@ -94,16 +68,9 @@ mod tests { assert_builtins_sound(); } - #[test] - fn native_and_dbtc_commits_differ() { - assert_ne!(NATIVE_POLICY_COMMIT, DBTC_POLICY_COMMIT); - } - #[test] fn builtin_enum_debug_format() { - let native = format!("{:?}", BuiltinPolicy::Native); let dbtc = format!("{:?}", BuiltinPolicy::Dbtc); - assert_eq!(native, "Native"); assert_eq!(dbtc, "Dbtc"); } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/policy/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/policy/mod.rs index 13467993f..75d2d9510 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/policy/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/policy/mod.rs @@ -11,7 +11,7 @@ use dsm::types::{error::DsmError, policy_types::PolicyAnchor}; pub fn builtin_policy_commit(token_id: &str) -> Option<[u8; 32]> { match token_id { - "ERA" => Some(*builtins::NATIVE_POLICY_COMMIT), + "ERA" => Some(dsm::core::token::token_state_manager::era_policy_commit()), "dBTC" => Some(*builtins::DBTC_POLICY_COMMIT), _ => None, } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/policy_commits/native.commit32 b/dsm_client/deterministic_state_machine/dsm_sdk/src/policy_commits/native.commit32 deleted file mode 100644 index df8072972..000000000 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/policy_commits/native.commit32 +++ /dev/null @@ -1 +0,0 @@ -¯I¹õù¡¦ @Mê6ÜÉI›Ë%ɭÁ·̚“Êä2b \ No newline at end of file diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs index 183148a94..23b459e38 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs @@ -746,9 +746,8 @@ impl CoreSDK { ); // Policies are registered from their committed bytes as tokens are // created and adopted (`register_policy_bytes`) and rehydrated from - // the durable store on a miss. ERA has none: its `policy_commit` - // constant has no preimage, so no ERA transfer or burn passes - // enforcement until ERA's policy blob exists. + // the durable store on a miss. ERA's is pre-rooted in Core and + // answered from its own bytes (SoFi Amendment S11). let policy_system = TokenPolicySystem::new(); let state_machine = Mutex::new(StateMachine::new()); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs index 220fd9453..b25d02025 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs @@ -485,6 +485,11 @@ pub(crate) fn anchored_policy_bytes( policy_commit: &[u8; 32], runtime: &tokio::runtime::Handle, ) -> Result, PeerLineageFailure> { + // ERA's policy is Core's own (SoFi Amendment S11): answered from its + // bytes, never fetched and never stored. + if *policy_commit == dsm::core::token::token_state_manager::era_policy_commit() { + return Ok(dsm::core::token::era_policy::era_policy_bytes().to_vec()); + } match crate::storage::client_db::token_registry::load_policy_verified(policy_commit) { Ok(Some(bytes)) => return Ok(bytes), Ok(None) => {} diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 1fab3e128..6034fc34f 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1049,7 +1049,7 @@ Tests for the faucet: frontend `dsm/__tests__/faucetClaim.test.ts`; `components/ Tests for policy publication: `dsm_sdk::handlers::token_routes::tests::bytes_that_are_not_a_token_policy_are_not_published` (refused and not kept; a policy Core accepts is kept); frontend `dsm/__tests__/policies.test.ts` · `publishes the pasted bytes exactly as pasted`, `bytes that are not a policy are Rust's to refuse, in its words`. Mutation controls, each red on its named test: Rust's parser check removed; the frontend's decode and re-encode restored. -Tests for token facts: `dsm_sdk::handlers::wallet_routes::tests::era_is_protocol_defined_with_the_reserve_supply_and_no_stated_permissions`, `a_registered_token_reports_its_policy_supply_and_permissions` (a policy packed by the one packer, stored under its anchor, registered as adoption registers it), `a_registry_row_that_disagrees_with_its_policy_is_refused`; frontend `components/screens/__tests__/AccountsScreen.tokens.test.tsx` · `shows a created token's supply and what its policy permits, as Rust reports them`, `shows a protocol asset's supply from Rust and states nothing Rust does not`, `offers no BURN where the policy forbids it, and says so`, `takes a token for a protocol asset only on Rust's word, never on its ticker`; `dsm/__tests__/wallet.test.ts` · `a row without the fields Rust always writes is refused, never filled in` (a created token's row without its policy facts). Mutation controls, each red on its named test: ERA's permissions defaulted where none are stated; a registered token reported as protocol-defined; a row disagreeing with its policy reported anyway; protocol-ness keyed on the ticker again; BURN offered regardless of the policy; permissions defaulted where Rust states none; a created token's row without its policy facts accepted. +Tests for token facts: `dsm_sdk::handlers::wallet_routes::tests::era_reports_its_committed_policy_supply_permissions_and_anchor` (since §6.33; before it, ERA stated no permissions), `a_registered_token_reports_its_policy_supply_and_permissions` (a policy packed by the one packer, stored under its anchor, registered as adoption registers it), `a_registry_row_that_disagrees_with_its_policy_is_refused`; frontend `components/screens/__tests__/AccountsScreen.tokens.test.tsx` · `shows a created token's supply and what its policy permits, as Rust reports them`, `shows a protocol asset's supply from Rust and states nothing Rust does not`, `offers no BURN where the policy forbids it, and says so`, `takes a token for a protocol asset only on Rust's word, never on its ticker`; `dsm/__tests__/wallet.test.ts` · `a row without the fields Rust always writes is refused, never filled in` (a created token's row without its policy facts). Mutation controls, each red on its named test: ERA's permissions defaulted where none are stated; a registered token reported as protocol-defined; a row disagreeing with its policy reported anyway; protocol-ness keyed on the ticker again; BURN offered regardless of the policy; permissions defaulted where Rust states none; a created token's row without its policy facts accepted. Tests for the reload path: frontend `hooks/__tests__/useWalletRefreshListener.test.tsx` · `an event after a completed refresh runs another: nothing is dropped`, `events during a running refresh owe exactly one more after it`, `an owed refresh is not run after unmount`; `contexts/__tests__/WalletCreditSound.test.tsx` · `reloads once per announced change and plays the coin sound only on a credit` (the event bridge's own announcements, decoded from bytes); `components/screens/__tests__/EnhancedWalletScreen.events.test.tsx` · `one inbox sync with new items reloads the wallet data once`; `dsm/__tests__/EventBridge.bilateral.test.ts` · `a BLE prepare response announces no wallet change`. Mutation controls, each red on its named test: the drop gate reintroduced; the provider's raw `inbox.updated` reload re-added; the screen hook's raw `inbox.updated` reload re-added; the BLE prepare-response emit restored. @@ -1137,11 +1137,11 @@ Owner decision (plan of 2026-09-25): delete the fake ERA policy and leave the ho | Location | Hole | |---|---| -| `dsm/src/core/token/token_state_manager.rs` · `ERA_POLICY_COMMIT` | ERA has no committed policy: no `TokenPolicyV3` bytes hash to its commitment, and the policy grammar has no encoding for its release rule (the emission schedule, §51 — emissions are out of this round). Until the blob exists, every ERA transfer and burn is refused at enforcement ("no policy is committed at the commitment the operation names"). The tests this turns red are listed in the expected-red manifest below. | +| `dsm/src/core/token/token_state_manager.rs` · `ERA_POLICY_COMMIT` | **Resolved by SoFi Amendment S11 (§6.33): ERA's policy is defined and its commitment derived from it.** ERA had no committed policy: no `TokenPolicyV3` bytes hash to its commitment, and the policy grammar has no encoding for its release rule (the emission schedule, §51 — emissions are out of this round). Until the blob exists, every ERA transfer and burn is refused at enforcement ("no policy is committed at the commitment the operation names"). The tests this turns red are listed in the expected-red manifest below. | | `dsm/src/sofi/validation.rs` · `market_legs_permitted` | The same absence at SoFi validation: ERA and dBTC are exempted as "pre-rooted" and read no policy bytes, so a SoFi leg in ERA is checked against nothing where every other token is checked against its committed policy. Recorded; not changed in this cut. | | `dsm/src/core/token/policy/policy_enforcement.rs` · `BitcoinTapConstraint` | A configuration-only arm that allows unconditionally, kept because Bitcoin is not touched in this round. | -**Expected-red manifest** (the `Rust tests (dsm_sdk)` job of this commit's board, run 36226702605: 1020 tests, 46 failed). The 45 below fail on the ERA refusal — "Token policy violation for `NW9MKEFNZ6GTD8209QN3DQ6996DWP9E9NQ0H5DYCKA9WNS0Z69H0`: no policy is committed at the commitment the operation names" — at an ERA transfer or burn. Every other job of the board is green (`Rust tests (dsm)`, `workspace-rest`, `Storage Node (Postgres)`, the gates, Lean). A red outside this list is a regression; a listed test that goes green without ERA's policy is a finding. +**Expected-red manifest** (the `Rust tests (dsm_sdk)` job of this commit's board, run 36226702605: 1020 tests, 46 failed). The 45 below fail on the ERA refusal — "Token policy violation for : no policy is committed at the commitment the operation names" — at an ERA transfer or burn. Every other job of the board is green (`Rust tests (dsm)`, `workspace-rest`, `Storage Node (Postgres)`, the gates, Lean). A red outside this list is a regression; a listed test that goes green without ERA's policy is a finding. - `dsm_sdk::bluetooth::offline_step_tests::an_online_send_waits_for_the_offline_step_in_flight` - `dsm_sdk::handlers::bilateral_finality_tests::a_send_before_the_previous_step_finalizes_is_gated_never_marked_for_resync` @@ -1191,16 +1191,43 @@ Owner decision (plan of 2026-09-25): delete the fake ERA policy and leave the ho Not in the manifest: `dsm_sdk::sdk::sofi_reads::tests::an_unestablished_genesis_candidate_is_not_read_as_unpublished`, red on `main` since #1013 for a reason of its own (it booted no device) and fixed by #1017. +### 6.33 ERA's canonical policy: its commitment derived from real bytes (SoFi Amendment S11, `feat/era-canonical-token-policy` #1022, 2026-09-26) + +**Finding.** ERA's policy commitment was raw BLAKE3 of empty input. Its only source, `dsm_sdk/src/policies/native.ctpa.bin`, was a 0-byte file, and the SDK's load-time assertion (`assert_builtins_sound`) hashed that empty file against the hash of an empty file, with raw rather than domain-separated BLAKE3. It committed to no policy, and no policy bytes exist for it. After §6.32 every ERA transfer and burn was refused for that reason. + +**Resolved** + +| Location | Finding | State | +|---|---|---| +| `specs/SoFi_Settlement_Specification.md` §47 | ERA had no policy the grammar could express: every blob named a creating device and 1..=16 signers, and ERA has neither. | Amendment S11 (owner): a network-anchored native policy names no creator and no signer set, and exactly one exists, ERA's. The text carries ERA's field values and its commitment as a check value. MR-SOFI-0334–0337 added. | +| `dsm/src/economic/token_policy.rs` | The grammar and its wrapper. | `Release::{AllAtCreation { .. }, Faucet}`; the parser branches on the rule byte. A device-created policy is byte-identical: its commitment was pinned before the change and holds after it. The `TokenPolicyV3` wrapper must be canonical, so one policy has one commitment. | +| `dsm/src/core/token/era_policy.rs` (new), `token_state_manager.rs` | ERA's identity was the hash of nothing. | ERA's 40-byte policy is in Core, and `era_policy_commit()` = `BLAKE3(DSM/policy ‖ 0x00 ‖ bytes)`, computed once. The literal is deleted. The one packer reproduces the bytes (SDK test), and the value equals the specification's check value. | +| `dsm/src/core/token/policy/mod.rs` · `policy_at`, `register_policy`; `economic/provenance.rs` · `verify_genesis_release` | ERA was absent from the enforcer. A registered ERA could have been evicted from the LRU cache, and it is in no registry. | ERA is pre-rooted: it is answered from Core's bytes before the cache and the resolver, and it is never registered. A creation naming ERA's commitment is refused from ERA's own policy: Invalid at once, not Incomplete. | +| `dsm_sdk/src/policy/builtins.rs`, `policies/native.ctpa.bin`, `policy_commits/native.commit32`; `handlers/token_routes.rs`, `sdk/economic_registers.rs`, `handlers/wallet_routes.rs` | The empty file, its commit and the assertion over them; adoption of a protocol asset by anchor; ERA's wallet facts hardcoded. | The native builtins are deleted, and `builtin_policy_commit("ERA")` reads Core. `tokens.addByAnchor` refuses protocol assets. `load_policy_bytes` and `anchored_policy_bytes` answer ERA from Core and never fetch or store it. Network-anchored lookalikes are neither adopted nor published. The wallet's ERA symbol, decimals, supply, permissions and anchor are ERA's policy's. | + +The §6.32 manifest is discharged: its 45 tests pass on this branch (release, Postgres; `dsm_sdk` lib 183/0 across the eleven manifest modules and the token, policy, wallet and faucet tests; `dsm` lib 73/0, integration 66/0), and none fails outside it. + +**Open** + +| Location | Hole | +|---|---| +| `dsm/src/economic/native_reserve.rs` · `ERA_RESERVE_GENESIS_SUPPLY`; `dsm_sdk/src/sdk/faucet_claim_flow.rs` | The reserve's genesis supply is still a constant of its own, equal to ERA's committed supply. A claim at exhaustion signs and freezes a release before any remaining-supply check, then reports a retryable network status. Follow-up: the supply comes from ERA's policy, `ReleaseRefusal::Exhausted` is checked before anything is signed, and the reserve release's parent commitment is checked (MR-SOFI-0337). | +| `dsm/src/sofi/validation.rs` · `market_legs_permitted`; `dsm/src/sofi/lineage.rs` · `token_is_a_market_leg` | ERA's legs are still exempted as a builtin. Its policy now exists, so they can be checked against it. Follow-up. | +| `dsm_sdk/src/handlers/wallet_routes.rs` · balance rows | Rows are keyed by ticker, so a created token whose ticker is "ERA" collides with protocol ERA in the display (the spec lets tokens share a ticker). Follow-up: key rows by commitment. | +| — | Join emission after beta changes ERA's release rule, and so its identity (§47). Out of scope this round. | + +Consequence (beta clean cut): ERA is re-keyed — every ERA balance key and the reserve id change. `R_0` is computed and never stored, so the fleet needs no reset. ERA held under the old commitment is left behind, so devices start from fresh installs. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 272 | 66 | 116 | 34 | 9 | 29 | 18 | -| SoFi (MR-SOFI) | 337 | 206 | 84 | 22 | 8 | 17 | 0 | +| SoFi (MR-SOFI) | 337 | 209 | 84 | 19 | 8 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 32 | 41 | 50 | 16 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **913** | **313** | **242** | **107** | **33** | **64** | **154** | +| **All** | **913** | **316** | **242** | **104** | **33** | **64** | **154** | ## 8 Per-requirement results @@ -1818,9 +1845,9 @@ Not in the manifest: `dsm_sdk::sdk::sofi_reads::tests::an_unestablished_genesis_ | MR-SOFI-0331 | Met | `dsm::sofi::validation::setup_valid`; `dsm::sofi::validation::Evidence` | `dsm::sofi::validation::tests::a_setup_naming_another_claim_than_the_accepted_one_is_invalid`; `dsm::sofi::validation::tests::a_setup_whose_accepted_claim_is_not_in_hand_is_missing` | Evidence carries the accepted claims lineage validation produced; a setup naming another claim is Invalid, and one whose accepted claim is not in hand is Missing. | | MR-SOFI-0332 | Met | `dsm::economic::token_policy::TokenPolicy`; `dsm::economic::provenance::verify_genesis_release` | `dsm::economic_provenance_semantics::a_genesis_release_funds_its_creators_whole_supply`; `dsm::economic_provenance_semantics::a_genesis_release_of_another_creators_policy_is_refused` | The policy blob carries creator_genesis and creator_device_id; a genesis release of another creator's policy is refused. | | MR-SOFI-0333 | Met | `dsm::economic::keys::token_creation_key`; `dsm::economic::write_set::build_write_set` | `dsm::economic_write_set::a_token_is_created_once_on_its_creators_lineage`; `dsm::sofi::lineage::tests::the_creation_record_is_an_economic_leaf_with_its_own_key` | Creation inserts the 0x0060 record under its own key from zero; a second creation of the same commit cannot build its write set. | -| MR-SOFI-0334 | Missing | — | — | Amendment S11 (2026-09-26): the network-anchored grammar, built in this PR. | -| MR-SOFI-0335 | Missing | — | — | Amendment S11 (2026-09-26): ERA's canonical policy in Core, built in this PR. | -| MR-SOFI-0336 | Missing | — | — | Amendment S11 (2026-09-26): confinement of the network-anchored shape to ERA, built in this PR. | +| MR-SOFI-0334 | Met | `dsm::economic::token_policy::parse_token_policy_blob`; `dsm::economic::token_policy::parse_token_policy`; `dsm_sdk::handlers::token_routes::build_policy_v3_bytes` | `dsm::economic::token_policy::tests::a_network_anchored_blob_names_no_creator_and_no_signer_set`; `dsm::economic::token_policy::tests::a_blob_whose_shape_does_not_match_its_release_rule_does_not_parse`; `dsm::economic::token_policy::tests::a_non_canonical_wrapper_does_not_parse`; `dsm_sdk::handlers::token_routes::tests::a_device_created_policy_keeps_its_layout_and_commitment` | The release rule decides the blob's shape; a device-created policy's bytes and commitment are unchanged (pinned before and after); one policy has one commitment (§6.33). | +| MR-SOFI-0335 | Met | `dsm::core::token::era_policy::era_policy_commit`; `dsm::core::token::era_policy::era_policy`; `dsm::core::token::policy::TokenPolicySystem::policy_at` | `dsm::core::token::era_policy::tests::eras_commitment_is_derived_from_its_bytes_and_is_the_specifications`; `dsm::core::token::era_policy::tests::eras_policy_states_what_the_specification_fixes`; `dsm::core::token::policy::tests::eras_policy_is_answered_from_cores_bytes_never_cached_or_resolved`; `dsm_sdk::handlers::token_routes::tests::the_one_packer_reproduces_eras_policy_bytes` | ERA's commitment is derived from its 40 bytes in Core and equals the specification's check value; the enforcer answers it from those bytes, never from the cache or a store (§6.33). | +| MR-SOFI-0336 | Met | `dsm::core::token::policy::TokenPolicySystem::register_policy`; `dsm_sdk::handlers::token_routes::adoptable_policy`; `dsm::economic::provenance::verify_genesis_release`; `dsm::economic::native_reserve::era_reserve_id` | `dsm::core::token::policy::tests::eras_own_bytes_are_never_registered`; `dsm_sdk::handlers::token_routes::tests::a_network_anchored_lookalike_is_neither_adopted_nor_published`; `dsm::economic_provenance_semantics::a_genesis_release_needs_the_all_at_creation_rule`; `dsm::economic_provenance_semantics::a_creation_naming_eras_commitment_is_refused_from_eras_own_policy` | No network-anchored policy is registered, adopted or published; none releases at creation; the only reserve is derived from ERA's commitment (§6.33). | | MR-SOFI-0337 | Missing | — | — | Amendment S11 (2026-09-26): the reserve's supply from ERA's policy and exhaustion refused before signing, built in this PR. | ### 8.3 dBTC native specification